CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
1095 lines
42 KiB
Rust
1095 lines
42 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 std::collections::VecDeque;
|
||
|
||
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;
|
||
|
||
/// Hard cap on the access-unit reassembly buffer. A real MPEG-2 frame is well
|
||
/// under 1 MiB (DVD I-frames ~100 KB); past this cap a corrupt stream that
|
||
/// never produces a second access-unit boundary is force-flushed as a single
|
||
/// frame rather than driving unbounded allocation.
|
||
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;
|
||
|
||
/// 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>>,
|
||
/// Unemitted elementary-stream bytes: the in-progress access unit plus any
|
||
/// lookahead needed to detect the next AU boundary.
|
||
buf: Vec<u8>,
|
||
/// Absolute ES byte offset of `buf[0]`. Used to associate PES PTS marks
|
||
/// (recorded by absolute offset) with the access units they belong to.
|
||
base_offset: u64,
|
||
/// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES
|
||
/// that carried a timestamp, in ascending offset order.
|
||
pts_marks: VecDeque<(u64, i64)>,
|
||
/// Per-frame presentation interval (ns), derived from the sequence header
|
||
/// frame rate. DVD stamps a PTS only ~once per VOBU (every ~0.5 s), so
|
||
/// frames between marks must be timed by `temporal_reference` × this
|
||
/// interval. 0 until a sequence header with a valid frame rate is seen.
|
||
frame_duration_ns: i64,
|
||
/// Cumulative count of coded pictures emitted in all GOPs before the
|
||
/// current one. `temporal_reference` is GOP-relative (display order within
|
||
/// the GOP); adding this base makes a whole-stream display index.
|
||
gop_base: u64,
|
||
/// Coded pictures emitted in the current GOP so far (folded into
|
||
/// `gop_base` at the next GOP boundary).
|
||
gop_count: u64,
|
||
/// Display index of the last frame that carried an explicit PES PTS, used
|
||
/// to anchor interpolated timestamps to the real disc timeline (so video
|
||
/// stays in sync with the PES-timestamped audio tracks).
|
||
anchor_index: Option<u64>,
|
||
/// PTS (ns) of the anchor frame.
|
||
anchor_pts: i64,
|
||
/// Frames emitted before the first PES PTS anchor is known, held with their
|
||
/// display index. A DVD title can open with a still-frame/first-play
|
||
/// sequence whose PTS lands a few frames in; buffering until the anchor lets
|
||
/// those leading frames take the disc's real timeline instead of a 0 base.
|
||
pending: Vec<(u64, 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,
|
||
buf: Vec::with_capacity(128 * 1024),
|
||
base_offset: 0,
|
||
pts_marks: VecDeque::new(),
|
||
frame_duration_ns: 0,
|
||
gop_base: 0,
|
||
gop_count: 0,
|
||
anchor_index: None,
|
||
anchor_pts: 0,
|
||
pending: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// The PTS (ns) to assign to an access unit whose first relevant byte is at
|
||
/// absolute ES offset `target`: the most recent PES timestamp at or before
|
||
/// that offset (the PES that contains the access unit's start). Falls back
|
||
/// to 0 when no timestamp has been seen yet.
|
||
fn pts_for(&self, target: u64) -> i64 {
|
||
let mut best = 0;
|
||
for &(off, pts) in &self.pts_marks {
|
||
if off <= target {
|
||
best = pts;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
best
|
||
}
|
||
|
||
/// Drain every complete access unit from `buf`, returning one Frame each.
|
||
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
|
||
/// in-progress access unit is emitted even without a following boundary.
|
||
fn drain_complete_aus(&mut self, force: bool) -> Vec<Frame> {
|
||
let mut out = Vec::new();
|
||
loop {
|
||
// An access unit must contain a coded picture; without one there is
|
||
// nothing to emit yet (leading sequence/GOP headers wait for it).
|
||
let Some(pic) = find_code(&self.buf, 0, PICTURE_CODE) else {
|
||
// No coded picture in an over-cap buffer means we are
|
||
// accumulating unparseable data (a stream with no picture
|
||
// start codes). Drop all but a 3-byte tail — enough to catch a
|
||
// start-code prefix straddling the boundary — and advance the
|
||
// absolute offset so the PES-mark invariant holds. Mirrors the
|
||
// post-picture buffer backstop in the AU-boundary search below.
|
||
if self.buf.len() > MAX_AU_BUFFER {
|
||
let drop = self.buf.len() - 3;
|
||
self.base_offset += drop as u64;
|
||
self.buf.drain(..drop);
|
||
let cutoff = self.base_offset;
|
||
while let Some(&(off, _)) = self.pts_marks.front() {
|
||
if off < cutoff {
|
||
self.pts_marks.pop_front();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
};
|
||
// The current AU ends where the next one begins: the first
|
||
// picture / sequence / GOP start code after this picture.
|
||
let end = match find_au_start(&self.buf, pic + 4) {
|
||
Some(b) => b,
|
||
None if force => self.buf.len(),
|
||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||
None => break, // AU not yet complete — await the next boundary
|
||
};
|
||
if end == 0 {
|
||
break;
|
||
}
|
||
|
||
// Phase 1 — read everything from `buf` before any mutation of self
|
||
// (the slice borrow must end before we touch self fields).
|
||
let hdr = extract_seq_header(&self.buf[..end]);
|
||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new
|
||
// GOP, resetting temporal_reference to 0.
|
||
let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some()
|
||
|| find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some();
|
||
let keyframe = pic + 5 < end && ((self.buf[pic + 5] >> 3) & 0x07) == PICTURE_TYPE_I;
|
||
// temporal_reference: the 10 bits immediately after the picture
|
||
// start code = display order within the GOP.
|
||
let tr = if pic + 5 < end {
|
||
(((self.buf[pic + 4] as u64) << 2) | ((self.buf[pic + 5] as u64) >> 6)) & 0x3FF
|
||
} else {
|
||
0
|
||
};
|
||
let pic_abs = self.base_offset + pic as u64;
|
||
let end_abs = self.base_offset + end as u64;
|
||
let data = self.buf[..end].to_vec();
|
||
|
||
// Phase 2 — mutate self.
|
||
if let Some(h) = hdr {
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if gop_boundary && self.gop_count > 0 {
|
||
self.gop_base += self.gop_count;
|
||
self.gop_count = 0;
|
||
}
|
||
let display_index = self.gop_base + tr;
|
||
|
||
// An explicit PES PTS for this access unit, if any. By the mark-drain
|
||
// invariant the front mark's offset is >= this AU's start, so a front
|
||
// mark inside [start, end) is this AU's own timestamp.
|
||
let explicit = self
|
||
.pts_marks
|
||
.front()
|
||
.filter(|&&(off, _)| off < end_abs)
|
||
.map(|&(_, p)| p);
|
||
|
||
let duration_ns = (self.frame_duration_ns > 0).then_some(self.frame_duration_ns as u64);
|
||
let mut frame = Frame {
|
||
pts_ns: 0,
|
||
keyframe,
|
||
data,
|
||
duration_ns,
|
||
};
|
||
|
||
if self.frame_duration_ns > 0 {
|
||
// Reconstruct from display order; anchor to the real PES PTS so
|
||
// video stays in sync with the PES-timestamped audio.
|
||
match explicit {
|
||
Some(p) => {
|
||
self.anchor_index = Some(display_index);
|
||
self.anchor_pts = p;
|
||
// Backfill any leading frames held before the anchor was
|
||
// known (still-frame / first-play opening): give each the
|
||
// disc's real timeline relative to this anchor.
|
||
for (di, mut held) in self.pending.drain(..) {
|
||
held.pts_ns =
|
||
p + (di as i64 - display_index as i64) * self.frame_duration_ns;
|
||
out.push(held);
|
||
}
|
||
frame.pts_ns = p;
|
||
out.push(frame);
|
||
}
|
||
None => match self.anchor_index {
|
||
Some(ai) => {
|
||
frame.pts_ns = self.anchor_pts
|
||
+ (display_index as i64 - ai as i64) * self.frame_duration_ns;
|
||
out.push(frame);
|
||
}
|
||
None if self.pending.len() < MAX_PENDING_FRAMES => {
|
||
// No anchor yet — hold so leading frames get the
|
||
// disc's real timeline once the first PTS arrives,
|
||
// not a 0 base.
|
||
self.pending.push((display_index, frame));
|
||
}
|
||
None => {
|
||
frame.pts_ns = display_index as i64 * self.frame_duration_ns;
|
||
out.push(frame);
|
||
}
|
||
},
|
||
}
|
||
} else {
|
||
// No frame rate yet (no sequence header) — fall back to the
|
||
// nearest preceding PES timestamp.
|
||
frame.pts_ns = self.pts_for(pic_abs);
|
||
out.push(frame);
|
||
}
|
||
|
||
self.gop_count += 1;
|
||
self.buf.drain(..end);
|
||
self.base_offset = end_abs;
|
||
// Drop PTS marks fully consumed by the emitted AU; keep the mark at
|
||
// the boundary (it belongs to the next AU).
|
||
while let Some(&(off, _)) = self.pts_marks.front() {
|
||
if off < end_abs {
|
||
self.pts_marks.pop_front();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
impl CodecParser for Mpeg2Parser {
|
||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||
if pes.data.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
// Record this PES's timestamp against the absolute offset of its first
|
||
// ES byte, BEFORE appending. 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 off = self.base_offset + self.buf.len() as u64;
|
||
if let Some(ts) = pes.pts.or(pes.dts) {
|
||
self.pts_marks.push_back((off, pts_to_ns(ts)));
|
||
}
|
||
self.buf.extend_from_slice(&pes.data);
|
||
self.drain_complete_aus(false)
|
||
}
|
||
|
||
fn flush(&mut self) -> Vec<Frame> {
|
||
let mut out = self.drain_complete_aus(true);
|
||
// EOF: if no PES ever supplied a PTS/DTS, `self.pending` still holds the
|
||
// frames buffered while waiting for an anchor (the opening keyframe +
|
||
// first ~20s). Without this they'd be silently dropped — a 100%-recovery
|
||
// violation. Emit each with the same 0-base fallback the no-anchor
|
||
// overflow arm uses (`display_index * frame_duration_ns`), ordered by
|
||
// display_index so presentation order is preserved.
|
||
if !self.pending.is_empty() {
|
||
let mut held: Vec<(u64, Frame)> = self.pending.drain(..).collect();
|
||
held.sort_by_key(|(di, _)| *di);
|
||
for (di, mut frame) in held {
|
||
frame.pts_ns = di as i64 * self.frame_duration_ns;
|
||
out.push(frame);
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
/// Find the next access-unit boundary at or after `from`: the position of a
|
||
/// picture (0x00), sequence header (0xB3), or GOP (0xB8) start code. Extension
|
||
/// (0xB5), slice (0x01..=0xAF), user-data (0xB2) and sequence-end (0xB7) codes
|
||
/// belong to the current access unit and are NOT boundaries.
|
||
fn find_au_start(data: &[u8], from: usize) -> Option<usize> {
|
||
let mut pos = from;
|
||
while let Some(sc) = find_start_code(data, pos) {
|
||
if sc + 3 >= data.len() {
|
||
return None;
|
||
}
|
||
let code = data[sc + 3];
|
||
if code == PICTURE_CODE || code == SEQ_HEADER_CODE || code == GOP_CODE {
|
||
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])
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::mux::ts::PesPacket;
|
||
|
||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||
PesPacket {
|
||
pid: 0x1011,
|
||
pts,
|
||
dts: None,
|
||
data,
|
||
}
|
||
}
|
||
|
||
/// 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_emit_two_frames_at_the_boundary() {
|
||
// pic1's frame is emitted as soon as pic2's start code is seen; pic2 on
|
||
// flush. Each frame contains exactly its own picture.
|
||
let mut parser = Mpeg2Parser::new();
|
||
|
||
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
|
||
pic1.extend_from_slice(&vec![0x11; 100]);
|
||
let mut pic2 = make_picture_header(2); // P
|
||
pic2.extend_from_slice(&vec![0x22; 100]);
|
||
|
||
let mut stream = pic1.clone();
|
||
stream.extend_from_slice(&pic2);
|
||
|
||
let mut frames = parser.parse(&make_pes(stream, Some(0)));
|
||
assert_eq!(
|
||
frames.len(),
|
||
1,
|
||
"first picture emitted at second's boundary"
|
||
);
|
||
assert_eq!(frames[0].data, pic1);
|
||
assert!(frames[0].keyframe);
|
||
|
||
frames.extend(parser.flush());
|
||
assert_eq!(frames.len(), 2);
|
||
assert_eq!(frames[1].data, pic2);
|
||
assert!(!frames[1].keyframe);
|
||
}
|
||
|
||
#[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(&vec![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() {
|
||
let mut parser = Mpeg2Parser::new();
|
||
|
||
// PES 1: pic1 (PTS 90000) + start of pic2's bytes carried later.
|
||
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
|
||
pic1.extend_from_slice(&vec![0x11; 50]);
|
||
let frames1 = parser.parse(&make_pes(pic1, Some(90000)));
|
||
assert!(frames1.is_empty(), "pic1 awaits pic2's boundary");
|
||
|
||
// PES 2: pic2 (PTS 180000).
|
||
let mut pic2 = make_picture_header(2);
|
||
pic2.extend_from_slice(&vec![0x22; 50]);
|
||
let mut frames = parser.parse(&make_pes(pic2, Some(180000)));
|
||
assert_eq!(frames.len(), 1, "pic1 emitted when pic2 starts");
|
||
assert_eq!(frames[0].pts_ns, 1_000_000_000, "pic1 → PTS 90000");
|
||
|
||
frames.extend(parser.flush());
|
||
assert_eq!(frames.len(), 2);
|
||
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));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
// --- 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()); // boundary → AU A emits
|
||
let fa = parser.parse(&make_pes(a, Some(0)));
|
||
assert_eq!(fa.len(), 1);
|
||
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 {
|
||
pid: 0x1011,
|
||
pts: None,
|
||
dts: Some(90000),
|
||
data,
|
||
};
|
||
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 {
|
||
pid: 0x1011,
|
||
pts: None,
|
||
dts: None,
|
||
data: data2,
|
||
};
|
||
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));
|
||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||
assert_eq!(
|
||
frames.len(),
|
||
1,
|
||
"over-cap AU force-flushed rather than buffered"
|
||
);
|
||
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
|
||
}
|
||
}
|