mux: codec-agnostic PictureInfo + provenance; measure field order, never guess

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.
This commit is contained in:
Matthew Jackson
2026-06-25 20:13:55 -07:00
parent 43fb97f71f
commit e3dbafcebd
32 changed files with 1188 additions and 192 deletions
+17
View File
@@ -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<u8>) -> PesPacket {
PesPacket {
source: None,
pid: 0,
pts: Some(90000),
dts: None,
+311
View File
@@ -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<FieldOrder> {
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<bool> {
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);
}
}
+5
View File
@@ -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<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
+7
View File
@@ -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<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1200,
pts,
dts: None,
+6
View File
@@ -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<u8>, pts: Option<i64>) -> 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,
+5
View File
@@ -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<u8>, pts: Option<i64>) -> 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),
+3
View File
@@ -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<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
+20
View File
@@ -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<u64>,
/// 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<PictureInfo>,
/// 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<crate::pes::SourcePos>,
}
/// Convert 90kHz PTS to nanoseconds (round to nearest).
@@ -107,6 +124,8 @@ impl CodecParser for PassthroughParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
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<i64>, data: Vec<u8>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1011,
pts,
dts: None,
+208 -7
View File
@@ -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<i64>,
/// 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<u8>, 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<u8>, pts: Option<i64>) -> 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,
+13
View File
@@ -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<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1200,
pts,
dts: None,
+3
View File
@@ -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<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1100,
pts,
dts: None,
+6
View File
@@ -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<u8>, pts: Option<i64>) -> 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,