From 47ad3a45da5306b9a83463423d2589bc31a7e931 Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 21 Dec 2025 18:45:26 +0300 Subject: [PATCH 1/4] fix: clamp max bitrate to 100mbps we should probably adjust this depending on resolution/fps, most 4k videos should be okay with this --- src/converter/speed.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/converter/speed.rs b/src/converter/speed.rs index d6b3a8f..aad41bb 100644 --- a/src/converter/speed.rs +++ b/src/converter/speed.rs @@ -138,7 +138,14 @@ impl ConversionSpeed { if *to != ConverterFormat::GIF { args.push("-b:v".to_string()); - let bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; + + let mut bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; + + let max_bitrate: u64 = 100_000_000; // 100 Mbps + if bitrate > max_bitrate { + bitrate = max_bitrate; + } + args.push(bitrate.to_string()); } From 97be208555348c26a815d172f0bb8a659b3e083b Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 21 Dec 2025 21:13:38 +0300 Subject: [PATCH 2/4] fix: better fps and resolution parsing fixes weird ts and m2ts files again --- src/converter/job.rs | 86 ++++++++++++++++++++++++++++-------------- src/converter/speed.rs | 2 +- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/converter/job.rs b/src/converter/job.rs index e4d99c7..6fb53ef 100644 --- a/src/converter/job.rs +++ b/src/converter/job.rs @@ -1,3 +1,4 @@ +use log::warn; use serde::{Deserialize, Serialize}; use tokio::process::Command; use uuid::Uuid; @@ -125,6 +126,8 @@ impl Job { return Ok(fps); } + let path = format!("input/{}.{}", self.id, self.from); + let output = Command::new("ffprobe") .args([ "-v", @@ -135,32 +138,45 @@ impl Job { "stream=r_frame_rate", "-of", "default=nokey=1:noprint_wrappers=1", - &format!("input/{}.{}", self.id, self.from), + &path, ]) .output() .await?; - // its gonna look like "30000/1001" - let fps = String::from_utf8(output.stdout) - .map_err(|e| anyhow::anyhow!("failed to parse fps: {}", e))?; - - let fps = fps.trim().split('/').collect::>(); - let fps = if fps.len() == 1 { - fps[0].parse::()? - } else if fps.len() == 2 { - let numerator = fps[0].parse::()?; - let denominator = fps[1].parse::()?; - (numerator as f64 / denominator as f64).round() as u32 - } else if fps.len() == 3 { - let numerator = fps[0].parse::()?; - let denominator = fps[2].parse::()?; - (numerator as f64 / denominator as f64).round() as u32 + let fps_out = String::from_utf8(output.stdout)?; + let fps_trim = fps_out + .lines() + .find(|l| !l.trim().is_empty()) + .map(|s| s.trim()) + .unwrap_or(""); + + if fps_trim.is_empty() { + warn!("ffprobe returned empty fps for {}", path); + let default = 30u32; + self.fps = Some(default); + return Ok(default); + } + + // parse fps which could be in the form of "30", "29.97", or "30000/1001" + let parsed = if let Some((n_str, d_str)) = fps_trim.split_once('/') { + match (n_str.trim().parse::(), d_str.trim().parse::()) { + (Ok(n), Ok(d)) if d != 0.0 => Some((n / d).round() as u32), + _ => None, + } } else { - return Err(anyhow::anyhow!("failed to parse fps")); + fps_trim.parse::().ok().map(|f| f.round() as u32) }; - self.fps = Some(fps); - Ok(fps) + let result = parsed.unwrap_or_else(|| { + warn!( + "failed to parse fps '{}' from ffprobe for {}", + fps_trim, path + ); + 30u32 + }); + + self.fps = Some(result); + Ok(result) } pub async fn bitrate_and_fps(&mut self) -> anyhow::Result<(u64, u32)> { @@ -186,15 +202,28 @@ impl Job { .output() .await?; - let res_str = String::from_utf8(output.stdout)?; - let mut parts = res_str.trim().split('x'); + let res_out = String::from_utf8(output.stdout)?; + let res_str = res_out + .lines() + .find(|l| !l.trim().is_empty()) + .map(|s| s.trim()) + .ok_or_else(|| { + anyhow::anyhow!("failed to get resolution from ffprobe output: {}", res_out) + })?; + let mut parts = res_str.split('x'); let width = parts .next() - .ok_or_else(|| anyhow::anyhow!("failed to get width"))? + .ok_or_else(|| { + anyhow::anyhow!("failed to get width from ffprobe output: '{}'", res_str) + })? + .trim() .parse::()?; let height = parts .next() - .ok_or_else(|| anyhow::anyhow!("failed to get height"))? + .ok_or_else(|| { + anyhow::anyhow!("failed to get height from ffprobe output: '{}'", res_str) + })? + .trim() .parse::()?; Ok((width, height)) @@ -218,13 +247,14 @@ impl Job { .output() .await?; - let pix_fmt = String::from_utf8(output.stdout)? + let pix_out = String::from_utf8(output.stdout)?; + let pix = pix_out .lines() - .next() - .ok_or_else(|| anyhow::anyhow!("failed to get pixel format"))? - .to_string(); + .find(|l| !l.trim().is_empty()) + .map(|s| s.trim().to_string()) + .ok_or_else(|| anyhow::anyhow!("failed to get pixel format from ffprobe output"))?; - Ok(pix_fmt) + Ok(pix) } pub async fn codecs(&self) -> anyhow::Result<(String, String)> { diff --git a/src/converter/speed.rs b/src/converter/speed.rs index aad41bb..356ddfc 100644 --- a/src/converter/speed.rs +++ b/src/converter/speed.rs @@ -141,7 +141,7 @@ impl ConversionSpeed { let mut bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; - let max_bitrate: u64 = 100_000_000; // 100 Mbps + let max_bitrate: u64 = 125_000_000; // 125 Mbps if bitrate > max_bitrate { bitrate = max_bitrate; } From 52aa2a0bd9dc82d5f3af6f7a972495352cddefb2 Mon Sep 17 00:00:00 2001 From: Maya Date: Mon, 22 Dec 2025 16:38:44 +0300 Subject: [PATCH 3/4] fix: nvenc fixes hopefully will be a bit more reliable with 4k videos, support 8k, and have these only apply to NVIDIA gpus and not every one (oops) these things are mostly for the official instance where we have a rtx 4000 ada --- src/converter/format.rs | 119 ++++++++++++++++++++++++++-------------- src/converter/mod.rs | 17 ++++++ src/converter/speed.rs | 4 +- 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/src/converter/format.rs b/src/converter/format.rs index bcf854f..d499c92 100644 --- a/src/converter/format.rs +++ b/src/converter/format.rs @@ -72,6 +72,65 @@ impl Conversion { default.to_string() } + // workarounds for NVENC for "weirder" videos + async fn nvenc_args( + &self, + gpu: &ConverterGPU, + job: &super::job::Job, + fps: u32, + ) -> anyhow::Result> { + let (width, height) = job.resolution().await?; + let is_4k = width == 3840 || height == 2160; + let is_above_4k = width > 3840 || height > 2160; + let pix_fmt = job.pix_fmt().await?; + + // choose codec + // prefer original codec, force h265 if original is h264 and (10bit or 4k) + let codecs = job.codecs().await?; + let has_h265 = codecs.0.to_lowercase().contains("hevc"); + let has_h264 = codecs.0.to_lowercase().contains("h264"); + let is_10bit = pix_fmt.contains("10le") || pix_fmt.contains("10be"); + let (codec_order, default) = if has_h265 || (has_h264 && (is_10bit || is_4k || is_above_4k)) + { + (&["hevc"][..], "libx265") + } else if has_h264 { + (&["h264"][..], "libx264") + } else { + (&["h264"][..], "libx264") + }; + + // do we still really need to check for codec support? this function is only called if gpu is nvidia + let encoder = self + .accelerated_or_default_codec(gpu, codec_order, default) + .await; + + let mut args = vec!["-c:v".to_string(), encoder.clone()]; + + // convert to 8 bit if 10 bit on h264_nvenc + if is_10bit && encoder == "h264_nvenc" { + args.extend(["-pix_fmt".to_string(), "yuv420p".to_string()]); + } + + if is_above_4k { + // older gpus might not like 6.2 + args.extend(["-level:v".to_string(), "6.2".to_string()]); + if fps > 60 { + args.extend(["-r".to_string(), "60".to_string()]); + } + } else if is_4k { + args.extend(["-level:v".to_string(), "5.2".to_string()]); + if fps > 120 { + args.extend(["-r".to_string(), "120".to_string()]); + } + } + + // scale to 160:-1 if width is less than 160 + if width < 160 { + args.extend(["-vf".to_string(), "scale=160:-1".to_string()]); + } + Ok(args) + } + pub async fn to_args( &self, speed: &ConversionSpeed, @@ -93,52 +152,30 @@ impl Conversion { | ConverterFormat::ThreeGP | ConverterFormat::ThreeG2 | ConverterFormat::H264 => { - let encoder = self - .accelerated_or_default_codec(gpu, &["h264"], "libx264") - .await; - - let mut args = vec!["-c:v".to_string(), encoder.clone()]; - - let (width, height) = job.resolution().await?; - let is_4k = width >= 3840 || height >= 2160; - let pix_fmt = job.pix_fmt().await?; - - // convert to 8bit if 10bit (h264_nvenc does not support 10bit) - // could probably use h265 instead? - let is_10bit = pix_fmt.contains("10le") || pix_fmt.contains("10be"); - if is_10bit { - args.extend(["-pix_fmt".to_string(), "yuv420p".to_string()]); + if matches!(gpu, ConverterGPU::NVIDIA) { + self.nvenc_args(gpu, job, fps).await? + } else { + let encoder = self + .accelerated_or_default_codec(gpu, &["h264"], "libx264") + .await; + vec![ + "-c:v".to_string(), + encoder, + "-c:a".to_string(), + "aac".to_string(), + "-strict".to_string(), + "experimental".to_string(), + ] } - - if is_4k { - args.extend(["-level:v".to_string(), "5.2".to_string()]); - if fps > 120 { - args.extend(["-r".to_string(), "120".to_string()]); - } - } - - // scale to 160:-1 if width is less than 160 - if width < 160 { - args.extend(["-vf".to_string(), "scale=160:-1".to_string()]); - } - - args.extend([ - "-c:a".to_string(), - "aac".to_string(), - "-strict".to_string(), - "experimental".to_string(), - ]); - - args } ConverterFormat::GIF => { vec![ - "-filter_complex".to_string(), - format!( - "fps={},scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer", - fps.min(24) - ) + "-filter_complex".to_string(), + format!( + "fps={},scale=800:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer", + fps.min(24) + ) ] } diff --git a/src/converter/mod.rs b/src/converter/mod.rs index f5b244f..5905e2e 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -80,6 +80,23 @@ impl Converter { .map(|s| s.to_string()) .collect::>(); + // if video is more than 4k on nvenc, remove -hwaccel cuda to avoid "Video width 7680 not within range from 48 to 4096" + // error from the h264_nvenc *decoder*, guh + let command = if matches!(gpu, gpu::ConverterGPU::NVIDIA) { + let (width, height) = job.resolution().await?; + if width > 3840 || height > 2160 { + command + .iter() + .filter(|s| *s != "-hwaccel" && *s != "cuda") + .cloned() + .collect() + } else { + command + } + } else { + command + }; + info!("running 'ffmpeg {}'", command.join(" ")); let mut process = Command::new("ffmpeg") diff --git a/src/converter/speed.rs b/src/converter/speed.rs index 356ddfc..7d9c306 100644 --- a/src/converter/speed.rs +++ b/src/converter/speed.rs @@ -141,7 +141,9 @@ impl ConversionSpeed { let mut bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; - let max_bitrate: u64 = 125_000_000; // 125 Mbps + // should probably depend on the codec being used, maybe resolution as well + // basically same as bitrate() in job.rs + let max_bitrate: u64 = 100_000_000; // 100 Mbps if bitrate > max_bitrate { bitrate = max_bitrate; } From b00233d97a3390a515d59415ba2a463f95ff5c9f Mon Sep 17 00:00:00 2001 From: Maya Date: Mon, 22 Dec 2025 18:22:24 +0300 Subject: [PATCH 4/4] feat: conversion fixes, cleanup use the default bitrate for videos rather than using a multiplier, scale default bitrate depending on resolution, clean up of unnecessary code --- src/converter/format.rs | 21 +++----- src/converter/job.rs | 27 ++++++----- src/converter/mod.rs | 3 +- src/converter/speed.rs | 10 ---- src/job/compression.rs | 20 -------- src/job/conversion.rs | 104 ---------------------------------------- src/job/mod.rs | 35 -------------- 7 files changed, 25 insertions(+), 195 deletions(-) delete mode 100644 src/job/compression.rs delete mode 100644 src/job/conversion.rs delete mode 100644 src/job/mod.rs diff --git a/src/converter/format.rs b/src/converter/format.rs index d499c92..316639d 100644 --- a/src/converter/format.rs +++ b/src/converter/format.rs @@ -76,10 +76,11 @@ impl Conversion { async fn nvenc_args( &self, gpu: &ConverterGPU, - job: &super::job::Job, + resolution: (u32, u32), fps: u32, + job: &super::job::Job, ) -> anyhow::Result> { - let (width, height) = job.resolution().await?; + let (width, height) = resolution; let is_4k = width == 3840 || height == 2160; let is_above_4k = width > 3840 || height > 2160; let pix_fmt = job.pix_fmt().await?; @@ -111,17 +112,8 @@ impl Conversion { args.extend(["-pix_fmt".to_string(), "yuv420p".to_string()]); } - if is_above_4k { - // older gpus might not like 6.2 - args.extend(["-level:v".to_string(), "6.2".to_string()]); - if fps > 60 { - args.extend(["-r".to_string(), "60".to_string()]); - } - } else if is_4k { - args.extend(["-level:v".to_string(), "5.2".to_string()]); - if fps > 120 { - args.extend(["-r".to_string(), "120".to_string()]); - } + if fps > 240 { + args.extend(["-r".to_string(), "240".to_string()]); } // scale to 160:-1 if width is less than 160 @@ -135,6 +127,7 @@ impl Conversion { &self, speed: &ConversionSpeed, gpu: &ConverterGPU, + resolution: (u32, u32), bitrate: u64, fps: u32, job: &super::job::Job, @@ -153,7 +146,7 @@ impl Conversion { | ConverterFormat::ThreeG2 | ConverterFormat::H264 => { if matches!(gpu, ConverterGPU::NVIDIA) { - self.nvenc_args(gpu, job, fps).await? + self.nvenc_args(gpu, resolution, fps, job).await? } else { let encoder = self .accelerated_or_default_codec(gpu, &["h264"], "libx264") diff --git a/src/converter/job.rs b/src/converter/job.rs index 6fb53ef..041cbf2 100644 --- a/src/converter/job.rs +++ b/src/converter/job.rs @@ -3,9 +3,6 @@ use serde::{Deserialize, Serialize}; use tokio::process::Command; use uuid::Uuid; -const DEFAULT_BITRATE: u64 = 4 * 1_000_000; -const BITRATE_MULTIPLIER: f64 = 2.5; - #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Job { @@ -52,9 +49,7 @@ impl Job { self.state == JobState::Processing } - // TODO: scale based on resolution pub async fn bitrate(&mut self) -> anyhow::Result { - // Ok(DEFAULT_BITRATE) if let Some(bitrate) = self.bitrate { return Ok(bitrate); } @@ -74,14 +69,24 @@ impl Job { .output() .await?; - let bitrate = String::from_utf8(output.stdout)?; - let bitrate = match bitrate.trim().parse::() { - Ok(bitrate) => bitrate, - Err(_) => DEFAULT_BITRATE, + // use detected bitrate + let bitrate = String::from_utf8(output.stdout)?.trim().parse::().ok(); + if let Some(bitrate_value) = bitrate { + return Ok(bitrate_value); + } + + // else check resolution and use default bitrate (based on resolution) + let (width, height) = self.resolution().await?; + let default_bitrate = match (width, height) { + (w, h) if w >= 3840 || h >= 2160 => 30_000_000, // 4K - 30 Mbps + (w, h) if w >= 2560 || h >= 1440 => 14_000_000, // 2K - 14 Mbps + (w, h) if w >= 1920 || h >= 1080 => 7_000_000, // 1080p - 7 Mbps + (w, h) if w >= 1280 || h >= 720 => 4_000_000, // 720p - 4 Mbps + _ => 1_500_000, // SD - 1.5 Mbps }; - self.bitrate = Some(bitrate); - Ok(((bitrate as f64) * BITRATE_MULTIPLIER) as u64) + self.bitrate = Some(default_bitrate); + Ok(default_bitrate) } pub async fn total_frames(&mut self) -> anyhow::Result { diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 5905e2e..4589ea1 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -51,9 +51,10 @@ impl Converter { // let fps = job.fps().await?; // the above but we run in parallel let (bitrate, fps) = job.bitrate_and_fps().await?; + let (width, height) = job.resolution().await?; let args = self .conversion - .to_args(&self.speed, gpu, bitrate, fps, job) + .to_args(&self.speed, gpu, (width, height), bitrate, fps, job) .await?; let args = args.iter().map(|s| s.as_str()).collect::>(); let args = args.as_slice(); diff --git a/src/converter/speed.rs b/src/converter/speed.rs index 7d9c306..8bf4da7 100644 --- a/src/converter/speed.rs +++ b/src/converter/speed.rs @@ -138,16 +138,6 @@ impl ConversionSpeed { if *to != ConverterFormat::GIF { args.push("-b:v".to_string()); - - let mut bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; - - // should probably depend on the codec being used, maybe resolution as well - // basically same as bitrate() in job.rs - let max_bitrate: u64 = 100_000_000; // 100 Mbps - if bitrate > max_bitrate { - bitrate = max_bitrate; - } - args.push(bitrate.to_string()); } diff --git a/src/job/compression.rs b/src/job/compression.rs deleted file mode 100644 index 3b67d6f..0000000 --- a/src/job/compression.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::JobTrait; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompressionJob { - pub id: Uuid, - pub auth: String, -} - -impl JobTrait for CompressionJob { - fn id(&self) -> Uuid { - self.id - } - - fn auth(&self) -> &str { - &self.auth - } -} diff --git a/src/job/conversion.rs b/src/job/conversion.rs deleted file mode 100644 index 823f5ba..0000000 --- a/src/job/conversion.rs +++ /dev/null @@ -1,104 +0,0 @@ -use super::JobTrait; -use serde::{Deserialize, Serialize}; -use tokio::process::Command; -use uuid::Uuid; - -const DEFAULT_BITRATE: u64 = 4 * 1_000_000; -const BITRATE_MULTIPLIER: f64 = 2.5; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConversionJob { - pub id: Uuid, - pub auth: String, - pub from: String, - pub to: Option, - pub completed: bool, - total_frames: Option, - bitrate: Option, -} - -impl ConversionJob { - pub fn new(auth_token: String, from: String) -> Self { - Self { - id: Uuid::new_v4(), - auth: auth_token, - from, - to: None, - completed: false, - total_frames: None, - bitrate: None, - } - } - // TODO: scale based on resolution - pub async fn bitrate(&mut self) -> anyhow::Result { - // Ok(DEFAULT_BITRATE) - if let Some(bitrate) = self.bitrate { - return Ok(bitrate); - } - let output = Command::new("ffprobe") - .args([ - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=bit_rate", - "-of", - "default=nokey=1:noprint_wrappers=1", - &format!("input/{}.{}", self.id, self.from), - ]) - .output() - .await?; - let bitrate = String::from_utf8(output.stdout)?; - let bitrate = match bitrate.trim().parse::() { - Ok(bitrate) => bitrate, - Err(_) => DEFAULT_BITRATE, - }; - self.bitrate = Some(bitrate); - Ok(((bitrate as f64) * BITRATE_MULTIPLIER) as u64) - } - pub async fn total_frames(&mut self) -> anyhow::Result { - if let Some(total_frames) = self.total_frames { - return Ok(total_frames); - } - let output = Command::new("ffprobe") - .args([ - "-v", - "error", - "-count_frames", - "-select_streams", - "v:0", - "-show_entries", - "stream=nb_read_frames", - "-of", - "default=nokey=1:noprint_wrappers=1", - &format!("input/{}.{}", self.id, self.from), - ]) - .output() - .await?; - let total_frames = String::from_utf8(output.stdout)?; - let total_frames = total_frames.trim().parse::()?; - self.total_frames = Some(total_frames); - Ok(total_frames) - } -} - -impl JobTrait for ConversionJob { - fn id(&self) -> Uuid { - self.id - } - - fn auth(&self) -> &str { - &self.auth - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "camelCase")] -pub enum ProgressUpdate { - #[serde(rename = "frame", rename_all = "camelCase")] - Frame(u64), - #[serde(rename = "fps", rename_all = "camelCase")] - FPS(f64), -} diff --git a/src/job/mod.rs b/src/job/mod.rs deleted file mode 100644 index ff88961..0000000 --- a/src/job/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -pub mod compression; -pub mod conversion; - -use compression::CompressionJob; -use conversion::ConversionJob; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -pub trait JobTrait { - fn id(&self) -> Uuid; - fn auth(&self) -> &str; -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum Job { - Conversion(ConversionJob), - Compression(CompressionJob), -} - -impl JobTrait for Job { - fn id(&self) -> Uuid { - match self { - Job::Conversion(job) => job.id(), - Job::Compression(job) => job.id(), - } - } - - fn auth(&self) -> &str { - match self { - Job::Conversion(job) => job.auth(), - Job::Compression(job) => job.auth(), - } - } -}