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:
+417
-7
@@ -11,23 +11,208 @@
|
|||||||
//!
|
//!
|
||||||
//! Scope today: enumerate the `.evo` clips and yield one [`DiscTitle`] per clip
|
//! Scope today: enumerate the `.evo` clips and yield one [`DiscTitle`] per clip
|
||||||
//! (container [`ContentFormat::MpegPs`], so the existing PS mux path handles it).
|
//! (container [`ContentFormat::MpegPs`], so the existing PS mux path handles it).
|
||||||
//! What is NOT parsed yet — and is honestly stubbed, not faked:
|
//! Per-clip streams ARE enumerated: the clip head is demuxed through the PS
|
||||||
//! * `.xpl` playlist ordering (title composition / chapters),
|
//! demuxer and one [`Stream`] is built per distinct elementary stream (video +
|
||||||
//! * per-clip stream enumeration (would demux the EVO program stream),
|
//! DD+ audio sub-streams), with the codec sniffed from the ES bytes — this is
|
||||||
//! * `.map` timemap → real durations.
|
//! what the mux path needs to route packets.
|
||||||
//!
|
//!
|
||||||
//! Extents and size ARE real (the ripper needs those to image a clip); the rest
|
//! What is NOT parsed yet — and is honestly stubbed, not faked:
|
||||||
//! is left empty rather than guessed.
|
//! * `.xpl` playlist ordering (title composition / chapters, FEATURE_1+2 join),
|
||||||
|
//! * `.map` timemap → real durations,
|
||||||
|
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
|
||||||
|
//!
|
||||||
|
//! Extents and size ARE real (the ripper needs those to image a clip); durations
|
||||||
|
//! and chapters are left empty rather than guessed.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use crate::udf;
|
use crate::udf;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
|
/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
|
||||||
/// separate tree from BD, so this is a separate constant — deliberately NOT an
|
/// separate tree from BD, so this is a separate constant — deliberately NOT an
|
||||||
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
|
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
|
||||||
const HDDVD_CLIP_EXT: &str = ".evo";
|
const HDDVD_CLIP_EXT: &str = ".evo";
|
||||||
|
|
||||||
|
/// Sectors of an `.evo` clip head to demux when probing its elementary streams
|
||||||
|
/// (~16 MiB). Enough to see the opening video access unit (SPS) plus every
|
||||||
|
/// interleaved audio sub-stream, without imaging the whole multi-GiB clip.
|
||||||
|
const EVO_PROBE_SECTORS: u32 = 8192;
|
||||||
|
|
||||||
|
/// Cap on the elementary-stream sample retained per stream while probing — a
|
||||||
|
/// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB
|
||||||
|
/// is generous while bounding probe memory.
|
||||||
|
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
|
||||||
|
|
||||||
|
/// Sniff a video codec from a program-stream video elementary-stream sample by
|
||||||
|
/// its MPEG / Annex-B start codes:
|
||||||
|
/// * `00 00 01 B3` → MPEG-2 (sequence_header)
|
||||||
|
/// * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU)
|
||||||
|
/// * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264
|
||||||
|
///
|
||||||
|
/// Returns `None` when no recognizable start code is present. The scan prefers
|
||||||
|
/// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
|
||||||
|
/// NAL so a stray slice/picture code can't be mistaken for a different codec.
|
||||||
|
fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
|
||||||
|
let mut saw_h264_sps = false;
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 4 <= es.len() {
|
||||||
|
if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 {
|
||||||
|
let code = es[i + 3];
|
||||||
|
match code {
|
||||||
|
0xB3 => return Some(Codec::Mpeg2),
|
||||||
|
0x0F => return Some(Codec::Vc1),
|
||||||
|
// H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the
|
||||||
|
// forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits).
|
||||||
|
// 0x07/0x27/0x47/0x67 all decode to a type-7 SPS.
|
||||||
|
_ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saw_h264_sps.then_some(Codec::H264)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only
|
||||||
|
/// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is
|
||||||
|
/// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an
|
||||||
|
/// unrecognized sample so the caller drops the stream rather than mislabeling it.
|
||||||
|
fn sniff_audio_codec(es: &[u8]) -> Option<Codec> {
|
||||||
|
let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77);
|
||||||
|
has_sync.then_some(Codec::Ac3Plus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and
|
||||||
|
/// build one [`Stream`] per distinct elementary stream found: the video track
|
||||||
|
/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream
|
||||||
|
/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes.
|
||||||
|
///
|
||||||
|
/// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
|
||||||
|
/// language / channels use sane HD-DVD defaults (the muxer reads the true pixel
|
||||||
|
/// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
|
||||||
|
/// here). Returns an empty vec when the clip cannot be read or carries no
|
||||||
|
/// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
|
||||||
|
fn probe_evo_streams(reader: &mut dyn SectorSource, extents: &[Extent]) -> Vec<Stream> {
|
||||||
|
let mut demux = PsDemuxer::new();
|
||||||
|
let mut video: Vec<u8> = Vec::new();
|
||||||
|
// Routing PID of the video track, captured from the first video PES seen:
|
||||||
|
// `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2),
|
||||||
|
// or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video
|
||||||
|
// (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid`
|
||||||
|
// so the emitted `Stream` PID matches what the demuxer routes at mux time.
|
||||||
|
let mut video_pid: Option<u16> = None;
|
||||||
|
// sub_id -> ES sample, ordered so audio tracks surface in sub-id order.
|
||||||
|
let mut audio: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
|
||||||
|
|
||||||
|
let mut remaining = EVO_PROBE_SECTORS;
|
||||||
|
'outer: for ext in extents {
|
||||||
|
let mut lba = ext.start_lba;
|
||||||
|
let mut left = ext.sector_count;
|
||||||
|
while left > 0 && remaining > 0 {
|
||||||
|
// 1 MiB read chunks (512 sectors) keep buffers small.
|
||||||
|
let n = left.min(remaining).min(512) as u16;
|
||||||
|
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
|
||||||
|
if reader.read_sectors(lba, n, &mut buf, false).is_err() {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
for pkt in demux.feed(&buf) {
|
||||||
|
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
|
||||||
|
}
|
||||||
|
lba += n as u32;
|
||||||
|
left -= n as u32;
|
||||||
|
remaining -= n as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pkt in demux.flush() {
|
||||||
|
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut streams = Vec::new();
|
||||||
|
if let Some(pid) = video_pid {
|
||||||
|
// Default to H.264 when a video PES was seen but the codec could not be
|
||||||
|
// sniffed from the sampled head — the demux found video, just no
|
||||||
|
// recognizable start code yet; dropping it would leave the title with no
|
||||||
|
// video track and fail the mux.
|
||||||
|
let codec = sniff_video_codec(&video).unwrap_or(Codec::H264);
|
||||||
|
streams.push(Stream::Video(VideoStream {
|
||||||
|
pid,
|
||||||
|
codec,
|
||||||
|
// HD-DVD is HD (1080). The muxer reads the true coded dimensions
|
||||||
|
// from the H.264/VC-1 bitstream; this is a coarse default only.
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (sub, sample) in &audio {
|
||||||
|
let Some(codec) = sniff_audio_codec(sample) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(pid) = dvd_audio_pid(*sub) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
streams.push(Stream::Audio(AudioStream {
|
||||||
|
pid,
|
||||||
|
codec,
|
||||||
|
// DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at
|
||||||
|
// scan time, so this is a default (a 2.0 track is over-stated as
|
||||||
|
// 5.1 in the header — the compressed audio itself is unaffected).
|
||||||
|
channels: AudioChannels::Surround51,
|
||||||
|
language: String::new(),
|
||||||
|
sample_rate: SampleRate::S48,
|
||||||
|
secondary: false,
|
||||||
|
purpose: crate::disc::LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate a demuxed PES packet's elementary-stream bytes into the video /
|
||||||
|
/// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]).
|
||||||
|
fn collect_es(
|
||||||
|
pkt: &crate::mux::ps::PsPacket,
|
||||||
|
video: &mut Vec<u8>,
|
||||||
|
video_pid: &mut Option<u16>,
|
||||||
|
audio: &mut BTreeMap<u8, Vec<u8>>,
|
||||||
|
) {
|
||||||
|
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
|
||||||
|
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||||
|
match pkt.stream_id {
|
||||||
|
// Plain MPEG video (0xE0-0xEF), or the HD-DVD extended-stream-id (0xFD)
|
||||||
|
// that carries VC-1 video. Both feed the single video ES sample; the
|
||||||
|
// routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
|
||||||
|
VIDEO..=VIDEO_MAX | EXTENDED_STREAM_ID => {
|
||||||
|
if video_pid.is_none() {
|
||||||
|
*video_pid = pkt.dvd_pid();
|
||||||
|
}
|
||||||
|
if video.len() < EVO_ES_SAMPLE_CAP {
|
||||||
|
video.extend_from_slice(&pkt.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PRIVATE_STREAM_1 => {
|
||||||
|
if let Some(sub) = pkt.sub_stream_id {
|
||||||
|
if (0xC0..=0xC7).contains(&sub) {
|
||||||
|
let slot = audio.entry(sub).or_default();
|
||||||
|
if slot.len() < EVO_ES_SAMPLE_CAP {
|
||||||
|
slot.extend_from_slice(&pkt.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
|
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
|
||||||
///
|
///
|
||||||
@@ -68,6 +253,11 @@ impl Disc {
|
|||||||
if extents.is_empty() {
|
if extents.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Probe the clip head for its elementary streams so the mux path
|
||||||
|
// builds a non-empty `pid_to_track` and actually routes packets.
|
||||||
|
// Without this the PS demuxer drops every packet and the mux emits
|
||||||
|
// nothing (the historical HD-DVD blocker).
|
||||||
|
let streams = probe_evo_streams(reader, &extents);
|
||||||
let clip_id = name
|
let clip_id = name
|
||||||
.rsplit_once('.')
|
.rsplit_once('.')
|
||||||
.map(|(base, _)| base.to_string())
|
.map(|(base, _)| base.to_string())
|
||||||
@@ -84,7 +274,7 @@ impl Disc {
|
|||||||
duration_secs: 0.0,
|
duration_secs: 0.0,
|
||||||
source_packets: 0,
|
source_packets: 0,
|
||||||
}],
|
}],
|
||||||
streams: Vec::new(),
|
streams,
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
extents,
|
extents,
|
||||||
content_format: ContentFormat::MpegPs,
|
content_format: ContentFormat::MpegPs,
|
||||||
@@ -154,4 +344,224 @@ mod tests {
|
|||||||
"clip_id drops the extension"
|
"clip_id drops the extension"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── codec sniffing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sniff_video_codec_recognizes_h264_vc1_mpeg2() {
|
||||||
|
// H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7.
|
||||||
|
assert_eq!(
|
||||||
|
sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]),
|
||||||
|
Some(Codec::H264)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]),
|
||||||
|
Some(Codec::H264)
|
||||||
|
);
|
||||||
|
// VC-1 sequence-header BDU (0x0F).
|
||||||
|
assert_eq!(
|
||||||
|
sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]),
|
||||||
|
Some(Codec::Vc1)
|
||||||
|
);
|
||||||
|
// MPEG-2 sequence_header (0xB3).
|
||||||
|
assert_eq!(
|
||||||
|
sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]),
|
||||||
|
Some(Codec::Mpeg2)
|
||||||
|
);
|
||||||
|
// A slice/picture-only sample (no SPS/sequence) is indeterminate.
|
||||||
|
assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None);
|
||||||
|
assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sniff_audio_codec_recognizes_eac3_syncword() {
|
||||||
|
assert_eq!(
|
||||||
|
sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]),
|
||||||
|
Some(Codec::Ac3Plus)
|
||||||
|
);
|
||||||
|
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EVO head probe → streams ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`.
|
||||||
|
fn pes(stream_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01, stream_id];
|
||||||
|
let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload
|
||||||
|
v.extend_from_slice(&len.to_be_bytes());
|
||||||
|
v.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||||
|
v.extend_from_slice(payload);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on
|
||||||
|
/// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES
|
||||||
|
/// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword),
|
||||||
|
/// then program-end.
|
||||||
|
fn synthetic_evo() -> Vec<u8> {
|
||||||
|
let mut d = Vec::new();
|
||||||
|
// MPEG-2 pack header (14 bytes, stuffing 0).
|
||||||
|
d.extend_from_slice(&[
|
||||||
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
|
||||||
|
]);
|
||||||
|
// Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF
|
||||||
|
// range): SPS (type 7) + IDR (type 5) Annex-B.
|
||||||
|
let video_es = [
|
||||||
|
0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS
|
||||||
|
0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice
|
||||||
|
];
|
||||||
|
d.extend_from_slice(&pes(0xE2, &video_es));
|
||||||
|
// DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in
|
||||||
|
// — the demuxer strips 4 bytes, leaving the E-AC-3 syncword.
|
||||||
|
for sub in [0xC0u8, 0xC1] {
|
||||||
|
let audio_payload = [
|
||||||
|
sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2)
|
||||||
|
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body
|
||||||
|
];
|
||||||
|
d.extend_from_slice(&pes(0xBD, &audio_payload));
|
||||||
|
}
|
||||||
|
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes.
|
||||||
|
fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs {
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "HVDVD_TS".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(disc, 10);
|
||||||
|
lay_dir(disc, &root);
|
||||||
|
crate::udf::read_filesystem(disc).expect("fs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and
|
||||||
|
/// two DD+ audio PES yields a title with the video track (canonical
|
||||||
|
/// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty
|
||||||
|
/// `streams` the mux path needs to route packets (the historical blocker).
|
||||||
|
#[test]
|
||||||
|
fn scan_hddvd_titles_probes_streams_from_evo_head() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
|
||||||
|
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||||
|
assert_eq!(titles.len(), 1);
|
||||||
|
let t = &titles[0];
|
||||||
|
|
||||||
|
let video: Vec<_> = t
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Video(v) => Some(v),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(video.len(), 1, "one video track probed");
|
||||||
|
assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264");
|
||||||
|
assert_eq!(
|
||||||
|
video[0].pid,
|
||||||
|
crate::mux::ps::DVD_VIDEO_PID,
|
||||||
|
"video routes to canonical PID"
|
||||||
|
);
|
||||||
|
|
||||||
|
let audio: Vec<_> = t
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Audio(a) => Some(a),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(audio.len(), 2, "both DD+ sub-streams probed");
|
||||||
|
assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus));
|
||||||
|
let pids: Vec<u16> = audio.iter().map(|a| a.pid).collect();
|
||||||
|
assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clip whose head carries no recognizable stream (unreadable /
|
||||||
|
/// ciphertext) leaves `streams` empty rather than fabricating one — the
|
||||||
|
/// title still enumerates (extents are real).
|
||||||
|
#[test]
|
||||||
|
fn scan_hddvd_titles_empty_streams_when_head_unrecognized() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
// 4 KiB of junk with no PS start codes.
|
||||||
|
let junk = vec![0x55u8; 4096];
|
||||||
|
let udf = make_hddvd_fs_with_evo(&mut disc, &junk);
|
||||||
|
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||||
|
assert_eq!(titles.len(), 1);
|
||||||
|
assert!(
|
||||||
|
titles[0].streams.is_empty(),
|
||||||
|
"no recognizable stream → empty, not fabricated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given
|
||||||
|
/// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01
|
||||||
|
/// (PES_extension only), header_data_length=3, optional bytes
|
||||||
|
/// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1
|
||||||
|
/// video PES uses (ext 0x55).
|
||||||
|
fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01, 0xFD];
|
||||||
|
let opt = [0x01u8, 0x81, stream_id_extension];
|
||||||
|
let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload
|
||||||
|
v.extend_from_slice(&len.to_be_bytes());
|
||||||
|
v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||||
|
v.extend_from_slice(&opt);
|
||||||
|
v.extend_from_slice(payload);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext
|
||||||
|
/// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES.
|
||||||
|
fn synthetic_evo_vc1() -> Vec<u8> {
|
||||||
|
let mut d = Vec::new();
|
||||||
|
d.extend_from_slice(&[
|
||||||
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
|
||||||
|
]);
|
||||||
|
// VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D).
|
||||||
|
let video_es = [
|
||||||
|
0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU
|
||||||
|
0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU
|
||||||
|
];
|
||||||
|
d.extend_from_slice(&pes_extended(0x55, &video_es));
|
||||||
|
let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD];
|
||||||
|
d.extend_from_slice(&pes(0xBD, &audio_payload));
|
||||||
|
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields
|
||||||
|
/// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer
|
||||||
|
/// derives from the same stream_id_extension, so mux-time routing lines up.
|
||||||
|
#[test]
|
||||||
|
fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1());
|
||||||
|
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||||
|
assert_eq!(titles.len(), 1);
|
||||||
|
|
||||||
|
let video: Vec<_> = titles[0]
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Video(v) => Some(v),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(video.len(), 1, "one video track probed");
|
||||||
|
assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed");
|
||||||
|
assert_eq!(
|
||||||
|
video[0].pid,
|
||||||
|
crate::mux::ps::hddvd_extended_pid(0x55),
|
||||||
|
"VC-1 routes to the extended-stream-id PID 0xFD55"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||||
// part of the parser/demux surface and covered by unit tests; allow the
|
// part of the parser/demux surface and covered by unit tests; allow the
|
||||||
// dead-code lint rather than delete still-relevant scaffolding.
|
// dead-code lint rather than delete still-relevant scaffolding.
|
||||||
|
pub(crate) mod au_assembly;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) mod codec;
|
pub(crate) mod codec;
|
||||||
pub(crate) mod demux_sink;
|
pub(crate) mod demux_sink;
|
||||||
|
|||||||
+184
-11
@@ -75,6 +75,24 @@ pub struct PipelinedPesStream {
|
|||||||
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||||
/// references the gate must protect). Indexed by stream index.
|
/// references the gate must protect). Indexed by stream index.
|
||||||
is_video: Vec<bool>,
|
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 {
|
impl PipelinedPesStream {
|
||||||
@@ -102,6 +120,11 @@ impl PipelinedPesStream {
|
|||||||
let resync = (0..title.streams.len())
|
let resync = (0..title.streams.len())
|
||||||
.map(|_| super::resync::ResyncGate::new())
|
.map(|_| super::resync::ResyncGate::new())
|
||||||
.collect();
|
.collect();
|
||||||
|
let au_asm = title
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
|
||||||
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
title,
|
title,
|
||||||
parsers,
|
parsers,
|
||||||
@@ -115,6 +138,7 @@ impl PipelinedPesStream {
|
|||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
|
au_asm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,21 +278,47 @@ impl PipelinedPesStream {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let pes = PesPacket {
|
// Carry the PS demuxer's byte-exact source stamp through to the codec
|
||||||
// Carry the PS demuxer's byte-exact source stamp through to the
|
// parser, exactly as the TS path does — provenance must survive the
|
||||||
// codec parser, exactly as the TS path does — provenance must
|
// PsPacket → PesPacket seam so the frame's `source` reaches the
|
||||||
// survive the PsPacket → PesPacket seam so the frame's `source`
|
// mux/index (FVI `src`), never reconstructed.
|
||||||
// reaches the mux/index (FVI `src`), never reconstructed.
|
let (pts_i64, dts_i64, src) = (
|
||||||
source: ps.source,
|
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,
|
pid,
|
||||||
pts: ps.pts.map(|p| p as i64),
|
pts: au.pts,
|
||||||
dts: ps.dts.map(|d| d as i64),
|
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,
|
data: ps.data,
|
||||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
|
||||||
discontinuity: false,
|
discontinuity: false,
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
|
for pes in &pkts {
|
||||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||||
for frame in parser.parse(&pes) {
|
for frame in parser.parse(pes) {
|
||||||
self.pending_frames
|
self.pending_frames
|
||||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||||
}
|
}
|
||||||
@@ -276,6 +326,7 @@ impl PipelinedPesStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Stream for PipelinedPesStream {
|
impl Stream for PipelinedPesStream {
|
||||||
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||||
@@ -315,11 +366,30 @@ impl Stream for PipelinedPesStream {
|
|||||||
let pending = &mut self.pending_frames;
|
let pending = &mut self.pending_frames;
|
||||||
let resync = &mut self.resync;
|
let resync = &mut self.resync;
|
||||||
let is_video = &self.is_video;
|
let is_video = &self.is_video;
|
||||||
|
let au_asm = &mut self.au_asm;
|
||||||
for (pid, parser) in self.parsers.iter_mut() {
|
for (pid, parser) in self.parsers.iter_mut() {
|
||||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||||
continue;
|
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) {
|
let emit = match resync.get_mut(track) {
|
||||||
Some(gate) => gate.admit(
|
Some(gate) => gate.admit(
|
||||||
is_video.get(track).copied().unwrap_or(false),
|
is_video.get(track).copied().unwrap_or(false),
|
||||||
@@ -801,6 +871,109 @@ mod tests {
|
|||||||
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
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:
|
/// 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
|
/// 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
|
/// 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
|
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||||
/// elementary stream; expected to be dropped on every disc.
|
/// elementary stream; expected to be dropped on every disc.
|
||||||
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
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
|
/// 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
|
/// 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`
|
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
/// 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> {
|
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||||
match sub_stream_id {
|
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,
|
_ => 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 {
|
impl PsPacket {
|
||||||
/// Map this packet to the canonical DVD PID assigned by
|
/// Map this packet to the canonical DVD PID assigned by
|
||||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||||
@@ -112,6 +136,12 @@ impl PsPacket {
|
|||||||
let sub = self.sub_stream_id?;
|
let sub = self.sub_stream_id?;
|
||||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
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,
|
_ => 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.
|
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||||
fn is_pes_stream_id(id: u8) -> bool {
|
fn is_pes_stream_id(id: u8) -> bool {
|
||||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
|
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
||||||
// We parse anything in the payload-bearing PES range.
|
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
||||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
|
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.
|
/// 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,
|
// 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.
|
// 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 sub_id = payload[0];
|
||||||
let skip = match sub_id {
|
let skip = match sub_id {
|
||||||
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
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
|
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
||||||
_ => 1,
|
_ => 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]
|
#[test]
|
||||||
fn dvd_pid_matches_scanner_assignment() {
|
fn dvd_pid_matches_scanner_assignment() {
|
||||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||||
@@ -1175,6 +1336,51 @@ mod tests {
|
|||||||
assert_eq!(dvd_audio_pid(0xA8), None);
|
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]
|
#[test]
|
||||||
fn dvd_subtitle_pid_range_boundaries() {
|
fn dvd_subtitle_pid_range_boundaries() {
|
||||||
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
||||||
|
|||||||
Reference in New Issue
Block a user