diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index b99be43..a367c2b 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -742,7 +742,7 @@ impl DemuxSink { tracks, ref_video_track, ref_first_pts_ns: None, - timeline: TimelineContinuity::new(), + timeline: TimelineContinuity::with_clips(&title.clips), finished: false, }) } @@ -866,7 +866,11 @@ impl Stream for DemuxSink { // an audio ES before the video ES, in which case track 0 is audio and a // non-video epoch driver would ratchet the frontier on sparse/lagging PTS. let drives = Some(frame.track) == self.ref_video_track; - let pts = self.timeline.adjust(frame.pts, drives); + // See `MkvMuxer::write_frame`: `None` is material outside the + // playlist's clip marks and is dropped rather than emitted. + let Some(pts) = self.timeline.map(frame.pts, drives) else { + return Ok(()); + }; if drives { // Delay reference: recorded here, not in the track's `TrackOut`, so // it survives the `audio://` / `sub://` kind filter dropping the diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 185c92e..f20451b 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -1349,6 +1349,22 @@ impl MkvMuxer { /// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a /// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not /// a keyframe. `None` for every non-3D frame. + /// Drive seam correction from the title's PlayItem marks instead of + /// inferring it from PTS jumps. + /// + /// A multi-clip Blu-ray playlist joins its clips with overlaps and skips + /// that PTS inspection cannot recover: a forward jump is indistinguishable + /// from frames lost to damaged media, and an overlap smaller than the + /// reorder threshold is invisible. Given the marks, each clip is placed at + /// the sum of the earlier clips' durations, so the output runs exactly as + /// long as the playlist says the title is. + /// + /// No-op for a title with fewer than two clips or without usable marks — + /// DVD, HD-DVD and file sources keep the inference path. + pub fn set_clips(&mut self, clips: &[crate::disc::Clip]) { + self.continuity = TimelineContinuity::with_clips(clips); + } + pub fn write_frame( &mut self, track_idx: usize, @@ -1391,7 +1407,13 @@ impl MkvMuxer { // band of non-monotonic block timestamps. Only the PRIMARY video track // drives the boundary decision; every other track (audio, subtitle, DV // EL) rides the current offset. No-op for single-clip titles. - let pts_ns = self.continuity.adjust(pts_ns, drives_epoch); + // `None` means the playlist does not include this frame — material + // outside every clip's IN/OUT marks, which only a seam-plan-driven + // title can report. Dropping it is the point: emitting it is what put + // duplicate content on the timeline at a join. + let Some(pts_ns) = self.continuity.map(pts_ns, drives_epoch) else { + return Ok(()); + }; let raw_ticks = pts_ns / TIMESTAMP_SCALE_NS; // Cluster boundaries normally coincide with a video keyframe so every diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index eae4b06..3d8d7cd 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -504,6 +504,8 @@ impl MkvStream { self.disc_title.duration_secs, &self.disc_title.chapters, )?; + // Seam correction from the playlist's marks where the title has them. + muxer.set_clips(&self.disc_title.clips); if let Some(path) = &pending.opening_capture_path { muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len())); } diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs index 59fa6e0..32b923c 100644 --- a/src/mux/timeline.rs +++ b/src/mux/timeline.rs @@ -19,6 +19,175 @@ pub(crate) const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; /// the new clip lands strictly after the previous timeline high (1 ms). pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000; +/// How close a frame's PTS must be to a clip's IN mark to be recognised as that +/// clip's opening frame. +/// +/// At an OVERLAP join the next clip's IN sits inside the current clip's range, +/// so "past the current OUT" never fires and the two clips share a PTS band. +/// The clips are concatenated in file order, though, so the new clip opens ON +/// its IN mark — this window is what tells that opening frame apart from the +/// old clip's tail. One video frame is ~42 ms at 24 fps; 250 ms allows for a +/// clip whose first frame sits a few frames past its mark without ever reaching +/// the next join. +pub(crate) const CLIP_START_TOLERANCE_NS: i64 = 250_000_000; + +/// MPLS 45 kHz tick → nanoseconds. PlayItem `in_time`/`out_time` are 45 kHz +/// (`disc::Clip`, and `disc/bluray.rs` divides by 45000.0 for the same reason). +fn mpls_ticks_to_ns(ticks: u32) -> i64 { + // 1e9 / 45_000 = 22_222.22…, so scale first and divide once to avoid + // accumulating a per-clip rounding error across an 11-clip title. + (ticks as i64).saturating_mul(1_000_000_000) / 45_000 +} + +/// One clip's placement on the output timeline, derived from its PlayItem marks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SeamClip { + /// Clip IN mark in the shared source clock (ns). + pub(crate) in_ns: i64, + /// Clip OUT mark in the shared source clock (ns). + pub(crate) out_ns: i64, + /// Added to a raw PTS inside this clip to place it on the output timeline. + /// Equals (sum of every earlier clip's playable duration) − `in_ns`. + pub(crate) offset_ns: i64, +} + +/// The playlist's own answer to "where does each clip belong on the timeline". +/// +/// A seamless-branching title's PlayItems do NOT chain contiguously in the +/// shared clock: one clip's OUT may sit *after* the next clip's IN (overlap — +/// the disc stores the join twice so a player can switch without a gap), or +/// *before* it (skip — the playlist jumps over material). Measured on one real +/// UHD title, `00801.mpls`, 11 PlayItems: +/// +/// ```text +/// clip 0 in 4199.0000 out 6033.0405 cum_start 0.0000 +/// clip 1 in 6031.2500 out 6308.1933 cum_start 1834.0405 <- 1.79s OVERLAP +/// clip 2 in 6298.1667 out 6875.0763 cum_start 2110.9839 +/// clip 3 in 6884.2500 out 6948.0220 cum_start 2687.8935 <- 9.17s SKIP +/// ``` +/// +/// Inferring seams from PTS jumps cannot recover this. A forward jump is +/// ambiguous — it means "the playlist skipped" OR "we lost frames to damaged +/// media", and compressing the latter would silently falsify timing on exactly +/// the rips that most need it faithful. An overlap smaller than the B-frame +/// reorder threshold is invisible to inference entirely, and its duplicated +/// content then collides in the muxer. +/// +/// So the marks are read rather than guessed. Each clip contributes exactly +/// `out − in` to the output, laid end to end: gaps never become dead timeline, +/// and material outside a clip's marks is dropped rather than emitted twice. +pub(crate) struct SeamPlan { + clips: Vec, + /// Index of the clip the primary video is currently inside. Only video + /// advances it, for the same reason only video drives epochs: the passive + /// tracks are sparse and lag, so letting them advance the cursor would + /// retire a clip while its audio was still arriving. + cursor: usize, +} + +impl SeamPlan { + /// Build a plan from a title's clips, or `None` when there is nothing to + /// place: no clips (DVD, HD-DVD, `mkv://`/`m2ts://` sources — none of which + /// carry PlayItem marks), a single clip (nothing to join), or marks that are + /// not usable (a zero/inverted span means the playlist is not telling us + /// anything we can act on, and guessing is what this type exists to avoid). + /// + /// Returning `None` leaves [`TimelineContinuity`] on its PTS-jump inference, + /// which is what every non-BD path has always used. + pub(crate) fn from_clips(clips: &[crate::disc::Clip]) -> Option { + if clips.len() < 2 { + return None; + } + let mut out = Vec::with_capacity(clips.len()); + let mut cum: i64 = 0; + for c in clips { + let in_ns = mpls_ticks_to_ns(c.in_time); + let out_ns = mpls_ticks_to_ns(c.out_time); + if out_ns <= in_ns { + return None; + } + out.push(SeamClip { + in_ns, + out_ns, + offset_ns: cum.saturating_sub(in_ns), + }); + cum = cum.saturating_add(out_ns - in_ns); + } + Some(Self { + clips: out, + cursor: 0, + }) + } + + /// Total playable duration (ns) — the sum of every clip's `out − in`. This + /// is the length the title actually is, and what the output timeline must + /// end at. + #[cfg(test)] + pub(crate) fn total_ns(&self) -> i64 { + self.clips + .iter() + .map(|c| c.out_ns - c.in_ns) + .fold(0i64, |a, b| a.saturating_add(b)) + } + + /// Place a raw PTS, advancing the cursor when `drives` (primary video). + /// + /// `None` means DROP: the frame lies outside every clip's marks, which is + /// material the playlist does not include — most often the overlap region a + /// disc stores twice. Emitting it is what produced duplicate content and a + /// backward DTS step at the join. + fn place(&mut self, raw_ns: i64, drives: bool) -> Option { + if drives { + // Advance to the next clip when this frame is its opening frame. + // + // Two signatures, because a join is either a skip or an overlap: + // + // - SKIP (next IN is after this OUT): the frame is simply past the + // current clip's OUT. + // - OVERLAP (next IN is BEFORE this OUT, the disc storing the join + // twice): the frame is still inside the current clip's range, so + // "past OUT" never fires. But the clips are concatenated in file + // order, so the first frame of the new clip lands on its IN mark. + // Recognising that is what distinguishes the new clip's opening + // from the old clip's tail, which share a PTS range. + // + // Bounded by the clip count, so a wild PTS cannot spin here. + while self.cursor + 1 < self.clips.len() { + let cur_out = self.clips[self.cursor].out_ns; + let next_in = self.clips[self.cursor + 1].in_ns; + let past_out = raw_ns > cur_out; + let at_next_in = raw_ns >= next_in + && raw_ns <= next_in.saturating_add(CLIP_START_TOLERANCE_NS); + if past_out || at_next_in { + self.cursor += 1; + } else { + break; + } + } + let c = self.clips[self.cursor]; + if raw_ns < c.in_ns || raw_ns > c.out_ns { + return None; + } + return Some(raw_ns.saturating_add(c.offset_ns)); + } + // Passive track. Try the clip video is in, then the one before it: at a + // join the tracks do not switch on the same frame, so a lagging audio or + // subtitle frame from the previous clip can arrive after video has moved + // on. Checking both places it correctly instead of dropping it. + let cur = self.clips[self.cursor]; + if raw_ns >= cur.in_ns && raw_ns <= cur.out_ns { + return Some(raw_ns.saturating_add(cur.offset_ns)); + } + if self.cursor > 0 { + let prev = self.clips[self.cursor - 1]; + if raw_ns >= prev.in_ns && raw_ns <= prev.out_ns { + return Some(raw_ns.saturating_add(prev.offset_ns)); + } + } + None + } +} + /// Global timeline-continuity corrector. freemkv reads a BD title's clips as /// one concatenated sector stream (clip boundaries / mpls connection_condition /// are not plumbed to the mux), so at a non-seamless boundary the source PES @@ -70,6 +239,10 @@ pub(crate) struct TimelineContinuity { /// running frontier. `None` until the first video frame. Only video advances /// it; non-video tracks never touch it. pub(crate) high_ns: Option, + /// The playlist's clip placement, when the source has one. Present = the + /// marks are known and are used verbatim; absent = fall back to inferring + /// seams from PTS jumps, which is all any non-BD source has ever had. + pub(crate) seams: Option, } impl TimelineContinuity { @@ -78,9 +251,48 @@ impl TimelineContinuity { offset_ns: 0, prev_offset_ns: 0, high_ns: None, + seams: None, } } + /// Corrector driven by a title's PlayItem marks where they exist. + /// + /// Falls back to [`Self::new`]'s inference when the title has fewer than two + /// clips or its marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://` + /// sources behave exactly as before. + pub(crate) fn with_clips(clips: &[crate::disc::Clip]) -> Self { + Self { + offset_ns: 0, + prev_offset_ns: 0, + high_ns: None, + seams: SeamPlan::from_clips(clips), + } + } + + /// Map a raw PES PTS onto the output timeline, or `None` to drop the frame. + /// + /// Dropping only ever happens under a [`SeamPlan`]: it is material outside + /// the playlist's marks, which the title does not include. + pub(crate) fn map(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> Option { + if self.seams.is_some() { + // Take the plan out for the call so `place` can borrow `self` + // mutably without fighting the borrow checker over the whole struct. + let mut plan = self.seams.take().expect("checked is_some"); + let placed = plan.place(raw_pts_ns, drives_epoch); + self.seams = Some(plan); + if let Some(p) = placed { + // Keep the frontier meaningful for anything that reads it, and + // keep `offset_ns` reporting the correction actually applied. + if drives_epoch { + self.high_ns = Some(self.high_ns.map_or(p, |h| h.max(p))); + } + self.offset_ns = p.saturating_sub(raw_pts_ns); + } + return placed; + } + Some(self.adjust(raw_pts_ns, drives_epoch)) + } + /// Map a raw PES PTS (ns) onto the continuous output timeline. /// /// `drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY @@ -254,6 +466,208 @@ mod tests { assert_eq!(tc.offset_ns, 0, "no rebase on forward progression"); } + /// Build a real seamless-branching clip table (`00801.mpls`, 11 PlayItems, marks + /// read off the disc) so the arithmetic is pinned to measured ground truth + /// rather than to invented numbers. + fn seamless_branching_clips() -> Vec { + // (in_time, out_time) in 45kHz ticks, verbatim from the disc. + const MARKS: [(u32, u32); 11] = [ + (188955000, 271486824), + (271406250, 283868700), + (283417500, 309378435), + (309791250, 312660991), + (312219375, 346451698), + (346854375, 355985371), + (353385000, 429270810), + (431786250, 440217172), + (437326875, 442467635), + (445344375, 451271546), + (447946875, 540576286), + ]; + MARKS + .iter() + .enumerate() + .map(|(i, &(in_time, out_time))| crate::disc::Clip { + clip_id: format!("{i:05}"), + in_time, + out_time, + duration_secs: (out_time - in_time) as f64 / 45_000.0, + source_packets: 0, + }) + .collect() + } + + /// The plan's total must equal the title's declared duration. + /// + /// This is the whole bug in one assertion: the delivered file declared + /// 7893.385 s and carried packets to 8029.298 s — 135.91 s of timeline the + /// playlist says does not exist. + #[test] + fn seam_plan_total_matches_the_declared_duration() { + let plan = SeamPlan::from_clips(&seamless_branching_clips()).expect("plan"); + let total = plan.total_ns(); + // 7893.3854s, the duration freemkv itself reports for this title. + assert!( + (total - 7_893_385_400_000).abs() < 1_000_000, + "plan total {total} ns is not the declared 7893.3854 s" + ); + } + + /// Clips are laid end to end: each starts exactly where the previous ended, + /// so a forward skip in the source clock never becomes dead timeline. + #[test] + fn seam_plan_lays_clips_end_to_end() { + let clips = seamless_branching_clips(); + let mut plan = SeamPlan::from_clips(&clips).expect("plan"); + let mut expected_start = 0i64; + for (i, c) in clips.iter().enumerate() { + let in_ns = mpls_ticks_to_ns(c.in_time); + let out_ns = mpls_ticks_to_ns(c.out_time); + // First frame of the clip lands at the running total. + let got = plan.place(in_ns, true).expect("clip start is inside its marks"); + assert_eq!(got, expected_start, "clip {i} start misplaced"); + // Last frame lands at the running total plus the clip's length. + let end = plan.place(out_ns, true).expect("clip end is inside its marks"); + assert_eq!(end, expected_start + (out_ns - in_ns), "clip {i} end misplaced"); + expected_start += out_ns - in_ns; + } + assert!( + (expected_start - 7_893_385_400_000).abs() < 1_000_000, + "clips do not sum to the declared duration" + ); + } + + /// The 9.174 s forward skip between clip 2 and clip 3 must vanish. + /// + /// Measured in the delivered file as a 20 s window holding 257 video packets + /// where it should hold 480. + #[test] + fn seam_plan_closes_the_forward_skip() { + let clips = seamless_branching_clips(); + let mut plan = SeamPlan::from_clips(&clips).expect("plan"); + let c2_out = mpls_ticks_to_ns(clips[2].out_time); + let c3_in = mpls_ticks_to_ns(clips[3].in_time); + assert!( + c3_in - c2_out > 9_000_000_000, + "fixture should contain the ~9.17s skip" + ); + let end_of_2 = plan.place(c2_out, true).expect("in clip 2"); + let start_of_3 = plan.place(c3_in, true).expect("in clip 3"); + assert_eq!( + start_of_3, end_of_2, + "clip 3 must begin exactly where clip 2 ended — the skip is not content" + ); + } + + /// The 1.79 s overlap at seam 1 must JOIN cleanly, not rewind the timeline. + /// + /// Clip 1's IN (6031.250 s) precedes clip 0's OUT (6033.041 s): the disc + /// stores that join twice. Emitting both copies is what collided in the + /// muxer and flattened 169 audio packets onto the 0.1 ms tick floor, + /// putting audio ~1.8 s ahead of picture for the rest of the film. + #[test] + fn seam_plan_joins_an_overlap_without_rewinding() { + let clips = seamless_branching_clips(); + let mut plan = SeamPlan::from_clips(&clips).expect("plan"); + let c0_out = mpls_ticks_to_ns(clips[0].out_time); + let c1_in = mpls_ticks_to_ns(clips[1].in_time); + assert!(c1_in < c0_out, "fixture should contain the overlap"); + // Play clip 0 through to its OUT mark. + let last_of_0 = plan.place(c0_out, true).expect("clip 0 OUT is inside clip 0"); + // The next clip opens ON its IN mark. Under the old inference this was a + // 1.79s backward step, below the reorder threshold, so no seam was + // recognised and the join was emitted as duplicate content whose + // timestamps then collided. With the marks known, clip 1 is placed to + // continue exactly where clip 0 ended: one monotonic timeline, no + // rewind, and no collision for the muxer to flatten. + let first_of_1 = plan.place(c1_in, true).expect("clip 1 IN"); + assert_eq!( + first_of_1, last_of_0, + "clip 1 must continue from clip 0's end, not rewind by the overlap" + ); + // And the timeline keeps moving forward from there. + let into_1 = plan + .place(c1_in + 1_000_000_000, true) + .expect("1s into clip 1"); + assert_eq!(into_1, first_of_1 + 1_000_000_000, "clip 1 advances normally"); + } + + /// A lagging audio/subtitle frame from the clip that just ended is placed in + /// that clip, not dropped — the tracks do not switch on the same frame. + #[test] + fn seam_plan_places_a_lagging_passive_frame_in_the_previous_clip() { + let clips = seamless_branching_clips(); + let mut plan = SeamPlan::from_clips(&clips).expect("plan"); + let c0_out = mpls_ticks_to_ns(clips[0].out_time); + let c1_in = mpls_ticks_to_ns(clips[1].in_time); + // Video crosses into clip 1. + plan.place(c1_in + 500_000_000, true).expect("in clip 1"); + // A straggler from clip 0's tail arrives afterwards. + let tail = c0_out - 50_000_000; // 50ms before clip 0's OUT + let placed = plan.place(tail, false).expect("straggler must be placed"); + let expected = tail + (0i64 - mpls_ticks_to_ns(clips[0].in_time)); + assert_eq!(placed, expected, "straggler must ride clip 0's offset"); + } + + /// Clips whose marks chain contiguously must come out byte-identical to the + /// old behaviour: a constant offset, nothing moved, nothing dropped. + /// + /// This is the no-regression guarantee for every title that is multi-clip + /// but not seamless-branching — HD-DVD's feature is chaptered this way (one + /// real title measured: 3 clips, each IN equal to the previous OUT). + #[test] + fn contiguous_clips_produce_a_constant_offset() { + // 0..2948.6667s, 2948.6667..6410.8667s, chained exactly. + let marks = [(0u32, 132_690_000u32), (132_690_000, 288_489_000)]; + let clips: Vec = marks + .iter() + .enumerate() + .map(|(i, &(in_time, out_time))| crate::disc::Clip { + clip_id: format!("{i}"), + in_time, + out_time, + duration_secs: (out_time - in_time) as f64 / 45_000.0, + source_packets: 0, + }) + .collect(); + let mut plan = SeamPlan::from_clips(&clips).expect("plan"); + // Every frame maps to itself: offset 0 throughout, no discontinuity. + for &t in &[0i64, 1_000_000_000, 2_948_000_000_000, 2_949_000_000_000, 6_410_000_000_000] { + assert_eq!( + plan.place(t, true), + Some(t), + "contiguous clips must not move a frame (t={t})" + ); + } + } + + /// No plan for sources that have no PlayItem marks — DVD, HD-DVD, and file + /// sources keep the inference path they have always used. + #[test] + fn no_seam_plan_without_usable_marks() { + assert!(SeamPlan::from_clips(&[]).is_none(), "no clips"); + assert!( + SeamPlan::from_clips(&seamless_branching_clips()[..1]).is_none(), + "a single clip has nothing to join" + ); + let mut bad = seamless_branching_clips(); + bad[3].out_time = bad[3].in_time; // zero-length span + assert!( + SeamPlan::from_clips(&bad).is_none(), + "unusable marks must fall back to inference, not guess" + ); + } + + /// Without a plan, `map` is exactly the old `adjust` and never drops. + #[test] + fn map_without_a_plan_is_the_old_behaviour() { + let mut tc = TimelineContinuity::new(); + assert_eq!(tc.map(0, true), Some(0)); + assert_eq!(tc.map(5 * S, true), Some(5 * S)); + assert_eq!(tc.map(25 * S, false), Some(25 * S)); + assert_eq!(tc.offset_ns, 0); + } + /// PRIMARY rc3 regression: a sparse, lagging NON-VIDEO track (PGS subtitle / /// trailing audio) on a SINGLE-clip title must NOT inflate `offset_ns`. This /// is the exact false-positive that destroyed a real title's seek index: with a