From 743981a16af52911d8ae22566aaee28ac10677fe Mon Sep 17 00:00:00 2001 From: Bjay kamwa Watanabe Date: Wed, 27 May 2026 21:30:43 +0200 Subject: [PATCH 1/3] Fix half-rate MXF video downscale --- src/media/transcode.rs | 191 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 174 insertions(+), 17 deletions(-) diff --git a/src/media/transcode.rs b/src/media/transcode.rs index 7ed9997..7a058b5 100644 --- a/src/media/transcode.rs +++ b/src/media/transcode.rs @@ -8,6 +8,11 @@ use std::process::Command; use crate::media::metadata::get_ffprobe_json; use crate::media::video_quality::VideoQuality; +#[derive(Clone, Debug)] +struct VideoSpeedCorrection { + frame_rate: String, +} + /// Returns true if the first video stream is interlaced (field_order is tb, bt, tt, or bb). fn is_interlaced(path: &PathBuf) -> Result { let probe = match get_ffprobe_json(path)? { @@ -89,6 +94,104 @@ fn probe_video_frames(path: &PathBuf) -> Result { .map_err(|e| format!("failed to parse ffprobe frame JSON output: {}", e)) } +fn parse_rational(value: Option<&str>) -> Option { + let value = value?; + let (num, den) = value.split_once('/')?; + let num: f64 = num.parse().ok()?; + let den: f64 = den.parse().ok()?; + if den == 0.0 { + return None; + } + Some(num / den) +} + +fn first_video_stream(probe: &Value) -> Option<&Value> { + probe + .get("streams") + .and_then(|s| s.as_array())? + .iter() + .find(|s| s.get("codec_type").and_then(|c| c.as_str()) == Some("video")) +} + +fn detect_mxf_half_rate_video(probe: &Value) -> Option { + let format_name = probe + .get("format") + .and_then(|f| f.get("format_name")) + .and_then(|f| f.as_str()) + .unwrap_or(""); + if !format_name.split(',').any(|name| name == "mxf") { + return None; + } + + let video_stream = first_video_stream(probe)?; + if video_stream.get("codec_name").and_then(|c| c.as_str()) != Some("mpeg2video") { + return None; + } + + let r_frame_rate = video_stream.get("r_frame_rate").and_then(|v| v.as_str()); + let avg_frame_rate = video_stream.get("avg_frame_rate").and_then(|v| v.as_str()); + let r_rate = parse_rational(r_frame_rate)?; + let avg_rate = parse_rational(avg_frame_rate)?; + + if avg_rate <= 0.0 || (r_rate * 2.0 - avg_rate).abs() > 0.001 { + return None; + } + + avg_frame_rate.map(|frame_rate| VideoSpeedCorrection { + frame_rate: frame_rate.to_string(), + }) +} + +fn detect_video_speed_correction(path: &PathBuf) -> Result, String> { + let Some(probe) = get_ffprobe_json(path)? else { + return Ok(None); + }; + Ok(detect_mxf_half_rate_video(&probe)) +} + +fn compute_video_essence_output_path(input: &PathBuf, out_base: &PathBuf) -> PathBuf { + out_base.join(format!( + "{}_video_essence.m2v", + input.file_stem().unwrap().to_string_lossy() + )) +} + +fn extract_video_essence(input: &PathBuf, output: &PathBuf) -> Result<(), String> { + if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(output)) { + if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) { + if out_time >= in_time { + return Ok(()); + } + } + } + + let status = Command::new("ffmpeg") + .args([ + "-y", + "-hide_banner", + "-i", + &input.to_string_lossy(), + "-map", + "0:v:0", + "-c:v", + "copy", + "-an", + &output.to_string_lossy(), + ]) + .status() + .map_err(|e| format!("failed to run ffmpeg video essence extraction: {}", e))?; + + if !status.success() { + return Err(format!( + "ffmpeg failed extracting video essence: {} -> {}", + input.display(), + output.display() + )); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -123,6 +226,38 @@ mod tests { assert!(!probe_has_interlaced_video(&probe)); } + + #[test] + fn detects_mxf_mpeg2_half_rate_video() { + let probe = json!({ + "format": {"format_name": "mxf"}, + "streams": [{ + "codec_type": "video", + "codec_name": "mpeg2video", + "r_frame_rate": "25/2", + "avg_frame_rate": "25/1" + }] + }); + + let correction = detect_mxf_half_rate_video(&probe).unwrap(); + + assert_eq!(correction.frame_rate, "25/1"); + } + + #[test] + fn ignores_avid_style_half_avg_rate_video() { + let probe = json!({ + "format": {"format_name": "mxf"}, + "streams": [{ + "codec_type": "video", + "codec_name": "h264", + "r_frame_rate": "25/1", + "avg_frame_rate": "25/2" + }] + }); + + assert!(detect_mxf_half_rate_video(&probe).is_none()); + } } /// Parse ffmpeg time string e.g. "00:01:23.45" into seconds. @@ -223,22 +358,25 @@ pub fn create_rendition( ) -> Result { let temp_base = get_temp_rendition_dir()?; let output = compute_rendition_output_path(input, &definition, &temp_base); - - if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(&output)) { - if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) { - if out_time >= in_time { - let msg = format!( - "Reusing existing rendition for {} at {} ({})", - input.display(), - output.display(), - definition.to_name() - ); - if let Some(f) = info_cb { - f(&msg); - } else if progress_cb.is_none() { - crate::output::info(msg); + let speed_correction = detect_video_speed_correction(input).unwrap_or(None); + + if speed_correction.is_none() { + if let (Ok(in_md), Ok(out_md)) = (std::fs::metadata(input), std::fs::metadata(&output)) { + if let (Ok(in_time), Ok(out_time)) = (in_md.modified(), out_md.modified()) { + if out_time >= in_time { + let msg = format!( + "Reusing existing rendition for {} at {} ({})", + input.display(), + output.display(), + definition.to_name() + ); + if let Some(f) = info_cb { + f(&msg); + } else if progress_cb.is_none() { + crate::output::info(msg); + } + return Ok(output); } - return Ok(output); } } } @@ -250,9 +388,26 @@ pub fn create_rendition( } } + let source_input = if let Some(correction) = &speed_correction { + if let Some(f) = info_cb { + f("Detected MXF half-rate video timing; extracting video essence before downscale"); + } + let essence_output = compute_video_essence_output_path(input, &temp_base); + extract_video_essence(input, &essence_output)?; + if let Some(f) = info_cb { + f(&format!( + "Using extracted video essence at {} fps", + correction.frame_rate + )); + } + essence_output + } else { + input.clone() + }; + let mut cmd = FfmpegCommand::new(); cmd.overwrite() - .input(input.to_string_lossy()) + .input(source_input.to_string_lossy()) .codec_video("libx264") .args(["-pix_fmt", "yuv420p"]); @@ -273,6 +428,9 @@ pub fn create_rendition( if let Some(crf) = definition.crf { cmd.crf(crf as u32); } + if let Some(correction) = &speed_correction { + cmd.args(["-r", &correction.frame_rate]); + } cmd.args(["-movflags", "+faststart"]).codec_audio("aac"); if let Some(abr_kbps) = definition.audio_bitrate { @@ -584,4 +742,3 @@ pub fn has_video_streams(path: &PathBuf) -> Result { Ok(output_str == "video") } - From ea1adba8f7961bd46963c2909572174ea531cf0e Mon Sep 17 00:00:00 2001 From: Bjay kamwa Watanabe Date: Wed, 27 May 2026 21:41:48 +0200 Subject: [PATCH 2/3] Update OpenAPI spec for upload preprocess --- src/commands/upload/dry_run.rs | 8 +------- src/commands/upload/main.rs | 7 +------ src/tellers_api/openapi.tellers_public_api.yaml | 12 +----------- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/commands/upload/dry_run.rs b/src/commands/upload/dry_run.rs index d1d7b9f..5ae825e 100644 --- a/src/commands/upload/dry_run.rs +++ b/src/commands/upload/dry_run.rs @@ -17,7 +17,7 @@ pub fn run_dry_run( in_app_path: &Option, auth_bearer: &Option, force_upload: bool, - disable_description_generation: bool, + _disable_description_generation: bool, local_encoding: bool, ) -> Result<(), String> { let bearer_env = auth_bearer @@ -255,14 +255,8 @@ pub fn run_dry_run( .unwrap_or_default() .to_string_lossy() .to_string(); - let generate_time_based_media_description = !disable_description_generation; output::plain(format!(" file: {}", file_name)); output::plain(" cutter_sensitivity: 0.2"); - output::plain(format!( - " generate_time_based_media_description: {}", - generate_time_based_media_description - )); - output::plain(" override_entity_ids: omitted"); if local_encoding { output::plain(" generate_proxy: []"); } else { diff --git a/src/commands/upload/main.rs b/src/commands/upload/main.rs index 2ab3858..a304782 100644 --- a/src/commands/upload/main.rs +++ b/src/commands/upload/main.rs @@ -824,7 +824,6 @@ fn run_two_queue_pipeline( let in_app_path = args.in_app_path.clone(); let user_id = user_id.to_string(); let upload_request_id = upload_request_id.to_string(); - let disable_description_generation = args.disable_description_generation; let block_result = rt.block_on(async move { // Start render loop inside runtime so tokio::spawn has a current runtime let render_handle = progress.start_render_loop(progress_handle.clone()); @@ -938,8 +937,6 @@ fn run_two_queue_pipeline( None::, ); preproc_req.cutter_sensitivity = Some(0.2); - preproc_req.generate_time_based_media_description = - Some(!disable_description_generation); preproc_req.generate_proxy = Some(vec![]); let preproc_tasks = api::process_assets_users_assets_preprocess_post( &cfg, @@ -1168,7 +1165,7 @@ async fn upload_with_per_file_presigned( cfg: &Configuration, api_key: &str, bearer_opt: Option<&str>, - disable_description_generation: bool, + _disable_description_generation: bool, ) -> Result, String> { let http = Arc::new( reqwest::Client::builder() @@ -1271,8 +1268,6 @@ async fn upload_with_per_file_presigned( None::, ); preproc_req.cutter_sensitivity = Some(0.2); - preproc_req.generate_time_based_media_description = - Some(!disable_description_generation); if let Err(e) = api::process_assets_users_assets_preprocess_post( &cfg_clone, preproc_req, diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index 8c6a8a5..e243a38 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -2946,6 +2946,7 @@ components: - image - placeholder - group_clip + - master_clip - aaf - folder - project @@ -3069,17 +3070,6 @@ components: type: number title: Cutter Sensitivity default: 0.5 - generate_time_based_media_description: - type: boolean - title: Generate Time Based Media Description - default: false - override_entity_ids: - anyOf: - - items: - type: string - type: array - - type: 'null' - title: Override Entity Ids generate_proxy: items: type: string From 2afa2b794a2a6ea397f6887960026bef46dc8931 Mon Sep 17 00:00:00 2001 From: Bjay kamwa Watanabe Date: Wed, 27 May 2026 21:51:58 +0200 Subject: [PATCH 3/3] Suppress known ffmpeg DTS warning --- src/media/transcode.rs | 66 +++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/media/transcode.rs b/src/media/transcode.rs index 7a058b5..423fad1 100644 --- a/src/media/transcode.rs +++ b/src/media/transcode.rs @@ -165,7 +165,7 @@ fn extract_video_essence(input: &PathBuf, output: &PathBuf) -> Result<(), String } } - let status = Command::new("ffmpeg") + let command_output = Command::new("ffmpeg") .args([ "-y", "-hide_banner", @@ -178,20 +178,46 @@ fn extract_video_essence(input: &PathBuf, output: &PathBuf) -> Result<(), String "-an", &output.to_string_lossy(), ]) - .status() + .output() .map_err(|e| format!("failed to run ffmpeg video essence extraction: {}", e))?; - if !status.success() { + if !command_output.status.success() { + let stderr = filtered_ffmpeg_stderr(&command_output.stderr); + let log_suffix = if stderr.is_empty() { + String::new() + } else { + format!("\nffmpeg log:\n{}", stderr) + }; return Err(format!( - "ffmpeg failed extracting video essence: {} -> {}", + "ffmpeg failed extracting video essence: {} -> {}{}", input.display(), - output.display() + output.display(), + log_suffix )); } Ok(()) } +fn is_ignorable_ffmpeg_log(line: &str) -> bool { + line.contains("mpeg2video @") + && line.contains("Application provided invalid, non monotonically increasing dts to muxer") +} + +fn filtered_ffmpeg_stderr(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .filter(|line| !is_ignorable_ffmpeg_log(line)) + .collect::>() + .join("\n") +} + +fn push_ffmpeg_log_line(lines: &mut Vec, line: &str) { + if !is_ignorable_ffmpeg_log(line) { + lines.push(line.to_string()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +284,27 @@ mod tests { assert!(detect_mxf_half_rate_video(&probe).is_none()); } + + #[test] + fn filters_known_mpeg2_non_monotonic_dts_warning() { + let stderr = b"[mpeg2video @ 0x7fd4d1f04180] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 1708 >= 1681\nreal failure\n"; + + let filtered = filtered_ffmpeg_stderr(stderr); + + assert_eq!(filtered, "real failure"); + } + + #[test] + fn keeps_unrelated_ffmpeg_errors() { + let stderr = b"[aac @ 0x123] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 2 >= 1\n"; + + let filtered = filtered_ffmpeg_stderr(stderr); + + assert_eq!( + filtered, + "[aac @ 0x123] Application provided invalid, non monotonically increasing dts to muxer in stream 1: 2 >= 1" + ); + } } /// Parse ffmpeg time string e.g. "00:01:23.45" into seconds. @@ -468,10 +515,10 @@ pub fn create_rendition( } } FfmpegEvent::Error(e) => { - stderr_lines.push(e.clone()); + push_ffmpeg_log_line(&mut stderr_lines, e); } FfmpegEvent::Log(LogLevel::Error, msg) | FfmpegEvent::Log(LogLevel::Fatal, msg) => { - stderr_lines.push(msg.clone()); + push_ffmpeg_log_line(&mut stderr_lines, msg); } _ => {} } @@ -609,10 +656,10 @@ fn run_ffmpeg_with_progress( } } FfmpegEvent::Error(e) => { - stderr_lines.push(e.clone()); + push_ffmpeg_log_line(&mut stderr_lines, e); } FfmpegEvent::Log(LogLevel::Error, msg) | FfmpegEvent::Log(LogLevel::Fatal, msg) => { - stderr_lines.push(msg.clone()); + push_ffmpeg_log_line(&mut stderr_lines, msg); } _ => {} } @@ -741,4 +788,3 @@ pub fn has_video_streams(path: &PathBuf) -> Result { let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); Ok(output_str == "video") } -