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.
1519 lines
60 KiB
Rust
1519 lines
60 KiB
Rust
//! 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
|
||
}
|
||
}
|