mux: HD-DVD VC-1 demux via extended stream id 0xFD
VC-1 HD-DVDs (e.g. Shaun of the Dead) carry video on MPEG-PS extended stream id 0xFD, with the real stream selector in stream_id_extension inside the PES extension. Parse that field so the video routes to a distinct track (pid 0xFD00|ext) instead of being dropped. Reframe VC-1 access units in AuAssembler with a dedicated Mode::Vc1: an AU is delimited by the next frame BDU (0x0D) once a frame has already been seen, so the sequence (0x0F) and entry-point (0x0E) headers that precede an I-frame stay attached to the frame they describe. The old single-start-code split stranded those headers on the prior AU, which the decoder reported as bits-overconsumption and hard decode failures. hddvd probe now tracks the video pid it detects and emits VC-1 on 0xFD.
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
//! Access-unit assembly — a codec-parser helper.
|
||||
//!
|
||||
//! The contract a codec parser converts is `PES → access units (Frames)`. A
|
||||
//! *transport* stream hands the parser one AU per PES for free (BD aligns one
|
||||
//! access unit per PES; the TS demuxer reassembles to the
|
||||
//! `payload_unit_start_indicator`). A *program* stream does not — the PS muxer
|
||||
//! chops the elementary stream into fixed-size PES fragments with no AU
|
||||
//! alignment, and only the first fragment of an AU carries a PTS. So a parser
|
||||
//! that assumes one-AU-per-PES (h264/hevc/vc1, written against TS) mis-frames a
|
||||
//! program stream, while `mpeg2` — the DVD/PS codec — has always reassembled
|
||||
//! across PES in its own parser.
|
||||
//!
|
||||
//! [`AuAssembler`] is that reassembly, factored out so the h264/hevc/vc1 parsers
|
||||
//! can do what `mpeg2` already does without hand-rolling the buffer three times.
|
||||
//! It buffers PES-fragment bytes and emits one AU per codec AU boundary, carrying
|
||||
//! the AU-start timing/source forward. Since the boundary is a codec start code,
|
||||
//! it lives with the codec parser (which picks the marker); only the generic
|
||||
//! buffering + timing-carry is shared here.
|
||||
//!
|
||||
//! This is *inside* the parser, not a pipeline stage: the pipeline stays
|
||||
//! `Demuxer → PES → Parser → Frames`, and the demuxer stays codec-agnostic. Every
|
||||
//! stream a parser sees runs through one of these — self-framing codecs (MPEG-2,
|
||||
//! audio) use [`Mode::Passthrough`] so the parser code path is uniform.
|
||||
|
||||
use crate::disc::Codec;
|
||||
use crate::pes::SourcePos;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Safety cap on a single in-progress access unit. A real coded picture is far
|
||||
/// below this; a stream that never yields a second AU boundary is force-flushed
|
||||
/// at the cap rather than buffering without bound on hostile/corrupt input.
|
||||
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// One AU-complete unit drained from the buffer: its elementary-stream bytes plus
|
||||
/// the timing/source/discontinuity of the fragment that opened the AU.
|
||||
pub(crate) struct AssembledAu {
|
||||
pub data: Vec<u8>,
|
||||
pub pts: Option<i64>,
|
||||
pub dts: Option<i64>,
|
||||
pub source: Option<SourcePos>,
|
||||
pub discontinuity: bool,
|
||||
}
|
||||
|
||||
/// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 <type>`.
|
||||
const VC1_FRAME: u8 = 0x0D; // coded picture
|
||||
const VC1_ENTRY: u8 = 0x0E; // entry-point header
|
||||
const VC1_SEQ: u8 = 0x0F; // sequence header
|
||||
|
||||
/// How a stream's fragments become AU-complete units.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mode {
|
||||
/// Split the elementary stream on the codec's single AU-delimiter start code
|
||||
/// `00 00 01 <marker>` (H.264 AUD `0x09`, HEVC AUD `0x46`). Every AU opens with
|
||||
/// exactly that code, so a plain split is correct.
|
||||
StartCode(u8),
|
||||
/// VC-1 has no single AU delimiter: an access unit is a `[sequence header?]
|
||||
/// [entry point?][frame][slices…]` group. The sequence-header (`0x0F`) and
|
||||
/// entry-point (`0x0E`) BDUs precede the frame (`0x0D`) they belong to, so a
|
||||
/// plain `0x0D` split would glue them onto the *previous* AU and strip every
|
||||
/// I-frame of its headers. The boundary is instead the next `0x0F`/`0x0E`/`0x0D`
|
||||
/// start code that follows a frame already seen in the current AU.
|
||||
Vc1,
|
||||
/// The codec self-frames (MPEG-2 reassembles in its own parser; audio resyncs
|
||||
/// on syncwords), so each fragment passes straight through as one unit. Lets
|
||||
/// the caller run EVERY stream through an assembler with no per-codec branch.
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
/// A timing/source mark taken at the absolute stream offset of a fragment that
|
||||
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
|
||||
/// whose byte range contains it.
|
||||
struct Mark {
|
||||
off: u64,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<SourcePos>,
|
||||
}
|
||||
|
||||
/// Reassembles PES fragments into AU-complete units. One per stream; stateful
|
||||
/// across `push` calls.
|
||||
pub(crate) struct AuAssembler {
|
||||
mode: Mode,
|
||||
/// Buffered elementary-stream bytes not yet emitted as a complete AU.
|
||||
buf: Vec<u8>,
|
||||
/// Absolute stream offset of `buf[0]`, so marks (taken at absolute offsets)
|
||||
/// survive `buf.drain(..)`.
|
||||
base: u64,
|
||||
/// Timing/source marks, in fragment order.
|
||||
marks: VecDeque<Mark>,
|
||||
/// Absolute offsets of fragments flagged with an upstream discontinuity.
|
||||
disc_marks: VecDeque<u64>,
|
||||
}
|
||||
|
||||
impl AuAssembler {
|
||||
/// An assembler for `codec`. Video codecs whose parsers assume AU-complete PES
|
||||
/// (H.264 / HEVC / VC-1) get a [`Mode::StartCode`] assembler; MPEG-2 (self-
|
||||
/// reassembles) and audio/subtitle codecs (self-framing) get [`Mode::Passthrough`]
|
||||
/// so callers can run every stream through this uniformly.
|
||||
pub(crate) fn for_codec(codec: Codec) -> Self {
|
||||
let mode = match codec {
|
||||
Codec::H264 => Mode::StartCode(0x09), // access_unit_delimiter NAL (type 9)
|
||||
Codec::Hevc => Mode::StartCode(0x46), // AUD NAL (type 35 → (35 << 1) = 0x46)
|
||||
Codec::Vc1 => Mode::Vc1, // frame + preceding seq/entry headers
|
||||
_ => Mode::Passthrough,
|
||||
};
|
||||
Self {
|
||||
mode,
|
||||
buf: Vec::with_capacity(256 * 1024),
|
||||
base: 0,
|
||||
marks: VecDeque::new(),
|
||||
disc_marks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one PES fragment; return every AU that is now complete.
|
||||
pub(crate) fn push(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
source: Option<SourcePos>,
|
||||
discontinuity: bool,
|
||||
) -> Vec<AssembledAu> {
|
||||
// Self-framing codecs pass through unchanged — one fragment, one unit,
|
||||
// its own timing. (This is exactly today's behaviour for mpeg2/audio.)
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return vec![AssembledAu {
|
||||
data: data.to_vec(),
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
discontinuity,
|
||||
}];
|
||||
}
|
||||
let off = self.base + self.buf.len() as u64;
|
||||
if pts.is_some() || dts.is_some() || source.is_some() {
|
||||
self.marks.push_back(Mark {
|
||||
off,
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if discontinuity {
|
||||
self.disc_marks.push_back(off);
|
||||
}
|
||||
self.buf.extend_from_slice(data);
|
||||
self.drain(false)
|
||||
}
|
||||
|
||||
/// Emit the trailing in-progress AU at end of stream (no following boundary).
|
||||
pub(crate) fn flush(&mut self) -> Vec<AssembledAu> {
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.drain(true)
|
||||
}
|
||||
|
||||
fn drain(&mut self, force: bool) -> Vec<AssembledAu> {
|
||||
if matches!(self.mode, Mode::Passthrough) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mode = self.mode;
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
// Locate the AU start code that opens the buffered run.
|
||||
let Some(a0) = au_opener(mode, &self.buf) else {
|
||||
// No AU boundary buffered. Bound memory: drop all but a 3-byte
|
||||
// tail (enough to catch a start-code prefix straddling the cut)
|
||||
// once over the cap; otherwise wait for more data.
|
||||
if self.buf.len() > MAX_AU_BUFFER {
|
||||
let drop = self.buf.len() - 3;
|
||||
self.buf.drain(..drop);
|
||||
self.base += drop as u64;
|
||||
self.drop_marks_before(self.base);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if a0 > 0 {
|
||||
// Leading bytes before the first AU boundary are a partial AU from
|
||||
// before we synced (or junk) — discard them and any stale marks.
|
||||
self.buf.drain(..a0);
|
||||
self.base += a0 as u64;
|
||||
self.drop_marks_before(self.base);
|
||||
continue;
|
||||
}
|
||||
// The AU runs from here (buf[0]) to the NEXT AU boundary.
|
||||
let end = match au_boundary(mode, &self.buf) {
|
||||
Some(next) => next,
|
||||
// No next boundary yet: on EOF (or over-cap backstop) the rest of
|
||||
// the buffer is this AU; otherwise wait for more data.
|
||||
None if force => self.buf.len(),
|
||||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||||
None => break,
|
||||
};
|
||||
if end == 0 {
|
||||
break;
|
||||
}
|
||||
let end_abs = self.base + end as u64;
|
||||
|
||||
// The AU's own timing/source/discontinuity: by the mark-drain
|
||||
// invariant (stale marks below `base` were already dropped) the front
|
||||
// mark, if it sits before this AU's end, belongs to this AU.
|
||||
let (mut pts, mut dts, mut source) = (None, None, None);
|
||||
if let Some(m) = self.marks.front() {
|
||||
if m.off < end_abs {
|
||||
pts = m.pts;
|
||||
dts = m.dts;
|
||||
source = m.source;
|
||||
}
|
||||
}
|
||||
while self.marks.front().is_some_and(|m| m.off < end_abs) {
|
||||
self.marks.pop_front();
|
||||
}
|
||||
let mut discontinuity = false;
|
||||
if self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||
discontinuity = true;
|
||||
}
|
||||
while self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
|
||||
let data = self.buf[..end].to_vec();
|
||||
self.buf.drain(..end);
|
||||
self.base += end as u64;
|
||||
out.push(AssembledAu {
|
||||
data,
|
||||
pts,
|
||||
dts,
|
||||
source,
|
||||
discontinuity,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn drop_marks_before(&mut self, off: u64) {
|
||||
while self.marks.front().is_some_and(|m| m.off < off) {
|
||||
self.marks.pop_front();
|
||||
}
|
||||
while self.disc_marks.front().is_some_and(|&o| o < off) {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset of the start code that opens the next AU in `buf` (at or after 0), or
|
||||
/// `None` if no AU-opening start code is buffered yet.
|
||||
fn au_opener(mode: Mode, buf: &[u8]) -> Option<usize> {
|
||||
match mode {
|
||||
Mode::StartCode(marker) => find_start_code(buf, 0, marker),
|
||||
// Any of the three AU-opening BDU types opens a VC-1 access unit.
|
||||
Mode::Vc1 => find_vc1_start(buf, 0),
|
||||
Mode::Passthrough => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset where the AU that opens at `buf[0]` ends (the start of the next AU), or
|
||||
/// `None` if the next boundary is not yet buffered.
|
||||
fn au_boundary(mode: Mode, buf: &[u8]) -> Option<usize> {
|
||||
match mode {
|
||||
// AU ends at the next delimiter; skip the opening one at buf[0].
|
||||
Mode::StartCode(marker) => find_start_code(buf, 4, marker),
|
||||
Mode::Vc1 => find_vc1_au_end(buf),
|
||||
Mode::Passthrough => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the next `00 00 01 <marker>` start code at or after `from`.
|
||||
fn find_start_code(buf: &[u8], from: usize, marker: u8) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && buf[i + 3] == marker {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the next VC-1 AU-opening BDU start code (`00 00 01` followed by a
|
||||
/// sequence header, entry point, or frame) at or after `from`.
|
||||
fn find_vc1_start(buf: &[u8], from: usize) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0
|
||||
&& buf[i + 1] == 0
|
||||
&& buf[i + 2] == 1
|
||||
&& matches!(buf[i + 3], VC1_FRAME | VC1_ENTRY | VC1_SEQ)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// End offset of the VC-1 access unit that opens at `buf[0]`: the next
|
||||
/// sequence-header / entry-point / frame BDU that appears *after* this AU already
|
||||
/// contains a frame (`0x0D`). Returns `None` while the AU is still open (no frame
|
||||
/// yet, or no following BDU buffered). A leading `0x0F`/`0x0E` header group thus
|
||||
/// stays attached to the frame it precedes rather than the previous AU.
|
||||
fn find_vc1_au_end(buf: &[u8]) -> Option<usize> {
|
||||
let mut seen_frame = false;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= buf.len() {
|
||||
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 {
|
||||
match buf[i + 3] {
|
||||
VC1_FRAME => {
|
||||
if i > 0 && seen_frame {
|
||||
return Some(i);
|
||||
}
|
||||
seen_frame = true;
|
||||
}
|
||||
VC1_ENTRY | VC1_SEQ => {
|
||||
if i > 0 && seen_frame {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AUD: &[u8] = &[0x00, 0x00, 0x01, 0x09]; // H.264 access-unit delimiter
|
||||
|
||||
fn au(payload: u8, len: usize) -> Vec<u8> {
|
||||
let mut v = AUD.to_vec();
|
||||
v.extend(std::iter::repeat(payload).take(len));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_framing_codecs_pass_through_each_fragment_unchanged() {
|
||||
// MPEG-2 (self-reassembles in its parser) and audio (syncword resync) run
|
||||
// through a Passthrough assembler: every fragment emerges immediately as
|
||||
// one unit with its own timing — byte-identical to today's path.
|
||||
for codec in [Codec::Mpeg2, Codec::Ac3Plus, Codec::Dts, Codec::Lpcm] {
|
||||
let mut a = AuAssembler::for_codec(codec);
|
||||
let out = a.push(&[1, 2, 3, 4], Some(42), None, None, false);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"{codec:?} passes each fragment straight through"
|
||||
);
|
||||
assert_eq!(out[0].data, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out[0].pts, Some(42));
|
||||
assert!(a.flush().is_empty(), "passthrough buffers nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_codecs_reassemble_across_fragments() {
|
||||
// H.264 buffers: one fragment is NOT a complete AU on its own.
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
assert!(
|
||||
a.push(&[0, 0, 1, 0x09, 0xAB], Some(1), None, None, false)
|
||||
.is_empty(),
|
||||
"holds an AU until the next boundary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_au_split_across_fragments_reassembles_with_start_pts() {
|
||||
// A single AU (AUD + 100 bytes) arrives as three fragments; only the
|
||||
// first carries a PTS. It must emit exactly ONE AU with that PTS.
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let full = au(0xAB, 100);
|
||||
assert!(
|
||||
a.push(&full[..40], Some(9000), None, None, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
|
||||
assert!(a.push(&full[80..], None, None, None, false).is_empty());
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(
|
||||
out[0].pts,
|
||||
Some(9000),
|
||||
"AU carries its START pts, not 0/None"
|
||||
);
|
||||
assert_eq!(out[0].data, full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_aus_emit_when_the_second_boundary_arrives() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let au1 = au(0x11, 50);
|
||||
let au2 = au(0x22, 60);
|
||||
let mut buf = au1.clone();
|
||||
buf.extend_from_slice(&au2);
|
||||
// AU1 + AU2's opening AUD → AU1 completes, tagged pts1.
|
||||
let out = a.push(&buf[..au1.len() + 4], Some(1000), None, None, false);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data, au1);
|
||||
assert_eq!(out[0].pts, Some(1000));
|
||||
a.push(&buf[au1.len() + 4..], None, None, None, false);
|
||||
let out2 = a.flush();
|
||||
assert_eq!(out2.len(), 1);
|
||||
assert_eq!(out2[0].data, au2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuity_flag_attaches_to_the_au_it_opens() {
|
||||
// A discontinuity-flagged fragment opens AU2; that flag must land on AU2,
|
||||
// not AU1 (the B1 resync gate keys off it).
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let au1 = au(0x11, 30);
|
||||
let au2 = au(0x22, 30);
|
||||
a.push(&au1, Some(1), None, None, false);
|
||||
// AU2 arrives flagged; its opening AUD completes AU1 first.
|
||||
let out = a.push(&au2, Some(2), None, None, true);
|
||||
assert_eq!(out.len(), 1, "AU1 completes when AU2's boundary arrives");
|
||||
assert!(!out[0].discontinuity, "AU1 is NOT the discontinuity");
|
||||
let out2 = a.flush();
|
||||
assert_eq!(out2.len(), 1);
|
||||
assert!(out2[0].discontinuity, "AU2 carries the discontinuity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_bytes_before_first_au_are_discarded() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let mut buf = vec![0xFF, 0xFF, 0xFF, 0xFF];
|
||||
buf.extend_from_slice(&au(0x33, 20));
|
||||
a.push(&buf, Some(500), None, None, false);
|
||||
let out = a.flush();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].data, au(0x33, 20), "leading junk dropped, AU intact");
|
||||
}
|
||||
|
||||
// ── VC-1 AU grouping ──────────────────────────────────────────────────
|
||||
|
||||
fn bdu(ty: u8, payload: u8, len: usize) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01, ty];
|
||||
v.extend(std::iter::repeat(payload).take(len));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_i_frame_keeps_its_preceding_seq_and_entry_headers() {
|
||||
// An I-frame AU is [seq 0x0F][entry 0x0E][frame 0x0D][slices]; a following
|
||||
// P-frame is just [frame 0x0D][slices]. A plain 0x0D split would strand the
|
||||
// seq/entry headers on the P-frame's AU — the decode bug. The VC-1 mode must
|
||||
// group them with the I-frame that follows them.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let mut iframe = bdu(VC1_SEQ, 0xAA, 8);
|
||||
iframe.extend(bdu(VC1_ENTRY, 0xBB, 6));
|
||||
iframe.extend(bdu(VC1_FRAME, 0xCC, 20)); // frame + slice bytes
|
||||
let pframe = bdu(VC1_FRAME, 0xDD, 15);
|
||||
|
||||
// Feed the I-frame; it stays open until the P-frame's boundary arrives.
|
||||
assert!(a.push(&iframe, Some(9000), None, None, false).is_empty());
|
||||
let out = a.push(&pframe, Some(9376), None, None, false);
|
||||
assert_eq!(out.len(), 1, "I-frame AU completes at the P-frame boundary");
|
||||
assert_eq!(out[0].data, iframe, "I-frame AU retains seq+entry+frame");
|
||||
assert_eq!(out[0].pts, Some(9000));
|
||||
|
||||
let tail = a.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data, pframe, "P-frame is its own AU");
|
||||
assert_eq!(tail[0].pts, Some(9376));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_consecutive_frames_split_one_per_au() {
|
||||
// Back-to-back frames with no headers between them each form their own AU.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let f1 = bdu(VC1_FRAME, 0x11, 30);
|
||||
let f2 = bdu(VC1_FRAME, 0x22, 40);
|
||||
let mut both = f1.clone();
|
||||
both.extend_from_slice(&f2);
|
||||
both.extend(bdu(VC1_FRAME, 0x33, 4)); // opening boundary of a 3rd frame
|
||||
let out = a.push(&both, Some(1), None, None, false);
|
||||
assert_eq!(out.len(), 2, "two complete frames emit");
|
||||
assert_eq!(out[0].data, f1);
|
||||
assert_eq!(out[1].data, f2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vc1_entry_point_without_seq_header_still_groups_with_frame() {
|
||||
// Mid-GOP open points can carry an entry-point header with no sequence
|
||||
// header; it must still attach to the frame that follows it.
|
||||
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||
let mut au = bdu(VC1_ENTRY, 0xEE, 5);
|
||||
au.extend(bdu(VC1_FRAME, 0xFF, 12));
|
||||
let mut done = a.push(&au, Some(500), None, None, false);
|
||||
// Next frame's opening boundary closes the entry+frame AU.
|
||||
done.extend(a.push(&bdu(VC1_FRAME, 0x00, 4), None, None, None, false));
|
||||
done.extend(a.flush());
|
||||
assert_eq!(done.len(), 2);
|
||||
assert_eq!(done[0].data, au, "entry+frame grouped");
|
||||
assert_eq!(done[0].pts, Some(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_cap_without_boundary_force_flushes() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
let big = au(0x44, MAX_AU_BUFFER + 16);
|
||||
let emitted = a.push(&big, Some(1), None, None, false);
|
||||
assert!(
|
||||
!emitted.is_empty(),
|
||||
"over-cap AU is force-flushed, not buffered forever"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub mod resolve;
|
||||
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||
// part of the parser/demux surface and covered by unit tests; allow the
|
||||
// dead-code lint rather than delete still-relevant scaffolding.
|
||||
pub(crate) mod au_assembly;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod codec;
|
||||
pub(crate) mod demux_sink;
|
||||
|
||||
+190
-17
@@ -75,6 +75,24 @@ pub struct PipelinedPesStream {
|
||||
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||
/// references the gate must protect). Indexed by stream index.
|
||||
is_video: Vec<bool>,
|
||||
/// Per-track access-unit assembler. On the PS path a program-stream video AU
|
||||
/// is split across many fixed-size PES fragments; this reassembles them to the
|
||||
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
|
||||
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
|
||||
/// use passthrough, so every track runs through it uniformly. Indexed by
|
||||
/// stream index. (TS titles are AU-complete already, so this is a passthrough
|
||||
/// there too — `consume_ts` does not use it.)
|
||||
au_asm: Vec<super::au_assembly::AuAssembler>,
|
||||
}
|
||||
|
||||
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
|
||||
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
|
||||
use crate::disc::Stream;
|
||||
match s {
|
||||
Stream::Video(v) => v.codec,
|
||||
Stream::Audio(a) => a.codec,
|
||||
Stream::Subtitle(sub) => sub.codec,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -102,6 +120,11 @@ impl PipelinedPesStream {
|
||||
let resync = (0..title.streams.len())
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
let au_asm = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
|
||||
.collect();
|
||||
Self {
|
||||
title,
|
||||
parsers,
|
||||
@@ -115,6 +138,7 @@ impl PipelinedPesStream {
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
is_video,
|
||||
au_asm,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,23 +278,50 @@ impl PipelinedPesStream {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let pes = PesPacket {
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the
|
||||
// codec parser, exactly as the TS path does — provenance must
|
||||
// survive the PsPacket → PesPacket seam so the frame's `source`
|
||||
// reaches the mux/index (FVI `src`), never reconstructed.
|
||||
source: ps.source,
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data,
|
||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
||||
discontinuity: false,
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the codec
|
||||
// parser, exactly as the TS path does — provenance must survive the
|
||||
// PsPacket → PesPacket seam so the frame's `source` reaches the
|
||||
// mux/index (FVI `src`), never reconstructed.
|
||||
let (pts_i64, dts_i64, src) = (
|
||||
ps.pts.map(|p| p as i64),
|
||||
ps.dts.map(|d| d as i64),
|
||||
ps.source,
|
||||
);
|
||||
// Reassemble the PS fragments into AU-complete PES for this track
|
||||
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
|
||||
// sees exactly the AU-complete shape a transport stream delivers. The
|
||||
// AU-start PTS/source survive the reassembly. A track with no assembler
|
||||
// (only reachable via a hand-built `pid_to_track` outrunning the stream
|
||||
// list) passes the fragment straight through. (PS path: no AACS conceal
|
||||
// → no continuity-gap flag.)
|
||||
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
|
||||
Some(asm) => asm
|
||||
.push(&ps.data, pts_i64, dts_i64, src, false)
|
||||
.into_iter()
|
||||
.map(|au| PesPacket {
|
||||
source: au.source,
|
||||
pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![PesPacket {
|
||||
source: src,
|
||||
pid,
|
||||
pts: pts_i64,
|
||||
dts: dts_i64,
|
||||
data: ps.data,
|
||||
discontinuity: false,
|
||||
}],
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
for pes in &pkts {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,11 +366,30 @@ impl Stream for PipelinedPesStream {
|
||||
let pending = &mut self.pending_frames;
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
let au_asm = &mut self.au_asm;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
for frame in parser.flush() {
|
||||
// First: the trailing access unit(s) the PS assembler
|
||||
// buffered past the final fragment (the last AU has no
|
||||
// following boundary). Parse them, THEN drain the parser's
|
||||
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
|
||||
let mut frames = Vec::new();
|
||||
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
|
||||
for au in tail {
|
||||
let pes = PesPacket {
|
||||
source: au.source,
|
||||
pid: *pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
};
|
||||
frames.extend(parser.parse(&pes));
|
||||
}
|
||||
frames.extend(parser.flush());
|
||||
for frame in frames {
|
||||
let emit = match resync.get_mut(track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(track).copied().unwrap_or(false),
|
||||
@@ -801,6 +871,109 @@ mod tests {
|
||||
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
||||
}
|
||||
|
||||
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
|
||||
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
|
||||
/// together form TWO H.264 access units (AUD-delimited); only AU-start
|
||||
/// fragments carry a PTS. Returns every emitted frame.
|
||||
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
|
||||
let mut title = DiscTitle::empty();
|
||||
title.streams.push(crate::disc::Stream::Video(VideoStream {
|
||||
pid: crate::mux::ps::DVD_VIDEO_PID,
|
||||
codec,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}));
|
||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
|
||||
crate::mux::ps::DVD_VIDEO_PID,
|
||||
Box::new(CountingParser {
|
||||
per_pes: 1,
|
||||
flush_n: 0,
|
||||
cp: None,
|
||||
}),
|
||||
)];
|
||||
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
|
||||
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||
|
||||
let frag = |pts, data: &[u8]| PsPacket {
|
||||
source: None,
|
||||
stream_id: 0xE0,
|
||||
sub_stream_id: None,
|
||||
pts,
|
||||
dts: None,
|
||||
data: data.to_vec(),
|
||||
};
|
||||
tx.send(DemuxBatch::Ps(vec![
|
||||
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
|
||||
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
|
||||
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
|
||||
]))
|
||||
.unwrap();
|
||||
tx.send(DemuxBatch::Eof).unwrap();
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = stream.read().unwrap() {
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PS-path integration: an H.264 access unit split across several fixed-size
|
||||
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
|
||||
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
|
||||
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
|
||||
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
|
||||
#[test]
|
||||
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
|
||||
let frames = run_ps_fragments(Codec::H264);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
2,
|
||||
"3 fragments → 2 access units, not 3 frames"
|
||||
);
|
||||
assert_eq!(frames[0].track, 0);
|
||||
assert_eq!(
|
||||
frames[0].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
|
||||
"AU1 = fragment1 + fragment2 rejoined"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[0].pts, 9_000,
|
||||
"AU carries its START pts, not the mid-fragment None→0"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[1].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
|
||||
"AU2 flushed at EOF (no following boundary)"
|
||||
);
|
||||
assert_eq!(frames[1].pts, 18_000);
|
||||
}
|
||||
|
||||
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
|
||||
/// a Passthrough assembler — the SAME three fragments pass straight through as
|
||||
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
|
||||
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
|
||||
#[test]
|
||||
fn ps_self_framing_codec_is_not_reassembled() {
|
||||
let frames = run_ps_fragments(Codec::Mpeg2);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
3,
|
||||
"MPEG-2 passthrough: one frame per fragment"
|
||||
);
|
||||
assert_eq!(frames[0].pts, 9_000);
|
||||
assert_eq!(
|
||||
frames[1].pts, 0,
|
||||
"mid-fragment has no PTS under passthrough"
|
||||
);
|
||||
assert_eq!(frames[2].pts, 18_000);
|
||||
}
|
||||
|
||||
/// A batch with no trackable packets must NOT terminate the stream early:
|
||||
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
|
||||
/// batch is followed by a real frame batch — the consumer must skip the
|
||||
|
||||
+211
-5
@@ -27,6 +27,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
|
||||
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||
/// elementary stream; expected to be dropped on every disc.
|
||||
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
||||
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
|
||||
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
|
||||
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
|
||||
/// a transport stream never uses it. The elementary-stream bytes follow the PES
|
||||
/// header exactly like any other PES — only the routing key differs.
|
||||
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||
|
||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||
@@ -73,9 +79,16 @@ pub const DVD_VIDEO_PID: u16 = 0xE0;
|
||||
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
||||
///
|
||||
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
|
||||
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
|
||||
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
|
||||
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
|
||||
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
|
||||
/// `0xC0..0xC3`) routes each track to its own distinct PID.
|
||||
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
match sub_stream_id {
|
||||
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +103,17 @@ pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
|
||||
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
|
||||
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
|
||||
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
|
||||
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
|
||||
/// `stream_id_extension`, so demux output routes through the title's
|
||||
/// `pid_to_track`.
|
||||
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
|
||||
0xFD00 | stream_id_extension as u16
|
||||
}
|
||||
|
||||
impl PsPacket {
|
||||
/// Map this packet to the canonical DVD PID assigned by
|
||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||
@@ -112,6 +136,12 @@ impl PsPacket {
|
||||
let sub = self.sub_stream_id?;
|
||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
||||
}
|
||||
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
|
||||
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
|
||||
// disc that puts several elementary streams on 0xFD keeps them apart.
|
||||
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
|
||||
// here — this only assigns a stable routing key.
|
||||
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -347,9 +377,74 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
fn is_pes_stream_id(id: u8) -> bool {
|
||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
|
||||
// We parse anything in the payload-bearing PES range.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
||||
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
|
||||
}
|
||||
|
||||
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
|
||||
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
|
||||
/// id. `data` starts at the PES start code; the optional fields live in
|
||||
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
|
||||
/// if the extension is absent or malformed.
|
||||
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
|
||||
let get = |p: usize| -> Option<u8> {
|
||||
if p < header_end {
|
||||
data.get(p).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut pos = 9usize;
|
||||
let pts_dts = (flags2 >> 6) & 0x03;
|
||||
if pts_dts & 0x02 != 0 {
|
||||
pos += 5; // PTS
|
||||
}
|
||||
if pts_dts == 0x03 {
|
||||
pos += 5; // DTS
|
||||
}
|
||||
if flags2 & 0x20 != 0 {
|
||||
pos += 6; // ESCR
|
||||
}
|
||||
if flags2 & 0x10 != 0 {
|
||||
pos += 3; // ES_rate
|
||||
}
|
||||
if flags2 & 0x08 != 0 {
|
||||
pos += 1; // DSM_trick_mode
|
||||
}
|
||||
if flags2 & 0x04 != 0 {
|
||||
pos += 1; // additional_copy_info
|
||||
}
|
||||
if flags2 & 0x02 != 0 {
|
||||
pos += 2; // PES_CRC
|
||||
}
|
||||
if flags2 & 0x01 == 0 {
|
||||
return None; // no PES_extension
|
||||
}
|
||||
let ext_flags = get(pos)?;
|
||||
pos += 1;
|
||||
if ext_flags & 0x80 != 0 {
|
||||
pos += 16; // PES_private_data
|
||||
}
|
||||
if ext_flags & 0x40 != 0 {
|
||||
// pack_header_field: 1-byte length + that many bytes.
|
||||
pos += 1 + get(pos)? as usize;
|
||||
}
|
||||
if ext_flags & 0x20 != 0 {
|
||||
pos += 2; // program_packet_sequence_counter
|
||||
}
|
||||
if ext_flags & 0x10 != 0 {
|
||||
pos += 2; // P-STD_buffer
|
||||
}
|
||||
if ext_flags & 0x01 == 0 {
|
||||
return None; // no PES_extension_flag_2
|
||||
}
|
||||
// PES_extension_field_length (7 bits, marker in the top bit), then the
|
||||
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
|
||||
let _field_len = get(pos)? & 0x7F;
|
||||
pos += 1;
|
||||
let b = get(pos)?;
|
||||
(b & 0x80 == 0).then_some(b & 0x7F)
|
||||
}
|
||||
|
||||
/// Parse a single PES packet from a byte slice that starts at the start code.
|
||||
@@ -416,10 +511,30 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
||||
|
||||
// For private stream 1, the first payload byte is the sub-stream ID,
|
||||
// followed by a sub-header whose length depends on the sub-stream type.
|
||||
let (sub_stream_id, es_data) = if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
|
||||
// HD-DVD extended-stream-id: the real stream id lives in the
|
||||
// stream_id_extension inside the PES extension. There is no leading
|
||||
// sub-header byte on the payload (unlike private_stream_1), so the ES
|
||||
// is the payload verbatim.
|
||||
(
|
||||
parse_stream_id_extension(data, data[7], header_end),
|
||||
payload.to_vec(),
|
||||
)
|
||||
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let sub_id = payload[0];
|
||||
let skip = match sub_id {
|
||||
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
||||
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
|
||||
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
|
||||
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
|
||||
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
|
||||
// sits `first_access_unit_pointer` bytes past this 4-byte header
|
||||
// (the leading bytes are the tail of the previous frame). Stripping
|
||||
// exactly these 4 bytes on EVERY packet yields a clean, continuous
|
||||
// E-AC-3 elementary stream that the ac3 parser reassembles across
|
||||
// PES boundaries; a shorter skip would splice the sub-header bytes
|
||||
// into a straddling frame and corrupt it.
|
||||
0xC0..=0xC7 => 4,
|
||||
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
||||
_ => 1,
|
||||
};
|
||||
@@ -836,6 +951,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_extracts_stream_id_extension() {
|
||||
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
|
||||
// only), header_data_length=3, optional bytes 0x0F/... — build the
|
||||
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
|
||||
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let opt = [0x01u8, 0x81, 0x55];
|
||||
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let len = (3 + opt.len() + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||
pkt.extend_from_slice(&opt);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
|
||||
assert_eq!(
|
||||
parsed.sub_stream_id,
|
||||
Some(0x55),
|
||||
"stream_id_extension extracted from PES extension"
|
||||
);
|
||||
// ES is the payload verbatim — no leading sub-header byte stripped.
|
||||
assert_eq!(parsed.data, es);
|
||||
// Routes to the extended-stream-id PID space.
|
||||
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
|
||||
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
|
||||
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
|
||||
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let es = [0x11u8, 0x22];
|
||||
let len = (3 + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.sub_stream_id, None);
|
||||
assert_eq!(parsed.dvd_pid(), None);
|
||||
assert_eq!(parsed.data, es);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_pid_matches_scanner_assignment() {
|
||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||
@@ -1175,6 +1336,51 @@ mod tests {
|
||||
assert_eq!(dvd_audio_pid(0xA8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_substream_maps_to_bd_pid() {
|
||||
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
|
||||
// distinct per track and disjoint from the DVD audio space. A DVD never
|
||||
// emits these, so the range is purely additive.
|
||||
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
|
||||
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
|
||||
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
|
||||
// Just outside the range.
|
||||
assert_eq!(dvd_audio_pid(0xBF), None);
|
||||
assert_eq!(dvd_audio_pid(0xC8), None);
|
||||
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
|
||||
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
|
||||
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
|
||||
// And route through dvd_pid on a private_stream_1 packet.
|
||||
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
|
||||
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
|
||||
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
|
||||
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
|
||||
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
|
||||
// payload — here the 0x0B77 syncword sits right after the sub-header.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let mut data = vec![
|
||||
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
||||
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
|
||||
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
||||
0xC0, // sub-stream id: DD+ track 0
|
||||
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
|
||||
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
|
||||
];
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||
let p = demuxer.feed(&data);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0].sub_stream_id, Some(0xC0));
|
||||
assert_eq!(
|
||||
p[0].data,
|
||||
vec![0x0B, 0x77, 0xDE, 0xAD],
|
||||
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_subtitle_pid_range_boundaries() {
|
||||
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
||||
|
||||
Reference in New Issue
Block a user