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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
+51
-102
@@ -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<W: Write + Seek> MkvMuxer<W> {
|
||||
},
|
||||
)?;
|
||||
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<W: Write + Seek> MkvMuxer<W> {
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+272
-45
@@ -76,15 +76,50 @@ struct ReadState {
|
||||
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// 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<Box<MkvMuxer<Box<dyn WriteSeek + Send>>>>,
|
||||
},
|
||||
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<PendingMux>),
|
||||
/// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum
|
||||
/// small (clippy::large_enum_variant).
|
||||
Active(Box<MkvMuxer<Box<dyn WriteSeek + Send>>>),
|
||||
/// 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<dyn WriteSeek + Send>,
|
||||
tracks: Vec<MkvTrack>,
|
||||
/// 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<usize>,
|
||||
/// `--log-level 3` opening-capture side-file path (if any).
|
||||
opening_capture_path: Option<std::path::PathBuf>,
|
||||
/// Frames received before activation, replayed in order once built.
|
||||
buffered: Vec<crate::pes::PesFrame>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
// `<output>.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<crate::mux::codec::PictureInfo>) -> 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<Self> {
|
||||
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<crate::mux::codec::PictureInfo>) {
|
||||
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<Option<crate::pes::PesFrame>> {
|
||||
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<Error> 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
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
@@ -46,6 +46,8 @@ mod tests {
|
||||
let mut sink: Box<dyn Stream> = 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,
|
||||
|
||||
@@ -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<super::super::codec::Frame> {
|
||||
(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<super::super::codec::Frame> {
|
||||
(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<u8>) -> 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<dyn CodecParser>)> =
|
||||
@@ -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),
|
||||
|
||||
+46
-1
@@ -51,6 +51,10 @@ pub struct PsPacket {
|
||||
pub dts: Option<u64>,
|
||||
/// Elementary stream payload data.
|
||||
pub data: Vec<u8>,
|
||||
/// 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<crate::pes::SourcePos>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
/// 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<PsPacket> {
|
||||
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<PsPacket> {
|
||||
// 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<PsPacket> {
|
||||
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<PsPacket> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+72
-6
@@ -26,6 +26,10 @@ pub struct PesPacket {
|
||||
pub dts: Option<i64>,
|
||||
/// Elementary stream data (video frame, audio frame, subtitle segment, etc.).
|
||||
pub data: Vec<u8>,
|
||||
/// 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<crate::pes::SourcePos>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
/// 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<crate::pes::SourcePos>,
|
||||
}
|
||||
|
||||
/// 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<i64>, dts: Option<i64>) -> Option<PesPacket> {
|
||||
/// `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<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<crate::pes::SourcePos>,
|
||||
) -> Option<PesPacket> {
|
||||
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<PesAssembler>,
|
||||
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
|
||||
remainder: Vec<u8>, // 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<PesPacket> {
|
||||
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<PesPacket> {
|
||||
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<crate::pes::SourcePos> {
|
||||
self.has_base
|
||||
.then(|| crate::pes::SourcePos::at_byte(self.feed_base + buf_offset as u64))
|
||||
}
|
||||
|
||||
fn feed_inner(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
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<PesPacket>) {
|
||||
fn process_packet(
|
||||
&mut self,
|
||||
packet: &[u8],
|
||||
source: Option<crate::pes::SourcePos>,
|
||||
completed: &mut Vec<PesPacket>,
|
||||
) {
|
||||
// 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<Vec<crate::disc::Stream>> {
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
top_field_first: None,
|
||||
measured_cicp: None,
|
||||
}))
|
||||
}
|
||||
|
||||
+74
@@ -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<u64>,
|
||||
/// 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<SourcePos>,
|
||||
/// 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<crate::mux::codec::PictureInfo>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
Reference in New Issue
Block a user