From 87f234c50c74aef47400292a77de887f1e38e343 Mon Sep 17 00:00:00 2001 From: Linuxuser67 <41808005+Linuxuser67@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:58:50 +0100 Subject: [PATCH] fix(video): clamp corrupt live remux PTS/DTS pairs A live capture that jumps timeline mid-file (for example across a Twitch ad break) leaves the first two post-jump video packets with the new PTS and a DTS synthesised from the pre-jump reorder buffer. Their difference overflows movenc's signed 32-bit check, so the live remux fails at the trailer with "pts/dts pair unsupported". Run setts as an input BSF on video remuxes. It leaves normal packets alone and resets only pairs whose difference exceeds ten minutes at the 90 kHz MPEG-TS timebase, before the interleaver reaches the muxer. --- src/video_argv.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++ src/video_tests.rs | 2 ++ 2 files changed, 51 insertions(+) diff --git a/src/video_argv.rs b/src/video_argv.rs index cf7e8df3..79d61636 100644 --- a/src/video_argv.rs +++ b/src/video_argv.rs @@ -367,6 +367,16 @@ pub(crate) fn live_capture_argv( args } +/// Clamp `PTS`/`DTS` pairs that movenc cannot represent as a signed 32-bit delta. +/// +/// A live capture can jump its timeline mid-file (for example across an ad +/// break). FFmpeg corrects the jump from the third post-jump packet, but the +/// first two keep the new PTS and a DTS synthesised from the pre-jump reorder +/// buffer; that difference overflows movenc's int32 check and aborts the remux. +/// Ten minutes at the 90 kHz MPEG-TS timebase is far beyond a real reorder +/// delay, so anything larger is corruption. `NOPTS` is left untouched. +const LIVE_REMUX_PTS_DTS_BSF: &str = "setts=pts='if(eq(DTS,NOPTS),PTS,if(eq(PTS,NOPTS),PTS,DTS+(PTS-DTS)*lt(abs(PTS-DTS),54000000)))'"; + /// ffmpeg argv remuxing a stopped live capture into the finished file (stream-copy + faststart). Pure. /// `page_url` is stamped into `comment`: the live leg never gets `--embed-metadata`, so this is its only provenance. /// `dest` is the `.part` remux target: ffmpeg can't guess a container from that name, so `-f` @@ -391,6 +401,13 @@ pub(crate) fn live_remux_argv( if repair_timestamps { argv.extend(["-fflags".to_string(), "+genpts+igndts".to_string()]); } + // Input-scoped: this runs after FFmpeg's own discontinuity correction and + // before the interleaver, so the pairs it could not fix never reach movenc. + // shortcut: clamps only the >10-minute pairs; remove when FFmpeg flushes + // its reorder buffer on a PTS jump. + if !audio_only { + argv.extend(["-bsf:v".to_string(), LIVE_REMUX_PTS_DTS_BSF.to_string()]); + } argv.extend(["-i".to_string(), ts_path.to_string_lossy().into_owned()]); if audio_only { argv.extend(["-map".to_string(), "0:a?".to_string()]); @@ -646,6 +663,38 @@ mod live_remux_argv_tests { assert_eq!(maps, vec!["0"]); } + #[test] + fn live_video_remux_clamps_pts_dts_pairs_before_input() { + let args = live_remux_argv( + Path::new("capture.ts"), + Path::new("finished.mp4.part"), + false, + true, + false, + "", + ); + let bsf_at = args + .iter() + .position(|arg| arg == "-bsf:v") + .expect("video remuxes must clamp overflowing pts/dts pairs"); + let input = args.iter().position(|arg| arg == "-i").unwrap(); + assert!(bsf_at < input, "the clamp is an input BSF"); + assert_eq!(args[bsf_at + 1], super::LIVE_REMUX_PTS_DTS_BSF); + + let audio = live_remux_argv( + Path::new("capture.m4a"), + Path::new("finished.m4a.part"), + true, + true, + false, + "", + ); + assert!( + !audio.iter().any(|arg| arg == "-bsf:v"), + "audio-only remuxes have no video stream to clamp" + ); + } + #[test] fn timestamp_repair_remux_narrows_mapping_to_audio_and_video() { let args = live_remux_argv( diff --git a/src/video_tests.rs b/src/video_tests.rs index 1309a252..48a595b7 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -3909,6 +3909,7 @@ fn live_remux_argv_copies_with_fixup_and_carries_provenance() { assert!(!argv.windows(2).any(|w| w == ["-map", "0:a?"])); assert!(argv.windows(2).any(|w| w == ["-c", "copy"])); assert!(argv.windows(2).any(|w| w == ["-bsf:a", "aac_adtstoasc"])); + assert!(argv.windows(2).any(|w| w[0] == "-bsf:v")); assert!(argv.windows(2).any(|w| w == ["-f", "mp4"])); assert!(argv.windows(2).any(|w| w == ["-movflags", "+faststart"])); assert!(!argv.iter().any(|a| a == "-vn")); @@ -3936,6 +3937,7 @@ fn live_remux_argv_copies_with_fixup_and_carries_provenance() { assert!(argv.windows(2).any(|w| w == ["-map", "0:a?"])); assert!(argv.windows(2).any(|w| w == ["-f", "mp4"])); assert!(!argv.iter().any(|a| a == "-bsf:a")); + assert!(!argv.iter().any(|a| a == "-bsf:v")); } #[test]