Files
libfreemkv/src/mux/codec/mpeg2.rs
T
Matthew Jackson 7d852419b5 audit: byte caps on GOP buffers, opener-scan resume, honest video codec
Round-6 findings from the 10-phase release audit:

- Wire the documented MAX_PENDING_BYTES byte cap into the MPEG-2 GOP
  buffer (it was dead code) and add an equivalent MAX_GOP_BYTES cap to the
  sparse-PTS reorder, so a crafted stream of few-but-huge access units
  cannot over-allocate — both were bounded only by frame count before.
- probe_evo_streams defaulted an unsniffable HD-DVD video stream to H.264,
  which mis-parses a VC-1 (or still-encrypted) clip into a corrupt track.
  Emit the video stream only when the codec is actually identified — the
  honest outcome, matching the audio path (a real clear clip always carries
  its sequence header at the head).
- Resume the AU-opener search from a cursor (like the boundary search), so
  a long unsynced junk run is O(bytes), not O(buffer) per push.
- Mark mpeg2's now-dead MAX_AU_BUFFER test-only; restore #[doc(hidden)] on
  the aacs probe harness module.
- Add regression tests: the 0xFD video-routing guard, the FMTS-is-UHD key
  state, and the GOP byte caps.
2026-07-09 18:31:47 -07:00

1519 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! MPEG-2 Video elementary stream parser.
//!
//! Reassembles coded pictures (access units) from the demuxed PES stream and
//! extracts sequence headers for MKV codecPrivate.
//!
//! **One PES is NOT one frame.** On a DVD the video elementary stream is sliced
//! into ~2 KB Program-Stream PES packets (one per 2048-byte pack), so a single
//! coded picture (~10-100 KB) spans many PES packets and only the first carries
//! a PTS. Emitting one MKV block per PES would write frame *fragments* — the
//! decoder then sees truncated pictures (`ac-tex damaged`) and picture-coding
//! extensions detached from their picture header (`ignoring pic cod ext`). So
//! this parser buffers ES bytes across PES packets and emits exactly one Frame
//! per coded picture. (Blu-ray aligns one access unit per PES and would not need
//! this, but DVD MPEG-2 PS does.)
//!
//! Access-unit model (ISO/IEC 13818-2): an AU is an optional sequence header +
//! optional GOP header + one picture header + its coding extension + slices. A
//! new AU begins at the next picture / sequence / GOP start code *once the
//! current AU already contains a picture* — leading sequence/GOP headers attach
//! to the picture that follows them.
//!
//! Start codes:
//! - Picture header: 00 00 01 00
//! - Slice: 00 00 01 01 .. AF
//! - Sequence header: 00 00 01 B3
//! - Extension (seq/pic):00 00 01 B5
//! - GOP header: 00 00 01 B8
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
use super::startcode::find_start_code;
use super::{CodecParser, Frame, pts_to_ns};
use crate::mux::ts::PesPacket;
/// Sequence header start code suffix.
const SEQ_HEADER_CODE: u8 = 0xB3;
/// Sequence / picture extension start code suffix.
const SEQ_EXT_CODE: u8 = 0xB5;
/// Group-of-pictures header start code suffix.
const GOP_CODE: u8 = 0xB8;
/// Picture start code suffix.
const PICTURE_CODE: u8 = 0x00;
/// Picture coding type: I-frame.
const PICTURE_TYPE_I: u8 = 1;
/// The access-unit reassembly cap now lives in [`crate::mux::au_assembly`] (the
/// `AuAssembler` owns cross-PES buffering); this mirror exists only so the
/// force-flush test below can size an over-cap fixture against the same bound.
#[cfg(test)]
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in
/// the first VOBU (~0.5 s ≈ 15 frames); this leaves generous slack. If no PTS
/// ever arrives within the cap, buffered frames are released on a 0 base.
const MAX_PENDING_FRAMES: usize = 600;
/// Byte cap on frames held awaiting the first PES PTS anchor. `MAX_PENDING_FRAMES`
/// alone bounds the *count*, but 600 full HD/UHD intra pictures can be ~1 GiB.
/// Mirror the AC-3/DTS/PGS byte caps: once the held data exceeds this, release
/// on the 0 base instead of accumulating further. 8 MiB ≈ a few large I-frames,
/// far more than the ~15 frames a well-formed DVD buffers before its first PTS.
const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024;
/// Frame rate table (index from sequence header frame_rate_code).
const FRAME_RATES: [(u32, u32); 9] = [
(0, 1), // 0: forbidden
(24000, 1001), // 1: 23.976
(24, 1), // 2: 24
(25, 1), // 3: 25
(30000, 1001), // 4: 29.97
(30, 1), // 5: 30
(50, 1), // 6: 50
(60000, 1001), // 7: 59.94
(60, 1), // 8: 60
];
/// Aspect ratio table (index from sequence header aspect_ratio_information).
const ASPECT_RATIOS: [(u8, u8); 5] = [
(0, 0), // 0: forbidden
(1, 1), // 1: square pixels (1:1 SAR)
(4, 3), // 2: 4:3 display
(16, 9), // 3: 16:9 display
(221, 100), // 4: 2.21:1 display
];
/// MPEG-2 Video elementary stream parser / access-unit reassembler.
pub struct Mpeg2Parser {
/// Raw bytes of the last seen sequence header (+ sequence extension if
/// present), captured for MKV codecPrivate.
seq_header: Option<Vec<u8>>,
/// Reassembles PES fragments into complete access units (one coded picture
/// with its leading sequence/GOP headers) and carries each AU's start
/// timing / source / discontinuity forward — the shared machinery the
/// H.264/HEVC/VC-1 parsers also use, in its MPEG-2 mode.
au_asm: crate::mux::au_assembly::AuAssembler,
/// 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
/// 3-field durations. 0 until a sequence header with a valid frame rate.
frame_duration_ns: i64,
/// `progressive_sequence` from the sequence extension — selects the
/// `nb_fields` rules for `repeat_first_field` pictures.
progressive_sequence: bool,
/// Pictures of the current GOP, buffered in DECODE order until the GOP
/// completes (the next GOP/sequence header). Held so each frame's PTS can be
/// the display-order prefix-sum of field durations — exact for 2:3 pulldown
/// without ever reordering emitted blocks (B-frames keep decode order; only
/// their PTS is lower).
gop_buf: Vec<BufferedPicture>,
/// Total field-display periods of all frames already emitted, in display
/// order — the running base for each new frame's display time.
emitted_fields: u64,
/// PTS (ns) that display-field 0 of the whole stream maps to. Re-locked from
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
/// audio. None until the first PES timestamp is seen.
origin_pts_ns: Option<i64>,
}
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
struct BufferedPicture {
/// `temporal_reference` — display order within the GOP.
tr: u64,
/// 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).
frame: Frame,
}
impl Default for Mpeg2Parser {
fn default() -> Self {
Self::new()
}
}
impl Mpeg2Parser {
/// Create a new MPEG-2 parser with no captured sequence-header state.
pub fn new() -> Self {
Self {
seq_header: None,
au_asm: crate::mux::au_assembly::AuAssembler::mpeg2(),
frame_duration_ns: 0,
progressive_sequence: false,
gop_buf: Vec::new(),
emitted_fields: 0,
origin_pts_ns: None,
}
}
/// Extract resolution from a captured sequence header.
/// Returns (width, height) or None if the header is too short.
pub fn resolution(&self) -> Option<(u16, u16)> {
let hdr = self.seq_header.as_ref()?;
parse_resolution(hdr)
}
/// Extract frame rate from a captured sequence header.
/// Returns (numerator, denominator) or None.
pub fn frame_rate(&self) -> Option<(u32, u32)> {
let hdr = self.seq_header.as_ref()?;
parse_frame_rate(hdr)
}
/// Extract aspect ratio from a captured sequence header.
/// Returns (width, height) for display aspect ratio, or None.
pub fn aspect_ratio(&self) -> Option<(u8, u8)> {
let hdr = self.seq_header.as_ref()?;
parse_aspect_ratio(hdr)
}
/// Process one reassembled access unit (from [`AuAssembler`]): decode its
/// per-picture coding info, capture a new sequence header, and buffer the
/// picture into the current GOP for display-order timestamping. The AU's
/// timing / source / discontinuity were already attributed by the assembler.
fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) {
let data = au.data;
// An access unit must contain a coded picture; a fragment that assembled
// without one (only headers, or truncated at EOF) yields nothing.
let Some(pic) = find_code(&data, 0, PICTURE_CODE) else {
return;
};
let end = data.len();
// Capture a sequence header for codecPrivate; a new one replaces the
// stored value and re-locks the frame duration.
if let Some(h) = extract_seq_header(&data) {
self.progressive_sequence = parse_progressive_sequence(&h);
self.seq_header = Some(h);
if let Some((num, den)) = self.frame_rate() {
if num > 0 {
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
}
}
}
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
// resetting temporal_reference to 0.
let gop_boundary = find_code(&data, 0, GOP_CODE).is_some()
|| find_code(&data, 0, SEQ_HEADER_CODE).is_some();
// picture_coding_type: the full 3-bit value (bits 5-3 of data[pic+5]).
// 0 when the picture header is truncated (no coding type available).
let raw_coding_type = if pic + 5 < end {
(data[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 {
(((data[pic + 4] as u64) << 2) | ((data[pic + 5] as u64) >> 6)) & 0x3FF
} else {
0
};
// 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();
// 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 boundary, keeping
// each GOP's display order self-contained.
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(out);
}
self.gop_buf.push(BufferedPicture {
tr,
info,
explicit_pts: au.pts,
frame: Frame {
pts_ns: 0,
keyframe,
// The assembler attributes the concealed-gap flag to the AU whose
// own bytes begin after the gap — the first post-gap picture — so
// it rides through GOP buffering/reorder to the ResyncGate.
discontinuity: au.discontinuity,
data,
duration_ns: None,
coding: Some(info),
source: au.source,
},
});
// Safety cap: a stream with no GOP/sequence boundaries would buffer
// unbounded. Force-flush a pathologically long run as its own GOP —
// bounded by BOTH the frame count and the total buffered bytes, so a
// crafted stream of few-but-huge pictures cannot over-allocate either.
let gop_bytes: usize = self.gop_buf.iter().map(|p| p.frame.data.len()).sum();
if self.gop_buf.len() >= MAX_PENDING_FRAMES || gop_bytes >= MAX_PENDING_BYTES {
self.flush_gop(out);
}
}
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
/// field durations from the timeline origin; its block duration is its own
/// `nb_fields × field_period`. Frames are emitted in DECODE (buffer) order —
/// B-frames keep their position with a correctly LOWER PTS, never reordered
/// (reordering emitted blocks is what corrupts the picture). The origin is
/// (re-)locked to the GOP's PES PTS; because that is a *presentation*
/// timestamp, backing out the carrying frame's display-field offset keeps the
/// timeline continuous and monotonic across GOP boundaries.
fn flush_gop(&mut self, out: &mut Vec<Frame>) {
let n = self.gop_buf.len();
if n == 0 {
return;
}
let field_period = self.frame_duration_ns / 2;
if field_period <= 0 {
// No sequence header / frame rate yet (malformed lead-in): emit in
// decode order off each AU's own PES PTS, with no field timing.
for bp in self.gop_buf.drain(..) {
let mut f = bp.frame;
f.pts_ns = bp.explicit_pts.unwrap_or(0);
out.push(f);
}
return;
}
// Fields displayed BEFORE each picture within this GOP: order indices by
// temporal_reference (display order) and prefix-sum `nb_fields`.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| self.gop_buf[i].tr);
let mut cum_before = vec![0u64; n];
let mut running = 0u64;
for &i in &order {
cum_before[i] = running;
running += self.gop_buf[i].info.nb_fields() as u64;
}
let gop_fields = running;
let base = self.emitted_fields;
// (Re-)lock the timeline origin to the GOP's PES PTS.
for &i in &order {
if let Some(p) = self.gop_buf[i].explicit_pts {
self.origin_pts_ns = Some(p - field_period * (base + cum_before[i]) as i64);
break;
}
}
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.info.nb_fields() as u64 * field_period as u64);
out.push(bp.frame);
}
self.emitted_fields += gop_fields;
}
}
impl CodecParser for Mpeg2Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
// Feed the fragment to the assembler, which reframes the elementary
// stream on picture boundaries and hands back each complete access unit
// with its start timing. MKV block timecodes are presentation timestamps;
// prefer PTS (DTS shows B-frames in decode order — judder and broken
// seeking), falling back to DTS only when PTS is absent.
let pts = pes.pts.or(pes.dts).map(pts_to_ns);
let aus = self
.au_asm
.push(&pes.data, pts, None, pes.source, pes.discontinuity);
let mut out = Vec::new();
for au in aus {
self.process_au(au, &mut out);
}
out
}
fn flush(&mut self) -> Vec<Frame> {
// Force-complete the trailing access unit, then flush the final GOP so
// nothing is left buffered at EOF.
let mut out = Vec::new();
for au in self.au_asm.flush() {
self.process_au(au, &mut out);
}
self.flush_gop(&mut out);
out
}
fn codec_private(&self) -> Option<Vec<u8>> {
self.seq_header.clone()
}
}
/// Extract the sequence header (+ any B5 extensions / user-data, up to the
/// first GOP or picture start code) from a fully-assembled access unit — exactly
/// the extradata an MPEG-2 decoder expects as codecPrivate. Returns None if the
/// access unit carries no sequence header. A NEW header replaces the stored one
/// (title boundary / channel change), so its extension is always re-captured.
fn extract_seq_header(au: &[u8]) -> Option<Vec<u8>> {
let b3 = find_code(au, 0, SEQ_HEADER_CODE)?;
let mut end = au.len();
let mut p = b3 + 4;
while let Some(sc) = find_start_code(au, p) {
if sc + 3 >= au.len() {
break;
}
let c = au[sc + 3];
if c == PICTURE_CODE || c == GOP_CODE {
end = sc;
break;
}
p = sc + 4;
}
Some(au[b3..end].to_vec())
}
/// Find the next start code at or after `from` whose code byte equals `want`.
fn find_code(data: &[u8], from: usize, want: u8) -> Option<usize> {
let mut pos = from;
while let Some(sc) = find_start_code(data, pos) {
if sc + 3 >= data.len() {
return None;
}
if data[sc + 3] == want {
return Some(sc);
}
pos = sc + 4;
}
None
}
/// Parse horizontal and vertical resolution from sequence header bytes.
/// The sequence header must start with 00 00 01 B3.
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
// Need at least start code (4) + 4 bytes of header data = 8 bytes.
if hdr.len() < 8 {
return None;
}
// Bytes 4-5: horizontal_size_value (12 bits) | vertical_size_value top 4 bits
// Bytes 5-6: vertical_size_value bottom 8 bits (12 bits total)
let h = ((hdr[4] as u16) << 4) | ((hdr[5] as u16) >> 4);
let v = (((hdr[5] & 0x0F) as u16) << 8) | hdr[6] as u16;
Some((h, v))
}
/// Parse frame rate code from sequence header.
fn parse_frame_rate(hdr: &[u8]) -> Option<(u32, u32)> {
if hdr.len() < 8 {
return None;
}
let frame_rate_code = (hdr[7] & 0x0F) as usize;
if frame_rate_code == 0 || frame_rate_code >= FRAME_RATES.len() {
return None;
}
Some(FRAME_RATES[frame_rate_code])
}
/// Parse aspect ratio information from sequence header.
fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> {
if hdr.len() < 8 {
return None;
}
let ar_code = ((hdr[7] >> 4) & 0x0F) as usize;
if ar_code == 0 || ar_code >= ASPECT_RATIOS.len() {
return None;
}
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
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
/// gap (the "play, pause, play" judder). `progressive_sequence` comes from the
/// sequence extension. Returns 2 (a normal frame) when no picture coding
/// extension is present.
fn picture_nb_fields(au: &[u8], progressive_sequence: bool) -> u8 {
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. A field picture
// (01/10) occupies a single field; two combine into one frame upstream.
if e2 & 0x03 != 0b11 {
return 1;
}
let tff = (e3 >> 7) & 1;
let rff = (e3 >> 1) & 1;
let progressive_frame = (e4 >> 7) & 1;
let repeat_pict = if rff == 0 {
0
} else if progressive_sequence {
if tff == 1 { 4 } else { 2 }
} else if progressive_frame == 1 {
1
} else {
0
};
return repeat_pict + 2;
}
2
}
/// Read `progressive_sequence` from a captured sequence header's sequence
/// extension (`00 00 01 B5`, ext-id `0001`). False when absent (MPEG-1 / no
/// extension) — the interlaced default. Bit layout after the start code:
/// ext-id(4) profile_and_level(8) **progressive_sequence(1)** … so it is bit 3
/// of the second extension byte (`hdr[q+5]`).
fn parse_progressive_sequence(hdr: &[u8]) -> bool {
let mut search = 0;
while let Some(q) = find_code(hdr, search, SEQ_EXT_CODE) {
search = q + 4;
if hdr.get(q + 4).map(|b| b >> 4) != Some(0b0001) {
continue;
}
return hdr.get(q + 5).map(|&b| (b >> 3) & 1 == 1).unwrap_or(false);
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
/// Build a picture coding extension (`00 00 01 B5`, ext-id 1000) carrying the
/// given pulldown flags, for `picture_nb_fields` tests.
fn pic_coding_ext(tff: u8, rff: u8, progressive_frame: u8, frame_picture: bool) -> Vec<u8> {
let e0 = 0x80; // ext-id 1000, f_code high nibble 0
let e1 = 0x00;
let e2 = if frame_picture { 0x03 } else { 0x01 }; // picture_structure bits 1-0
let e3 = (tff << 7) | (rff << 1);
let e4 = progressive_frame << 7;
vec![0x00, 0x00, 0x01, SEQ_EXT_CODE, e0, e1, e2, e3, e4]
}
#[test]
fn nb_fields_normal_frame_is_two() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 0, 0, true), false), 2);
}
#[test]
fn nb_fields_telecine_repeat_field_is_three() {
// NTSC 2:3 soft telecine: interlaced sequence, progressive frame, rff=1.
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 1, 1, true), false), 3);
}
#[test]
fn nb_fields_field_picture_is_one() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 0, 0, false), false), 1);
}
#[test]
fn nb_fields_progressive_seq_rff_tff_is_six() {
assert_eq!(picture_nb_fields(&pic_coding_ext(1, 1, 0, true), true), 6);
}
#[test]
fn nb_fields_progressive_seq_rff_no_tff_is_four() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 1, 0, true), true), 4);
}
#[test]
fn nb_fields_no_picture_ext_defaults_two() {
// A picture header with no coding extension → assume a normal 2-field frame.
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,
discontinuity: false,
};
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.
assert!(parse_progressive_sequence(&[
0,
0,
1,
SEQ_EXT_CODE,
0x10,
0x08
]));
assert!(!parse_progressive_sequence(&[
0,
0,
1,
SEQ_EXT_CODE,
0x10,
0x00
]));
// No sequence extension at all → interlaced default (false).
assert!(!parse_progressive_sequence(&[
0,
0,
1,
SEQ_HEADER_CODE,
0,
0
]));
}
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket {
source: None,
pid: 0x1011,
pts,
dts: None,
data,
discontinuity: false,
}
}
/// Build a minimal MPEG-2 sequence header.
/// 00 00 01 B3 [h_size:12][v_size:12] [aspect:4][frame_rate:4] ...
fn make_seq_header(width: u16, height: u16, aspect: u8, frame_rate: u8) -> Vec<u8> {
let mut hdr = vec![0x00, 0x00, 0x01, SEQ_HEADER_CODE];
hdr.push((width >> 4) as u8);
hdr.push(((width & 0x0F) as u8) << 4 | ((height >> 8) & 0x0F) as u8);
hdr.push((height & 0xFF) as u8);
hdr.push((aspect << 4) | (frame_rate & 0x0F));
// Bit rate (18 bits) + marker + VBV buffer size (10 bits) etc — pad minimally.
hdr.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
hdr
}
/// Build a picture header with the given coding type.
fn make_picture_header(coding_type: u8) -> Vec<u8> {
// 00 00 01 00 [temporal_ref:10][picture_coding_type:3][...]
let byte5 = (coding_type & 0x07) << 3;
vec![0x00, 0x00, 0x01, PICTURE_CODE, 0x00, byte5, 0x00, 0x00]
}
/// A GOP header start code (used as a clean access-unit delimiter in tests).
fn gop() -> Vec<u8> {
vec![0x00, 0x00, 0x01, GOP_CODE, 0x00, 0x00, 0x00, 0x00]
}
/// Picture header carrying an explicit 10-bit temporal_reference.
fn make_picture_header_tr(coding_type: u8, tr: u16) -> Vec<u8> {
let b4 = ((tr >> 2) & 0xFF) as u8;
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
vec![0x00, 0x00, 0x01, PICTURE_CODE, b4, b5, 0x00, 0x00]
}
/// Collect every frame from a single PES followed by an EOF flush — the
/// common single-picture test shape (the final AU emits on flush()).
fn parse_then_flush(parser: &mut Mpeg2Parser, pes: &PesPacket) -> Vec<Frame> {
let mut frames = parser.parse(pes);
frames.extend(parser.flush());
frames
}
// --- Sequence header parsing ---
#[test]
fn parse_sequence_header_resolution() {
assert_eq!(
parse_resolution(&make_seq_header(720, 480, 2, 4)),
Some((720, 480))
);
}
#[test]
fn parse_sequence_header_1920x1080() {
assert_eq!(
parse_resolution(&make_seq_header(1920, 1080, 3, 4)),
Some((1920, 1080))
);
}
#[test]
fn parse_sequence_header_frame_rate() {
let hdr = make_seq_header(720, 480, 2, 4); // frame_rate_code 4 = 29.97
assert_eq!(parse_frame_rate(&hdr), Some((30000, 1001)));
}
#[test]
fn parse_sequence_header_aspect_ratio() {
let hdr = make_seq_header(720, 480, 3, 4); // aspect code 3 = 16:9
assert_eq!(parse_aspect_ratio(&hdr), Some((16, 9)));
}
#[test]
fn parse_sequence_header_too_short() {
let hdr = vec![0x00, 0x00, 0x01, SEQ_HEADER_CODE];
assert!(parse_resolution(&hdr).is_none());
assert!(parse_frame_rate(&hdr).is_none());
assert!(parse_aspect_ratio(&hdr).is_none());
}
// --- I-frame detection ---
#[test]
fn detect_i_frame() {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(PICTURE_TYPE_I);
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "I-frame should be detected as keyframe");
}
#[test]
fn detect_p_frame_not_keyframe() {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(2); // P-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "P-frame should not be keyframe");
}
#[test]
fn detect_b_frame_not_keyframe() {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(3); // B-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "B-frame should not be keyframe");
}
// --- The core fix: a picture split across many PES packets is ONE frame ---
#[test]
fn picture_fragmented_across_pes_is_reassembled_into_one_frame() {
// A DVD coded picture spans multiple ~2 KB PES packets; only the first
// carries a PTS. The parser must concatenate them into ONE access unit,
// not emit one fragment per PES.
let mut parser = Mpeg2Parser::new();
let mut au = make_seq_header(720, 480, 3, 4);
au.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
au.extend_from_slice(&vec![0xAA; 5000]); // slice data (no start codes)
// Split the AU into 2 KB fragments across separate PES packets.
let mut frames = Vec::new();
for (i, chunk) in au.chunks(2000).enumerate() {
let pts = if i == 0 { Some(90000) } else { None };
frames.extend(parser.parse(&make_pes(chunk.to_vec(), pts)));
}
// No boundary yet → nothing emitted during parse().
assert!(frames.is_empty(), "incomplete AU must not emit fragments");
// Flush completes the trailing AU.
frames.extend(parser.flush());
assert_eq!(frames.len(), 1, "fragments reassembled into ONE frame");
assert_eq!(frames[0].data, au, "frame is the whole picture, byte-exact");
assert!(frames[0].keyframe);
assert_eq!(
frames[0].pts_ns, 1_000_000_000,
"PTS from the first fragment"
);
}
#[test]
fn two_pictures_in_one_gop_emit_both_on_flush() {
// Two pictures with no GOP/sequence boundary between them are ONE GOP.
// The VFR timeline needs the whole GOP (a P-frame's PTS depends on its
// later B-frames), so they buffer until the GOP closes / EOF, then emit
// in DECODE order, each containing exactly its own picture.
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 100]);
let mut pic2 = make_picture_header(2); // P
pic2.extend_from_slice(&[0x22; 100]);
let mut stream = pic1.clone();
stream.extend_from_slice(&pic2);
let frames = parser.parse(&make_pes(stream, Some(0)));
assert!(frames.is_empty(), "same GOP — buffered until flush");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data, pic1);
assert!(frames[0].keyframe);
assert_eq!(frames[1].data, pic2);
assert!(!frames[1].keyframe);
}
/// B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a
/// concealed gap must be associated by OFFSET (like PTS), landing on the
/// picture whose own bytes begin after the gap — NOT the previous picture
/// that completes when the discontinuity PES arrives. pic1 (I) is pre-gap;
/// pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU.
#[test]
fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() {
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 100]);
let mut pic2 = make_picture_header(2); // P
pic2.extend_from_slice(&[0x22; 100]);
// pic1 on a clean PES; nothing emits (same GOP, buffered).
assert!(parser.parse(&make_pes(pic1.clone(), Some(0))).is_empty());
// pic2 on a PES flagged discontinuity (a concealed gap preceded it).
// parse() of this PES completes pic1's AU (the PREVIOUS picture) — which
// must stay clean — while pic2 keeps buffering.
let pes2 = PesPacket {
source: None,
pid: 0x1011,
pts: Some(90000),
dts: None,
data: pic2.clone(),
discontinuity: true,
};
assert!(parser.parse(&pes2).is_empty(), "same GOP — still buffered");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data, pic1);
assert!(
!frames[0].discontinuity,
"the previous (pre-gap) I picture must NOT be flagged"
);
assert_eq!(frames[1].data, pic2);
assert!(
frames[1].discontinuity,
"the post-gap P picture (the discontinuity PES's own AU) IS flagged"
);
}
#[test]
fn picture_coding_extension_stays_with_its_picture() {
// Regression for `ignoring pic cod ext after 0`: the picture coding
// extension (00 00 01 B5) must remain in the SAME access unit as its
// picture header, never split into the next block.
let mut parser = Mpeg2Parser::new();
let mut au = make_picture_header(PICTURE_TYPE_I);
au.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x88, 0x00]); // pic coding ext
au.extend_from_slice(&[0x00, 0x00, 0x01, 0x01]); // slice
au.extend_from_slice(&[0x77; 50]);
let frames = parse_then_flush(&mut parser, &make_pes(au.clone(), Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].data, au,
"picture + coding extension + slice = one AU"
);
}
// --- PTS association across fragments ---
#[test]
fn each_picture_gets_the_pts_of_the_pes_that_began_it() {
// With no sequence header (no frame rate) the parser falls back to each
// AU's own PES PTS. Both pictures are one GOP → emitted on flush in
// decode order, each carrying the PTS of the PES that began it.
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 50]);
let frames1 = parser.parse(&make_pes(pic1, Some(90000)));
assert!(frames1.is_empty(), "buffered until flush");
let mut pic2 = make_picture_header(2);
pic2.extend_from_slice(&[0x22; 50]);
let frames2 = parser.parse(&make_pes(pic2, Some(180000)));
assert!(frames2.is_empty(), "same GOP — still buffered");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].pts_ns, 1_000_000_000, "pic1 → PTS 90000");
assert_eq!(frames[1].pts_ns, 2_000_000_000, "pic2 → PTS 180000");
}
// --- sparse PTS reconstructed from temporal_reference + frame rate ---
#[test]
fn sparse_pts_interpolated_by_temporal_reference() {
// DVD stamps a PTS only ~once per VOBU; frames between marks must be
// timed by temporal_reference × frame interval, anchored to the real
// PES PTS so audio stays in sync. Frame rate code 3 = 25 fps = 40 ms.
let mut p = Mpeg2Parser::new();
// GOP 1: seq + gop + I(TR0) carrying PES PTS 0 (the anchor).
let mut a = make_seq_header(720, 480, 3, 3);
a.extend_from_slice(&gop());
a.extend_from_slice(&make_picture_header_tr(1, 0));
a.extend_from_slice(&[0xAA; 20]);
let mut frames = p.parse(&make_pes(a, Some(0)));
assert!(
frames.is_empty(),
"first AU waits for the next picture boundary"
);
// TR1, no PES PTS → interpolate.
let mut b1 = make_picture_header_tr(3, 1);
b1.extend_from_slice(&[0xBB; 20]);
frames.extend(p.parse(&make_pes(b1, None)));
// TR2, no PES PTS → interpolate.
let mut b2 = make_picture_header_tr(3, 2);
b2.extend_from_slice(&[0xCC; 20]);
frames.extend(p.parse(&make_pes(b2, None)));
frames.extend(p.flush());
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].pts_ns, 0, "anchor frame uses its real PES PTS");
assert_eq!(frames[1].pts_ns, 40_000_000, "TR1 → +1 frame interval");
assert_eq!(frames[2].pts_ns, 80_000_000, "TR2 → +2 frame intervals");
assert_eq!(frames[0].duration_ns, Some(40_000_000));
}
/// A frame-picture AU with a picture coding extension carrying pulldown
/// flags (progressive_frame=1, so rff=1 → 3 fields), for VFR timing tests.
fn make_pulldown_picture(coding_type: u8, tr: u16, rff: u8) -> Vec<u8> {
let mut au = make_picture_header_tr(coding_type, tr);
// 00 00 01 B5 | e0 ext-id 1000 | e1 | e2 frame-pic | e3 rff<<1 | e4 prog_frame
au.extend_from_slice(&[
0x00,
0x00,
0x01,
SEQ_EXT_CODE,
0x80,
0x00,
0x03,
rff << 1,
0x80,
]);
au.extend_from_slice(&[0xAA; 16]);
au
}
#[test]
fn telecine_pts_accumulates_by_field_durations_not_a_fixed_grid() {
// NTSC film, frame_rate_code 4 = 29.97 → field_period ≈ 16.683 ms. A 2:3
// frame (rff=1) occupies 3 fields, a 2:2 frame 2 fields. PTS must
// accumulate by ACTUAL field durations so the next frame starts exactly
// when this one ends — closing the fixed-29.97-grid gap that judders.
let mut p = Mpeg2Parser::new();
let field = 1_000_000_000i64 * 1001 / 30000 / 2;
let mut a = make_seq_header(720, 480, 2, 4);
a.extend_from_slice(&gop());
a.extend(make_pulldown_picture(1, 0, 1)); // I tr0, 3 fields, PES anchor 0
a.extend(make_pulldown_picture(2, 1, 0)); // P tr1, 2 fields
let mut frames = p.parse(&make_pes(a, Some(0)));
frames.extend(p.flush());
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].pts_ns, 0, "I anchored to PES PTS 0");
assert_eq!(
frames[0].duration_ns,
Some(3 * field as u64),
"I = 3 fields"
);
assert_eq!(
frames[1].pts_ns,
3 * field,
"P starts exactly at I-end (3 fields), not the 1/29.97 grid"
);
assert_eq!(
frames[1].duration_ns,
Some(2 * field as u64),
"P = 2 fields"
);
assert!(frames[1].pts_ns > frames[0].pts_ns, "strictly monotonic");
}
#[test]
fn b_frames_emit_in_decode_order_with_lower_display_pts() {
// Decode order I(tr0) P(tr2) B(tr1): emitted in DECODE order, but the
// B-frame carries a LOWER (earlier) display PTS than the P that precedes
// it in the stream — never reordered (reordering corrupts the picture).
let mut p = Mpeg2Parser::new();
let field = 1_000_000_000i64 * 1001 / 30000 / 2;
let mut a = make_seq_header(720, 480, 2, 4);
a.extend_from_slice(&gop());
a.extend(make_pulldown_picture(1, 0, 0)); // I tr0 (displays 1st), PES anchor 0
a.extend(make_pulldown_picture(2, 2, 0)); // P tr2 (displays 3rd)
a.extend(make_pulldown_picture(3, 1, 0)); // B tr1 (displays 2nd)
let mut frames = p.parse(&make_pes(a, Some(0)));
frames.extend(p.flush());
assert_eq!(frames.len(), 3);
assert!(frames[0].keyframe, "decode order preserved: I first");
assert_eq!(frames[0].pts_ns, 0, "I (tr0) displays 1st");
assert_eq!(frames[1].pts_ns, 4 * field, "P (tr2) displays 3rd");
assert_eq!(frames[2].pts_ns, 2 * field, "B (tr1) displays 2nd");
assert!(
frames[2].pts_ns < frames[1].pts_ns,
"B emitted AFTER P (decode order) but displays BEFORE it (lower PTS)"
);
}
#[test]
fn temporal_reference_resets_each_gop_via_gop_base() {
// Across a GOP boundary, temporal_reference restarts at 0 but the
// whole-stream display index must keep climbing (gop_base folds the
// previous GOP's frame count). 25 fps = 40 ms.
let mut p = Mpeg2Parser::new();
// GOP 1: two pictures TR0 (anchor PTS 0), TR1.
let mut g1 = make_seq_header(720, 480, 3, 3);
g1.extend_from_slice(&gop());
g1.extend_from_slice(&make_picture_header_tr(1, 0));
g1.extend_from_slice(&[0xAA; 10]);
g1.extend_from_slice(&make_picture_header_tr(2, 1));
g1.extend_from_slice(&[0xBB; 10]);
let mut frames = p.parse(&make_pes(g1, Some(0)));
// GOP 2: new GOP header, picture TR0 again (no PES PTS).
let mut g2 = gop();
g2.extend_from_slice(&make_picture_header_tr(1, 0));
g2.extend_from_slice(&[0xCC; 10]);
frames.extend(p.parse(&make_pes(g2, None)));
frames.extend(p.flush());
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].pts_ns, 0); // GOP1 TR0
assert_eq!(frames[1].pts_ns, 40_000_000); // GOP1 TR1
// GOP2 TR0 → display index 2 (gop_base 2 + TR 0), NOT a reset to 0.
assert_eq!(
frames[2].pts_ns, 80_000_000,
"gop_base keeps the clock climbing"
);
}
#[test]
fn leading_frames_buffered_until_first_pts_anchor() {
// A DVD title can open with a still-frame/first-play sequence whose PTS
// lands a few frames in (the disc stamps the opening I-frames at one real
// PES PTS, not 0). Leading frames must be held and then anchored to that
// real timeline — never zero-stamped. 25 fps = 40 ms. PTS (2 s) arrives
// only on the THIRD picture.
let mut p = Mpeg2Parser::new();
let mut a = make_seq_header(720, 480, 3, 3);
a.extend_from_slice(&gop());
a.extend_from_slice(&make_picture_header_tr(1, 0));
a.extend_from_slice(&[0xAA; 20]);
let mut f = p.parse(&make_pes(a, None)); // no PTS → buffered
let mut b1 = make_picture_header_tr(3, 1);
b1.extend_from_slice(&[0xBB; 20]);
f.extend(p.parse(&make_pes(b1, None))); // no PTS → buffered
let mut b2 = make_picture_header_tr(3, 2);
b2.extend_from_slice(&[0xCC; 20]);
f.extend(p.parse(&make_pes(b2, Some(180000)))); // PTS 2 s → anchor + backfill
f.extend(p.flush());
assert_eq!(f.len(), 3);
// Anchored to the real disc timeline, NOT a 0 base.
assert_eq!(
f[0].pts_ns,
2_000_000_000 - 80_000_000,
"leading frame back-anchored"
);
assert_eq!(f[1].pts_ns, 2_000_000_000 - 40_000_000);
assert_eq!(
f[2].pts_ns, 2_000_000_000,
"anchor frame = its real PES PTS"
);
// Decode order preserved.
assert!(f[0].keyframe);
}
#[test]
fn opening_au_keeps_disc_pts_and_opening_seq_header_no_zero_floor() {
// SOTL SUB-TASK 2 regression (opening-GOP / still-frame open). A DVD
// title opens on a VOBU that begins with a sequence header + I-frame; the
// disc stamps that opening I-frame at its REAL (non-zero) timeline PTS,
// not 0. The parser must (a) emit the opening I-frame with that real PTS
// — never floored to 0 — and (b) capture THAT opening sequence header as
// codec_private (read at headers-ready, before any later AU). Proves the
// opening pictures are emitted with the correct seq header + PTS, ruling
// out the "wrong/last seq header" and "PTS floored to t=0" hypotheses.
let mut p = Mpeg2Parser::new();
// Opening AU: seq header (the codecPrivate) + GOP + I-frame TR0 carrying
// the disc's real opening PTS (2 s here, i.e. NOT zero). 25 fps PAL.
let mut a = make_seq_header(720, 576, 3, 3); // 16:9, 25 fps
a.extend_from_slice(&gop());
a.extend_from_slice(&make_picture_header_tr(PICTURE_TYPE_I, 0));
a.extend_from_slice(&[0xAA; 20]);
let mut frames = p.parse(&make_pes(a, Some(180_000))); // PTS = 2 s (90 kHz)
assert!(frames.is_empty(), "first AU waits for the next boundary");
// Second picture (no PTS) closes the opening AU: the I-frame emits and
// the opening sequence header is captured (headers-ready timing — the
// consumer reads codec_private once the first AU drains).
let mut b = make_picture_header_tr(3, 1);
b.extend_from_slice(&[0xBB; 20]);
frames.extend(p.parse(&make_pes(b, None)));
// codec_private is the OPENING sequence header (read at headers-ready,
// before any later AU could replace it).
let cp = p
.codec_private()
.expect("opening seq header captured at headers-ready");
assert_eq!(
&cp[..4],
&[0x00, 0x00, 0x01, SEQ_HEADER_CODE],
"codec_private is the opening sequence header"
);
assert_eq!(p.resolution(), Some((720, 576)), "576i opening header");
assert_eq!(p.frame_rate(), Some((25, 1)), "25 fps opening header");
frames.extend(p.flush());
assert_eq!(frames.len(), 2);
assert!(frames[0].keyframe, "opening picture is the I-frame");
assert_eq!(
frames[0].pts_ns, 2_000_000_000,
"opening I-frame keeps the disc's real PTS (2 s), NOT floored to 0"
);
assert_eq!(
frames[1].pts_ns, 2_040_000_000,
"next frame is one 40 ms interval later on the real timeline"
);
}
// --- Sequence header → codec_private ---
#[test]
fn codec_private_from_sequence_header() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(720, 480, 3, 4);
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 8]);
let _ = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
let cp = parser
.codec_private()
.expect("codec_private after seq header");
assert_eq!(&cp[..4], &[0x00, 0x00, 0x01, SEQ_HEADER_CODE]);
}
#[test]
fn codec_private_none_initially() {
assert!(Mpeg2Parser::new().codec_private().is_none());
}
#[test]
fn codec_private_includes_extension_but_not_picture() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(1920, 1080, 3, 4);
// Sequence extension: 00 00 01 B5 [ext data]
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x14, 0x8A, 0x00, 0x01]);
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 4]);
let _ = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
let cp = parser.codec_private().unwrap();
assert!(
cp.windows(4).any(|w| w == [0x00, 0x00, 0x01, SEQ_EXT_CODE]),
"codec_private should include the sequence extension"
);
// It must stop before the picture header — extradata is seq header only.
assert!(
!cp.windows(4).any(|w| w == [0x00, 0x00, 0x01, PICTURE_CODE]),
"codec_private must NOT include the picture start code"
);
}
// --- seq-header keyframe flag must not leak into a P/B-frame ---
#[test]
fn seq_header_then_p_frame_is_not_keyframe() {
// A PES carrying a sequence header followed by a P-frame must NOT be a
// keyframe — keyframe-ness belongs to the coded picture.
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(720, 480, 3, 4);
data.extend_from_slice(&make_picture_header(2)); // P-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert!(
!frames[0].keyframe,
"seq-header + P-frame must not be a keyframe"
);
assert!(parser.codec_private().is_some());
}
#[test]
fn sequence_header_with_picture_is_keyframe() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(720, 480, 3, 4);
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe);
assert!(parser.codec_private().is_some());
}
// --- a SECOND sequence header re-captures (title boundary) ---
#[test]
fn new_sequence_header_replaces_codec_private() {
let mut parser = Mpeg2Parser::new();
// AU A: 1920x1080 seq header + I picture, delimited by a following GOP.
let mut a = make_seq_header(1920, 1080, 3, 4);
a.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
a.extend_from_slice(&[0xAA; 20]);
a.extend_from_slice(&gop()); // trailing GOP header starts the next GOP
let _fa = parser.parse(&make_pes(a, Some(0)));
// Header A is captured during parse (codec_private) even though its GOP
// only emits once header B's picture closes it / on flush.
assert_eq!(parser.resolution(), Some((1920, 1080)));
// AU B: a NEW 720x480 seq header + I picture. Its extension/header must
// replace the stored one rather than keeping stale 1920x1080.
let mut b = make_seq_header(720, 480, 2, 4);
b.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
b.extend_from_slice(&[0xBB; 20]);
let _ = parse_then_flush(&mut parser, &make_pes(b, Some(3600)));
assert_eq!(
parser.resolution(),
Some((720, 480)),
"codec_private updated to header B"
);
}
// --- PTS conversion ---
#[test]
fn pts_conversion_to_nanoseconds() {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(PICTURE_TYPE_I);
data.extend_from_slice(&[0xFF; 4]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn mpeg2_dts_fallback_and_zero() {
let mut parser = Mpeg2Parser::new();
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),
data,
discontinuity: false,
};
let f = parse_then_flush(&mut parser, &pes);
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
let mut parser2 = Mpeg2Parser::new();
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,
data: data2,
discontinuity: false,
};
let f2 = parse_then_flush(&mut parser2, &pes2);
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
}
// --- Empty PES ---
#[test]
fn empty_pes_no_frames() {
let mut parser = Mpeg2Parser::new();
assert!(parser.parse(&make_pes(Vec::new(), Some(0))).is_empty());
}
// --- parameter-set-only stream: seq header, no picture → no frame ---
#[test]
fn sequence_header_only_emits_no_frame_but_captures_codec_private() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(1920, 1080, 3, 4);
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x14, 0x8A]);
// No picture start code at all.
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert!(frames.is_empty(), "no coded picture → no frame");
// codec_private only captured when an AU is emitted; a header-only
// stream emits nothing, so nothing is captured — and there is no frame
// to need it. (Real streams always follow the header with a picture.)
}
// --- buffer cap: corrupt stream with no second boundary is force-flushed ---
#[test]
fn oversized_au_without_boundary_is_force_flushed() {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(PICTURE_TYPE_I);
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
// The AU assembler force-completes the ~8 MiB AU (no boundary), and the
// GOP byte cap (MAX_PENDING_BYTES) then force-flushes that oversized GOP
// during parse rather than buffering it unbounded.
let mut frames = parser.parse(&make_pes(data, Some(0)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 1, "over-cap AU force-flushed, not dropped");
assert!(frames[0].keyframe);
}
// --- parse_resolution: 12-bit field packing (ISO 13818-2 §6.2.2.1) ---
#[test]
fn resolution_packs_split_nibble_correctly() {
let hdr = make_seq_header(0xABC, 0xDEF, 1, 1);
assert_eq!(parse_resolution(&hdr), Some((0xABC, 0xDEF)));
}
#[test]
fn resolution_max_12bit() {
let hdr = make_seq_header(4095, 4095, 1, 1);
assert_eq!(parse_resolution(&hdr), Some((4095, 4095)));
}
#[test]
fn resolution_too_short_none() {
assert_eq!(parse_resolution(&[0x00, 0x00, 0x01, 0xB3, 0x07]), None);
}
// --- parse_frame_rate: full table + reserved codes ---
#[test]
fn frame_rate_all_valid_codes() {
let expect = [
(24000u32, 1001u32),
(24, 1),
(25, 1),
(30000, 1001),
(30, 1),
(50, 1),
(60000, 1001),
(60, 1),
];
for (i, &want) in expect.iter().enumerate() {
let code = (i + 1) as u8;
let hdr = make_seq_header(720, 480, 1, code);
assert_eq!(parse_frame_rate(&hdr), Some(want), "frame_rate_code {code}");
}
}
#[test]
fn frame_rate_code_zero_forbidden_none() {
assert_eq!(parse_frame_rate(&make_seq_header(720, 480, 1, 0)), None);
}
#[test]
fn frame_rate_code_out_of_range_none() {
assert_eq!(parse_frame_rate(&make_seq_header(720, 480, 1, 0x0F)), None);
}
// --- parse_aspect_ratio: table + reserved codes ---
#[test]
fn aspect_ratio_all_valid_codes() {
let expect = [(1u8, 1u8), (4, 3), (16, 9), (221, 100)];
for (i, &want) in expect.iter().enumerate() {
let code = (i + 1) as u8;
let hdr = make_seq_header(720, 480, code, 4);
assert_eq!(parse_aspect_ratio(&hdr), Some(want), "aspect code {code}");
}
}
#[test]
fn aspect_ratio_code_zero_none() {
assert_eq!(parse_aspect_ratio(&make_seq_header(720, 480, 0, 4)), None);
}
#[test]
fn aspect_ratio_code_out_of_range_none() {
assert_eq!(
parse_aspect_ratio(&make_seq_header(720, 480, 0x0F, 4)),
None
);
}
// --- picture_coding_type: byte position + bit field ---
#[test]
fn picture_coding_type_bits_5_3() {
for (ct, is_kf) in [(1u8, true), (2, false), (3, false), (4, false)] {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(ct);
data.extend_from_slice(&[0xFF; 8]);
let f = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].keyframe, is_kf, "picture_coding_type {ct}");
}
}
#[test]
fn parser_resolution_method() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(720, 576, 2, 3);
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 4]);
let _ = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert_eq!(parser.resolution(), Some((720, 576)));
assert_eq!(parser.frame_rate(), Some((25, 1))); // frame_rate_code 3 = 25fps
assert_eq!(parser.aspect_ratio(), Some((4, 3))); // aspect code 2 = 4:3
}
}