mux: fix DVD subtitle/audio track collision, PGS/VobSub flush, unify TS codec table

Subtitle/DVD output-corruption + stream-mapping coverage fixes.

1. DVD subtitle/audio track-mapping collision (CRITICAL). The PS path
   routed 0xBD private-stream packets to a track via (sub_id & 0x1F)+1,
   so VobSub subtitle sub-id 0x20+j aliased audio track j+1: subtitle
   PES was fed to the AC-3 parser and the real subtitle track got
   nothing. Route by the canonical DVD PID instead via a new
   PsPacket::dvd_pid() that mirrors scan_dvd_titles' PID assignment
   (video 0xE0, audio 0xBD00+i, subtitle 0x20+j), then look up the
   track in pid_to_track. Fixed identically at all three sites
   (pipelined_stream consume_ps, disc.rs live feed, disc.rs EOF flush).
   Unmappable/unmapped packets now WARN instead of silently dropping.

2. PGS flush() missing. PgsParser inherited the no-op default flush, so
   the last subtitle of every PGS track (emitted only when a following
   PCS arrives) was dropped at EOF. Implemented flush() to drain the
   pending display set (duration_ns: None for the trailing block).

3. DVD VobSub multi-PES SPU not reassembled. A subpicture unit larger
   than one PES spans multiple PES (only the head carries a PTS).
   DvdSubParser is now stateful: it buffers per sub-stream until the
   leading 2-byte SPU_size is satisfied, inherits the head PTS, and
   emits one Frame. flush() drains a truncated trailing SPU at EOF.

4. One-table hygiene. scan_streams had a duplicate stream_type->Codec
   table that had drifted from Codec::from_coding_type (missing 0x80
   LPCM, 0x85 mapped to DTS-HD MA vs HR, etc.). scan_streams now uses
   from_coding_type plus a new Codec::kind()/CodecKind category split,
   so the two mappings can never diverge. Silent drops in
   scan_streams and bluray STN parsing now WARN with PID + type.

Tests: dvd_pid mapping + subtitle/audio collision regression, PGS
final-subtitle flush, VobSub multi-PES reassembly + EOF flush,
scan_streams 0x80 LPCM via from_coding_type.
This commit is contained in:
MattJackson
2026-06-06 21:33:11 -07:00
parent 7d58ba7b08
commit cfc12774f2
8 changed files with 519 additions and 152 deletions
+10 -2
View File
@@ -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();
+35 -1
View File
@@ -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 {
+120 -10
View File
@@ -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<Vec<u8>>,
/// In-progress SPU reassembly: (head PTS in ns, declared SPU_size, bytes).
pending: Option<(i64, usize, Vec<u8>)>,
}
impl DvdSubParser {
pub fn new(codec_data: Option<Vec<u8>>) -> 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<Frame> {
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<Frame> {
// 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<Vec<u8>> {
@@ -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]
+43
View File
@@ -117,6 +117,25 @@ impl CodecParser for PgsParser {
out
}
fn flush(&mut self) -> Vec<Frame> {
// 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<Vec<u8>> {
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();
+46 -36
View File
@@ -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,
+24 -17
View File
@@ -124,24 +124,31 @@ impl PipelinedPesStream {
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
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),
+95
View File
@@ -39,6 +39,39 @@ pub struct PsPacket {
pub data: Vec<u8>,
}
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<u16> {
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<u8>) -> 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<usize> {
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] {
+146 -86
View File
@@ -466,50 +466,36 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
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<Vec<crate::disc::Stream>> {
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<u8> {
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<u8> {
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<u8> {
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"
);
}
}