diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index fd2fa48..a47022f 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -11,23 +11,208 @@ //! //! Scope today: enumerate the `.evo` clips and yield one [`DiscTitle`] per clip //! (container [`ContentFormat::MpegPs`], so the existing PS mux path handles it). -//! What is NOT parsed yet — and is honestly stubbed, not faked: -//! * `.xpl` playlist ordering (title composition / chapters), -//! * per-clip stream enumeration (would demux the EVO program stream), -//! * `.map` timemap → real durations. +//! Per-clip streams ARE enumerated: the clip head is demuxed through the PS +//! demuxer and one [`Stream`] is built per distinct elementary stream (video + +//! DD+ audio sub-streams), with the codec sniffed from the ES bytes — this is +//! what the mux path needs to route packets. //! -//! Extents and size ARE real (the ripper needs those to image a clip); the rest -//! is left empty rather than guessed. +//! What is NOT parsed yet — and is honestly stubbed, not faked: +//! * `.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 crate::mux::ps::{PsDemuxer, dvd_audio_pid}; use crate::sector::SectorSource; use crate::udf; +use std::collections::BTreeMap; /// 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 /// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`. 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 { + 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 { + 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 { + let mut demux = PsDemuxer::new(); + let mut video: Vec = 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 = None; + // sub_id -> ES sample, ordered so audio tracks surface in sub-id order. + let mut audio: BTreeMap> = 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, + video_pid: &mut Option, + audio: &mut BTreeMap>, +) { + 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 { /// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips. /// @@ -68,6 +253,11 @@ impl Disc { if extents.is_empty() { 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 .rsplit_once('.') .map(|(base, _)| base.to_string()) @@ -84,7 +274,7 @@ impl Disc { duration_secs: 0.0, source_packets: 0, }], - streams: Vec::new(), + streams, chapters: Vec::new(), extents, content_format: ContentFormat::MpegPs, @@ -154,4 +344,224 @@ mod tests { "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 { + 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 { + 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 = 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 { + 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 { + 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" + ); + } } diff --git a/src/mux/au_assembly.rs b/src/mux/au_assembly.rs new file mode 100644 index 0000000..f080613 --- /dev/null +++ b/src/mux/au_assembly.rs @@ -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, + pub pts: Option, + pub dts: Option, + pub source: Option, + pub discontinuity: bool, +} + +/// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 `. +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 ` (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, + dts: Option, + source: Option, +} + +/// 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, + /// 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, + /// Absolute offsets of fragments flagged with an upstream discontinuity. + disc_marks: VecDeque, +} + +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, + dts: Option, + source: Option, + discontinuity: bool, + ) -> Vec { + // 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 { + if matches!(self.mode, Mode::Passthrough) { + return Vec::new(); + } + self.drain(true) + } + + fn drain(&mut self, force: bool) -> Vec { + 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 { + 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 { + 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 ` start code at or after `from`. +fn find_start_code(buf: &[u8], from: usize, marker: u8) -> Option { + 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 { + 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 { + 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 { + 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 { + 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" + ); + } +} diff --git a/src/mux/mod.rs b/src/mux/mod.rs index f582d5f..a3ed8be 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -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; diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 715a7f9..3ada30e 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -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, + /// 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, +} + +/// 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 = 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 { + 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)> = 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 diff --git a/src/mux/ps.rs b/src/mux/ps.rs index c40fe9e..b0c69c2 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -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 { 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 { } } +/// 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 { /// 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 { + let get = |p: usize| -> Option { + 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 { // 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 = (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.