diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs
index 8426421..9f39f32 100644
--- a/src/mux/demux_sink.rs
+++ b/src/mux/demux_sink.rs
@@ -1543,14 +1543,14 @@ mod tests {
fn timeline_track0_drives_epoch_others_ride() {
let mut tl = TimelineContinuity::new();
// Clip 1: video 0..10s (track 0 drives the epoch).
- assert_eq!(tl.adjust(0, true), 0);
- assert_eq!(tl.adjust(0, false), 0); // audio rides the same offset
- assert_eq!(tl.adjust(10_000_000_000, true), 10_000_000_000);
+ assert_eq!(tl.adjust(0, true, 0), 0);
+ assert_eq!(tl.adjust(0, false, 1), 0); // audio rides the same offset
+ assert_eq!(tl.adjust(10_000_000_000, true, 0), 10_000_000_000);
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
- let out = tl.adjust(0, true);
+ let out = tl.adjust(0, true, 0);
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
// Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved).
- let a = tl.adjust(0, false);
+ let a = tl.adjust(0, false, 1);
assert_eq!(a, out);
}
diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs
index 9d899c7..caf3972 100644
--- a/src/mux/timeline.rs
+++ b/src/mux/timeline.rs
@@ -658,6 +658,24 @@ pub(crate) struct TimelineContinuity {
/// frontier, which by then may be a whole title away. Bounded: a source that
/// rebases forever must not grow this.
epoch_offsets: Vec<(i64, i64)>,
+ /// Last raw PTS seen per track, for spotting a track's OWN discontinuity.
+ /// Within an epoch a passive track's PTS only advances (audio and subtitles
+ /// do not reorder, and a passive video track's B-frame dip is far under the
+ /// backstep), so a large BACKWARD step is unambiguous. This is a different
+ /// signal from the shared frontier, which is what the old false-positive
+ /// ratchet keyed on.
+ last_raw_ns: Vec>,
+ /// Per-track provisional offset for frames that arrive BEFORE the video
+ /// frame opening their epoch, as `(epochs retired when it was taken,
+ /// offset)`.
+ ///
+ /// It is deliberately private to one track and never written to
+ /// `offset_ns`, never advances `high_ns`, and never retires an epoch — so
+ /// it cannot move the video timeline. Letting a passive track open a real
+ /// epoch was tried and inflated a 476.776 s title to 656.216 s, because
+ /// every track observes a boundary at its own pace and the video path
+ /// rebased again on top of whatever they had done.
+ provisional: Vec >,
}
/// Most epochs retained for straggler resolution. A title has a handful; this
@@ -668,6 +686,8 @@ impl TimelineContinuity {
pub(crate) fn new() -> Self {
Self {
epoch_offsets: Vec::new(),
+ last_raw_ns: Vec::new(),
+ provisional: Vec::new(),
offset_ns: 0,
prev_offset_ns: 0,
high_ns: None,
@@ -702,6 +722,8 @@ impl TimelineContinuity {
};
Self {
epoch_offsets: Vec::new(),
+ last_raw_ns: Vec::new(),
+ provisional: Vec::new(),
offset_ns: 0,
prev_offset_ns: 0,
high_ns: None,
@@ -749,7 +771,52 @@ impl TimelineContinuity {
}
return placed;
}
- Some(self.adjust(raw_pts_ns, drives_epoch))
+ Some(self.adjust(raw_pts_ns, drives_epoch, track))
+ }
+
+ /// The offset a passive frame should ride, and the bookkeeping around it.
+ ///
+ /// Returns the effective offset for THIS frame. Normally that is the
+ /// current epoch's. It differs only for a frame that arrived ahead of the
+ /// video that opens its epoch: such a frame's own raw PTS has just jumped
+ /// backwards AND its current-epoch mapping lands a whole epoch below the
+ /// frontier, which no in-epoch frame ever does.
+ ///
+ /// A provisional is dropped the moment the video actually retires an epoch,
+ /// so the run rejoins the real offset with no seam — the two agree because
+ /// both are `frontier - mapping + gap`.
+ fn passive_offset(&mut self, track: usize, raw_pts_ns: i64) -> i64 {
+ if self.last_raw_ns.len() <= track {
+ self.last_raw_ns.resize(track + 1, None);
+ self.provisional.resize(track + 1, None);
+ }
+ let prev_raw = self.last_raw_ns[track].replace(raw_pts_ns);
+ let retired = self.epoch_offsets.len();
+
+ // A provisional only survives until the video opens the epoch for real.
+ if let Some((taken_at, _)) = self.provisional[track]
+ && taken_at != retired
+ {
+ self.provisional[track] = None;
+ }
+ let effective = self.provisional[track].map_or(self.offset_ns, |(_, o)| o);
+
+ if let Some(high) = self.high_ns
+ && self.provisional[track].is_none()
+ && let Some(pr) = prev_raw
+ && raw_pts_ns < pr.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
+ {
+ let mapped = raw_pts_ns.saturating_add(effective);
+ if mapped < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) {
+ let off = high
+ .saturating_sub(mapped)
+ .saturating_add(DISCONTINUITY_GAP_NS);
+ let off = effective.saturating_add(off);
+ self.provisional[track] = Some((retired, off));
+ return off;
+ }
+ }
+ effective
}
/// Retire the current epoch and open a new one continuing just after the
@@ -820,12 +887,13 @@ impl TimelineContinuity {
/// - **Everything else** (normal progression + sub-threshold B-frame reorder
/// dips) passes through with the current offset and advances the frontier,
/// preserving PTS.
- pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> i64 {
+ pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool, track: usize) -> i64 {
// Passive track: ride the current epoch's offset. Never advance the
// frontier and never open an epoch — these tracks each run on their own
// (sparse/laggy/independent) timeline and would false-trigger the ratchet.
if !drives_epoch {
- let mapped = raw_pts_ns.saturating_add(self.offset_ns);
+ let effective = self.passive_offset(track, raw_pts_ns);
+ let mapped = raw_pts_ns.saturating_add(effective);
// Tail-straggler remap: at a REAL (base-video-driven) multi-clip
// boundary the offset has just jumped forward by ~a whole clip, but a
// lagging tail frame from the just-ended clip still carries an
@@ -897,10 +965,10 @@ mod tests {
// Convenience: a video frame drives epoch decisions; non-video rides the
// current offset. These wrappers make the test intent explicit.
fn adj_video(tc: &mut TimelineContinuity, p: i64) -> i64 {
- tc.adjust(p, true)
+ tc.adjust(p, true, 0)
}
fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 {
- tc.adjust(p, false)
+ tc.adjust(p, false, 1)
}
/// Characterization of the BUG: a BD title's two clips concatenated with a
@@ -2314,6 +2382,85 @@ mod tests {
);
}
+ /// MEASURED on a real DVD title with 8 cell boundaries. The demuxer hands
+ /// the muxer ~18 audio frames of the NEXT cell before that cell's first
+ /// video frame. Riding the just-ended epoch's offset put them ~21 s in the
+ /// past, and the MKV writer's strictly-monotonic nudge then crushed the run
+ /// onto one instant 0.1 ms apart — half a second of audio as a click,
+ /// eight times in an 8-minute title.
+ ///
+ /// They must instead continue after the frontier, and must rejoin the real
+ /// offset seamlessly once the video opens the epoch.
+ #[test]
+ fn frames_arriving_before_their_epochs_video_ride_a_provisional_offset() {
+ let mut tc = TimelineContinuity::new();
+ for i in 0..=600 {
+ adj_video(&mut tc, i * S);
+ }
+ let frontier = tc.high_ns.expect("frontier");
+ adj_other(&mut tc, 599 * S);
+
+ // The next cell's audio arrives first, raw restarted at 0.
+ let step = 32_000_000; // 32 ms, the measured cadence
+ let a0 = adj_other(&mut tc, 0);
+ let a1 = adj_other(&mut tc, step);
+ let a2 = adj_other(&mut tc, 2 * step);
+ assert_eq!(
+ a0,
+ frontier + DISCONTINUITY_GAP_NS,
+ "an early frame must continue after the frontier, not land an epoch \
+ in the past where the monotonic nudge crushes it"
+ );
+ assert_eq!(a1 - a0, step, "the run keeps its cadence");
+ assert_eq!(a2 - a1, step);
+
+ // Critically: the video timeline must be untouched. Letting a passive
+ // track open a real epoch inflated a 476.776 s title to 656.216 s.
+ assert_eq!(tc.offset_ns, 0, "a provisional must not move the offset");
+ assert_eq!(tc.high_ns, Some(frontier), "nor the frontier");
+ assert!(tc.epoch_offsets.is_empty(), "nor retire an epoch");
+
+ // Now the video for that cell arrives and opens the epoch for real.
+ let v = adj_video(&mut tc, 0);
+ assert_eq!(v, frontier + DISCONTINUITY_GAP_NS);
+ assert_eq!(
+ tc.epoch_offsets.len(),
+ 1,
+ "exactly one epoch, opened by video"
+ );
+
+ // The next audio frame rejoins the real offset with no seam: both are
+ // `frontier - mapping + gap`, so they agree.
+ let a3 = adj_other(&mut tc, 3 * step);
+ assert_eq!(a3 - a2, step, "the run continues across the handover");
+ }
+
+ /// A provisional must not outlive its epoch: once the video has retired an
+ /// epoch, later frames of that track take the real offset. Otherwise a track
+ /// would drift away from every other one for the rest of the title.
+ #[test]
+ fn a_provisional_is_dropped_once_the_epoch_is_real() {
+ let mut tc = TimelineContinuity::new();
+ for i in 0..=600 {
+ adj_video(&mut tc, i * S);
+ }
+ adj_other(&mut tc, 599 * S);
+ adj_other(&mut tc, 0); // takes a provisional
+ assert!(tc.provisional[1].is_some());
+
+ adj_video(&mut tc, 0); // video opens the epoch for real
+ adj_other(&mut tc, S); // next frame of that track
+ assert!(
+ tc.provisional[1].is_none(),
+ "the provisional must be dropped once the real epoch exists"
+ );
+ assert_eq!(
+ adj_other(&mut tc, 2 * S),
+ 2 * S + tc.offset_ns,
+ "the track now rides the real offset like every other"
+ );
+ }
+
/// MEASURED on a real HD-DVD title. Its second audio track's LAST frame
/// carries a clip-1 raw PTS but arrives after clip 2's video opened the
/// epoch, so it took clip 2's offset and landed at 12834.587 s in a