diff --git a/src/converter/format.rs b/src/converter/format.rs index bcf854f..316639d 100644 --- a/src/converter/format.rs +++ b/src/converter/format.rs @@ -72,10 +72,62 @@ impl Conversion { default.to_string() } + // workarounds for NVENC for "weirder" videos + async fn nvenc_args( + &self, + gpu: &ConverterGPU, + resolution: (u32, u32), + fps: u32, + job: &super::job::Job, + ) -> anyhow::Result> { + 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?; + + // 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 fps > 240 { + args.extend(["-r".to_string(), "240".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, gpu: &ConverterGPU, + resolution: (u32, u32), bitrate: u64, fps: u32, job: &super::job::Job, @@ -93,52 +145,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, resolution, fps, job).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/job.rs b/src/converter/job.rs index e4d99c7..041cbf2 100644 --- a/src/converter/job.rs +++ b/src/converter/job.rs @@ -1,10 +1,8 @@ +use log::warn; 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 { @@ -51,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); } @@ -73,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 { @@ -125,6 +131,8 @@ impl Job { return Ok(fps); } + let path = format!("input/{}.{}", self.id, self.from); + let output = Command::new("ffprobe") .args([ "-v", @@ -135,32 +143,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 +207,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 +252,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/mod.rs b/src/converter/mod.rs index f5b244f..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(); @@ -80,6 +81,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 d6b3a8f..8bf4da7 100644 --- a/src/converter/speed.rs +++ b/src/converter/speed.rs @@ -138,7 +138,6 @@ impl ConversionSpeed { if *to != ConverterFormat::GIF { args.push("-b:v".to_string()); - let bitrate = (bitrate as f64 * self.to_bitrate_mul()) as u64; 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(), - } - } -}