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:
+120
-10
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user