diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 6a05681..a4ade26 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -156,8 +156,16 @@ impl Disc { qualifier: crate::disc::LabelQualifier::None, codec_data: None, })), - // Stream type 4 = IG, unknown types -- skip - _ => None, + // Stream type 4 = IG, unknown types -- skip. + other => { + tracing::warn!( + "dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})", + other, + s.pid, + s.coding_type, + ); + None + } } }) .collect(); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 3a5c9c5..067a2fd 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -522,7 +522,7 @@ impl Codec { ("ssa", "SSA", Codec::Ssa), ]; - fn from_coding_type(ct: u8) -> Self { + pub(crate) fn from_coding_type(ct: u8) -> Self { match ct { 0x24 => Codec::Hevc, 0x1B => Codec::H264, @@ -540,6 +540,40 @@ impl Codec { ct => Codec::Unknown(ct), } } + + /// Broad stream category for a codec. Used by demuxers to decide + /// whether a PMT/STN entry becomes a video, audio, or subtitle + /// `Stream` without duplicating per-codec knowledge. + pub fn kind(&self) -> CodecKind { + match self { + Codec::Hevc | Codec::H264 | Codec::Vc1 | Codec::Mpeg2 | Codec::Mpeg1 | Codec::Av1 => { + CodecKind::Video + } + Codec::TrueHd + | Codec::DtsHdMa + | Codec::DtsHdHr + | Codec::Dts + | Codec::Ac3 + | Codec::Ac3Plus + | Codec::Lpcm + | Codec::Aac + | Codec::Mp2 + | Codec::Mp3 + | Codec::Flac + | Codec::Opus => CodecKind::Audio, + Codec::Pgs | Codec::DvdSub | Codec::Srt | Codec::Ssa => CodecKind::Subtitle, + Codec::Unknown(_) => CodecKind::Unknown, + } + } +} + +/// Broad category of a [`Codec`] — video / audio / subtitle / unknown. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodecKind { + Video, + Audio, + Subtitle, + Unknown, } impl std::fmt::Display for Codec { diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index 40fe82b..34e487b 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -1,22 +1,52 @@ //! DVD bitmap subtitle (VobSub) parser. //! //! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F. -//! Each subtitle display set may span multiple PES packets, but at the MKV level -//! we pass through the raw VobSub packets as-is — the container wraps them. +//! A single subpicture unit (SPU — one displayed bitmap) may span multiple PES +//! packets: only the first PES carries a PTS, continuations carry PTS=0. The SPU +//! begins with a 2-byte big-endian `SPU_size` giving the total byte length of the +//! whole unit. We reassemble across PES boundaries into one Frame so large +//! subtitles aren't split/garbled, inheriting the head PES's PTS. //! //! For MKV: codec ID "S_VOBSUB". //! All frames are keyframes (each is a complete bitmap). use super::{CodecParser, Frame, PesPacket, pts_to_ns}; +/// Upper bound on a single reassembled SPU. The SPU_size field is 16 bits, so a +/// well-formed unit is at most 0xFFFF bytes; cap accumulation here to bound +/// memory if the field is corrupt or the stream never completes a unit. +const MAX_SPU_BYTES: usize = 0xFFFF; + pub struct DvdSubParser { /// Pre-formatted VobSub .idx palette header for codec_private. codec_data: Option>, + /// In-progress SPU reassembly: (head PTS in ns, declared SPU_size, bytes). + pending: Option<(i64, usize, Vec)>, } impl DvdSubParser { pub fn new(codec_data: Option>) -> Self { - Self { codec_data } + Self { + codec_data, + pending: None, + } + } + + /// Emit `pending` as a Frame if it is complete (or `force` at EOF), + /// returning it and clearing the buffer. Returns None if nothing to emit. + fn take_if_complete(&mut self, force: bool) -> Option { + let (pts_ns, size, buf) = self.pending.as_ref()?; + if force || buf.len() >= *size { + let (pts_ns, _, data) = self.pending.take().unwrap(); + return Some(Frame { + pts_ns, + keyframe: true, + data, + duration_ns: None, + }); + } + let _ = pts_ns; + None } } @@ -25,13 +55,52 @@ impl CodecParser for DvdSubParser { if pes.data.is_empty() { return Vec::new(); } + + let mut out = Vec::new(); + + if self.pending.is_some() { + // Continuation of an in-progress SPU (PTS=0 on these). Append, + // bounded by MAX_SPU_BYTES. + if let Some((_, _, buf)) = self.pending.as_mut() { + let room = MAX_SPU_BYTES.saturating_sub(buf.len()); + let take = room.min(pes.data.len()); + buf.extend_from_slice(&pes.data[..take]); + } + if let Some(frame) = self.take_if_complete(false) { + out.push(frame); + } + return out; + } + + // Start of a new SPU. The first 2 bytes are the big-endian total size. let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); - vec![Frame { - pts_ns, - keyframe: true, - data: pes.data.clone(), - duration_ns: None, - }] + let declared = if pes.data.len() >= 2 { + ((pes.data[0] as usize) << 8) | pes.data[1] as usize + } else { + // Too short to carry SPU_size — pass through as a lone frame. + return vec![Frame { + pts_ns, + keyframe: true, + data: pes.data.clone(), + duration_ns: None, + }]; + }; + + let mut buf = pes.data.clone(); + if buf.len() > MAX_SPU_BYTES { + buf.truncate(MAX_SPU_BYTES); + } + self.pending = Some((pts_ns, declared, buf)); + if let Some(frame) = self.take_if_complete(false) { + out.push(frame); + } + out + } + + fn flush(&mut self) -> Vec { + // At EOF, emit whatever SPU bytes remain even if the declared size was + // never reached (truncated final subtitle is better than dropping it). + self.take_if_complete(true).into_iter().collect() } fn codec_private(&self) -> Option> { @@ -152,12 +221,53 @@ mod tests { #[test] fn no_pts_defaults_to_zero() { let mut parser = DvdSubParser::new(None); - let pes = make_pes(vec![0x01, 0x02], None); + // SPU_size = 2, single complete PES (the 2 size bytes themselves). + let pes = make_pes(vec![0x00, 0x02], None); let frames = parser.parse(&pes); assert_eq!(frames.len(), 1); assert_eq!(frames[0].pts_ns, 0); } + #[test] + fn multi_pes_spu_reassembled() { + let mut parser = DvdSubParser::new(None); + // Declared SPU_size = 12 bytes total. First PES carries the 2 size + // bytes + 4 payload bytes and the only PTS; the next two PESs are + // continuations with PTS=0. + let head = vec![0x00, 0x0C, 0xAA, 0xBB, 0xCC, 0xDD]; + let cont1 = vec![0x11, 0x22, 0x33]; + let cont2 = vec![0x44, 0x55, 0x66]; + + let f = parser.parse(&make_pes(head.clone(), Some(90000))); + assert!(f.is_empty(), "incomplete SPU should not emit yet"); + let f = parser.parse(&make_pes(cont1.clone(), Some(0))); + assert!(f.is_empty(), "still incomplete"); + let frames = parser.parse(&make_pes(cont2.clone(), Some(0))); + assert_eq!(frames.len(), 1, "completed SPU emits exactly one frame"); + + // Reassembled bytes = head + cont1 + cont2, in order. + let mut expected = head; + expected.extend_from_slice(&cont1); + expected.extend_from_slice(&cont2); + assert_eq!(frames[0].data, expected); + // PTS inherited from the head PES (1s = 1e9 ns), not the PTS=0 tails. + assert_eq!(frames[0].pts_ns, 1_000_000_000); + assert!(frames[0].keyframe); + } + + #[test] + fn flush_emits_truncated_trailing_spu() { + let mut parser = DvdSubParser::new(None); + // Declared 100 bytes but only 6 ever arrive before EOF. + let head = vec![0x00, 0x64, 0xDE, 0xAD, 0xBE, 0xEF]; + let f = parser.parse(&make_pes(head.clone(), Some(90000))); + assert!(f.is_empty(), "incomplete SPU should not emit during parse"); + let frames = parser.flush(); + assert_eq!(frames.len(), 1, "EOF flush emits the partial SPU"); + assert_eq!(frames[0].data, head); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + // ── YCbCr → RGB conversion tests ────────────────────────────────────── #[test] diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index e9b2af5..df87b3f 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -117,6 +117,25 @@ impl CodecParser for PgsParser { out } + fn flush(&mut self) -> Vec { + // A display set is only emitted when the *next* PCS arrives + // (either an empty clear PCS or a replacing display PCS). At + // end-of-stream there is no follower, so without this the last + // subtitle of every PGS track would be silently dropped. Emit + // the pending set with no duration — the trailing block lingers + // until end of file, which is exactly the desired behavior for + // the final on-screen subtitle (see the module doc). + match self.pending.take() { + Some((start_pts, data)) => vec![Frame { + pts_ns: start_pts, + keyframe: true, + data, + duration_ns: None, + }], + None => Vec::new(), + } + } + fn codec_private(&self) -> Option> { None } @@ -218,6 +237,30 @@ mod tests { assert_eq!(frames.len(), 1); } + #[test] + fn flush_emits_final_pending_subtitle() { + let mut parser = PgsParser::new(); + + // Display PCS at PTS 90000 — buffered as pending, no follower. + let display = pcs_bytes(1); + let frames = parser.parse(&make_pes(display.clone(), Some(90000))); + assert!(frames.is_empty(), "display PCS should be pending"); + + // EOF: without flush() this last subtitle would be dropped. + let frames = parser.flush(); + assert_eq!(frames.len(), 1, "final pending subtitle must flush"); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + assert_eq!(frames[0].data, display); + // Trailing block lingers to EOF — no duration per module doc. + assert_eq!(frames[0].duration_ns, None); + } + + #[test] + fn flush_with_nothing_pending_is_empty() { + let mut parser = PgsParser::new(); + assert!(parser.flush().is_empty()); + } + #[test] fn codec_private_none() { let parser = PgsParser::new(); diff --git a/src/mux/disc.rs b/src/mux/disc.rs index e783f3b..ec3968a 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -508,24 +508,30 @@ impl crate::pes::Stream for DiscStream { // PS demuxer flush (DVD) if let Some(ref mut demuxer) = self.ps_demuxer { for ps in &demuxer.flush() { - let track = match ps.stream_id { - 0xE0..=0xEF => 0, - 0xC0..=0xDF => 1, - 0xBD => ps - .sub_stream_id - .map(|s| (s & 0x1F) as usize + 1) - .unwrap_or(1), - _ => continue, - }; - if track >= self.title.streams.len() { + // Route by the REAL DVD PID (see consume_ps in + // pipelined_stream.rs); the old (sub_id & 0x1F)+1 + // heuristic mis-routed VobSub into the AC-3 parser. + let Some(pid) = ps.dvd_pid() else { + tracing::warn!( + target: "mux", + "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", + ps.stream_id, + ps.sub_stream_id, + ); continue; - } - let pid = self - .pid_to_track - .iter() - .find(|(_, idx)| *idx == track) - .map(|(p, _)| *p) - .unwrap_or(0); + }; + let Some((_, track)) = + self.pid_to_track.iter().find(|(p, _)| *p == pid).copied() + else { + tracing::warn!( + target: "mux", + "dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})", + pid, + ps.stream_id, + ps.sub_stream_id, + ); + continue; + }; let pes = super::ts::PesPacket { pid, pts: ps.pts.map(|p| p as i64), @@ -607,26 +613,30 @@ impl crate::pes::Stream for DiscStream { } else if let Some(ref mut demuxer) = self.ps_demuxer { let packets = demuxer.feed(&self.read_buf[..bytes]); for ps in &packets { - let track = match ps.stream_id { - 0xE0..=0xEF => 0, - 0xC0..=0xDF => 1, - 0xBD => ps - .sub_stream_id - .map(|s| (s & 0x1F) as usize + 1) - .unwrap_or(1), - _ => continue, - }; - if track >= self.title.streams.len() { + // Route by the REAL DVD PID (see consume_ps in + // pipelined_stream.rs); the old (sub_id & 0x1F)+1 + // heuristic mis-routed VobSub into the AC-3 parser. + let Some(pid) = ps.dvd_pid() else { + tracing::warn!( + target: "mux", + "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", + ps.stream_id, + ps.sub_stream_id, + ); continue; - } - - // Convert PsPacket to PesPacket for codec parser (same as BD-TS path) - let pid = self - .pid_to_track - .iter() - .find(|(_, idx)| *idx == track) - .map(|(p, _)| *p) - .unwrap_or(0); + }; + let Some((_, track)) = + self.pid_to_track.iter().find(|(p, _)| *p == pid).copied() + else { + tracing::warn!( + target: "mux", + "dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})", + pid, + ps.stream_id, + ps.sub_stream_id, + ); + continue; + }; let pes = super::ts::PesPacket { pid, diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index a2064bd..c7a95c1 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -124,24 +124,31 @@ impl PipelinedPesStream { fn consume_ps(&mut self, packets: Vec) { for ps in packets { - let track = match ps.stream_id { - 0xE0..=0xEF => 0, - 0xC0..=0xDF => 1, - 0xBD => ps - .sub_stream_id - .map(|s| (s & 0x1F) as usize + 1) - .unwrap_or(1), - _ => continue, - }; - if track >= self.title.streams.len() { + // Route by the REAL DVD PID (matching the PIDs that + // `scan_dvd_titles` assigns) rather than a synthetic track + // index. The old `(sub_id & 0x1F) + 1` heuristic collided + // subtitle sub-id 0x20+j with audio track j+1, feeding + // VobSub PES into the AC-3 parser. + let Some(pid) = ps.dvd_pid() else { + tracing::warn!( + target: "mux", + "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", + ps.stream_id, + ps.sub_stream_id, + ); continue; - } - let pid = self - .pid_to_track - .iter() - .find(|(_, idx)| *idx == track) - .map(|(p, _)| *p) - .unwrap_or(0); + }; + let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied() + else { + tracing::warn!( + target: "mux", + "dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})", + pid, + ps.stream_id, + ps.sub_stream_id, + ); + continue; + }; let pes = PesPacket { pid, pts: ps.pts.map(|p| p as i64), diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 0ea5c97..ae916b0 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -39,6 +39,39 @@ pub struct PsPacket { pub data: Vec, } +impl PsPacket { + /// Map this packet to the canonical DVD PID assigned by + /// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can + /// be looked up in the title's `pid_to_track` map. + /// + /// The PID space mirrors `dvd.rs` exactly: + /// - video stream id `0xE0..=0xEF` → `0xE0` + /// - private-stream-1 audio sub-id `0x80..=0x87` (AC-3), + /// `0x88..=0x8F` (DTS), `0xA0..=0xA7` (LPCM) → `0xBD00 + index` + /// - private-stream-1 subtitle sub-id `0x20..=0x3F` → `0x20 + index` + /// + /// Returns `None` for stream/sub-stream combinations the DVD title + /// scanner does not assign a PID to (e.g. MPEG audio 0xC0-0xDF, + /// private stream 2, unrecognized sub-stream ranges). The caller is + /// expected to WARN-and-drop in that case rather than silently + /// mis-routing the packet. + pub fn dvd_pid(&self) -> Option { + match self.stream_id { + 0xE0..=0xEF => Some(0xE0), + 0xBD => match self.sub_stream_id? { + // AC-3 / DTS / LPCM audio → 0xBD00 + audio index. + s @ 0x80..=0x87 => Some(0xBD00 + (s - 0x80) as u16), + s @ 0x88..=0x8F => Some(0xBD00 + (s - 0x88) as u16), + s @ 0xA0..=0xA7 => Some(0xBD00 + (s - 0xA0) as u16), + // VobSub subtitle sub-id 0x20+j → PID 0x20+j (identity). + s @ 0x20..=0x3F => Some(s as u16), + _ => None, + }, + _ => None, + } + } +} + /// MPEG-2 Program Stream demuxer. /// /// Accepts raw PS bytes via `feed()` and produces demuxed PES packets. @@ -549,6 +582,68 @@ mod tests { assert_eq!(decoded, val); } + // --- DVD PID mapping (track-routing collision regression) --- + + fn mk(stream_id: u8, sub: Option) -> PsPacket { + PsPacket { + stream_id, + sub_stream_id: sub, + pts: None, + dts: None, + data: vec![0xAA], + } + } + + #[test] + fn dvd_pid_matches_scanner_assignment() { + // Video → 0xE0 (matches dvd.rs VideoStream pid). + assert_eq!(mk(0xE0, None).dvd_pid(), Some(0xE0)); + // AC-3 audio stream 0/1 → 0xBD00 / 0xBD01 (matches 0xBD00 + i). + assert_eq!(mk(0xBD, Some(0x80)).dvd_pid(), Some(0xBD00)); + assert_eq!(mk(0xBD, Some(0x81)).dvd_pid(), Some(0xBD01)); + // DTS / LPCM audio indices. + assert_eq!(mk(0xBD, Some(0x88)).dvd_pid(), Some(0xBD00)); + assert_eq!(mk(0xBD, Some(0xA0)).dvd_pid(), Some(0xBD00)); + // VobSub subtitle 0x20/0x21 → 0x20 / 0x21 (matches 0x20 + j). + assert_eq!(mk(0xBD, Some(0x20)).dvd_pid(), Some(0x20)); + assert_eq!(mk(0xBD, Some(0x21)).dvd_pid(), Some(0x21)); + // Unmappable: MPEG audio, private stream 2, bogus sub-id. + assert_eq!(mk(0xC0, None).dvd_pid(), None); + assert_eq!(mk(0xBF, None).dvd_pid(), None); + assert_eq!(mk(0xBD, Some(0x10)).dvd_pid(), None); + } + + #[test] + fn subtitle_does_not_collide_with_audio_track() { + // Regression for the (sub_id & 0x1F)+1 bug: subtitle sub-id 0x20 + // used to alias audio track 1. With the real PID it routes to its + // own subtitle PID (0x20), distinct from audio (0xBD00+). + let audio0 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); // 0xBD00 + let sub0 = mk(0xBD, Some(0x20)).dvd_pid().unwrap(); // 0x20 + assert_ne!( + audio0, sub0, + "subtitle sub-id 0x20 must NOT map to the audio PID" + ); + + // Mirror dvd.rs PID assignment for a title with [video, audio0, + // audio1, sub0, sub1] and confirm each PS packet lands on its + // own track via pid_to_track. + let pid_to_track: Vec<(u16, usize)> = + vec![(0xE0, 0), (0xBD00, 1), (0xBD01, 2), (0x20, 3), (0x21, 4)]; + let route = |p: PsPacket| -> Option { + let pid = p.dvd_pid()?; + pid_to_track + .iter() + .find(|(x, _)| *x == pid) + .map(|(_, t)| *t) + }; + assert_eq!(route(mk(0xE0, None)), Some(0)); + assert_eq!(route(mk(0xBD, Some(0x80))), Some(1)); + assert_eq!(route(mk(0xBD, Some(0x81))), Some(2)); + assert_eq!(route(mk(0xBD, Some(0x20))), Some(3)); // sub0 → track 3, NOT 1 + assert_eq!(route(mk(0xBD, Some(0x21))), Some(4)); // sub1 → track 4, NOT 2 + } + // --- Helper: encode PTS for tests --- fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] { diff --git a/src/mux/ts.rs b/src/mux/ts.rs index c8494d2..31e26e9 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -466,50 +466,36 @@ pub fn scan_streams(data: &[u8]) -> Option> { let es_pid = (((data[pos + 1] & 0x1F) as u16) << 8) | data[pos + 2] as u16; let es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize; - let stream = match stream_type { - 0x1B => Some(Stream::Video(VideoStream { + // Single source of truth for stream_type → Codec: reuse + // `Codec::from_coding_type` (the same table the BD STN / + // disc scanner uses) so the two mappings can never drift. + // We only retain the category (video/audio/subtitle) and + // per-kind default attribute logic here. + let codec = Codec::from_coding_type(stream_type); + let stream = match codec.kind() { + CodecKind::Video => { + // Default resolution by codec generation (HEVC → + // UHD, MPEG-2 → 1080i, else 1080p); refined later + // from the actual elementary stream. + let resolution = match codec { + Codec::Hevc => Resolution::R2160p, + Codec::Mpeg2 => Resolution::R1080i, + _ => Resolution::R1080p, + }; + Some(Stream::Video(VideoStream { + pid: es_pid, + codec, + resolution, + frame_rate: FrameRate::Unknown, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + secondary: false, + label: String::new(), + })) + } + CodecKind::Audio => Some(Stream::Audio(AudioStream { pid: es_pid, - codec: Codec::H264, - resolution: Resolution::R1080p, - frame_rate: FrameRate::Unknown, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt709, - secondary: false, - label: String::new(), - })), - 0x24 => Some(Stream::Video(VideoStream { - pid: es_pid, - codec: Codec::Hevc, - resolution: Resolution::R2160p, - frame_rate: FrameRate::Unknown, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt709, - secondary: false, - label: String::new(), - })), - 0xEA => Some(Stream::Video(VideoStream { - pid: es_pid, - codec: Codec::Vc1, - resolution: Resolution::R1080p, - frame_rate: FrameRate::Unknown, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt709, - secondary: false, - label: String::new(), - })), - 0x02 => Some(Stream::Video(VideoStream { - pid: es_pid, - codec: Codec::Mpeg2, - resolution: Resolution::R1080i, - frame_rate: FrameRate::Unknown, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt709, - secondary: false, - label: String::new(), - })), - 0x81 => Some(Stream::Audio(AudioStream { - pid: es_pid, - codec: Codec::Ac3, + codec, channels: AudioChannels::Surround51, language: "und".into(), sample_rate: SampleRate::S48, @@ -517,55 +503,23 @@ pub fn scan_streams(data: &[u8]) -> Option> { purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })), - 0x83 => Some(Stream::Audio(AudioStream { + CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream { pid: es_pid, - codec: Codec::TrueHd, - channels: AudioChannels::Surround51, - language: "und".into(), - sample_rate: SampleRate::S48, - secondary: false, - purpose: crate::disc::LabelPurpose::Normal, - label: String::new(), - })), - 0x84 | 0xA1 => Some(Stream::Audio(AudioStream { - pid: es_pid, - codec: Codec::Ac3Plus, - channels: AudioChannels::Surround51, - language: "und".into(), - sample_rate: SampleRate::S48, - secondary: false, - purpose: crate::disc::LabelPurpose::Normal, - label: String::new(), - })), - 0x85 | 0x86 => Some(Stream::Audio(AudioStream { - pid: es_pid, - codec: Codec::DtsHdMa, - channels: AudioChannels::Surround51, - language: "und".into(), - sample_rate: SampleRate::S48, - secondary: false, - purpose: crate::disc::LabelPurpose::Normal, - label: String::new(), - })), - 0x82 => Some(Stream::Audio(AudioStream { - pid: es_pid, - codec: Codec::Dts, - channels: AudioChannels::Surround51, - language: "und".into(), - sample_rate: SampleRate::S48, - secondary: false, - purpose: crate::disc::LabelPurpose::Normal, - label: String::new(), - })), - 0x90 => Some(Stream::Subtitle(SubtitleStream { - pid: es_pid, - codec: Codec::Pgs, + codec, language: "und".into(), forced: false, qualifier: crate::disc::LabelQualifier::None, codec_data: None, })), - _ => None, + CodecKind::Unknown => { + tracing::warn!( + target: "mux", + "dropping PMT stream entry with unknown stream_type {:#04x} (PID {:#06x})", + stream_type, + es_pid, + ); + None + } }; if let Some(s) = stream { @@ -613,4 +567,110 @@ mod tests { let result = demux.feed(&[]); assert!(result.is_empty()); } + + // ── scan_streams PMT parsing ────────────────────────────────────────── + + /// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet + /// (4-byte timecode prefix the scanner skips). + fn bdts_packet(body: [u8; 184], pid: u16, pusi: bool) -> Vec { + let mut pkt = vec![0u8; BD_TS_PACKET_SIZE]; + // 4-byte timecode prefix is ignored; leave zero. + pkt[4] = SYNC_BYTE; + pkt[5] = ((pid >> 8) as u8) & 0x1F; + if pusi { + pkt[5] |= 0x40; + } + pkt[6] = (pid & 0xFF) as u8; + pkt[7] = 0x10; // payload only, no adaptation field + pkt[8..8 + 184].copy_from_slice(&body); + pkt + } + + /// Build a PAT TS packet pointing program 1 at `pmt_pid`. + fn pat_packet(pmt_pid: u16) -> Vec { + let mut body = [0xFFu8; 184]; + let mut i = 0; + body[i] = 0x00; // pointer_field + i += 1; + body[i] = 0x00; // table_id = PAT + // section_length counts bytes after the length field: tsid(2) + + // version/current_next(1) + section_number(1) + last_section(1) + + // one 4-byte program entry + 4-byte CRC = 13. + body[i + 1] = 0xB0; // section_syntax + reserved + len high nibble + body[i + 2] = 0x0D; // section_length low byte = 13 + body[i + 3] = 0x00; // tsid hi + body[i + 4] = 0x01; // tsid lo + body[i + 5] = 0xC1; // version/current_next + body[i + 6] = 0x00; // section_number + body[i + 7] = 0x00; // last_section_number + // program entry: program_number=1 → pmt_pid + body[i + 8] = 0x00; + body[i + 9] = 0x01; + body[i + 10] = 0xE0 | (((pmt_pid >> 8) as u8) & 0x1F); + body[i + 11] = (pmt_pid & 0xFF) as u8; + // (CRC bytes left as 0xFF — scanner doesn't validate CRC) + let _ = &mut i; + bdts_packet(body, 0, true) + } + + /// Build a PMT TS packet listing the given `(stream_type, es_pid)` entries. + fn pmt_packet(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec { + let mut body = [0xFFu8; 184]; + body[0] = 0x00; // pointer_field + let s = 1; // table start + body[s] = 0x02; // table_id = PMT + // Fixed PMT fields after section_length: 2(prog) +1 +2 +2(pcr) + // +2(prog_info_len=0) = 9, then per-entry 5 bytes, then 4 CRC. + let entries_len = entries.len() * 5; + let section_length = 9 + entries_len + 4; + body[s + 1] = 0xB0 | (((section_length >> 8) as u8) & 0x0F); + body[s + 2] = (section_length & 0xFF) as u8; + body[s + 3] = 0x00; // program_number hi + body[s + 4] = 0x01; // program_number lo + body[s + 5] = 0xC1; // version/current_next + body[s + 6] = 0x00; // section_number + body[s + 7] = 0x00; // last_section_number + body[s + 8] = 0xE0; // PCR PID hi (reserved bits) + body[s + 9] = 0x00; // PCR PID lo + body[s + 10] = 0xF0; // program_info_length hi (=0) + body[s + 11] = 0x00; // program_info_length lo + let mut p = s + 12; + for &(stype, es_pid) in entries { + body[p] = stype; + body[p + 1] = 0xE0 | (((es_pid >> 8) as u8) & 0x1F); + body[p + 2] = (es_pid & 0xFF) as u8; + body[p + 3] = 0xF0; // ES_info_length hi (=0) + body[p + 4] = 0x00; // ES_info_length lo + p += 5; + } + bdts_packet(body, pmt_pid, true) + } + + #[test] + fn scan_streams_maps_lpcm_via_from_coding_type() { + use crate::disc::{Codec, Stream}; + let pmt_pid = 0x0100; + let mut data = pat_packet(pmt_pid); + // 0x80 = LPCM (present in from_coding_type, was MISSING from the + // old duplicate table in scan_streams). 0x1B = H.264 video. + data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011), (0x80, 0x1100)])); + + let streams = scan_streams(&data).expect("PMT should parse"); + assert_eq!(streams.len(), 2, "video + LPCM audio"); + + let lpcm = streams + .iter() + .find(|s| matches!(s, Stream::Audio(a) if a.pid == 0x1100)) + .expect("LPCM audio stream present"); + if let Stream::Audio(a) = lpcm { + assert_eq!(a.codec, Codec::Lpcm, "0x80 must map to LPCM"); + } + + assert!( + streams + .iter() + .any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)), + "H.264 video present" + ); + } }