From c24ab64d3a1ebb337473089dae5349a50a55528e Mon Sep 17 00:00:00 2001 From: Bjay kamwa Watanabe Date: Tue, 28 Jul 2026 14:29:57 +0200 Subject: [PATCH] Merge discrete mono audio tracks into the rendition instead of dropping them create_rendition built its ffmpeg command with .codec_audio("aac") and no -map arguments, relying on ffmpeg's default stream selection, which writes exactly one audio stream to the output. Avid/broadcast MXF masters conventionally carry the stereo program as two discrete mono tracks (Track 3 / Track 4), so half the program audio was silently lost in the rendition. Reproduced on a 1080p25 DNxHD MXF with two pcm_s24le mono streams: the rendition came out with a single mono AAC stream. With the fix it is stereo AAC, and a DFT of the split channels confirms track 3 lands left and track 4 right. build_mono_merge_filter inspects the ffprobe streams and returns a filter_complex graph only when there is more than one audio stream and every one of them is mono: -map 0:v:0 -filter_complex "[0:a:0][0:a:1]amerge=inputs=2, aformat=channel_layouts=stereo[aout]" -map [aout] A complex filtergraph disables automatic output stream selection, so the video stream is mapped explicitly too. aformat is used rather than -ac 2, which is unreliable on a filtergraph output and would not handle more than two tracks. Merging is restricted to the all-mono case. A master carrying a stereo mix plus an alternate-language stereo pair would otherwise have two different programs blended together; ffmpeg's default selection already picks the stream with the most channels there, which is the correct one. Single-stream sources keep the existing default behaviour. The speed-corrected (half-rate MXF) path feeds extract_video_essence output, written with -an, so there is no audio to merge there and the probe is skipped. Mirrors the tellers-backend fix in video_processing_worker downscale_video. Co-Authored-By: Claude Opus 4.8 --- src/media/transcode.rs | 183 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/src/media/transcode.rs b/src/media/transcode.rs index 423fad1..4a135e2 100644 --- a/src/media/transcode.rs +++ b/src/media/transcode.rs @@ -149,6 +149,49 @@ fn detect_video_speed_correction(path: &PathBuf) -> Result Option { + let streams = probe.get("streams").and_then(|s| s.as_array())?; + let audio_stream_count = streams + .iter() + .filter(|s| s.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) + .count(); + if audio_stream_count < 2 { + return None; + } + let all_mono = streams + .iter() + .filter(|s| s.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) + .all(|s| s.get("channels").and_then(|c| c.as_u64()) == Some(1)); + if !all_mono { + return None; + } + + let count = audio_stream_count.min(MAX_MERGED_AUDIO_STREAMS); + let inputs: String = (0..count).map(|index| format!("[0:a:{}]", index)).collect(); + // aformat rather than -ac 2: -ac on a filtergraph output is unreliable, and + // this also downmixes correctly when there are more than two mono tracks. + Some(format!( + "{}amerge=inputs={},aformat=channel_layouts=stereo[aout]", + inputs, count + )) +} + fn compute_video_essence_output_path(input: &PathBuf, out_base: &PathBuf) -> PathBuf { out_base.join(format!( "{}_video_essence.m2v", @@ -285,6 +328,115 @@ mod tests { assert!(detect_mxf_half_rate_video(&probe).is_none()); } + fn mono_audio(codec_name: &str) -> Value { + json!({"codec_type": "audio", "codec_name": codec_name, "channels": 1}) + } + + fn stereo_audio() -> Value { + json!({"codec_type": "audio", "codec_name": "aac", "channels": 2}) + } + + #[test] + fn merges_discrete_mono_tracks_to_stereo() { + // Avid/broadcast MXF master: the stereo program lives on two discrete + // mono tracks. Default stream selection would keep only the first. + let probe = json!({ + "streams": [ + {"codec_type": "video", "codec_name": "dnxhd"}, + mono_audio("pcm_s24le"), + mono_audio("pcm_s24le"), + ] + }); + + assert_eq!( + build_mono_merge_filter(&probe).unwrap(), + "[0:a:0][0:a:1]amerge=inputs=2,aformat=channel_layouts=stereo[aout]" + ); + } + + #[test] + fn mono_merge_caps_input_count() { + let mut streams = vec![json!({"codec_type": "video", "codec_name": "dnxhd"})]; + for _ in 0..(MAX_MERGED_AUDIO_STREAMS + 4) { + streams.push(mono_audio("pcm_s24le")); + } + let probe = json!({ "streams": streams }); + + let filter = build_mono_merge_filter(&probe).unwrap(); + + assert!(filter.contains(&format!("amerge=inputs={}", MAX_MERGED_AUDIO_STREAMS))); + assert!(filter.contains(&format!("[0:a:{}]", MAX_MERGED_AUDIO_STREAMS - 1))); + assert!(!filter.contains(&format!("[0:a:{}]", MAX_MERGED_AUDIO_STREAMS))); + } + + #[test] + fn single_mono_track_is_not_merged() { + let probe = json!({ + "streams": [ + {"codec_type": "video", "codec_name": "h264"}, + mono_audio("aac"), + ] + }); + + assert!(build_mono_merge_filter(&probe).is_none()); + } + + #[test] + fn multiple_stereo_streams_are_not_merged() { + // Alternate mixes/languages, not channels of one mix — merging them + // would blend two programs together. + let probe = json!({ + "streams": [ + {"codec_type": "video", "codec_name": "h264"}, + stereo_audio(), + stereo_audio(), + ] + }); + + assert!(build_mono_merge_filter(&probe).is_none()); + } + + #[test] + fn mixed_channel_counts_are_not_merged() { + let probe = json!({ + "streams": [ + {"codec_type": "video", "codec_name": "h264"}, + stereo_audio(), + mono_audio("pcm_s24le"), + mono_audio("pcm_s24le"), + ] + }); + + assert!(build_mono_merge_filter(&probe).is_none()); + } + + #[test] + fn audioless_source_is_not_merged() { + let probe = json!({"streams": [{"codec_type": "video", "codec_name": "h264"}]}); + + assert!(build_mono_merge_filter(&probe).is_none()); + } + + #[test] + fn probe_without_streams_is_not_merged() { + assert!(build_mono_merge_filter(&json!({})).is_none()); + } + + #[test] + fn streams_missing_channel_counts_are_not_merged() { + // Without a channels field we cannot tell discrete mono tracks from + // alternate mixes; keep ffmpeg's default selection. + let probe = json!({ + "streams": [ + {"codec_type": "video", "codec_name": "h264"}, + {"codec_type": "audio", "codec_name": "pcm_s24le"}, + {"codec_type": "audio", "codec_name": "pcm_s24le"}, + ] + }); + + assert!(build_mono_merge_filter(&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"; @@ -452,6 +604,23 @@ pub fn create_rendition( input.clone() }; + // extract_video_essence writes video only (-an), so there is nothing to merge + // on the speed-corrected path; only the original input can carry mono tracks. + let mono_merge_filter = if speed_correction.is_some() { + None + } else { + get_ffprobe_json(input) + .ok() + .flatten() + .as_ref() + .and_then(build_mono_merge_filter) + }; + if mono_merge_filter.is_some() { + if let Some(f) = info_cb { + f("Source carries discrete mono audio tracks; merging them to stereo"); + } + } + let mut cmd = FfmpegCommand::new(); cmd.overwrite() .input(source_input.to_string_lossy()) @@ -479,6 +648,20 @@ pub fn create_rendition( cmd.args(["-r", &correction.frame_rate]); } + if let Some(filter) = &mono_merge_filter { + // A complex filtergraph disables automatic output stream selection, so the + // video stream has to be mapped explicitly too. -map order sets output + // stream order: video first, then the merged audio. + cmd.args([ + "-map", + "0:v:0", + "-filter_complex", + filter.as_str(), + "-map", + "[aout]", + ]); + } + cmd.args(["-movflags", "+faststart"]).codec_audio("aac"); if let Some(abr_kbps) = definition.audio_bitrate { cmd.args(["-b:a", &format!("{}k", abr_kbps)]);