diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 2ecadb6..7a582e6 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -23,6 +23,8 @@ //! The sink does NOT touch the MKV mux path; it is purely additive. use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream}; +use crate::mux::hevc::{append_length_prefixed_as_annex_b, avcc_to_annex_b, hvcc_to_annex_b}; +use crate::mux::timeline::TimelineContinuity; use crate::pes::{PesFrame, Stream}; use std::fs::File; use std::io::{self, BufWriter, Write}; @@ -189,8 +191,6 @@ impl EsWriter for PassthroughWriter { } } -const ANNEXB_START: [u8; 4] = [0x00, 0x00, 0x00, 0x01]; - /// HEVC/H.264 writer: reframes 4-byte-length-prefixed NALs (the hvcC/avcC form /// the parsers emit) into Annex-B, prepending the parameter sets once. struct AnnexBWriter { @@ -223,119 +223,31 @@ impl EsWriter for AnnexBWriter { } self.wrote_params = true; } - n += length_prefixed_to_annexb(&f.data, w)?; + // Reframe via the canonical length-prefixed→Annex-B converter (single + // source of truth across all muxers — see `crate::mux::hevc`). It skips + // zero-length NALs and drops a truncated trailing NAL without panicking, + // rather than `break`ing on the first zero-length NAL. + let mut scratch = Vec::with_capacity(f.data.len() + (f.data.len() / 32) + 4); + append_length_prefixed_as_annex_b(&mut scratch, &f.data); + w.write_all(&scratch)?; + n += scratch.len(); Ok(n) } } -/// Convert a buffer of 4-byte big-endian length-prefixed NAL units to Annex-B -/// (each NAL prefixed with `00 00 00 01`). Returns bytes written. A malformed -/// length (running past the buffer) stops the walk cleanly rather than panic. -fn length_prefixed_to_annexb(data: &[u8], w: &mut dyn Write) -> io::Result { - let mut pos = 0; - let mut written = 0; - while pos + 4 <= data.len() { - let len = - u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize; - pos += 4; - if len == 0 || pos + len > data.len() { - // Truncated / malformed length prefix: stop the walk. Emitting a - // partial NAL would corrupt the stream worse than dropping the tail. - break; - } - w.write_all(&ANNEXB_START)?; - w.write_all(&data[pos..pos + len])?; - written += ANNEXB_START.len() + len; - pos += len; - } - Ok(written) -} - /// Extract the parameter-set NALs from an hvcC (HEVC) or avcC (H.264) /// configuration record and return them as a single Annex-B blob /// (`00 00 00 01 | NAL …`). Returns an empty Vec if the record can't be parsed. +/// Delegates to the canonical hvcC/avcC → Annex-B converters in +/// [`crate::mux::hevc`] — the single source of truth across all muxers. fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec { match codec { - Codec::Hevc => hvcc_param_sets(record), - Codec::H264 => avcc_param_sets(record), + Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(), + Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(), _ => Vec::new(), } } -/// Parse VPS/SPS/PPS arrays out of an HEVCDecoderConfigurationRecord. -/// Layout: 22-byte fixed header, then `numOfArrays` (u8); per array: -/// `array_completeness|NAL_type` (u8), `numNalus` (u16 BE); per NAL: -/// `nalUnitLength` (u16 BE) + bytes. -fn hvcc_param_sets(rec: &[u8]) -> Vec { - let mut out = Vec::new(); - if rec.len() < 23 { - return out; - } - let num_arrays = rec[22] as usize; - let mut pos = 23; - for _ in 0..num_arrays { - if pos + 3 > rec.len() { - break; - } - // rec[pos] = array_completeness(1) | reserved(1) | NAL_unit_type(6) - pos += 1; - let num_nalus = u16::from_be_bytes([rec[pos], rec[pos + 1]]) as usize; - pos += 2; - for _ in 0..num_nalus { - if pos + 2 > rec.len() { - return out; - } - let nlen = u16::from_be_bytes([rec[pos], rec[pos + 1]]) as usize; - pos += 2; - if pos + nlen > rec.len() { - return out; - } - out.extend_from_slice(&ANNEXB_START); - out.extend_from_slice(&rec[pos..pos + nlen]); - pos += nlen; - } - } - out -} - -/// Parse SPS/PPS out of an AVCDecoderConfigurationRecord. -/// Layout: 5-byte fixed header, `numOfSPS`(u8, low 5 bits); per SPS: -/// length(u16 BE) + bytes; `numOfPPS`(u8); per PPS: length(u16 BE) + bytes. -fn avcc_param_sets(rec: &[u8]) -> Vec { - let mut out = Vec::new(); - if rec.len() < 6 { - return out; - } - let num_sps = (rec[5] & 0x1F) as usize; - let mut pos = 6; - let take = |count: usize, pos: &mut usize, out: &mut Vec| -> bool { - for _ in 0..count { - if *pos + 2 > rec.len() { - return false; - } - let nlen = u16::from_be_bytes([rec[*pos], rec[*pos + 1]]) as usize; - *pos += 2; - if *pos + nlen > rec.len() { - return false; - } - out.extend_from_slice(&ANNEXB_START); - out.extend_from_slice(&rec[*pos..*pos + nlen]); - *pos += nlen; - } - true - }; - if !take(num_sps, &mut pos, &mut out) { - return out; - } - if pos >= rec.len() { - return out; - } - let num_pps = rec[pos] as usize; - pos += 1; - take(num_pps, &mut pos, &mut out); - out -} - /// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped. /// /// The parser hands us the concatenated PGS segments of a display set in @@ -347,6 +259,27 @@ fn avcc_param_sets(rec: &[u8]) -> Vec { /// an empty composition at `pts + duration` so players time the subtitle out. struct PgsSupWriter; +// ── PGS / HDMV segment framing constants ───────────────────────────────────── +// HDMV Presentation Graphics Stream, as published in the Blu-ray Disc +// Read-Only Format (BD-ROM) Part 3 graphics-stream specification (and the +// public US 2009/0185789 A1 application that documents the segment layout). + +/// `.sup` per-segment magic: ASCII "PG" (0x50 0x47) starting each segment's +/// 13-byte header (magic | PTS u32 BE | DTS u32 BE) in a PGStream `.sup` file. +const SUP_MAGIC: [u8; 2] = [0x50, 0x47]; +/// Size in bytes of the `.sup` per-segment header (magic 2 + PTS 4 + DTS 4). +const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4; +/// PGS segment type: Presentation Composition Segment (PCS). +const SEG_PCS: u8 = 0x16; +/// PGS segment type: END of display set. +const SEG_END: u8 = 0x80; +/// PCS `composition_state` value: Epoch Start (a fresh display). +const PCS_COMPOSITION_STATE_EPOCH_START: u8 = 0x80; +/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE). +const PGS_SEG_HEADER_LEN: usize = 3; +/// Byte offset of `width`/`height` within a PCS segment (after type+size). +const PCS_WIDTH_OFFSET: usize = PGS_SEG_HEADER_LEN; // 3 + /// 90 kHz ticks from nanoseconds (saturating into u32 for the `.sup` header). fn ns_to_90k(pts_ns: i64) -> u32 { if pts_ns <= 0 { @@ -369,27 +302,94 @@ impl PgsSupWriter { let mut pos = 0; let mut written = 0; // Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes. - while pos + 3 <= data.len() { + while pos + PGS_SEG_HEADER_LEN <= data.len() { let size = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize; - let seg_end = pos + 3 + size; + let seg_end = pos + PGS_SEG_HEADER_LEN + size; if seg_end > data.len() { break; } - w.write_all(&[0x50, 0x47])?; // "PG" + w.write_all(&SUP_MAGIC)?; w.write_all(&pts90k.to_be_bytes())?; w.write_all(&dts90k.to_be_bytes())?; w.write_all(&data[pos..seg_end])?; - written += 13 + size; + written += SUP_HEADER_LEN + size; pos = seg_end; } Ok(written) } + + /// Build a synthetic "clear" display set: an empty PCS (0 composition + /// objects) followed by an END segment. The parser folds the original + /// clear/end PCS pair's wipe time into the display frame's `duration_ns` + /// and drops the clear bytes, so a faithful `.sup` re-emits one here at + /// `display_pts + duration`. Without it every subtitle lingers to EOF. + /// + /// `width`/`height` are carried from the display set's PCS so the clear PCS + /// advertises the same video geometry; they don't affect the wipe but keep + /// the segment well-formed. + /// + /// Returned bytes are concatenated `type(1)+size(2 BE)+payload` segments, + /// the same shape [`emit_segments`] consumes. + fn synthetic_clear_display_set(width: u16, height: u16) -> Vec { + // Empty PCS payload (HDMV PGS, BD-ROM Part 3): width(2) height(2) + // frame_rate(1) composition_number(2) composition_state(1) + // palette_update_flag(1) palette_id(1) number_of_composition_objects(1). + const PCS_FRAME_RATE: u8 = 0x10; // reserved high nibble | rate code + const PCS_NO_OBJECTS: u8 = 0x00; // number_of_composition_objects = 0 + let [w_hi, w_lo] = width.to_be_bytes(); + let [h_hi, h_lo] = height.to_be_bytes(); + let pcs_payload = [ + w_hi, + w_lo, + h_hi, + h_lo, + PCS_FRAME_RATE, + 0x00, + 0x00, // composition_number + PCS_COMPOSITION_STATE_EPOCH_START, + 0x00, // palette_update_flag + 0x00, // palette_id + PCS_NO_OBJECTS, + ]; + let mut out = Vec::with_capacity(PGS_SEG_HEADER_LEN * 2 + pcs_payload.len()); + out.push(SEG_PCS); + out.extend_from_slice(&(pcs_payload.len() as u16).to_be_bytes()); + out.extend_from_slice(&pcs_payload); + // END segment: type SEG_END, zero-length payload. + out.push(SEG_END); + out.extend_from_slice(&0u16.to_be_bytes()); + out + } + + /// Read the (width, height) the display set's first PCS advertises, if the + /// frame starts with a PCS carrying them; else `(0, 0)`. + fn pcs_dimensions(data: &[u8]) -> (u16, u16) { + // segment: type(1) size(2) payload; PCS payload begins width(2) height(2). + if data.len() >= PCS_WIDTH_OFFSET + 4 && data[0] == SEG_PCS { + let w = u16::from_be_bytes([data[PCS_WIDTH_OFFSET], data[PCS_WIDTH_OFFSET + 1]]); + let h = u16::from_be_bytes([data[PCS_WIDTH_OFFSET + 2], data[PCS_WIDTH_OFFSET + 3]]); + (w, h) + } else { + (0, 0) + } + } } impl EsWriter for PgsSupWriter { fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result { let pts90 = ns_to_90k(pts_ns); - Self::emit_segments(&f.data, pts90, pts90, w) + let mut written = Self::emit_segments(&f.data, pts90, pts90, w)?; + // The parser folds the display/clear PCS pair's wipe time into + // `duration_ns` and drops the clear bytes. Re-emit a synthetic clear + // display set at `pts + duration` so the subtitle is timed out instead + // of lingering to EOF. + if let Some(dur) = f.duration_ns { + let clear_pts = ns_to_90k(pts_ns.saturating_add(dur as i64)); + let (w_px, h_px) = Self::pcs_dimensions(&f.data); + let clear = Self::synthetic_clear_display_set(w_px, h_px); + written += Self::emit_segments(&clear, clear_pts, clear_pts, w)?; + } + Ok(written) } } @@ -399,20 +399,27 @@ struct VobSubWriter { idx_path: PathBuf, /// Pre-formatted `.idx` palette header line bytes, if available. palette_line: Option, + /// Two-letter language id for the `.idx` `id:` line (empty = omit). + lang2: String, entries: Vec<(i64, u64)>, pos: u64, } impl VobSubWriter { - fn new(idx_path: PathBuf, codec_private: Option<&[u8]>) -> Self { + fn new(idx_path: PathBuf, codec_private: Option<&[u8]>, lang: &str) -> Self { // codec_private for DvdSub is the pre-formatted VobSub `.idx` palette // header (UTF-8). Carry it through verbatim if present. let palette_line = codec_private .and_then(|b| std::str::from_utf8(b).ok()) .map(|s| s.trim_end().to_string()); + // VobSub `id:` lines use a 2-letter code; stream languages are ISO + // 639-2 (3-letter). Take the leading two chars — the convention + // mkvmerge reads to assign a track language. + let lang2: String = lang.chars().take(2).collect(); Self { idx_path, palette_line, + lang2, entries: Vec::new(), pos: 0, } @@ -435,6 +442,14 @@ impl EsWriter for VobSubWriter { idx.push('\n'); } idx.push_str("langidx: 0\n\n"); + // The conventional `id: , index: 0` line mkvmerge reads to + // assign the subtitle track's language. Omit the language token when + // unknown but still emit the index so the entry list is well-formed. + if self.lang2.is_empty() { + idx.push_str("id: , index: 0\n"); + } else { + idx.push_str(&format!("id: {}, index: 0\n", self.lang2)); + } for (pts_ns, filepos) in &self.entries { idx.push_str(&format!( "timestamp: {}, filepos: {:09x}\n", @@ -462,6 +477,7 @@ fn es_writer_for( codec: Codec, codec_private: Option<&[u8]>, idx_path: Option, + lang: &str, ) -> Box { match codec { Codec::Hevc | Codec::H264 => Box::new(AnnexBWriter::new(codec, codec_private)), @@ -469,58 +485,12 @@ fn es_writer_for( Codec::DvdSub => Box::new(VobSubWriter::new( idx_path.unwrap_or_else(|| PathBuf::from("subtitle.idx")), codec_private, + lang, )), _ => Box::new(PassthroughWriter), } } -// ── Timeline rebase (seamless-branch PTS continuity) ───────────────────────── - -/// Discontinuity threshold: a backward video-PTS jump larger than this opens a -/// new epoch. Mirrors the MKV muxer's `DISCONTINUITY_BACKSTEP_NS` (3 s). -const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; -/// 1 ms seam gap inserted between epochs (mirrors the MKV muxer). -const SEAM_GAP_NS: i64 = 1_000_000; - -/// Port of the MKV muxer's `TimelineContinuity` for the demux sink: track 0 -/// (primary video) drives epochs; a single global `offset_ns` is added to every -/// track so A/V sync is preserved across clip seams in seamless-branched titles. -struct TimelineRebase { - offset_ns: i64, - high_ns: i64, - started: bool, -} - -impl TimelineRebase { - fn new() -> Self { - Self { - offset_ns: 0, - high_ns: 0, - started: false, - } - } - - /// Map a raw concatenated PTS to a continuous one. Only track 0 opens - /// epochs; all tracks get the same global offset. - fn rebase(&mut self, track: usize, pts_ns: i64) -> i64 { - if track == 0 { - if !self.started { - self.started = true; - self.high_ns = pts_ns; - } else if pts_ns < self.high_ns - DISCONTINUITY_BACKSTEP_NS { - // Clip seam: shift this and following frames forward so the new - // epoch starts just after the previous high-water mark. - self.offset_ns += (self.high_ns - pts_ns) + SEAM_GAP_NS; - } - let out = pts_ns + self.offset_ns; - self.high_ns = self.high_ns.max(out); - out - } else { - pts_ns + self.offset_ns - } - } -} - // ── Delay + chapter helpers ────────────────────────────────────────────────── /// Delay in ms = round((audio_first_pts − ref_video_first_pts) / 1e6). @@ -640,7 +610,7 @@ pub struct DemuxSink { /// Index = track id; `None` for unselected tracks. tracks: Vec>, ref_video_track: Option, - timeline: TimelineRebase, + timeline: TimelineContinuity, finished: bool, } @@ -685,7 +655,7 @@ impl DemuxSink { None }; let codec_private = title.codec_privates.get(idx).and_then(|o| o.as_deref()); - let writer = es_writer_for(codec, codec_private, sidecar.clone()); + let writer = es_writer_for(codec, codec_private, sidecar.clone(), &lang); let _ = sidecar; // sidecar path is owned by the VobSub writer tracks.push(Some(TrackOut { @@ -703,7 +673,7 @@ impl DemuxSink { opts: opts.clone(), tracks, ref_video_track, - timeline: TimelineRebase::new(), + timeline: TimelineContinuity::new(), finished: false, }) } @@ -811,7 +781,9 @@ impl Stream for DemuxSink { } fn write(&mut self, frame: &PesFrame) -> io::Result<()> { - let pts = self.timeline.rebase(frame.track, frame.pts); + // Track 0 (primary video) drives epoch decisions; every other track is a + // passive rider on the same global offset — see `TimelineContinuity`. + let pts = self.timeline.adjust(frame.pts, frame.track == 0); if let Some(Some(t)) = self.tracks.get_mut(frame.track) { t.first_pts_ns.get_or_insert(pts); t.writer.write_frame(&mut t.w, frame, pts)?; @@ -885,51 +857,38 @@ mod tests { } // ── Annex-B reframing ──────────────────────────────────────────────────── + // + // The length-prefixed → Annex-B conversion and the hvcC/avcC param-set + // extraction are exercised canonically in `crate::mux::hevc`; the sink + // delegates to those helpers. Here we only assert the sink-level wiring: + // param-set prepend and (crucially) that a zero-length NAL mid-frame no + // longer truncates the rest of the access unit. #[test] - fn length_prefixed_converts_to_annexb() { - // Two NALs: lengths 2 and 3. - let data = [0, 0, 0, 2, 0xAA, 0xBB, 0, 0, 0, 3, 0x01, 0x02, 0x03]; + fn zero_length_nal_midframe_does_not_truncate_access_unit() { + // The OLD local reframer `break`d on a zero-length NAL, dropping every + // NAL after it. The canonical `append_length_prefixed_as_annex_b` skips + // just the empty NAL and keeps going. Frame: NAL(2) | NAL(0) | NAL(3). + let mut w = AnnexBWriter::new(Codec::H264, None); let mut out = Vec::new(); - let n = length_prefixed_to_annexb(&data, &mut out).unwrap(); + let f = PesFrame { + track: 0, + pts: 0, + keyframe: true, + data: vec![ + 0, 0, 0, 2, 0xAA, 0xBB, // NAL #1 (len 2) + 0, 0, 0, 0, // zero-length NAL — must be skipped, not fatal + 0, 0, 0, 3, 0x01, 0x02, 0x03, // NAL #3 (len 3) — must survive + ], + duration_ns: None, + }; + w.write_frame(&mut out, &f, 0).unwrap(); + // Both real NALs present; the empty NAL emitted nothing. assert_eq!( out, - vec![0, 0, 0, 1, 0xAA, 0xBB, 0, 0, 0, 1, 0x01, 0x02, 0x03] + vec![0, 0, 0, 1, 0xAA, 0xBB, 0, 0, 0, 1, 0x01, 0x02, 0x03], + "trailing NAL after a zero-length NAL must NOT be dropped" ); - assert_eq!(n, out.len()); - } - - #[test] - fn length_prefixed_stops_on_truncation() { - // Declares length 5 but only 2 bytes follow → drop the bad tail. - let data = [0, 0, 0, 5, 0xAA, 0xBB]; - let mut out = Vec::new(); - length_prefixed_to_annexb(&data, &mut out).unwrap(); - assert!(out.is_empty()); - } - - #[test] - fn avcc_param_sets_extracted_as_annexb() { - // Minimal avcC: header(5) numSPS=1 spsLen=2 SPS=[0x67,0x42] numPPS=1 - // ppsLen=1 PPS=[0x68]. - let rec = [ - 1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68, - ]; - let blob = avcc_param_sets(&rec); - assert_eq!(blob, vec![0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68]); - } - - #[test] - fn hvcc_param_sets_extracted_as_annexb() { - // hvcC: 22-byte header (we only need byte 22 = numArrays), then arrays. - let mut rec = vec![0u8; 22]; - rec.push(2); // numArrays - // Array 1: type byte, numNalus=1, len=2, NAL=[0x40,0x01] - rec.extend_from_slice(&[0x20, 0, 1, 0, 2, 0x40, 0x01]); - // Array 2: type byte, numNalus=1, len=1, NAL=[0x42] - rec.extend_from_slice(&[0x21, 0, 1, 0, 1, 0x42]); - let blob = hvcc_param_sets(&rec); - assert_eq!(blob, vec![0, 0, 0, 1, 0x40, 0x01, 0, 0, 0, 1, 0x42]); } #[test] @@ -1020,14 +979,86 @@ mod tests { #[test] fn pgs_sup_frames_each_segment_with_pg_header() { // One segment: type=0x16, size=2, payload=[0xDE,0xAD]. - let payload = [0x16, 0x00, 0x02, 0xDE, 0xAD]; + let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD]; let mut out = Vec::new(); let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap(); - assert_eq!(&out[0..2], b"PG"); + assert_eq!(&out[0..2], &SUP_MAGIC); assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS - assert_eq!(&out[10..], &payload); // segment body verbatim - assert_eq!(written, 13 + 2); + assert_eq!(&out[SUP_HEADER_LEN..], &payload); // segment body verbatim + assert_eq!(written, SUP_HEADER_LEN + 2); + } + + #[test] + fn pgs_frame_with_duration_emits_clear_segment() { + // A display set with a real PCS (type 0x16) carrying 1920x1080, and a + // duration → the writer must append a synthetic clear display set + // (empty PCS + END) timestamped at pts + duration. + let mut pcs = vec![SEG_PCS, 0x00, 0x0B]; + pcs.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080 + pcs.extend_from_slice(&[0x10, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01]); // 1 object + let f = PesFrame { + track: 0, + pts: 1_000_000_000, // 1s + keyframe: true, + data: pcs, + duration_ns: Some(2_000_000_000), // 2s display → clear at 3s + }; + let mut out = Vec::new(); + let mut w = PgsSupWriter; + w.write_frame(&mut out, &f, f.pts).unwrap(); + + // Parse out every PG-framed segment: PG(2) PTS(4) DTS(4) type(1) size(2). + let mut segs: Vec<(u8, u32)> = Vec::new(); + let mut pos = 0; + while pos + SUP_HEADER_LEN <= out.len() { + assert_eq!( + &out[pos..pos + 2], + &SUP_MAGIC, + "each segment carries PG magic" + ); + let pts = u32::from_be_bytes([out[pos + 2], out[pos + 3], out[pos + 4], out[pos + 5]]); + let seg_type = out[pos + SUP_HEADER_LEN]; + let size = + u16::from_be_bytes([out[pos + SUP_HEADER_LEN + 1], out[pos + SUP_HEADER_LEN + 2]]) + as usize; + segs.push((seg_type, pts)); + pos += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size; + } + // Display PCS at 1s (90k), then a clear PCS + END at 3s. + let clear90 = ns_to_90k(3_000_000_000); + assert!( + segs.iter().any(|&(t, p)| t == SEG_PCS && p == clear90), + "a clear PCS must be emitted at pts+duration, got {segs:?}" + ); + assert!( + segs.iter().any(|&(t, p)| t == SEG_END && p == clear90), + "an END segment must terminate the clear display set, got {segs:?}" + ); + } + + #[test] + fn pgs_frame_without_duration_emits_no_clear() { + // No duration → no synthetic clear (the subtitle's wipe time is unknown). + let f = PesFrame { + track: 0, + pts: 0, + keyframe: true, + data: vec![SEG_PCS, 0x00, 0x02, 0xDE, 0xAD], + duration_ns: None, + }; + let mut out = Vec::new(); + let mut w = PgsSupWriter; + w.write_frame(&mut out, &f, 0).unwrap(); + // Exactly one PG-framed segment (the display), no clear appended. + // Output = `.sup` header (10) + the on-wire segment (type+size 3 + 2 + // payload = 5) → 15 bytes, with no trailing clear. + assert_eq!(&out[0..2], &SUP_MAGIC); + assert_eq!( + out.len(), + SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + 2, + "only the display segment, no clear" + ); } #[test] @@ -1044,7 +1075,7 @@ mod tests { fn vobsub_idx_synthesis() { let dir = tempdir(); let idx = dir.join("sub.idx"); - let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff")); + let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff"), "eng"); let mut sub = Vec::new(); let f1 = PesFrame { track: 0, @@ -1065,6 +1096,11 @@ mod tests { w.finish(&mut sub).unwrap(); let idx_text = std::fs::read_to_string(&idx).unwrap(); assert!(idx_text.contains("palette: 000000, ffffff")); + // The conventional `id:` line mkvmerge reads to assign the language. + assert!( + idx_text.contains("id: en, index: 0"), + "missing id: line, got:\n{idx_text}" + ); assert!(idx_text.contains("timestamp: 00:00:00:000, filepos: 000000000")); // Second SPU at 1s, filepos = 10. assert!(idx_text.contains("timestamp: 00:00:01:000, filepos: 00000000a")); @@ -1100,20 +1136,24 @@ mod tests { assert!(ogm.contains("CHAPTER02NAME=2")); } - // ── Timeline rebase ────────────────────────────────────────────────────── + // ── Timeline continuity ────────────────────────────────────────────────── + // + // The corrector itself is tested verbatim in `crate::mux::timeline`. Here we + // only confirm the sink drives it with the right `drives_epoch`: track 0 is + // the epoch driver, every other track is a passive rider on the same offset. #[test] - fn timeline_rebase_handles_seam_jump() { - let mut tl = TimelineRebase::new(); - // Clip 1: video 0..10s. - assert_eq!(tl.rebase(0, 0), 0); - assert_eq!(tl.rebase(1, 0), 0); // audio rides the same offset - assert_eq!(tl.rebase(0, 10_000_000_000), 10_000_000_000); + 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); // Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch. - let out = tl.rebase(0, 0); + let out = tl.adjust(0, true); assert!(out >= 10_000_000_000, "epoch must advance past prev high"); - // Audio in clip 2 gets the SAME offset (A/V sync preserved). - let a = tl.rebase(1, 0); + // Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved). + let a = tl.adjust(0, false); assert_eq!(a, out); } diff --git a/src/mux/hevc/mod.rs b/src/mux/hevc/mod.rs index cd319a5..0bc444f 100644 --- a/src/mux/hevc/mod.rs +++ b/src/mux/hevc/mod.rs @@ -248,10 +248,105 @@ fn starts_with_start_code(data: &[u8]) -> bool { data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01]) } +/// Convert an `AVCDecoderConfigurationRecord` (avcC) into Annex B NAL +/// units. Returns `Some(bytes)` if at least one NAL was extracted, else +/// `None`. +/// +/// Layout (per ISO/IEC 14496-15 §5.3.3.1.2): +/// - 5-byte fixed header +/// - byte 5 = `[reserved:3 | numOfSequenceParameterSets:5]` +/// - `numOfSPS` × `(sequenceParameterSetLength:u16-BE + SPS bytes)` +/// - 1 byte = `numOfPictureParameterSets` +/// - `numOfPPS` × `(pictureParameterSetLength:u16-BE + PPS bytes)` +/// +/// The H.264 counterpart to [`hvcc_to_annex_b`]: the single source of +/// truth for avcC → Annex B across all muxers (H.264 ES, BD-TS, standard +/// MPEG-TS, the `demux://` sink). Do not reimplement it. +pub(crate) fn avcc_to_annex_b(avcc: &[u8]) -> Option> { + // avcC fixed header is 5 bytes; byte 5 carries the SPS count (low 5 bits), + // then the SPS array begins at byte 6 (ISO/IEC 14496-15 §5.3.3.1.2). + const AVCC_HEADER_LEN: usize = 5; + const NUM_SPS_MASK: u8 = 0x1F; // numOfSequenceParameterSets: low 5 bits + if avcc.len() < AVCC_HEADER_LEN + 1 { + return None; + } + let num_sps = (avcc[AVCC_HEADER_LEN] & NUM_SPS_MASK) as usize; + let mut offset = AVCC_HEADER_LEN + 1; + let mut out = Vec::new(); + + // Extract `count` length-prefixed NALs starting at `*offset` into `out`. + // Returns `false` (truncated) if a length field or NAL body runs past the + // end — the caller then stops, so it never reads further length fields out + // of mid-NAL bytes (mirrors `hvcc_to_annex_b`). + fn take(avcc: &[u8], count: usize, offset: &mut usize, out: &mut Vec) -> bool { + for _ in 0..count { + if *offset + 2 > avcc.len() { + return false; + } + let nal_len = u16::from_be_bytes([avcc[*offset], avcc[*offset + 1]]) as usize; + *offset += 2; + if *offset + nal_len > avcc.len() { + return false; + } + // ISO/IEC 14496-15 disallows zero-length NAL entries; emitting a + // bare start code with no RBSP yields an invalid Annex B NAL. + if nal_len == 0 { + continue; + } + out.extend_from_slice(&START_CODE); + out.extend_from_slice(&avcc[*offset..*offset + nal_len]); + *offset += nal_len; + } + true + } + + let sps_ok = take(avcc, num_sps, &mut offset, &mut out); + if sps_ok && offset < avcc.len() { + let num_pps = avcc[offset] as usize; + offset += 1; + take(avcc, num_pps, &mut offset, &mut out); + } + + if out.is_empty() { None } else { Some(out) } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn avcc_extracts_sps_and_pps() { + // header(5) numSPS=1 spsLen=2 SPS=[0x67,0x42] numPPS=1 ppsLen=1 + // PPS=[0x68]. + let avcc = [ + 1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68, + ]; + let out = avcc_to_annex_b(&avcc).expect("SPS+PPS"); + assert_eq!(out, vec![0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68]); + } + + #[test] + fn avcc_too_short_is_none() { + assert!(avcc_to_annex_b(&[]).is_none()); + assert!(avcc_to_annex_b(&[1, 0x42, 0, 0x1F, 0xFF]).is_none()); + } + + #[test] + fn avcc_truncated_sps_stops_cleanly() { + // numSPS=1, declares spsLen=5 but only 2 bytes follow → drop it, and + // do NOT misread the trailing bytes as a PPS count. + let avcc = [1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 5, 0xAA, 0xBB]; + assert!(avcc_to_annex_b(&avcc).is_none()); + } + + #[test] + fn avcc_skips_zero_length_nal() { + // numSPS=1 spsLen=0 (skipped) numPPS=1 ppsLen=1 PPS=[0x68]. + let avcc = [1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 0, 1, 0, 1, 0x68]; + let out = avcc_to_annex_b(&avcc).expect("just the PPS"); + assert_eq!(out, vec![0, 0, 0, 1, 0x68]); + } + #[test] fn length_prefixed_converts_to_annex_b() { // Two NALs: [3-byte payload AA BB CC] and [2-byte payload DD EE]. diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index b18688a..ad4dff5 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -5,6 +5,7 @@ //! cues and seek head are finalized at the end. use super::ebml; +use super::timeline::TimelineContinuity; use crate::disc::{ AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate, SubtitleStream, VideoStream, @@ -416,156 +417,9 @@ const MAX_BLOCK_REL: i64 = i16::MAX as i64; /// Minimum block-relative timestamp expressible in the signed 16-bit field. const MIN_BLOCK_REL: i64 = i16::MIN as i64; -/// A backward PTS step larger than this is treated as a clip-boundary -/// discontinuity (a non-seamless BD clip / dual-layer-break where the source -/// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out -/// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate -/// reorder window and far below any real clip's duration, so it never -/// false-triggers within a clip. -const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; -/// Sub-frame gap inserted after a rebased discontinuity so the first frame of -/// the new clip lands strictly after the previous timeline high (1 ms). -const DISCONTINUITY_GAP_NS: i64 = 1_000_000; - -/// 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 -/// PTS jumps backward. Left uncorrected, that produces a sustained band of -/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS). -/// -/// A single running `offset_ns` is applied to EVERY track, so the concatenated -/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a -/// boundary shift by the same amount). It is global, not per-track: a clip -/// boundary resets every stream together by the same delta. -/// -/// **Only the VIDEO track drives epoch decisions.** A title carries one video -/// track plus many interleaved audio + subtitle tracks (Top Gun UHD: 2 video, -/// 11 audio, 32 PGS). Those non-video tracks are sparse and lag the video by -/// seconds, so their raw PTS swing well over the 3 s discontinuity threshold -/// against a shared frontier even within a SINGLE clip — a late subtitle PTS -/// would ratchet `high_ns` up, then the next normal video frame would sit >3 s -/// below it and be misread as a clip boundary, permanently bumping `offset_ns`. -/// That false-positive ratchet (firing thousands of times on a one-clip title) -/// inflated Top Gun's cluster/Cue timestamps into the billions of ms and -/// destroyed its seek index. The clip-boundary INFERENCE is therefore keyed on -/// video PTS alone: video establishes and advances the frontier and is the only -/// track that can open a new epoch. Non-video frames are remapped under the -/// CURRENT offset and never touch the frontier or the offset — they ride the -/// timeline the video defines, preserving A/V sync (all tracks at a boundary -/// shift by the same delta) without ever triggering a rebase themselves. -/// -/// The demuxer interleaves the tracks, so at a real (multi-clip) boundary the -/// streams do NOT all reset on the same frame — a lagging audio/PGS frame from -/// the just-ended clip's tail can arrive AFTER the next clip's video has already -/// reset the epoch. Such a "straggler" carries an old-epoch raw PTS; adding the -/// new (clip-sized) offset to it would fling it far past the frontier and force -/// a forward-dated split cluster. A non-video frame whose mapped position lands -/// more than a backstep past the frontier is therefore clamped to the frontier -/// (the seam) — it never perturbs the offset or the frontier and never -/// forward-dates a cluster. Genuine multi-clip seamless rebasing (the design -/// that is correct for real HEVC/H.264 multi-clip titles) is preserved: it is -/// the video back-jump that opens a new epoch, exactly as before. -struct TimelineContinuity { - /// Offset (ns) added to raw PTS for the CURRENT epoch. - offset_ns: i64, - /// Offset (ns) of the immediately previous epoch — used to recognise and - /// remap a non-video tail straggler at a boundary (an old-epoch frame whose - /// current-offset mapping flies forward but whose previous-offset mapping - /// lands at the seam). Equals `offset_ns` until the first boundary. - prev_offset_ns: i64, - /// Highest adjusted VIDEO PTS (ns) accepted onto the timeline so far — the - /// running frontier. `None` until the first video frame. Only video advances - /// it; non-video tracks never touch it. - high_ns: Option, -} - -impl TimelineContinuity { - fn new() -> Self { - Self { - offset_ns: 0, - prev_offset_ns: 0, - high_ns: None, - } - } - - /// Map a raw PES PTS (ns) onto the continuous output timeline. - /// - /// `drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY - /// video track (base layer, track 0) ONLY. Every other track — audio, PGS - /// subtitle, and a second video track such as a Dolby Vision enhancement - /// layer — passes `false` and is a passive rider. (The DV EL is video but - /// runs its own PTS timeline interleaved with the base layer's; letting it - /// drive epochs would false-trigger a reset on every GOP.) - /// - /// **Passive tracks** (`drives_epoch == false`). Always remapped under the - /// CURRENT offset. They never advance `high_ns`, never trigger a clip-boundary - /// reset, and never bump `offset_ns`. This is what kills the single-clip - /// ratchet: a sparse/lagging subtitle/audio PTS, or an interleaved EL frame, - /// can no longer push the frontier up and make the next base-video frame look - /// like a boundary. A/V sync is preserved because the offset they ride is the - /// same one the base video established for the epoch. - /// - /// **Primary video** (`drives_epoch == true`): - /// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier = - /// clip-boundary reset: open a new epoch (bump the offset so this frame - /// continues just after the frontier). This is the genuine multi-clip - /// seamless rebasing, now driven only by real base-video back-jumps. - /// - **Everything else** (normal progression + sub-threshold B-frame reorder - /// dips) passes through with the current offset and advances the frontier, - /// preserving PTS. - fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> 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); - // 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 - // OLD-epoch raw PTS. Adding the NEW offset flings it ~a clip past the - // frontier and would force a forward-dated split cluster (breaking - // cluster monotonicity). Such a straggler is recognised precisely: its - // current-offset mapping lands more than a backstep PAST the frontier - // AND its PREVIOUS-offset mapping lands at/below the frontier (i.e. it - // belongs to the prior epoch). Remap it with the previous offset so - // it lands at its true seam position. This is what distinguishes a - // tail straggler from a frame that legitimately runs ahead of the - // (base-video-only) frontier — a long audio-only tail, a sparse - // subtitle, or an EL frame — which is left on the current offset. - if let Some(high) = self.high_ns { - if mapped > high + DISCONTINUITY_BACKSTEP_NS { - let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns); - if prev_mapped <= high { - return prev_mapped; - } - } - } - return mapped; - } - - let Some(high) = self.high_ns else { - let adj = raw_pts_ns.saturating_add(self.offset_ns); - self.high_ns = Some(adj); - return adj; - }; - let adj = raw_pts_ns.saturating_add(self.offset_ns); - if adj < high - DISCONTINUITY_BACKSTEP_NS { - // Clip-boundary reset (real multi-clip seam): continue just after the - // frontier. Save the previous offset so a lagging non-video tail - // frame can be recognised and remapped to the seam (see above). - self.prev_offset_ns = self.offset_ns; - let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS); - self.offset_ns = self.offset_ns.saturating_add(bump); - let adj2 = raw_pts_ns.saturating_add(self.offset_ns); - self.high_ns = Some(high.max(adj2)); - adj2 - } else { - // Normal progression / sub-threshold B-frame reorder: keep true PTS. - self.high_ns = Some(high.max(adj)); - adj - } - } -} +// The clip-boundary timeline-continuity corrector (`TimelineContinuity`) lives +// in `crate::mux::timeline` — shared verbatim with the `demux://` sink. It is +// imported below where the muxer uses it. /// Force a per-track block timestamp (in TimestampScale ticks) to be strictly /// later than the previous one written for that track. `prev` is the last @@ -1648,264 +1502,6 @@ mod tests { assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000); } - // ── Clip-boundary timeline-continuity (PTS discontinuity rebasing) ── - - const S: i64 = 1_000_000_000; // 1 second in ns - - // 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) - } - fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 { - tc.adjust(p, false) - } - - /// Characterization of the BUG: a BD title's two clips concatenated with a - /// PTS reset at the boundary. WITHOUT correction the raw VIDEO timeline goes - /// hard backward at clip 2 (what produced the non-monotonic-DTS band on - /// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and - /// continuous across the boundary. The boundary is driven by VIDEO. - #[test] - fn continuity_rebases_clip_boundary_reset() { - // Clip1 video rising to 10s, then clip2 RESETS near 0 — non-seamless. - let clip1: Vec = (0..=10).map(|i| i * S).collect(); // 0..10s - let clip2: Vec = (0..=10).map(|i| i * S).collect(); // resets to 0..10s - let raw: Vec = clip1.iter().chain(clip2.iter()).copied().collect(); - - // Uncorrected (the bug): the sequence is NOT monotonic — clip2's first - // frame (0) is 10s below clip1's last (10s). - assert!( - raw.windows(2).any(|w| w[1] < w[0]), - "precondition: raw clip-reset sequence is non-monotonic" - ); - - // Corrected: strictly non-decreasing, and clip2 continues AFTER clip1. - let mut tc = TimelineContinuity::new(); - let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); - assert!( - out.windows(2).all(|w| w[1] >= w[0]), - "corrected timeline must be monotonic non-decreasing, got {out:?}" - ); - // Clip2's first frame lands just after clip1's last (10s) + the gap. - assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS); - // Clip2's last frame is offset by the whole of clip1, not back near 0. - assert!(out[21] > 19 * S); - } - - /// Regression guard: NORMAL B-frame reorder (a small backward dip, well - /// under the discontinuity threshold) on VIDEO must pass through UNCHANGED. - #[test] - fn continuity_preserves_bframe_reorder() { - let mut tc = TimelineContinuity::new(); - // I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2 - // frames (~83ms), far under the 3s threshold. - let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000]; - let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); - assert_eq!(out, raw, "B-frame reorder must pass through unchanged"); - assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder"); - } - - /// A legitimate FORWARD gap (a real timing gap within a clip) on VIDEO must - /// be PRESERVED, not clamped — only backward video clip-boundary jumps are - /// rebased. - #[test] - fn continuity_preserves_forward_gap() { - let mut tc = TimelineContinuity::new(); - let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream - let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); - assert_eq!(out, raw, "forward gap preserved verbatim"); - assert_eq!(tc.offset_ns, 0, "no rebase on forward progression"); - } - - /// 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 Top Gun's seek index: with a - /// shared frontier, a late subtitle PTS ratcheted the frontier up, then the - /// next normal video frame sat >3s below it and was misread as a clip - /// boundary, permanently bumping the offset — thousands of times, until the - /// Cue/cluster timestamps inflated into the billions of ms. - /// - /// Correct behaviour: non-video frames ride the current offset and NEVER - /// touch the frontier or the offset, so no amount of subtitle/audio lag can - /// trigger a rebase on a one-clip title. - #[test] - fn single_clip_late_subtitle_does_not_inflate_offset() { - let mut tc = TimelineContinuity::new(); - // One continuous clip: video advances steadily 0..60s. - // Interleaved, a subtitle track is sparse — it emits a cue at 0s, then - // nothing for a long stretch, then a late cue, then jumps around. Each - // subtitle PTS swings many seconds against the video frontier. - // Drive a realistic interleave. - let mut max_out = i64::MIN; - for sec in 0..=60 { - // Video frame every second. - let v = adj_video(&mut tc, sec * S); - max_out = max_out.max(v); - // Every 7th second, a subtitle appears whose raw PTS lags the video - // frontier by ~5s (a late display-set delivered by the interleaver) - // — far more than the 3s discontinuity threshold. - if sec % 7 == 0 && sec >= 7 { - let sub_raw = (sec - 5) * S; - let s = adj_other(&mut tc, sub_raw); - // The subtitle maps under the current (zero) offset, near its - // true time — it does NOT fling the timeline forward. - assert_eq!(s, sub_raw, "subtitle rides the current offset"); - } - } - // The crux: a single-clip title must NEVER open an epoch. Offset stays 0 - // and the timeline never inflates. - assert_eq!( - tc.offset_ns, 0, - "single-clip interleave must not ratchet offset (was {})", - tc.offset_ns - ); - // And the video frontier is exactly 60s — not billions. - assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks video only"); - assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); - } - - /// PRIMARY rc3 regression (Dolby Vision dual-layer): a SECOND video track — - /// the DV enhancement layer — runs its OWN PTS timeline interleaved with the - /// base layer's, so the two video PTS sequences OVERLAP. The EL must be a - /// PASSIVE rider (drives_epoch == false): if it drove epochs, every EL GOP - /// would look like a multi-second backward jump against the base-layer - /// frontier and false-trigger a clip-boundary reset — the exact ratchet that - /// inflated Top Gun's 1-clip 1h49m timeline to ~7 h. Here the base layer - /// advances 0..60s while the EL re-emits the SAME 0..60s interleaved; the - /// timeline must stay at 60s with offset 0. - #[test] - fn dv_enhancement_layer_does_not_drive_epochs() { - let mut tc = TimelineContinuity::new(); - let mut max_out = i64::MIN; - for sec in 0..=60 { - // Base layer (track 0) drives the epoch. - let bl = adj_video(&mut tc, sec * S); - // EL (track 1) re-emits the same time — a passive rider. Its raw PTS - // equals the base layer's, but it arrives just AFTER the base frame - // for the NEXT second sometimes; simulate the overlap by feeding the - // PREVIOUS second's time, which is a backward swing vs the frontier. - let el_raw = if sec > 0 { (sec - 1) * S } else { 0 }; - let el = adj_other(&mut tc, el_raw); - assert_eq!(el, el_raw, "EL rides current offset, true PTS preserved"); - max_out = max_out.max(bl).max(el); - } - assert_eq!( - tc.offset_ns, 0, - "DV EL interleave must not ratchet offset (was {})", - tc.offset_ns - ); - assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks base video only"); - assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); - } - - /// Companion: a non-video frame must never ADVANCE the frontier. Even a - /// non-video PTS far ABOVE the current video frontier (a subtitle/audio - /// timestamp that leads the video momentarily) leaves `high_ns` untouched, - /// so a subsequent normal video frame is not misread as a boundary. - #[test] - fn non_video_never_advances_frontier() { - let mut tc = TimelineContinuity::new(); - adj_video(&mut tc, 0); - adj_video(&mut tc, 5 * S); - let frontier = tc.high_ns.unwrap(); - // A subtitle leading the video by 20s. - let s = adj_other(&mut tc, 25 * S); - assert_eq!(s, 25 * S, "non-video maps under current offset"); - assert_eq!( - tc.high_ns.unwrap(), - frontier, - "non-video must NOT advance the frontier" - ); - // The next normal video frame (6s) is well below 25s but is NOT treated - // as a boundary, because the frontier is still 5s (video-only). - let v = adj_video(&mut tc, 6 * S); - assert_eq!(v, 6 * S, "video continues normally, no false boundary"); - assert_eq!( - tc.offset_ns, 0, - "no rebase triggered by the leading subtitle" - ); - } - - /// Regression for the original Top Gun band: a LARGE, real-magnitude - /// clip-boundary back-jump on VIDEO (clip 1 ≈ 13 min, clip 2 resets to 0) - /// must STILL be rebased to one continuous monotonic timeline — the genuine - /// multi-clip seamless behaviour is preserved, now keyed on real video - /// back-jumps. - #[test] - fn continuity_large_clip_boundary_backjump_rebased() { - let mut tc = TimelineContinuity::new(); - // Clip 1: 0 .. 780s (13 min) at 1s steps. - let clip1: Vec = (0..=780).map(|i| i * S).collect(); - // Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity. - let clip2: Vec = (0..=120).map(|i| i * S).collect(); - let mut last = i64::MIN; - let mut max = i64::MIN; - for &p in clip1.iter().chain(clip2.iter()) { - let a = adj_video(&mut tc, p); - assert!( - a >= last, - "rebased timeline must be monotonic, got {a} < {last}" - ); - last = a; - max = max.max(a); - } - // Offset ≈ the whole of clip 1 (one boundary, no ratchet). - assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS); - // Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past - // the boundary — not capped at it, and not ratcheted far beyond. - assert!( - (900 * S..901 * S).contains(&max), - "timeline must span ~900s (clip1+clip2), got {max}" - ); - } - - /// At a REAL video-driven boundary, a lagging NON-VIDEO tail frame from the - /// just-ended clip (an old-epoch raw PTS arriving interleaved after the - /// reset) must be REMAPPED to its true seam position with the PREVIOUS - /// offset — not flung ~a clip past the frontier by the freshly-bumped - /// offset. Otherwise it would force a forward-dated split cluster and break - /// cluster monotonicity. - #[test] - fn non_video_straggler_remapped_to_seam_at_boundary() { - let mut tc = TimelineContinuity::new(); - // Clip1 video rises to 600s. - for i in 0..=600 { - adj_video(&mut tc, i * S); - } - let frontier = tc.high_ns.unwrap(); - assert_eq!(frontier, 600 * S); - // Clip2 video resets to 0 → boundary, offset bumps by ~600s. - let c2 = adj_video(&mut tc, 0); - assert_eq!(c2, 600 * S + DISCONTINUITY_GAP_NS); - // Straggler: clip1's tail audio (raw 599.5s) arrives now. Under the new - // offset it would map to ~1199.5s; it must instead remap with the - // previous (zero) offset to its true seam position 599.5s. - let straggler_raw = 599 * S + 500_000_000; - let straggler = adj_other(&mut tc, straggler_raw); - assert_eq!( - straggler, straggler_raw, - "straggler must remap to its seam position via the previous offset" - ); - assert!( - straggler <= frontier, - "straggler must land at/below the frontier, got {straggler}" - ); - // It must NOT have perturbed the offset or the frontier. - assert_eq!( - tc.high_ns.unwrap(), - c2, - "straggler must not move the frontier" - ); - // A NORMAL clip2 audio frame (raw ~1s, current epoch) is NOT remapped — - // it rides the new offset to ~601s, just past the frontier but within a - // backstep (its previous-offset mapping ~1s is below the frontier but the - // current-offset mapping is not a backstep past it, so it is not treated - // as a straggler). - let normal = adj_other(&mut tc, S); - assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS); - } - /// End-to-end output regression (the symptom, at the block-timecode level): /// a large clip-boundary reset WITH an interleaved straggler audio frame /// from clip 1's tail, driven through the full muxer. Asserts cluster diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 4ed8fa2..2e80e9e 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -88,6 +88,9 @@ pub(crate) mod network; pub(crate) mod null; pub(crate) mod ps; pub(crate) mod stdio; +/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer +/// and the `demux://` sink). +pub(crate) mod timeline; pub(crate) mod ts; pub(crate) mod tsmux; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 6e30cf0..23fb7fb 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -466,10 +466,17 @@ pub fn output( // `output()` call with the default option set. StreamUrl::Demux { ref dir } => { validate_file_path(dir, "demux")?; + // The full `--demux/--naming/--delay/--container/--chapters` flag + // surface is parsed in the CLI, which constructs `DemuxSink` directly. + // This bare `output()` arm uses defaults but still seeds the filename + // `base` from the title's playlist name when present (the default + // "title" stem is only a last resort for an unnamed title). + let mut opts = super::demux_sink::DemuxOptions::default(); + if !title.playlist.is_empty() { + opts.base = title.playlist.clone(); + } Ok(Box::new(super::demux_sink::DemuxSink::create( - dir, - title, - &super::demux_sink::DemuxOptions::default(), + dir, title, &opts, )?)) } StreamUrl::Unknown { ref raw } => { diff --git a/src/mux/timeline.rs b/src/mux/timeline.rs new file mode 100644 index 0000000..64bf0f1 --- /dev/null +++ b/src/mux/timeline.rs @@ -0,0 +1,421 @@ +//! Shared clip-boundary timeline-continuity corrector. +//! +//! A BD/UHD title's clips are read 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 PTS jumps backward. Left uncorrected, +//! that produces a sustained band of non-monotonic block timestamps. Every +//! muxer/sink that consumes the interleaved per-track PES stream and emits a +//! monotonic timeline (the MKV muxer, the `demux://` elementary-stream sink) +//! uses [`TimelineContinuity`] so the correction lives in exactly one place. + +/// A backward PTS step larger than this is treated as a clip-boundary +/// discontinuity (a non-seamless BD clip / dual-layer-break where the source +/// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out +/// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate +/// reorder window and far below any real clip's duration, so it never +/// false-triggers within a clip. +pub(crate) const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; +/// Sub-frame gap inserted after a rebased discontinuity so the first frame of +/// the new clip lands strictly after the previous timeline high (1 ms). +pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000; + +/// 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 +/// PTS jumps backward. Left uncorrected, that produces a sustained band of +/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS). +/// +/// A single running `offset_ns` is applied to EVERY track, so the concatenated +/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a +/// boundary shift by the same amount). It is global, not per-track: a clip +/// boundary resets every stream together by the same delta. +/// +/// **Only the VIDEO track drives epoch decisions.** A title carries one video +/// track plus many interleaved audio + subtitle tracks (Top Gun UHD: 2 video, +/// 11 audio, 32 PGS). Those non-video tracks are sparse and lag the video by +/// seconds, so their raw PTS swing well over the 3 s discontinuity threshold +/// against a shared frontier even within a SINGLE clip — a late subtitle PTS +/// would ratchet `high_ns` up, then the next normal video frame would sit >3 s +/// below it and be misread as a clip boundary, permanently bumping `offset_ns`. +/// That false-positive ratchet (firing thousands of times on a one-clip title) +/// inflated Top Gun's cluster/Cue timestamps into the billions of ms and +/// destroyed its seek index. The clip-boundary INFERENCE is therefore keyed on +/// video PTS alone: video establishes and advances the frontier and is the only +/// track that can open a new epoch. Non-video frames are remapped under the +/// CURRENT offset and never touch the frontier or the offset — they ride the +/// timeline the video defines, preserving A/V sync (all tracks at a boundary +/// shift by the same delta) without ever triggering a rebase themselves. +/// +/// The demuxer interleaves the tracks, so at a real (multi-clip) boundary the +/// streams do NOT all reset on the same frame — a lagging audio/PGS frame from +/// the just-ended clip's tail can arrive AFTER the next clip's video has already +/// reset the epoch. Such a "straggler" carries an old-epoch raw PTS; adding the +/// new (clip-sized) offset to it would fling it far past the frontier and force +/// a forward-dated split cluster. A non-video frame whose mapped position lands +/// more than a backstep past the frontier is therefore clamped to the frontier +/// (the seam) — it never perturbs the offset or the frontier and never +/// forward-dates a cluster. Genuine multi-clip seamless rebasing (the design +/// that is correct for real HEVC/H.264 multi-clip titles) is preserved: it is +/// the video back-jump that opens a new epoch, exactly as before. +pub(crate) struct TimelineContinuity { + /// Offset (ns) added to raw PTS for the CURRENT epoch. + pub(crate) offset_ns: i64, + /// Offset (ns) of the immediately previous epoch — used to recognise and + /// remap a non-video tail straggler at a boundary (an old-epoch frame whose + /// current-offset mapping flies forward but whose previous-offset mapping + /// lands at the seam). Equals `offset_ns` until the first boundary. + pub(crate) prev_offset_ns: i64, + /// Highest adjusted VIDEO PTS (ns) accepted onto the timeline so far — the + /// running frontier. `None` until the first video frame. Only video advances + /// it; non-video tracks never touch it. + pub(crate) high_ns: Option, +} + +impl TimelineContinuity { + pub(crate) fn new() -> Self { + Self { + offset_ns: 0, + prev_offset_ns: 0, + high_ns: None, + } + } + + /// Map a raw PES PTS (ns) onto the continuous output timeline. + /// + /// `drives_epoch` gates EVERY epoch decision. It is `true` for the PRIMARY + /// video track (base layer, track 0) ONLY. Every other track — audio, PGS + /// subtitle, and a second video track such as a Dolby Vision enhancement + /// layer — passes `false` and is a passive rider. (The DV EL is video but + /// runs its own PTS timeline interleaved with the base layer's; letting it + /// drive epochs would false-trigger a reset on every GOP.) + /// + /// **Passive tracks** (`drives_epoch == false`). Always remapped under the + /// CURRENT offset. They never advance `high_ns`, never trigger a clip-boundary + /// reset, and never bump `offset_ns`. This is what kills the single-clip + /// ratchet: a sparse/lagging subtitle/audio PTS, or an interleaved EL frame, + /// can no longer push the frontier up and make the next base-video frame look + /// like a boundary. A/V sync is preserved because the offset they ride is the + /// same one the base video established for the epoch. + /// + /// **Primary video** (`drives_epoch == true`): + /// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier = + /// clip-boundary reset: open a new epoch (bump the offset so this frame + /// continues just after the frontier). This is the genuine multi-clip + /// seamless rebasing, now driven only by real base-video back-jumps. + /// - **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 { + // 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); + // 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 + // OLD-epoch raw PTS. Adding the NEW offset flings it ~a clip past the + // frontier and would force a forward-dated split cluster (breaking + // cluster monotonicity). Such a straggler is recognised precisely: its + // current-offset mapping lands more than a backstep PAST the frontier + // AND its PREVIOUS-offset mapping lands at/below the frontier (i.e. it + // belongs to the prior epoch). Remap it with the previous offset so + // it lands at its true seam position. This is what distinguishes a + // tail straggler from a frame that legitimately runs ahead of the + // (base-video-only) frontier — a long audio-only tail, a sparse + // subtitle, or an EL frame — which is left on the current offset. + if let Some(high) = self.high_ns { + if mapped > high + DISCONTINUITY_BACKSTEP_NS { + let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns); + if prev_mapped <= high { + return prev_mapped; + } + } + } + return mapped; + } + + let Some(high) = self.high_ns else { + let adj = raw_pts_ns.saturating_add(self.offset_ns); + self.high_ns = Some(adj); + return adj; + }; + let adj = raw_pts_ns.saturating_add(self.offset_ns); + if adj < high - DISCONTINUITY_BACKSTEP_NS { + // Clip-boundary reset (real multi-clip seam): continue just after the + // frontier. Save the previous offset so a lagging non-video tail + // frame can be recognised and remapped to the seam (see above). + self.prev_offset_ns = self.offset_ns; + let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS); + self.offset_ns = self.offset_ns.saturating_add(bump); + let adj2 = raw_pts_ns.saturating_add(self.offset_ns); + self.high_ns = Some(high.max(adj2)); + adj2 + } else { + // Normal progression / sub-threshold B-frame reorder: keep true PTS. + self.high_ns = Some(high.max(adj)); + adj + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const S: i64 = 1_000_000_000; // 1 second in ns + + // 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) + } + fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 { + tc.adjust(p, false) + } + + /// Characterization of the BUG: a BD title's two clips concatenated with a + /// PTS reset at the boundary. WITHOUT correction the raw VIDEO timeline goes + /// hard backward at clip 2 (what produced the non-monotonic-DTS band on + /// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and + /// continuous across the boundary. The boundary is driven by VIDEO. + #[test] + fn continuity_rebases_clip_boundary_reset() { + // Clip1 video rising to 10s, then clip2 RESETS near 0 — non-seamless. + let clip1: Vec = (0..=10).map(|i| i * S).collect(); // 0..10s + let clip2: Vec = (0..=10).map(|i| i * S).collect(); // resets to 0..10s + let raw: Vec = clip1.iter().chain(clip2.iter()).copied().collect(); + + // Uncorrected (the bug): the sequence is NOT monotonic — clip2's first + // frame (0) is 10s below clip1's last (10s). + assert!( + raw.windows(2).any(|w| w[1] < w[0]), + "precondition: raw clip-reset sequence is non-monotonic" + ); + + // Corrected: strictly non-decreasing, and clip2 continues AFTER clip1. + let mut tc = TimelineContinuity::new(); + let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); + assert!( + out.windows(2).all(|w| w[1] >= w[0]), + "corrected timeline must be monotonic non-decreasing, got {out:?}" + ); + // Clip2's first frame lands just after clip1's last (10s) + the gap. + assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS); + // Clip2's last frame is offset by the whole of clip1, not back near 0. + assert!(out[21] > 19 * S); + } + + /// Regression guard: NORMAL B-frame reorder (a small backward dip, well + /// under the discontinuity threshold) on VIDEO must pass through UNCHANGED. + #[test] + fn continuity_preserves_bframe_reorder() { + let mut tc = TimelineContinuity::new(); + // I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2 + // frames (~83ms), far under the 3s threshold. + let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000]; + let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); + assert_eq!(out, raw, "B-frame reorder must pass through unchanged"); + assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder"); + } + + /// A legitimate FORWARD gap (a real timing gap within a clip) on VIDEO must + /// be PRESERVED, not clamped — only backward video clip-boundary jumps are + /// rebased. + #[test] + fn continuity_preserves_forward_gap() { + let mut tc = TimelineContinuity::new(); + let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream + let out: Vec = raw.iter().map(|&p| adj_video(&mut tc, p)).collect(); + assert_eq!(out, raw, "forward gap preserved verbatim"); + assert_eq!(tc.offset_ns, 0, "no rebase on forward progression"); + } + + /// 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 Top Gun's seek index: with a + /// shared frontier, a late subtitle PTS ratcheted the frontier up, then the + /// next normal video frame sat >3s below it and was misread as a clip + /// boundary, permanently bumping the offset — thousands of times, until the + /// Cue/cluster timestamps inflated into the billions of ms. + /// + /// Correct behaviour: non-video frames ride the current offset and NEVER + /// touch the frontier or the offset, so no amount of subtitle/audio lag can + /// trigger a rebase on a one-clip title. + #[test] + fn single_clip_late_subtitle_does_not_inflate_offset() { + let mut tc = TimelineContinuity::new(); + // One continuous clip: video advances steadily 0..60s. + // Interleaved, a subtitle track is sparse — it emits a cue at 0s, then + // nothing for a long stretch, then a late cue, then jumps around. Each + // subtitle PTS swings many seconds against the video frontier. + // Drive a realistic interleave. + let mut max_out = i64::MIN; + for sec in 0..=60 { + // Video frame every second. + let v = adj_video(&mut tc, sec * S); + max_out = max_out.max(v); + // Every 7th second, a subtitle appears whose raw PTS lags the video + // frontier by ~5s (a late display-set delivered by the interleaver) + // — far more than the 3s discontinuity threshold. + if sec % 7 == 0 && sec >= 7 { + let sub_raw = (sec - 5) * S; + let s = adj_other(&mut tc, sub_raw); + // The subtitle maps under the current (zero) offset, near its + // true time — it does NOT fling the timeline forward. + assert_eq!(s, sub_raw, "subtitle rides the current offset"); + } + } + // The crux: a single-clip title must NEVER open an epoch. Offset stays 0 + // and the timeline never inflates. + assert_eq!( + tc.offset_ns, 0, + "single-clip interleave must not ratchet offset (was {})", + tc.offset_ns + ); + // And the video frontier is exactly 60s — not billions. + assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks video only"); + assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); + } + + /// PRIMARY rc3 regression (Dolby Vision dual-layer): a SECOND video track — + /// the DV enhancement layer — runs its OWN PTS timeline interleaved with the + /// base layer's, so the two video PTS sequences OVERLAP. The EL must be a + /// PASSIVE rider (drives_epoch == false): if it drove epochs, every EL GOP + /// would look like a multi-second backward jump against the base-layer + /// frontier and false-trigger a clip-boundary reset — the exact ratchet that + /// inflated Top Gun's 1-clip 1h49m timeline to ~7 h. Here the base layer + /// advances 0..60s while the EL re-emits the SAME 0..60s interleaved; the + /// timeline must stay at 60s with offset 0. + #[test] + fn dv_enhancement_layer_does_not_drive_epochs() { + let mut tc = TimelineContinuity::new(); + let mut max_out = i64::MIN; + for sec in 0..=60 { + // Base layer (track 0) drives the epoch. + let bl = adj_video(&mut tc, sec * S); + // EL (track 1) re-emits the same time — a passive rider. Its raw PTS + // equals the base layer's, but it arrives just AFTER the base frame + // for the NEXT second sometimes; simulate the overlap by feeding the + // PREVIOUS second's time, which is a backward swing vs the frontier. + let el_raw = if sec > 0 { (sec - 1) * S } else { 0 }; + let el = adj_other(&mut tc, el_raw); + assert_eq!(el, el_raw, "EL rides current offset, true PTS preserved"); + max_out = max_out.max(bl).max(el); + } + assert_eq!( + tc.offset_ns, 0, + "DV EL interleave must not ratchet offset (was {})", + tc.offset_ns + ); + assert_eq!(tc.high_ns, Some(60 * S), "frontier tracks base video only"); + assert!(max_out <= 60 * S, "no timeline inflation, max={max_out}"); + } + + /// Companion: a non-video frame must never ADVANCE the frontier. Even a + /// non-video PTS far ABOVE the current video frontier (a subtitle/audio + /// timestamp that leads the video momentarily) leaves `high_ns` untouched, + /// so a subsequent normal video frame is not misread as a boundary. + #[test] + fn non_video_never_advances_frontier() { + let mut tc = TimelineContinuity::new(); + adj_video(&mut tc, 0); + adj_video(&mut tc, 5 * S); + let frontier = tc.high_ns.unwrap(); + // A subtitle leading the video by 20s. + let s = adj_other(&mut tc, 25 * S); + assert_eq!(s, 25 * S, "non-video maps under current offset"); + assert_eq!( + tc.high_ns.unwrap(), + frontier, + "non-video must NOT advance the frontier" + ); + // The next normal video frame (6s) is well below 25s but is NOT treated + // as a boundary, because the frontier is still 5s (video-only). + let v = adj_video(&mut tc, 6 * S); + assert_eq!(v, 6 * S, "video continues normally, no false boundary"); + assert_eq!( + tc.offset_ns, 0, + "no rebase triggered by the leading subtitle" + ); + } + + /// Regression for the original Top Gun band: a LARGE, real-magnitude + /// clip-boundary back-jump on VIDEO (clip 1 ≈ 13 min, clip 2 resets to 0) + /// must STILL be rebased to one continuous monotonic timeline — the genuine + /// multi-clip seamless behaviour is preserved, now keyed on real video + /// back-jumps. + #[test] + fn continuity_large_clip_boundary_backjump_rebased() { + let mut tc = TimelineContinuity::new(); + // Clip 1: 0 .. 780s (13 min) at 1s steps. + let clip1: Vec = (0..=780).map(|i| i * S).collect(); + // Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity. + let clip2: Vec = (0..=120).map(|i| i * S).collect(); + let mut last = i64::MIN; + let mut max = i64::MIN; + for &p in clip1.iter().chain(clip2.iter()) { + let a = adj_video(&mut tc, p); + assert!( + a >= last, + "rebased timeline must be monotonic, got {a} < {last}" + ); + last = a; + max = max.max(a); + } + // Offset ≈ the whole of clip 1 (one boundary, no ratchet). + assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS); + // Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past + // the boundary — not capped at it, and not ratcheted far beyond. + assert!( + (900 * S..901 * S).contains(&max), + "timeline must span ~900s (clip1+clip2), got {max}" + ); + } + + /// At a REAL video-driven boundary, a lagging NON-VIDEO tail frame from the + /// just-ended clip (an old-epoch raw PTS arriving interleaved after the + /// reset) must be REMAPPED to its true seam position with the PREVIOUS + /// offset — not flung ~a clip past the frontier by the freshly-bumped + /// offset. Otherwise it would force a forward-dated split cluster and break + /// cluster monotonicity. + #[test] + fn non_video_straggler_remapped_to_seam_at_boundary() { + let mut tc = TimelineContinuity::new(); + // Clip1 video rises to 600s. + for i in 0..=600 { + adj_video(&mut tc, i * S); + } + let frontier = tc.high_ns.unwrap(); + assert_eq!(frontier, 600 * S); + // Clip2 video resets to 0 → boundary, offset bumps by ~600s. + let c2 = adj_video(&mut tc, 0); + assert_eq!(c2, 600 * S + DISCONTINUITY_GAP_NS); + // Straggler: clip1's tail audio (raw 599.5s) arrives now. Under the new + // offset it would map to ~1199.5s; it must instead remap with the + // previous (zero) offset to its true seam position 599.5s. + let straggler_raw = 599 * S + 500_000_000; + let straggler = adj_other(&mut tc, straggler_raw); + assert_eq!( + straggler, straggler_raw, + "straggler must remap to its seam position via the previous offset" + ); + assert!( + straggler <= frontier, + "straggler must land at/below the frontier, got {straggler}" + ); + // It must NOT have perturbed the offset or the frontier. + assert_eq!( + tc.high_ns.unwrap(), + c2, + "straggler must not move the frontier" + ); + // A NORMAL clip2 audio frame (raw ~1s, current epoch) is NOT remapped — + // it rides the new offset to ~601s, just past the frontier but within a + // backstep (its previous-offset mapping ~1s is below the frontier but the + // current-offset mapping is not a backstep past it, so it is not treated + // as a straggler). + let normal = adj_other(&mut tc, S); + assert_eq!(normal, S + 600 * S + DISCONTINUITY_GAP_NS); + } +}