Merge branch 'worktree-agent-a184f5a97a37e2c1a'
This commit is contained in:
+10
-2
@@ -156,8 +156,16 @@ impl Disc {
|
|||||||
qualifier: crate::disc::LabelQualifier::None,
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
codec_data: None,
|
codec_data: None,
|
||||||
})),
|
})),
|
||||||
// Stream type 4 = IG, unknown types -- skip
|
// Stream type 4 = IG, unknown types -- skip.
|
||||||
_ => None,
|
other => {
|
||||||
|
tracing::warn!(
|
||||||
|
"dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})",
|
||||||
|
other,
|
||||||
|
s.pid,
|
||||||
|
s.coding_type,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
+35
-1
@@ -522,7 +522,7 @@ impl Codec {
|
|||||||
("ssa", "SSA", Codec::Ssa),
|
("ssa", "SSA", Codec::Ssa),
|
||||||
];
|
];
|
||||||
|
|
||||||
fn from_coding_type(ct: u8) -> Self {
|
pub(crate) fn from_coding_type(ct: u8) -> Self {
|
||||||
match ct {
|
match ct {
|
||||||
0x24 => Codec::Hevc,
|
0x24 => Codec::Hevc,
|
||||||
0x1B => Codec::H264,
|
0x1B => Codec::H264,
|
||||||
@@ -540,6 +540,40 @@ impl Codec {
|
|||||||
ct => Codec::Unknown(ct),
|
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 {
|
impl std::fmt::Display for Codec {
|
||||||
|
|||||||
+116
-6
@@ -1,22 +1,52 @@
|
|||||||
//! DVD bitmap subtitle (VobSub) parser.
|
//! DVD bitmap subtitle (VobSub) parser.
|
||||||
//!
|
//!
|
||||||
//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F.
|
//! 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
|
//! A single subpicture unit (SPU — one displayed bitmap) may span multiple PES
|
||||||
//! we pass through the raw VobSub packets as-is — the container wraps them.
|
//! 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".
|
//! For MKV: codec ID "S_VOBSUB".
|
||||||
//! All frames are keyframes (each is a complete bitmap).
|
//! All frames are keyframes (each is a complete bitmap).
|
||||||
|
|
||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
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 {
|
pub struct DvdSubParser {
|
||||||
/// Pre-formatted VobSub .idx palette header for codec_private.
|
/// Pre-formatted VobSub .idx palette header for codec_private.
|
||||||
codec_data: Option<Vec<u8>>,
|
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 {
|
impl DvdSubParser {
|
||||||
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
|
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() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
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);
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||||
vec![Frame {
|
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,
|
pts_ns,
|
||||||
keyframe: true,
|
keyframe: true,
|
||||||
data: pes.data.clone(),
|
data: pes.data.clone(),
|
||||||
duration_ns: None,
|
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>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
@@ -152,12 +221,53 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_pts_defaults_to_zero() {
|
fn no_pts_defaults_to_zero() {
|
||||||
let mut parser = DvdSubParser::new(None);
|
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);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
assert_eq!(frames[0].pts_ns, 0);
|
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 ──────────────────────────────────────
|
// ── YCbCr → RGB conversion tests ──────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -117,6 +117,25 @@ impl CodecParser for PgsParser {
|
|||||||
out
|
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>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -218,6 +237,30 @@ mod tests {
|
|||||||
assert_eq!(frames.len(), 1);
|
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]
|
#[test]
|
||||||
fn codec_private_none() {
|
fn codec_private_none() {
|
||||||
let parser = PgsParser::new();
|
let parser = PgsParser::new();
|
||||||
|
|||||||
+46
-36
@@ -508,24 +508,30 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
// PS demuxer flush (DVD)
|
// PS demuxer flush (DVD)
|
||||||
if let Some(ref mut demuxer) = self.ps_demuxer {
|
if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||||
for ps in &demuxer.flush() {
|
for ps in &demuxer.flush() {
|
||||||
let track = match ps.stream_id {
|
// Route by the REAL DVD PID (see consume_ps in
|
||||||
0xE0..=0xEF => 0,
|
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||||
0xC0..=0xDF => 1,
|
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||||
0xBD => ps
|
let Some(pid) = ps.dvd_pid() else {
|
||||||
.sub_stream_id
|
tracing::warn!(
|
||||||
.map(|s| (s & 0x1F) as usize + 1)
|
target: "mux",
|
||||||
.unwrap_or(1),
|
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||||
_ => continue,
|
ps.stream_id,
|
||||||
};
|
ps.sub_stream_id,
|
||||||
if track >= self.title.streams.len() {
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let pid = self
|
let Some((_, track)) =
|
||||||
.pid_to_track
|
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||||
.iter()
|
else {
|
||||||
.find(|(_, idx)| *idx == track)
|
tracing::warn!(
|
||||||
.map(|(p, _)| *p)
|
target: "mux",
|
||||||
.unwrap_or(0);
|
"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 {
|
let pes = super::ts::PesPacket {
|
||||||
pid,
|
pid,
|
||||||
pts: ps.pts.map(|p| p as i64),
|
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 {
|
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||||
for ps in &packets {
|
for ps in &packets {
|
||||||
let track = match ps.stream_id {
|
// Route by the REAL DVD PID (see consume_ps in
|
||||||
0xE0..=0xEF => 0,
|
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||||
0xC0..=0xDF => 1,
|
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||||
0xBD => ps
|
let Some(pid) = ps.dvd_pid() else {
|
||||||
.sub_stream_id
|
tracing::warn!(
|
||||||
.map(|s| (s & 0x1F) as usize + 1)
|
target: "mux",
|
||||||
.unwrap_or(1),
|
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||||
_ => continue,
|
ps.stream_id,
|
||||||
};
|
ps.sub_stream_id,
|
||||||
if track >= self.title.streams.len() {
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
let Some((_, track)) =
|
||||||
// Convert PsPacket to PesPacket for codec parser (same as BD-TS path)
|
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||||
let pid = self
|
else {
|
||||||
.pid_to_track
|
tracing::warn!(
|
||||||
.iter()
|
target: "mux",
|
||||||
.find(|(_, idx)| *idx == track)
|
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
|
||||||
.map(|(p, _)| *p)
|
pid,
|
||||||
.unwrap_or(0);
|
ps.stream_id,
|
||||||
|
ps.sub_stream_id,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let pes = super::ts::PesPacket {
|
let pes = super::ts::PesPacket {
|
||||||
pid,
|
pid,
|
||||||
|
|||||||
+24
-17
@@ -124,24 +124,31 @@ impl PipelinedPesStream {
|
|||||||
|
|
||||||
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
|
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
|
||||||
for ps in packets {
|
for ps in packets {
|
||||||
let track = match ps.stream_id {
|
// Route by the REAL DVD PID (matching the PIDs that
|
||||||
0xE0..=0xEF => 0,
|
// `scan_dvd_titles` assigns) rather than a synthetic track
|
||||||
0xC0..=0xDF => 1,
|
// index. The old `(sub_id & 0x1F) + 1` heuristic collided
|
||||||
0xBD => ps
|
// subtitle sub-id 0x20+j with audio track j+1, feeding
|
||||||
.sub_stream_id
|
// VobSub PES into the AC-3 parser.
|
||||||
.map(|s| (s & 0x1F) as usize + 1)
|
let Some(pid) = ps.dvd_pid() else {
|
||||||
.unwrap_or(1),
|
tracing::warn!(
|
||||||
_ => continue,
|
target: "mux",
|
||||||
};
|
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||||
if track >= self.title.streams.len() {
|
ps.stream_id,
|
||||||
|
ps.sub_stream_id,
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let pid = self
|
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||||
.pid_to_track
|
else {
|
||||||
.iter()
|
tracing::warn!(
|
||||||
.find(|(_, idx)| *idx == track)
|
target: "mux",
|
||||||
.map(|(p, _)| *p)
|
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
|
||||||
.unwrap_or(0);
|
pid,
|
||||||
|
ps.stream_id,
|
||||||
|
ps.sub_stream_id,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let pes = PesPacket {
|
let pes = PesPacket {
|
||||||
pid,
|
pid,
|
||||||
pts: ps.pts.map(|p| p as i64),
|
pts: ps.pts.map(|p| p as i64),
|
||||||
|
|||||||
@@ -39,6 +39,39 @@ pub struct PsPacket {
|
|||||||
pub data: Vec<u8>,
|
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.
|
/// MPEG-2 Program Stream demuxer.
|
||||||
///
|
///
|
||||||
/// Accepts raw PS bytes via `feed()` and produces demuxed PES packets.
|
/// Accepts raw PS bytes via `feed()` and produces demuxed PES packets.
|
||||||
@@ -549,6 +582,68 @@ mod tests {
|
|||||||
assert_eq!(decoded, val);
|
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 ---
|
// --- Helper: encode PTS for tests ---
|
||||||
|
|
||||||
fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] {
|
fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] {
|
||||||
|
|||||||
+140
-80
@@ -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_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 es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize;
|
||||||
|
|
||||||
let stream = match stream_type {
|
// Single source of truth for stream_type → Codec: reuse
|
||||||
0x1B => Some(Stream::Video(VideoStream {
|
// `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,
|
pid: es_pid,
|
||||||
codec: Codec::H264,
|
codec,
|
||||||
resolution: Resolution::R1080p,
|
resolution,
|
||||||
frame_rate: FrameRate::Unknown,
|
frame_rate: FrameRate::Unknown,
|
||||||
hdr: HdrFormat::Sdr,
|
hdr: HdrFormat::Sdr,
|
||||||
color_space: ColorSpace::Bt709,
|
color_space: ColorSpace::Bt709,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
})),
|
}))
|
||||||
0x24 => Some(Stream::Video(VideoStream {
|
}
|
||||||
|
CodecKind::Audio => Some(Stream::Audio(AudioStream {
|
||||||
pid: es_pid,
|
pid: es_pid,
|
||||||
codec: Codec::Hevc,
|
codec,
|
||||||
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,
|
|
||||||
channels: AudioChannels::Surround51,
|
channels: AudioChannels::Surround51,
|
||||||
language: "und".into(),
|
language: "und".into(),
|
||||||
sample_rate: SampleRate::S48,
|
sample_rate: SampleRate::S48,
|
||||||
@@ -517,55 +503,23 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
|||||||
purpose: crate::disc::LabelPurpose::Normal,
|
purpose: crate::disc::LabelPurpose::Normal,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
})),
|
})),
|
||||||
0x83 => Some(Stream::Audio(AudioStream {
|
CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream {
|
||||||
pid: es_pid,
|
pid: es_pid,
|
||||||
codec: Codec::TrueHd,
|
codec,
|
||||||
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,
|
|
||||||
language: "und".into(),
|
language: "und".into(),
|
||||||
forced: false,
|
forced: false,
|
||||||
qualifier: crate::disc::LabelQualifier::None,
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
codec_data: 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 {
|
if let Some(s) = stream {
|
||||||
@@ -613,4 +567,110 @@ mod tests {
|
|||||||
let result = demux.feed(&[]);
|
let result = demux.feed(&[]);
|
||||||
assert!(result.is_empty());
|
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