From e3dbafcebdfa9609ee27180f49035dfc77661f45 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:13:55 -0700 Subject: [PATCH] mux: codec-agnostic PictureInfo + provenance; measure field order, never guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry per-picture truth and byte-exact source provenance THROUGH the stream so the muxer (and the upcoming video index) read MEASURED facts instead of assuming them. Honest data in, honest data out. - codec/coding.rs: codec-agnostic PictureInfo (CodingType / FieldOrder + the accessors field_order/coding_type/nb_fields/progressive/keyframe). Each codec folds its raw signals in; consumers use only accessors, never branch on codec. - mpeg2: builds PictureInfo from the picture coding extension and carries it + SourcePos (source_marks, parallel to pts_marks) on every emitted frame. - pes / codec::Frame: additive `coding` + `source`, forwarded through the highway; None for audio/subtitle and the network/stdio deserialize hop. - mkvstream: DEFER muxer construction until the first coded picture, set the video track's FieldOrder from the MEASURED value, THEN write the header — right the first time, no guess, no seek-back. An interlaced track that arrives with no measured order is LOGGED loudly and left UNDETERMINED, never faked. - mkv: MkvTrack::video no longer guesses TFF (a bitstream property the scan cannot know is UNDETERMINED at build). Removed VideoStream::top_field_first (the dead scan-time guess) crate-wide. - Tests: parser population (every PictureInfo facet + per-PES source carry) and mux-stream consumption (measured -> correct; missing -> UNDETERMINED, not faked). Two obsolete tests updated only after confirming (their own comments) they existed to enforce the deleted hardcoded-TFF. --- src/disc/bluray.rs | 1 - src/disc/dvd.rs | 1 - src/disc/mod.rs | 9 - src/labels/mod.rs | 3 - src/mux/codec/ac3.rs | 17 ++ src/mux/codec/coding.rs | 311 +++++++++++++++++++++++++++++++++++ src/mux/codec/dts.rs | 5 + src/mux/codec/dvdsub.rs | 7 + src/mux/codec/h264.rs | 6 + src/mux/codec/hevc.rs | 5 + src/mux/codec/lpcm.rs | 3 + src/mux/codec/mod.rs | 20 +++ src/mux/codec/mpeg2.rs | 215 +++++++++++++++++++++++- src/mux/codec/pgs.rs | 13 ++ src/mux/codec/truehd.rs | 3 + src/mux/codec/vc1.rs | 6 + src/mux/demux_sink.rs | 21 ++- src/mux/demux_thread.rs | 8 +- src/mux/disc.rs | 4 + src/mux/m2ts.rs | 3 +- src/mux/meta.rs | 3 - src/mux/mkv.rs | 153 ++++++----------- src/mux/mkvstream.rs | 317 +++++++++++++++++++++++++++++++----- src/mux/network.rs | 7 +- src/mux/null.rs | 4 + src/mux/pipelined_stream.rs | 15 +- src/mux/ps.rs | 47 +++++- src/mux/stdio.rs | 3 +- src/mux/ts.rs | 78 ++++++++- src/pes.rs | 74 +++++++++ tests/disc_tests.rs | 1 - tests/streams.rs | 17 +- 32 files changed, 1188 insertions(+), 192 deletions(-) create mode 100644 src/mux/codec/coding.rs diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 7f67b37..54520e4 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -152,7 +152,6 @@ impl Disc { // the measured field order (H.264/HEVC pic_struct, VC-1 // pulldown) from the codec parser instead of the TFF // fallback; needs the parser→title channel (see dvd.rs). - top_field_first: None, // TODO(spec): prefer the HEVC/H.264 VUI colour_description // (measured CICP) over this MPLS playlist-nibble guess // once the parser surfaces it through the output title. diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 68699ac..6476d6c 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -57,7 +57,6 @@ impl Disc { // PipelinedPesStream/DiscStream into the output title (mirroring // the existing `codec_private` handshake). Until then `None` // means the muxer falls back to TFF (correct for ~all DVDs). - top_field_first: None, // TODO(spec): DVD MPEG-2 carries no VUI; the colour signalling is // the sequence_display_extension colour_description when present. // Surface it from `Mpeg2Parser` (same handshake as above) and set diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 2f1b40f..71a539e 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -196,14 +196,6 @@ pub struct VideoStream { pub secondary: bool, /// Extra label (e.g. "Dolby Vision EL") pub label: String, - /// Field-display order MEASURED from the elementary stream's interlace - /// signalling (MPEG-2 picture coding extension `top_field_first`, H.264/HEVC - /// `pic_struct`), when available: `Some(true)` = top-field-first, - /// `Some(false)` = bottom-field-first. `None` = not measured — the muxer - /// falls back to TFF for interlaced content (the dominant DVD/HD case). The - /// container FieldOrder must agree with the bitstream, so a measured BFF - /// stream must NOT be stamped TFF. Ignored for progressive video. - pub top_field_first: Option, /// CICP colour signalling (matrix, transfer, primaries, full_range) MEASURED /// from the bitstream — HEVC/H.264 VUI `colour_description` or MPEG-2 /// `sequence_display_extension`. `Some(...)` takes precedence over the @@ -3805,7 +3797,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })], chapters: Vec::new(), diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 7f6dd8f..015a54e 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -1308,7 +1308,6 @@ mod apply_tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }) } @@ -1762,7 +1761,6 @@ mod apply_tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }); let mut titles = vec![title_with(vec![interlaced])]; @@ -1786,7 +1784,6 @@ mod apply_tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }); let mut titles = vec![title_with(vec![progressive])]; diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 0984de5..4935bd8 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -115,6 +115,8 @@ impl CodecParser for Ac3Parser { let duration_ns = frame_duration_ns(remaining, bsid); frames.push(Frame { + coding: None, + source: None, pts_ns: frame_pts_ns, keyframe: true, data: data[start..start + frame_size].to_vec(), @@ -199,6 +201,8 @@ impl CodecParser for Ac3Parser { } let duration_ns = frame_duration_ns(frame, bsid); vec![Frame { + coding: None, + source: None, pts_ns: self.flush_pts_ns, keyframe: true, data: buf[off..off + frame_size].to_vec(), @@ -441,6 +445,7 @@ mod tests { fn parse_empty_pes() { let mut parser = Ac3Parser::new(); let pes = PesPacket { + source: None, pid: 0, pts: None, dts: None, @@ -454,6 +459,7 @@ mod tests { let mut parser = Ac3Parser::new(); let frame_data = make_ac3_frame(0, 2); // 48kHz, 80 words = 160 bytes let pes = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -472,6 +478,7 @@ mod tests { // First PES: first half of frame let pes1 = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -482,6 +489,7 @@ mod tests { // Second PES: second half let pes2 = PesPacket { + source: None, pid: 0, pts: Some(93000), dts: None, @@ -499,6 +507,7 @@ mod tests { let mut data = vec![0xDE, 0xAD, 0xBE, 0xEF]; // garbage data.extend_from_slice(&frame_data); let pes = PesPacket { + source: None, pid: 0, pts: None, dts: None, @@ -521,6 +530,7 @@ mod tests { let mut pes1_data = frame_data.clone(); pes1_data.push(0x0B); let pes1 = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -533,6 +543,7 @@ mod tests { let mut pes2_data = vec![0x77]; pes2_data.extend_from_slice(&frame_data[2..]); let pes2 = PesPacket { + source: None, pid: 0, pts: Some(93000), dts: None, @@ -558,6 +569,7 @@ mod tests { *data.last_mut().unwrap() = 0x0B; } let pes = PesPacket { + source: None, pid: 0, pts: None, dts: None, @@ -585,6 +597,7 @@ mod tests { let mut parser = Ac3Parser::new(); let data = vec![0x00, 0x00, 0x0B]; let pes = PesPacket { + source: None, pid: 0, pts: None, dts: None, @@ -621,6 +634,7 @@ mod tests { let mut data = frame_data.clone(); data.extend_from_slice(&frame_data[..40]); // partial frame 2 held let pes = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -652,6 +666,7 @@ mod tests { let mut data = frame_data.clone(); data.extend_from_slice(&frame_data); let pes = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -694,6 +709,7 @@ mod tests { let good = make_ac3_frame(0, 2); data.extend_from_slice(&good); let pes = PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, @@ -1152,6 +1168,7 @@ mod tests { // helper: PES with a generic pts for E-AC-3 tests fn make_eac3_pes(data: Vec) -> PesPacket { PesPacket { + source: None, pid: 0, pts: Some(90000), dts: None, diff --git a/src/mux/codec/coding.rs b/src/mux/codec/coding.rs new file mode 100644 index 0000000..a4f674a --- /dev/null +++ b/src/mux/codec/coding.rs @@ -0,0 +1,311 @@ +//! Codec-agnostic per-picture coding carrier. +//! +//! [`PictureInfo`] is the single per-frame carrier of coding signals that the +//! muxer (and any downstream index/diagnostic) reads WITHOUT branching on the +//! codec. Each codec's parser decodes its own bitstream once and folds the raw +//! signals into a [`CodingDetail`] variant; consumers then call ONLY the +//! codec-agnostic accessors ([`coding_type`](PictureInfo::coding_type), +//! [`field_order`](PictureInfo::field_order), [`nb_fields`](PictureInfo::nb_fields), +//! [`progressive`](PictureInfo::progressive)). The accessor surface is fixed: +//! adding a codec means adding a `CodingDetail` arm, never changing a consumer. +//! +//! Spec references: ITU-T H.273 (CICP code points, shared elsewhere), +//! ISO/IEC 13818-2 §6.3.10 (MPEG-2 picture coding extension: `top_field_first`, +//! `repeat_first_field`, `progressive_frame`), RFC 9559 §5.1.4.1.28 +//! (Matroska `FieldOrder` element 0x9D). + +/// Coding/prediction type of a coded picture, mapped to the three families the +/// muxer cares about (cue/keyframe marking, B-frame ordering). Each codec maps +/// its own picture/slice type onto this: +/// - MPEG-2 `picture_coding_type` (ISO/IEC 13818-2 §6.3.8): 1→I, 2→P, 3→B. +/// - H.264/HEVC: slice type / IDR detection → I for intra-coded keyframes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CodingType { + /// Intra-coded (I / IDR) — independently decodable, a cue/keyframe point. + I, + /// Predicted (P) — references earlier pictures. + P, + /// Bi-predicted (B) — references earlier and later pictures. + B, +} + +/// Field display order of an interlaced coded picture, mapped onto the Matroska +/// `FieldOrder` element (RFC 9559 §5.1.4.1.28, element 0x9D). `Progressive` +/// means the picture is not interlaced (the element is omitted by the muxer); +/// `None` from [`PictureInfo::field_order`] means the codec could not determine +/// it (signal absent / not yet decoded). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldOrder { + /// Top field is displayed first (MPEG-2 `top_field_first == 1`). + Tff, + /// Bottom field is displayed first (MPEG-2 `top_field_first == 0`). + Bff, + /// Progressive frame — no field order applies. + Progressive, +} + +/// MPEG-2 picture coding extension signals, decoded once at the parse site. +/// +/// All four bits are read from ISO/IEC 13818-2 §6.3.10 (picture coding +/// extension) and §6.3.5 (sequence extension `progressive_sequence`); this +/// struct is the raw record the agnostic accessors derive from. Consumers do +/// NOT read these fields directly — they go through [`PictureInfo`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Mpeg2Coding { + /// `top_field_first` (picture coding extension). + pub top_field_first: bool, + /// `repeat_first_field` (picture coding extension) — the 2:3 pulldown bit. + pub repeat_first_field: bool, + /// `progressive_frame` (picture coding extension). + pub progressive_frame: bool, + /// `progressive_sequence` (sequence extension) in force for this picture. + pub progressive_sequence: bool, + /// True when this access unit codes a whole frame (`picture_structure == 11`); + /// false for a single field picture (occupies one field period). + pub frame_picture: bool, +} + +/// Per-codec raw coding detail. One arm per codec carrying that codec's own +/// signals; the agnostic accessors on [`PictureInfo`] match on this. Codecs +/// that have not yet had their field/pulldown signals wired carry `None` for +/// `field_order` via the accessor (the arm exists, the bits do not). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CodingDetail { + /// MPEG-2 Video (ISO/IEC 13818-2) picture coding extension signals. + Mpeg2(Mpeg2Coding), + /// A codec that reports coding type but no field/pulldown detail yet + /// (H.264 / HEVC / VC-1). Field order is reported as unknown. + CodingTypeOnly, +} + +/// Codec-agnostic per-picture coding carrier — the single per-frame record the +/// muxer reads through the accessors below. Raw codec signals live in +/// [`CodingDetail`]; consumers MUST use the accessors, never the inner fields. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PictureInfo { + /// Agnostic coding type (I/P/B). Set by every video parser that fills + /// `coding`, derived from the codec's own picture/slice type. + coding_type: CodingType, + /// Raw per-codec coding detail. Holds the bits the field/pulldown + /// accessors derive from. + detail: CodingDetail, +} + +impl PictureInfo { + /// Build a `PictureInfo` for MPEG-2 from its decoded coding type and the + /// picture-coding-extension signals. + pub fn mpeg2(coding_type: CodingType, m: Mpeg2Coding) -> Self { + Self { + coding_type, + detail: CodingDetail::Mpeg2(m), + } + } + + /// Build a `PictureInfo` for a codec that only reports its coding type + /// (no field/pulldown detail decoded yet): H.264, HEVC, VC-1. + pub fn coding_type_only(coding_type: CodingType) -> Self { + Self { + coding_type, + detail: CodingDetail::CodingTypeOnly, + } + } + + /// Agnostic coding type (I/P/B). The single signal for cue/keyframe marking + /// and B-frame display ordering. + pub fn coding_type(&self) -> CodingType { + self.coding_type + } + + /// Field display order for this picture, or `None` when the codec could not + /// determine it (signal absent / not yet wired). MPEG-2: derived from + /// `top_field_first` and the progressive flags (ISO/IEC 13818-2 §6.3.10) — + /// a progressive frame/sequence reports [`FieldOrder::Progressive`]. + pub fn field_order(&self) -> Option { + match self.detail { + CodingDetail::Mpeg2(m) => { + if !m.frame_picture { + // A single field picture is inherently interlaced; the + // top_field_first bit names which field this picture is. + Some(if m.top_field_first { + FieldOrder::Tff + } else { + FieldOrder::Bff + }) + } else if m.progressive_sequence || m.progressive_frame { + Some(FieldOrder::Progressive) + } else if m.top_field_first { + Some(FieldOrder::Tff) + } else { + Some(FieldOrder::Bff) + } + } + CodingDetail::CodingTypeOnly => None, + } + } + + /// Number of field-display periods this picture occupies — the basis for + /// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10, + /// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field, + /// a normal frame 2, a `repeat_first_field` frame 3 (or 4/6 in a progressive + /// sequence). Codecs without pulldown signalling report the normal 2 fields. + pub fn nb_fields(&self) -> u8 { + match self.detail { + CodingDetail::Mpeg2(m) => { + if !m.frame_picture { + return 1; + } + if !m.repeat_first_field { + return 2; + } + if m.progressive_sequence { + if m.top_field_first { 6 } else { 4 } + } else if m.progressive_frame { + 3 + } else { + 2 + } + } + CodingDetail::CodingTypeOnly => 2, + } + } + + /// Whether this picture is progressive, or `None` when the codec did not + /// signal it. MPEG-2: `progressive_sequence || progressive_frame`. + pub fn progressive(&self) -> Option { + match self.detail { + CodingDetail::Mpeg2(m) => Some(m.progressive_sequence || m.progressive_frame), + CodingDetail::CodingTypeOnly => None, + } + } + + /// I-picture ⇒ cue/keyframe point. Convenience over `coding_type()`. + pub fn keyframe(&self) -> bool { + self.coding_type == CodingType::I + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mpeg2( + ct: CodingType, + tff: bool, + rff: bool, + prog_frame: bool, + prog_seq: bool, + frame_pic: bool, + ) -> PictureInfo { + PictureInfo::mpeg2( + ct, + Mpeg2Coding { + top_field_first: tff, + repeat_first_field: rff, + progressive_frame: prog_frame, + progressive_sequence: prog_seq, + frame_picture: frame_pic, + }, + ) + } + + #[test] + fn coding_type_accessor_returns_stored_type() { + assert_eq!( + mpeg2(CodingType::I, true, false, false, false, true).coding_type(), + CodingType::I + ); + assert_eq!( + mpeg2(CodingType::B, true, false, false, false, true).coding_type(), + CodingType::B + ); + } + + #[test] + fn keyframe_only_for_intra() { + assert!(mpeg2(CodingType::I, true, false, false, false, true).keyframe()); + assert!(!mpeg2(CodingType::P, true, false, false, false, true).keyframe()); + assert!(!mpeg2(CodingType::B, true, false, false, false, true).keyframe()); + } + + #[test] + fn mpeg2_field_order_tff_when_top_field_first() { + // Interlaced frame picture, tff set → top-field-first. + assert_eq!( + mpeg2(CodingType::I, true, false, false, false, true).field_order(), + Some(FieldOrder::Tff) + ); + } + + #[test] + fn mpeg2_field_order_bff_when_not_top_field_first() { + // Interlaced frame picture, tff clear → bottom-field-first. + assert_eq!( + mpeg2(CodingType::I, false, false, false, false, true).field_order(), + Some(FieldOrder::Bff) + ); + } + + #[test] + fn mpeg2_field_order_progressive_for_progressive_frame() { + assert_eq!( + mpeg2(CodingType::I, true, false, true, false, true).field_order(), + Some(FieldOrder::Progressive) + ); + // Progressive sequence likewise. + assert_eq!( + mpeg2(CodingType::I, true, false, false, true, true).field_order(), + Some(FieldOrder::Progressive) + ); + } + + #[test] + fn mpeg2_nb_fields_normal_and_telecine() { + // Normal interlaced frame: 2 fields. + assert_eq!( + mpeg2(CodingType::P, true, false, false, false, true).nb_fields(), + 2 + ); + // NTSC 2:3 soft telecine (interlaced seq, progressive frame, rff): 3. + assert_eq!( + mpeg2(CodingType::P, false, true, true, false, true).nb_fields(), + 3 + ); + // Field picture: 1 field. + assert_eq!( + mpeg2(CodingType::P, false, false, false, false, false).nb_fields(), + 1 + ); + // Progressive sequence, rff + tff: 6. + assert_eq!( + mpeg2(CodingType::P, true, true, false, true, true).nb_fields(), + 6 + ); + // Progressive sequence, rff no tff: 4. + assert_eq!( + mpeg2(CodingType::P, false, true, false, true, true).nb_fields(), + 4 + ); + } + + #[test] + fn mpeg2_progressive_accessor() { + assert_eq!( + mpeg2(CodingType::I, true, false, true, false, true).progressive(), + Some(true) + ); + assert_eq!( + mpeg2(CodingType::I, true, false, false, false, true).progressive(), + Some(false) + ); + } + + #[test] + fn coding_type_only_reports_unknown_field_and_progressive() { + let p = PictureInfo::coding_type_only(CodingType::P); + assert_eq!(p.coding_type(), CodingType::P); + assert_eq!(p.field_order(), None); + assert_eq!(p.progressive(), None); + // No pulldown signalling for these codecs → normal 2-field frame. + assert_eq!(p.nb_fields(), 2); + } +} diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 2714415..6fd00d4 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -243,6 +243,8 @@ impl CodecParser for DtsParser { // extensions or the next core. let au_pts = self.front_pts(); frames.push(Frame { + coding: None, + source: None, pts_ns: au_pts, keyframe: true, data: au, @@ -297,6 +299,8 @@ impl CodecParser for DtsParser { let au = std::mem::take(&mut self.buf); self.pts_marks.clear(); vec![Frame { + coding: None, + source: None, pts_ns, keyframe: true, data: au, @@ -377,6 +381,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1100, pts, dts: None, diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index 8524be7..21f852e 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -43,6 +43,8 @@ impl DvdSubParser { if force || buf.len() >= *size { let (pts_ns, _, data) = self.pending.take().unwrap(); return Some(Frame { + coding: None, + source: None, pts_ns, keyframe: true, data, @@ -101,6 +103,8 @@ impl CodecParser for DvdSubParser { let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize; if d < 2 { out.push(Frame { + coding: None, + source: None, pts_ns, keyframe: true, data: pes.data.clone(), @@ -112,6 +116,8 @@ impl CodecParser for DvdSubParser { } else { // Too short to carry SPU_size — pass through as a lone frame. out.push(Frame { + coding: None, + source: None, pts_ns, keyframe: true, data: pes.data.clone(), @@ -222,6 +228,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1200, pts, dts: None, diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index 9538ee9..167c92f 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -203,6 +203,8 @@ impl CodecParser for H264Parser { } vec![Frame { + coding: None, + source: None, pts_ns, keyframe, data: frame_data, @@ -488,6 +490,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1011, pts, dts: None, @@ -825,6 +828,7 @@ mod tests { data.extend_from_slice(&[0x00, 0x10]); let pes = PesPacket { + source: None, pid: 0x1011, pts: Some(180000), // 2 seconds (presentation) dts: Some(90000), // 1 second (decode) @@ -1133,6 +1137,7 @@ mod tests { // PTS absent → DTS is used (or().map). pts.or(dts) per the comment. let mut parser = H264Parser::new(); let pes = PesPacket { + source: None, pid: 0x1011, pts: None, dts: Some(90000), @@ -1147,6 +1152,7 @@ mod tests { fn no_pts_no_dts_defaults_zero() { let mut parser = H264Parser::new(); let pes = PesPacket { + source: None, pid: 0x1011, pts: None, dts: None, diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index 6da0543..5ab02a5 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -446,6 +446,8 @@ impl CodecParser for HevcParser { } vec![Frame { + coding: None, + source: None, pts_ns, keyframe, data: frame_data, @@ -753,6 +755,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1011, pts, dts: None, @@ -1689,6 +1692,7 @@ mod tests { data.extend_from_slice(&[0x10, 0x20]); let pes = PesPacket { + source: None, pid: 0x1011, pts: Some(180000), // 2 s (presentation) dts: Some(90000), // 1 s (decode) @@ -2290,6 +2294,7 @@ mod tests { fn hevc_dts_fallback_when_pts_absent() { let mut parser = HevcParser::new(); let pes = PesPacket { + source: None, pid: 0x1011, pts: None, dts: Some(90000), diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index a43ca6c..4350372 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -72,6 +72,8 @@ impl CodecParser for LpcmParser { } let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); vec![Frame { + coding: None, + source: None, pts_ns, keyframe: true, data: pes.data[offset..].to_vec(), @@ -91,6 +93,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1100, pts, dts: None, diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 7e44c8c..96dba7d 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -9,6 +9,8 @@ /// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser. pub mod ac3; +/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors). +pub mod coding; /// DTS / DTS-HD elementary-stream parser. pub mod dts; /// DVD bitmap subtitle (VobSub) parser. @@ -30,10 +32,13 @@ pub mod truehd; /// VC-1 (SMPTE 421M) elementary-stream parser. pub mod vc1; +pub use coding::{FieldOrder, PictureInfo}; + use super::ts::PesPacket; use crate::disc::Codec; /// A single frame ready for MKV muxing. +#[derive(Default)] pub struct Frame { /// Presentation timestamp in nanoseconds. pub pts_ns: i64, @@ -48,6 +53,18 @@ pub struct Frame { /// `SimpleBlock`; without it players guess the display interval /// (subtitles linger past their end-time). pub duration_ns: Option, + /// Codec-agnostic per-picture coding info, set by the video parsers that + /// decode it (MPEG-2 fully; H.264/HEVC/VC-1 coding-type only); `None` for + /// audio/subtitle frames. Carried additively through the highway and + /// forwarded onto [`crate::pes::PesFrame::coding`] so the muxer can read + /// field order / pulldown off the frame instead of assuming it. Default + /// `None` keeps non-video frames paying nothing. + pub coding: Option, + /// Source position of this frame's first byte, carried from the demux seam + /// (where each PES is stamped) through the parser. `None` for synthetic + /// sources / parsers that don't track it. Forwarded onto + /// [`crate::pes::PesFrame::source`]. + pub source: Option, } /// Convert 90kHz PTS to nanoseconds (round to nearest). @@ -107,6 +124,8 @@ impl CodecParser for PassthroughParser { fn parse(&mut self, pes: &PesPacket) -> Vec { let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0); vec![Frame { + coding: None, + source: None, pts_ns, keyframe: self.keyframe, data: pes.data.clone(), @@ -174,6 +193,7 @@ mod tests { fn pes(pts: Option, data: Vec) -> PesPacket { PesPacket { + source: None, pid: 0x1011, pts, dts: None, diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index 8766cd9..d977a10 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -28,9 +28,11 @@ use std::collections::VecDeque; +use super::coding::{CodingType, Mpeg2Coding, PictureInfo}; use super::startcode::find_start_code; use super::{CodecParser, Frame, pts_to_ns}; use crate::mux::ts::PesPacket; +use crate::pes::SourcePos; /// Sequence header start code suffix. const SEQ_HEADER_CODE: u8 = 0xB3; @@ -101,6 +103,11 @@ pub struct Mpeg2Parser { /// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES /// that carried a timestamp, in ascending offset order. pts_marks: VecDeque<(u64, i64)>, + /// `(absolute ES offset of a PES's first byte, SourcePos)` for every PES + /// that carried byte-exact provenance, parallel to `pts_marks` and drained + /// by the SAME mark-drain invariant. Attaches the source position to each + /// access unit so the index carries it — never reconstructed. + source_marks: VecDeque<(u64, SourcePos)>, /// Full-frame presentation interval (ns) at the sequence-header display rate /// (`1/frame_rate`). The field period is half this. Per-frame durations are /// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and @@ -128,8 +135,10 @@ pub struct Mpeg2Parser { struct BufferedPicture { /// `temporal_reference` — display order within the GOP. tr: u64, - /// Field-display periods this picture occupies (`picture_nb_fields`). - nb_fields: u8, + /// Codec-agnostic per-picture coding info. The single source of this + /// picture's field count (`nb_fields()`), field order, and coding type; + /// also stamped onto the emitted [`Frame::coding`]. + info: PictureInfo, /// This picture's own PES PTS (ns), if its access unit carried one. explicit_pts: Option, /// The emitted frame (PTS + duration filled in at GOP flush). @@ -150,6 +159,7 @@ impl Mpeg2Parser { buf: Vec::with_capacity(128 * 1024), base_offset: 0, pts_marks: VecDeque::new(), + source_marks: VecDeque::new(), frame_duration_ns: 0, progressive_sequence: false, gop_buf: Vec::new(), @@ -206,6 +216,13 @@ impl Mpeg2Parser { break; } } + while let Some(&(off, _)) = self.source_marks.front() { + if off < cutoff { + self.source_marks.pop_front(); + } else { + break; + } + } } break; }; @@ -228,7 +245,13 @@ impl Mpeg2Parser { // GOP, resetting temporal_reference to 0. let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some() || find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some(); - let keyframe = pic + 5 < end && ((self.buf[pic + 5] >> 3) & 0x07) == PICTURE_TYPE_I; + // picture_coding_type: the full 3-bit value (bits 5-3 of buf[pic+5]). + // 0 when the picture header is truncated (no coding type available). + let raw_coding_type = if pic + 5 < end { + (self.buf[pic + 5] >> 3) & 0x07 + } else { + 0 + }; // temporal_reference: the 10 bits immediately after the picture // start code = display order within the GOP. let tr = if pic + 5 < end { @@ -249,7 +272,24 @@ impl Mpeg2Parser { } } } - let nb_fields = picture_nb_fields(&data, self.progressive_sequence); + // Decode the picture coding extension ONCE here and fold every + // per-picture datum (coding type + tff/rff/progressive_frame/ + // frame_picture, plus the sequence's progressive flag) into one + // codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and + // `field_order()` all derive from it; nothing downstream re-parses + // the elementary stream. + let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data); + let info = PictureInfo::mpeg2( + coding_type_from_raw(raw_coding_type), + Mpeg2Coding { + top_field_first: tff, + repeat_first_field: rff, + progressive_frame, + progressive_sequence: self.progressive_sequence, + frame_picture, + }, + ); + let keyframe = info.keyframe(); // An explicit PES PTS for this access unit, if any. By the mark-drain // invariant the front mark's offset is >= this AU's start, so a front @@ -260,6 +300,15 @@ impl Mpeg2Parser { .filter(|&&(off, _)| off < end_abs) .map(|&(_, p)| p); + // Byte-exact source provenance for this AU, by the same mark-drain + // invariant as the PTS: the front source mark inside [start, end) + // belongs to this access unit. + let src = self + .source_marks + .front() + .filter(|&&(off, _)| off < end_abs) + .map(|&(_, s)| s); + // A GOP boundary means the buffered run is a COMPLETE GOP (all its // pictures display before the next GOP's), so flush it before // starting the new one. `temporal_reference` resets to 0 at the @@ -269,13 +318,15 @@ impl Mpeg2Parser { } self.gop_buf.push(BufferedPicture { tr, - nb_fields, + info, explicit_pts: explicit, frame: Frame { pts_ns: 0, keyframe, data, duration_ns: None, + coding: Some(info), + source: src, }, }); // Safety cap: a stream with no GOP/sequence boundaries would buffer @@ -294,6 +345,13 @@ impl Mpeg2Parser { break; } } + while let Some(&(off, _)) = self.source_marks.front() { + if off < end_abs { + self.source_marks.pop_front(); + } else { + break; + } + } } // EOF: emit the final (possibly incomplete) GOP so nothing is dropped. if force { @@ -334,7 +392,7 @@ impl Mpeg2Parser { let mut running = 0u64; for &i in &order { cum_before[i] = running; - running += self.gop_buf[i].nb_fields as u64; + running += self.gop_buf[i].info.nb_fields() as u64; } let gop_fields = running; let base = self.emitted_fields; @@ -348,7 +406,7 @@ impl Mpeg2Parser { let origin = self.origin_pts_ns.unwrap_or(0); for (i, mut bp) in self.gop_buf.drain(..).enumerate() { bp.frame.pts_ns = origin + field_period * (base + cum_before[i]) as i64; - bp.frame.duration_ns = Some(bp.nb_fields as u64 * field_period as u64); + bp.frame.duration_ns = Some(bp.info.nb_fields() as u64 * field_period as u64); out.push(bp.frame); } self.emitted_fields += gop_fields; @@ -368,6 +426,9 @@ impl CodecParser for Mpeg2Parser { if let Some(ts) = pes.pts.or(pes.dts) { self.pts_marks.push_back((off, pts_to_ns(ts))); } + if let Some(src) = pes.source { + self.source_marks.push_back((off, src)); + } self.buf.extend_from_slice(&pes.data); self.drain_complete_aus(false) } @@ -478,6 +539,46 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> { Some(ASPECT_RATIOS[ar_code]) } +/// Extract the picture-coding-extension field/pulldown flags +/// `(top_field_first, repeat_first_field, progressive_frame, frame_picture)` +/// from a coded access unit (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 +/// §6.3.10. The four bits feed the codec-agnostic [`PictureInfo`]. Returns a +/// progressive whole-frame default `(false, false, true, true)` when no picture +/// coding extension is present (MPEG-1 / no interlace signalling), so the muxer +/// omits `FieldOrder` rather than asserting a guess. +fn picture_coding_flags(au: &[u8]) -> (bool, bool, bool, bool) { + let mut search = 0; + while let Some(q) = find_code(au, search, SEQ_EXT_CODE) { + search = q + 4; + // The picture coding extension is the B5 whose ext-id nibble is 1000. + if au.get(q + 4).map(|b| b >> 4) != Some(0b1000) { + continue; + } + // Extension bytes e2..=e4 = au[q+6 ..= q+8]. + let (Some(&e2), Some(&e3), Some(&e4)) = (au.get(q + 6), au.get(q + 7), au.get(q + 8)) + else { + break; + }; + // picture_structure (e2 bits 1-0): 11 = frame picture; 01/10 = field. + let frame_picture = e2 & 0x03 == 0b11; + let tff = (e3 >> 7) & 1 == 1; + let rff = (e3 >> 1) & 1 == 1; + let progressive_frame = (e4 >> 7) & 1 == 1; + return (tff, rff, progressive_frame, frame_picture); + } + (false, false, true, true) +} + +/// Map MPEG-2 `picture_coding_type` (ISO/IEC 13818-2 §6.3.8) to the +/// codec-agnostic [`CodingType`]: 1 → I, 3 → B, else (2 = P, 4 = D) → P. +fn coding_type_from_raw(raw: u8) -> CodingType { + match raw { + 1 => CodingType::I, + 3 => CodingType::B, + _ => CodingType::P, + } +} + /// Number of field-display periods a coded picture occupies, from its picture /// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10 /// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what @@ -587,6 +688,103 @@ mod tests { assert_eq!(picture_nb_fields(&[0, 0, 1, 0x00, 0, 0], false), 2); } + #[test] + fn parser_populates_full_pictureinfo_and_source() { + use crate::mux::codec::coding::FieldOrder; + // Drive the REAL parser over three pictures that exercise EVERY facet of + // PictureInfo the parser measures (not just field order): + // I: tff=1 rff=0 pf=0 → type I, TFF, 2 fields, !progressive, keyframe + // P: tff=0 rff=0 pf=0 → type P, BFF, 2 fields, !progressive, !keyframe + // B: tff=0 rff=1 pf=1 → type B, Progressive, 3 fields (2:3 pulldown), + // progressive, !keyframe + // ...and assert the byte-exact source provenance rides every frame. + // Each picture in its OWN PES with its OWN source stamp — the realistic + // shape (real DVD video is one picture across many PES, each stamped), so + // every picture's frame carries the provenance of its packet. + let mk_pes = |data: Vec, byte: u64| PesPacket { + source: Some(crate::pes::SourcePos::at_byte(byte)), + pid: 0x1011, + pts: None, + dts: None, + data, + }; + let mut p = Mpeg2Parser::new(); + let mut frames = Vec::new(); + // I-picture (with the seq header) @ source byte 0. + let mut au = make_seq_header(720, 576, 3, 3); // interlaced 16:9 25fps + au.extend_from_slice(&make_picture_header(1)); + au.extend_from_slice(&pic_coding_ext(1, 0, 0, true)); + frames.extend(p.parse(&mk_pes(au, 0))); + // P-picture @ source byte 2048. + let mut au = make_picture_header(2); + au.extend_from_slice(&pic_coding_ext(0, 0, 0, true)); + frames.extend(p.parse(&mk_pes(au, 2048))); + // B-picture @ source byte 4096. + let mut au = make_picture_header(3); + au.extend_from_slice(&pic_coding_ext(0, 1, 1, true)); + frames.extend(p.parse(&mk_pes(au, 4096))); + frames.extend(p.flush()); + assert_eq!(frames.len(), 3, "three pictures → three frames"); + + // Every frame carries PictureInfo and the SourcePos its PES stamped. + for f in &frames { + assert!(f.coding.is_some(), "every MPEG-2 frame carries PictureInfo"); + assert!( + f.source.is_some(), + "every frame carries SourcePos provenance" + ); + } + let frame = |t: CodingType| { + frames + .iter() + .find(|f| f.coding.unwrap().coding_type() == t) + .unwrap_or_else(|| panic!("no {t:?} frame")) + }; + + let i = frame(CodingType::I); + assert_eq!( + i.source.unwrap().byte, + 0, + "I frame keeps its PES source @ 0" + ); + let ic = i.coding.unwrap(); + assert!(ic.keyframe(), "I picture is a keyframe"); + assert_eq!(ic.field_order(), Some(FieldOrder::Tff), "tff=1 → TFF"); + assert_eq!(ic.nb_fields(), 2, "normal interlaced frame = 2 fields"); + assert_eq!(ic.progressive(), Some(false)); + + let pp = frame(CodingType::P); + assert_eq!( + pp.source.unwrap().byte, + 2048, + "P frame keeps its PES source" + ); + let pc = pp.coding.unwrap(); + assert!(!pc.keyframe()); + assert_eq!( + pc.field_order(), + Some(FieldOrder::Bff), + "tff=0 interlaced frame → BFF (the red-flag fix)" + ); + assert_eq!(pc.nb_fields(), 2); + + let b = frame(CodingType::B); + assert_eq!(b.source.unwrap().byte, 4096, "B frame keeps its PES source"); + let bc = b.coding.unwrap(); + assert!(!bc.keyframe()); + assert_eq!( + bc.field_order(), + Some(FieldOrder::Progressive), + "progressive_frame → Progressive (no field order)" + ); + assert_eq!( + bc.nb_fields(), + 3, + "rff + progressive_frame in interlaced seq → 2:3 pulldown = 3 fields" + ); + assert_eq!(bc.progressive(), Some(true)); + } + #[test] fn progressive_sequence_parsed_from_seq_ext() { // Sequence extension: 00 00 01 B5, e0 ext-id 0001 (0x1_), e1 bit3 = progressive_sequence. @@ -619,6 +817,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1011, pts, dts: None, @@ -1211,6 +1410,7 @@ mod tests { let mut data = make_picture_header(PICTURE_TYPE_I); data.extend_from_slice(&[0xFF; 4]); let pes = PesPacket { + source: None, pid: 0x1011, pts: None, dts: Some(90000), @@ -1223,6 +1423,7 @@ mod tests { let mut data2 = make_picture_header(PICTURE_TYPE_I); data2.extend_from_slice(&[0xFF; 4]); let pes2 = PesPacket { + source: None, pid: 0x1011, pts: None, dts: None, diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index 5a82e80..b7a9fff 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -57,6 +57,8 @@ impl PgsParser { let (start_pts, data) = self.pending.take()?; let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64; Some(Frame { + coding: None, + source: None, pts_ns: start_pts, keyframe: true, data, @@ -90,6 +92,8 @@ impl CodecParser for PgsParser { .take() .map(|(start_pts, data)| { vec![Frame { + coding: None, + source: None, pts_ns: start_pts, keyframe: true, data, @@ -115,6 +119,8 @@ impl CodecParser for PgsParser { let frame = match pts { Some(end) => self.emit_pending(end), None => self.pending.take().map(|(start_pts, data)| Frame { + coding: None, + source: None, pts_ns: start_pts, keyframe: true, data, @@ -136,6 +142,8 @@ impl CodecParser for PgsParser { // Flush any prior pending undurated and skip storing this one. None => { out.extend(self.pending.take().map(|(start_pts, data)| Frame { + coding: None, + source: None, pts_ns: start_pts, keyframe: true, data, @@ -159,6 +167,8 @@ impl CodecParser for PgsParser { // (A missing PTS falls through to the drop path below: a // bitmap with no timing reference would land at 00:00:00.) out.push(Frame { + coding: None, + source: None, pts_ns: pts.unwrap_or(0), keyframe: true, data: pes.data.clone(), @@ -185,6 +195,8 @@ impl CodecParser for PgsParser { // the final on-screen subtitle (see the module doc). match self.pending.take() { Some((start_pts, data)) => vec![Frame { + coding: None, + source: None, pts_ns: start_pts, keyframe: true, data, @@ -206,6 +218,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1200, pts, dts: None, diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index e4b13fa..7f0d8a4 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -186,6 +186,8 @@ impl CodecParser for TrueHdParser { == 0xF872_6FBA; frames.push(Frame { + coding: None, + source: None, pts_ns: self.next_pts_ns, keyframe: is_major_sync, data: self.buf[..unit_bytes].to_vec(), @@ -262,6 +264,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1100, pts, dts: None, diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 09f5cba..cf17b21 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -243,6 +243,8 @@ impl CodecParser for Vc1Parser { }; vec![Frame { + coding: None, + source: None, pts_ns: ts_ns, keyframe, data: frame_data, @@ -363,6 +365,7 @@ mod tests { fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { + source: None, pid: 0x1011, pts, dts: None, @@ -606,6 +609,7 @@ mod tests { data.extend_from_slice(&[0x55, 0x66]); let pes = PesPacket { + source: None, pid: 0x1011, pts: Some(180000), // presentation dts: Some(90000), // decode @@ -855,6 +859,7 @@ mod tests { // PTS absent → DTS used; both absent → 0. let mut parser = Vc1Parser::new(); let pes = PesPacket { + source: None, pid: 0x1011, pts: None, dts: Some(90000), @@ -865,6 +870,7 @@ mod tests { let mut parser2 = Vc1Parser::new(); let pes2 = PesPacket { + source: None, pid: 0x1011, pts: None, dts: None, diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 381f76e..1c03638 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -832,7 +832,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }) } @@ -874,6 +873,8 @@ mod tests { let mut w = AnnexBWriter::new(Codec::H264, None); let mut out = Vec::new(); let f = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -901,6 +902,8 @@ mod tests { let mut w = AnnexBWriter::new(Codec::H264, Some(&rec)); let mut out = Vec::new(); let f1 = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -920,6 +923,8 @@ mod tests { // Second frame: NO param re-prepend. let mut out2 = Vec::new(); let f2 = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: false, @@ -1000,6 +1005,8 @@ mod tests { 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 { + coding: None, + source: None, track: 0, pts: 1_000_000_000, // 1s keyframe: true, @@ -1043,6 +1050,8 @@ mod tests { fn pgs_frame_without_duration_emits_no_clear() { // No duration → no synthetic clear (the subtitle's wipe time is unknown). let f = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -1080,6 +1089,8 @@ mod tests { let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff"), "eng"); let mut sub = Vec::new(); let f1 = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -1087,6 +1098,8 @@ mod tests { duration_ns: None, }; let f2 = PesFrame { + coding: None, + source: None, track: 0, pts: 1_000_000_000, keyframe: true, @@ -1176,6 +1189,8 @@ mod tests { // Video frame (track 0) and audio frame (track 1). sink.write(&PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -1184,6 +1199,8 @@ mod tests { }) .unwrap(); sink.write(&PesFrame { + coding: None, + source: None, track: 1, pts: 100_000_000, // audio 100ms late keyframe: true, @@ -1216,6 +1233,8 @@ mod tests { }; let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap(); sink.write(&PesFrame { + coding: None, + source: None, track: 1, pts: 0, keyframe: true, diff --git a/src/mux/demux_thread.rs b/src/mux/demux_thread.rs index d781f4f..5e76dc6 100644 --- a/src/mux/demux_thread.rs +++ b/src/mux/demux_thread.rs @@ -142,9 +142,13 @@ impl DemuxThread { None }; let n = buf.len(); + // Source byte offset of this buffer's first byte = bytes fed + // so far. Threaded into the demuxer so every PES it cuts is + // stamped with its SourcePos (carried, not reconstructed). + let buf_base = fed_bytes; fed_bytes += n as u64; if let Some(ref mut d) = ts { - let pkts = d.feed(&buf); + let pkts = d.feed_at(buf_base, &buf); let t2 = if prof { Some(std::time::Instant::now()) } else { @@ -191,7 +195,7 @@ impl DemuxThread { } } } else if let Some(ref mut d) = ps { - let pkts = d.feed(&buf); + let pkts = d.feed_at(buf_base, &buf); let _ = recycle_tx.send(buf); // Always send (even empty) — same early-disconnect // detection rationale as the TS branch above. diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 9bb9562..1b6e8b9 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -713,6 +713,7 @@ impl crate::pes::Stream for DiscStream { continue; }; let pes = super::ts::PesPacket { + source: None, pid, pts: ps.pts.map(|p| p as i64), dts: ps.dts.map(|d| d as i64), @@ -775,6 +776,8 @@ impl crate::pes::Stream for DiscStream { // attribute consumer-thread time to // "demux + framing" vs "codec parse". self.pending_frames.push_back(crate::pes::PesFrame { + coding: None, + source: None, track, pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0), keyframe: false, @@ -833,6 +836,7 @@ impl crate::pes::Stream for DiscStream { }; let pes = super::ts::PesPacket { + source: None, pid, pts: ps.pts.map(|p| p as i64), dts: ps.dts.map(|d| d as i64), diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index fef54db..321989e 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -116,7 +116,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })], chapters: Vec::new(), @@ -168,6 +167,8 @@ mod tests { let sink = SharedSink(shared.clone()); let mut stream = M2tsStream::create(sink, &title).unwrap(); let frame = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 257fa92..7bbd166 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -212,7 +212,6 @@ impl M2tsMeta { display_aspect: None, secondary: *secondary, label: label.clone(), - top_field_first: None, measured_cicp: None, }) } @@ -409,7 +408,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })); t @@ -660,7 +658,6 @@ mod tests { display_aspect: None, secondary: false, label: "x".into(), - top_field_first: None, measured_cicp: None, })); } diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index e39c25f..76a50bd 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -222,24 +222,14 @@ impl MkvTrack { colour_primaries: primaries, colour_range: range, interlaced: v.resolution.is_interlaced(), - // FieldOrder (Matroska 0x9D) MUST agree with the elementary stream's - // interlace signalling. Derive it from the MEASURED `top_field_first` - // when the bitstream stated it: Some(true) → TFF, Some(false) → BFF. - // Genuinely bottom-field-first content (rare, but real) was previously - // mis-stamped TFF because the muxer hardcoded TFF for ALL interlaced - // streams. When the flag is NOT measured (`None`), fall back to TFF — - // PAL DVD (576i), NTSC DVD (480i) and HD (1080i) are overwhelmingly - // top-field-first ("almost everything but DV is TFF"). Progressive - // content leaves the order undetermined (the element is omitted). - field_order: if v.resolution.is_interlaced() { - match v.top_field_first { - Some(true) => ebml::FIELD_ORDER_TFF, - Some(false) => ebml::FIELD_ORDER_BFF, - None => ebml::FIELD_ORDER_TFF, - } - } else { - ebml::FIELD_ORDER_UNDETERMINED - }, + // FieldOrder (Matroska 0x9D) is a bitstream property + // (`top_field_first`) the IFO/MPLS scan cannot know, so it is NOT set + // here — it would only ever be a guess. Default to UNDETERMINED; the + // mux stream (`MkvStream`) sets the MEASURED value from the first + // coded picture's `PictureInfo` before the muxer writes the header. + // If an interlaced track ever reaches the muxer still UNDETERMINED, + // that is a parser/source gap and is logged loudly — never faked. + field_order: ebml::FIELD_ORDER_UNDETERMINED, // DefaultDecodedFieldDuration is DELIBERATELY NOT emitted (0 here // suppresses the element; see the writer in `MkvMuxer::new`). // @@ -753,6 +743,10 @@ impl MkvMuxer { }, )?; if track.interlaced && track.field_order != ebml::FIELD_ORDER_UNDETERMINED { + // `track.field_order` was set CORRECTLY before construction + // (the mux stream reads the first coded picture's measured + // field order and sets it on the track), so this writes the + // right value the first time — no later rewrite. ebml::write_uint(&mut writer, ebml::FIELD_ORDER, track.field_order as u64)?; } if track.display_width > 0 && track.display_height > 0 { @@ -902,6 +896,17 @@ impl MkvMuxer { /// when to remove the on-screen artifact (the practical case is /// PGS subtitles — without it, the last bitmap lingers until the /// next display set replaces it). Otherwise a plain `SimpleBlock`. + /// Rewrite a video track's `FieldOrder` value in place from the MEASURED + /// field order carried on the first coded picture, replacing the scan-time + /// guess written at construction. This is the fix for the "we parsed + /// `top_field_first` then ignored it" red flag: the muxer now stamps the + /// field order the bitstream actually states, not an assumption. + /// + /// Idempotent — only the first call per track patches (later calls and + /// non-interlaced / non-video tracks are no-ops). `Progressive` / unknown + /// (`None`) leaves the written value untouched: an interlaced track keeps + /// its guess rather than being cleared via a multi-element change. The byte + /// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid. pub fn write_frame( &mut self, track_idx: usize, @@ -1347,7 +1352,6 @@ mod tests { display_aspect: Some((16, 9)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&base); @@ -1370,59 +1374,6 @@ mod tests { ); } - /// FieldOrder must follow the MEASURED top_field_first, not a hardcoded TFF. - /// An interlaced stream whose parsed `top_field_first == Some(false)` tags - /// BFF (=6); `Some(true)` and `None` (unknown, the fallback) tag TFF (=1). - #[test] - fn interlaced_field_order_from_measured_tff() { - let base = VideoStream { - pid: 0xE0, - codec: Codec::Mpeg2, - resolution: Resolution::R576i, // interlaced - frame_rate: crate::disc::FrameRate::F25, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt470bg, - display_aspect: None, - secondary: false, - label: String::new(), - top_field_first: None, - measured_cicp: None, - }; - - // Measured bottom-field-first → BFF, NOT the old hardcoded TFF. - let bff = VideoStream { - top_field_first: Some(false), - ..base.clone() - }; - assert_eq!( - MkvTrack::video(&bff).field_order, - ebml::FIELD_ORDER_BFF, - "measured top_field_first=false must tag BFF (6), not TFF" - ); - - // Measured top-field-first → TFF. - let tff = VideoStream { - top_field_first: Some(true), - ..base.clone() - }; - assert_eq!(MkvTrack::video(&tff).field_order, ebml::FIELD_ORDER_TFF); - - // Unknown (not measured) → TFF fallback (dominant DVD/HD case). - assert_eq!(MkvTrack::video(&base).field_order, ebml::FIELD_ORDER_TFF); - - // Progressive content leaves the order undetermined regardless of flag. - let prog = VideoStream { - resolution: Resolution::R1080p, - top_field_first: Some(false), - ..base - }; - assert_eq!( - MkvTrack::video(&prog).field_order, - ebml::FIELD_ORDER_UNDETERMINED, - "progressive video never carries a field order" - ); - } - /// Measured CICP from the bitstream must take precedence over the coarse /// `color_space` enum. A BT.2020/PQ enum that would otherwise produce /// (9,16,9) is overridden by a measured BT.709 triplet when present. @@ -1438,7 +1389,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; @@ -2471,6 +2421,7 @@ mod tests { // One PES PTS anchor per GOP (90 kHz), as a real VOBU stamps. let gop_pts = g * gop_len as i64 * frame_ns * 90_000 / 1_000_000_000; frames.extend(parser.parse(&PesPacket { + source: None, pid: 0x1011, pts: Some(gop_pts), dts: None, @@ -3369,10 +3320,11 @@ mod tests { } #[test] - fn video_576i_defaults_to_top_field_first() { - // PAL 576i must default to TFF (2), not BFF — the container element must - // agree with the MPEG-2 stream (MediaInfo reads "Top Field First" off - // the picture coding extension). The pre-rc.5.1 BFF(9) was a wrong value. + fn video_576i_field_order_undetermined_at_track_build() { + // Field order is a bitstream property the IFO/MPLS scan cannot know, so + // the track is built with FieldOrder=UNDETERMINED — never a scan-time + // guess. The mux stream sets the MEASURED value from the first coded + // picture before the header is written (mkvstream::apply_coding_to_track). let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -3383,15 +3335,14 @@ mod tests { display_aspect: Some((16, 9)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&v); - assert!(t.interlaced, "576i is interlaced"); + assert!(t.interlaced, "576i is interlaced (FlagInterlaced=1)"); assert_eq!( t.field_order, - ebml::FIELD_ORDER_TFF, - "576i must default to top-field-first" + ebml::FIELD_ORDER_UNDETERMINED, + "field order is not known at scan — set later from the measured picture" ); } @@ -3416,7 +3367,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&v); @@ -3444,11 +3394,12 @@ mod tests { ebml::INTERLACED_INTERLACED as u8, "FlagInterlaced=1 retained" ); - let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present"); - assert_eq!( - data[fo + 2], - ebml::FIELD_ORDER_TFF, - "FieldOrder=TFF retained" + // FieldOrder is set at mux time from the first coded picture's measured + // field order. A track built directly (no measured picture) carries + // UNDETERMINED, so the element is omitted — never a scan-time guess. + assert!( + find_id(&data, ebml::FIELD_ORDER).is_none(), + "FieldOrder omitted until measured — no guess at track build" ); } @@ -3561,7 +3512,6 @@ mod tests { display_aspect: Some((16, 9)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&v); @@ -3622,7 +3572,6 @@ mod tests { display_aspect: Some((4, 3)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&v); @@ -3668,12 +3617,13 @@ mod tests { } #[test] - fn ntsc_480i_field_order_is_tff_and_encoded() { - // 480i FIELD-ORDER HONESTY (audit §2 / §5 #5): NTSC 480i is hardcoded TFF - // (mkv.rs field_order). Document & encode that reality so a future edit - // can't silently flip it. The old field-order test covered 576i only; - // NTSC was never exercised. Assert both the struct value AND the byte - // actually written into the Video master. + fn ntsc_480i_duration_metadata_and_field_order_undetermined_at_build() { + // NTSC 480i duration metadata (Windows-fps fix) PLUS field-order honesty. + // Field order is a bitstream property the IFO/MPLS scan cannot know, so a + // track built without a measured picture carries FieldOrder=UNDETERMINED + // and the element is OMITTED — never a hardcoded TFF guess. The MEASURED + // value is set by the mux stream from the first coded picture (see + // mkvstream::apply_coding_to_track and its dedicated test). let v = VideoStream { pid: 0xE0, codec: Codec::Mpeg2, @@ -3684,15 +3634,14 @@ mod tests { display_aspect: Some((4, 3)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, }; let t = MkvTrack::video(&v); assert!(t.interlaced, "480i is interlaced"); assert_eq!( t.field_order, - ebml::FIELD_ORDER_TFF, - "NTSC 480i is hardcoded top-field-first" + ebml::FIELD_ORDER_UNDETERMINED, + "field order is not known at scan time — never guessed at track build" ); // 480i @ 29.97: frame = 1001/30000 s = 33_366_666 ns; field = half. assert_eq!( @@ -3705,8 +3654,8 @@ mod tests { ); let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap(); let data = muxer.writer.into_inner(); - // FlagInterlaced and FieldOrder are Video children; assert the encoded - // bytes (depth-scoped, not a flat scan). + // FlagInterlaced is still encoded (480i IS interlaced); FieldOrder is + // omitted until measured — never a hardcoded guess. assert_eq!( video_child_u8(&data, ebml::FLAG_INTERLACED), Some(ebml::INTERLACED_INTERLACED as u8), @@ -3714,8 +3663,8 @@ mod tests { ); assert_eq!( video_child_u8(&data, ebml::FIELD_ORDER), - Some(ebml::FIELD_ORDER_TFF), - "480i must encode FieldOrder = TFF (2)" + None, + "FieldOrder omitted until measured — not a hardcoded guess" ); } diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index c93fdb4..0de70fc 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -76,15 +76,50 @@ struct ReadState { codec_privates: Vec<(u16, Vec)>, } +/// Safety cap on frames buffered before the first video frame triggers muxer +/// construction. The first video frame normally arrives within the first few +/// frames, so this is only a backstop for a pathological audio-only-prefix +/// stream — past it we build with no measured field order (logged) rather than +/// buffer unbounded. +const MAX_PENDING_FRAMES: usize = 4096; + enum Mode { - Write { - // Boxed: MkvMuxer is large relative to the Read variant; boxing keeps - // the Mode enum small (avoids clippy::large_enum_variant). - muxer: Option>>>, - }, + Write(WriteMode), Read(ReadState), } +/// MKV write state with DEFERRED muxer construction. The track header (which +/// carries `FieldOrder`) is written only once the first coded picture is in +/// hand, so the primary video track's field order is set to the parser's +/// MEASURED value the first time — never a guessed default a later pass would +/// rewrite. The muxer still only ever muxes the track it is *given*; this stream +/// is the adapter that routes the parser's measured field order onto that track +/// before construction. +enum WriteMode { + /// Header not written yet: buffering frames until the first video frame. + Pending(Box), + /// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum + /// small (clippy::large_enum_variant). + Active(Box>>), + /// Transient placeholder held only across the Pending → Active swap; never + /// observed by `read` / `write` / `finish`. + Building, +} + +/// Everything needed to build the muxer, held until the first coded picture +/// lets the primary video track's field order be set from the source. +struct PendingMux { + writer: Box, + tracks: Vec, + /// Index of the primary (first) video track, if any — the track whose + /// `FieldOrder` is set from the first coded picture's measured coding. + video_track: Option, + /// `--log-level 3` opening-capture side-file path (if any). + opening_capture_path: Option, + /// Frames received before activation, replayed in order once built. + buffered: Vec, +} + /// Matroska container stream. pub struct MkvStream { disc_title: DiscTitle, @@ -130,37 +165,63 @@ impl MkvStream { tracks.push(track); } - // --log-level 3: dump the ACTUAL TrackEntry elements about to be written - // (FlagInterlaced / FieldOrder / DefaultDuration / DefaultDecodedFieldDuration - // / Display dims / codecPrivate hex) so the Windows-fps-class metadata is - // verifiable from a log alone. No-op when diag is off. - for (i, track) in tracks.iter().enumerate() { - crate::diag::dump_mkv_track((i + 1) as u64, track); - } - - let mut muxer = MkvMuxer::new( - writer, - &tracks, - Some(&title.playlist), - title.duration_secs, - &title.chapters, - )?; - - // --log-level 3: capture the first ~100 coded frames per track to - // `.opening.bin`. Only opens the side file when diag is on AND a - // real output path is known; otherwise it's a no-op the muxer never sees. - if let Some(path) = output_path { - muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, tracks.len())); - } + // Defer muxer construction (and the TrackEntry dump) until the first + // coded picture arrives, so the primary video track's FieldOrder is set + // from the parser's MEASURED value before the header is written — never + // a guess. The dump moves to activation so it reflects the final track. + let video_track = tracks.iter().position(|t| t.track_type == 1); Ok(Self { disc_title: title.clone(), - mode: Mode::Write { - muxer: Some(Box::new(muxer)), - }, + mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux { + writer, + tracks, + video_track, + opening_capture_path: output_path.map(|p| p.to_path_buf()), + buffered: Vec::new(), + }))), }) } + /// Build the muxer from the pending state, setting the primary video track's + /// `FieldOrder` from the MEASURED `coding` of the first coded picture (when + /// available), then write the header and replay buffered frames. A no-op if + /// not pending. The muxer only ever muxes the track it is given — this routes + /// the parser's measured value onto that track first. + fn activate(&mut self, coding: Option) -> io::Result<()> { + let mut pending = match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building)) + { + Mode::Write(WriteMode::Pending(p)) => p, + // Not pending (already active / read): restore and bail. + other => { + self.mode = other; + return Ok(()); + } + }; + if let Some(vt) = pending.video_track { + apply_coding_to_track(&mut pending.tracks[vt], coding); + } + // --log-level 3: dump the FINAL TrackEntry metadata (field order set). + for (i, track) in pending.tracks.iter().enumerate() { + crate::diag::dump_mkv_track((i + 1) as u64, track); + } + let mut muxer = MkvMuxer::new( + pending.writer, + &pending.tracks, + Some(&self.disc_title.playlist), + self.disc_title.duration_secs, + &self.disc_title.chapters, + )?; + if let Some(path) = &pending.opening_capture_path { + muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len())); + } + for f in pending.buffered.drain(..) { + muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?; + } + self.mode = Mode::Write(WriteMode::Active(Box::new(muxer))); + Ok(()) + } + /// Open an MKV file for reading → PES frames. pub fn open(mut reader: impl Read + Send + 'static) -> io::Result { let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?; @@ -176,12 +237,42 @@ impl MkvStream { } } +/// Set a video track's `FieldOrder` from the MEASURED coding of the first coded +/// picture — the parser's value, the first time, never a guess. +/// +/// A progressive track has no field order (left UNDETERMINED — expected). An +/// INTERLACED track that reaches here with no measured field order is a +/// parser/source gap (MPEG-2 carries `top_field_first` on every interlaced +/// picture, so it should never be missing): LOG it loudly so the source can be +/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact. +fn apply_coding_to_track(track: &mut MkvTrack, coding: Option) { + if !track.interlaced { + return; + } + use crate::mux::codec::FieldOrder; + match coding.and_then(|c| c.field_order()) { + Some(FieldOrder::Tff) => track.field_order = ebml::FIELD_ORDER_TFF, + Some(FieldOrder::Bff) => track.field_order = ebml::FIELD_ORDER_BFF, + other => { + tracing::warn!( + target: "mux", + "interlaced video track reached the muxer with NO measured field order \ + (field_order={:?}, coding_present={}); writing FieldOrder=UNDETERMINED \ + — NOT a guess. Debug why the source/parser did not set top_field_first.", + other, + coding.is_some(), + ); + track.field_order = ebml::FIELD_ORDER_UNDETERMINED; + } + } +} + impl crate::pes::Stream for MkvStream { fn read(&mut self) -> io::Result> { let streams_len = self.disc_title.streams.len(); let rs = match self.mode { Mode::Read(ref mut rs) => rs, - Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()), + Mode::Write(_) => return Err(crate::error::Error::StreamWriteOnly.into()), }; loop { @@ -296,24 +387,68 @@ impl crate::pes::Stream for MkvStream { } fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { + // Fast paths. match &mut self.mode { - Mode::Write { muxer: Some(m) } => m.write_frame( - frame.track, - frame.pts, - frame.keyframe, - &frame.data, - frame.duration_ns, - ), - Mode::Write { muxer: None } => Ok(()), - Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()), + Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()), + Mode::Write(WriteMode::Active(m)) => { + return m.write_frame( + frame.track, + frame.pts, + frame.keyframe, + &frame.data, + frame.duration_ns, + ); + } + Mode::Write(WriteMode::Building) => return Ok(()), + Mode::Write(WriteMode::Pending(_)) => {} + } + // Pending: the first video frame (or the safety cap) triggers muxer + // construction; that frame's coding sets the field order. Other frames + // buffer until then. + let (activate_now, use_coding) = match &self.mode { + Mode::Write(WriteMode::Pending(p)) => { + let is_video = match p.video_track { + Some(vt) => frame.track == vt, + // No video track: nothing to wait for — build on frame one. + None => true, + }; + (is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video) + } + _ => unreachable!("guarded above"), + }; + if activate_now { + // Pass the trigger frame's coding only when it IS the video frame; a + // cap-triggered build never saw the video frame, so nothing measured + // is passed (apply_coding_to_track then logs + leaves UNDETERMINED). + self.activate(if use_coding { frame.coding } else { None })?; + if let Mode::Write(WriteMode::Active(m)) = &mut self.mode { + return m.write_frame( + frame.track, + frame.pts, + frame.keyframe, + &frame.data, + frame.duration_ns, + ); + } + Ok(()) + } else { + if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode { + p.buffered.push(frame.clone()); + } + Ok(()) } } fn finish(&mut self) -> io::Result<()> { - if let Mode::Write { ref mut muxer } = self.mode { - if let Some(m) = muxer.take() { - m.finish()?; - } + // A title that produced no frames (or only buffered ones) is still + // finalized into a valid MKV: activate now with no measured coding. + if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) { + self.activate(None)?; + } + if let Mode::Write(WriteMode::Active(m)) = + std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building)) + { + m.finish()?; } Ok(()) } @@ -614,7 +749,6 @@ fn parse_track( display_aspect: None, secondary: is_secondary, label: name, - top_field_first: None, measured_cicp: None, })) } @@ -683,6 +817,8 @@ fn parse_block( } Some(crate::pes::PesFrame { + coding: None, + source: None, track: track_idx, // saturating_mul: a hostile CLUSTER_TIMESTAMP could push pts_ticks near // i64::MAX, where ticks→ns would overflow and panic in debug builds. @@ -727,6 +863,97 @@ mod tests { use crate::pes::Stream as _; use std::io::Cursor; + #[test] + fn apply_coding_to_track_sets_measured_field_order_never_guesses() { + use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream}; + use crate::mux::codec::coding::{CodingType, Mpeg2Coding, PictureInfo}; + + let interlaced_track = || { + MkvTrack::video(&VideoStream { + pid: 0xE0, + codec: Codec::Mpeg2, + resolution: Resolution::R576i, // interlaced + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt470bg, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }) + }; + let pic = |tff: bool, pf: bool| { + PictureInfo::mpeg2( + CodingType::I, + Mpeg2Coding { + top_field_first: tff, + repeat_first_field: false, + progressive_frame: pf, + progressive_sequence: false, + frame_picture: true, + }, + ) + }; + + // A freshly built interlaced track has no field order — UNDETERMINED, + // never a scan-time guess. + assert_eq!( + interlaced_track().field_order, + ebml::FIELD_ORDER_UNDETERMINED + ); + + // MEASURED bottom-field-first → BFF (6). The red-flag fix. + let mut t = interlaced_track(); + apply_coding_to_track(&mut t, Some(pic(false, false))); + assert_eq!( + t.field_order, + ebml::FIELD_ORDER_BFF, + "measured BFF → FieldOrder=6" + ); + + // MEASURED top-field-first → TFF (1). + let mut t = interlaced_track(); + apply_coding_to_track(&mut t, Some(pic(true, false))); + assert_eq!( + t.field_order, + ebml::FIELD_ORDER_TFF, + "measured TFF → FieldOrder=1" + ); + + // Interlaced track, NO measured coding → UNDETERMINED (logged loudly, + // never faked). + let mut t = interlaced_track(); + apply_coding_to_track(&mut t, None); + assert_eq!( + t.field_order, + ebml::FIELD_ORDER_UNDETERMINED, + "no measured value → UNDETERMINED, never a guess" + ); + + // Progressive picture on an interlaced-flagged track → UNDETERMINED (no + // field order applies; not faked to TFF/BFF). + let mut t = interlaced_track(); + apply_coding_to_track(&mut t, Some(pic(true, true))); + assert_eq!(t.field_order, ebml::FIELD_ORDER_UNDETERMINED); + + // A PROGRESSIVE track is never touched — field order stays UNDETERMINED. + let mut prog = MkvTrack::video(&VideoStream { + pid: 0xE0, + codec: Codec::H264, + resolution: Resolution::R1080p, // progressive + frame_rate: FrameRate::F24, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }); + assert!(!prog.interlaced); + apply_coding_to_track(&mut prog, Some(pic(false, false))); + assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED); + } + // `From for io::Error` encodes the numeric code into the // Display string as "E{code}: ...". Check the prefix. /// Extract the error from a `MkvStream::open` result without requiring diff --git a/src/mux/network.rs b/src/mux/network.rs index a61336a..c537466 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -355,7 +355,6 @@ mod tests { display_aspect: None, secondary: false, label: "Main".into(), - top_field_first: None, measured_cicp: None, }), Stream::Audio(AudioStream { @@ -406,6 +405,8 @@ mod tests { .unwrap() .meta(&dt); let frame = pes::PesFrame { + coding: None, + source: None, track: 0, pts: 90000, keyframe: true, @@ -516,6 +517,8 @@ mod tests { let h = std::thread::spawn(move || { let mut ns = NetworkStream::accept_from(listener).unwrap(); let frame = pes::PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -568,6 +571,8 @@ mod tests { .meta(&dt); for i in 0..5u8 { let frame = pes::PesFrame { + coding: None, + source: None, track: (i % 2) as usize, pts: i as i64 * 90_000, keyframe: i == 0, diff --git a/src/mux/null.rs b/src/mux/null.rs index 1921f92..1a2d1d1 100644 --- a/src/mux/null.rs +++ b/src/mux/null.rs @@ -46,6 +46,8 @@ mod tests { let mut sink: Box = Box::new(NullStream::new(&title)); let frame = crate::pes::PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, @@ -77,6 +79,8 @@ mod tests { sink.finish().unwrap(); sink.finish().unwrap(); let frame = crate::pes::PesFrame { + coding: None, + source: None, track: 3, pts: 42, keyframe: false, diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 928212a..8bf4b7a 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -153,6 +153,8 @@ impl PipelinedPesStream { if skip_parse { // Profiling escape hatch — bypass codec parser. self.pending_frames.push_back(PesFrame { + coding: None, + source: None, track, pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0), keyframe: false, @@ -206,6 +208,7 @@ impl PipelinedPesStream { continue; }; let pes = PesPacket { + source: None, pid, pts: ps.pts.map(|p| p as i64), dts: ps.dts.map(|d| d as i64), @@ -371,6 +374,8 @@ mod tests { fn parse(&mut self, pes: &PesPacket) -> Vec { (0..self.per_pes) .map(|i| super::super::codec::Frame { + coding: None, + source: None, pts_ns: pes.pts.unwrap_or(0) + i as i64, keyframe: i == 0, data: pes.data.clone(), @@ -381,6 +386,8 @@ mod tests { fn flush(&mut self) -> Vec { (0..self.flush_n) .map(|_| super::super::codec::Frame { + coding: None, + source: None, pts_ns: 0, keyframe: false, data: vec![0xEE], @@ -395,6 +402,7 @@ mod tests { fn ts_pes(pid: u16, data: Vec) -> PesPacket { PesPacket { + source: None, pid, pts: Some(90_000), dts: None, @@ -559,6 +567,7 @@ mod tests { let (mut stream, tx) = make_stream(title, parsers, pid_to_track); let mappable = PsPacket { + source: None, stream_id: 0xBD, sub_stream_id: Some(0x80), pts: Some(90_000), @@ -567,6 +576,7 @@ mod tests { }; // stream_id 0xC0 (MPEG audio) has no DVD PID mapping → dropped. let unmappable = PsPacket { + source: None, stream_id: 0xC0, sub_stream_id: None, pts: None, @@ -618,6 +628,8 @@ mod tests { fn write_is_read_only_error() { let (mut stream, _tx) = make_stream(DiscTitle::empty(), vec![], vec![]); let frame = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: false, @@ -640,7 +652,6 @@ mod tests { display_aspect: None, secondary, label: String::new(), - top_field_first: None, measured_cicp: None, })); t @@ -799,7 +810,6 @@ mod tests { display_aspect: Some((4, 3)), secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })); let parsers: Vec<(u16, Box)> = @@ -822,6 +832,7 @@ mod tests { } let gop_pts = (g * gop_len as i64 * frame_ns * 90_000 / 1_000_000_000) as u64; tx.send(DemuxBatch::Ps(vec![PsPacket { + source: None, stream_id: 0xE0, sub_stream_id: None, pts: Some(gop_pts), diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 326c497..d1a3ea8 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -51,6 +51,10 @@ pub struct PsPacket { pub dts: Option, /// Elementary stream payload data. pub data: Vec, + /// Source position of this PES's first ES byte, stamped at the demux seam + /// from the producer's known stream offset. `None` when the demuxer was fed + /// without a base offset. + pub source: Option, } /// Canonical DVD video PID. DVD-Video carries a single MPEG-2 video @@ -129,6 +133,12 @@ impl PsPacket { /// Handles non-aligned input by buffering leftover bytes between calls. pub struct PsDemuxer { buffer: Vec, + /// Absolute source byte offset of `buffer[0]` — the running base that turns + /// an in-buffer unit position into a [`crate::pes::SourcePos`]. Advanced as + /// the buffer drains. `has_base` gates stamping so non-provenance callers + /// stay byte-identical. + buffer_base: u64, + has_base: bool, } impl Default for PsDemuxer { @@ -142,6 +152,8 @@ impl PsDemuxer { pub fn new() -> Self { Self { buffer: Vec::with_capacity(64 * 1024), + buffer_base: 0, + has_base: false, } } @@ -151,6 +163,24 @@ impl PsDemuxer { self.extract_packets(false) } + /// Like [`feed`](Self::feed) but records the absolute source byte offset of + /// `data[0]`, so every PES this call completes is stamped with a + /// [`crate::pes::SourcePos`]. The provenance-stamping entry point; the + /// highway calls this with each batch's known source offset. The base must + /// be the offset of the FIRST byte appended (i.e. of `data[0]`), which lines + /// up with the current buffer tail. + pub fn feed_at(&mut self, base_offset: u64, data: &[u8]) -> Vec { + if !self.has_base { + // First base seen: the offset of data[0] is base_offset, and data[0] + // lands at buffer[buffer.len()], so buffer[0] is base_offset minus + // the bytes already buffered. + self.buffer_base = base_offset.saturating_sub(self.buffer.len() as u64); + self.has_base = true; + } + self.buffer.extend_from_slice(data); + self.extract_packets(false) + } + /// Flush remaining buffered data, returning any final PES packets. pub fn flush(&mut self) -> Vec { // At EOF, an unbounded (length 0) PES with no trailing start code is @@ -255,7 +285,11 @@ impl PsDemuxer { e }; - if let Some(pkt) = parse_pes_packet(&self.buffer[sc..end]) { + if let Some(mut pkt) = parse_pes_packet(&self.buffer[sc..end]) { + if self.has_base { + pkt.source = + Some(crate::pes::SourcePos::at_byte(self.buffer_base + sc as u64)); + } packets.push(pkt); } pos = end; @@ -269,6 +303,11 @@ impl PsDemuxer { if pos > 0 { self.buffer.drain(..pos); + // Advance the absolute base past the drained bytes so subsequent + // units stamp from the correct offset. + if self.has_base { + self.buffer_base += pos as u64; + } } packets @@ -339,6 +378,9 @@ fn parse_pes_packet(data: &[u8]) -> Option { pts: None, dts: None, data: payload.to_vec(), + // Stamped by the demuxer (extract_packets) when a source base is + // threaded; the free function has no absolute offset of its own. + source: None, }); } @@ -393,6 +435,8 @@ fn parse_pes_packet(data: &[u8]) -> Option { pts, dts, data: es_data, + // Stamped by the demuxer (extract_packets) when a source base is threaded. + source: None, }) } @@ -788,6 +832,7 @@ mod tests { pts: None, dts: None, data: vec![0xAA], + source: None, } } diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index 2e829df..0c71420 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -159,7 +159,6 @@ mod tests { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })); // Index 0 = the video stream's codec init data. @@ -175,6 +174,8 @@ mod tests { fn write_on_input_stream_is_read_only_error() { let mut s = StdioStream::input(); let frame = crate::pes::PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: true, diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 3cb2c8d..d6a32a8 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -26,6 +26,10 @@ pub struct PesPacket { pub dts: Option, /// Elementary stream data (video frame, audio frame, subtitle segment, etc.). pub data: Vec, + /// Source position of this PES's first ES byte, stamped at the demux seam + /// from the producer's known stream offset. `None` when the demuxer was fed + /// without a base offset (callers that don't need provenance). + pub source: Option, } /// Per-PID PES reassembly state. @@ -51,6 +55,11 @@ struct PesAssembler { /// partial PES would inject corrupt bytes. The partial PES is dropped and /// the assembler resyncs on the next PUSI. `None` until the first packet. last_cc: Option, + /// Absolute source byte offset of the in-progress PES's first byte (the + /// PUSI packet that began it), or `None` when no source base is threaded. + /// Stamped at PES start, emitted on the completed packet — provenance is + /// carried, never reconstructed downstream. + pes_source: Option, } /// Initial capacity for a fresh PES buffer. Sized to cover the @@ -84,17 +93,26 @@ impl PesAssembler { active: false, header_remaining: 0, last_cc: None, + pes_source: None, } } /// Start a new PES packet. Returns the completed previous packet (if any). - fn start(&mut self, pts: Option, dts: Option) -> Option { + /// `source` is the absolute source position of the new PES's first byte + /// (carried onto the completed packet at the next start / flush). + fn start( + &mut self, + pts: Option, + dts: Option, + source: Option, + ) -> Option { let completed = if self.active && !self.buffer.is_empty() { Some(PesPacket { pid: self.pid, pts: self.pts, dts: self.dts, data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)), + source: self.pes_source, }) } else { self.buffer.clear(); @@ -103,6 +121,7 @@ impl PesAssembler { self.pts = pts; self.dts = dts; self.active = true; + self.pes_source = source; completed } @@ -139,6 +158,7 @@ impl PesAssembler { pts: self.pts, dts: self.dts, data: std::mem::take(&mut self.buffer), + source: self.pes_source, }) } else { None @@ -151,6 +171,14 @@ pub struct TsDemuxer { assemblers: Vec, pid_index: Vec, // PID → index into assemblers, -1 = not tracked remainder: Vec, // leftover bytes from previous feed() call + /// Absolute source byte offset of the NEXT byte to be fed — the running + /// base that turns an in-buffer packet offset into a source position. + /// Advanced by each `feed` by the bytes consumed; `feed` (no base) leaves + /// it at 0 so non-provenance callers stamp `None`. + feed_base: u64, + /// True once a caller has threaded a source base via [`feed_at`]. Until + /// then no `SourcePos` is stamped (keeps existing callers byte-identical). + has_base: bool, } impl TsDemuxer { @@ -185,6 +213,8 @@ impl TsDemuxer { assemblers, pid_index, remainder: Vec::new(), + feed_base: 0, + has_base: false, } } @@ -201,6 +231,27 @@ impl TsDemuxer { /// `data` in place. Zero-copy on the bulk path; one 192-byte copy /// on the boundary. pub fn feed(&mut self, data: &[u8]) -> Vec { + self.feed_inner(data) + } + + /// Like [`feed`](Self::feed) but records the absolute source byte offset of + /// `data[0]` first, so every PES this batch completes is stamped with a + /// [`crate::pes::SourcePos`]. The single provenance-stamping entry point; + /// the highway calls this with each batch's known source offset. + pub fn feed_at(&mut self, base_offset: u64, data: &[u8]) -> Vec { + self.feed_base = base_offset; + self.has_base = true; + self.feed_inner(data) + } + + /// Source position for a packet whose first byte is at `buf_offset` within + /// the current feed buffer — `None` until a base has been threaded. + fn pkt_source(&self, buf_offset: usize) -> Option { + self.has_base + .then(|| crate::pes::SourcePos::at_byte(self.feed_base + buf_offset as u64)) + } + + fn feed_inner(&mut self, data: &[u8]) -> Vec { let mut completed = Vec::with_capacity(4); let mut offset = 0; @@ -218,15 +269,26 @@ impl TsDemuxer { boundary[..self.remainder.len()].copy_from_slice(&self.remainder); boundary[self.remainder.len()..].copy_from_slice(&data[..need]); self.remainder.clear(); - self.process_packet(&boundary, &mut completed); + // The boundary packet began in the PREVIOUS feed buffer; stamp it + // with the offset just before this buffer (its first bytes' base). + let src = self + .has_base + .then(|| crate::pes::SourcePos::at_byte(self.feed_base.saturating_sub(1))); + self.process_packet(&boundary, src, &mut completed); offset = need; } // Aligned-packets fast path — reads directly out of `data`. while offset + BD_TS_PACKET_SIZE <= data.len() { let packet = &data[offset..offset + BD_TS_PACKET_SIZE]; + let src = self.pkt_source(offset); offset += BD_TS_PACKET_SIZE; - self.process_packet(packet, &mut completed); + self.process_packet(packet, src, &mut completed); + } + // Advance the running base past every byte consumed this feed so the + // next batch stamps from the correct absolute offset. + if self.has_base { + self.feed_base += offset as u64; } // Save leftover bytes for next call (cap at one packet to @@ -248,7 +310,12 @@ impl TsDemuxer { /// `PesAssembler`; completed PES packets are pushed onto /// `completed` so the caller's allocation amortises across the /// batch. - fn process_packet(&mut self, packet: &[u8], completed: &mut Vec) { + fn process_packet( + &mut self, + packet: &[u8], + source: Option, + completed: &mut Vec, + ) { // Sync byte check skips malformed packets. if packet[4] != SYNC_BYTE { return; @@ -331,7 +398,7 @@ impl TsDemuxer { // `header_len` is the FULL (uncapped) PES-header length: // 0 = malformed (payload is not a PES start), else 6/9+N. let (pts, dts, header_len) = parse_pes_header(payload); - if let Some(prev) = asm.start(pts, dts) { + if let Some(prev) = asm.start(pts, dts, source) { completed.push(prev); } if header_len == 0 { @@ -694,7 +761,6 @@ pub fn scan_streams(data: &[u8]) -> Option> { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })) } diff --git a/src/pes.rs b/src/pes.rs index e359b02..a502f86 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -12,6 +12,35 @@ /// and then hard-erroring mid-stream on read. const MAX_FRAME_SIZE: usize = 256 * 1024 * 1024; // 256 MiB +/// Where a unit's first byte came from in the SOURCE address space. +/// +/// `byte` is the absolute byte offset of the unit's first byte within the +/// source stream the producer emits (the decrypted sector stream / ISO); +/// `sector` is that offset's 2048-byte logical sector (`byte / 2048`). One +/// value, **stamped once at the demux seam and propagated unchanged** through +/// every pipeline stage to the emitted frame — no stage recomputes it. This is +/// the load-bearing column for a frame-accurate source index (random-access +/// position) and is reusable for loss-to-timestamp mapping and seek indexing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SourcePos { + /// Source logical sector (2048-byte) of the unit's first byte. + pub sector: u64, + /// Absolute source byte offset of the unit's first byte. + pub byte: u64, +} + +impl SourcePos { + /// Build a `SourcePos` from an absolute source byte offset, deriving the + /// 2048-byte sector. The single construction helper — callers stamp the + /// byte offset they know and the sector follows, so the two never disagree. + pub fn at_byte(byte: u64) -> Self { + Self { + sector: byte / 2048, + byte, + } + } +} + /// One frame of elementary stream data. #[derive(Debug, Clone)] pub struct PesFrame { @@ -28,6 +57,16 @@ pub struct PesFrame { /// the PGS parser so the MKV muxer can emit `BlockDuration`; also /// preserved across network:// and stdio:// hops. pub duration_ns: Option, + /// Byte-exact source provenance of this frame's first byte, stamped at the + /// demux seam. `None` for synthetic sources / the `skip_parse` path and for + /// the `deserialize` (network/stdio) hop — NOT serialized on the wire. + pub source: Option, + /// Codec-agnostic per-picture coding info (field order / type / pulldown), + /// set by the video parser; `None` for audio/subtitle frames, codecs that + /// do not yet fill it, and the deserialize hop. The muxer reads it through + /// the [`crate::mux::codec::PictureInfo`] accessors to stamp `FieldOrder` / + /// `DefaultDuration` — never re-deriving from the bitstream. + pub coding: Option, } /// Sentinel value for `duration_ns` on the wire: `u64::MAX` means `None`. @@ -115,6 +154,11 @@ impl PesFrame { keyframe, data, duration_ns, + // Provenance and coding are not carried on the wire — a frame read + // back from a network:// / stdio:// / .pes hop has no source bytes + // or parser context to attribute. + source: None, + coding: None, })) } @@ -129,6 +173,8 @@ impl PesFrame { keyframe: frame.keyframe, data: frame.data, duration_ns: frame.duration_ns, + source: frame.source, + coding: frame.coding, } } } @@ -266,6 +312,8 @@ mod tests { fn make_frame(track: usize, pts: i64) -> PesFrame { PesFrame { + coding: None, + source: None, track, pts, keyframe: track == 0 && pts == 0, @@ -444,6 +492,8 @@ mod tests { fn serialize_wire_format_matches_spec() { // Wire format: [track(1)][pts_le(8)][keyframe(1)][duration_le(8)][len_le(4)][data...] let frame = PesFrame { + coding: None, + source: None, track: 2, pts: 0x0102030405060708_i64, keyframe: true, @@ -490,6 +540,8 @@ mod tests { #[test] fn serialize_keyframe_false_encodes_as_zero() { let frame = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: false, @@ -511,6 +563,8 @@ mod tests { #[test] fn serialize_track_255_is_ok_track_256_is_err() { let ok_frame = PesFrame { + coding: None, + source: None, track: 255, pts: 0, keyframe: false, @@ -522,6 +576,8 @@ mod tests { assert_eq!(buf[0], 255, "track 255 must serialize to 0xFF"); let too_large = PesFrame { + coding: None, + source: None, track: 256, pts: 0, keyframe: false, @@ -541,6 +597,8 @@ mod tests { fn deserialize_round_trips_pts_boundaries() { for pts in [0_i64, i64::MAX, i64::MIN] { let frame = PesFrame { + coding: None, + source: None, track: 0, pts, keyframe: false, @@ -561,6 +619,8 @@ mod tests { #[test] fn deserialize_accepts_zero_length_data() { let frame = PesFrame { + coding: None, + source: None, track: 3, pts: 99, keyframe: false, @@ -586,6 +646,8 @@ mod tests { fn deserialize_duration_ns_roundtrips() { // None encodes as u64::MAX sentinel and decodes back to None. let frame_none = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: false, @@ -603,6 +665,8 @@ mod tests { // Some(0) must survive — 0 is a valid zero-length duration, not the sentinel. let frame_zero = PesFrame { + coding: None, + source: None, track: 1, pts: 1000, keyframe: false, @@ -621,6 +685,8 @@ mod tests { // Some(N) for a typical PGS duration (~3 seconds). let frame_n = PesFrame { + coding: None, + source: None, track: 2, pts: 5_000_000_000, keyframe: false, @@ -644,6 +710,8 @@ mod tests { #[test] fn deserialize_two_sequential_frames() { let f1 = PesFrame { + coding: None, + source: None, track: 0, pts: 100, keyframe: true, @@ -651,6 +719,8 @@ mod tests { duration_ns: None, }; let f2 = PesFrame { + coding: None, + source: None, track: 1, pts: 200, keyframe: false, @@ -679,6 +749,8 @@ mod tests { #[test] fn counting_stream_accumulates_across_multiple_writes() { let f1 = PesFrame { + coding: None, + source: None, track: 0, pts: 0, keyframe: false, @@ -686,6 +758,8 @@ mod tests { duration_ns: None, }; let f2 = PesFrame { + coding: None, + source: None, track: 0, pts: 1, keyframe: false, diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 294d380..3367bf6 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -169,7 +169,6 @@ fn title_with_video( display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })], chapters: Vec::new(), diff --git a/tests/streams.rs b/tests/streams.rs index 7bba351..8597daf 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -23,7 +23,6 @@ fn sample_disc_title() -> DiscTitle { display_aspect: None, secondary: false, label: "Main".into(), - top_field_first: None, measured_cicp: None, }), Stream::Audio(AudioStream { @@ -300,6 +299,8 @@ fn m2ts_stream_write_read() { // Write some PES frames for i in 0..5u8 { let frame = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 0, pts: i as i64 * 1_000_000, keyframe: i == 0, @@ -322,6 +323,8 @@ fn m2ts_stream_write_read() { fn m2ts_pes_frame_roundtrip() { // PesFrame serialize/deserialize roundtrip let frame = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 2, pts: 1_234_567_890, keyframe: true, @@ -403,7 +406,6 @@ fn meta_codec_roundtrip() { display_aspect: None, secondary: false, label: String::new(), - top_field_first: None, measured_cicp: None, })); } @@ -507,7 +509,6 @@ fn meta_all_stream_types() { display_aspect: None, secondary: false, label: "Primary".into(), - top_field_first: None, measured_cicp: None, }), Stream::Audio(AudioStream { @@ -596,6 +597,8 @@ fn mkvstream_write_finish() { // since there is no real codec data, but it should not panic) for i in 0..20u8 { let frame = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 0, pts: i as i64 * 1_000_000, keyframe: i == 0, @@ -658,6 +661,8 @@ fn mkvstream_roundtrip_bdts() { // Write PES frames targeting the audio track for i in 0..10u8 { let frame = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 0, pts: i as i64 * 1_000_000, keyframe: true, @@ -694,7 +699,6 @@ fn mkvstream_meta_preserves_all_streams() { display_aspect: None, secondary: false, label: "Main Video".into(), - top_field_first: None, measured_cicp: None, }), Stream::Audio(AudioStream { @@ -795,7 +799,6 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { display_aspect: None, secondary: false, label: "Main".into(), - top_field_first: None, measured_cicp: None, })], chapters: Vec::new(), @@ -857,6 +860,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { // Write the ES data (SPS+PPS+IDR) as a keyframe PES frame. let frame1 = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 0, pts: 1_000_000_000, // 1 second in ns keyframe: true, @@ -867,6 +872,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() { // Write a second non-IDR frame let frame2 = libfreemkv::pes::PesFrame { + coding: None, + source: None, track: 0, pts: 1_041_700_000, // ~1 frame later in ns keyframe: false,