0.31.0: hardening and correctness pass across mux, codec, AACS/CSS, UDF/MPLS/CLPI, recovery, drive/SCSI, labels, and I/O
Library-wide review-and-fix pass: tightened AACS keydb/handshake/variant handling and trailing-partial-unit policy, corrected MPLS mark offset and added UDF allocation bounds, hardened the mux/codec framing and M2TS paths, guarded SCSI READ CAPACITY short transfers and unified error mapping, added overflow guards on untrusted disc input, and made prefetch shutdown deterministic. Release profile now builds with thin LTO + single codegen unit.
This commit is contained in:
+89
-20
@@ -7,10 +7,22 @@
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is
|
||||
/// reserved in AC-3 and signals "fscod2" (reduced rates) in E-AC-3; we treat
|
||||
/// the base rate as 48 kHz in that case for duration purposes.
|
||||
/// reserved in AC-3; in E-AC-3 it signals "fscod2" (reduced rates: 24/22.05/16
|
||||
/// kHz, selected by byte-4 bits [5:4]). `frame_sample_rate` decodes fscod2 in
|
||||
/// the E-AC-3 case; this table's index-3 entry (48 kHz) is only the fallback
|
||||
/// when the header is too short to read fscod2.
|
||||
const SAMPLE_RATES: [u32; 4] = [48_000, 44_100, 32_000, 48_000];
|
||||
|
||||
/// E-AC-3 reduced sample rates indexed by fscod2 (byte-4 bits [5:4]), used when
|
||||
/// fscod==3. Index 3 is reserved; we fall back to 48 kHz for it.
|
||||
const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000];
|
||||
|
||||
/// Minimum byte length of a valid (E-)AC-3 frame. A real E-AC-3 frame must carry
|
||||
/// at least the syncword (2) + BSI header (~4) before any audio. `eac3_frame_size`
|
||||
/// returns `(frmsiz + 1) * 2`, so frmsiz=0/1 yield 2/4-byte "frames" that are
|
||||
/// sub-header junk; rejecting anything below this guards against emitting them.
|
||||
const MIN_FRAME_BYTES: usize = 6;
|
||||
|
||||
/// AC-3 (legacy) always carries 6 audio blocks × 256 samples = 1536 samples.
|
||||
const AC3_SAMPLES_PER_FRAME: u32 = 1536;
|
||||
|
||||
@@ -89,8 +101,9 @@ impl CodecParser for Ac3Parser {
|
||||
ac3_frame_size(remaining)
|
||||
};
|
||||
|
||||
if frame_size == 0 || frame_size > 8192 {
|
||||
// Invalid frame size — skip this sync word
|
||||
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) {
|
||||
// Invalid/sub-header frame size (e.g. an E-AC-3 frmsiz of 0/1
|
||||
// sizing to a 2/4-byte fragment) — skip this sync word.
|
||||
pos = start + 2;
|
||||
continue;
|
||||
}
|
||||
@@ -112,13 +125,15 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
|
||||
// Keep unconsumed data for the next call. `pos` is the start of the
|
||||
// unconsumed region: either a partial frame that straddles this PES
|
||||
// boundary — which, by construction, begins at a syncword (every byte
|
||||
// before `pos` was emitted as a frame or skipped as pre-sync junk) — or
|
||||
// trailing bytes too short to size/complete a frame. Carry from `pos`,
|
||||
// NOT from the next syncword: discarding bytes between `pos` and the
|
||||
// next sync would drop the partial frame we are deliberately keeping
|
||||
// across the boundary.
|
||||
// last unprocessed search region. On the `start + frame_size > len`
|
||||
// break it sits exactly at the straddling frame's syncword; on the
|
||||
// `remaining.len() < 6` break it is the value from the top of that
|
||||
// iteration, with the syncword possibly sitting after some pre-sync
|
||||
// junk — so the re-scan below (from `pos`, NOT a recomputed sync) is
|
||||
// required to locate the carry-over syncword. Carry from `pos`, NOT
|
||||
// from the next syncword: discarding bytes between `pos` and the next
|
||||
// sync would drop the partial frame we are deliberately keeping across
|
||||
// the boundary.
|
||||
let keep_from = if pos < data.len() {
|
||||
// A syncword at/after `pos` marks the carry-over start (anything
|
||||
// before it is junk with no sync). With no full sync, retain the
|
||||
@@ -139,6 +154,11 @@ impl CodecParser for Ac3Parser {
|
||||
// No frame could be parsed out of a buffer this large — this is
|
||||
// not valid AC-3 here. Drop it and resync on the next PES rather
|
||||
// than grow without bound on pathological input.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"ac3: carry-over buffer exceeded {} bytes without a frame; dropping and resyncing",
|
||||
MAX_AC3_BUF
|
||||
);
|
||||
self.buf.clear();
|
||||
} else {
|
||||
self.buf = tail.to_vec();
|
||||
@@ -174,7 +194,7 @@ impl CodecParser for Ac3Parser {
|
||||
} else {
|
||||
ac3_frame_size(frame)
|
||||
};
|
||||
if frame_size == 0 || frame_size > 8192 || off + frame_size > buf.len() {
|
||||
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
@@ -212,12 +232,20 @@ fn eac3_samples_per_frame(data: &[u8]) -> u32 {
|
||||
numblks * 256
|
||||
}
|
||||
|
||||
/// Sample rate (Hz) of an AC-3/E-AC-3 frame from its fscod field (byte 4 bits 7-6).
|
||||
fn frame_sample_rate(data: &[u8]) -> u32 {
|
||||
/// Sample rate (Hz) of an AC-3/E-AC-3 frame from its fscod field (byte 4 bits
|
||||
/// 7-6). For E-AC-3 (`bsid >= 11`) an fscod of 3 selects a reduced rate via
|
||||
/// fscod2 (byte 4 bits [5:4]); decoding it keeps the frame duration correct
|
||||
/// instead of mistiming reduced-rate frames at 48 kHz (A/V drift).
|
||||
fn frame_sample_rate(data: &[u8], bsid: u8) -> u32 {
|
||||
if data.len() < 5 {
|
||||
return SAMPLE_RATES[0];
|
||||
}
|
||||
SAMPLE_RATES[((data[4] >> 6) & 0x03) as usize]
|
||||
let fscod = (data[4] >> 6) & 0x03;
|
||||
if fscod == 0x03 && bsid >= 11 {
|
||||
let fscod2 = (data[4] >> 4) & 0x03;
|
||||
return EAC3_REDUCED_RATES[fscod2 as usize];
|
||||
}
|
||||
SAMPLE_RATES[fscod as usize]
|
||||
}
|
||||
|
||||
/// Duration of one AC-3/E-AC-3 frame in nanoseconds: samples_per_frame /
|
||||
@@ -228,7 +256,7 @@ fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 {
|
||||
} else {
|
||||
AC3_SAMPLES_PER_FRAME
|
||||
} as u64;
|
||||
let rate = frame_sample_rate(data) as u64;
|
||||
let rate = frame_sample_rate(data, bsid) as u64;
|
||||
// samples / rate seconds → ns, rounded to nearest.
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
@@ -240,7 +268,7 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
|
||||
/// Extract bsid from an AC-3/E-AC-3 frame starting at the syncword.
|
||||
/// bsid is at byte 5, bits 7..3.
|
||||
pub fn get_bsid(data: &[u8]) -> u8 {
|
||||
fn get_bsid(data: &[u8]) -> u8 {
|
||||
if data.len() < 6 {
|
||||
return 0;
|
||||
}
|
||||
@@ -256,8 +284,11 @@ fn eac3_frame_size(data: &[u8]) -> usize {
|
||||
(frmsiz + 1) * 2
|
||||
}
|
||||
|
||||
/// Calculate AC-3 frame size in bytes from fscod and frmsizecod.
|
||||
fn ac3_frame_size(data: &[u8]) -> usize {
|
||||
/// Calculate AC-3 frame size in bytes from fscod and frmsizecod. Returns 0 for
|
||||
/// an unmappable header (reserved fscod==3, or frmsizecod out of table range).
|
||||
/// `pub(crate)` so the TrueHD parser can reuse it when skipping interleaved AC-3
|
||||
/// frames instead of duplicating the size table.
|
||||
pub(crate) fn ac3_frame_size(data: &[u8]) -> usize {
|
||||
if data.len() < 5 {
|
||||
return 0;
|
||||
}
|
||||
@@ -439,7 +470,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn buffer_stays_bounded_across_many_garbage_pes() {
|
||||
// Finding 14: the carry-over buffer must never grow without bound. Feed
|
||||
// The carry-over buffer must never grow without bound. Feed
|
||||
// many large PES packets that contain no usable frame and assert the
|
||||
// retained buffer stays tiny — carry-from-`pos` drops all pre-sync junk,
|
||||
// and a never-completing frame is bounded by the 8192-byte frame cap and
|
||||
@@ -573,6 +604,44 @@ mod tests {
|
||||
assert_eq!(frame_duration_ns(&frame, bsid), 32_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_subheader_sized_frame_is_rejected() {
|
||||
// An E-AC-3 sync with frmsiz=0 sizes to a 2-byte "frame"; frmsiz=1 to
|
||||
// 4 bytes. Both are sub-header junk that must NOT be emitted as audio.
|
||||
// bsid must be >= 11 for the E-AC-3 sizing path. Byte 5 bits 7..3 = bsid.
|
||||
let mut parser = Ac3Parser::new();
|
||||
// Build an E-AC-3 sync: 0x0B 0x77, frmsiz=0 (bytes 2-3 low bits = 0),
|
||||
// bsid=16 (>=11) at byte 5. Pad to a few bytes so find_ac3_sync + sizing
|
||||
// run. eac3_frame_size = (0 + 1) * 2 = 2 < MIN_FRAME_BYTES.
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 16 << 3, 0x00, 0x00];
|
||||
// Append a real AC-3 frame after the junk so we can confirm the parser
|
||||
// resyncs past the junk and still emits the valid frame.
|
||||
let good = make_ac3_frame(0, 2);
|
||||
data.extend_from_slice(&good);
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
|
||||
assert_eq!(frames[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_fscod2_reduced_rate_duration() {
|
||||
// E-AC-3 with fscod==3 (reduced rate) and fscod2==0 → 24 kHz, not 48.
|
||||
// bsid>=11 selects the E-AC-3 path. When fscod==3 the block count is
|
||||
// fixed at 6 → 1536 samples. Byte 4 layout: fscod(2)|fscod2(2)|...
|
||||
// fscod=3 (0b11), fscod2=0 (0b00) → byte4 = 0b1100_0000 = 0xC0.
|
||||
let data = [0x0B, 0x77, 0x00, 0x00, 0xC0, 16 << 3];
|
||||
let bsid = get_bsid(&data);
|
||||
assert!(bsid >= 11, "test frame is E-AC-3");
|
||||
// 1536 samples / 24000 Hz = 64 ms.
|
||||
assert_eq!(frame_duration_ns(&data, bsid), 64_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_frame_size_table() {
|
||||
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
|
||||
|
||||
+87
-21
@@ -15,6 +15,11 @@ const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
|
||||
#[cfg(test)]
|
||||
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||
|
||||
/// DTS / DTS-HD elementary-stream parser. Buffers DTS across PES boundaries so
|
||||
/// a core frame plus all of its trailing DTS-HD extension substreams are
|
||||
/// emitted together as one access unit, delimited by the next valid core sync.
|
||||
/// This preserves the lossless extension data instead of downgrading to lossy
|
||||
/// core (the Dunkirk / Fight Club lossy-core bug).
|
||||
pub struct DtsParser {
|
||||
buf: Vec<u8>,
|
||||
/// PTS of the access unit currently being assembled in `buf` (the unit
|
||||
@@ -92,11 +97,22 @@ impl DtsParser {
|
||||
/// this without a clean boundary we resync rather than stall or balloon.
|
||||
const MAX_AU_BYTES: usize = 65536;
|
||||
|
||||
/// Minimum plausible DTS core frame size. The core header alone is ~10-14
|
||||
/// bytes; a decoded `core_size` below this means we matched a false/corrupt
|
||||
/// core sync (the 14-bit `fsize` field decoded to a tiny value) rather than a
|
||||
/// real frame, so we resync instead of emitting a junk access unit.
|
||||
const MIN_CORE_FRAME_BYTES: usize = 10;
|
||||
/// Number of leading bytes that must be buffered before the core `fsize` field
|
||||
/// (bytes 5-7) can be decoded. This is a HEADER-LAYOUT minimum — "enough bytes
|
||||
/// to read the size field" — and is deliberately distinct from
|
||||
/// `MIN_CORE_FRAME_BYTES` (the decoded-frame-size validity floor). They must not
|
||||
/// be conflated: this one gates buffer reads of the header, the other rejects
|
||||
/// implausible decoded sizes.
|
||||
const CORE_HEADER_MIN_BYTES: usize = 10;
|
||||
|
||||
/// Minimum plausible decoded DTS core frame size, per ETSI TS 102 114: the
|
||||
/// on-wire FSIZE floor is 95, so a real core frame is at least 96 bytes. A
|
||||
/// decoded `core_size` below this means we matched a false/corrupt core sync
|
||||
/// (a lucky 0x7FFE8001 in extension-substream payload whose 14-bit `fsize`
|
||||
/// decoded to a tiny value) rather than a real frame, so we resync instead of
|
||||
/// closing an access unit at a junk boundary and dropping the DTS-HD extension
|
||||
/// tail.
|
||||
const MIN_CORE_FRAME_BYTES: usize = 96;
|
||||
|
||||
/// Sentinel for "no valid PTS base captured yet". Real PTS-in-ns values are
|
||||
/// non-negative (derived from the unsigned 90 kHz PES timestamp), so a negative
|
||||
@@ -156,25 +172,30 @@ impl CodecParser for DtsParser {
|
||||
};
|
||||
if start > 0 {
|
||||
self.drain_front(start);
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) {
|
||||
// Shouldn't happen, but never loop forever.
|
||||
break;
|
||||
}
|
||||
// The sync `find_sync` located at offset `start` is now at
|
||||
// offset 0 by construction, so a re-scan would be a redundant
|
||||
// O(buf_len) walk per iteration; assert the invariant instead.
|
||||
debug_assert_eq!(
|
||||
find_sync(&self.buf, &DTS_CORE_SYNC),
|
||||
Some(0),
|
||||
"drain_front(start) must leave the core sync at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
// Need the core header to size the core frame.
|
||||
if self.buf.len() < 10 {
|
||||
if self.buf.len() < CORE_HEADER_MIN_BYTES {
|
||||
break;
|
||||
}
|
||||
let core_size = dts_core_frame_size(&self.buf);
|
||||
// `dts_core_frame_size` returns a 14-bit `fsize + 1`, so it is
|
||||
// always in [1, 16384]; the bare `== 0` / `> MAX_AU_BYTES` checks
|
||||
// can never fire. A real DTS core header is at least ~10-14 bytes,
|
||||
// so any decoded size below that came from a false/corrupt sync.
|
||||
// Reject it (drain the 4 syncword bytes and resync) instead of
|
||||
// letting a tiny bogus size close the current access unit at a junk
|
||||
// boundary and drop the trailing extension substreams. The
|
||||
// `> MAX_AU_BYTES` upper bound is kept as a harmless guard.
|
||||
// can never fire. A real DTS core frame is at least
|
||||
// MIN_CORE_FRAME_BYTES (96, the ETSI spec floor), so any decoded
|
||||
// size below that came from a false/corrupt sync. Reject it (drain
|
||||
// the 4 syncword bytes and resync) instead of letting a tiny bogus
|
||||
// size close the current access unit at a junk boundary and drop the
|
||||
// trailing extension substreams. The `> MAX_AU_BYTES` upper bound is
|
||||
// kept as a harmless guard.
|
||||
if !(MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&core_size) {
|
||||
// Bogus core sync — skip past it and resync.
|
||||
self.drain_front(4);
|
||||
@@ -257,7 +278,8 @@ impl CodecParser for DtsParser {
|
||||
// core + its extension substreams, which had no following core sync to
|
||||
// close it during streaming). Require a complete core frame; drop a
|
||||
// bare partial sync tail.
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < 10 {
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
|
||||
{
|
||||
self.buf.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -315,7 +337,7 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
|
||||
let pos = from + rel;
|
||||
// Need the candidate's core header to judge it.
|
||||
if buf.len() - pos < 10 {
|
||||
if buf.len() - pos < CORE_HEADER_MIN_BYTES {
|
||||
return NextCore::NeedMore;
|
||||
}
|
||||
let sz = dts_core_frame_size(&buf[pos..]);
|
||||
@@ -328,10 +350,17 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
NextCore::None
|
||||
}
|
||||
|
||||
/// DTS core frame size from header bits.
|
||||
/// fsize is at bits 46-59 (14 bits) of the header: bytes 5-7.
|
||||
/// DTS core frame size from header bits. `fsize` is the 14-bit field at bits
|
||||
/// 46-59 of the header (bytes 5-7). On the wire `fsize` is the frame length
|
||||
/// minus one, so this returns `fsize + 1`, i.e. the core frame length in bytes
|
||||
/// (range 1..=16384). Callers treat the result as the actual byte length and
|
||||
/// the MIN..=MAX range checks assume so.
|
||||
///
|
||||
/// Returns `0` when `data` is shorter than `CORE_HEADER_MIN_BYTES` — every call
|
||||
/// site rejects that via the minimum-frame lower bound, so a `0` is never
|
||||
/// mistaken for a valid tiny frame.
|
||||
fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
if data.len() < 10 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
return 0;
|
||||
}
|
||||
// fsize field: 14 bits starting at bit 46
|
||||
@@ -629,6 +658,43 @@ mod tests {
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_spec_core_size_is_rejected_as_false_sync() {
|
||||
// A core sync whose decoded fsize+1 lands in [CORE_HEADER_MIN_BYTES,
|
||||
// MIN_CORE_FRAME_BYTES) — i.e. a "frame" smaller than the 96-byte ETSI
|
||||
// spec minimum — is a false sync inside extension payload and must NOT
|
||||
// close an access unit. Pick a decoded size of 64 (well inside the old
|
||||
// 10..96 false-positive window the raised floor now rejects).
|
||||
let false_size = 64usize;
|
||||
assert!(
|
||||
(CORE_HEADER_MIN_BYTES..MIN_CORE_FRAME_BYTES).contains(&false_size),
|
||||
"test fixture must sit in the widened reject window"
|
||||
);
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// Frame 1: real core(512) + extension that embeds a sub-spec "core sync"
|
||||
// whose fsize decodes to 64 bytes.
|
||||
let mut ext = make_dts_ext(256);
|
||||
let bogus = make_dts_core(false_size); // valid-looking sync, size 64
|
||||
ext[64..64 + bogus.len()].copy_from_slice(&bogus);
|
||||
let mut frame1 = make_dts_core(512);
|
||||
frame1.extend_from_slice(&ext);
|
||||
|
||||
assert!(
|
||||
parser.parse(&make_pes(frame1, Some(90000))).is_empty(),
|
||||
"sub-spec core size must not close the AU"
|
||||
);
|
||||
|
||||
// Real next core closes frame 1 as core + full extension.
|
||||
let f = parser.parse(&make_pes(make_dts_core(640), Some(93000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].data.len(),
|
||||
512 + 256,
|
||||
"AU must not be split at the sub-spec false sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_emit_does_not_corrupt_next_au_pts() {
|
||||
// When the buffer exceeds MAX_AU_BYTES with no next core sync, the
|
||||
|
||||
+130
-22
@@ -2,10 +2,14 @@
|
||||
//!
|
||||
//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F.
|
||||
//! 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.
|
||||
//! packets: only the first PES carries a PTS; continuation PES packets have no
|
||||
//! PTS field (the PS demuxer leaves `pts` as `None`). 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. The presence of a PTS — not
|
||||
//! merely an open `pending` — is the authoritative SPU-boundary signal, so a
|
||||
//! lost continuation or a corrupt SPU_size can't merge the next subtitle into
|
||||
//! the stuck unit.
|
||||
//!
|
||||
//! For MKV: codec ID "S_VOBSUB".
|
||||
//! All frames are keyframes (each is a complete bitmap).
|
||||
@@ -35,7 +39,7 @@ impl DvdSubParser {
|
||||
/// 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()?;
|
||||
let (_, size, buf) = self.pending.as_ref()?;
|
||||
if force || buf.len() >= *size {
|
||||
let (pts_ns, _, data) = self.pending.take().unwrap();
|
||||
return Some(Frame {
|
||||
@@ -45,7 +49,6 @@ impl DvdSubParser {
|
||||
duration_ns: None,
|
||||
});
|
||||
}
|
||||
let _ = pts_ns;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -58,32 +61,63 @@ impl CodecParser for DvdSubParser {
|
||||
|
||||
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]);
|
||||
// A PES carrying a real PTS is the START of a new SPU; continuations of
|
||||
// an in-progress SPU carry no PTS (the PS demuxer leaves `pts` None when
|
||||
// the PES has no PTS field — see the module doc). PTS is therefore the
|
||||
// authoritative SPU-boundary signal, NOT merely `pending.is_some()`.
|
||||
//
|
||||
// Append-as-continuation ONLY when this PES has no PTS. When it has a
|
||||
// PTS but a stale `pending` is still open (a lost continuation, or a
|
||||
// corrupt/oversized declared SPU_size that real data never reaches),
|
||||
// force-emit the stuck unit truncated and fall through to start a fresh
|
||||
// SPU from this PES. Without this, one bad SPU_size would swallow every
|
||||
// later subtitle until EOF — exactly the damaged-disc case we target.
|
||||
if pes.pts.is_none() {
|
||||
if self.pending.is_some() {
|
||||
// Continuation: 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;
|
||||
}
|
||||
if let Some(frame) = self.take_if_complete(false) {
|
||||
out.push(frame);
|
||||
}
|
||||
return out;
|
||||
// No pending and no PTS: nothing to attach this to. Pass it through
|
||||
// as a lone frame (PTS unknown → 0) rather than drop it.
|
||||
} else if let Some(frame) = self.take_if_complete(true) {
|
||||
// New SPU starting while a previous one is still open → flush stale.
|
||||
out.push(frame);
|
||||
}
|
||||
|
||||
// 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 declared = if pes.data.len() >= 2 {
|
||||
((pes.data[0] as usize) << 8) | pes.data[1] as usize
|
||||
// SPU_size includes the 2-byte header, so a declared size < 2 is
|
||||
// always malformed; treat it like the too-short path (lone frame)
|
||||
// rather than emit an immediate oversized unit.
|
||||
let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize;
|
||||
if d < 2 {
|
||||
out.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
d
|
||||
} else {
|
||||
// Too short to carry SPU_size — pass through as a lone frame.
|
||||
return vec![Frame {
|
||||
out.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}];
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
let mut buf = pes.data.clone();
|
||||
@@ -114,6 +148,18 @@ impl CodecParser for DvdSubParser {
|
||||
///
|
||||
/// Input: `[padding, Y, Cb, Cr]` (as stored in DVD IFO PGC data).
|
||||
/// Returns `[R, G, B]`.
|
||||
///
|
||||
/// Range convention (deliberate): this uses the **full-range (JFIF) BT.601**
|
||||
/// coefficients with no 16/235 luma scaling. DVD IFO palette YCbCr is nominally
|
||||
/// studio-swing BT.601, so studio-swing math would be more colorimetrically
|
||||
/// "correct" in isolation. But the output here is a VobSub `.idx` `palette:`
|
||||
/// line, and the entire VobSub ecosystem (the original tooling, mkvtoolnix,
|
||||
/// players that read the .idx palette) is built around this full-range formula —
|
||||
/// it is the de-facto on-disk convention. Emitting studio-swing-scaled RGB here
|
||||
/// would make freemkv's palettes inconsistent with every other tool and wrong in
|
||||
/// players that assume the VobSub convention. We therefore intentionally keep
|
||||
/// full-range; do NOT "fix" this to studio-swing without changing the consuming
|
||||
/// side in lockstep.
|
||||
pub fn ycbcr_to_rgb(color: &[u8; 4]) -> [u8; 3] {
|
||||
let y = color[1] as f64;
|
||||
let cb = color[2] as f64;
|
||||
@@ -240,9 +286,10 @@ mod tests {
|
||||
|
||||
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)));
|
||||
// Continuations carry NO PTS (None), per the PS demuxer.
|
||||
let f = parser.parse(&make_pes(cont1.clone(), None));
|
||||
assert!(f.is_empty(), "still incomplete");
|
||||
let frames = parser.parse(&make_pes(cont2.clone(), Some(0)));
|
||||
let frames = parser.parse(&make_pes(cont2.clone(), None));
|
||||
assert_eq!(frames.len(), 1, "completed SPU emits exactly one frame");
|
||||
|
||||
// Reassembled bytes = head + cont1 + cont2, in order.
|
||||
@@ -268,6 +315,67 @@ mod tests {
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_pts_pes_force_emits_stale_pending_and_starts_new_spu() {
|
||||
// A lost continuation leaves an incomplete pending SPU. The NEXT real
|
||||
// subtitle arrives with its own PTS — it must force-emit the stuck unit
|
||||
// (truncated) and begin a fresh SPU, not be appended as a continuation.
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
|
||||
// SPU 1 declares 100 bytes but only 6 arrive; the continuation is lost.
|
||||
let head1 = vec![0x00, 0x64, 0xDE, 0xAD, 0xBE, 0xEF];
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(head1.clone(), Some(90000)))
|
||||
.is_empty(),
|
||||
"SPU 1 incomplete, held pending"
|
||||
);
|
||||
|
||||
// SPU 2 arrives with a real PTS — declares 4 bytes, fully present.
|
||||
let head2 = vec![0x00, 0x04, 0x11, 0x22];
|
||||
let frames = parser.parse(&make_pes(head2.clone(), Some(180000)));
|
||||
// First the truncated stale SPU 1, then complete SPU 2.
|
||||
assert_eq!(frames.len(), 2, "stale flushed + new emitted");
|
||||
assert_eq!(frames[0].data, head1, "stale SPU 1 emitted truncated");
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000, "SPU 1 keeps its PTS");
|
||||
assert_eq!(frames[1].data, head2, "SPU 2 emitted fresh");
|
||||
assert_eq!(frames[1].pts_ns, 2_000_000_000, "SPU 2 keeps its own PTS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_oversized_size_recovers_on_next_real_pts() {
|
||||
// A corrupt SPU_size that real data never reaches must not swallow every
|
||||
// later subtitle. The next real-PTS PES resets pending and recovers the
|
||||
// track.
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
|
||||
// Declares 0xFFFF but only a few bytes ever arrive (corrupt size).
|
||||
let bad = vec![0xFF, 0xFF, 0x01, 0x02, 0x03];
|
||||
assert!(parser.parse(&make_pes(bad.clone(), Some(90000))).is_empty());
|
||||
// A no-PTS stray continuation appends (still stuck under the bad size).
|
||||
assert!(parser.parse(&make_pes(vec![0x04, 0x05], None)).is_empty());
|
||||
|
||||
// Next real subtitle (PTS present) recovers: stale flushed + new SPU.
|
||||
let good = vec![0x00, 0x04, 0xAA, 0xBB];
|
||||
let frames = parser.parse(&make_pes(good.clone(), Some(270000)));
|
||||
assert_eq!(frames.len(), 2, "track recovers, not swallowed to EOF");
|
||||
assert_eq!(frames[1].data, good);
|
||||
assert_eq!(frames[1].pts_ns, 3_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_size_below_two_passes_through_as_lone_frame() {
|
||||
// SPU_size includes its own 2-byte header, so a declared size < 2 is
|
||||
// malformed. It must pass through as a lone frame, not emit an oversized
|
||||
// unit or get stuck pending.
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let data = vec![0x00, 0x00, 0xAB, 0xCD]; // declared = 0
|
||||
let frames = parser.parse(&make_pes(data.clone(), Some(90000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data, "passed through whole");
|
||||
assert!(parser.pending.is_none(), "no pending left open");
|
||||
}
|
||||
|
||||
// ── YCbCr → RGB conversion tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
+79
-83
@@ -4,6 +4,7 @@
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::startcode::{find_start_code, skip_start_code};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
@@ -12,6 +13,9 @@ const NAL_SPS: u8 = 7;
|
||||
const NAL_PPS: u8 = 8;
|
||||
const NAL_AUD: u8 = 9;
|
||||
|
||||
/// H.264 (AVC) Annex B → MKV codec parser: extracts SPS/PPS for the avcC
|
||||
/// codecPrivate, detects IDR keyframes, and converts each PES access unit into
|
||||
/// length-prefixed NAL units. Implements [`CodecParser`].
|
||||
pub struct H264Parser {
|
||||
// First-seen SPS/PPS seed the MKV codecPrivate (avcC) — the only out-of-band
|
||||
// copy the player gets. BD H.264 repeats the parameter sets at every IDR;
|
||||
@@ -32,6 +36,7 @@ impl Default for H264Parser {
|
||||
}
|
||||
|
||||
impl H264Parser {
|
||||
/// Create a fresh H.264 parser with no parameter sets captured yet.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sps: None,
|
||||
@@ -56,7 +61,14 @@ fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Ve
|
||||
Some(f) if f.as_slice() == nal => {} // == codecPrivate → player has it
|
||||
Some(_) => {
|
||||
// Differs from codecPrivate → emit in-band so it wins at this AU.
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
// A NAL longer than u32::MAX cannot be length-prefixed in the
|
||||
// 4-byte field; skip it rather than emit a truncated length over
|
||||
// the full body (mis-framed NALU). Unreachable in practice — no
|
||||
// real access unit is >4 GiB.
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +90,10 @@ impl CodecParser for H264Parser {
|
||||
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
|
||||
// AVCDecoderConfigurationRecord expects a 4-byte length prefix per NAL).
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
// Pre-size: output is ~input bytes plus a few 4-byte NAL length prefixes.
|
||||
// The unsized Vec growth chain otherwise reallocs several times per
|
||||
// frame in the mux hot path (mirrors the HEVC parser).
|
||||
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
@@ -88,13 +103,21 @@ impl CodecParser for H264Parser {
|
||||
// mid-title redefinition differs from the avcC copy.
|
||||
NAL_SPS => handle_param_set(&mut self.sps, nal, &mut frame_data),
|
||||
NAL_PPS => handle_param_set(&mut self.pps, nal, &mut frame_data),
|
||||
// Access unit delimiters: drop.
|
||||
// Access unit delimiters: drop. Intentional and spec-correct —
|
||||
// Matroska H.264 frame data omits AUDs (the container delimits
|
||||
// access units), so keeping them in-band is redundant. Mirrors
|
||||
// the HEVC parser.
|
||||
NAL_AUD => {}
|
||||
_ => {
|
||||
if nal_type == NAL_SLICE_IDR {
|
||||
keyframe = true;
|
||||
}
|
||||
let len = nal.len() as u32;
|
||||
// A NAL longer than u32::MAX can't be length-prefixed in the
|
||||
// 4-byte field; skip it rather than mis-frame the output.
|
||||
// Unreachable in practice (no real AU is >4 GiB).
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
continue;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
@@ -182,61 +205,42 @@ impl<'a> Iterator for NalIterator<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<&'a [u8]> {
|
||||
if self.pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
// Loop (not tail-recursion) over empty NALs: a crafted/garbled Annex B
|
||||
// stream with many adjacent start codes (e.g. 00 00 01 00 00 01 ...)
|
||||
// yields empty NALs back-to-back; recursing once per empty NAL would
|
||||
// overflow the stack. `self.pos` advances to `nal_end` each iteration,
|
||||
// so the loop always terminates. Mirrors the HEVC parser's while-scan.
|
||||
loop {
|
||||
if self.pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip the start code at current position
|
||||
let nal_start = skip_start_code(self.data, self.pos)?;
|
||||
// Skip the start code at current position
|
||||
let nal_start = skip_start_code(self.data, self.pos)?;
|
||||
|
||||
// Find next start code (or end of data)
|
||||
let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len());
|
||||
// Find next start code (or end of data)
|
||||
let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len());
|
||||
|
||||
// Remove trailing zeros (part of next start code's zero prefix)
|
||||
let mut end = nal_end;
|
||||
while end > nal_start && self.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
// Strip the leading zeros of the following start code. For a
|
||||
// conforming bitstream this is lossless: rbsp_trailing_bits() sets a
|
||||
// stop-one bit, so the final byte of any RBSP is never 0x00 — the only
|
||||
// trailing zeros here belong to the next 00 00 (00) 01 prefix, never to
|
||||
// the NAL's RBSP payload. (Mirrors the HEVC parser.)
|
||||
let mut end = nal_end;
|
||||
while end > nal_start && self.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
self.pos = nal_end;
|
||||
self.pos = nal_end;
|
||||
|
||||
if end > nal_start {
|
||||
Some(&self.data[nal_start..end])
|
||||
} else {
|
||||
self.next()
|
||||
if end > nal_start {
|
||||
return Some(&self.data[nal_start..end]);
|
||||
}
|
||||
// Empty NAL — continue scanning instead of recursing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
///
|
||||
/// Backed by `memchr::memmem::find` for SIMD-accelerated bytestring
|
||||
/// search. On AVX2-capable x86_64 this runs ~5–10× the byte-by-byte
|
||||
/// scan that preceded it; on a 200 KB UHD HEVC frame the saving is
|
||||
/// in the hundreds of microseconds per call.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
memchr::memmem::find(&data[from..], b"\x00\x00\x01").map(|rel| from + rel)
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after it.
|
||||
pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
if pos + 2 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if data[pos] == 0x00 && data[pos + 1] == 0x00 {
|
||||
if pos + 3 < data.len() && data[pos + 2] == 0x00 && data[pos + 3] == 0x01 {
|
||||
return Some(pos + 4); // 4-byte start code
|
||||
}
|
||||
if data[pos + 2] == 0x01 {
|
||||
return Some(pos + 3); // 3-byte start code
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,39 +255,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_start_code tests ---
|
||||
|
||||
#[test]
|
||||
fn find_start_code_3byte() {
|
||||
let data = [0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(find_start_code(&data, 0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_4byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
|
||||
// find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code
|
||||
assert_eq!(find_start_code(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_offset() {
|
||||
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
|
||||
assert_eq!(find_start_code(&data, 0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_none() {
|
||||
let data = [0x00, 0x00, 0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_too_short() {
|
||||
let data = [0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
// --- parse SPS+PPS → codec_private ---
|
||||
|
||||
#[test]
|
||||
@@ -590,6 +561,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_empty_nals_do_not_overflow_stack() {
|
||||
// Regression: NalIterator::next must iterate, not recurse, over empty
|
||||
// NALs. A crafted Annex B stream of tens of thousands of adjacent start
|
||||
// codes (each producing an empty NAL) would blow the stack under the old
|
||||
// tail-recursive implementation. Iterating handles it in bounded stack.
|
||||
let mut data = Vec::new();
|
||||
// 50_000 back-to-back 3-byte start codes → 50_000 empty NALs.
|
||||
for _ in 0..50_000 {
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
}
|
||||
// One real NAL at the end so the iterator yields something.
|
||||
data.extend_from_slice(&[0x41, 0xAA, 0xBB]);
|
||||
|
||||
let mut parser = H264Parser::new();
|
||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||
// Exactly one populated frame; the empty NALs are skipped without
|
||||
// overflowing.
|
||||
assert_eq!(frames.len(), 1);
|
||||
let fd = &frames[0].data;
|
||||
let len = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]) as usize;
|
||||
assert_eq!(len, 3, "the single real NAL is length-prefixed");
|
||||
assert_eq!(fd[4], 0x41);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_oversized_param_set_returns_none() {
|
||||
// A param set > 65535 bytes can't be length-encoded in avcC's 16-bit
|
||||
|
||||
+103
-24
@@ -4,7 +4,7 @@
|
||||
//! Detects keyframes (IRAP pictures: IDR, CRA, BLA).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
use super::startcode::{find_start_code, skip_start_code};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
// HEVC NAL unit types
|
||||
@@ -20,6 +20,9 @@ const _NAL_UNSPEC62_DV_RPU: u8 = 62;
|
||||
const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
|
||||
/// HEVC (H.265) Annex B → MKV codec parser: extracts VPS/SPS/PPS for the hvcC
|
||||
/// codecPrivate, detects IRAP keyframes, and converts each PES access unit into
|
||||
/// length-prefixed NAL units. Implements [`CodecParser`].
|
||||
pub struct HevcParser {
|
||||
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
||||
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
||||
@@ -42,6 +45,7 @@ impl Default for HevcParser {
|
||||
}
|
||||
|
||||
impl HevcParser {
|
||||
/// Create a fresh HEVC parser with no parameter sets captured yet.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
vps: None,
|
||||
@@ -71,12 +75,30 @@ fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Ve
|
||||
Some(f) if f.as_slice() == nal => {} // == codecPrivate → player has it
|
||||
Some(_) => {
|
||||
// Differs from codecPrivate → emit in-band so it wins at this AU.
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
// A NAL longer than u32::MAX can't be length-prefixed in the 4-byte
|
||||
// field; skip it rather than mis-frame the output. Unreachable in
|
||||
// practice (no real access unit is >4 GiB).
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `nal` to `out` as a 4-byte big-endian length prefix followed by the
|
||||
/// NAL body. A NAL longer than `u32::MAX` can't be length-prefixed in the
|
||||
/// 4-byte field, so it is skipped rather than mis-framed. Unreachable in
|
||||
/// practice (no real access unit is >4 GiB).
|
||||
fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) {
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
impl CodecParser for HevcParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -112,7 +134,12 @@ impl CodecParser for HevcParser {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
if nal_start < data.len() {
|
||||
// Skip empty NALs entirely. When the trailing-zero strip reduces
|
||||
// `end` back to `nal_start` (e.g. `00 00 01 00 00 01`, or a
|
||||
// zero-filled bad sector between two start codes), the slice is
|
||||
// empty; emitting a 4-byte 0x00000000 length prefix with no NAL
|
||||
// body produces a structurally invalid NALU a decoder rejects.
|
||||
if nal_start < data.len() && end > nal_start {
|
||||
// HEVC NAL header: 2 bytes. Type is bits 1-6 of first byte.
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
@@ -126,18 +153,18 @@ impl CodecParser for HevcParser {
|
||||
NAL_PPS => {
|
||||
handle_param_set(&mut self.pps, &data[nal_start..end], &mut frame_data)
|
||||
}
|
||||
NAL_AUD => {} // Skip access unit delimiters
|
||||
// Drop Access Unit Delimiters. This is intentional and
|
||||
// spec-correct: Matroska HEVC frame data omits AUDs
|
||||
// (the container delimits access units), so carrying
|
||||
// them in-band is redundant. H.264 does the same below.
|
||||
NAL_AUD => {}
|
||||
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
|
||||
keyframe = true;
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||
}
|
||||
_ => {
|
||||
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
|
||||
let nal = &data[nal_start..end];
|
||||
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +200,9 @@ impl CodecParser for HevcParser {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Simplified: store as arrays in Annex B format
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
// Build a conforming HEVCDecoderConfigurationRecord: fixed header
|
||||
// (configurationVersion, profile_tier_level fields, parallelism, parsed
|
||||
// chroma/bit depths) followed by numOfArrays length-prefixed NAL arrays.
|
||||
let mut record = Vec::new();
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header.
|
||||
@@ -198,17 +226,17 @@ impl CodecParser for HevcParser {
|
||||
if sps.len() > 7 {
|
||||
record.extend_from_slice(&sps[4..8]);
|
||||
} else {
|
||||
let avail = sps.len().saturating_sub(4).min(4);
|
||||
let target = record.len() + 4;
|
||||
record.extend_from_slice(&sps[sps.len().min(4)..sps.len().min(8)]);
|
||||
record.extend_from_slice(&vec![0u8; 4 - avail]);
|
||||
record.resize(target, 0u8); // zero-pad the missing bytes in place
|
||||
}
|
||||
// general_constraint_indicator_flags (6 bytes) — SPS bytes 8..14
|
||||
if sps.len() > 13 {
|
||||
record.extend_from_slice(&sps[8..14]);
|
||||
} else {
|
||||
let avail = sps.len().saturating_sub(8).min(6);
|
||||
let target = record.len() + 6;
|
||||
record.extend_from_slice(&sps[sps.len().min(8)..sps.len().min(14)]);
|
||||
record.extend_from_slice(&vec![0u8; 6 - avail]);
|
||||
record.resize(target, 0u8); // zero-pad the missing bytes in place
|
||||
}
|
||||
// general_level_idc — SPS byte 14
|
||||
record.push(if sps.len() > 14 { sps[14] } else { 0 });
|
||||
@@ -224,6 +252,8 @@ impl CodecParser for HevcParser {
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma_minus8: 0,
|
||||
bit_depth_chroma_minus8: 0,
|
||||
max_sub_layers_minus1: 0,
|
||||
temporal_id_nesting_flag: 0,
|
||||
});
|
||||
// chromaFormat (6 reserved bits set + 2-bit chroma_format_idc)
|
||||
record.push(0xFC | (chroma.chroma_format_idc & 0x03));
|
||||
@@ -233,8 +263,14 @@ impl CodecParser for HevcParser {
|
||||
record.push(0xF8 | (chroma.bit_depth_chroma_minus8 & 0x07));
|
||||
// avgFrameRate
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// Byte 21 packs four fields (ISO/IEC 14496-15):
|
||||
// constantFrameRate u(2) = 0 (unknown / not constant)
|
||||
// numTemporalLayers u(3) = sps_max_sub_layers_minus1 + 1
|
||||
// temporalIdNested u(1) = sps_temporal_id_nesting_flag
|
||||
// lengthSizeMinusOne u(2) = 3 (4-byte length prefix)
|
||||
let num_temporal_layers = (chroma.max_sub_layers_minus1 + 1) & 0x07;
|
||||
let temporal_id_nested = chroma.temporal_id_nesting_flag & 0x01;
|
||||
record.push((num_temporal_layers << 3) | (temporal_id_nested << 2) | 0x03);
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
@@ -271,6 +307,10 @@ struct SpsChroma {
|
||||
chroma_format_idc: u8,
|
||||
bit_depth_luma_minus8: u8,
|
||||
bit_depth_chroma_minus8: u8,
|
||||
/// sps_max_sub_layers_minus1 (u3): numTemporalLayers = this + 1 for hvcC.
|
||||
max_sub_layers_minus1: u8,
|
||||
/// sps_temporal_id_nesting_flag (u1) for hvcC temporalIdNested.
|
||||
temporal_id_nesting_flag: u8,
|
||||
}
|
||||
|
||||
/// Minimal MSB-first bit reader over a byte slice.
|
||||
@@ -365,7 +405,7 @@ fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> {
|
||||
// sps_max_sub_layers_minus1 u(3)
|
||||
let max_sub_layers_minus1 = r.read_bits(3)?;
|
||||
// sps_temporal_id_nesting_flag u(1)
|
||||
r.skip_bits(1)?;
|
||||
let temporal_id_nesting_flag = r.read_bit()?;
|
||||
|
||||
// profile_tier_level( 1, sps_max_sub_layers_minus1 )
|
||||
parse_profile_tier_level(&mut r, max_sub_layers_minus1)?;
|
||||
@@ -396,17 +436,18 @@ fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> {
|
||||
chroma_format_idc,
|
||||
bit_depth_luma_minus8,
|
||||
bit_depth_chroma_minus8,
|
||||
max_sub_layers_minus1: max_sub_layers_minus1 as u8,
|
||||
temporal_id_nesting_flag: temporal_id_nesting_flag as u8,
|
||||
})
|
||||
}
|
||||
|
||||
/// Consume a profile_tier_level(profilePresentFlag=1, maxNumSubLayersMinus1)
|
||||
/// structure from the bit reader (HEVC 7.3.3).
|
||||
fn parse_profile_tier_level(r: &mut BitReader, max_sub_layers_minus1: u32) -> Option<()> {
|
||||
// general: profile_space u(2) + tier u(1) + profile_idc u(5) = 8 bits,
|
||||
// profile_compatibility_flags u(32), 4× constraint/flags + reserved = 44
|
||||
// bits, general_inbld/reserved = 1 bit (total constraint area 48 bits),
|
||||
// general_level_idc u(8). 88 bits = 11 bytes... but the spec packs the
|
||||
// general PTL as 8 + 32 + 48 + 8 = 96 bits = 12 bytes. Skip 96 bits.
|
||||
// general PTL fixed layout (HEVC 7.3.3): profile_space u(2) + tier u(1) +
|
||||
// profile_idc u(5) = 8, general_profile_compatibility_flags u(32),
|
||||
// constraint-flags/reserved area = 48, general_level_idc u(8).
|
||||
// Total = 8 + 32 + 48 + 8 = 96 bits = 12 bytes. Skip 96 bits.
|
||||
r.skip_bits(96)?;
|
||||
|
||||
if max_sub_layers_minus1 > 0 {
|
||||
@@ -859,6 +900,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- empty NAL between adjacent start codes is skipped ---
|
||||
|
||||
#[test]
|
||||
fn empty_nal_between_start_codes_emits_no_bare_prefix() {
|
||||
// `00 00 01 00 00 01 <real NAL>`: the first start code is immediately
|
||||
// followed by another, so the in-between NAL is empty after the
|
||||
// trailing-zero strip. It must be skipped, NOT written as a bare
|
||||
// 0x00000000 length prefix (which a decoder treats as malformed).
|
||||
let mut parser = HevcParser::new();
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]); // start code, empty NAL
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]); // next start code
|
||||
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
let fd = &frames[0].data;
|
||||
// Exactly one length-prefixed NAL — no zero-length entry.
|
||||
let len = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]) as usize;
|
||||
assert!(len > 0, "no bare zero-length prefix emitted");
|
||||
assert_eq!(len + 4, fd.len(), "exactly one NAL in frame data");
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
@@ -1129,6 +1194,20 @@ mod tests {
|
||||
assert_eq!(cp[18], 0xF8 | 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_byte21_from_sps_temporal_layers() {
|
||||
// make_sps_with_chroma sets sps_max_sub_layers_minus1 = 0 and
|
||||
// sps_temporal_id_nesting_flag = 1, so byte 21 must encode
|
||||
// numTemporalLayers = 1, temporalIdNested = 1, lengthSizeMinusOne = 3:
|
||||
// (1 << 3) | (1 << 2) | 3 = 0x0F.
|
||||
let sps = make_sps_with_chroma(1, 2, 2);
|
||||
let cp = codec_private_from_sps(&sps);
|
||||
assert_eq!(
|
||||
cp[21], 0x0F,
|
||||
"byte 21: numTemporalLayers=1, temporalIdNested=1, lengthSizeMinusOne=3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_handles_emulation_prevention_in_sps() {
|
||||
// Insert an emulation-prevention byte (00 00 03) into the SPS RBSP and
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
//! DVD = leave intact. The raw PCM data is otherwise one complete audio frame
|
||||
//! per PES; no framing is needed.
|
||||
//!
|
||||
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
|
||||
//! For MKV: both BD and DVD LPCM map to codec ID "A_PCM/INT/BIG" (big-endian).
|
||||
//! DVD-Video LPCM is big-endian per the DVD-Video spec, and `mkv.rs` emits
|
||||
//! "A_PCM/INT/BIG" unconditionally for `Codec::Lpcm` — there is no DVD/BD branch
|
||||
//! and no "A_PCM/INT/LIT" path, so no byte-swap or alternate codec ID applies.
|
||||
//! All frames are keyframes (uncompressed audio).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
@@ -131,8 +134,9 @@ mod tests {
|
||||
fn dvd_lpcm_preserves_all_pcm_bytes() {
|
||||
// DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header,
|
||||
// so the payload handed to this parser is raw PCM. The DVD parser must
|
||||
// NOT strip any further bytes (the round-2 audit Finding 3 bug: the BD
|
||||
// 4-byte strip dropped one sample pair per PES, drifting the audio).
|
||||
// NOT strip any further bytes — applying the BD 4-byte strip to a DVD
|
||||
// payload would drop one sample pair per PES and progressively drift
|
||||
// the audio.
|
||||
let mut parser = LpcmParser::new_dvd();
|
||||
let pcm = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02];
|
||||
let frames = parser.parse(&make_pes(pcm.clone(), Some(90000)));
|
||||
|
||||
+85
-2
@@ -7,15 +7,27 @@
|
||||
//! - Determine keyframe status
|
||||
//! - Convert PTS from 90kHz to nanoseconds
|
||||
|
||||
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
|
||||
pub mod ac3;
|
||||
/// DTS / DTS-HD elementary-stream parser.
|
||||
pub mod dts;
|
||||
/// DVD bitmap subtitle (VobSub) parser.
|
||||
pub mod dvdsub;
|
||||
/// H.264 (AVC) Annex-B elementary-stream parser.
|
||||
pub mod h264;
|
||||
/// HEVC (H.265) Annex-B elementary-stream parser.
|
||||
pub mod hevc;
|
||||
/// BD/DVD LPCM (Linear PCM) audio parser.
|
||||
pub mod lpcm;
|
||||
/// MPEG-2 Video elementary-stream parser.
|
||||
pub mod mpeg2;
|
||||
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
pub mod pgs;
|
||||
/// Shared MPEG/Annex-B start-code scanning helpers.
|
||||
pub(crate) mod startcode;
|
||||
/// Dolby TrueHD / Atmos elementary-stream parser.
|
||||
pub mod truehd;
|
||||
/// VC-1 (SMPTE 421M) elementary-stream parser.
|
||||
pub mod vc1;
|
||||
|
||||
use super::ts::PesPacket;
|
||||
@@ -70,12 +82,20 @@ pub trait CodecParser: Send {
|
||||
}
|
||||
|
||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||
/// Used for codecs where PES = frame (AC3, DTS, PGS).
|
||||
///
|
||||
/// Used for the audio codecs that have no dedicated parser and whose PES
|
||||
/// boundaries already line up with frame boundaries (Aac, Mp2, Mp3, Flac,
|
||||
/// Opus). AC3/DTS/TrueHD have their own parsers; PGS/DvdSub have their own
|
||||
/// subtitle parsers. Video codecs must NOT use the all-keyframe form of this
|
||||
/// parser — see `parser_for_codec`.
|
||||
pub struct PassthroughParser {
|
||||
keyframe: bool,
|
||||
}
|
||||
|
||||
impl PassthroughParser {
|
||||
/// Create a passthrough parser. Pass `true` for codecs where every PES is
|
||||
/// independently decodable (audio / subtitle keyframes), `false` for the
|
||||
/// video fallback where no frame-boundary or keyframe detection occurs.
|
||||
pub fn new(always_keyframe: bool) -> Self {
|
||||
Self {
|
||||
keyframe: always_keyframe,
|
||||
@@ -124,6 +144,69 @@ pub fn parser_for_codec(
|
||||
Codec::Lpcm if is_dvd_ps => Box::new(lpcm::LpcmParser::new_dvd()),
|
||||
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
|
||||
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
// Video codecs with no dedicated parser. There is no frame-boundary
|
||||
// detection here, so a PES carrying multiple access units is emitted as
|
||||
// one oversized block — but marking every frame a keyframe (as the
|
||||
// audio passthrough does) would explode Cues density and mislead
|
||||
// seeking. Use the non-keyframe passthrough and warn that framing is
|
||||
// approximate. Mpeg1/Av1 are real Codec variants without a parser yet.
|
||||
Codec::Mpeg1 | Codec::Av1 => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"no dedicated parser for video codec {:?}; using non-keyframe passthrough (frame boundaries/keyframes not detected)",
|
||||
codec
|
||||
);
|
||||
Box::new(PassthroughParser::new(false))
|
||||
}
|
||||
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
|
||||
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
|
||||
// here; keyframe flag is irrelevant for them.
|
||||
Codec::Aac | Codec::Mp2 | Codec::Mp3 | Codec::Flac | Codec::Opus => {
|
||||
Box::new(PassthroughParser::new(true))
|
||||
}
|
||||
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pes(pts: Option<i64>, data: Vec<u8>) -> PesPacket {
|
||||
PesPacket {
|
||||
pid: 0x1011,
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_video_codecs_use_non_keyframe_passthrough() {
|
||||
// Mpeg1/Av1 have no dedicated parser. They must NOT be marked
|
||||
// all-keyframe (that would explode Cues density and mislead seeking);
|
||||
// the non-keyframe passthrough is the safe fallback.
|
||||
for codec in [Codec::Mpeg1, Codec::Av1] {
|
||||
let mut parser = parser_for_codec(codec, None, false);
|
||||
let frames = parser.parse(&pes(Some(9000), vec![0xDE, 0xAD, 0xBE, 0xEF]));
|
||||
assert_eq!(frames.len(), 1, "{codec:?}");
|
||||
assert!(
|
||||
!frames[0].keyframe,
|
||||
"{codec:?} must not be flagged keyframe by the fallback parser"
|
||||
);
|
||||
assert_eq!(frames[0].data, vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_audio_codecs_use_keyframe_passthrough() {
|
||||
// PES = frame audio codecs: every frame is independently decodable, so
|
||||
// all-keyframe passthrough is correct.
|
||||
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
||||
let mut parser = parser_for_codec(codec, None, false);
|
||||
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
||||
assert_eq!(frames.len(), 1, "{codec:?}");
|
||||
assert!(frames[0].keyframe, "{codec:?} should be keyframe");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-28
@@ -9,6 +9,7 @@
|
||||
//! - Sequence extension: 00 00 01 B5
|
||||
//! - Picture header: 00 00 01 00
|
||||
|
||||
use super::startcode::find_start_code;
|
||||
use super::{CodecParser, Frame, pts_to_ns};
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
@@ -61,6 +62,7 @@ impl Default for Mpeg2Parser {
|
||||
}
|
||||
|
||||
impl Mpeg2Parser {
|
||||
/// Create a new MPEG-2 parser with no captured sequence-header state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seq_header: None,
|
||||
@@ -102,8 +104,13 @@ impl CodecParser for Mpeg2Parser {
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
// Keyframe-ness is a property of the coded PICTURE, not of a sequence
|
||||
// header. A PES may carry a sequence header followed by a P/B-frame
|
||||
// (open-GOP / re-encoded MPEG-2); the picture, not the seq header,
|
||||
// decides the cue point. Set this only from the PICTURE_CODE arm.
|
||||
let mut picture_is_keyframe = false;
|
||||
let mut has_picture = false;
|
||||
let mut saw_seq_header = false;
|
||||
|
||||
// Scan for start codes in the elementary stream data.
|
||||
let mut pos = 0;
|
||||
@@ -165,7 +172,19 @@ impl CodecParser for Mpeg2Parser {
|
||||
};
|
||||
|
||||
self.seq_header = Some(data[hdr_start..hdr_end].to_vec());
|
||||
keyframe = true;
|
||||
// A NEW sequence header replaces the stored one, so its B5
|
||||
// sequence extension must be re-captured. Reset the flag the
|
||||
// SEQ_EXT_CODE arm guards on; otherwise, once the first
|
||||
// header's B3+B5 pair was seen, every later header (channel
|
||||
// change, title boundary, parser reuse) would be stored
|
||||
// without its extension bytes — corrupting codecPrivate
|
||||
// (interlace, chroma format, progressive-sequence flags).
|
||||
self.has_extension = false;
|
||||
// NOTE: a sequence header does NOT make the access unit a
|
||||
// keyframe — that is decided solely by the PICTURE_CODE arm
|
||||
// (picture_is_keyframe). Setting it here would mis-cue a
|
||||
// seq-header-followed-by-P/B-frame PES.
|
||||
saw_seq_header = true;
|
||||
pos = if next_sc.is_some() { hdr_end } else { sc + 4 };
|
||||
}
|
||||
SEQ_EXT_CODE if self.seq_header.is_some() && !self.has_extension => {
|
||||
@@ -185,7 +204,7 @@ impl CodecParser for Mpeg2Parser {
|
||||
if sc + 5 < data.len() {
|
||||
let picture_coding_type = (data[sc + 5] >> 3) & 0x07;
|
||||
if picture_coding_type == PICTURE_TYPE_I {
|
||||
keyframe = true;
|
||||
picture_is_keyframe = true;
|
||||
}
|
||||
}
|
||||
pos = sc + 4;
|
||||
@@ -209,13 +228,15 @@ impl CodecParser for Mpeg2Parser {
|
||||
// header and no picture. A PES with neither (e.g. a slice
|
||||
// continuation) still passes through unchanged, preserving real
|
||||
// keyframe detection.
|
||||
if !has_picture && contains_seq_header(data) {
|
||||
// `saw_seq_header` is set by the scan loop's SEQ_HEADER_CODE arm above,
|
||||
// so this reuses that single pass instead of re-scanning the PES bytes.
|
||||
if !has_picture && saw_seq_header {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
keyframe: picture_is_keyframe,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
@@ -264,29 +285,6 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> {
|
||||
Some(ASPECT_RATIOS[ar_code])
|
||||
}
|
||||
|
||||
/// Returns true if `data` contains a sequence-header start code (00 00 01 B3).
|
||||
fn contains_seq_header(data: &[u8]) -> bool {
|
||||
let mut pos = 0;
|
||||
while let Some(sc) = find_start_code(data, pos) {
|
||||
if sc + 3 >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[sc + 3] == SEQ_HEADER_CODE {
|
||||
return true;
|
||||
}
|
||||
pos = sc + 4;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -487,6 +485,33 @@ mod tests {
|
||||
assert!(parser.codec_private().is_some());
|
||||
}
|
||||
|
||||
// --- seq-header keyframe flag must not leak into a P/B-frame ---
|
||||
|
||||
#[test]
|
||||
fn seq_header_then_p_frame_is_not_keyframe() {
|
||||
// A PES carrying a sequence header followed by a P-frame (open-GOP /
|
||||
// re-encoded MPEG-2) must NOT be flagged a keyframe — the keyframe-ness
|
||||
// belongs to the coded picture, not the sequence header. A spurious
|
||||
// keyframe here produces a bad MKV cue point.
|
||||
let mut parser = Mpeg2Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&make_seq_header(720, 480, 3, 4));
|
||||
data.extend_from_slice(&make_picture_header(2)); // P-frame
|
||||
data.extend_from_slice(&[0xFF; 16]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(
|
||||
!frames[0].keyframe,
|
||||
"seq-header + P-frame must not be a keyframe"
|
||||
);
|
||||
// The sequence header is still captured for codecPrivate.
|
||||
assert!(parser.codec_private().is_some());
|
||||
}
|
||||
|
||||
// --- parameter-set-only PES (seq header, no picture) emits no frame ---
|
||||
|
||||
#[test]
|
||||
@@ -522,6 +547,49 @@ mod tests {
|
||||
assert!(frames2[0].keyframe);
|
||||
}
|
||||
|
||||
// --- a SECOND sequence header re-captures its extension ---
|
||||
|
||||
#[test]
|
||||
fn new_sequence_header_recaptures_extension() {
|
||||
// Regression: has_extension was never reset when a new sequence header
|
||||
// replaced the stored one, so a second header (channel change / title
|
||||
// boundary) was stored WITHOUT its B5 sequence extension. To exercise
|
||||
// the SEQ_EXT_CODE arm (which the has_extension flag guards), each
|
||||
// header and its extension arrive in SEPARATE PES packets.
|
||||
let mut parser = Mpeg2Parser::new();
|
||||
|
||||
// Header A (no trailing start code → captured alone), then its B5
|
||||
// extension in the next PES.
|
||||
let _ = parser.parse(&make_pes(make_seq_header(1920, 1080, 3, 4), Some(0)));
|
||||
let mut ext_a = vec![0x00, 0x00, 0x01, SEQ_EXT_CODE];
|
||||
ext_a.extend_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55, 0x66]);
|
||||
let _ = parser.parse(&make_pes(ext_a, Some(0)));
|
||||
assert!(
|
||||
parser
|
||||
.codec_private()
|
||||
.unwrap()
|
||||
.windows(6)
|
||||
.any(|w| w == [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]),
|
||||
"first header's extension captured (has_extension now true)"
|
||||
);
|
||||
|
||||
// A NEW header B, then ITS extension in a separate PES. With the bug,
|
||||
// has_extension stayed true and this extension would be dropped.
|
||||
let _ = parser.parse(&make_pes(make_seq_header(720, 480, 2, 4), Some(3600)));
|
||||
let mut ext_b = vec![0x00, 0x00, 0x01, SEQ_EXT_CODE];
|
||||
ext_b.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
let _ = parser.parse(&make_pes(ext_b, Some(3600)));
|
||||
|
||||
let cp2 = parser.codec_private().unwrap();
|
||||
assert!(
|
||||
cp2.windows(6)
|
||||
.any(|w| w == [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]),
|
||||
"second header's extension must be re-captured, not dropped"
|
||||
);
|
||||
// It is header B (720x480), not stale header A.
|
||||
assert_eq!(parser.resolution(), Some((720, 480)));
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
#[test]
|
||||
|
||||
+153
-19
@@ -31,6 +31,8 @@ const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
||||
// palette_id_ref) = 13.
|
||||
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
||||
|
||||
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
||||
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
||||
pub struct PgsParser {
|
||||
pending: Option<(i64, Vec<u8>)>,
|
||||
}
|
||||
@@ -42,9 +44,25 @@ impl Default for PgsParser {
|
||||
}
|
||||
|
||||
impl PgsParser {
|
||||
/// Create a fresh PGS parser with no pending display set.
|
||||
pub fn new() -> Self {
|
||||
Self { pending: None }
|
||||
}
|
||||
|
||||
/// Take the pending display set (if any) and emit it as a Frame whose
|
||||
/// duration runs from its start PTS to `end_pts_ns` (the PTS of the PCS that
|
||||
/// closes or replaces it), clamped to >= 0. Shared by the clear-PCS and
|
||||
/// replace-PCS arms so the Frame shape stays in one place.
|
||||
fn emit_pending(&mut self, end_pts_ns: i64) -> Option<Frame> {
|
||||
let (start_pts, data) = self.pending.take()?;
|
||||
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
Some(Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: Some(duration),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for PgsParser {
|
||||
@@ -52,10 +70,36 @@ impl CodecParser for PgsParser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
// Keep PTS as Option: a PCS with no PTS has an UNKNOWN start/clear time.
|
||||
// Collapsing it to a 0 sentinel produces a frame with a wrong start time
|
||||
// and an absurd duration (the full elapsed time of the disc). PGS PCS
|
||||
// packets carry a PTS on well-formed BD streams, so a missing PTS is a
|
||||
// malformed-stream path that we skip cleanly rather than corrupt.
|
||||
let pts = pes.pts.map(pts_to_ns);
|
||||
|
||||
let is_pcs = pes.data[0] == SEGMENT_PCS;
|
||||
let pcs_num_objects = if is_pcs && pes.data.len() > PCS_NUM_OBJECTS_OFFSET {
|
||||
|
||||
// A PCS too short to carry number_of_composition_objects is malformed.
|
||||
// Don't let it fall through to the non-PCS arm (where it would pollute
|
||||
// the pending display set or pass through as a lone frame): close any
|
||||
// pending set undurated (mirroring the no-PTS display path) and drop
|
||||
// the truncated header so the parser resyncs on the next PCS.
|
||||
if is_pcs && pes.data.len() <= PCS_NUM_OBJECTS_OFFSET {
|
||||
return self
|
||||
.pending
|
||||
.take()
|
||||
.map(|(start_pts, data)| {
|
||||
vec![Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let pcs_num_objects = if is_pcs {
|
||||
Some(pes.data[PCS_NUM_OBJECTS_OFFSET])
|
||||
} else {
|
||||
None
|
||||
@@ -65,33 +109,40 @@ impl CodecParser for PgsParser {
|
||||
match pcs_num_objects {
|
||||
// Clear/empty PCS — closes any pending display. Drop the
|
||||
// clear segment itself; BlockDuration covers the screen
|
||||
// wipe.
|
||||
// wipe. A clear PCS with no PTS can't time the duration, so
|
||||
// emit the pending set with no duration (it lingers to EOF).
|
||||
Some(0) => {
|
||||
if let Some((start_pts, data)) = self.pending.take() {
|
||||
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
out.push(Frame {
|
||||
let frame = match pts {
|
||||
Some(end) => self.emit_pending(end),
|
||||
None => self.pending.take().map(|(start_pts, data)| Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: Some(duration),
|
||||
});
|
||||
}
|
||||
duration_ns: None,
|
||||
}),
|
||||
};
|
||||
out.extend(frame);
|
||||
}
|
||||
// Display PCS — start a new pending. If a prior display
|
||||
// was never explicitly cleared (replace-without-clear),
|
||||
// emit it with the new PCS's PTS as its end.
|
||||
Some(_) => {
|
||||
if let Some((start_pts, data)) = self.pending.take() {
|
||||
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
out.push(Frame {
|
||||
Some(_) => match pts {
|
||||
Some(start) => {
|
||||
out.extend(self.emit_pending(start));
|
||||
self.pending = Some((start, pes.data.clone()));
|
||||
}
|
||||
// A display PCS with no PTS has an unknown start time. Don't
|
||||
// store it with a 0 sentinel (wrong start, absurd duration).
|
||||
// Flush any prior pending undurated and skip storing this one.
|
||||
None => {
|
||||
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: Some(duration),
|
||||
});
|
||||
duration_ns: None,
|
||||
}));
|
||||
}
|
||||
self.pending = Some((pts_ns, pes.data.clone()));
|
||||
}
|
||||
},
|
||||
// Non-PCS first segment — either a continuation of the
|
||||
// current display set, or non-standard layout. If we have
|
||||
// a pending display, append; otherwise emit as-is.
|
||||
@@ -103,14 +154,21 @@ impl CodecParser for PgsParser {
|
||||
if buf.len() + pes.data.len() <= MAX_PGS_PENDING_BYTES {
|
||||
buf.extend_from_slice(&pes.data);
|
||||
}
|
||||
} else {
|
||||
} else if pes.pts.is_some() {
|
||||
// A lone non-PCS segment with a real PTS — pass it through.
|
||||
// (A missing PTS falls through to the drop path below: a
|
||||
// bitmap with no timing reference would land at 00:00:00.)
|
||||
out.push(Frame {
|
||||
pts_ns,
|
||||
pts_ns: pts.unwrap_or(0),
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
});
|
||||
}
|
||||
// No pending set AND no PTS: drop it. Emitting at pts_ns=0 would
|
||||
// place a stray bitmap at 00:00:00.000 with no timing reference;
|
||||
// the no-PTS PCS arms above avoid the 0 sentinel for the same
|
||||
// reason.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +313,82 @@ mod tests {
|
||||
assert_eq!(frames[0].duration_ns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_pcs_without_pts_is_not_stored_with_zero_start() {
|
||||
// A display PCS with no PTS has an unknown start time. It must NOT be
|
||||
// stored with a 0 sentinel — otherwise a later clear PCS at real PTS T
|
||||
// would emit a frame with pts_ns=0 and duration_ns=T (hours of ns for a
|
||||
// mid-disc subtitle). The malformed display PCS is skipped instead.
|
||||
let mut parser = PgsParser::new();
|
||||
let frames = parser.parse(&make_pes(pcs_bytes(1), None));
|
||||
assert!(frames.is_empty(), "no-PTS display PCS emits nothing");
|
||||
assert!(
|
||||
parser.pending.is_none(),
|
||||
"no-PTS display PCS must not be stored as pending"
|
||||
);
|
||||
|
||||
// A subsequent well-formed display + clear pair must time correctly,
|
||||
// unpolluted by the skipped no-PTS PCS.
|
||||
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
||||
let f = parser.parse(&make_pes(pcs_bytes(0), Some(270000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, 1_000_000_000);
|
||||
assert_eq!(f[0].duration_ns, Some(2_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_pcs_without_pts_emits_pending_undurated() {
|
||||
// A clear PCS that lacks a PTS can't compute a duration; the pending
|
||||
// display is still emitted, but with no duration (lingers to EOF)
|
||||
// instead of a bogus absurd one.
|
||||
let mut parser = PgsParser::new();
|
||||
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
||||
let f = parser.parse(&make_pes(pcs_bytes(0), None));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, 1_000_000_000, "pending keeps its real start");
|
||||
assert_eq!(f[0].duration_ns, None, "no duration without a clear PTS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_pcs_flushes_pending_and_resyncs() {
|
||||
// A PCS too short to carry number_of_composition_objects arriving with a
|
||||
// pending display must close that display (undurated) and drop the
|
||||
// truncated header, not append its bytes into the pending bitmap.
|
||||
let mut parser = PgsParser::new();
|
||||
let display = pcs_bytes(1);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(display.clone(), Some(90000)))
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// A 13-byte (<= PCS_NUM_OBJECTS_OFFSET) PCS: truncated.
|
||||
let truncated = vec![SEGMENT_PCS; PCS_NUM_OBJECTS_OFFSET];
|
||||
let frames = parser.parse(&make_pes(truncated, Some(180000)));
|
||||
assert_eq!(frames.len(), 1, "pending display flushed on truncated PCS");
|
||||
assert_eq!(frames[0].data, display, "pending bitmap not polluted");
|
||||
assert_eq!(frames[0].duration_ns, None, "flushed undurated");
|
||||
assert!(parser.pending.is_none(), "parser resynced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_non_pcs_without_pts_is_dropped() {
|
||||
// A non-PCS segment with no pending set and no PTS must be dropped, not
|
||||
// emitted at pts_ns = 0 (which would land a stray bitmap at time zero).
|
||||
let mut parser = PgsParser::new();
|
||||
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA], None));
|
||||
assert!(frames.is_empty(), "no pending + no PTS → dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_non_pcs_with_pts_passes_through() {
|
||||
// A lone non-PCS segment WITH a PTS still passes through.
|
||||
let mut parser = PgsParser::new();
|
||||
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA], Some(90000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_with_nothing_pending_is_empty() {
|
||||
let mut parser = PgsParser::new();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Shared MPEG/Annex-B start-code scanning helpers.
|
||||
//!
|
||||
//! H.264, HEVC, MPEG-2 and the MPEG-2 Program Stream demuxer all locate the
|
||||
//! 3-byte `00 00 01` start-code prefix to delimit NAL units / PES units. A
|
||||
//! single memchr-backed implementation lives here so every caller gets the
|
||||
//! same SIMD-accelerated scan instead of a hand-rolled byte-by-byte loop.
|
||||
|
||||
/// Find the position of the next start code (`00 00 01`) at or after `from`.
|
||||
///
|
||||
/// Backed by `memchr::memmem::find` for SIMD-accelerated bytestring search. On
|
||||
/// AVX2-capable x86_64 this runs several times faster than a byte-by-byte scan;
|
||||
/// on a 200 KB UHD HEVC frame the saving is in the hundreds of microseconds per
|
||||
/// call. The reported offset is the start of the `00 00 01` triple, so a 4-byte
|
||||
/// `00 00 00 01` start code is reported at the second `00`.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
memchr::memmem::find(&data[from..], b"\x00\x00\x01").map(|rel| from + rel)
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after
|
||||
/// it. Handles both the 3-byte (`00 00 01`) and 4-byte (`00 00 00 01`) forms.
|
||||
/// Returns `None` if `pos` does not begin a start code or the buffer is too
|
||||
/// short to contain one.
|
||||
pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
if pos + 2 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if data[pos] == 0x00 && data[pos + 1] == 0x00 {
|
||||
if pos + 3 < data.len() && data[pos + 2] == 0x00 && data[pos + 3] == 0x01 {
|
||||
return Some(pos + 4); // 4-byte start code
|
||||
}
|
||||
if data[pos + 2] == 0x01 {
|
||||
return Some(pos + 3); // 3-byte start code
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_start_code_3byte() {
|
||||
let data = [0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(find_start_code(&data, 0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_4byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
|
||||
// The 00 00 01 triple starts at offset 1 in a 4-byte start code.
|
||||
assert_eq!(find_start_code(&data, 0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_offset() {
|
||||
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
|
||||
assert_eq!(find_start_code(&data, 0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_none() {
|
||||
let data = [0x00, 0x00, 0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_start_code_too_short() {
|
||||
let data = [0x00, 0x00];
|
||||
assert_eq!(find_start_code(&data, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_3byte() {
|
||||
let data = [0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(skip_start_code(&data, 0), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_4byte() {
|
||||
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
|
||||
assert_eq!(skip_start_code(&data, 0), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_not_a_start_code() {
|
||||
let data = [0xFF, 0x00, 0x01, 0x65];
|
||||
assert_eq!(skip_start_code(&data, 0), None);
|
||||
}
|
||||
}
|
||||
+155
-59
@@ -4,9 +4,10 @@
|
||||
//! Access units span PES boundaries — must buffer and reassemble.
|
||||
//!
|
||||
//! TrueHD access unit header (4 bytes):
|
||||
//! [0..1] upper 4 bits = parity, lower 12 bits = length in 2-byte words
|
||||
//! [2..3] timing value
|
||||
//! [4..] substream data (major sync 0xF8726FBA may appear at offset 4)
|
||||
//! bytes 0-1: top nibble = MLP check/access-unit nibble, lower 12 bits =
|
||||
//! access-unit length in 2-byte words
|
||||
//! bytes 2-3: timing value
|
||||
//! bytes 4..: substream data (major sync 0xF8726FBA may appear at offset 4)
|
||||
//!
|
||||
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||
@@ -41,55 +42,70 @@ impl TrueHdParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip an AC-3 frame starting at the current buffer position.
|
||||
/// Returns number of bytes consumed, or 0 if not enough data.
|
||||
fn skip_ac3_frame(&self) -> usize {
|
||||
/// Size (bytes) of the AC-3 frame at the buffer head.
|
||||
///
|
||||
/// Distinguishes three cases the caller must treat differently:
|
||||
/// - `Unmappable`: the header's fscod/frmsizecod don't map to a real frame
|
||||
/// size (reserved fscod==3, or frmsizecod >= 38). The caller must drain
|
||||
/// and resync, NOT wait for more data — waiting would stall forever.
|
||||
/// - `NeedMore`: a valid size, but the frame isn't fully buffered yet.
|
||||
/// - `Frame(n)`: a complete `n`-byte AC-3 frame is buffered.
|
||||
///
|
||||
/// Frame sizing reuses `ac3::ac3_frame_size` so the AC-3 size table has a
|
||||
/// single source of truth shared with the AC-3 parser; a returned `0` there
|
||||
/// (reserved fscod or out-of-range frmsizecod) is the unmappable case.
|
||||
fn ac3_frame_at_head(&self) -> Ac3Size {
|
||||
if self.buf.len() < 6 {
|
||||
return 0;
|
||||
return Ac3Size::NeedMore;
|
||||
}
|
||||
// AC-3 frame size from frmsizcod + fscod
|
||||
// Byte 4: [fscod:2][frmsizecod:6]
|
||||
let fscod = (self.buf[4] >> 6) & 0x03;
|
||||
let frmsizecod = (self.buf[4] & 0x3F) as usize;
|
||||
// Frame size in 16-bit words per fscod (simplified table for common rates)
|
||||
let frame_words = match fscod {
|
||||
0 => {
|
||||
// 48 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
64, 64, 80, 80, 96, 96, 112, 112, 128, 128, 160, 160, 192, 192, 224, 224, 256,
|
||||
256, 320, 320, 384, 384, 448, 448, 512, 512, 640, 640, 768, 768, 896, 896,
|
||||
1024, 1024, 1152, 1152, 1280, 1280,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
1 => {
|
||||
// 44.1 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
69, 70, 87, 88, 104, 105, 121, 122, 139, 140, 174, 175, 208, 209, 243, 244,
|
||||
278, 279, 348, 349, 417, 418, 487, 488, 557, 558, 696, 697, 835, 836, 975, 976,
|
||||
1114, 1115, 1253, 1254, 1393, 1394,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
2 => {
|
||||
// 32 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
96, 96, 120, 120, 144, 144, 168, 168, 192, 192, 240, 240, 288, 288, 336, 336,
|
||||
384, 384, 480, 480, 576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152, 1344,
|
||||
1344, 1536, 1536, 1728, 1728, 1920, 1920,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let frame_bytes = frame_words * 2;
|
||||
if frame_bytes == 0 || self.buf.len() < frame_bytes {
|
||||
return 0;
|
||||
let frame_bytes = super::ac3::ac3_frame_size(&self.buf);
|
||||
if frame_bytes == 0 {
|
||||
// Reserved fscod or out-of-range frmsizecod → unmappable header.
|
||||
return Ac3Size::Unmappable;
|
||||
}
|
||||
frame_bytes
|
||||
if self.buf.len() < frame_bytes {
|
||||
return Ac3Size::NeedMore;
|
||||
}
|
||||
Ac3Size::Frame(frame_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary validation for an AC-3 frame of `frame_bytes` at the buffer head:
|
||||
/// is its computed end a plausible boundary? Accept when the frame fills the
|
||||
/// rest of the buffer, or the bytes that follow start another AC-3 sync
|
||||
/// (0x0B77) or a plausible TrueHD access unit (non-zero 12-bit length within
|
||||
/// the 32 KiB cap). If none holds, the leading 0x0B77 is more likely a TrueHD
|
||||
/// AU header that happens to look like AC-3, so the AC-3 reading is rejected.
|
||||
fn ac3_boundary_corroborated(buf: &[u8], frame_bytes: usize) -> bool {
|
||||
if frame_bytes >= buf.len() {
|
||||
// The AC-3 frame is fully buffered and ends the data — consistent.
|
||||
return true;
|
||||
}
|
||||
let tail = &buf[frame_bytes..];
|
||||
if tail.len() < 2 {
|
||||
// Not enough following bytes to judge; accept (the next call will see
|
||||
// the continuation).
|
||||
return true;
|
||||
}
|
||||
// Another AC-3 sync immediately after?
|
||||
if tail[0] == 0x0B && tail[1] == 0x77 {
|
||||
return true;
|
||||
}
|
||||
// A plausible TrueHD AU header after? (non-zero 12-bit length, <= 32 KiB)
|
||||
let next_words = (((tail[0] as usize) << 8) | tail[1] as usize) & 0xFFF;
|
||||
next_words != 0 && next_words * 2 <= 32768
|
||||
}
|
||||
|
||||
/// Outcome of sizing the AC-3 frame at the TrueHD buffer head.
|
||||
enum Ac3Size {
|
||||
/// fscod/frmsizecod don't map to a real frame size — resync, don't wait.
|
||||
Unmappable,
|
||||
/// A valid size, but the frame is not fully buffered yet.
|
||||
NeedMore,
|
||||
/// A complete `n`-byte AC-3 frame is buffered.
|
||||
Frame(usize),
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -118,29 +134,48 @@ impl CodecParser for TrueHdParser {
|
||||
break;
|
||||
}
|
||||
|
||||
// AC-3 frame (interleaved): starts with sync word 0x0B77
|
||||
// AC-3 frame (interleaved): starts with sync word 0x0B77.
|
||||
//
|
||||
// 0x0B 0x77 is also a legal TrueHD AU header (check-nibble 0,
|
||||
// length-high-bits 0xB → length 0xB77 words). To avoid an AC-3
|
||||
// misread stealing a real TrueHD AU, an AC-3 frame is only accepted
|
||||
// when its computed end is corroborated by what follows: end of
|
||||
// buffer (frame fills the rest), another AC-3 sync, or a plausible
|
||||
// TrueHD AU header. If none holds, this is treated as a TrueHD AU.
|
||||
if self.buf[0] == 0x0B && self.buf[1] == 0x77 {
|
||||
let skip = self.skip_ac3_frame();
|
||||
if skip == 0 {
|
||||
break; // incomplete AC-3 frame, wait for more data
|
||||
match self.ac3_frame_at_head() {
|
||||
Ac3Size::Unmappable => {
|
||||
// Permanently unmappable header at the head would stall
|
||||
// the parser forever; resync by dropping 2 bytes so one
|
||||
// bad frame costs one frame, not the whole buffer.
|
||||
self.buf.drain(..2);
|
||||
continue;
|
||||
}
|
||||
Ac3Size::NeedMore => break, // wait for the rest of the frame
|
||||
Ac3Size::Frame(skip) => {
|
||||
if ac3_boundary_corroborated(&self.buf, skip) {
|
||||
self.buf.drain(..skip);
|
||||
continue;
|
||||
}
|
||||
// Not corroborated — fall through and interpret the
|
||||
// 0x0B77 bytes as a TrueHD access unit instead.
|
||||
}
|
||||
}
|
||||
self.buf.drain(..skip);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TrueHD access unit: lower 12 bits of first 2 bytes = length in words
|
||||
let unit_words = (((self.buf[0] as usize) << 8) | self.buf[1] as usize) & 0xFFF;
|
||||
if unit_words == 0 {
|
||||
self.buf.drain(..2);
|
||||
// A zero-length AU is malformed/padding. The AU header is 4 bytes
|
||||
// (length + timing); drain the whole header, not just the length
|
||||
// word, otherwise the timing bytes get misread as the next
|
||||
// length word and produce a spurious parse on the next iteration.
|
||||
self.buf.drain(..4);
|
||||
continue;
|
||||
}
|
||||
// unit_words is masked to 12 bits, so unit_bytes <= 4095 * 2 = 8190;
|
||||
// no separate oversize-resync guard is reachable.
|
||||
let unit_bytes = unit_words * 2;
|
||||
if unit_bytes > 32768 {
|
||||
// Likely misaligned — try to resync by scanning for AC-3 sync or
|
||||
// a valid TrueHD length
|
||||
self.buf.drain(..2);
|
||||
continue;
|
||||
}
|
||||
if self.buf.len() < unit_bytes {
|
||||
break; // incomplete access unit, wait for more data
|
||||
}
|
||||
@@ -352,6 +387,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_au_drains_full_header() {
|
||||
// A zero-length AU header (4 bytes: length=0 + timing) must be skipped
|
||||
// whole. If only 2 bytes were drained the timing bytes would be misread
|
||||
// as a bogus length word. Here the timing bytes are 0x01 0x90 (= 0x190 =
|
||||
// 400 words = 800 bytes) which, if misread, would stall the parser
|
||||
// waiting for 800 bytes that never come. Draining 4 lets the following
|
||||
// real unit parse.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut data = vec![0x00, 0x00, 0x01, 0x90]; // length=0, timing=0x0190
|
||||
data.extend_from_slice(&make_truehd_unit(200));
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 1, "real unit parses after zero-length header");
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmappable_ac3_header_resyncs_not_stalls() {
|
||||
// A permanently unmappable 0x0B77 header at the buffer head (reserved
|
||||
// fscod==3) must NOT stall the parser. It used to be treated as
|
||||
// "incomplete, wait" and break forever, dropping every following AU.
|
||||
// Now it resyncs (drains 2 bytes) so a clean TrueHD unit behind it is
|
||||
// eventually emitted.
|
||||
let mut parser = TrueHdParser::new();
|
||||
// Unmappable AC-3-looking head: 0x0B77, byte4 fscod=3 (0xC0).
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0x00];
|
||||
// A clean TrueHD AU follows.
|
||||
data.extend_from_slice(&make_truehd_unit(200));
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
1,
|
||||
"TrueHD AU behind a bad header is recovered"
|
||||
);
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
assert!(parser.buf.is_empty(), "buffer fully consumed, no stall");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_au_with_0b77_head_not_stolen_by_ac3() {
|
||||
// A TrueHD AU whose first two bytes are 0x0B 0x77 (length 0xB77 = 2935
|
||||
// words = 5870 bytes) must NOT be misrouted to the AC-3 path. The AC-3
|
||||
// size for this header (fscod from byte4) would close the boundary in
|
||||
// the wrong place; the secondary corroboration rejects it because the
|
||||
// computed AC-3 end is not followed by another AC-3 sync / TrueHD AU.
|
||||
let mut parser = TrueHdParser::new();
|
||||
// 5870-byte AU starting with 0x0B 0x77. Byte 4 = 0x00 → AC-3 would
|
||||
// size it as fscod=0, frmsizecod=0 → 128 bytes. The bytes at offset 128
|
||||
// are zeros (next_words==0) → not corroborated → kept as TrueHD.
|
||||
let mut unit = vec![0u8; 5870];
|
||||
unit[0] = 0x0B; // 0xB high nibble of the 12-bit length, check nibble 0
|
||||
unit[1] = 0x77; // low byte of length 0xB77
|
||||
let frames = parser.parse(&make_pes(unit, Some(90000)));
|
||||
assert_eq!(frames.len(), 1, "0x0B77-headed TrueHD AU kept whole");
|
||||
assert_eq!(
|
||||
frames[0].data.len(),
|
||||
5870,
|
||||
"AU sized by TrueHD length, not AC-3 frame size"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = TrueHdParser::new();
|
||||
|
||||
+97
-14
@@ -3,7 +3,8 @@
|
||||
//! VC-1 uses start codes similar to MPEG-2.
|
||||
//! Sequence header (0x0F) contains codec initialization data.
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
//! I-frames (keyframes) are signalled by the presence of a Sequence Header
|
||||
//! (0x0F) in the PES, per the BD VC-1 convention (see `parse`).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
@@ -164,25 +165,54 @@ fn parse_vc1_resolution(sh: &[u8]) -> Option<(u32, u32)> {
|
||||
// Simple/Main profile: resolution not in sequence header
|
||||
return None;
|
||||
}
|
||||
// Advanced profile layout (bit-level starting from sh[4]):
|
||||
// profile(2) + level(3) + chroma_format(2) + quantizer_spec(3) +
|
||||
// postproc_flag(1) + max_coded_width(12) + max_coded_height(12) ...
|
||||
// Total bits before width: 2+3+2+3+1 = 11 bits
|
||||
// We need at least 11+12+12 = 35 bits = 5 bytes from sh[4..]
|
||||
// Advanced profile sequence-header layout (SMPTE 421M, bit-level from sh[4]):
|
||||
// PROFILE(2) + LEVEL(3) + COLORDIFF_FORMAT(2) + FRMRTQ_POSTPROC(3) +
|
||||
// BITRTQ_POSTPROC(5) + POSTPROCFLAG(1) + MAX_CODED_WIDTH(12) +
|
||||
// MAX_CODED_HEIGHT(12) ...
|
||||
// Total bits before MAX_CODED_WIDTH: 2+3+2+3+5+1 = 16 bits.
|
||||
// We need 16+12+12 = 40 bits = 5 de-escaped bytes from sh[4..].
|
||||
if sh.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
// Build a u64 from bytes 4..9 for easy bit extraction
|
||||
let mut bits: u64 = 0;
|
||||
for j in 0..5 {
|
||||
bits = (bits << 8) | sh[4 + j] as u64;
|
||||
// VC-1 Annex-B EBDU payload may carry emulation-prevention bytes (an
|
||||
// inserted 0x03 after a 00 00 run). De-escape the payload before bit
|
||||
// extraction so an EP byte landing within the first few bytes can't shift
|
||||
// every subsequent bit and corrupt MAX_CODED_WIDTH/HEIGHT. Collect just the
|
||||
// 5 de-escaped bytes the bit fields need.
|
||||
let payload = &sh[4..];
|
||||
let mut deesc = Vec::with_capacity(5);
|
||||
let mut zeros = 0u8;
|
||||
for &b in payload {
|
||||
if zeros >= 2 && b == 0x03 {
|
||||
zeros = 0; // drop the emulation-prevention byte
|
||||
continue;
|
||||
}
|
||||
deesc.push(b);
|
||||
if deesc.len() == 5 {
|
||||
break;
|
||||
}
|
||||
zeros = if b == 0x00 { zeros + 1 } else { 0 };
|
||||
}
|
||||
// bits has 40 bits. Skip first 11 bits, then read 12+12.
|
||||
let coded_width = ((bits >> (40 - 11 - 12)) & 0xFFF) as u32 + 1;
|
||||
let coded_height = ((bits >> (40 - 11 - 24)) & 0xFFF) as u32 + 1;
|
||||
if deesc.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
// Build a u64 from the 5 de-escaped bytes for easy bit extraction.
|
||||
let mut bits: u64 = 0;
|
||||
for &b in &deesc {
|
||||
bits = (bits << 8) | b as u64;
|
||||
}
|
||||
// bits holds 40 significant bits laid out as:
|
||||
// [16 leading bits][MAX_CODED_WIDTH:12][MAX_CODED_HEIGHT:12]
|
||||
// so MAX_CODED_WIDTH starts 12 bits from the LSB end and MAX_CODED_HEIGHT
|
||||
// occupies the low 12 bits (shift 0).
|
||||
const WIDTH_SHIFT: u64 = 12; // 40 - 16 - 12
|
||||
let coded_width = ((bits >> WIDTH_SHIFT) & 0xFFF) as u32 + 1;
|
||||
let coded_height = (bits & 0xFFF) as u32 + 1;
|
||||
// coded_width/height are `(bits & 0xFFF) + 1`, so always >= 1; after the
|
||||
// ×2 both are always >= 2. Only the upper bound can fail.
|
||||
let w = coded_width * 2;
|
||||
let h = coded_height * 2;
|
||||
if w > 0 && h > 0 && w <= 8192 && h <= 8192 {
|
||||
if w <= 8192 && h <= 8192 {
|
||||
Some((w, h))
|
||||
} else {
|
||||
None
|
||||
@@ -444,6 +474,59 @@ mod tests {
|
||||
assert_eq!(frames[0].pts_ns, 2_000_000_000);
|
||||
}
|
||||
|
||||
// --- advanced-profile resolution parsing (bit-offset regression) ---
|
||||
|
||||
/// Build an advanced-profile VC-1 sequence header encoding the given
|
||||
/// width/height. Layout from sh[4]: PROFILE(2)=3, LEVEL(3), COLORDIFF(2),
|
||||
/// FRMRTQ(3), BITRTQ(5), POSTPROCFLAG(1) = 16 bits, then
|
||||
/// MAX_CODED_WIDTH(12) = width/2 - 1, MAX_CODED_HEIGHT(12) = height/2 - 1.
|
||||
fn make_ap_seq_header(width: u32, height: u32) -> Vec<u8> {
|
||||
let coded_w = (width / 2) - 1;
|
||||
let coded_h = (height / 2) - 1;
|
||||
// Accumulate 40 bits MSB-first: 16 leading bits then 12+12.
|
||||
let mut acc: u64 = 0;
|
||||
let mut nbits = 0u32;
|
||||
let put = |val: u64, n: u32, acc: &mut u64, nbits: &mut u32| {
|
||||
*acc = (*acc << n) | (val & ((1u64 << n) - 1));
|
||||
*nbits += n;
|
||||
};
|
||||
// PROFILE = 3 (advanced), then 14 more leading bits (all zero here).
|
||||
put(0b11, 2, &mut acc, &mut nbits);
|
||||
put(0, 14, &mut acc, &mut nbits); // level+colordiff+frmrtq+bitrtq+postproc
|
||||
put(coded_w as u64, 12, &mut acc, &mut nbits);
|
||||
put(coded_h as u64, 12, &mut acc, &mut nbits);
|
||||
// 40 bits → 5 bytes, MSB-first.
|
||||
let mut payload = Vec::with_capacity(5);
|
||||
for i in (0..5).rev() {
|
||||
payload.push(((acc >> (i * 8)) & 0xFF) as u8);
|
||||
}
|
||||
let mut sh = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER];
|
||||
sh.extend_from_slice(&payload);
|
||||
sh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_profile_resolution_uses_16bit_offset() {
|
||||
// Regression: the parser skipped 11 bits (omitting BITRTQ_POSTPROC's 5
|
||||
// bits) instead of 16, reading width/height 5 bits too early. Encode a
|
||||
// non-default 1280x720 and confirm it round-trips, proving the 16-bit
|
||||
// pre-width offset.
|
||||
let mut parser = Vc1Parser::new();
|
||||
let mut data = make_ap_seq_header(1280, 720);
|
||||
// A frame so the parser emits and stores the header.
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data.extend_from_slice(&[0x55, 0x66]);
|
||||
parser.parse(&make_pes(data, Some(0)));
|
||||
|
||||
let cp = parser.codec_private();
|
||||
// codec_private needs an entry point too; resolution is in width/height
|
||||
// fields regardless. Read them off the parser via codec_private when
|
||||
// available, else assert the internal fields directly.
|
||||
assert_eq!(parser.width, 1280, "width parsed at the 16-bit offset");
|
||||
assert_eq!(parser.height, 720, "height parsed at the 16-bit offset");
|
||||
let _ = cp;
|
||||
}
|
||||
|
||||
// --- find_next_sc utility ---
|
||||
|
||||
#[test]
|
||||
|
||||
+27
-161
@@ -18,13 +18,14 @@
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! [`DemuxThread::spawn`] takes ownership of the inner reader and the
|
||||
//! demuxer state, returns a handle plus a `Receiver<DemuxBatch>`.
|
||||
//! Dropping the handle closes the channel which signals the thread
|
||||
//! to exit; the join in `Drop::drop` is bounded.
|
||||
//! [`DemuxThread::spawn_zero_copy`] consumes the prefetch channels and
|
||||
//! the demuxer state, returning a handle plus a `Receiver<DemuxBatch>`.
|
||||
//! Dropping the handle closes the channel which signals the worker to
|
||||
//! exit; the join in `Drop::drop` blocks until the worker observes
|
||||
//! channel closure and returns (no timeout — a wedged downstream would
|
||||
//! block the drop until it releases the channel).
|
||||
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -39,6 +40,13 @@ pub enum DemuxBatch {
|
||||
Ps(Vec<super::ps::PsPacket>),
|
||||
/// Underlying reader returned an error. Terminal.
|
||||
Err(std::io::Error),
|
||||
/// Explicit clean-completion sentinel. The worker sends this as its
|
||||
/// LAST message on every non-error exit (input exhausted, or halt
|
||||
/// cancelled) so the consumer can distinguish a normal end-of-stream
|
||||
/// from a bare channel disconnection. A worker that panics mid-stream
|
||||
/// drops `tx` without sending this, so the consumer sees `RecvError`
|
||||
/// and reports the panic rather than silently truncating output.
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Spawned demux thread. Drop joins.
|
||||
@@ -56,158 +64,7 @@ pub struct DemuxThread {
|
||||
}
|
||||
|
||||
impl DemuxThread {
|
||||
/// Spawn the demux thread. Returns the thread handle and a
|
||||
/// receiver for [`DemuxBatch`] items.
|
||||
///
|
||||
/// `reader` is the fully-composed read+decrypt stack (e.g.
|
||||
/// [`PrefetchedSectorSource`](crate::sector::PrefetchedSectorSource)
|
||||
/// wrapping
|
||||
/// [`DecryptingSectorSource`](crate::sector::DecryptingSectorSource)).
|
||||
/// `extents` is what the thread walks; it issues one
|
||||
/// `read_sectors` per batch of `batch_sectors` sectors (aligned
|
||||
/// to 3-sector AACS units when possible).
|
||||
pub fn spawn<S: SectorSource + Send + 'static>(
|
||||
mut reader: S,
|
||||
extents: Vec<crate::disc::Extent>,
|
||||
batch_sectors: u16,
|
||||
halt: Option<Halt>,
|
||||
ts: Option<super::ts::TsDemuxer>,
|
||||
ps: Option<super::ps::PsDemuxer>,
|
||||
) -> (Self, Receiver<DemuxBatch>) {
|
||||
let (tx, rx) = bounded::<DemuxBatch>(DEMUX_CHANNEL_DEPTH);
|
||||
let mut ts = ts;
|
||||
let mut ps = ps;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("freemkv-demux".into())
|
||||
.spawn(move || {
|
||||
let mut buf = vec![0u8; batch_sectors as usize * 2048];
|
||||
let mut ext_idx = 0usize;
|
||||
let mut offset: u32 = 0;
|
||||
let prof = std::env::var_os("FREEMKV_PROFILE").is_some();
|
||||
let mut prof_started = std::time::Instant::now();
|
||||
let mut prof_last_dump = prof_started;
|
||||
let mut prof_read_ns: u128 = 0;
|
||||
let mut prof_feed_ns: u128 = 0;
|
||||
let mut prof_send_ns: u128 = 0;
|
||||
let mut prof_bytes: u64 = 0;
|
||||
while ext_idx < extents.len() {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let ext = &extents[ext_idx];
|
||||
let remaining = ext.sector_count.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
ext_idx += 1;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
let mut sectors = remaining.min(batch_sectors as u32) as u16;
|
||||
if sectors >= 3 {
|
||||
sectors -= sectors % 3;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
if buf.len() < bytes {
|
||||
buf.resize(bytes, 0);
|
||||
}
|
||||
let lba = ext.start_lba + offset;
|
||||
let t0 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n = match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = tx.send(DemuxBatch::Err(e.into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t1 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
offset += sectors as u32;
|
||||
|
||||
// Demux this batch immediately so the channel
|
||||
// carries already-parsed PesPackets, not raw
|
||||
// sector bytes.
|
||||
if let Some(ref mut d) = ts {
|
||||
let pkts = d.feed(&buf[..n]);
|
||||
let t2 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ts(pkts)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
let t3 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if prof {
|
||||
prof_read_ns += t1.unwrap().duration_since(t0.unwrap()).as_nanos();
|
||||
prof_feed_ns += t2.unwrap().duration_since(t1.unwrap()).as_nanos();
|
||||
prof_send_ns += t3.unwrap().duration_since(t2.unwrap()).as_nanos();
|
||||
prof_bytes += n as u64;
|
||||
let now = t3.unwrap();
|
||||
if now.duration_since(prof_last_dump)
|
||||
>= std::time::Duration::from_secs(5)
|
||||
{
|
||||
let el = now.duration_since(prof_started).as_millis().max(1);
|
||||
let mbps = prof_bytes as u128 * 1000 / 1_000_000 / el;
|
||||
eprintln!(
|
||||
"[demux] elapsed={}ms in={}MB/s read={}% feed={}% send={}%",
|
||||
el,
|
||||
mbps,
|
||||
prof_read_ns / 10_000 / el,
|
||||
prof_feed_ns / 10_000 / el,
|
||||
prof_send_ns / 10_000 / el,
|
||||
);
|
||||
prof_last_dump = now;
|
||||
prof_started = now;
|
||||
prof_read_ns = 0;
|
||||
prof_feed_ns = 0;
|
||||
prof_send_ns = 0;
|
||||
prof_bytes = 0;
|
||||
}
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let pkts = d.feed(&buf[..n]);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ps(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF — emit any flushed packets too.
|
||||
if let Some(ref mut d) = ts {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ts(tail));
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ps(tail));
|
||||
}
|
||||
}
|
||||
// Sender drops here -> consumer sees RecvError → EOF.
|
||||
})
|
||||
.expect("freemkv-demux thread spawn failed");
|
||||
|
||||
(
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
producer_shell: None,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Zero-copy variant. Instead of taking a `SectorSource` and
|
||||
/// Spawn the demux thread. Instead of taking a `SectorSource` and
|
||||
/// memcpy-ing through its `read_sectors` API, this constructor
|
||||
/// consumes the prefetch channels directly: filled buffers come
|
||||
/// in via `prefetch_rx`, the demux thread feeds them, then
|
||||
@@ -230,7 +87,7 @@ impl DemuxThread {
|
||||
halt: Option<Halt>,
|
||||
ts: Option<super::ts::TsDemuxer>,
|
||||
ps: Option<super::ps::PsDemuxer>,
|
||||
) -> (Self, Receiver<DemuxBatch>) {
|
||||
) -> crate::error::Result<(Self, Receiver<DemuxBatch>)> {
|
||||
let (tx, rx) = bounded::<DemuxBatch>(DEMUX_CHANNEL_DEPTH);
|
||||
let mut ts = ts;
|
||||
let mut ps = ps;
|
||||
@@ -246,6 +103,10 @@ impl DemuxThread {
|
||||
let mut prof_bytes: u64 = 0;
|
||||
loop {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
// Caller-initiated stop is a clean termination —
|
||||
// send the Eof sentinel so the consumer doesn't
|
||||
// mistake it for a worker panic.
|
||||
let _ = tx.send(DemuxBatch::Eof);
|
||||
return;
|
||||
}
|
||||
let t0 = if prof {
|
||||
@@ -328,16 +189,21 @@ impl DemuxThread {
|
||||
let _ = tx.send(DemuxBatch::Ps(tail));
|
||||
}
|
||||
}
|
||||
// Clean end-of-stream sentinel. Reaching here means no
|
||||
// panic occurred; a panic during `feed`/`flush` skips
|
||||
// this and drops `tx`, which the consumer reads as an
|
||||
// error rather than a clean EOF.
|
||||
let _ = tx.send(DemuxBatch::Eof);
|
||||
})
|
||||
.expect("freemkv-demux thread spawn failed");
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
|
||||
(
|
||||
Ok((
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
producer_shell: Some(Box::new(producer_shell)),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-32
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
|
||||
use crate::disc::{Disc, DiscTitle, Extent};
|
||||
use crate::disc::{DiscTitle, Extent};
|
||||
use crate::drive::extract_scsi_context;
|
||||
use crate::event::{BatchSizeReason, Event, EventKind};
|
||||
use crate::halt::Halt;
|
||||
@@ -107,7 +107,6 @@ pub struct DiscStream {
|
||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||
title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
/// Mirror of the keys handed in at construction. The decorator
|
||||
/// owns the cryptographic state; this field is kept for
|
||||
/// metadata-side callers (`info()` and friends) that want to
|
||||
@@ -159,6 +158,17 @@ pub struct DiscStream {
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
/// Cached `FREEMKV_SKIP_PARSE` profiling flag. The env var cannot
|
||||
/// change at runtime, and `std::env::var_os` takes a process-wide
|
||||
/// lock; reading it once at construction keeps it out of the
|
||||
/// per-batch read() hot loop.
|
||||
skip_parse: bool,
|
||||
/// Cached `FREEMKV_PROFILE` presence, read once at construction. When
|
||||
/// false, the read() loop skips the four `Instant::now()` captures and the
|
||||
/// `prof_tick` calls entirely, so profiling-off runs pay no per-iteration
|
||||
/// timestamp cost or `prof_active()` env-var lookup (which takes a
|
||||
/// process-wide lock).
|
||||
profiling: bool,
|
||||
}
|
||||
|
||||
impl DiscStream {
|
||||
@@ -177,11 +187,15 @@ impl DiscStream {
|
||||
let extents = title.extents.clone();
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux reading from drive instead of ISO
|
||||
// Debug log reader type at construction — critical for diagnosing mux
|
||||
// reading from drive instead of ISO. `type_name_of_val(&*reader)`
|
||||
// resolves the CONCRETE type behind the box (Drive / FileSectorSource),
|
||||
// unlike `type_name::<dyn SectorSource>()` which always prints the
|
||||
// trait-object name regardless of the underlying source.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"DiscStream constructed with reader type: {}",
|
||||
std::any::type_name::<dyn SectorSource>()
|
||||
std::any::type_name_of_val(&*reader)
|
||||
);
|
||||
|
||||
let mut pids = Vec::new();
|
||||
@@ -220,7 +234,6 @@ impl DiscStream {
|
||||
// the decorator is a pass-through.
|
||||
reader: DecryptingSectorSource::new(reader, decrypt_keys.clone()),
|
||||
title,
|
||||
disc: None,
|
||||
decrypt_keys,
|
||||
extents,
|
||||
current_extent: 0,
|
||||
@@ -240,6 +253,8 @@ impl DiscStream {
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +313,6 @@ impl DiscStream {
|
||||
self.reader.set_keys(crate::decrypt::DecryptKeys::None);
|
||||
}
|
||||
|
||||
/// Get the scanned Disc (for listing all titles).
|
||||
pub fn disc(&self) -> Option<&Disc> {
|
||||
self.disc.as_ref()
|
||||
}
|
||||
|
||||
fn fill_extents(&mut self) -> io::Result<bool> {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(false);
|
||||
@@ -317,7 +327,10 @@ impl DiscStream {
|
||||
return self.fill_extents();
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
// start_lba comes from UDF/MPLS extents; a malformed extent near
|
||||
// u32::MAX would overflow (debug panic / release wrap to a wrong LBA).
|
||||
// Saturate for consistency with the rest of the file's arithmetic.
|
||||
let lba = ext_start.saturating_add(self.current_offset);
|
||||
|
||||
// Adaptive sizer: start at current (preferred until a failure), shrink
|
||||
// on failure, advance on success. One 5s read attempt per try — no
|
||||
@@ -349,15 +362,20 @@ impl DiscStream {
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
let ok = self
|
||||
let res = self
|
||||
.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false)
|
||||
.is_ok();
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false);
|
||||
|
||||
if ok {
|
||||
if let Ok(&got) = res.as_ref() {
|
||||
// SectorSource::read_sectors returns the number of bytes
|
||||
// written into buf. All in-tree sources return full-or-error,
|
||||
// but a short count would leave the stale/zeroed tail of
|
||||
// read_buf in place; trust the returned count, not `bytes`.
|
||||
debug_assert!(got <= bytes, "read_sectors over-reported byte count");
|
||||
if let Some(ev) = self.adaptive.on_success(sectors) {
|
||||
self.emit(ev);
|
||||
}
|
||||
let bytes = got.min(bytes);
|
||||
self.buf_valid = bytes;
|
||||
self.current_offset += sectors as u32;
|
||||
self.bytes_read_total = self.bytes_read_total.saturating_add(bytes as u64);
|
||||
@@ -379,10 +397,14 @@ impl DiscStream {
|
||||
self.current_offset += 1;
|
||||
break;
|
||||
} else {
|
||||
let err = self
|
||||
.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..2048], false)
|
||||
.err();
|
||||
// Build the error from the failure we ALREADY hold.
|
||||
// Re-reading the same known-bad LBA here doubled drive
|
||||
// abuse (hard rule #2: repeated failed reads on the
|
||||
// same LBA push the BU40N into fast-fail) and, if the
|
||||
// retry transiently succeeded, dropped the good data
|
||||
// and returned a bogus status=0/sense=None error for a
|
||||
// readable sector.
|
||||
let err = res.err();
|
||||
let (status, sense) =
|
||||
err.as_ref().map(extract_scsi_context).unwrap_or((0, None));
|
||||
return Err(crate::error::Error::DiscRead {
|
||||
@@ -409,9 +431,9 @@ impl DiscStream {
|
||||
}
|
||||
|
||||
/// Per-stage profiling state — populated only when `FREEMKV_PROFILE`
|
||||
/// is set. Dumps a percentage breakdown to stderr every
|
||||
/// [`PROFILE_INTERVAL`]. Zero overhead in normal runs (Option check
|
||||
/// is the only added cost).
|
||||
/// is set. Logs a percentage breakdown via `tracing` (target "mux")
|
||||
/// every [`PROFILE_INTERVAL`]. Zero overhead in normal runs (the
|
||||
/// `DiscStream::profiling` check is the only added cost).
|
||||
struct StageProf {
|
||||
started: std::time::Instant,
|
||||
last_dump: std::time::Instant,
|
||||
@@ -465,7 +487,8 @@ fn prof_tick(stage: &str, ns: u128, bytes: u64) {
|
||||
let feed_pct = p.feed_ns / 10_000 / elapsed_ms;
|
||||
let consume_pct = p.consume_ns / 10_000 / elapsed_ms;
|
||||
let mbps = p.bytes_in as u128 * 1000 / 1_000_000 / elapsed_ms;
|
||||
eprintln!(
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"[profile] elapsed={}ms in={}MB/s fill={}% feed={}% consume={}%",
|
||||
elapsed_ms, mbps, fill_pct, feed_pct, consume_pct,
|
||||
);
|
||||
@@ -484,7 +507,9 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
loop {
|
||||
let t0 = std::time::Instant::now();
|
||||
// Profiling timestamps only when FREEMKV_PROFILE is set; otherwise
|
||||
// these stay None and no Instant::now() is taken in the hot loop.
|
||||
let t0 = self.profiling.then(std::time::Instant::now);
|
||||
if !self.fill_extents()? {
|
||||
self.eof = true;
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
@@ -565,8 +590,10 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
let bytes = self.buf_valid;
|
||||
let t1 = std::time::Instant::now();
|
||||
prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64);
|
||||
let t1 = self.profiling.then(std::time::Instant::now);
|
||||
if let (Some(t0), Some(t1)) = (t0, t1) {
|
||||
prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64);
|
||||
}
|
||||
// Plaintext: the wrapped reader (DecryptingSectorSource)
|
||||
// applied AACS / CSS in-place during fill_extents'
|
||||
// read_sectors call. The pre-0.18 inline decrypt step
|
||||
@@ -574,9 +601,11 @@ impl crate::pes::Stream for DiscStream {
|
||||
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
let t2 = std::time::Instant::now();
|
||||
prof_tick("feed", t2.duration_since(t1).as_nanos(), 0);
|
||||
let skip_parse = std::env::var_os("FREEMKV_SKIP_PARSE").is_some();
|
||||
let t2 = self.profiling.then(std::time::Instant::now);
|
||||
if let (Some(t1), Some(t2)) = (t1, t2) {
|
||||
prof_tick("feed", t2.duration_since(t1).as_nanos(), 0);
|
||||
}
|
||||
let skip_parse = self.skip_parse;
|
||||
for pes in packets {
|
||||
if let Some((_, track)) = self
|
||||
.pid_to_track
|
||||
@@ -608,8 +637,10 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
let t3 = std::time::Instant::now();
|
||||
prof_tick("consume", t3.duration_since(t2).as_nanos(), 0);
|
||||
let t3 = self.profiling.then(std::time::Instant::now);
|
||||
if let (Some(t2), Some(t3)) = (t2, t3) {
|
||||
prof_tick("consume", t3.duration_since(t2).as_nanos(), 0);
|
||||
}
|
||||
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for ps in &packets {
|
||||
@@ -691,7 +722,7 @@ impl crate::pes::Stream for DiscStream {
|
||||
// bottleneck profiling, so codec_private is never populated.
|
||||
// Pretend headers are ready immediately in that mode so the
|
||||
// CLI loop doesn't hang waiting for them.
|
||||
if std::env::var_os("FREEMKV_SKIP_PARSE").is_some() {
|
||||
if self.skip_parse {
|
||||
return true;
|
||||
}
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
|
||||
+141
-10
@@ -40,6 +40,13 @@ pub fn write_size(w: &mut impl Write, size: u64) -> io::Result<()> {
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else if size >= 0x00FF_FFFF_FFFF_FFFF {
|
||||
// 0x00FF_FFFF_FFFF_FFFF (max 56-bit) encodes byte-for-byte
|
||||
// identical to write_unknown_size (the EBML all-ones
|
||||
// "unknown/open-ended" sentinel), and anything larger doesn't fit
|
||||
// the 7-byte payload. Reject so a finite size can never be emitted
|
||||
// as the unknown-size marker.
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
} else {
|
||||
// 8-byte size for large elements
|
||||
w.write_all(&[
|
||||
@@ -119,9 +126,23 @@ pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> {
|
||||
}
|
||||
|
||||
/// End a master element: seek back and write the actual size.
|
||||
///
|
||||
/// `size_pos` must be the offset returned by [`start_master`], which always
|
||||
/// writes the 8-byte size placeholder before any body bytes. Therefore
|
||||
/// `end_pos >= size_pos + 8` always holds, and the resulting `data_size`
|
||||
/// fits the 7-byte VINT payload (a single MKV element exceeding 2^56 bytes
|
||||
/// is not representable and never produced here).
|
||||
pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
|
||||
let end_pos = w.stream_position()?;
|
||||
debug_assert!(
|
||||
end_pos >= size_pos + 8,
|
||||
"end_master: end_pos {end_pos} < size_pos {size_pos} + 8 (placeholder not written?)"
|
||||
);
|
||||
let data_size = end_pos - size_pos - 8; // subtract the 8-byte size field itself
|
||||
debug_assert!(
|
||||
data_size < 0x0100_0000_0000_0000,
|
||||
"end_master: data_size {data_size} exceeds the 7-byte VINT payload"
|
||||
);
|
||||
w.seek(SeekFrom::Start(size_pos))?;
|
||||
// Write as 8-byte VINT: 0x01 followed by 7 bytes of size
|
||||
w.write_all(&[
|
||||
@@ -216,6 +237,9 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
| (b[1] as u64) << 16
|
||||
| (b[2] as u64) << 8
|
||||
| b[3] as u64;
|
||||
if val == 0x07_FFFF_FFFF {
|
||||
return Ok((u64::MAX, 5));
|
||||
}
|
||||
Ok((val, 5))
|
||||
} else if b0 & 0x04 != 0 {
|
||||
let mut b = [0u8; 5];
|
||||
@@ -226,6 +250,9 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
| (b[2] as u64) << 16
|
||||
| (b[3] as u64) << 8
|
||||
| b[4] as u64;
|
||||
if val == 0x3FF_FFFF_FFFF {
|
||||
return Ok((u64::MAX, 6));
|
||||
}
|
||||
Ok((val, 6))
|
||||
} else if b0 & 0x02 != 0 {
|
||||
let mut b = [0u8; 6];
|
||||
@@ -237,8 +264,11 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
| (b[3] as u64) << 16
|
||||
| (b[4] as u64) << 8
|
||||
| b[5] as u64;
|
||||
if val == 0x01_FFFF_FFFF_FFFF {
|
||||
return Ok((u64::MAX, 7));
|
||||
}
|
||||
Ok((val, 7))
|
||||
} else {
|
||||
} else if b0 & 0x01 != 0 {
|
||||
let mut b = [0u8; 7];
|
||||
r.read_exact(&mut b)?;
|
||||
let val = (b[0] as u64) << 48
|
||||
@@ -252,6 +282,13 @@ pub fn read_size(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
return Ok((u64::MAX, 8));
|
||||
}
|
||||
Ok((val, 8))
|
||||
} else {
|
||||
// b0 == 0x00: no length marker in the first byte. A VINT wider than
|
||||
// 8 bytes is not representable by Matroska's size encoding, so this
|
||||
// is a malformed/over-long size field rather than a valid 8-byte
|
||||
// length. Reject it instead of silently building a size from the
|
||||
// following 7 bytes (which would desync the parse).
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,13 +317,10 @@ pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Read a float value. EBML floats are exactly 0, 4, or 8 bytes.
|
||||
///
|
||||
/// The previous `else` branch read a fixed 8 bytes for ANY non-4 length,
|
||||
/// so a malformed element with `len > 8` left `len - 8` unconsumed bytes
|
||||
/// (mis-read as the next EBML header → desync of the rest of the parent
|
||||
/// element) and `len < 4` over-read. Consume exactly `len` bytes and
|
||||
/// reject anything that isn't a valid float width.
|
||||
/// Read a float value. EBML floats are exactly 0, 4, or 8 bytes; any other
|
||||
/// length is rejected as [`Error::MkvInvalid`] and exactly the float width is
|
||||
/// consumed (so a malformed element never under- or over-reads and desyncs the
|
||||
/// rest of the parent element).
|
||||
pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> {
|
||||
match len {
|
||||
0 => Ok(0.0),
|
||||
@@ -311,7 +345,9 @@ pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
// Library rule: errors are numeric variants, never English strings.
|
||||
// A non-UTF-8 string element is malformed input → MkvInvalid.
|
||||
String::from_utf8(buf).map_err(|_| crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
|
||||
/// Read binary data of `len` bytes.
|
||||
@@ -330,7 +366,10 @@ fn read_exact_bounded(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let got = r.take(len as u64).read_to_end(&mut buf)?;
|
||||
if got != len {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
// A truncated element is malformed input. Use the typed crate error
|
||||
// so callers matching on Error::MkvInvalid catch short reads rather
|
||||
// than a bare io::ErrorKind that bypasses the numeric-code identity.
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -456,6 +495,25 @@ mod tests {
|
||||
assert_eq!(buf, [126 | 0x80]); // 126 < 0x7F, uses 1 byte
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_size_rejects_unknown_size_sentinel() {
|
||||
// 0x00FF_FFFF_FFFF_FFFF would encode byte-for-byte identical to the
|
||||
// EBML unknown-size marker; it must be rejected, not silently emitted.
|
||||
let mut buf = Vec::new();
|
||||
let e = write_size(&mut buf, 0x00FF_FFFF_FFFF_FFFF).unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(buf.is_empty(), "no bytes should be written on rejection");
|
||||
|
||||
// One below the boundary still encodes as a normal 8-byte size whose
|
||||
// payload is NOT all-ones, so read_size yields the finite value back.
|
||||
buf.clear();
|
||||
let v = 0x00FF_FFFF_FFFF_FFFE;
|
||||
write_size(&mut buf, v).unwrap();
|
||||
let (back, consumed) = read_size(&mut Cursor::new(&buf)).unwrap();
|
||||
assert_eq!(consumed, 8);
|
||||
assert_eq!(back, v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
@@ -632,6 +690,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_size_unknown_sentinel_all_widths() {
|
||||
// The all-ones VINT of each width is the EBML "unknown size" marker
|
||||
// and must read back as u64::MAX. write_size never emits the 5/6/7-byte
|
||||
// widths, so these are hand-crafted. Each entry is (bytes, expected_len).
|
||||
let cases: &[(&[u8], usize)] = &[
|
||||
// 1-byte: 0x80 | 0x7F
|
||||
(&[0xFF], 1),
|
||||
// 2-byte: 0x40 marker, value bits all 1
|
||||
(&[0x7F, 0xFF], 2),
|
||||
// 3-byte
|
||||
(&[0x3F, 0xFF, 0xFF], 3),
|
||||
// 4-byte
|
||||
(&[0x1F, 0xFF, 0xFF, 0xFF], 4),
|
||||
// 5-byte (0x08 marker)
|
||||
(&[0x0F, 0xFF, 0xFF, 0xFF, 0xFF], 5),
|
||||
// 6-byte (0x04 marker)
|
||||
(&[0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], 6),
|
||||
// 7-byte (0x02 marker)
|
||||
(&[0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], 7),
|
||||
// 8-byte (0x01 marker)
|
||||
(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], 8),
|
||||
];
|
||||
for (bytes, expected_len) in cases {
|
||||
let mut cursor = Cursor::new(*bytes);
|
||||
let (size, consumed) = read_size(&mut cursor).unwrap();
|
||||
assert_eq!(
|
||||
size,
|
||||
u64::MAX,
|
||||
"all-ones {}-byte VINT should be unknown-size",
|
||||
expected_len
|
||||
);
|
||||
assert_eq!(consumed, *expected_len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_size_concrete_5_6_7_byte_values() {
|
||||
// A non-sentinel 5/6/7-byte size must read back as its concrete value,
|
||||
// not be mistaken for unknown-size.
|
||||
// 5-byte: marker 0x08, value 0x01 (0x0800000001 with width bit only).
|
||||
let mut c = Cursor::new(&[0x08u8, 0x00, 0x00, 0x00, 0x01]);
|
||||
assert_eq!(read_size(&mut c).unwrap(), (1, 5));
|
||||
// 6-byte
|
||||
let mut c = Cursor::new(&[0x04u8, 0x00, 0x00, 0x00, 0x00, 0x05]);
|
||||
assert_eq!(read_size(&mut c).unwrap(), (5, 6));
|
||||
// 7-byte
|
||||
let mut c = Cursor::new(&[0x02u8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09]);
|
||||
assert_eq!(read_size(&mut c).unwrap(), (9, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_size_rejects_zero_first_byte() {
|
||||
// b0 == 0x00 has no width marker — an over-long/invalid VINT. It must
|
||||
// be rejected, not silently treated as an 8-byte size.
|
||||
let mut c = Cursor::new(&[0x00u8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
let e = read_size(&mut c).unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_size_rejects_at_or_above_2_56() {
|
||||
// 2^56 cannot be encoded in the 7-payload-byte 8-byte VINT and must
|
||||
// error rather than silently truncate.
|
||||
let mut buf = Vec::new();
|
||||
let e = write_size(&mut buf, 0x0100_0000_0000_0000).unwrap_err();
|
||||
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
|
||||
// The largest encodable size still succeeds.
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, 0x00FF_FFFF_FFFF_FFFE).unwrap();
|
||||
assert_eq!(buf.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_size() {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
+77
-68
@@ -1,18 +1,20 @@
|
||||
//! Fragmented MP4 muxer — **stub** for Phase 3.
|
||||
//! Fragmented MP4 muxer — **STUB**: fragment emission is not implemented.
|
||||
//!
|
||||
//! Goal: ISO/IEC 14496-12 fragmented MP4 (`ftyp` + `moov` init segment,
|
||||
//! then a sequence of `moof+mdat` media fragments) targeting a
|
||||
//! [`SequentialSink`](crate::io::sink::SequentialSink). DASH-friendly,
|
||||
//! no Cues backpatch.
|
||||
//!
|
||||
//! Status (v0.21.0 Phase 3): **STUB**. We ship the init segment
|
||||
//! (`ftyp` + a minimal HEVC `moov` skeleton with one video track) so
|
||||
//! the muxer's shape and call site are validated, but media fragments
|
||||
//! are NOT yet emitted — calls to [`Fmp4Mux::write_video`] currently
|
||||
//! accumulate frames into an internal buffer and discard them on
|
||||
//! [`Fmp4Mux::finish`].
|
||||
//! Status: **STUB**. The muxer can emit the init segment (`ftyp` + a
|
||||
//! minimal HEVC `moov` skeleton with one video track) via
|
||||
//! [`Fmp4Mux::write_init_segment`], so the shape and call site are
|
||||
//! validated, but media fragments (`moof`/`mdat`) are NOT emitted.
|
||||
//! [`Fmp4Mux::write_video`] therefore returns
|
||||
//! [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented)
|
||||
//! rather than silently accepting and discarding frames. It buffers
|
||||
//! nothing, so it cannot accumulate memory.
|
||||
//!
|
||||
//! ## What's TODO (tracked in Phase 4 / v0.22.0 scope)
|
||||
//! ## Not yet implemented
|
||||
//!
|
||||
//! - `moof` box: `mfhd` (sequence_number) + `traf` (`tfhd` + `tfdt`
|
||||
//! + `trun` with sample sizes, durations, flags, composition offsets).
|
||||
@@ -20,7 +22,7 @@
|
||||
//! - Fragment cadence: one fragment per GOP or every N seconds,
|
||||
//! whichever comes first.
|
||||
//! - HEVC `hvcC` box inside `moov.trak.mdia.minf.stbl.stsd` so the
|
||||
//! init segment is self-describing.
|
||||
//! init segment is self-describing (`stsd` currently has zero entries).
|
||||
//! - Sample-flags computation (sync vs. delta, depends_on, etc.).
|
||||
//! - Edit lists / fragment_duration for accurate seeking.
|
||||
//!
|
||||
@@ -64,83 +66,66 @@ const VIDEO_TRACK_ID: u32 = 1;
|
||||
/// Fragmented MP4 muxer — stub.
|
||||
///
|
||||
/// See the module-level doc comment for what is and isn't shipped in
|
||||
/// this stub.
|
||||
/// this stub. Fragment emission is not implemented:
|
||||
/// [`write_video`](Self::write_video) returns
|
||||
/// [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented)
|
||||
/// rather than discarding media.
|
||||
pub struct Fmp4Mux<W: Write> {
|
||||
writer: W,
|
||||
header_written: bool,
|
||||
/// Pending frames — held for the future fragment-emit path. The
|
||||
/// stub drops these on `finish` but keeping them around lets the
|
||||
/// post-stub work re-attach without changing the public API.
|
||||
pending: Vec<PendingSample>,
|
||||
/// hvcC bytes, if provided. Embedded in the `moov.…stsd.hvc1.hvcC`
|
||||
/// box once that path lands.
|
||||
/// box once the emission path lands.
|
||||
#[allow(dead_code)]
|
||||
codec_private: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct PendingSample {
|
||||
#[allow(dead_code)]
|
||||
pts_ns: i64,
|
||||
#[allow(dead_code)]
|
||||
keyframe: bool,
|
||||
#[allow(dead_code)]
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<W: Write> Fmp4Mux<W> {
|
||||
pub fn new(writer: W) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
header_written: false,
|
||||
pending: Vec::new(),
|
||||
codec_private: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the `HEVCDecoderConfigurationRecord` for the video track.
|
||||
/// The stub stores it but doesn't yet embed it in `moov` — that's
|
||||
/// part of the post-stub work.
|
||||
/// part of the unimplemented emission path.
|
||||
pub fn set_video_codec_private(&mut self, hvcc: Vec<u8>) {
|
||||
self.codec_private = Some(hvcc);
|
||||
}
|
||||
|
||||
/// Write one video PES frame.
|
||||
///
|
||||
/// **Stub behaviour:** the first call emits the init segment
|
||||
/// (`ftyp` + `moov`) so any consumer that just wants the shape can
|
||||
/// receive it. Subsequent calls accumulate frames in memory for
|
||||
/// the future fragmenting path; **no media bytes are written yet**.
|
||||
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
if !self.header_written {
|
||||
self.write_init_segment()?;
|
||||
self.header_written = true;
|
||||
/// Emit the init segment (`ftyp` + `moov`) once. Idempotent — a
|
||||
/// second call is a no-op. Lets a consumer that just wants the
|
||||
/// container shape obtain a valid (if sample-less) init segment.
|
||||
pub fn write_init_segment(&mut self) -> io::Result<()> {
|
||||
if self.header_written {
|
||||
return Ok(());
|
||||
}
|
||||
// TODO(0.22.0): emit one `moof+mdat` per GOP. For now stash the
|
||||
// frame so the future patch can hot-wire emission without API
|
||||
// churn.
|
||||
self.pending.push(PendingSample {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: data.to_vec(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush. The stub additionally drops accumulated `pending` frames.
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
// TODO(0.22.0): emit final fragment from pending; today the
|
||||
// stub just clears the buffer to release memory.
|
||||
self.pending.clear();
|
||||
self.writer.flush()
|
||||
}
|
||||
|
||||
fn write_init_segment(&mut self) -> io::Result<()> {
|
||||
let ftyp = build_ftyp();
|
||||
let moov = build_moov();
|
||||
self.writer.write_all(&ftyp)?;
|
||||
self.writer.write_all(&moov)?;
|
||||
self.header_written = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write one video PES frame.
|
||||
///
|
||||
/// **Stub:** `moof`/`mdat` emission is not implemented. To avoid
|
||||
/// silently dropping media (and avoid unbounded buffering), this
|
||||
/// emits the init segment on the first call and then returns
|
||||
/// [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented).
|
||||
/// No frame bytes are buffered or written.
|
||||
pub fn write_video(&mut self, _pts_ns: i64, _keyframe: bool, _data: &[u8]) -> io::Result<()> {
|
||||
self.write_init_segment()?;
|
||||
Err(crate::error::Error::Fmp4Unimplemented.into())
|
||||
}
|
||||
|
||||
/// Flush the underlying writer.
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `ftyp` box. `major_brand = "iso6"`, `minor_version = 1`,
|
||||
@@ -218,7 +203,9 @@ fn build_tkhd() -> Vec<u8> {
|
||||
for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] {
|
||||
body.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
// width / height in 16.16 fixed point — placeholder 1920x1080.
|
||||
// width / height in 16.16 fixed point — placeholder; replace with
|
||||
// SPS-derived dimensions (and matching stsd visual width/height) when
|
||||
// fragment emission lands.
|
||||
body.extend_from_slice(&(1920u32 << 16).to_be_bytes());
|
||||
body.extend_from_slice(&(1080u32 << 16).to_be_bytes());
|
||||
wrap_box(&TKHD, &body)
|
||||
@@ -291,7 +278,9 @@ fn build_dinf() -> Vec<u8> {
|
||||
fn build_stbl() -> Vec<u8> {
|
||||
// Stub stsd: empty sample description (zero entries). Replace with
|
||||
// hvc1+hvcC once the fragmenting path lands so the init segment is
|
||||
// actually decodable.
|
||||
// actually decodable. Must be populated together with build_mvex:
|
||||
// when the hvc1+hvcC sample entry lands and entry_count becomes 1,
|
||||
// the trex default_sample_description_index=1 becomes valid.
|
||||
let mut stsd_body = Vec::new();
|
||||
stsd_body.extend_from_slice(&[0, 0, 0, 0]);
|
||||
stsd_body.extend_from_slice(&0u32.to_be_bytes()); // entry_count
|
||||
@@ -317,6 +306,9 @@ fn build_stbl() -> Vec<u8> {
|
||||
|
||||
fn build_mvex() -> Vec<u8> {
|
||||
// trex: track_ID=1, default_sample_description_index=1, others=0.
|
||||
// dsdi=1 only becomes valid once build_stbl's stsd carries the
|
||||
// matching hvc1 sample entry (entry_count=1) — keep the two in sync
|
||||
// when fragment emission lands.
|
||||
let mut trex_body = Vec::new();
|
||||
trex_body.extend_from_slice(&[0, 0, 0, 0]); // version + flags
|
||||
trex_body.extend_from_slice(&VIDEO_TRACK_ID.to_be_bytes());
|
||||
@@ -331,9 +323,19 @@ fn build_mvex() -> Vec<u8> {
|
||||
/// Wrap a box body in `[size:u32-BE][type:4]`. Suitable for any body
|
||||
/// that fits in u32; oversized boxes (size > 4 GiB) need the 64-bit
|
||||
/// large-size extension which we don't generate in the stub.
|
||||
///
|
||||
/// All callers build tiny init-segment boxes (kilobytes at most), so the
|
||||
/// `u32` size never overflows; the saturating cast plus the debug assert
|
||||
/// documents and guards that invariant rather than silently emitting a
|
||||
/// truncated, structurally corrupt size field. `body` is always internally
|
||||
/// constructed here, never untrusted input — a future caller feeding a
|
||||
/// multi-gigabyte body trips the debug assert instead of writing a malformed
|
||||
/// box.
|
||||
fn wrap_box(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let size = (body.len() + 8) as u32;
|
||||
let mut out = Vec::with_capacity(body.len() + 8);
|
||||
let total = body.len() + 8;
|
||||
debug_assert!(total <= u32::MAX as usize, "fMP4 box exceeds u32 size");
|
||||
let size = u32::try_from(total).unwrap_or(u32::MAX);
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(box_type);
|
||||
out.extend_from_slice(body);
|
||||
@@ -355,9 +357,7 @@ mod tests {
|
||||
fn init_segment_starts_with_ftyp_then_moov() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = Fmp4Mux::new(&mut sink);
|
||||
// Trigger init emission via a single (stubbed) write.
|
||||
mux.write_video(0, true, &[0x00, 0x00, 0x00, 0x01, 0x40])
|
||||
.unwrap();
|
||||
mux.write_init_segment().unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
@@ -375,17 +375,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moov_contains_trak_mvex() {
|
||||
fn write_video_reports_unimplemented_and_buffers_nothing() {
|
||||
// write_video must NOT silently accept-and-drop media: it emits the
|
||||
// init segment, then signals that fragment emission is unimplemented.
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = Fmp4Mux::new(&mut sink);
|
||||
mux.write_video(0, true, &[]).unwrap();
|
||||
let err = mux.write_video(0, true, &[0xDE; 4096]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
mux.finish().unwrap();
|
||||
drop(sink);
|
||||
drop(mux);
|
||||
// Only the init segment (ftyp + moov) was written — no media bytes.
|
||||
let (ftyp_size, _) = read_box_header(&sink);
|
||||
let (moov_size, _) = read_box_header(&sink[ftyp_size as usize..]);
|
||||
assert_eq!(sink.len(), ftyp_size as usize + moov_size as usize);
|
||||
}
|
||||
|
||||
// Re-emit into a fresh buffer for parsing.
|
||||
#[test]
|
||||
fn moov_contains_trak_mvex() {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut mux2 = Fmp4Mux::new(&mut buf);
|
||||
mux2.write_video(0, true, &[]).unwrap();
|
||||
mux2.write_init_segment().unwrap();
|
||||
mux2.finish().unwrap();
|
||||
drop(mux2);
|
||||
|
||||
|
||||
+184
-23
@@ -69,19 +69,32 @@ impl<W: Write> HevcMux<W> {
|
||||
/// - Length-prefixed: `[u32-BE len][NAL bytes]` repeated. This is
|
||||
/// the form emitted by libfreemkv's HEVC parser (the MKV-native
|
||||
/// layout). Converted to Annex B.
|
||||
/// - Already Annex B: bytes containing `00 00 00 01` start codes
|
||||
/// anywhere in the buffer. Passed through unchanged.
|
||||
/// - Already Annex B: a buffer beginning with a `00 00 00 01` or
|
||||
/// `00 00 01` start code. Passed through unchanged.
|
||||
///
|
||||
/// `_pts_ns` is accepted for symmetry with other muxers but ignored
|
||||
/// — Annex B has no timing layer.
|
||||
pub fn write_frame(&mut self, _pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
||||
if !self.params_written {
|
||||
// Mark written *before* the write: a partial write that then
|
||||
// errors must not cause a later re-entry to re-emit the full
|
||||
// parameter set on top of the bytes the sink already
|
||||
// received (duplicate/split VPS/SPS/PPS). Callers discard the
|
||||
// mux on any write error.
|
||||
self.params_written = true;
|
||||
if let Some(cp) = &self.codec_private {
|
||||
if let Some(params) = hvcc_to_annex_b(cp) {
|
||||
self.writer.write_all(¶ms)?;
|
||||
match hvcc_to_annex_b(cp) {
|
||||
Some(params) => self.writer.write_all(¶ms)?,
|
||||
// A non-empty hvcC that yields no NAL is a caller
|
||||
// contract violation: emitting the stream without
|
||||
// VPS/SPS/PPS produces undecodable output. Surface it
|
||||
// rather than dropping the parameter sets silently.
|
||||
None if !cp.is_empty() => {
|
||||
return Err(crate::error::Error::HevcParamParse.into());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
self.params_written = true;
|
||||
}
|
||||
let annex_b = length_prefixed_to_annex_b(data);
|
||||
self.writer.write_all(&annex_b)
|
||||
@@ -107,15 +120,22 @@ impl<W: Write> HevcMux<W> {
|
||||
///
|
||||
/// We don't filter on NAL type — VPS (32), SPS (33), PPS (34), and any
|
||||
/// SEI arrays included in hvcC all get the same Annex B treatment.
|
||||
fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
///
|
||||
/// This is the single source of truth for hvcC → Annex B across all
|
||||
/// muxers (HEVC ES, BD-TS, standard MPEG-TS). Do not reimplement it.
|
||||
pub(crate) fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
if hvcc.len() < 23 {
|
||||
return None;
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 23;
|
||||
// Set when an inner loop exits on truncation so the outer loop stops
|
||||
// too — otherwise it would re-interpret mid-NAL bytes as the next
|
||||
// array header and synthesize spurious parameter-set NALs.
|
||||
let mut truncated = false;
|
||||
for _ in 0..num_arrays {
|
||||
if offset + 3 > hvcc.len() {
|
||||
if truncated || offset + 3 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
offset += 1; // array_completeness + nal_type byte
|
||||
@@ -123,13 +143,20 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
offset += 2;
|
||||
for _ in 0..num_nalus {
|
||||
if offset + 2 > hvcc.len() {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
if offset + nal_len > hvcc.len() {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
// ISO/IEC 14496-15 disallows zero-length NAL entries; emitting
|
||||
// a bare start code with no RBSP yields an invalid Annex B NAL.
|
||||
if nal_len == 0 {
|
||||
continue;
|
||||
}
|
||||
out.extend_from_slice(&START_CODE);
|
||||
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
|
||||
offset += nal_len;
|
||||
@@ -141,13 +168,42 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
/// Convert length-prefixed NAL units (`[u32-BE len][NAL]` repeated) to
|
||||
/// Annex B (`00 00 00 01 [NAL]` repeated).
|
||||
///
|
||||
/// If the input doesn't parse as length-prefixed (no valid lengths
|
||||
/// extracted), it's returned unchanged on the assumption that it's
|
||||
/// already Annex B — some upstream paths (raw HEVC ES from disc) pass
|
||||
/// Annex B straight through the PES layer.
|
||||
/// Already-Annex-B input (a buffer beginning with a `00 00 00 01` or
|
||||
/// `00 00 01` start code) is detected up front and passed through
|
||||
/// unchanged — some upstream paths (raw HEVC ES from disc) hand Annex B
|
||||
/// straight through the PES layer, and a genuine start code would
|
||||
/// otherwise be misread as a u32-BE length prefix.
|
||||
///
|
||||
/// Truncation policy (single source of truth across all muxers): if a
|
||||
/// length prefix runs past the end of the buffer (e.g. a NAL truncated
|
||||
/// by a bad disc sector), the truncated trailing NAL is dropped and only
|
||||
/// the valid Annex-B prefix accumulated so far is emitted. We never emit
|
||||
/// a half-NAL nor leak raw length-prefixed bytes into the Annex-B stream.
|
||||
pub(crate) fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
// Probe for a leading Annex B start code before attempting to parse
|
||||
// length prefixes: `00 00 00 01` would otherwise parse as length 1.
|
||||
if starts_with_start_code(data) {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() + (data.len() / 32));
|
||||
append_length_prefixed_as_annex_b(&mut out, data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Append the Annex B form of `data` (length-prefixed NALs) into `out`.
|
||||
///
|
||||
/// Same conversion as [`length_prefixed_to_annex_b`] but writes directly
|
||||
/// into a caller-owned buffer, avoiding an intermediate allocation on
|
||||
/// hot paths (e.g. per-frame video muxing). If `data` doesn't parse as
|
||||
/// length-prefixed (no NALs extracted), it's appended unchanged on the
|
||||
/// assumption it's already Annex B.
|
||||
pub(crate) fn append_length_prefixed_as_annex_b(out: &mut Vec<u8>, data: &[u8]) {
|
||||
let mut offset = 0;
|
||||
// True once we've consumed at least one well-formed length prefix
|
||||
// (even a zero-length one). Distinguishes "parsed as length-prefixed,
|
||||
// all NALs empty" (emit nothing) from "not length-prefixed at all"
|
||||
// (pass through as already-Annex B).
|
||||
let mut parsed_any = false;
|
||||
while offset + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([
|
||||
data[offset],
|
||||
@@ -157,19 +213,39 @@ pub(crate) fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
if offset + len > data.len() {
|
||||
// Mid-NAL truncation — fall through to the pass-through path
|
||||
// rather than emitting a half-NAL.
|
||||
return data.to_vec();
|
||||
// Mid-NAL truncation (e.g. a NAL cut by a bad disc sector) —
|
||||
// drop the truncated trailing NAL and emit only the valid
|
||||
// Annex-B prefix accumulated so far. We never emit a half-NAL
|
||||
// nor leak raw length-prefixed bytes into the Annex-B stream.
|
||||
break;
|
||||
}
|
||||
parsed_any = true;
|
||||
if len == 0 {
|
||||
// A zero-length prefix (e.g. pad bytes read off a damaged
|
||||
// sector) would otherwise emit a bare start code with no
|
||||
// RBSP — an invalid empty Annex B NAL. Skip it, mirroring
|
||||
// the `nal_len == 0` guard in `hvcc_to_annex_b` (ISO/IEC
|
||||
// 14496-15).
|
||||
continue;
|
||||
}
|
||||
out.extend_from_slice(&START_CODE);
|
||||
out.extend_from_slice(&data[offset..offset + len]);
|
||||
offset += len;
|
||||
}
|
||||
if out.is_empty() && !data.is_empty() {
|
||||
// No length prefixes found — input is likely already Annex B.
|
||||
return data.to_vec();
|
||||
if !parsed_any && !data.is_empty() {
|
||||
// No length prefixes parsed at all and no leading start code:
|
||||
// pass the bytes through rather than discard them (recover-100%
|
||||
// goal — a decoder can attempt its own resync; dropping them
|
||||
// guarantees loss). This is distinct from "parsed as length-
|
||||
// prefixed but every NAL was zero-length", which emits nothing.
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `data` begins with a 4-byte (`00 00 00 01`) or 3-byte
|
||||
/// (`00 00 01`) Annex B start code.
|
||||
fn starts_with_start_code(data: &[u8]) -> bool {
|
||||
data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -203,18 +279,103 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_nal_truncation_returns_original() {
|
||||
// `[u32-BE 100][only 3 bytes]` — length prefix claims 100 bytes
|
||||
// but the input only has 3 after the prefix. We treat that as
|
||||
// malformed and pass the original buffer through so receivers
|
||||
// can attempt their own recovery.
|
||||
fn mid_nal_truncation_drops_trailing_nal_keeps_prefix() {
|
||||
// First NAL is valid (2-byte payload), second has a length prefix
|
||||
// claiming 100 bytes with only 3 present. Policy: emit the valid
|
||||
// first NAL as Annex B, drop the truncated trailing NAL — never
|
||||
// leak raw length-prefixed bytes into the Annex B stream.
|
||||
let mut raw = Vec::new();
|
||||
raw.extend_from_slice(&2u32.to_be_bytes());
|
||||
raw.extend_from_slice(&[0x11, 0x22]);
|
||||
raw.extend_from_slice(&100u32.to_be_bytes());
|
||||
raw.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
let got = length_prefixed_to_annex_b(&raw);
|
||||
let want = [0x00, 0x00, 0x00, 0x01, 0x11, 0x22];
|
||||
assert_eq!(&got[..], &want[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_annex_b_start_code_passes_through() {
|
||||
// Genuine Annex B beginning with 00 00 00 01 must NOT be reframed:
|
||||
// the start code would otherwise parse as a u32-BE length of 1.
|
||||
let raw = [
|
||||
0x00, 0x00, 0x00, 0x01, 0x26, 0x01, 0xDE, 0xAD, // NAL 1
|
||||
0x00, 0x00, 0x00, 0x01, 0x02, 0x01, 0xBE, 0xEF, // NAL 2
|
||||
];
|
||||
let got = length_prefixed_to_annex_b(&raw);
|
||||
assert_eq!(
|
||||
&got[..],
|
||||
&raw[..],
|
||||
"Annex B input must pass through verbatim"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_three_byte_start_code_passes_through() {
|
||||
let raw = [0x00, 0x00, 0x01, 0x26, 0x01, 0xDE, 0xAD];
|
||||
let got = length_prefixed_to_annex_b(&raw);
|
||||
assert_eq!(&got[..], &raw[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_skips_zero_length_nal_entries() {
|
||||
// hvcC with one array containing a zero-length NAL followed by a
|
||||
// valid one: the zero-length entry must be skipped, not emitted as
|
||||
// a bare start code.
|
||||
let mut hvcc = vec![0u8; 22];
|
||||
hvcc.push(1); // numArrays
|
||||
hvcc.push(33); // SPS
|
||||
hvcc.extend_from_slice(&2u16.to_be_bytes()); // numNalus = 2
|
||||
hvcc.extend_from_slice(&0u16.to_be_bytes()); // NAL 0: length 0
|
||||
hvcc.extend_from_slice(&3u16.to_be_bytes()); // NAL 1: length 3
|
||||
hvcc.extend_from_slice(&[0x42, 0x01, 0x01]);
|
||||
let annex_b = hvcc_to_annex_b(&hvcc).expect("one valid NAL");
|
||||
let want = [0x00, 0x00, 0x00, 0x01, 0x42, 0x01, 0x01];
|
||||
assert_eq!(&annex_b[..], &want[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_frame_errors_on_unparseable_non_empty_hvcc() {
|
||||
// A non-empty hvcC that yields no NAL must surface an error
|
||||
// instead of silently producing a parameter-set-less stream.
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = HevcMux::new(&mut sink);
|
||||
mux.set_codec_private(vec![0xDE, 0xAD]); // too short to be valid hvcC
|
||||
let err = mux.write_frame(0, &[]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_length_nal_is_skipped_not_bare_start_code() {
|
||||
// A zero-length prefix between two real NALs must be skipped, not
|
||||
// turned into a bare `00 00 00 01` with no RBSP.
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&3u32.to_be_bytes());
|
||||
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes()); // zero-length NAL
|
||||
buf.extend_from_slice(&2u32.to_be_bytes());
|
||||
buf.extend_from_slice(&[0xDD, 0xEE]);
|
||||
|
||||
let got = length_prefixed_to_annex_b(&buf);
|
||||
let want = [
|
||||
0x00, 0x00, 0x00, 0x01, 0xAA, 0xBB, 0xCC, // first NAL
|
||||
0x00, 0x00, 0x00, 0x01, 0xDD, 0xEE, // second NAL (zero-length skipped)
|
||||
];
|
||||
assert_eq!(&got[..], &want[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_zero_length_nals_emit_nothing() {
|
||||
// A buffer of only zero-length prefixes parses as length-prefixed
|
||||
// but yields no NALs — output must be empty, not a pass-through of
|
||||
// the raw zero bytes.
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
let got = length_prefixed_to_annex_b(&buf);
|
||||
assert!(got.is_empty(), "expected empty output, got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_extracts_vps_sps_pps() {
|
||||
// Build a minimal-but-valid hvcC: 22-byte header, then 3 arrays
|
||||
|
||||
+18
-7
@@ -22,11 +22,13 @@ impl M2tsStream {
|
||||
/// Create for writing PES frames → BD-TS output.
|
||||
/// Writes FMKV metadata header, then muxes PES frames into BD transport stream.
|
||||
pub fn create(mut writer: impl Write + Send + 'static, title: &DiscTitle) -> io::Result<Self> {
|
||||
// Write FMKV metadata header
|
||||
if !title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(title);
|
||||
meta::write_header(&mut writer, &m)?;
|
||||
}
|
||||
// Write FMKV metadata header unconditionally. An empty streams
|
||||
// array is valid JSON and round-trips fine; skipping the header
|
||||
// for a zero-stream title would make the output indistinguishable
|
||||
// from a non-FMKV file on read-back (read_header returns
|
||||
// Ok(None) → PMT fallback) even though M2tsStream produced it.
|
||||
let m = meta::M2tsMeta::from_title(title);
|
||||
meta::write_header(&mut writer, &m)?;
|
||||
let pids: Vec<u16> = title
|
||||
.streams
|
||||
.iter()
|
||||
@@ -39,8 +41,14 @@ impl M2tsStream {
|
||||
let boxed: Box<dyn Write + Send> = Box::new(writer);
|
||||
let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids);
|
||||
for (i, cp) in title.codec_privates.iter().enumerate() {
|
||||
// codec_privates is parallel to streams/pids; ignore any
|
||||
// trailing entries that exceed the track count rather than
|
||||
// surfacing a track-range error for a benign metadata overrun.
|
||||
if i >= pids.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(data) = cp {
|
||||
muxer.set_codec_private(i, data.clone());
|
||||
muxer.set_codec_private(i, data.clone())?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -178,8 +186,11 @@ mod tests {
|
||||
let ts_bytes = &buf[header_end..];
|
||||
|
||||
// Find first PUSI packet on VIDEO_PID; verify RAI in AF flags.
|
||||
// chunks_exact drops any partial trailing chunk — only whole
|
||||
// 192-byte BD-TS packets are valid, and it avoids OOB indexing on a
|
||||
// short final chunk.
|
||||
let pkt = ts_bytes
|
||||
.chunks(192)
|
||||
.chunks_exact(192)
|
||||
.find(|p| {
|
||||
let h = &p[4..];
|
||||
let pid = (((h[1] & 0x1F) as u16) << 8) | h[2] as u16;
|
||||
|
||||
+152
-68
@@ -76,9 +76,14 @@ const PCR_INTERVAL_PACKETS: u64 = 40;
|
||||
/// the picture it timestamps. 200 ms in 90 kHz ticks.
|
||||
const PCR_LEAD_90KHZ: u64 = 90_000 / 5;
|
||||
|
||||
/// Stream-type codes from ISO/IEC 13818-1 Table 2-29 + later amendments.
|
||||
/// HEVC stream-type code, ISO/IEC 13818-1 Table 2-34 (2015 amendment).
|
||||
const STREAM_TYPE_HEVC: u8 = 0x24;
|
||||
/// AC-3 / E-AC-3. Not an ISO assignment — sits in the user-private
|
||||
/// 0x80-0xFF range and is the Blu-ray Disc Association / ATSC A/52
|
||||
/// convention.
|
||||
const STREAM_TYPE_AC3: u8 = 0x81;
|
||||
/// Dolby TrueHD. Also a private/BD-conventional value in the
|
||||
/// user-private 0x80-0xFF range, not an ISO assignment.
|
||||
const STREAM_TYPE_TRUEHD: u8 = 0x83;
|
||||
|
||||
/// Audio codec hint for [`M2tsMux::new`] / [`M2tsMux::set_audio`]. The
|
||||
@@ -126,6 +131,10 @@ pub struct M2tsMux<W: Write> {
|
||||
packets_written: u64,
|
||||
/// Video packets written since last PCR, used to gate PCR cadence.
|
||||
video_packets_since_pcr: u64,
|
||||
/// Set once the first video TS packet has been emitted. Forces a PCR
|
||||
/// onto the very first video PES so a receiver tuning at stream start
|
||||
/// has a clock reference (the PMT advertises the video PID as PCR_PID).
|
||||
first_video_written: bool,
|
||||
}
|
||||
|
||||
impl<W: Write> M2tsMux<W> {
|
||||
@@ -145,6 +154,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
cc_pmt: 0,
|
||||
packets_written: 0,
|
||||
video_packets_since_pcr: 0,
|
||||
first_video_written: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +176,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
/// PES (and gates codec_private NAL prepending — those only attach to
|
||||
/// the first keyframe).
|
||||
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
let pts_90k = self.base_relative_pts(pts_ns);
|
||||
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true);
|
||||
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
|
||||
// first frame so we don't underflow.
|
||||
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
|
||||
@@ -177,16 +187,15 @@ impl<W: Write> M2tsMux<W> {
|
||||
let mut es = Vec::with_capacity(data.len() + 64);
|
||||
if keyframe && !self.params_written {
|
||||
if let Some(cp) = &self.video_codec_private {
|
||||
let payload = hvcc_payload(cp);
|
||||
if !payload.is_empty() {
|
||||
let params = super::hevc::length_prefixed_to_annex_b(&payload);
|
||||
if let Some(params) = super::hevc::hvcc_to_annex_b(cp) {
|
||||
es.extend_from_slice(¶ms);
|
||||
}
|
||||
}
|
||||
self.params_written = true;
|
||||
}
|
||||
let annex_b = super::hevc::length_prefixed_to_annex_b(data);
|
||||
es.extend_from_slice(&annex_b);
|
||||
// Append the Annex-B form directly into the pre-sized `es`
|
||||
// buffer rather than materializing an intermediate Vec.
|
||||
super::hevc::append_length_prefixed_as_annex_b(&mut es, data);
|
||||
|
||||
let pes = build_video_pes(pts_90k, &es);
|
||||
self.write_pes(PID_VIDEO, &pes, Some(pcr), keyframe)
|
||||
@@ -200,7 +209,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
if self.audio.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pts_90k = self.base_relative_pts(pts_ns);
|
||||
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ false);
|
||||
let pes = build_audio_pes(pts_90k, data);
|
||||
self.write_pes(PID_AUDIO, &pes, None, false)
|
||||
}
|
||||
@@ -212,15 +221,24 @@ impl<W: Write> M2tsMux<W> {
|
||||
}
|
||||
|
||||
/// Convert input PTS (nanoseconds) to 90 kHz ticks rebased on the
|
||||
/// first frame's PTS. Saturating at 0 keeps the math friendly when
|
||||
/// frames arrive slightly out of decode order.
|
||||
fn base_relative_pts(&mut self, pts_ns: i64) -> u64 {
|
||||
/// stream's PTS origin. The origin is seeded ONLY by the first video
|
||||
/// frame (`may_seed_base == true`); audio frames never seed it. This
|
||||
/// keeps the audio/video offset intact: a leading audio frame can't
|
||||
/// pull the base up and collapse the first/lowest-PTS video frame to 0.
|
||||
/// Frames earlier than the base saturate to 0.
|
||||
fn base_relative_pts(&mut self, pts_ns: i64, may_seed_base: bool) -> u64 {
|
||||
let raw_90k = if pts_ns > 0 {
|
||||
(pts_ns as u64) * 9 / 100_000
|
||||
// Widen to u128 so adversarial timestamps can't overflow the
|
||||
// intermediate multiply (pts_ns * 9 exceeds u64 above
|
||||
// ~2.05e18 ns), then clamp to the 33-bit PTS range.
|
||||
(((pts_ns as u128) * 9 / 100_000) as u64) & 0x1_FFFF_FFFF
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let base = *self.base_pts_90k.get_or_insert(raw_90k);
|
||||
if may_seed_base {
|
||||
self.base_pts_90k.get_or_insert(raw_90k);
|
||||
}
|
||||
let base = self.base_pts_90k.unwrap_or(raw_90k);
|
||||
raw_90k.saturating_sub(base)
|
||||
}
|
||||
|
||||
@@ -255,10 +273,13 @@ impl<W: Write> M2tsMux<W> {
|
||||
while offset < pes.len() {
|
||||
self.maybe_emit_psi()?;
|
||||
|
||||
// Force a PCR on the FIRST video PES (PAT+PMT precede it, so
|
||||
// `packets_written` is never 0 here) so a receiver tuning at
|
||||
// stream start has the clock reference the PMT promises.
|
||||
let attach_pcr = first
|
||||
&& (pid == PID_VIDEO)
|
||||
&& (pcr.is_some())
|
||||
&& (self.packets_written == 0
|
||||
&& (!self.first_video_written
|
||||
|| self.video_packets_since_pcr >= PCR_INTERVAL_PACKETS);
|
||||
|
||||
// RAI rides only the FIRST packet of a keyframe video PES.
|
||||
@@ -282,9 +303,16 @@ impl<W: Write> M2tsMux<W> {
|
||||
// When AF body is empty we can still skip the AF entirely
|
||||
// and get the full 184 B; only invoke the AF when we'd
|
||||
// otherwise need stuffing.
|
||||
//
|
||||
// Per ISO/IEC 13818-1 Table 2-6, when an adaptation field is
|
||||
// present its first body byte is the mandatory 8-bit flags
|
||||
// byte. A stuffing-only field still needs that flags byte
|
||||
// (all flags 0) — omitting it would make a strict decoder
|
||||
// read the first 0xFF stuffing byte as flags (PCR_flag=1,
|
||||
// …) and parse a phantom PCR out of the stuffing/payload.
|
||||
let (af_present, payload_len, stuffing): (bool, usize, usize) = if !af_body.is_empty() {
|
||||
// AF is mandatory (PCR). 1 byte length + body + stuffing
|
||||
// + payload = 184.
|
||||
// AF is mandatory (PCR, RAI, …). 1 byte length + body +
|
||||
// stuffing + payload = 184.
|
||||
let max_payload = 184 - 1 - af_body.len();
|
||||
let p = remaining.min(max_payload);
|
||||
let s = max_payload - p;
|
||||
@@ -293,11 +321,13 @@ impl<W: Write> M2tsMux<W> {
|
||||
// Full payload packet — no AF at all.
|
||||
(false, 184, 0)
|
||||
} else {
|
||||
// Last (small) packet — stuff via empty AF.
|
||||
// 1 byte length + 0 body + stuffing + payload = 184.
|
||||
let max_payload = 183;
|
||||
// Last (small) packet — stuff via an AF whose body is a
|
||||
// single zero-flags byte. 1 byte length + 1 flags byte +
|
||||
// stuffing + payload = 184, so payload caps at 182.
|
||||
let max_payload = 182;
|
||||
let p = remaining.min(max_payload);
|
||||
let s = max_payload - p;
|
||||
af_body.push(0x00); // zero-flags byte
|
||||
(true, p, s)
|
||||
};
|
||||
|
||||
@@ -305,14 +335,14 @@ impl<W: Write> M2tsMux<W> {
|
||||
let mut packet = Packet::new();
|
||||
packet.set_header(pid, first, true, af_present, cc);
|
||||
if af_present {
|
||||
packet.append_adaptation(&af_body, stuffing);
|
||||
packet.append_adaptation(&af_body, stuffing)?;
|
||||
}
|
||||
packet.append_payload(&pes[offset..offset + payload_len]);
|
||||
debug_assert_eq!(packet.len(), 188, "packet not 188 bytes");
|
||||
packet.append_payload(&pes[offset..offset + payload_len])?;
|
||||
self.out.write_packet(&packet)?;
|
||||
|
||||
self.packets_written += 1;
|
||||
if pid == PID_VIDEO {
|
||||
self.first_video_written = true;
|
||||
if attach_pcr {
|
||||
self.video_packets_since_pcr = 0;
|
||||
} else {
|
||||
@@ -351,7 +381,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
let cc = self.advance_cc(PID_PAT);
|
||||
let mut packet = Packet::new();
|
||||
packet.set_header(PID_PAT, true, true, false, cc);
|
||||
packet.append_payload(&payload);
|
||||
packet.append_payload(&payload)?;
|
||||
packet.pad_to_188();
|
||||
self.out.write_packet(&packet)?;
|
||||
self.packets_written += 1;
|
||||
@@ -363,7 +393,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
let cc = self.advance_cc(PID_PMT);
|
||||
let mut packet = Packet::new();
|
||||
packet.set_header(PID_PMT, true, true, false, cc);
|
||||
packet.append_payload(&payload);
|
||||
packet.append_payload(&payload)?;
|
||||
packet.pad_to_188();
|
||||
self.out.write_packet(&packet)?;
|
||||
self.packets_written += 1;
|
||||
@@ -371,42 +401,6 @@ impl<W: Write> M2tsMux<W> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the raw hvcC bytes for handoff to `length_prefixed_to_annex_b`.
|
||||
/// hvcC layout: 22-byte fixed header, then `numOfArrays` arrays of
|
||||
/// `(nalType, numNalus, [nalLength:u16, NAL bytes]…)`. We convert this
|
||||
/// directly to a length-prefixed byte stream (NAL length is u16 in
|
||||
/// hvcC; widen to u32 for the standard length-prefixed encoding).
|
||||
fn hvcc_payload(hvcc: &[u8]) -> Vec<u8> {
|
||||
if hvcc.len() < 23 {
|
||||
return Vec::new();
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 23;
|
||||
for _ in 0..num_arrays {
|
||||
if offset + 3 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
offset += 1;
|
||||
let num_nalus = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
for _ in 0..num_nalus {
|
||||
if offset + 2 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
if offset + nal_len > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&(nal_len as u32).to_be_bytes());
|
||||
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
|
||||
offset += nal_len;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a PES packet for a video access unit.
|
||||
fn build_video_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
|
||||
build_pes_packet(0xE0, pts_90k, es, /* length_in_header */ false)
|
||||
@@ -414,9 +408,11 @@ fn build_video_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
|
||||
|
||||
/// Build a PES packet for an audio access unit.
|
||||
fn build_audio_pes(pts_90k: u64, es: &[u8]) -> Vec<u8> {
|
||||
// Audio PES: length is fillable when it fits in u16. We always
|
||||
// write the length so receivers don't have to scan for the next
|
||||
// start code.
|
||||
// Audio PES: a bounded length is written whenever the PES fits in a
|
||||
// u16 (the common case), so receivers don't have to scan for the next
|
||||
// start code. For an access unit larger than ~64 KiB (rare — e.g. a
|
||||
// large TrueHD frame) the length field falls back to the unbounded
|
||||
// (0x0000) form, which most demuxers tolerate for private_stream_1.
|
||||
build_pes_packet(0xBD, pts_90k, es, /* length_in_header */ true)
|
||||
}
|
||||
|
||||
@@ -631,6 +627,30 @@ mod tests {
|
||||
assert!(pids.iter().any(|p| *p == PID_VIDEO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_video_pes_carries_pcr() {
|
||||
// A receiver tuning at stream start needs the clock reference the
|
||||
// PMT promises (video PID = PCR_PID). The very first video PES must
|
||||
// therefore carry a PCR even though PAT+PMT precede it.
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&4u32.to_be_bytes());
|
||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, true, &frame).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
// First PUSI video packet must carry an AF with the PCR flag (0x10).
|
||||
let pkt = sink
|
||||
.chunks(188)
|
||||
.find(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
|
||||
.expect("video PUSI packet exists");
|
||||
let af = af_body(pkt).expect("first video PES must carry an adaptation field");
|
||||
assert!(!af.is_empty(), "AF flags byte present");
|
||||
assert_eq!(af[0] & 0x10, 0x10, "PCR flag set on first video PES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_track_appears_in_pmt_and_stream() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
@@ -714,6 +734,69 @@ mod tests {
|
||||
Some(packet[5..5 + af_len].to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stuffing_only_tail_packet_is_spec_valid() {
|
||||
// A short final PES packet must stuff via an adaptation field
|
||||
// whose first body byte is the mandatory zero-flags byte (per
|
||||
// ISO/IEC 13818-1 Table 2-6), never a bare 0xFF stuffing byte
|
||||
// that a decoder would misread as PCR/OPCR/etc. flags.
|
||||
//
|
||||
// Use an AUDIO PES (no PCR, no RAI on its tail) so the only AF on
|
||||
// the last packet is the stuffing-only field under test. The PES
|
||||
// is sized so its final TS packet is short (< 184 payload bytes).
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
mux.set_audio(AudioCodec::Ac3);
|
||||
// Drive a keyframe first so the stream is well-formed, then the
|
||||
// audio frame whose tail is short.
|
||||
let mut vframe = Vec::new();
|
||||
vframe.extend_from_slice(&4u32.to_be_bytes());
|
||||
vframe.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, true, &vframe).unwrap();
|
||||
// 200-byte audio payload → PES > 184 → spills into a short tail.
|
||||
let audio: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
|
||||
mux.write_audio(20_000_000, &audio).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
assert_ts_well_formed(&sink);
|
||||
|
||||
// Find every audio packet that carries an adaptation field; the
|
||||
// short tail packet is one of them. Each such AF must have a
|
||||
// length >= 1 and a zero-flags body byte (not 0xFF).
|
||||
let mut saw_stuffing_af = false;
|
||||
for pkt in sink.chunks(188) {
|
||||
let pid = u16::from_be_bytes([pkt[1] & 0x1F, pkt[2]]);
|
||||
if pid != PID_AUDIO {
|
||||
continue;
|
||||
}
|
||||
let afc = (pkt[3] >> 4) & 0x03;
|
||||
if afc & 0b10 == 0 {
|
||||
continue; // no AF on this packet
|
||||
}
|
||||
let af_len = pkt[4] as usize;
|
||||
assert!(
|
||||
af_len >= 1,
|
||||
"stuffing AF must include the mandatory flags byte"
|
||||
);
|
||||
// First AF body byte is the flags byte — must be zero, never
|
||||
// a 0xFF stuffing byte masquerading as flags.
|
||||
assert_eq!(
|
||||
pkt[5], 0x00,
|
||||
"stuffing-only AF flags byte must be 0x00, not 0x{:02X}",
|
||||
pkt[5]
|
||||
);
|
||||
// adaptation_field_length + payload must fill exactly 184.
|
||||
// (4 header + 1 AF-length + af_len + payload = 188.)
|
||||
assert!(af_len <= 183, "AF length overflows the 184-byte body");
|
||||
saw_stuffing_af = true;
|
||||
}
|
||||
assert!(
|
||||
saw_stuffing_af,
|
||||
"expected at least one audio packet with a stuffing AF"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rai_set_on_keyframe_pes_packet() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
@@ -792,11 +875,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keyframe_video_with_pcr_combines_flags() {
|
||||
// PCR attaches only when video_packets_since_pcr >=
|
||||
// PCR_INTERVAL_PACKETS (40). The very first video packet emits a
|
||||
// PAT+PMT first, so packets_written != 0 and attach_pcr is false on
|
||||
// frame 0. We push: keyframe (no PCR) → many non-key (drives the
|
||||
// PCR counter past the interval) → second keyframe (PCR + RAI).
|
||||
// The first video PES carries a PCR (and RAI) and resets the PCR
|
||||
// counter. After that, PCR re-attaches only when
|
||||
// video_packets_since_pcr >= PCR_INTERVAL_PACKETS (40). We push:
|
||||
// keyframe (PCR+RAI, counter reset) → many non-key (drives the
|
||||
// counter past the interval) → second keyframe whose PUSI combines
|
||||
// RAI (keyframe) and PCR (counter exceeded).
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
let mut small = Vec::new();
|
||||
|
||||
+104
-26
@@ -4,26 +4,50 @@
|
||||
//! byte layout so the parent module can compose PSI / PCR / PES bytes
|
||||
//! without each caller re-implementing the 188-byte boundary math.
|
||||
|
||||
use crate::error::Error;
|
||||
use std::io::{self, Write};
|
||||
|
||||
const TS_PACKET_SIZE: usize = 188;
|
||||
/// Header is 4 bytes, leaving 184 bytes for the adaptation field area
|
||||
/// plus payload. With a 1-byte `adaptation_field_length` prefix the
|
||||
/// field body + stuffing can be at most 183 bytes.
|
||||
const MAX_AF_LEN: usize = TS_PACKET_SIZE - 4 - 1;
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
const STUFF_BYTE: u8 = 0xFF;
|
||||
|
||||
/// One TS packet under construction. Always emits 188 bytes when
|
||||
/// [`pad_to_188`](Self::pad_to_188) is called; if it's not called the
|
||||
/// caller is responsible for filling the packet exactly.
|
||||
/// One TS packet under construction. Backed by a fixed 188-byte array
|
||||
/// with a write cursor — no per-packet heap allocation. Always emits 188
|
||||
/// bytes when [`pad_to_188`](Self::pad_to_188) is called; if it's not
|
||||
/// called the caller is responsible for filling the packet exactly.
|
||||
pub(super) struct Packet {
|
||||
buf: Vec<u8>,
|
||||
buf: [u8; TS_PACKET_SIZE],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(TS_PACKET_SIZE),
|
||||
buf: [0u8; TS_PACKET_SIZE],
|
||||
len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a byte, saturating at the packet boundary. The boundary is
|
||||
/// never reached by the sole caller (mod.rs sizes every field to sum
|
||||
/// to 188); the bound prevents a future caller from corrupting memory.
|
||||
fn push(&mut self, b: u8) {
|
||||
if self.len < TS_PACKET_SIZE {
|
||||
self.buf[self.len] = b;
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn extend(&mut self, bytes: &[u8]) {
|
||||
let n = bytes.len().min(TS_PACKET_SIZE - self.len);
|
||||
self.buf[self.len..self.len + n].copy_from_slice(&bytes[..n]);
|
||||
self.len += n;
|
||||
}
|
||||
|
||||
/// Write the 4-byte TS packet header.
|
||||
///
|
||||
/// * `pid` — 13-bit PID
|
||||
@@ -39,12 +63,12 @@ impl Packet {
|
||||
has_adaptation: bool,
|
||||
cc: u8,
|
||||
) {
|
||||
self.buf.clear();
|
||||
self.buf.push(SYNC_BYTE);
|
||||
self.len = 0;
|
||||
self.push(SYNC_BYTE);
|
||||
let pus_bit = if payload_unit_start { 0x40 } else { 0 };
|
||||
// transport_error_indicator(1)=0 | payload_unit_start(1) | transport_priority(1)=0 | PID(5 high)
|
||||
self.buf.push(pus_bit | ((pid >> 8) as u8 & 0x1F));
|
||||
self.buf.push(pid as u8);
|
||||
self.push(pus_bit | ((pid >> 8) as u8 & 0x1F));
|
||||
self.push(pid as u8);
|
||||
// transport_scrambling_control(2)=0 | adaptation_field_control(2) | continuity_counter(4)
|
||||
let afc = match (has_adaptation, has_payload) {
|
||||
(false, false) => 0b00, // reserved — should not happen
|
||||
@@ -52,7 +76,7 @@ impl Packet {
|
||||
(true, false) => 0b10, // adaptation only
|
||||
(true, true) => 0b11, // both
|
||||
};
|
||||
self.buf.push((afc << 4) | (cc & 0x0F));
|
||||
self.push((afc << 4) | (cc & 0x0F));
|
||||
}
|
||||
|
||||
/// Append the adaptation field after the header.
|
||||
@@ -62,20 +86,36 @@ impl Packet {
|
||||
/// append after the body. The first byte of the field
|
||||
/// (`adaptation_field_length`) is computed here from
|
||||
/// `body.len() + stuffing`.
|
||||
pub(super) fn append_adaptation(&mut self, body: &[u8], stuffing: usize) {
|
||||
///
|
||||
/// Returns [`Error::M2tsPacketMalformed`] if the computed
|
||||
/// `adaptation_field_length` would exceed `MAX_AF_LEN` — the length
|
||||
/// byte and the bytes actually written must always agree, so an
|
||||
/// over-long field is rejected rather than written with a clamped
|
||||
/// (and therefore lying) length byte.
|
||||
pub(super) fn append_adaptation(&mut self, body: &[u8], stuffing: usize) -> io::Result<()> {
|
||||
let af_len = body.len() + stuffing;
|
||||
debug_assert!(af_len <= 183, "adaptation field overflow");
|
||||
self.buf.push(af_len as u8);
|
||||
self.buf.extend_from_slice(body);
|
||||
for _ in 0..stuffing {
|
||||
self.buf.push(STUFF_BYTE);
|
||||
if af_len > MAX_AF_LEN {
|
||||
return Err(Error::M2tsPacketMalformed.into());
|
||||
}
|
||||
self.push(af_len as u8);
|
||||
self.extend(body);
|
||||
for _ in 0..stuffing {
|
||||
self.push(STUFF_BYTE);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append payload bytes.
|
||||
pub(super) fn append_payload(&mut self, payload: &[u8]) {
|
||||
self.buf.extend_from_slice(payload);
|
||||
debug_assert!(self.buf.len() <= TS_PACKET_SIZE, "packet overflow");
|
||||
///
|
||||
/// Returns [`Error::M2tsPacketMalformed`] if doing so would push the
|
||||
/// packet past 188 bytes — overflow is a muxer invariant break, not
|
||||
/// something to silently emit.
|
||||
pub(super) fn append_payload(&mut self, payload: &[u8]) -> io::Result<()> {
|
||||
if self.len + payload.len() > TS_PACKET_SIZE {
|
||||
return Err(Error::M2tsPacketMalformed.into());
|
||||
}
|
||||
self.extend(payload);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pad the packet to exactly 188 bytes with `0xFF` bytes — used by
|
||||
@@ -83,21 +123,24 @@ impl Packet {
|
||||
/// For PSI packets only — payload-carrying packets reserve room for
|
||||
/// stuffing via `append_adaptation`.
|
||||
pub(super) fn pad_to_188(&mut self) {
|
||||
while self.buf.len() < TS_PACKET_SIZE {
|
||||
self.buf.push(STUFF_BYTE);
|
||||
while self.len < TS_PACKET_SIZE {
|
||||
self.push(STUFF_BYTE);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
&self.buf[..self.len]
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.buf.len()
|
||||
self.len
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffered writer for assembled TS packets. Owns the underlying sink.
|
||||
/// Writer for assembled TS packets. Owns the underlying sink and writes
|
||||
/// each 188-byte packet straight through — it adds no buffering of its
|
||||
/// own, so callers that need buffering should wrap the sink in a
|
||||
/// `BufWriter`.
|
||||
pub(super) struct PacketWriter<W: Write> {
|
||||
inner: W,
|
||||
}
|
||||
@@ -109,7 +152,12 @@ impl<W: Write> PacketWriter<W> {
|
||||
|
||||
pub(super) fn write_packet(&mut self, packet: &Packet) -> io::Result<()> {
|
||||
let bytes = packet.bytes();
|
||||
debug_assert_eq!(bytes.len(), TS_PACKET_SIZE);
|
||||
// Hard check, not a debug_assert: a non-188-byte packet would
|
||||
// corrupt the transport stream, so refuse to write it in any
|
||||
// build rather than emitting a short/long packet silently.
|
||||
if bytes.len() != TS_PACKET_SIZE {
|
||||
return Err(Error::M2tsPacketMalformed.into());
|
||||
}
|
||||
self.inner.write_all(bytes)
|
||||
}
|
||||
|
||||
@@ -126,7 +174,7 @@ mod tests {
|
||||
fn pad_fills_to_188() {
|
||||
let mut p = Packet::new();
|
||||
p.set_header(0x100, true, true, false, 0);
|
||||
p.append_payload(&[1, 2, 3]);
|
||||
p.append_payload(&[1, 2, 3]).unwrap();
|
||||
p.pad_to_188();
|
||||
assert_eq!(p.bytes().len(), 188);
|
||||
assert_eq!(p.bytes()[0], SYNC_BYTE);
|
||||
@@ -134,6 +182,36 @@ mod tests {
|
||||
assert_eq!(p.bytes()[7], STUFF_BYTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_adaptation_rejects_overflow() {
|
||||
let mut p = Packet::new();
|
||||
p.set_header(0x100, true, true, true, 0);
|
||||
// body(1) + stuffing(MAX_AF_LEN) = MAX_AF_LEN + 1 > MAX_AF_LEN.
|
||||
let err = p.append_adaptation(&[0x00], MAX_AF_LEN).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_payload_rejects_overflow() {
|
||||
let mut p = Packet::new();
|
||||
p.set_header(0x100, true, true, false, 0);
|
||||
// 4-byte header + 185 payload = 189 > 188.
|
||||
let err = p.append_payload(&[0u8; 185]).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_packet_rejects_short_packet() {
|
||||
let mut p = Packet::new();
|
||||
p.set_header(0x100, true, true, false, 0);
|
||||
p.append_payload(&[1, 2, 3]).unwrap(); // only 7 bytes, not padded
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut w = PacketWriter::new(&mut sink);
|
||||
let err = w.write_packet(&p).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(sink.is_empty(), "short packet must not be written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_pid_round_trips() {
|
||||
let mut p = Packet::new();
|
||||
|
||||
+221
-30
@@ -21,9 +21,17 @@ fn color_space_from_hdr(hdr: HdrFormat) -> ColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
/// Magic bytes: "FMKV" + 1 reserved byte + version (=1) + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
|
||||
/// Highest header format version this build understands. A header tagged with
|
||||
/// a newer version is rejected so older readers cleanly refuse incompatible
|
||||
/// formats instead of silently mis-parsing them as v1.
|
||||
const SUPPORTED_VERSION: u8 = 1;
|
||||
|
||||
/// Index of the version byte within [`MAGIC`].
|
||||
const VERSION_BYTE: usize = 5;
|
||||
|
||||
/// BD-TS packet size (header must be padded to this boundary).
|
||||
const PACKET_SIZE: usize = 192;
|
||||
|
||||
@@ -83,6 +91,11 @@ pub enum MetaStream {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
secondary: bool,
|
||||
/// Base64-encoded codec initialization data. Absent for codecs that
|
||||
/// carry none. Without this, a remux driven from an FMKV header would
|
||||
/// emit audio tracks missing their init data versus a direct rip.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
},
|
||||
#[serde(rename = "subtitle")]
|
||||
Subtitle {
|
||||
@@ -92,6 +105,9 @@ pub enum MetaStream {
|
||||
language: String,
|
||||
#[serde(default)]
|
||||
forced: bool,
|
||||
/// Base64-encoded codec initialization data (e.g. VobSub idx palette).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,6 +115,16 @@ impl M2tsMeta {
|
||||
/// Build metadata from a DiscTitle. Codec privates come from title.codec_privates.
|
||||
pub fn from_title(title: &DiscTitle) -> Self {
|
||||
use base64::Engine;
|
||||
// Per-stream codec init data, base64-encoded. Preserved for ALL stream
|
||||
// kinds (video/audio/subtitle) so an FMKV-header-driven remux matches a
|
||||
// direct disc rip — previously only video round-tripped.
|
||||
let codec_private_b64 = |i: usize| -> Option<String> {
|
||||
title
|
||||
.codec_privates
|
||||
.get(i)
|
||||
.and_then(|cp| cp.as_ref())
|
||||
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp))
|
||||
};
|
||||
let streams = title
|
||||
.streams
|
||||
.iter()
|
||||
@@ -113,11 +139,7 @@ impl M2tsMeta {
|
||||
color_space: v.color_space.id().into(),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: title
|
||||
.codec_privates
|
||||
.get(i)
|
||||
.and_then(|cp| cp.as_ref())
|
||||
.map(|cp| base64::engine::general_purpose::STANDARD.encode(cp)),
|
||||
codec_private: codec_private_b64(i),
|
||||
},
|
||||
Stream::Audio(a) => MetaStream::Audio {
|
||||
pid: a.pid,
|
||||
@@ -127,12 +149,14 @@ impl M2tsMeta {
|
||||
sample_rate: a.sample_rate.to_string(),
|
||||
label: a.label.clone(),
|
||||
secondary: a.secondary,
|
||||
codec_private: codec_private_b64(i),
|
||||
},
|
||||
Stream::Subtitle(s) => MetaStream::Subtitle {
|
||||
pid: s.pid,
|
||||
codec: s.codec.id().into(),
|
||||
language: s.language.clone(),
|
||||
forced: s.forced,
|
||||
codec_private: codec_private_b64(i),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
@@ -197,6 +221,7 @@ impl M2tsMeta {
|
||||
sample_rate,
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
} => Stream::Audio(AudioStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
@@ -216,13 +241,14 @@ impl M2tsMeta {
|
||||
codec,
|
||||
language,
|
||||
forced,
|
||||
codec_private,
|
||||
} => Stream::Subtitle(SubtitleStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
codec_data: decode_codec_private(codec_private),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
@@ -243,32 +269,44 @@ impl M2tsMeta {
|
||||
|
||||
/// Extract codec_private data per stream (from FMKV header).
|
||||
/// Returns a Vec matching stream order — None for streams without codec_private.
|
||||
/// Covers all three stream kinds so audio/subtitle init data round-trips,
|
||||
/// not just video.
|
||||
pub fn codec_privates(&self) -> Vec<Option<Vec<u8>>> {
|
||||
self.streams
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if let MetaStream::Video {
|
||||
codec_private: Some(b64),
|
||||
..
|
||||
} = s
|
||||
{
|
||||
{
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(b64).ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let b64 = match s {
|
||||
MetaStream::Video { codec_private, .. }
|
||||
| MetaStream::Audio { codec_private, .. }
|
||||
| MetaStream::Subtitle { codec_private, .. } => codec_private,
|
||||
};
|
||||
decode_codec_private(b64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an optional base64 codec_private string into raw bytes. Invalid
|
||||
/// base64 decodes to `None` (treated as absent) rather than erroring — a
|
||||
/// corrupt init blob shouldn't fail the whole metadata parse.
|
||||
fn decode_codec_private(b64: &Option<String>) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
b64.as_ref()
|
||||
.and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok())
|
||||
}
|
||||
|
||||
/// Write the metadata header to a writer. Padded to 192-byte boundary.
|
||||
pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
let json = serde_json::to_vec(meta).map_err(io::Error::other)?;
|
||||
// Serializing our own struct effectively cannot fail, but map the
|
||||
// error to a numeric crate variant rather than embedding serde's
|
||||
// English string into an io::Error (no-English rule).
|
||||
let json = serde_json::to_vec(meta).map_err(|_| crate::error::Error::NoMetadata)?;
|
||||
|
||||
let json_len = json.len() as u32;
|
||||
// Guard the length field against truncation: the read side rejects
|
||||
// anything over MAX_JSON_SIZE, and `as u32` would silently wrap a
|
||||
// >=4 GiB JSON into a wrong, smaller length. Near-impossible for
|
||||
// real stream metadata, but a v1.0 primitive shouldn't truncate.
|
||||
let json_len = u32::try_from(json.len()).map_err(|_| crate::error::Error::NoMetadata)?;
|
||||
let raw_len = 8 + 4 + json.len(); // magic + len + json
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
@@ -277,7 +315,9 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
w.write_all(&json_len.to_be_bytes())?;
|
||||
w.write_all(&json)?;
|
||||
if padding > 0 {
|
||||
w.write_all(&vec![0u8; padding])?;
|
||||
// Padding is at most PACKET_SIZE-1 bytes — stack buffer, no heap alloc.
|
||||
let pad = [0u8; PACKET_SIZE];
|
||||
w.write_all(&pad[..padding])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -288,14 +328,37 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
||||
pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
let mut magic = [0u8; 8];
|
||||
if r.read_exact(&mut magic).is_err() {
|
||||
return Ok(None);
|
||||
// Read the first byte alone so a zero-byte stream (a legitimate
|
||||
// headerless file) stays Ok(None), while a stream that begins with some
|
||||
// magic bytes then truncates mid-magic surfaces as an error rather than
|
||||
// being masked as "no header".
|
||||
let mut first = [0u8; 1];
|
||||
if let Err(e) = r.read_exact(&mut first) {
|
||||
// A clean EOF (no header at all) means "no FMKV header" — the caller
|
||||
// falls back to a PMT scan. Any OTHER I/O failure (broken pipe,
|
||||
// permission denied, mid-read disc error) is a real error and must
|
||||
// propagate, not masquerade as a headerless stream.
|
||||
if e.kind() == io::ErrorKind::UnexpectedEof {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if first[0] != MAGIC[0] {
|
||||
return Ok(None); // not an FMKV stream
|
||||
}
|
||||
let mut rest = [0u8; 7];
|
||||
r.read_exact(&mut rest)?; // started with 'F' but truncated → error
|
||||
let magic = [
|
||||
first[0], rest[0], rest[1], rest[2], rest[3], rest[4], rest[5], rest[6],
|
||||
];
|
||||
|
||||
if magic[..4] != MAGIC[..4] {
|
||||
return Ok(None);
|
||||
}
|
||||
if magic[VERSION_BYTE] > SUPPORTED_VERSION {
|
||||
// Newer, incompatible format — refuse rather than mis-parse as v1.
|
||||
return Err(crate::error::Error::NoMetadata.into());
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
r.read_exact(&mut len_buf)?;
|
||||
@@ -307,16 +370,17 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
let mut json_buf = vec![0u8; json_len];
|
||||
r.read_exact(&mut json_buf)?;
|
||||
|
||||
let meta: M2tsMeta = serde_json::from_slice(&json_buf)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let meta: M2tsMeta =
|
||||
serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?;
|
||||
|
||||
// Skip padding to next 192-byte boundary
|
||||
// Skip padding to next 192-byte boundary (at most PACKET_SIZE-1 bytes →
|
||||
// a stack buffer, no heap allocation).
|
||||
let raw_len = 8 + 4 + json_len;
|
||||
let padded_len = raw_len.div_ceil(PACKET_SIZE) * PACKET_SIZE;
|
||||
let padding = padded_len - raw_len;
|
||||
if padding > 0 {
|
||||
let mut skip = vec![0u8; padding];
|
||||
r.read_exact(&mut skip)?;
|
||||
let mut skip = [0u8; PACKET_SIZE];
|
||||
r.read_exact(&mut skip[..padding])?;
|
||||
}
|
||||
|
||||
Ok(Some(meta))
|
||||
@@ -406,6 +470,133 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_header_empty_is_none_not_error() {
|
||||
// No bytes at all → clean EOF on the magic read → Ok(None), the
|
||||
// "no FMKV header, fall back" signal.
|
||||
let empty: &[u8] = &[];
|
||||
let mut cursor = io::Cursor::new(empty);
|
||||
let got = read_header(&mut cursor).expect("clean EOF must be Ok(None)");
|
||||
assert!(got.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_header_propagates_non_eof_error() {
|
||||
// A reader that fails with a non-EOF error must surface that
|
||||
// error, not be swallowed as Ok(None).
|
||||
struct BrokenReader;
|
||||
impl Read for BrokenReader {
|
||||
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
|
||||
Err(io::Error::from(io::ErrorKind::BrokenPipe))
|
||||
}
|
||||
}
|
||||
let mut r = BrokenReader;
|
||||
let err = read_header(&mut r).expect_err("broken pipe must propagate");
|
||||
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_header_then_read_header_round_trips() {
|
||||
let title = video_title(HdrFormat::Hdr10, ColorSpace::Bt2020);
|
||||
let meta = M2tsMeta::from_title(&title);
|
||||
let mut buf = Vec::new();
|
||||
write_header(&mut buf, &meta).expect("write");
|
||||
let mut cursor = io::Cursor::new(&buf);
|
||||
let back = read_header(&mut cursor)
|
||||
.expect("read")
|
||||
.expect("header present");
|
||||
assert_eq!(back.streams.len(), 1);
|
||||
// Header is padded to a 192-byte boundary; the cursor must land
|
||||
// exactly there so the following BD-TS data stays aligned.
|
||||
assert_eq!(cursor.position() as usize % PACKET_SIZE, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_and_subtitle_codec_private_round_trip() {
|
||||
use crate::disc::{AudioChannels, AudioStream, LabelPurpose, SampleRate, SubtitleStream};
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams.push(Stream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Dts,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}));
|
||||
t.streams.push(Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::DvdSub,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}));
|
||||
// codec_privates: index 0 = audio init data, index 1 = subtitle init data.
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC]), Some(vec![0x01, 0x02])];
|
||||
|
||||
let meta = M2tsMeta::from_title(&t);
|
||||
// Must serialize for both audio and subtitle (not just video).
|
||||
let cps = meta.codec_privates();
|
||||
assert_eq!(cps[0].as_deref(), Some(&[0xAA, 0xBB, 0xCC][..]));
|
||||
assert_eq!(cps[1].as_deref(), Some(&[0x01, 0x02][..]));
|
||||
|
||||
// And to_title restores the subtitle codec_data from the header.
|
||||
let back = meta.to_title();
|
||||
match &back.streams[1] {
|
||||
Stream::Subtitle(s) => {
|
||||
assert_eq!(s.codec_data.as_deref(), Some(&[0x01, 0x02][..]))
|
||||
}
|
||||
_ => panic!("expected subtitle stream"),
|
||||
}
|
||||
// The round-tripped title also carries all codec_privates.
|
||||
assert_eq!(
|
||||
back.codec_privates[0].as_deref(),
|
||||
Some(&[0xAA, 0xBB, 0xCC][..])
|
||||
);
|
||||
assert_eq!(back.codec_privates[1].as_deref(), Some(&[0x01, 0x02][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_version_header_rejected() {
|
||||
// A header tagged with a version above SUPPORTED_VERSION must be
|
||||
// refused, not silently parsed as v1.
|
||||
let meta = M2tsMeta::from_title(&video_title(HdrFormat::Sdr, ColorSpace::Bt709));
|
||||
let mut buf = Vec::new();
|
||||
write_header(&mut buf, &meta).unwrap();
|
||||
buf[VERSION_BYTE] = SUPPORTED_VERSION + 1; // bump version byte
|
||||
let mut cur = io::Cursor::new(buf);
|
||||
let err = read_header(&mut cur).unwrap_err();
|
||||
// NoMetadata (E9008) maps to InvalidInput.
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stream_is_clean_none_but_partial_magic_errors() {
|
||||
// Zero bytes → no header (Ok(None)).
|
||||
let mut empty = io::Cursor::new(Vec::<u8>::new());
|
||||
assert!(read_header(&mut empty).unwrap().is_none());
|
||||
|
||||
// Begins with 'F' (MAGIC[0]) then truncates → error, not None.
|
||||
let mut partial = io::Cursor::new(vec![b'F', b'M', b'K']);
|
||||
assert!(read_header(&mut partial).is_err());
|
||||
|
||||
// Does not begin with the FMKV magic at all → Ok(None) (headerless).
|
||||
let mut other = io::Cursor::new(vec![0x47u8; 16]);
|
||||
assert!(read_header(&mut other).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_round_trips_through_write_read() {
|
||||
let meta = M2tsMeta::from_title(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
|
||||
let mut buf = Vec::new();
|
||||
write_header(&mut buf, &meta).unwrap();
|
||||
let mut cur = io::Cursor::new(buf);
|
||||
let back = read_header(&mut cur).unwrap().expect("header present");
|
||||
assert_eq!(back.streams.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sdr_without_color_space_derives_bt709() {
|
||||
let json = r#"{
|
||||
|
||||
+253
-55
@@ -55,6 +55,11 @@ pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8>
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
|
||||
/// colour metadata is derived from the stream's colour space and HDR format
|
||||
/// (PQ for HDR10/HDR10+/DV, HLG for HLG). When `hdr == DolbyVision` a dvcC
|
||||
/// BlockAdditionMapping is attached automatically so players recognise the
|
||||
/// Dolby Vision layer.
|
||||
pub fn video(v: &VideoStream) -> Self {
|
||||
let codec_id = match v.codec {
|
||||
Codec::H264 => "V_MPEG4/ISO/AVC",
|
||||
@@ -111,6 +116,9 @@ impl MkvTrack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an audio track from an [`AudioStream`]. The codec ID follows the
|
||||
/// Matroska registry; every DTS family member (core, DTS-HD HR, DTS-HD MA)
|
||||
/// maps to the single registered `A_DTS` ID (see the note below).
|
||||
pub fn audio(a: &AudioStream) -> Self {
|
||||
// The Matroska codec-ID registry defines `A_DTS` for the entire
|
||||
// DTS family — the spec text for `A_DTS` explicitly states it
|
||||
@@ -160,6 +168,10 @@ impl MkvTrack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a subtitle track from a [`SubtitleStream`]. PGS maps to
|
||||
/// `S_HDMV/PGS` and DVD VobSub to `S_VOBSUB`; the stream's `codec_data`
|
||||
/// (the VobSub `.idx` palette header for DVD) becomes the track's
|
||||
/// CodecPrivate. The forced-display flag is propagated from the stream.
|
||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||
let codec_id = match s.codec {
|
||||
Codec::DvdSub => "S_VOBSUB",
|
||||
@@ -219,6 +231,12 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
last_pts_ms: std::collections::HashMap<usize, i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// Frames handed to `write_frame` that were dropped because no cluster was
|
||||
/// open yet (a cluster only opens on a track-0 video keyframe). If this is
|
||||
/// non-zero at `finish()` and not a single frame was ever written, the
|
||||
/// caller produced an empty MKV — surfaced as an error rather than a
|
||||
/// silently empty file. See `write_frame` for the track-0 invariant.
|
||||
dropped_pre_cluster: u64,
|
||||
seek_fixups: Vec<SeekPositionFixup>,
|
||||
info_offset: u64,
|
||||
tracks_offset: u64,
|
||||
@@ -229,10 +247,15 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
|
||||
/// Maximum block-relative timestamp expressible in the signed 16-bit
|
||||
/// SimpleBlock/Block field (`i16::MAX` ms). A frame further than this from
|
||||
/// the open cluster's timestamp forces a new cluster (see `write_frame`) so
|
||||
/// the `as i16` cast can never wrap.
|
||||
/// SimpleBlock/Block field (`i16::MAX` ms). A frame whose offset from the open
|
||||
/// cluster's timestamp falls outside `i16::MIN..=i16::MAX` ms forces a new
|
||||
/// cluster (see `write_frame`) so the `as i16` cast can never wrap — in EITHER
|
||||
/// direction. PES timestamps come from untrusted disc/file bytes and can
|
||||
/// back-jump on discontinuities, so the lower bound matters as much as the
|
||||
/// upper one.
|
||||
const MAX_BLOCK_REL_MS: i64 = i16::MAX as i64;
|
||||
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
|
||||
const MIN_BLOCK_REL_MS: i64 = i16::MIN as i64;
|
||||
|
||||
/// Force a per-track block timestamp to be strictly later than the previous one
|
||||
/// written for that track. `prev` is the last timestamp for the track (`None`
|
||||
@@ -247,6 +270,28 @@ fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
|
||||
/// returning the buffer and the used length. Track numbers are small (1-based,
|
||||
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
|
||||
/// no heap allocation, called once per block on the mux hot path.
|
||||
///
|
||||
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
|
||||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
||||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
||||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
||||
/// so this documents the bound rather than handling 3-byte VINTs.
|
||||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
||||
if track_num < 0x80 {
|
||||
([(track_num as u8) | 0x80, 0], 1)
|
||||
} else {
|
||||
debug_assert!(
|
||||
track_num < 0x4000,
|
||||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
||||
);
|
||||
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||||
pub fn new(
|
||||
@@ -446,6 +491,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
last_pts_ms: std::collections::HashMap::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
dropped_pre_cluster: 0,
|
||||
seek_fixups,
|
||||
info_offset,
|
||||
tracks_offset,
|
||||
@@ -469,8 +515,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
duration_ns: Option<u64>,
|
||||
) -> io::Result<()> {
|
||||
let raw_ms = pts_ns / 1_000_000;
|
||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||
let pts_ms = raw_ms - base;
|
||||
|
||||
// Cluster boundaries normally coincide with a video keyframe so every
|
||||
// Cues entry resolves to a seekable IDR at the cluster start.
|
||||
let is_video_key = keyframe && track_idx == 0;
|
||||
|
||||
// Derive the timestamp base from the first *kept* keyframe (the frame
|
||||
// that opens the first cluster), NOT the first frame merely seen. The
|
||||
// first frame seen can have a higher display PTS than the subsequent
|
||||
// I-frame (B-frame reordering / a PTS discontinuity), which would make
|
||||
// later cluster/cue timestamps negative and wrap to ~u64::MAX on the
|
||||
// `as u64` cast in `start_cluster`/`finish`. Anchoring on the first kept
|
||||
// keyframe guarantees the open cluster's timestamp is 0 and all later
|
||||
// relative offsets are computed from a frame we actually wrote.
|
||||
let base = match self.base_pts_ms {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
if !is_video_key {
|
||||
// No cluster can open yet (clusters start on a track-0
|
||||
// keyframe). Drop this frame as before, but count it so an
|
||||
// all-dropped run surfaces as an error at finish().
|
||||
self.dropped_pre_cluster += 1;
|
||||
return Ok(());
|
||||
}
|
||||
self.base_pts_ms = Some(raw_ms);
|
||||
raw_ms
|
||||
}
|
||||
};
|
||||
// Floor at 0: base is the first kept keyframe, so any frame with an
|
||||
// earlier PTS (audio/subtitle arriving with a pre-keyframe timestamp, or
|
||||
// a back-jump on a stream discontinuity) would compute negative here,
|
||||
// which would wrap to ~u64::MAX on the `as u64` cluster/cue write and
|
||||
// could overflow the i16 block-relative cast. Frames before the first
|
||||
// kept keyframe are clamped to t=0 rather than corrupting the timeline.
|
||||
let pts_ms = (raw_ms - base).max(0);
|
||||
|
||||
// Enforce strictly-monotonic per-track block timestamps. Some audio PES
|
||||
// PTS truncate to the same millisecond as the previous frame (or, rarely,
|
||||
@@ -479,14 +557,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// and A/V sync is unaffected at millisecond granularity.
|
||||
let pts_ms = monotonic_ts(self.last_pts_ms.get(&track_idx).copied(), pts_ms);
|
||||
|
||||
// Cluster boundaries normally coincide with a video keyframe so every
|
||||
// Cues entry resolves to a seekable IDR at the cluster start.
|
||||
let is_video_key = keyframe && track_idx == 0;
|
||||
let needs_new_cluster = !self.cluster_open
|
||||
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
|
||||
|
||||
if needs_new_cluster {
|
||||
if !is_video_key {
|
||||
// A cluster is open but this non-keyframe wants a fresh one only
|
||||
// because !cluster_open is false here — so this branch is the
|
||||
// "no cluster open and not a keyframe" case. Drop and count.
|
||||
if !self.cluster_open {
|
||||
self.dropped_pre_cluster += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.start_cluster(pts_ms)?;
|
||||
@@ -495,17 +576,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
} else if (pts_ms - self.cluster_ts_ms) > MAX_BLOCK_REL_MS {
|
||||
// The block-relative timestamp is a signed 16-bit value, so a
|
||||
// frame more than i16::MAX ms (~32.767 s) past the current
|
||||
// cluster's timestamp would silently wrap on the `as i16` cast,
|
||||
// corrupting A/V sync. The keyframe-driven boundary above only
|
||||
// fires on a video keyframe — a long audio-only stretch, or a
|
||||
// very long GOP with no intervening keyframe, can drift past the
|
||||
// i16 range. Force a fresh cluster here even without a keyframe
|
||||
// to keep the cast in range. This cluster is not keyframe-aligned
|
||||
// so it gets no Cues entry (Cues stay IDR-only for seekability).
|
||||
self.start_cluster(pts_ms)?;
|
||||
} else {
|
||||
let rel = pts_ms - self.cluster_ts_ms;
|
||||
if !(MIN_BLOCK_REL_MS..=MAX_BLOCK_REL_MS).contains(&rel) {
|
||||
// The block-relative timestamp is a signed 16-bit value, so a
|
||||
// frame whose offset from the current cluster's timestamp falls
|
||||
// outside i16::MIN..=i16::MAX ms (~±32.767 s) would silently wrap
|
||||
// on the `as i16` cast, corrupting A/V sync. The keyframe-driven
|
||||
// boundary above only fires on a video keyframe — a long
|
||||
// audio-only stretch, a very long GOP with no intervening
|
||||
// keyframe (positive direction), or an audio/subtitle PES whose
|
||||
// PTS back-jumps below the open cluster (negative direction, e.g.
|
||||
// a stream discontinuity) can drift past the i16 range. Force a
|
||||
// fresh cluster here even without a keyframe to keep the cast in
|
||||
// range. pts_ms is already floored at 0 above, so the new
|
||||
// cluster timestamp never wraps on the `as u64` write in
|
||||
// start_cluster. This cluster is not keyframe-aligned so it gets
|
||||
// no Cues entry (Cues stay IDR-only for seekability).
|
||||
self.start_cluster(pts_ms)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Committed to writing this frame — record its (monotonic) timestamp so
|
||||
@@ -528,7 +617,24 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
}
|
||||
|
||||
/// Finish the MKV file: write Cues element.
|
||||
///
|
||||
/// # Track-0 invariant
|
||||
///
|
||||
/// A cluster only opens on a track-0 video keyframe, so the caller must
|
||||
/// supply track 0 as the video track and deliver a keyframe on it before
|
||||
/// (or alongside) other-track data. If no track-0 keyframe ever arrives,
|
||||
/// every `write_frame` is silently dropped; rather than emit a structurally
|
||||
/// valid but empty MKV (zero clusters, zero frames), `finish` returns
|
||||
/// `Error::MkvInvalid` when frames were submitted but none were written.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
// A title that produced no frames (e.g. fully unreadable, or every
|
||||
// frame dropped before the first track-0 keyframe opened a cluster)
|
||||
// would otherwise yield a structurally-empty MKV with no clusters or
|
||||
// cues. Surface that as an error rather than writing valid-but-empty
|
||||
// output.
|
||||
if self.frame_count == 0 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
// Close final cluster
|
||||
self.end_cluster()?;
|
||||
|
||||
@@ -558,7 +664,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let offset = match fixup.target_id {
|
||||
ebml::INFO => self.info_offset,
|
||||
ebml::TRACKS => self.tracks_offset,
|
||||
ebml::CHAPTERS => self.chapters_offset.unwrap_or(0),
|
||||
ebml::CHAPTERS => self
|
||||
.chapters_offset
|
||||
.expect("CHAPTERS seek fixup present => chapters_offset is Some"),
|
||||
ebml::CUES => cues_offset,
|
||||
_ => 0,
|
||||
};
|
||||
@@ -601,19 +709,15 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
// SimpleBlock: [track_number VINT] [relative_ts i16] [flags u8] [data]
|
||||
// Track number as EBML VINT
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
let (tv, tv_len) = track_vint(track_num);
|
||||
let track_vint = &tv[..tv_len];
|
||||
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len(); // vint + ts(2) + flags(1) + data
|
||||
ebml::write_id(&mut self.writer, ebml::SIMPLE_BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
@@ -629,18 +733,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
data: &[u8],
|
||||
duration_ms: u64,
|
||||
) -> io::Result<()> {
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
let (tv, tv_len) = track_vint(track_num);
|
||||
let track_vint = &tv[..tv_len];
|
||||
// The 0x80 Keyframe flag is defined only for SimpleBlock; inside a
|
||||
// Block within a BlockGroup that high bit is reserved and MUST be 0
|
||||
// (keyframe-ness is signalled by the absence of a ReferenceBlock
|
||||
// child). `keyframe` is intentionally unused here — every Block this
|
||||
// path emits is intra (PGS subtitle frames carrying a duration).
|
||||
let _ = keyframe;
|
||||
let flags: u8 = 0x00;
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||||
|
||||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
@@ -820,29 +927,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mkv_finish_writes_cues_element() {
|
||||
// Use a Vec wrapped in Cursor, then check after finish
|
||||
// finish() consumes self and flushes the writer, so use the
|
||||
// module-level SharedWriter to inspect the buffer afterwards.
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// We'll write to a Cursor, but finish() consumes self.
|
||||
// The trick: Cursor<Vec<u8>> - we can get data back via into_inner chain.
|
||||
// But MkvMuxer::finish consumes self and flushes writer.
|
||||
// We need a way to inspect the output. Let's use a wrapper.
|
||||
|
||||
struct SharedWriter(Arc<Mutex<Cursor<Vec<u8>>>>);
|
||||
impl Write for SharedWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.0.lock().unwrap().flush()
|
||||
}
|
||||
}
|
||||
impl Seek for SharedWriter {
|
||||
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||
self.0.lock().unwrap().seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let tracks = [make_video_track()];
|
||||
@@ -1597,4 +1685,114 @@ mod tests {
|
||||
}
|
||||
assert_eq!(sb_count, 1, "expected exactly one SimpleBlock in output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_track0_keyframe_yields_error_not_empty_file() {
|
||||
// If track 0 never delivers a keyframe, every frame is dropped. finish()
|
||||
// must surface this rather than emitting a structurally valid empty MKV.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
|
||||
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
|
||||
.unwrap();
|
||||
let err = muxer.finish().unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_with_no_frames_errors() {
|
||||
// A muxer that received no frames at all must surface MkvInvalid on
|
||||
// finish() rather than writing a structurally-empty MKV.
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let err = muxer.finish().unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_relative_audio_forces_new_cluster_no_i16_wrap() {
|
||||
// An audio frame whose PTS back-jumps far below the open cluster (a
|
||||
// discontinuity) must force a fresh cluster rather than wrap the i16
|
||||
// block-relative cast. Build: keyframe at t=0 opening a cluster, a video
|
||||
// keyframe far later (so cluster ts is large), then an audio frame whose
|
||||
// PTS lands before that cluster's start by more than i16::MIN ms.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
// base = 0 (first kept keyframe). Cluster opens at 0; a later keyframe at
|
||||
// 40s opens a second cluster at ts=40000. Then audio at t=0 → relative
|
||||
// 0-40000 = -40000 ms, below i16::MIN (-32768) → must open a new cluster.
|
||||
let frames = vec![
|
||||
(0usize, 0i64, true, vec![0x01; 16]),
|
||||
(0usize, 40_000_000_000i64, true, vec![0x02; 16]), // 40s
|
||||
(1usize, 0i64, true, vec![0x03; 16]), // back-jumped audio
|
||||
];
|
||||
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
|
||||
assert_eq!(frame_count, 3);
|
||||
let clusters = find_clusters(&data);
|
||||
// Three clusters: t=0 (video kf), t=40000 (video kf), t=0 (forced for the
|
||||
// back-jumped audio, no Cues entry).
|
||||
assert!(
|
||||
clusters.len() >= 3,
|
||||
"back-jumped audio must force a fresh cluster, got {} clusters",
|
||||
clusters.len()
|
||||
);
|
||||
// Every SimpleBlock's relative timestamp must round-trip through i16
|
||||
// without the block landing outside the cluster (verified implicitly by
|
||||
// the muxer never panicking on the `as i16` cast; here we assert the
|
||||
// forced cluster's timestamp is non-negative so the `as u64` write is
|
||||
// also safe).
|
||||
for (_, _, ts) in &clusters {
|
||||
assert!(*ts <= i64::MAX as u64, "cluster ts must not have wrapped");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_pts_audio_after_keyframe_does_not_wrap() {
|
||||
// Stream order: video keyframe at 5s (anchors base=5000ms, opens cluster
|
||||
// at ts 0), then an audio frame with raw PTS 4s — earlier than base.
|
||||
// raw_ms - base = -1000ms (negative). It must be floored to 0 rather
|
||||
// than wrapping the `as u64` cluster/cue write or overflowing the i16
|
||||
// relative cast.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let frames_in_order = [
|
||||
(0usize, 5_000_000_000i64, true, vec![0xBB; 16]), // video kf at 5s
|
||||
(1usize, 4_000_000_000i64, true, vec![0xAA; 8]), // audio at 4s (< base)
|
||||
];
|
||||
// Do NOT sort — preserve the out-of-order arrival.
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
for (t, pts, kf, data) in &frames_in_order {
|
||||
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
let clusters = find_clusters(&data);
|
||||
assert!(!clusters.is_empty());
|
||||
for (_, _, ts) in &clusters {
|
||||
// A wrapped negative would be a huge near-u64::MAX value.
|
||||
assert!(*ts < 1_000_000_000, "cluster timestamp wrapped: {}", ts);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_vint_encodes_one_and_two_byte_forms() {
|
||||
// 1-byte form for track numbers < 0x80, high bit set.
|
||||
let (b, n) = track_vint(1);
|
||||
assert_eq!(&b[..n], &[0x81]);
|
||||
let (b, n) = track_vint(0x7F);
|
||||
assert_eq!(&b[..n], &[0xFF]);
|
||||
// 2-byte form at/above 0x80, 0x40 length marker in the top byte.
|
||||
let (b, n) = track_vint(0x80);
|
||||
assert_eq!(&b[..n], &[0x40, 0x80]);
|
||||
let (b, n) = track_vint(0x3FFF);
|
||||
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
||||
}
|
||||
}
|
||||
|
||||
+513
-39
@@ -6,7 +6,9 @@
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::{WriteSeek, ebml};
|
||||
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
||||
/// (title, codec_privates, ts_scale_ns) — `ts_scale_ns` is the
|
||||
/// TimestampScale in nanoseconds per tick, threaded into the frame read path.
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>, i64)>;
|
||||
|
||||
/// Skip `n` bytes on a forward-only reader (no Seek required).
|
||||
fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
|
||||
@@ -62,7 +64,14 @@ use std::io::{self, Read};
|
||||
|
||||
struct ReadState {
|
||||
reader: Box<dyn Read + Send>,
|
||||
cluster_ts_ms: i64,
|
||||
/// Current cluster timestamp in TimestampScale *ticks* (not ms). Combined
|
||||
/// with each block's relative tick offset and scaled to nanoseconds via
|
||||
/// `ts_scale_ns`.
|
||||
cluster_ts_ticks: i64,
|
||||
/// TimestampScale in nanoseconds per tick (Matroska INFO/TimestampScale,
|
||||
/// default 1_000_000 = 1 ms). Foreign MKVs may use a different scale; the
|
||||
/// frame PTS must honour it, not assume milliseconds.
|
||||
ts_scale_ns: i64,
|
||||
/// Codec private data per track (track_number, hvcC/avcC bytes).
|
||||
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||
}
|
||||
@@ -123,12 +132,13 @@ impl MkvStream {
|
||||
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
||||
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
cluster_ts_ms: 0,
|
||||
cluster_ts_ticks: 0,
|
||||
ts_scale_ns,
|
||||
codec_privates,
|
||||
}),
|
||||
})
|
||||
@@ -137,6 +147,7 @@ impl MkvStream {
|
||||
|
||||
impl crate::pes::Stream for MkvStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
let streams_len = self.disc_title.streams.len();
|
||||
let rs = match self.mode {
|
||||
Mode::Read(ref mut rs) => rs,
|
||||
Mode::Write { .. } => return Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
@@ -145,47 +156,95 @@ impl crate::pes::Stream for MkvStream {
|
||||
loop {
|
||||
let (id, size, _) = match ebml::read_element_header(&mut rs.reader) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Ok(None),
|
||||
// Only a genuine premature/clean EOF ends the stream. Any other
|
||||
// error (disc read failure, corrupt sector, network drop) must
|
||||
// propagate, or a mid-mux I/O failure would silently truncate
|
||||
// the output with no error signal.
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
match id {
|
||||
ebml::CLUSTER => continue,
|
||||
ebml::CLUSTER_TIMESTAMP => {
|
||||
rs.cluster_ts_ms = read_uint_bounded(&mut rs.reader, size)? as i64;
|
||||
let raw = read_uint_bounded(&mut rs.reader, size)?;
|
||||
// The cluster timestamp is an untrusted u64; a value above
|
||||
// i64::MAX would cast to a large negative i64 and poison
|
||||
// every block PTS in the cluster. Reject it, mirroring the
|
||||
// EBML-size guard in parse_mkv_header.
|
||||
if raw > i64::MAX as u64 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
rs.cluster_ts_ticks = raw as i64;
|
||||
continue;
|
||||
}
|
||||
ebml::SIMPLE_BLOCK => {
|
||||
let block =
|
||||
ebml::read_binary_val(&mut rs.reader, checked_size(size, MAX_BLOCK_SIZE)?)?;
|
||||
if block.len() < 4 {
|
||||
continue;
|
||||
if let Some(frame) = parse_block(
|
||||
&block,
|
||||
rs.cluster_ts_ticks,
|
||||
rs.ts_scale_ns,
|
||||
streams_len,
|
||||
None,
|
||||
) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
let (track, vl) = block_vint(&block);
|
||||
if vl + 3 > block.len() {
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
ebml::BLOCK_GROUP => {
|
||||
// MkvMuxer emits a BlockGroup (BLOCK + BLOCK_DURATION) for
|
||||
// every frame carrying a duration — i.e. all AC3 audio and
|
||||
// PGS subtitle frames. Descend into the group, read the
|
||||
// inner BLOCK (0xA1) and BLOCK_DURATION (0x9B), and yield a
|
||||
// frame so a round-trip through this muxer does not silently
|
||||
// drop those tracks. A non-u64::MAX size bounds the children.
|
||||
if size == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let keyframe = block[vl + 2] & 0x80 != 0;
|
||||
let data = block[vl + 3..].to_vec();
|
||||
let pts_ms = rs.cluster_ts_ms + rel_ts as i64;
|
||||
let track_idx = (track as usize).saturating_sub(1); // MKV tracks are 1-based
|
||||
|
||||
// Skip blocks for non-existent tracks
|
||||
if track_idx >= self.disc_title.streams.len() {
|
||||
continue;
|
||||
let mut remaining = size;
|
||||
let mut block: Option<Vec<u8>> = None;
|
||||
let mut duration_ms: Option<u64> = None;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(&mut rs.reader)?;
|
||||
if cs == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::BLOCK => {
|
||||
block = Some(ebml::read_binary_val(
|
||||
&mut rs.reader,
|
||||
checked_size(cs, MAX_BLOCK_SIZE)?,
|
||||
)?);
|
||||
}
|
||||
ebml::BLOCK_DURATION => {
|
||||
duration_ms = Some(read_uint_bounded(&mut rs.reader, cs)?);
|
||||
}
|
||||
_ => skip_bytes(&mut rs.reader, cs)?,
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(crate::pes::PesFrame {
|
||||
track: track_idx,
|
||||
pts: pts_ms * 1_000_000, // ms → ns
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
}));
|
||||
if let Some(block) = block {
|
||||
let dur_ns = duration_ms.map(|ms| ms.saturating_mul(1_000_000));
|
||||
if let Some(frame) = parse_block(
|
||||
&block,
|
||||
rs.cluster_ts_ticks,
|
||||
rs.ts_scale_ns,
|
||||
streams_len,
|
||||
dur_ns,
|
||||
) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
// An unknown-size element here would drain the whole stream
|
||||
// (take(u64::MAX)) and silently drop all later frames;
|
||||
// reject it like the rest of the parser.
|
||||
if size == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
skip_bytes(&mut rs.reader, size)?;
|
||||
continue;
|
||||
}
|
||||
@@ -272,14 +331,27 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
}
|
||||
let (id, size, _) = match ebml::read_element_header(r) {
|
||||
Ok(h) => h,
|
||||
Err(_) => break,
|
||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
match id {
|
||||
ebml::INFO => {
|
||||
// An unknown-size (u64::MAX) parent would drain children until
|
||||
// an EOF read error instead of a clean MkvInvalid; reject it for
|
||||
// parity with the segment loop guard below.
|
||||
if size == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
// An inner child declaring EBML unknown size (cs == u64::MAX)
|
||||
// would overflow `hlen + cs` (debug panic) and is meaningless
|
||||
// for a sized parent — reject it.
|
||||
if cs == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = read_uint_bounded(r, cs)?,
|
||||
@@ -293,9 +365,15 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
got_info = true;
|
||||
}
|
||||
ebml::TRACKS => {
|
||||
if size == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
if cs == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
if cid == ebml::TRACK_ENTRY {
|
||||
let (stream, tnum, cp) = parse_track(r, cs)?;
|
||||
@@ -325,7 +403,38 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
Ok((disc_title, codec_privates))
|
||||
// Clamp the (untrusted) scale to a positive i64 for the tick→ns multiply on
|
||||
// the read path; default to 1 ms if absent or absurd.
|
||||
let ts_scale_ns = if ts_scale == 0 || ts_scale > i64::MAX as u64 {
|
||||
1_000_000
|
||||
} else {
|
||||
ts_scale as i64
|
||||
};
|
||||
Ok((disc_title, codec_privates, ts_scale_ns))
|
||||
}
|
||||
|
||||
/// Largest valid 13-bit MPEG-TS PID.
|
||||
const MAX_TS_PID: u32 = 0x1FFF;
|
||||
|
||||
/// Map an MKV track number to a synthetic BD-TS PID, rejecting any value that
|
||||
/// would overflow the 13-bit PID space. Track 1 is the video PID (0x1011);
|
||||
/// every other track maps to `0x1100 + (tnum - 2)`. Computed in `u32` so the
|
||||
/// addition can never wrap, unlike the prior `u16` arithmetic.
|
||||
fn ts_pid_for_track(tnum: u16) -> io::Result<u16> {
|
||||
// MKV track numbers are 1-based; 0 is invalid (and would underflow the
|
||||
// `tnum - 2` below).
|
||||
if tnum == 0 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
let pid: u32 = if tnum == 1 {
|
||||
0x1011
|
||||
} else {
|
||||
0x1100u32 + (tnum as u32 - 2)
|
||||
};
|
||||
if pid > MAX_TS_PID {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
Ok(pid as u16)
|
||||
}
|
||||
|
||||
/// Returns (stream, track_number, codec_private_bytes)
|
||||
@@ -341,9 +450,21 @@ fn parse_track(
|
||||
let mut remaining = size;
|
||||
while remaining > 0 {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
if cs == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TRACK_NUMBER => tnum = read_uint_bounded(r, cs)? as u16,
|
||||
ebml::TRACK_NUMBER => {
|
||||
// Reject a TRACK_NUMBER above u16::MAX rather than truncating
|
||||
// with `as u16` (which would alias 65536→0, 65537→1, … onto
|
||||
// existing small track numbers and corrupt PID/codec lookup).
|
||||
let n = read_uint_bounded(r, cs)?;
|
||||
if n > u16::MAX as u64 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
tnum = n as u16;
|
||||
}
|
||||
ebml::TRACK_TYPE => ttype = read_uint_bounded(r, cs)?,
|
||||
ebml::CODEC_ID => codec_id = read_string_bounded(r, cs)?,
|
||||
ebml::CODEC_PRIVATE => {
|
||||
@@ -359,6 +480,9 @@ fn parse_track(
|
||||
let mut vrem = cs;
|
||||
while vrem > 0 {
|
||||
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
|
||||
if vs == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
vrem = vrem.saturating_sub(vhlen as u64 + vs);
|
||||
if vid == ebml::PIXEL_HEIGHT {
|
||||
ph = read_uint_bounded(r, vs)? as u32;
|
||||
@@ -371,6 +495,9 @@ fn parse_track(
|
||||
let mut arem = cs;
|
||||
while arem > 0 {
|
||||
let (aid, as_, ahlen) = ebml::read_element_header(r)?;
|
||||
if as_ == u64::MAX {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
@@ -409,12 +536,11 @@ fn parse_track(
|
||||
SampleRate::S48
|
||||
};
|
||||
|
||||
// Map MKV track numbers to BD-TS PIDs
|
||||
let ts_pid = if tnum == 1 {
|
||||
0x1011
|
||||
} else {
|
||||
0x1100 + (tnum - 2)
|
||||
};
|
||||
// Map MKV track numbers to BD-TS PIDs. A 13-bit TS PID tops out at
|
||||
// 0x1FFF; compute in u32 so the `0x1100 + (tnum - 2)` arithmetic can't
|
||||
// wrap u16 for large track numbers, and reject anything that would land
|
||||
// outside the valid PID space.
|
||||
let ts_pid = ts_pid_for_track(tnum)?;
|
||||
|
||||
let stream = match ttype {
|
||||
1 => {
|
||||
@@ -453,6 +579,58 @@ fn parse_track(
|
||||
Ok((stream, tnum, codec_priv))
|
||||
}
|
||||
|
||||
/// Parse a (Simple)Block payload into a PesFrame, or `None` if it should be
|
||||
/// skipped (too short, track 0, or a track index out of range).
|
||||
///
|
||||
/// `cluster_ts_ticks` is the open cluster's timestamp in TimestampScale ticks
|
||||
/// and `ts_scale_ns` is that scale (ns per tick); the block PTS is computed as
|
||||
/// `(cluster_ts_ticks + rel_ts) * ts_scale_ns` so foreign MKVs whose scale
|
||||
/// isn't 1 ms are honoured (freemkv's own output uses 1_000_000 and round-trips
|
||||
/// unchanged). `streams_len` bounds the resolved track index; `duration_ns` is
|
||||
/// propagated for BlockGroup blocks (None for SimpleBlock).
|
||||
fn parse_block(
|
||||
block: &[u8],
|
||||
cluster_ts_ticks: i64,
|
||||
ts_scale_ns: i64,
|
||||
streams_len: usize,
|
||||
duration_ns: Option<u64>,
|
||||
) -> Option<crate::pes::PesFrame> {
|
||||
if block.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let (track, vl) = block_vint(block);
|
||||
if vl + 3 > block.len() {
|
||||
return None;
|
||||
}
|
||||
// Track 0 is invalid (MKV track numbers are 1-based). block_vint also
|
||||
// returns 0 for an unsupported 5+ byte VINT, so a corrupt/zero-track block
|
||||
// must be skipped rather than attributed to the first stream.
|
||||
if track == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rel_ts = i16::from_be_bytes([block[vl], block[vl + 1]]);
|
||||
let keyframe = block[vl + 2] & 0x80 != 0;
|
||||
let data = block[vl + 3..].to_vec();
|
||||
let pts_ticks = cluster_ts_ticks + rel_ts as i64;
|
||||
let track_idx = (track as usize) - 1; // track >= 1 checked above
|
||||
|
||||
// Skip blocks for non-existent tracks.
|
||||
if track_idx >= streams_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(crate::pes::PesFrame {
|
||||
track: track_idx,
|
||||
// saturating_mul: a hostile CLUSTER_TIMESTAMP could push pts_ticks near
|
||||
// i64::MAX, where ticks→ns would overflow and panic in debug builds.
|
||||
pts: pts_ticks.saturating_mul(ts_scale_ns),
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns,
|
||||
})
|
||||
}
|
||||
|
||||
fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
if d.is_empty() {
|
||||
return (0, 0);
|
||||
@@ -489,12 +667,38 @@ mod tests {
|
||||
|
||||
// `From<Error> for io::Error` encodes the numeric code into the
|
||||
// Display string as "E{code}: ...". Check the prefix.
|
||||
/// Extract the error from a `MkvStream::open` result without requiring
|
||||
/// `MkvStream: Debug` (which `unwrap_err` would).
|
||||
fn open_err(r: io::Result<MkvStream>) -> io::Error {
|
||||
match r {
|
||||
Ok(_) => panic!("expected MkvStream::open to fail"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mkv_invalid(e: &io::Error) -> bool {
|
||||
e.kind() == io::ErrorKind::InvalidData
|
||||
&& e.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_MKV_INVALID))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_pid_for_track_maps_and_rejects_overflow() {
|
||||
// Track 1 → video PID; track 2 → first audio PID base.
|
||||
assert_eq!(ts_pid_for_track(1).unwrap(), 0x1011);
|
||||
assert_eq!(ts_pid_for_track(2).unwrap(), 0x1100);
|
||||
assert_eq!(ts_pid_for_track(3).unwrap(), 0x1101);
|
||||
// Highest track that still lands inside the 13-bit PID space.
|
||||
// 0x1100 + (tnum-2) <= 0x1FFF ⇒ tnum <= 0xF01.
|
||||
assert_eq!(ts_pid_for_track(0xF01).unwrap(), 0x1FFF);
|
||||
// One past the edge must be rejected, not wrap u16.
|
||||
assert!(is_mkv_invalid(&ts_pid_for_track(0xF02).unwrap_err()));
|
||||
// Former overflow case (debug panic / release garbage PID) is rejected.
|
||||
assert!(is_mkv_invalid(&ts_pid_for_track(u16::MAX).unwrap_err()));
|
||||
// Track 0 is invalid (1-based) and would underflow tnum-2.
|
||||
assert!(is_mkv_invalid(&ts_pid_for_track(0).unwrap_err()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_size_rejects_over_cap() {
|
||||
// Within cap → Ok with usize value.
|
||||
@@ -614,4 +818,274 @@ mod tests {
|
||||
assert!(frame.keyframe);
|
||||
assert_eq!(frame.data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
}
|
||||
#[test]
|
||||
fn truncated_simple_block_body_errors_not_panics() {
|
||||
// A SIMPLE_BLOCK that declares a 64-byte payload but supplies none.
|
||||
// read_exact_bounded must surface a clean typed MkvInvalid error
|
||||
// (a truncated declared element is malformed input), never panic,
|
||||
// and never allocate the full declared size up front.
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, 64).unwrap();
|
||||
// No body bytes follow → short read.
|
||||
let bytes = minimal_mkv_with_cluster(&cluster);
|
||||
|
||||
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
|
||||
let e = stream.read().unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
/// Build a minimal MKV header + Segment + Info, then a Tracks element with a
|
||||
/// single TRACK_ENTRY of the given track number/type, then the cluster bytes.
|
||||
fn mkv_with_track_and_cluster(tnum: u64, ttype: u64, cluster_body: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, tnum).unwrap();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, ttype).unwrap();
|
||||
let mut track_entry = Vec::new();
|
||||
ebml::write_id(&mut track_entry, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut track_entry, entry.len() as u64).unwrap();
|
||||
track_entry.extend_from_slice(&entry);
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, track_entry.len() as u64).unwrap();
|
||||
out.extend_from_slice(&track_entry);
|
||||
|
||||
out.extend_from_slice(cluster_body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_codec_private_is_rejected() {
|
||||
// A TRACK_ENTRY whose CODEC_PRIVATE declares a payload above
|
||||
// MAX_CODEC_PRIVATE must be rejected (MkvInvalid) before any
|
||||
// multi-MB allocation, while parsing the header.
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, 1).unwrap();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, 1).unwrap();
|
||||
// CODEC_PRIVATE header claiming a huge size (no body needed — the
|
||||
// size check fires first).
|
||||
ebml::write_id(&mut entry, ebml::CODEC_PRIVATE).unwrap();
|
||||
ebml::write_size(&mut entry, MAX_CODEC_PRIVATE + 1).unwrap();
|
||||
let mut track_entry = Vec::new();
|
||||
ebml::write_id(&mut track_entry, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut track_entry, entry.len() as u64).unwrap();
|
||||
track_entry.extend_from_slice(&entry);
|
||||
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, track_entry.len() as u64).unwrap();
|
||||
out.extend_from_slice(&track_entry);
|
||||
|
||||
let e = match MkvStream::open(Cursor::new(out)) {
|
||||
Ok(_) => panic!("expected MkvInvalid, got Ok"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_group_frame_round_trips_with_duration() {
|
||||
// MkvMuxer emits AC3/PGS frames as a BlockGroup (BLOCK + BLOCK_DURATION).
|
||||
// The reader must descend into the group and yield the frame (with its
|
||||
// duration) rather than skipping it — otherwise every AC3/PGS frame this
|
||||
// muxer writes is lost on read-back.
|
||||
let block = [0x82u8, 0x00, 0x05, 0x00, 0x11, 0x22, 0x33]; // track 2, rel 5, not-kf, 3 data
|
||||
let mut bg_body = Vec::new();
|
||||
ebml::write_id(&mut bg_body, ebml::BLOCK).unwrap();
|
||||
ebml::write_size(&mut bg_body, block.len() as u64).unwrap();
|
||||
bg_body.extend_from_slice(&block);
|
||||
ebml::write_uint(&mut bg_body, ebml::BLOCK_DURATION, 40).unwrap(); // 40 ms
|
||||
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
// CLUSTER_TIMESTAMP = 100 ms so pts = (100 + 5) ms.
|
||||
ebml::write_uint(&mut cluster, ebml::CLUSTER_TIMESTAMP, 100).unwrap();
|
||||
ebml::write_id(&mut cluster, ebml::BLOCK_GROUP).unwrap();
|
||||
ebml::write_size(&mut cluster, bg_body.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&bg_body);
|
||||
|
||||
// Track 2 (audio) so track_idx 1 needs two streams; give two TRACK_ENTRYs.
|
||||
// Reuse the helper for track 1, then a manual second entry would be
|
||||
// simpler — instead build directly with two entries.
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
let mut tracks = Vec::new();
|
||||
for (n, t) in [(1u64, 1u64), (2u64, 2u64)] {
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, n).unwrap();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, t).unwrap();
|
||||
ebml::write_id(&mut tracks, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut tracks, entry.len() as u64).unwrap();
|
||||
tracks.extend_from_slice(&entry);
|
||||
}
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, tracks.len() as u64).unwrap();
|
||||
out.extend_from_slice(&tracks);
|
||||
out.extend_from_slice(&cluster);
|
||||
|
||||
let mut stream = MkvStream::open(Cursor::new(out)).unwrap();
|
||||
let frame = stream
|
||||
.read()
|
||||
.unwrap()
|
||||
.expect("BlockGroup frame must be read");
|
||||
assert_eq!(frame.track, 1, "track 2 → index 1");
|
||||
assert!(!frame.keyframe);
|
||||
assert_eq!(frame.data, vec![0x11, 0x22, 0x33]);
|
||||
assert_eq!(frame.pts, 105 * 1_000_000, "pts = (cluster 100 + rel 5) ms");
|
||||
assert_eq!(frame.duration_ns, Some(40 * 1_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_number_zero_is_rejected() {
|
||||
// A TRACK_ENTRY with TRACK_NUMBER 0 must be rejected (the ts_pid
|
||||
// computation would underflow `tnum - 2`).
|
||||
let bytes = mkv_with_track_and_cluster(0, 1, &[]);
|
||||
let e = open_err(MkvStream::open(Cursor::new(bytes)));
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_number_above_u16_is_rejected() {
|
||||
// 65536 would truncate to 0 via `as u16` and then underflow.
|
||||
let bytes = mkv_with_track_and_cluster(65536, 1, &[]);
|
||||
let e = open_err(MkvStream::open(Cursor::new(bytes)));
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_size_inner_child_in_tracks_is_rejected() {
|
||||
// A TRACK_ENTRY child declaring EBML unknown size (cs == u64::MAX) must
|
||||
// be rejected, not used in `hlen + cs` (which would overflow → debug
|
||||
// panic). Hand-build a TRACK_ENTRY whose first child carries the
|
||||
// unknown-size marker.
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_id(&mut entry, ebml::TRACK_NUMBER).unwrap();
|
||||
ebml::write_unknown_size(&mut entry).unwrap(); // child size = unknown
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
ebml::write_id(&mut tracks, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut tracks, entry.len() as u64).unwrap();
|
||||
tracks.extend_from_slice(&entry);
|
||||
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, tracks.len() as u64).unwrap();
|
||||
out.extend_from_slice(&tracks);
|
||||
|
||||
let e = open_err(MkvStream::open(Cursor::new(out)));
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_title_string_is_rejected() {
|
||||
// INFO/TITLE declaring a string above MAX_STRING_LEN must be
|
||||
// rejected during header parse, not allocated.
|
||||
let mut info = Vec::new();
|
||||
ebml::write_id(&mut info, ebml::TITLE).unwrap();
|
||||
ebml::write_size(&mut info, MAX_STRING_LEN + 1).unwrap();
|
||||
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, info.len() as u64).unwrap();
|
||||
out.extend_from_slice(&info);
|
||||
|
||||
let e = match MkvStream::open(Cursor::new(out)) {
|
||||
Ok(_) => panic!("expected MkvInvalid, got Ok"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_uint_val_len_nine_errors_not_panics() {
|
||||
// Direct helper test: an EBML uint cannot exceed 8 bytes. len=9
|
||||
// would index past the fixed 8-byte stack buffer and panic on
|
||||
// untrusted input; it must return MkvInvalid instead.
|
||||
let mut data = Cursor::new(vec![0u8; 16]);
|
||||
let e = ebml::read_uint_val(&mut data, 9).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_float_val_bad_width_errors() {
|
||||
// EBML floats are exactly 0, 4, or 8 bytes. Any other width is
|
||||
// malformed and must error rather than over- or under-read.
|
||||
let mut data = Cursor::new(vec![0u8; 16]);
|
||||
let e = ebml::read_float_val(&mut data, 5).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
// 0/4/8 remain valid widths.
|
||||
let mut z = Cursor::new(vec![0u8; 16]);
|
||||
assert_eq!(ebml::read_float_val(&mut z, 0).unwrap(), 0.0);
|
||||
let mut f4 = Cursor::new(vec![0u8; 16]);
|
||||
assert!(ebml::read_float_val(&mut f4, 4).is_ok());
|
||||
let mut f8 = Cursor::new(vec![0u8; 16]);
|
||||
assert!(ebml::read_float_val(&mut f8, 8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_string_element_is_rejected() {
|
||||
// A string element with invalid UTF-8 bytes must surface a numeric
|
||||
// MkvInvalid error, not an io::Error wrapping the FromUtf8Error
|
||||
// English message (library no-English rule).
|
||||
let mut data = Cursor::new(vec![0xFF, 0xFE, 0xFD, 0xFC]);
|
||||
let e = ebml::read_string_val(&mut data, 4).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_block_track_zero_is_skipped() {
|
||||
// A SimpleBlock with track vint 0 must be skipped, not attributed to
|
||||
// track 0. Build one track, then a cluster whose only block is track 0
|
||||
// followed by a valid track-1 block; read() must return the track-1 one.
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
// track vint 0 is not directly encodable (0x80 is track 0 → block_vint
|
||||
// returns (0,1)); use 0x80 as the track byte.
|
||||
let bad = [0x80u8, 0x00, 0x00, 0x80, 0xEE];
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, bad.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&bad);
|
||||
let good = [0x81u8, 0x00, 0x00, 0x80, 0xAB, 0xCD];
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, good.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&good);
|
||||
|
||||
let bytes = mkv_with_track_and_cluster(1, 1, &cluster);
|
||||
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
|
||||
let frame = stream.read().unwrap().expect("track-1 frame expected");
|
||||
assert_eq!(frame.track, 0);
|
||||
assert_eq!(frame.data, vec![0xAB, 0xCD]);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-13
@@ -1,7 +1,15 @@
|
||||
//! Stream-based I/O pipeline.
|
||||
//!
|
||||
//! All formats are PES streams. Read from a format → PES frames.
|
||||
//! Write PES frames → a format.
|
||||
//! Two muxer families live here:
|
||||
//!
|
||||
//! 1. **Bidirectional PES streams** (`disc`, `mkv`, `m2ts`, `network`,
|
||||
//! `stdio`, `null`) implement the [`crate::pes::Stream`] interface:
|
||||
//! read a format → PES frames, or write PES frames → a format.
|
||||
//! 2. **Write-only sequential-sink muxers** (`fmp4`, `hevc`,
|
||||
//! `m2ts_mux`) consume PES frames and write a container to a
|
||||
//! `SequentialSink`; they do not implement the read loop below.
|
||||
//!
|
||||
//! The bidirectional family is driven like this:
|
||||
//!
|
||||
//! ```text
|
||||
//! let mut input = input("iso://Disc.iso", &opts)?;
|
||||
@@ -16,12 +24,26 @@
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
// Public modules — types here are intentionally part of the consumable API.
|
||||
pub mod codec;
|
||||
pub mod demux_thread;
|
||||
pub mod disc;
|
||||
pub mod pipelined_stream;
|
||||
pub mod resolve;
|
||||
|
||||
// Internal-only modules. Every reference is via `crate::mux::…` /
|
||||
// `super::…` from inside the crate; nothing in the downstream crates or
|
||||
// integration tests imports them and lib.rs re-exports nothing from
|
||||
// them, so they are not part of the stable public API.
|
||||
//
|
||||
// `#[allow(dead_code)]`: narrowing these from `pub` to `pub(crate)`
|
||||
// surfaces a handful of helpers/accessors that were only ever reachable
|
||||
// as (unused) public API — e.g. the MPEG-2 resolution/frame-rate
|
||||
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||
// part of the parser/demux surface and covered by unit tests; allow the
|
||||
// dead-code lint rather than delete still-relevant scaffolding.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod codec;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod demux_thread;
|
||||
|
||||
// Internal modules — implementation details. Their *types* are re-exported
|
||||
// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`),
|
||||
// but the module paths themselves are not part of the API. Pre-0.13 these
|
||||
@@ -35,16 +57,30 @@ pub(crate) mod m2ts;
|
||||
/// Exposed for integration tests that exercise the wire format directly.
|
||||
pub mod meta;
|
||||
|
||||
// ── Phase 3 sequential muxers ──────────────────────────────────────────────
|
||||
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
||||
//
|
||||
// New container muxers that consume PES frames and write to a
|
||||
// `SequentialSink`. They are NOT refactors of the existing `MkvStream` /
|
||||
// `M2tsStream` (which round-trip via the legacy `Stream` trait + the
|
||||
// BD-TS framing); they're sequential-only and target the Phase 2 sink
|
||||
// split end-to-end.
|
||||
pub mod fmp4;
|
||||
pub mod hevc;
|
||||
pub mod m2ts_mux;
|
||||
// Container muxers that consume PES frames and write to a
|
||||
// `SequentialSink`. They are NOT the bidirectional `MkvStream` /
|
||||
// `M2tsStream` (which round-trip via the `Stream` trait + BD-TS
|
||||
// framing); these are write-only and sequential.
|
||||
//
|
||||
// `pub(crate)`: these have no external callers and are not re-exported
|
||||
// from lib.rs. `fmp4` is an explicit STUB (`Fmp4Mux::write_video`
|
||||
// accumulates and discards) — shipping it as `pub` would lock a
|
||||
// half-built type into the v1.0 stability contract via the
|
||||
// `libfreemkv::mux::fmp4::Fmp4Mux` path. `m2ts_mux` is the plain
|
||||
// MPEG-TS sequential muxer and `hevc` is its Annex-B helper; both are
|
||||
// staged scaffolding for the sink split and are not yet wired into a
|
||||
// live pipeline (the production paths use `tsmux` / `mkv`).
|
||||
// `#[allow(dead_code)]`: retained intentionally until the sink split
|
||||
// lands; they are exercised by their own unit tests. If any becomes a
|
||||
// public muxer, re-export its concrete type from lib.rs instead.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod fmp4;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod hevc;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod m2ts_mux;
|
||||
pub(crate) mod mkv;
|
||||
pub(crate) mod mkvstream;
|
||||
pub(crate) mod network;
|
||||
|
||||
+95
-21
@@ -35,6 +35,11 @@ impl NetworkStream {
|
||||
/// Sends FMKV metadata header on first write.
|
||||
pub fn connect(addr: &str) -> io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
// The sender is the latency-sensitive side; set nodelay here too
|
||||
// (the listen side already does) so the final sub-MSS flush after
|
||||
// finish() isn't held by Nagle. The 256 KB BufWriter coalesces
|
||||
// bulk writes, so this only affects the tail.
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle::empty(),
|
||||
mode: Mode::Write {
|
||||
@@ -44,7 +49,13 @@ impl NetworkStream {
|
||||
})
|
||||
}
|
||||
|
||||
/// Set stream metadata (for write side). Returns self for chaining.
|
||||
/// Set stream metadata (write side only). Returns self for chaining.
|
||||
///
|
||||
/// Only meaningful on a [`connect`](Self::connect)-constructed
|
||||
/// (write) stream — the title is sent in the FMKV header on first
|
||||
/// write. On a [`listen`](Self::listen)-constructed (read) stream
|
||||
/// the stored title is immediately overwritten by the header read in
|
||||
/// `listen()`, so calling `meta()` there is a silent no-op.
|
||||
pub fn meta(mut self, dt: &DiscTitle) -> Self {
|
||||
self.disc_title = dt.clone();
|
||||
self
|
||||
@@ -52,8 +63,19 @@ impl NetworkStream {
|
||||
|
||||
/// Listen for an incoming connection and read from it.
|
||||
/// Extracts FMKV metadata header from the sender.
|
||||
///
|
||||
/// Accepts exactly one connection; the listening socket is dropped after
|
||||
/// `accept`, so the bound port is freed and any subsequent connection
|
||||
/// attempt to the same address is refused.
|
||||
pub fn listen(addr: &str) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
Self::accept_from(TcpListener::bind(addr)?)
|
||||
}
|
||||
|
||||
/// Accept one connection from an already-bound listener and read from it.
|
||||
/// Lets a caller bind first (learning the actual port for an ephemeral
|
||||
/// `:0` bind) and hand the listener in, closing the bind/drop/re-bind race
|
||||
/// that `listen(addr)` would otherwise have.
|
||||
pub fn accept_from(listener: TcpListener) -> io::Result<Self> {
|
||||
let (stream, _peer) = listener.accept()?;
|
||||
stream.set_nodelay(true)?;
|
||||
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
|
||||
@@ -70,6 +92,23 @@ impl NetworkStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the FMKV metadata header exactly once, before any frames. Always
|
||||
/// writes (even when the title has no streams) so the receiver's
|
||||
/// `read_header()` always finds the magic and never falls into the
|
||||
/// NoMetadata path on a zero-frame stream.
|
||||
fn ensure_header_written(
|
||||
writer: &mut BufWriter<TcpStream>,
|
||||
header_written: &mut bool,
|
||||
disc_title: &DiscTitle,
|
||||
) -> io::Result<()> {
|
||||
if !*header_written {
|
||||
let m = meta::M2tsMeta::from_title(disc_title);
|
||||
meta::write_header(writer, &m)?;
|
||||
*header_written = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for NetworkStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
match &mut self.mode {
|
||||
@@ -82,22 +121,24 @@ impl crate::pes::Stream for NetworkStream {
|
||||
Mode::Write {
|
||||
writer,
|
||||
header_written,
|
||||
..
|
||||
} => {
|
||||
if !*header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(&mut *writer, &m)?;
|
||||
}
|
||||
*header_written = true;
|
||||
}
|
||||
ensure_header_written(writer, header_written, &self.disc_title)?;
|
||||
frame.serialize(writer)
|
||||
}
|
||||
_ => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
if let Mode::Write { writer, .. } = &mut self.mode {
|
||||
if let Mode::Write {
|
||||
writer,
|
||||
header_written,
|
||||
} = &mut self.mode
|
||||
{
|
||||
// Always emit the FMKV header before shutdown, even for a
|
||||
// zero-frame stream (e.g. a title that produced no PES frames).
|
||||
// Without it the receiver's read_header() sees a clean EOF and
|
||||
// rejects the stream with NoMetadata.
|
||||
ensure_header_written(writer, header_written, &self.disc_title)?;
|
||||
writer.flush()?;
|
||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||
}
|
||||
@@ -156,19 +197,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires TCP; may be flaky in CI environments
|
||||
fn network_pes_roundtrip() {
|
||||
use crate::pes;
|
||||
use std::sync::mpsc;
|
||||
|
||||
// The listener thread owns the bound socket and reports its actual
|
||||
// local address back over a channel before accept(). The main thread
|
||||
// connects only after receiving the address — no bind/drop/re-bind
|
||||
// window, no sleep-as-synchronisation.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let addr_clone = addr.clone();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (addr_tx, addr_rx) = mpsc::channel();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut ns = NetworkStream::listen(&addr_clone).unwrap();
|
||||
addr_tx.send(addr).unwrap();
|
||||
let mut ns = NetworkStream::accept_from(listener).unwrap();
|
||||
let info = pes::Stream::info(&ns).clone();
|
||||
let mut frames = Vec::new();
|
||||
while let Ok(Some(f)) = pes::Stream::read(&mut ns) {
|
||||
@@ -177,10 +220,9 @@ mod tests {
|
||||
(info, frames)
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let addr = addr_rx.recv().unwrap();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let frame = pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 90000,
|
||||
@@ -199,6 +241,38 @@ mod tests {
|
||||
assert_eq!(frames[0].pts, 90000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_zero_frame_finish_still_sends_header() {
|
||||
use crate::pes;
|
||||
use std::sync::mpsc;
|
||||
|
||||
// A title that produces no PES frames must still send the FMKV header
|
||||
// on finish(), so the receiver gets the metadata instead of rejecting
|
||||
// the stream with NoMetadata on a clean EOF.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (addr_tx, addr_rx) = mpsc::channel();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
addr_tx.send(addr).unwrap();
|
||||
// listen()'s read_header must succeed (header present), not error.
|
||||
let ns = NetworkStream::accept_from(listener).unwrap();
|
||||
pes::Stream::info(&ns).playlist.clone()
|
||||
});
|
||||
|
||||
let addr = addr_rx.recv().unwrap();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
// No write() at all — straight to finish().
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
|
||||
let playlist = handle.join().unwrap();
|
||||
assert_eq!(
|
||||
playlist, "NetworkTest",
|
||||
"zero-frame finish() must still deliver the metadata header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_empty_addr_errors() {
|
||||
let result = NetworkStream::connect("");
|
||||
|
||||
+14
-1
@@ -18,7 +18,10 @@ impl NullStream {
|
||||
|
||||
impl crate::pes::Stream for NullStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
Ok(None)
|
||||
// Write-only sink: per the Stream trait contract, read() on a
|
||||
// write-opened stream returns StreamWriteOnly. Returning Ok(None)
|
||||
// would be misread as a legitimate empty stream.
|
||||
Err(crate::error::Error::StreamWriteOnly.into())
|
||||
}
|
||||
fn write(&mut self, _: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
Ok(())
|
||||
@@ -53,4 +56,14 @@ mod tests {
|
||||
let _ = sink.info();
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
/// read() on the write-only NullStream must return StreamWriteOnly,
|
||||
/// not Ok(None) (which a caller would misread as an empty stream).
|
||||
#[test]
|
||||
fn read_returns_write_only_error() {
|
||||
let title = DiscTitle::empty();
|
||||
let mut sink = NullStream::new(&title);
|
||||
let err = Stream::read(&mut sink).expect_err("read on a sink must error");
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
//! recycled buffer pools — no allocations or memcpys in the steady-
|
||||
//! state hot loop.
|
||||
//!
|
||||
//! This is the *only* read-side `Stream` impl in tree. Both ISO file
|
||||
//! mux ([`crate::mux::resolve`]) and BD-TS file mux ([`crate::mux::M2tsStream`])
|
||||
//! return a `PipelinedPesStream`; the differences are in how the
|
||||
//! producer thread (A) is configured — sector-aligned reads with
|
||||
//! AACS decrypt for ISO, raw byte reads for M2TS.
|
||||
//! This is the *only* read-side `Stream` impl in tree. Both the ISO
|
||||
//! file mux and the BD-TS (`m2ts://`) file mux input paths are built by
|
||||
//! [`crate::mux::resolve`] (`build_iso_pipeline` / the m2ts pipeline
|
||||
//! builder) and hand back a `PipelinedPesStream`; the differences are
|
||||
//! in how the producer thread (A) is configured — sector-aligned reads
|
||||
//! with AACS decrypt for ISO, raw byte reads for M2TS.
|
||||
//! ([`crate::mux::M2tsStream`] itself is a write-only sink and does not
|
||||
//! construct this type.)
|
||||
|
||||
use super::codec::CodecParser;
|
||||
use super::demux_thread::{DemuxBatch, DemuxThread};
|
||||
@@ -48,6 +51,11 @@ pub struct PipelinedPesStream {
|
||||
|
||||
pending_frames: std::collections::VecDeque<PesFrame>,
|
||||
eof: bool,
|
||||
/// Cached `FREEMKV_SKIP_PARSE` profiling flag. Read once in `new()`
|
||||
/// — the env var cannot change for the life of the stream, and
|
||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||
/// per-poll reads it replaces were needless hot-path overhead.
|
||||
skip_parse: bool,
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -55,7 +63,12 @@ impl PipelinedPesStream {
|
||||
/// `DemuxThread` (which in turn owns the producer); we take the
|
||||
/// receiver end + the join handle bundle so cleanup is bounded
|
||||
/// on drop.
|
||||
pub fn new(
|
||||
///
|
||||
/// `pub(crate)`: the signature takes the internal `DemuxThread` /
|
||||
/// `DemuxBatch` / `CodecParser` types, so external callers reach this
|
||||
/// stream via [`super::resolve::input`] / `build_iso_pipeline`
|
||||
/// instead.
|
||||
pub(crate) fn new(
|
||||
demux_thread: DemuxThread,
|
||||
demux_rx: Receiver<DemuxBatch>,
|
||||
title: DiscTitle,
|
||||
@@ -70,6 +83,7 @@ impl PipelinedPesStream {
|
||||
demux_thread,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
eof: false,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +102,19 @@ impl PipelinedPesStream {
|
||||
Ok(true)
|
||||
}
|
||||
Ok(DemuxBatch::Err(e)) => Err(e),
|
||||
Err(_) => Ok(false),
|
||||
// Explicit clean-completion sentinel from the demux worker.
|
||||
Ok(DemuxBatch::Eof) => Ok(false),
|
||||
// The channel disconnected WITHOUT the worker first sending
|
||||
// an `Eof` (or `Err`) sentinel — the worker panicked or was
|
||||
// dropped mid-stream. Surface this as an error so a parser /
|
||||
// demux panic is never reported to the caller as a clean
|
||||
// end-of-stream (which would silently truncate output).
|
||||
Err(_) => Err(crate::error::Error::DemuxThreadPanicked.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_ts(&mut self, packets: Vec<PesPacket>) {
|
||||
let skip_parse = std::env::var_os("FREEMKV_SKIP_PARSE").is_some();
|
||||
let skip_parse = self.skip_parse;
|
||||
for pes in packets {
|
||||
if let Some((_, track)) = self
|
||||
.pid_to_track
|
||||
@@ -218,7 +239,7 @@ impl Stream for PipelinedPesStream {
|
||||
// codec_private before the consumer can write the container
|
||||
// header. FREEMKV_SKIP_PARSE forces ready (no parser ever
|
||||
// populates codec_private in that mode).
|
||||
if std::env::var_os("FREEMKV_SKIP_PARSE").is_some() {
|
||||
if self.skip_parse {
|
||||
return true;
|
||||
}
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
|
||||
+276
-61
@@ -11,6 +11,8 @@
|
||||
//! - 0xC0-0xDF: MPEG audio
|
||||
//! - 0xBD: private stream 1 (AC3, DTS, LPCM, subtitles via sub-stream ID)
|
||||
|
||||
use super::codec::startcode::find_start_code;
|
||||
|
||||
/// Pack header start code suffix.
|
||||
const PACK_HEADER_ID: u8 = 0xBA;
|
||||
|
||||
@@ -23,6 +25,15 @@ const PROGRAM_END_ID: u8 = 0xB9;
|
||||
/// Private stream 1 (AC3, DTS, LPCM, subtitles).
|
||||
const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
|
||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||
/// an unbounded PES and never follows it with a boundary, `feed()` would
|
||||
/// otherwise accumulate the entire input. Past this cap we force the in-progress
|
||||
/// unbounded PES to flush at the buffer end so untrusted input cannot drive
|
||||
/// unbounded allocation. A real DVD pack/PES is at most a few KB; this leaves
|
||||
/// generous slack while still bounding worst-case memory.
|
||||
const MAX_PS_BUFFER: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// A demuxed PES packet from the Program Stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PsPacket {
|
||||
@@ -39,16 +50,48 @@ pub struct PsPacket {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Canonical DVD video PID. DVD-Video carries a single MPEG-2 video
|
||||
/// elementary stream; both the scanner and the muxer use this PID.
|
||||
pub const DVD_VIDEO_PID: u16 = 0xE0;
|
||||
|
||||
/// Canonical PID for a `private_stream_1` audio stream identified by its
|
||||
/// on-wire sub-stream id. Returns `None` for sub-ids outside the AC-3 /
|
||||
/// DTS / LPCM audio ranges.
|
||||
///
|
||||
/// The PID is `0xBD00 | sub_stream_id`, which is unique per sub-stream id
|
||||
/// (AC-3 / DTS `0x80..=0x8F`, LPCM `0xA0..=0xA7`). Unlike the old
|
||||
/// per-codec relative arithmetic, distinct sub-ids therefore always yield
|
||||
/// distinct PIDs — so a mixed-codec title (e.g. AC-3 + DTS, whose sub-ids
|
||||
/// are 0x80 and 0x88) can never collide on one PID. This is the single
|
||||
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
||||
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
match sub_stream_id {
|
||||
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical PID for a VobSub subtitle stream identified by its on-wire
|
||||
/// sub-stream id (`0x20..=0x3F`). The PID is the sub-id itself (identity),
|
||||
/// which never overlaps the `0xBD..` audio PID space.
|
||||
pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
match sub_stream_id {
|
||||
0x20..=0x3F => Some(sub_stream_id as u16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
/// Routes by the REAL on-wire `(stream_id, sub_stream_id)` via the
|
||||
/// shared [`dvd_audio_pid`] / [`dvd_subtitle_pid`] tables the scanner
|
||||
/// also uses — never per-codec relative arithmetic, which collided on
|
||||
/// mixed-codec audio (AC-3 0x80 and DTS 0x88 both mapping to 0xBD00).
|
||||
///
|
||||
/// Returns `None` for stream/sub-stream combinations the DVD title
|
||||
/// scanner does not assign a PID to (e.g. MPEG audio 0xC0-0xDF,
|
||||
@@ -57,16 +100,11 @@ impl PsPacket {
|
||||
/// 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,
|
||||
},
|
||||
0xE0..=0xEF => Some(DVD_VIDEO_PID),
|
||||
0xBD => {
|
||||
let sub = self.sub_stream_id?;
|
||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -97,20 +135,26 @@ impl PsDemuxer {
|
||||
/// Feed raw MPEG-2 PS bytes, returning any completely parsed PES packets.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PsPacket> {
|
||||
self.buffer.extend_from_slice(data);
|
||||
self.extract_packets()
|
||||
self.extract_packets(false)
|
||||
}
|
||||
|
||||
/// Flush remaining buffered data, returning any final PES packets.
|
||||
pub fn flush(&mut self) -> Vec<PsPacket> {
|
||||
// Try to extract whatever remains. If the buffer contains an incomplete
|
||||
// PES packet we cannot parse, it will be discarded.
|
||||
let packets = self.extract_packets();
|
||||
// At EOF, an unbounded (length 0) PES with no trailing start code is
|
||||
// a complete-but-unterminated final packet — emit it rather than
|
||||
// dropping the tail of the last frame. Genuinely incomplete packets
|
||||
// (a length-bounded PES short of its declared size) are still
|
||||
// discarded.
|
||||
let packets = self.extract_packets(true);
|
||||
self.buffer.clear();
|
||||
packets
|
||||
}
|
||||
|
||||
/// Scan the buffer for complete start-code-delimited units and parse them.
|
||||
fn extract_packets(&mut self) -> Vec<PsPacket> {
|
||||
/// Scan the buffer for complete start-code-delimited units and parse
|
||||
/// them. When `flushing` is true, a trailing unbounded PES that has no
|
||||
/// following start code is emitted using the rest of the buffer as its
|
||||
/// payload (EOF terminates it).
|
||||
fn extract_packets(&mut self, flushing: bool) -> Vec<PsPacket> {
|
||||
let mut packets = Vec::with_capacity(4);
|
||||
let mut pos = 0;
|
||||
|
||||
@@ -132,7 +176,10 @@ impl PsDemuxer {
|
||||
if sc + 14 > self.buffer.len() {
|
||||
break; // wait for more data
|
||||
}
|
||||
// MPEG-2 packs have bit pattern 01 in bits 7-6 of byte 4.
|
||||
// DVD-Video is always MPEG-2 PS, so every 0xBA is treated
|
||||
// as a 14-byte MPEG-2 pack: the low 3 bits of byte 13 are
|
||||
// pack_stuffing_length. (An MPEG-1 pack would be 12 bytes
|
||||
// with no stuffing field, but DVD never emits one.)
|
||||
let stuffing = (self.buffer[sc + 13] & 0x07) as usize;
|
||||
let pack_len = 14 + stuffing;
|
||||
if sc + pack_len > self.buffer.len() {
|
||||
@@ -162,13 +209,30 @@ impl PsDemuxer {
|
||||
((self.buffer[sc + 4] as usize) << 8) | self.buffer[sc + 5] as usize;
|
||||
|
||||
// Total bytes = 6 (start code + stream_id + length) + pes_packet_len.
|
||||
// A length of 0 means unbounded (video streams); in that case we need
|
||||
// to find the next start code to delimit the packet.
|
||||
// A length of 0 means unbounded (video streams); in that
|
||||
// case the packet runs to the next PS-LAYER boundary (pack /
|
||||
// system header / program end / next PES), NOT the next raw
|
||||
// start code — the video ES payload is itself full of
|
||||
// 00 00 01 xx codes that would otherwise cut the PES short.
|
||||
let end = if pes_packet_len == 0 {
|
||||
// Find the next start code after this one.
|
||||
match find_start_code(&self.buffer, sc + 4) {
|
||||
Some(next_sc) => next_sc,
|
||||
None => break, // wait for more data
|
||||
match find_ps_boundary(&self.buffer, sc + 4) {
|
||||
Some(next) => next,
|
||||
// At EOF the rest of the buffer is this PES's
|
||||
// payload — emit it.
|
||||
None if flushing => self.buffer.len(),
|
||||
None => {
|
||||
// No boundary buffered yet. Normally wait for
|
||||
// more data, but a corrupt stream could declare
|
||||
// an unbounded PES followed by endless non-
|
||||
// boundary bytes — bounding the buffer here
|
||||
// stops untrusted input forcing unbounded
|
||||
// allocation. Past the cap, flush what we have.
|
||||
if self.buffer.len() - sc > MAX_PS_BUFFER {
|
||||
self.buffer.len()
|
||||
} else {
|
||||
break; // wait for more data
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let e = sc + 6 + pes_packet_len;
|
||||
@@ -198,6 +262,36 @@ impl PsDemuxer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the next PS-layer unit boundary at or after `from`: a start code whose
|
||||
/// ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a
|
||||
/// payload-carrying PES stream ID (0xBD..=0xEF).
|
||||
///
|
||||
/// A length-0 (unbounded) video PES must be delimited by the next PS-layer unit
|
||||
/// — NOT by the next raw `00 00 01`. The MPEG-2 video elementary stream inside
|
||||
/// the PES is itself full of `00 00 01 xx` start codes (picture 0x00, slices
|
||||
/// 0x01..=0xAF, GOP 0xB8, sequence 0xB3); a plain start-code scan would cut the
|
||||
/// PES inside its own payload and re-scan the discarded video bytes as bogus PS
|
||||
/// units. Restricting the search to PS-layer IDs (>= 0xB9, excluding the video
|
||||
/// ES codes below it) frames the unbounded PES at the right boundary.
|
||||
fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
let mut pos = from;
|
||||
while let Some(sc) = find_start_code(data, pos) {
|
||||
if sc + 3 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
let id = data[sc + 3];
|
||||
if id == PACK_HEADER_ID
|
||||
|| id == SYSTEM_HEADER_ID
|
||||
|| id == PROGRAM_END_ID
|
||||
|| is_pes_stream_id(id)
|
||||
{
|
||||
return Some(sc);
|
||||
}
|
||||
pos = sc + 4;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
fn is_pes_stream_id(id: u8) -> bool {
|
||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||
@@ -251,10 +345,15 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
// The PTS (5 bytes at data[9..14]) and DTS (5 bytes at data[14..19])
|
||||
// live INSIDE the PES header, so gate on header_data_len covering them
|
||||
// (>=5 for PTS, >=10 for PTS+DTS), not merely on total length. A
|
||||
// non-conformant packet that sets the flags but declares a too-short
|
||||
// header would otherwise read payload bytes as a bogus timestamp.
|
||||
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
|
||||
pts = Some(parse_pts(&data[9..14]));
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
|
||||
dts = Some(parse_pts(&data[14..19]));
|
||||
}
|
||||
|
||||
@@ -286,13 +385,13 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp field (33 bits at 90kHz).
|
||||
///
|
||||
/// Layout:
|
||||
/// Layout (ISO/IEC 13818-1 Table 2-17):
|
||||
/// ```text
|
||||
/// byte0: [marker_4bits][bit32][marker_1]
|
||||
/// byte1: [bits 31..24]
|
||||
/// byte2: [bits 23..15][marker_1]
|
||||
/// byte3: [bits 14..7]
|
||||
/// byte4: [bits 6..0][marker_1]
|
||||
/// byte0: [prefix:4][pts 32..30:3][marker:1]
|
||||
/// byte1: [pts 29..22:8]
|
||||
/// byte2: [pts 21..15:7][marker:1]
|
||||
/// byte3: [pts 14..7:8]
|
||||
/// byte4: [pts 6..0:7][marker:1]
|
||||
/// ```
|
||||
fn parse_pts(buf: &[u8]) -> u64 {
|
||||
debug_assert!(buf.len() >= 5);
|
||||
@@ -305,14 +404,6 @@ fn parse_pts(buf: &[u8]) -> u64 {
|
||||
((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -537,6 +628,25 @@ mod tests {
|
||||
assert_eq!(p2[0].data, vec![0xAA, 0xBB, 0xCC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_emits_trailing_unbounded_video_pes() {
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
// Unbounded (length 0) video PES with no trailing start code — the
|
||||
// common EOF case. feed() must not emit it (awaiting a delimiter),
|
||||
// but flush() must emit the tail rather than discarding it.
|
||||
let data = vec![
|
||||
0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, // video, length 0 (unbounded)
|
||||
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
||||
0xAA, 0xBB, 0xCC, 0xDD,
|
||||
];
|
||||
let fed = demuxer.feed(&data);
|
||||
assert!(fed.is_empty(), "unbounded PES not emitted until delimited");
|
||||
let flushed = demuxer.flush();
|
||||
assert_eq!(flushed.len(), 1, "flush emits the trailing PES");
|
||||
assert_eq!(flushed[0].stream_id, 0xE0);
|
||||
assert_eq!(flushed[0].data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
}
|
||||
|
||||
// --- Multiple PES packets ---
|
||||
|
||||
#[test]
|
||||
@@ -564,6 +674,74 @@ mod tests {
|
||||
assert_eq!(packets[1].stream_id, 0xC0);
|
||||
}
|
||||
|
||||
// --- unbounded (length-0) video PES framing ---
|
||||
|
||||
#[test]
|
||||
fn unbounded_video_pes_not_cut_by_embedded_start_codes() {
|
||||
// A length-0 video PES whose ES payload contains embedded MPEG start
|
||||
// codes (picture 0x00, slice 0x01, GOP 0xB8, sequence 0xB3) must be
|
||||
// delimited by the NEXT PS-layer boundary (here a program-end 0xB9),
|
||||
// not by the first embedded 00 00 01 inside the payload.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
|
||||
let mut data = vec![
|
||||
0x00, 0x00, 0x01, 0xE0, // video stream
|
||||
0x00, 0x00, // length = 0 (unbounded)
|
||||
0x80, 0x00, 0x00, // flags: no PTS, header_data_len = 0
|
||||
];
|
||||
// ES payload with embedded MPEG-2 start codes.
|
||||
let payload = [
|
||||
0x00, 0x00, 0x01, 0xB3, // sequence header
|
||||
0x11, 0x22, 0x00, 0x00, 0x01, 0x00, // picture start code
|
||||
0x33, 0x44, 0x00, 0x00, 0x01, 0x01, // slice
|
||||
0x55, 0x66,
|
||||
];
|
||||
data.extend_from_slice(&payload);
|
||||
// PS-layer boundary that closes the unbounded PES.
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||
|
||||
let packets = demuxer.feed(&data);
|
||||
assert_eq!(packets.len(), 1, "one PES, not several payload fragments");
|
||||
assert_eq!(packets[0].stream_id, 0xE0);
|
||||
// The whole ES payload survives — none of it discarded as bogus units.
|
||||
assert_eq!(packets[0].data, payload.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_video_pes_waits_for_boundary() {
|
||||
// Without a following PS-layer boundary the unbounded PES is held
|
||||
// (waiting for more data), not emitted truncated.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let mut data = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 0xAA, 0xBB]); // picture SC, no PS boundary
|
||||
let packets = demuxer.feed(&data);
|
||||
assert!(packets.is_empty(), "no PS boundary yet → hold the PES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_video_pes_buffer_is_bounded() {
|
||||
// A corrupt stream declaring an unbounded PES followed by endless
|
||||
// non-boundary bytes must not grow the buffer without limit.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let header = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||
let packets = demuxer.feed(&header);
|
||||
assert!(packets.is_empty());
|
||||
// Feed >MAX_PS_BUFFER of bytes containing no PS-layer boundary.
|
||||
let chunk = vec![0x55u8; 1024 * 1024];
|
||||
let mut emitted = 0;
|
||||
for _ in 0..(MAX_PS_BUFFER / chunk.len() + 4) {
|
||||
emitted += demuxer.feed(&chunk).len();
|
||||
}
|
||||
assert!(
|
||||
demuxer.buffer.len() <= MAX_PS_BUFFER + chunk.len(),
|
||||
"buffer grew to {} (cap {})",
|
||||
demuxer.buffer.len(),
|
||||
MAX_PS_BUFFER
|
||||
);
|
||||
// The force-flush emits the over-long PES rather than accumulating it.
|
||||
assert!(emitted >= 1, "over-cap unbounded PES is force-flushed");
|
||||
}
|
||||
|
||||
// --- PTS parsing edge cases ---
|
||||
|
||||
#[test]
|
||||
@@ -597,14 +775,13 @@ mod tests {
|
||||
#[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(0xE0, None).dvd_pid(), Some(DVD_VIDEO_PID));
|
||||
// PID = 0xBD00 | sub_stream_id — unique per sub-id, no collision.
|
||||
assert_eq!(mk(0xBD, Some(0x80)).dvd_pid(), Some(0xBD80)); // AC-3 #0
|
||||
assert_eq!(mk(0xBD, Some(0x81)).dvd_pid(), Some(0xBD81)); // AC-3 #1
|
||||
assert_eq!(mk(0xBD, Some(0x88)).dvd_pid(), Some(0xBD88)); // DTS #0
|
||||
assert_eq!(mk(0xBD, Some(0xA0)).dvd_pid(), Some(0xBDA0)); // LPCM #0
|
||||
// VobSub subtitle 0x20/0x21 → 0x20 / 0x21 (identity).
|
||||
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.
|
||||
@@ -613,23 +790,61 @@ mod tests {
|
||||
assert_eq!(mk(0xBD, Some(0x10)).dvd_pid(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_codec_audio_does_not_collide() {
|
||||
// The core regression: a title mixing AC-3 (0x80), DTS (0x88) and
|
||||
// LPCM (0xA0) audio. The old per-codec relative arithmetic mapped
|
||||
// all three to 0xBD00. They must now get distinct PIDs that match
|
||||
// what dvd.rs assigns from the same dvd_audio_pid() table.
|
||||
let ac3 = mk(0xBD, Some(0x80)).dvd_pid().unwrap();
|
||||
let dts = mk(0xBD, Some(0x88)).dvd_pid().unwrap();
|
||||
let lpcm = mk(0xBD, Some(0xA0)).dvd_pid().unwrap();
|
||||
assert_ne!(ac3, dts, "AC-3 and DTS must not collide");
|
||||
assert_ne!(ac3, lpcm, "AC-3 and LPCM must not collide");
|
||||
assert_ne!(dts, lpcm, "DTS and LPCM must not collide");
|
||||
|
||||
// Scanner side uses the same table; build a pid_to_track for a
|
||||
// mixed-codec title [video, AC-3, DTS, LPCM, sub] and route every
|
||||
// PS packet to its own distinct track.
|
||||
let pid_to_track: Vec<(u16, usize)> = vec![
|
||||
(DVD_VIDEO_PID, 0),
|
||||
(dvd_audio_pid(0x80).unwrap(), 1),
|
||||
(dvd_audio_pid(0x88).unwrap(), 2),
|
||||
(dvd_audio_pid(0xA0).unwrap(), 3),
|
||||
(dvd_subtitle_pid(0x20).unwrap(), 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)); // AC-3 → its own track
|
||||
assert_eq!(route(mk(0xBD, Some(0x88))), Some(2)); // DTS → its own track
|
||||
assert_eq!(route(mk(0xBD, Some(0xA0))), Some(3)); // LPCM → its own track
|
||||
assert_eq!(route(mk(0xBD, Some(0x20))), Some(4)); // sub → its own track
|
||||
}
|
||||
|
||||
#[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
|
||||
// Subtitle sub-id 0x20 routes to its own subtitle PID (0x20),
|
||||
// distinct from any audio PID (0xBD80+).
|
||||
let audio0 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); // 0xBD80
|
||||
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 pid_to_track: Vec<(u16, usize)> = vec![
|
||||
(DVD_VIDEO_PID, 0),
|
||||
(dvd_audio_pid(0x80).unwrap(), 1),
|
||||
(dvd_audio_pid(0x81).unwrap(), 2),
|
||||
(dvd_subtitle_pid(0x20).unwrap(), 3),
|
||||
(dvd_subtitle_pid(0x21).unwrap(), 4),
|
||||
];
|
||||
let route = |p: PsPacket| -> Option<usize> {
|
||||
let pid = p.dvd_pid()?;
|
||||
pid_to_track
|
||||
|
||||
+125
-33
@@ -14,6 +14,11 @@
|
||||
//!
|
||||
//! Bare paths without a scheme are rejected.
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
//!
|
||||
//! Note: `disc://` cannot be opened through [`input`]; it returns
|
||||
//! [`crate::error::Error::DiscUrlNotDirect`]. Live-disc input must go
|
||||
//! through `Drive::open()` + `Disc::scan()` + `DiscStream::new()`, not
|
||||
//! the URL resolver.
|
||||
|
||||
use super::network::NetworkStream;
|
||||
use super::null::NullStream;
|
||||
@@ -29,6 +34,7 @@ use std::path::{Path, PathBuf};
|
||||
const IO_BUF_SIZE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Parsed stream URL.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamUrl {
|
||||
/// Optical disc drive. Device path is optional (auto-detect if None).
|
||||
Disc { device: Option<PathBuf> },
|
||||
@@ -109,11 +115,18 @@ pub fn parse_url(url: &str) -> StreamUrl {
|
||||
addr: rest.to_string(),
|
||||
};
|
||||
}
|
||||
if url == "null://" || url.starts_with("null://") {
|
||||
return StreamUrl::Null;
|
||||
if let Some(rest) = url.strip_prefix("null://") {
|
||||
// null:// / stdio:// are scheme-only; a trailing path is
|
||||
// malformed and must fall through to Unknown rather than be
|
||||
// silently discarded.
|
||||
if rest.is_empty() {
|
||||
return StreamUrl::Null;
|
||||
}
|
||||
}
|
||||
if url == "stdio://" || url.starts_with("stdio://") {
|
||||
return StreamUrl::Stdio;
|
||||
if let Some(rest) = url.strip_prefix("stdio://") {
|
||||
if rest.is_empty() {
|
||||
return StreamUrl::Stdio;
|
||||
}
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("iso://") {
|
||||
return StreamUrl::Iso {
|
||||
@@ -150,6 +163,16 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
}
|
||||
.into());
|
||||
}
|
||||
// A bare IPv6 literal ("::1", "2001:db8::1") contains ':' yet has no port,
|
||||
// so the simple `contains(':')` check would wrongly pass it and TcpListener
|
||||
// would later return an untyped io::Error. Treat anything that parses as a
|
||||
// bare IpAddr (v4 or v6) as port-less.
|
||||
if addr.parse::<std::net::IpAddr>().is_ok() {
|
||||
return Err(crate::error::Error::StreamUrlMissingPort {
|
||||
addr: addr.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if !addr.contains(':') {
|
||||
return Err(crate::error::Error::StreamUrlMissingPort {
|
||||
addr: addr.to_string(),
|
||||
@@ -160,13 +183,15 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Default)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InputOptions {
|
||||
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
|
||||
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
|
||||
/// caller has no key. The library does no lookup — a key source resolves
|
||||
/// these and the caller passes them here.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// 0-based title index to open; `None` selects title 0. An
|
||||
/// out-of-range index yields [`crate::error::Error::DiscTitleRange`].
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
@@ -253,19 +278,36 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below.
|
||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||
// --raw mode: the probe would re-open + decrypt for nothing (on an
|
||||
// AACS disc with no key the correction is a no-op on ciphertext, and
|
||||
// raw output isn't decoded anyway).
|
||||
let keys = disc.decrypt_keys();
|
||||
if let Ok(probe) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(probe, keys.clone());
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
if !opts.raw {
|
||||
match crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
Ok(probe) => {
|
||||
let mut dec =
|
||||
crate::sector::DecryptingSectorSource::new(probe, keys.clone());
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos
|
||||
// channel counts uncorrected (understated as 5.1). Log so
|
||||
// the uncorrected path is diagnosable rather than silent.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"TrueHD channel-correction probe re-open failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 16 MiB batch — sequential read from fast
|
||||
// storage, no bad sectors. Measured optimum on the rip1
|
||||
// testbed; bumping to 32 MiB regressed (more cache
|
||||
// pressure, longer per-batch latency starves the consumer
|
||||
// between iterations). Physical drives keep smaller
|
||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||
// sequential read from fast storage, no bad sectors. Measured
|
||||
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
|
||||
// regressed (more cache pressure, longer per-batch latency starves
|
||||
// the consumer between iterations). Physical drives keep smaller
|
||||
// batches for adaptive error handling.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
|
||||
@@ -286,7 +328,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
format,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
@@ -403,6 +445,21 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||
/// `PipelinedPesStream`.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `reader`: the sector source to read from (typically a
|
||||
/// `FileSectorSource` over the ISO image).
|
||||
/// - `title`: the selected title; its `extents` drive the read range and its
|
||||
/// `streams` build the demux/parse tables.
|
||||
/// - `keys`: decryption keys applied per sector batch. Pass
|
||||
/// [`crate::decrypt::DecryptKeys::None`] for raw / unencrypted reads (the
|
||||
/// decrypt decorator then becomes a pass-through).
|
||||
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
|
||||
/// throughput/latency tuning knob, not a correctness parameter.
|
||||
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
|
||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
reader: S,
|
||||
title: DiscTitle,
|
||||
@@ -411,7 +468,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
format: ContentFormat,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
) -> PipelinedPesStream {
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
let extents = title.extents.clone();
|
||||
let decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
@@ -421,13 +478,21 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
batch_sectors,
|
||||
halt.clone(),
|
||||
event_fn,
|
||||
);
|
||||
)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
let (rx, recycle_tx, shell) = prefetched.into_channels();
|
||||
|
||||
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, format);
|
||||
let (demux_thread, demux_rx) =
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps);
|
||||
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
Ok(PipelinedPesStream::new(
|
||||
demux_thread,
|
||||
demux_rx,
|
||||
title,
|
||||
parsers,
|
||||
pid_to_track,
|
||||
))
|
||||
}
|
||||
|
||||
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
||||
@@ -455,19 +520,32 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
|
||||
};
|
||||
head.truncate(head_len);
|
||||
|
||||
// Try FMKV metadata header first; fall back to PMT scan.
|
||||
// Try FMKV metadata header first; fall back to PMT scan. Only a
|
||||
// genuine absence of the FMKV magic (`Ok(None)`) falls through to
|
||||
// the PMT path — a corrupt/truncated FMKV header (`Err`) propagates
|
||||
// instead of being misreported as a PMT-derived title or NoStreams.
|
||||
let mut cursor = io::Cursor::new(&head);
|
||||
let (title, head_consumed) = if let Ok(Some(m)) = meta::read_header(&mut cursor) {
|
||||
(m.to_title(), cursor.position() as usize)
|
||||
} else {
|
||||
let streams = super::ts::scan_streams(&head)
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
|
||||
let t = DiscTitle {
|
||||
duration_secs: 0.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
(t, 0)
|
||||
let (title, head_consumed) = match meta::read_header(&mut cursor)? {
|
||||
Some(m) => {
|
||||
let t = m.to_title();
|
||||
// Guard the FMKV branch the same way the ISO and PMT paths
|
||||
// do: a header carrying zero streams yields an empty title
|
||||
// that would mux nothing — surface NoStreams instead.
|
||||
if t.streams.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
(t, cursor.position() as usize)
|
||||
}
|
||||
None => {
|
||||
let streams = super::ts::scan_streams(&head)
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
|
||||
let t = DiscTitle {
|
||||
duration_secs: 0.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
(t, 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Chain: any un-consumed head bytes + the remainder of the
|
||||
@@ -479,12 +557,13 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
|
||||
chained,
|
||||
crate::io::byte_prefetcher::DEFAULT_CHUNK_BYTES,
|
||||
None,
|
||||
);
|
||||
)?;
|
||||
let (rx, recycle_tx, shell) = prefetcher.into_channels();
|
||||
|
||||
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, ContentFormat::BdTs);
|
||||
let (demux_thread, demux_rx) =
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, None, ts, ps);
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, None, ts, ps)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
Ok(PipelinedPesStream::new(
|
||||
demux_thread,
|
||||
demux_rx,
|
||||
@@ -497,8 +576,21 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::aacs_key_missing;
|
||||
use super::validate_network_addr;
|
||||
use crate::decrypt::DecryptKeys;
|
||||
|
||||
#[test]
|
||||
fn validate_network_addr_rejects_portless() {
|
||||
// Empty, bare IPv4, and bare IPv6 (which contains ':') must all fail.
|
||||
assert!(validate_network_addr("").is_err());
|
||||
assert!(validate_network_addr("127.0.0.1").is_err());
|
||||
assert!(validate_network_addr("::1").is_err());
|
||||
assert!(validate_network_addr("2001:db8::1").is_err());
|
||||
// host:port and ip:port forms pass.
|
||||
assert!(validate_network_addr("127.0.0.1:9000").is_ok());
|
||||
assert!(validate_network_addr("host:9000").is_ok());
|
||||
}
|
||||
|
||||
fn aacs_keys() -> DecryptKeys {
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(1, [0x11u8; 16])],
|
||||
|
||||
+55
-20
@@ -15,7 +15,12 @@ pub struct StdioStream {
|
||||
writer: Option<io::BufWriter<io::Stdout>>,
|
||||
header_written: bool,
|
||||
header_read: bool,
|
||||
stored_codec_privates: Vec<Option<Vec<u8>>>,
|
||||
/// True once an FMKV header was actually parsed on the read side
|
||||
/// (set only inside the `Some(meta)` arm). Distinct from
|
||||
/// `header_read`, which is true after the first read attempt even
|
||||
/// when no header was present — `headers_ready()` must gate on the
|
||||
/// metadata actually being available, not merely on having looked.
|
||||
meta_parsed: bool,
|
||||
}
|
||||
|
||||
impl StdioStream {
|
||||
@@ -27,7 +32,7 @@ impl StdioStream {
|
||||
writer: None,
|
||||
header_written: false,
|
||||
header_read: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
meta_parsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +44,25 @@ impl StdioStream {
|
||||
writer: Some(io::BufWriter::new(io::stdout())),
|
||||
header_written: false,
|
||||
header_read: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
meta_parsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the FMKV metadata header to stdout exactly once, before any
|
||||
/// frames. Always writes (even when the title has no streams) so a
|
||||
/// zero-frame output stream still emits the magic + metadata header,
|
||||
/// keeping the wire protocol symmetric with the read side's read_header().
|
||||
fn ensure_header_written(&mut self) -> io::Result<()> {
|
||||
if let Some(w) = &mut self.writer {
|
||||
if !self.header_written {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(w, &m)?;
|
||||
self.header_written = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the FMKV metadata header from stdin on first read.
|
||||
fn ensure_header_read(&mut self) -> io::Result<()> {
|
||||
if self.header_read {
|
||||
@@ -50,10 +70,16 @@ impl StdioStream {
|
||||
}
|
||||
self.header_read = true;
|
||||
if let Some(ref mut r) = self.reader {
|
||||
if let Ok(Some(m)) = meta::read_header(r) {
|
||||
let title = m.to_title();
|
||||
self.stored_codec_privates = title.codec_privates.clone();
|
||||
self.disc_title = title;
|
||||
// Propagate real header errors. read_header consumes bytes
|
||||
// from the unbuffered stdin BEFORE it can fail (oversized
|
||||
// length, bad JSON, partial read), so swallowing the Err
|
||||
// would leave the stream misaligned and PesFrame::deserialize
|
||||
// would then read garbage. `?` surfaces the true error;
|
||||
// Ok(None) (genuine magic mismatch / clean EOF) stays a
|
||||
// non-error and leaves the empty default title in place.
|
||||
if let Some(m) = meta::read_header(r)? {
|
||||
self.disc_title = m.to_title();
|
||||
self.meta_parsed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -69,21 +95,20 @@ impl crate::pes::Stream for StdioStream {
|
||||
}
|
||||
}
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
if self.writer.is_none() {
|
||||
return Err(crate::error::Error::StreamReadOnly.into());
|
||||
}
|
||||
self.ensure_header_written()?;
|
||||
match &mut self.writer {
|
||||
Some(w) => {
|
||||
if !self.header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
meta::write_header(w, &m)?;
|
||||
}
|
||||
self.header_written = true;
|
||||
}
|
||||
frame.serialize(w)
|
||||
}
|
||||
Some(w) => frame.serialize(w),
|
||||
None => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
// Emit the header even when write() was never called, so a zero-frame
|
||||
// title still produces the FMKV magic + metadata header on stdout
|
||||
// (symmetric with the read side's read_header()).
|
||||
self.ensure_header_written()?;
|
||||
if let Some(w) = &mut self.writer {
|
||||
w.flush()?;
|
||||
}
|
||||
@@ -94,13 +119,23 @@ impl crate::pes::Stream for StdioStream {
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
self.stored_codec_privates
|
||||
// Single source of truth: the title's own codec_privates. (The
|
||||
// previous `stored_codec_privates` field was a redundant clone
|
||||
// of exactly this, populated from the same header.)
|
||||
self.disc_title
|
||||
.codec_privates
|
||||
.get(track)
|
||||
.and_then(|c| c.clone())
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
// After first read(), header is parsed and codec_privates populated
|
||||
self.header_read || self.writer.is_some()
|
||||
// Write side: caller supplied the title up front, so headers are
|
||||
// always ready. Read side: ready only once an FMKV header was
|
||||
// actually parsed — gating on `header_read` alone would claim
|
||||
// readiness for a headerless stream whose codec_private() is None
|
||||
// for every track, starving the downstream MKV writer of init
|
||||
// data. A genuinely headerless stream never flips ready (the
|
||||
// caller must then fall back to its own codec detection).
|
||||
self.writer.is_some() || self.meta_parsed
|
||||
}
|
||||
}
|
||||
|
||||
+464
-147
@@ -128,6 +128,14 @@ impl TsDemuxer {
|
||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||
/// valid (wholly-unused) table.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
// The PID→assembler index is stored as i16 (-1 = untracked), so a
|
||||
// 32768th+ tracked PID would truncate to a negative value and be
|
||||
// silently treated as untracked. Callers pass a handful of PIDs
|
||||
// (BD-TS has at most ~8192), so this is a programmer-error guard.
|
||||
debug_assert!(
|
||||
pids.len() <= i16::MAX as usize,
|
||||
"TsDemuxer: too many PIDs for an i16 index table"
|
||||
);
|
||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||
let table_size = (max_pid + 1).max(8192);
|
||||
let mut pid_index = vec![-1i16; table_size];
|
||||
@@ -222,6 +230,13 @@ impl TsDemuxer {
|
||||
if idx < 0 {
|
||||
return;
|
||||
}
|
||||
// adaptation_field_control == 0b00 is reserved (ISO 13818-1) and
|
||||
// carries no payload; discard so a corrupt/desynced packet can't
|
||||
// inject its 184 bytes into the PES assembler.
|
||||
if adaptation == 0x00 {
|
||||
return;
|
||||
}
|
||||
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
@@ -309,23 +324,24 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
|
||||
let stream_id = data[3];
|
||||
|
||||
// Some stream IDs don't have the standard PES header extension
|
||||
// (program_stream_map, padding, private_stream_2, ECM, EMM, etc.)
|
||||
// Some stream IDs don't carry the standard PES header extension
|
||||
// (ISO 13818-1 Table 2-22: program_stream_map, padding, private_stream_2,
|
||||
// ECM, EMM, DSMCC_stream 0xF2, H.222.1 type E 0xF8, program_stream_directory).
|
||||
if stream_id == 0xBC
|
||||
|| stream_id == 0xBE
|
||||
|| stream_id == 0xBF
|
||||
|| stream_id == 0xF0
|
||||
|| stream_id == 0xF1
|
||||
|| stream_id == 0xF2
|
||||
|| stream_id == 0xF8
|
||||
|| stream_id == 0xFF
|
||||
{
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
// Standard PES header: [6] = flags1, [7] = flags2, [8] = header_data_length
|
||||
if data.len() < 9 {
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
// Standard PES header: [6] = flags1, [7] = flags2, [8] = header_data_length.
|
||||
// The `data.len() < 9` precondition was already checked at the top of
|
||||
// this function and nothing shrinks `data` since, so no re-check here.
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
// Full, uncapped header length. PTS/DTS (if present) live in the
|
||||
@@ -352,8 +368,9 @@ fn parse_timestamp(data: &[u8]) -> Option<i64> {
|
||||
if data.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
// Validate marker bits: byte 2 bit 0 and byte 4 bit 0 must be 1
|
||||
if (data[2] & 0x01) == 0 || (data[4] & 0x01) == 0 {
|
||||
// Validate marker bits: per MPEG-2 Systems (Table 2-17) bit 0 of
|
||||
// bytes 0, 2 and 4 of the 5-byte PTS/DTS field must all be 1.
|
||||
if (data[0] & 0x01) == 0 || (data[2] & 0x01) == 0 || (data[4] & 0x01) == 0 {
|
||||
return None;
|
||||
}
|
||||
let b0 = data[0] as i64;
|
||||
@@ -369,167 +386,252 @@ fn parse_timestamp(data: &[u8]) -> Option<i64> {
|
||||
// Stream scanning (PAT/PMT → stream list)
|
||||
// ============================================================
|
||||
|
||||
/// Whether `offset` is a credible BD-TS packet boundary in the PSI scanner.
|
||||
///
|
||||
/// Requires the sync byte at `data[offset + 4]`, and — to avoid latching onto
|
||||
/// a stray 0x47 inside a TP_extra_header or payload during a desync — also
|
||||
/// requires the next 192-spaced position to carry a sync byte when one exists
|
||||
/// in the buffer. A lone trailing packet (no follower in range) is accepted on
|
||||
/// its single sync byte.
|
||||
fn is_resync_point(data: &[u8], offset: usize) -> bool {
|
||||
if data.get(offset + 4) != Some(&SYNC_BYTE) {
|
||||
return false;
|
||||
}
|
||||
match data.get(offset + BD_TS_PACKET_SIZE + 4) {
|
||||
Some(&b) => b == SYNC_BYTE,
|
||||
None => true, // last packet in the buffer — no follower to corroborate
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the byte offset of the PSI payload (the pointer_field) for a BD-TS
|
||||
/// packet starting at `pkt` (the 4-byte TP_extra_header + 188-byte TS packet).
|
||||
///
|
||||
/// Accounts for the adaptation_field_control (bits 5:4 of the 4th TS header
|
||||
/// byte). Returns `None` when the packet carries no payload (AFC 0b10 = AF
|
||||
/// only, or the reserved 0b00) or when the adaptation field length runs past
|
||||
/// the packet. `pkt` must be at least [`BD_TS_PACKET_SIZE`] bytes.
|
||||
fn psi_payload_base(pkt: &[u8]) -> Option<usize> {
|
||||
// TS header is pkt[4..]; byte pkt[7] holds AFC in bits 5:4.
|
||||
let afc = (pkt[7] >> 4) & 0x03;
|
||||
match afc {
|
||||
0x01 => Some(8), // payload only: 4 (TP_extra) + 4 (TS header)
|
||||
0x03 => {
|
||||
// Adaptation field present + payload. AF length byte is pkt[8];
|
||||
// payload starts after it.
|
||||
let af_len = pkt[8] as usize;
|
||||
let base = 9 + af_len; // 4 + 4 + 1(length byte) + af_len
|
||||
if base < BD_TS_PACKET_SIZE {
|
||||
Some(base)
|
||||
} else {
|
||||
None // AF overruns the packet
|
||||
}
|
||||
}
|
||||
// 0x02 = AF only (no payload), 0x00 = reserved.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reassemble a single PSI section (PAT / PMT) for `target_pid` with
|
||||
/// the expected `table_id`, respecting TS-packet boundaries.
|
||||
///
|
||||
/// The section pointed at by `pointer_field` in the PUSI packet may be
|
||||
/// longer than the 184-byte TS payload (PSI sections can reach 1021
|
||||
/// bytes; a PMT with many ES entries spans 2+ packets). Reading a flat
|
||||
/// slice of the input would walk straight through the next packet's
|
||||
/// TP_extra_header + TS header as if it were table content, yielding a
|
||||
/// wrong PID / garbage stream_type. This walks the PUSI packet, applies
|
||||
/// `pointer_field` bounded to within that packet's payload, then appends
|
||||
/// the payload of each subsequent continuation packet (same PID, no
|
||||
/// PUSI) until `3 + section_length` bytes have been collected.
|
||||
///
|
||||
/// The PUSI packet's payload base is computed with [`psi_payload_base`]
|
||||
/// so a PSI section carried behind an adaptation field is located
|
||||
/// correctly rather than assuming the payload starts at `offset + 8`.
|
||||
///
|
||||
/// Returns the section bytes (starting at the table_id) or `None` if no
|
||||
/// matching section is found.
|
||||
fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> {
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if !is_resync_point(data, offset) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
|
||||
if pid == target_pid && pusi {
|
||||
// Locate the payload (pointer_field) accounting for any
|
||||
// adaptation field. A packet with no payload (AF only) or an
|
||||
// AF that overruns the packet is skipped.
|
||||
let Some(payload_off) = psi_payload_base(&data[offset..offset + BD_TS_PACKET_SIZE])
|
||||
else {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
};
|
||||
let payload = &data[offset + payload_off..offset + BD_TS_PACKET_SIZE];
|
||||
// pointer_field is the FIRST payload byte; the section starts
|
||||
// pointer_field bytes after it. Bound the start to within
|
||||
// THIS packet's payload — a pointer that runs into the next
|
||||
// packet is malformed.
|
||||
let pointer = payload[0] as usize;
|
||||
let sec_start = 1 + pointer;
|
||||
if sec_start + 3 > payload.len() || payload[sec_start] != table_id {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let section_len =
|
||||
(((payload[sec_start + 1] & 0x0F) as usize) << 8) | payload[sec_start + 2] as usize;
|
||||
let total = 3 + section_len; // table_id + 2 length bytes + body
|
||||
let mut section = Vec::with_capacity(total);
|
||||
section.extend_from_slice(&payload[sec_start..]);
|
||||
if section.len() >= total {
|
||||
section.truncate(total);
|
||||
return Some(section);
|
||||
}
|
||||
// Need continuation packets: same PID, no PUSI.
|
||||
let mut scan = offset + BD_TS_PACKET_SIZE;
|
||||
while scan + BD_TS_PACKET_SIZE <= data.len() && section.len() < total {
|
||||
if data[scan + 4] != SYNC_BYTE {
|
||||
scan += 1;
|
||||
continue;
|
||||
}
|
||||
let cpid = (((data[scan + 5] & 0x1F) as u16) << 8) | data[scan + 6] as u16;
|
||||
let cpusi = data[scan + 5] & 0x40 != 0;
|
||||
if cpid == target_pid && !cpusi {
|
||||
// Continuation packets may also carry an adaptation
|
||||
// field; compute their payload base the same way.
|
||||
if let Some(cbase) = psi_payload_base(&data[scan..scan + BD_TS_PACKET_SIZE]) {
|
||||
section.extend_from_slice(&data[scan + cbase..scan + BD_TS_PACKET_SIZE]);
|
||||
}
|
||||
}
|
||||
scan += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
if section.len() >= total {
|
||||
section.truncate(total);
|
||||
return Some(section);
|
||||
}
|
||||
// Incomplete section (truncated input) — stop looking.
|
||||
return None;
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan BD-TS data for streams by parsing PAT and PMT tables.
|
||||
/// Returns None if no valid program is found.
|
||||
pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
use crate::disc::*;
|
||||
|
||||
// Pass 1: find PMT PID from PAT
|
||||
// Pass 1: find PMT PID from PAT (table_id 0x00 on PID 0).
|
||||
let pat = collect_psi_section(data, 0, 0x00)?;
|
||||
let pat_section_len = (((pat[1] & 0x0F) as usize) << 8) | pat[2] as usize;
|
||||
if pat_section_len < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut pat_pmt_pid: Option<u16> = None;
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
|
||||
if pid == 0 && pusi {
|
||||
let payload_start = offset + 4 + 4;
|
||||
if payload_start + 12 < data.len() {
|
||||
let pointer = data[payload_start] as usize;
|
||||
let pat_start = payload_start + 1 + pointer;
|
||||
if pat_start + 12 < data.len() && data[pat_start] == 0x00 {
|
||||
let section_len = (((data[pat_start + 1] & 0x0F) as usize) << 8)
|
||||
| data[pat_start + 2] as usize;
|
||||
let entries_start = pat_start + 8;
|
||||
if section_len < 4 {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let entries_end = pat_start + 3 + section_len - 4;
|
||||
let mut e = entries_start;
|
||||
while e + 4 <= data.len() && e < entries_end {
|
||||
let prog_num = ((data[e] as u16) << 8) | data[e + 1] as u16;
|
||||
let p = (((data[e + 2] & 0x1F) as u16) << 8) | data[e + 3] as u16;
|
||||
if prog_num != 0 {
|
||||
pat_pmt_pid = Some(p);
|
||||
break;
|
||||
}
|
||||
e += 4;
|
||||
}
|
||||
}
|
||||
{
|
||||
let entries_start = 8;
|
||||
// section_length counts bytes after the length field, incl. the
|
||||
// 4-byte CRC; the program loop stops before the CRC.
|
||||
let entries_end = (3 + pat_section_len - 4).min(pat.len());
|
||||
let mut e = entries_start;
|
||||
while e + 4 <= entries_end {
|
||||
let prog_num = ((pat[e] as u16) << 8) | pat[e + 1] as u16;
|
||||
let p = (((pat[e + 2] & 0x1F) as u16) << 8) | pat[e + 3] as u16;
|
||||
if prog_num != 0 {
|
||||
pat_pmt_pid = Some(p);
|
||||
break;
|
||||
}
|
||||
e += 4;
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
|
||||
let pmt_pid = pat_pmt_pid?;
|
||||
|
||||
// Pass 2: parse PMT for stream entries
|
||||
// Pass 2: parse PMT for stream entries (table_id 0x02 on pmt_pid).
|
||||
let mut streams = Vec::new();
|
||||
offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
let pmt = collect_psi_section(data, pmt_pid, 0x02)?;
|
||||
if pmt.len() >= 12 {
|
||||
let section_len = (((pmt[1] & 0x0F) as usize) << 8) | pmt[2] as usize;
|
||||
// section_length counts the bytes after this field, including the
|
||||
// trailing 4-byte CRC; `< 4` would underflow `end` below.
|
||||
if section_len < 4 {
|
||||
return None;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
let prog_info_len = (((pmt[10] & 0x0F) as usize) << 8) | pmt[11] as usize;
|
||||
let mut pos = 12 + prog_info_len;
|
||||
// Clamp the section end to the reassembled bytes; a malformed
|
||||
// section_len or prog_info_len must never drive reads past `pmt`.
|
||||
let end = (3 + section_len - 4).min(pmt.len());
|
||||
|
||||
if pid == pmt_pid && pusi {
|
||||
let payload_start = offset + 4 + 4;
|
||||
if payload_start + 1 >= data.len() {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let pointer = data[payload_start] as usize;
|
||||
let pmt_start = payload_start + 1 + pointer;
|
||||
if pmt_start + 12 >= data.len() {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
if data[pmt_start] != 0x02 {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
while pos + 5 <= end {
|
||||
let stream_type = pmt[pos];
|
||||
let es_pid = (((pmt[pos + 1] & 0x1F) as u16) << 8) | pmt[pos + 2] as u16;
|
||||
let es_info_len = (((pmt[pos + 3] & 0x0F) as usize) << 8) | pmt[pos + 4] as usize;
|
||||
|
||||
let section_len =
|
||||
(((data[pmt_start + 1] & 0x0F) as usize) << 8) | data[pmt_start + 2] as usize;
|
||||
// section_length counts the bytes after this field, including the
|
||||
// trailing 4-byte CRC; `< 4` would underflow `end` below. Guard it
|
||||
// exactly like the PAT parser above.
|
||||
if section_len < 4 {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let prog_info_len =
|
||||
(((data[pmt_start + 10] & 0x0F) as usize) << 8) | data[pmt_start + 11] as usize;
|
||||
let mut pos = pmt_start + 12 + prog_info_len;
|
||||
// Clamp the section end to the buffer; a malformed section_len or
|
||||
// prog_info_len must never drive reads past `data`.
|
||||
let end = (pmt_start + 3 + section_len - 4).min(data.len());
|
||||
|
||||
while pos + 5 <= data.len() && pos < end {
|
||||
let stream_type = data[pos];
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
// 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,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
resolution,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: es_pid,
|
||||
codec,
|
||||
language: "und".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: 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 {
|
||||
streams.push(s);
|
||||
}))
|
||||
}
|
||||
pos += 5 + es_info_len;
|
||||
CodecKind::Audio => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: es_pid,
|
||||
codec,
|
||||
language: "und".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: 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 {
|
||||
streams.push(s);
|
||||
}
|
||||
break;
|
||||
pos += 5 + es_info_len;
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
|
||||
if streams.is_empty() {
|
||||
@@ -646,6 +748,139 @@ mod tests {
|
||||
bdts_packet(body, pmt_pid, true)
|
||||
}
|
||||
|
||||
/// Build a 192-byte BD-TS data packet on `pid` carrying `payload`
|
||||
/// (payload-only adaptation, truncated/padded to fit one packet).
|
||||
fn data_packet(pid: u16, pusi: bool, payload: &[u8]) -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
|
||||
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
|
||||
let room = TS_PACKET_SIZE - 4; // 184 ES bytes after the 4-byte TS header
|
||||
let n = payload.len().min(room);
|
||||
pkt[8..8 + n].copy_from_slice(&payload[..n]);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Like `pmt_packet` but with a 2-byte adaptation field (AFC=0b11) of
|
||||
/// stuffing before the payload, to exercise the adaptation-field-aware
|
||||
/// payload base computation in scan_streams.
|
||||
fn pmt_packet_with_af(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> {
|
||||
let af_len: u8 = 2; // 1 flags byte + 1 stuffing byte
|
||||
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
|
||||
pkt[4] = SYNC_BYTE;
|
||||
pkt[5] = (((pmt_pid >> 8) as u8) & 0x1F) | 0x40; // PUSI set
|
||||
pkt[6] = (pmt_pid & 0xFF) as u8;
|
||||
pkt[7] = 0x30; // AFC = 0b11 (adaptation + payload)
|
||||
pkt[8] = af_len; // adaptation_field_length
|
||||
pkt[9] = 0x00; // AF flags
|
||||
pkt[10] = 0xFF; // stuffing
|
||||
// Payload (PSI) begins at 4 + 4 + 1 + af_len = 11.
|
||||
let payload_off = 4 + 4 + 1 + af_len as usize;
|
||||
let mut body = vec![0xFFu8; BD_TS_PACKET_SIZE - payload_off];
|
||||
body[0] = 0x00; // pointer_field
|
||||
let s = 1;
|
||||
body[s] = 0x02; // table_id = PMT
|
||||
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;
|
||||
body[s + 4] = 0x01;
|
||||
body[s + 5] = 0xC1;
|
||||
body[s + 6] = 0x00;
|
||||
body[s + 7] = 0x00;
|
||||
body[s + 8] = 0xE0;
|
||||
body[s + 9] = 0x00;
|
||||
body[s + 10] = 0xF0;
|
||||
body[s + 11] = 0x00;
|
||||
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;
|
||||
body[p + 4] = 0x00;
|
||||
p += 5;
|
||||
}
|
||||
pkt[payload_off..].copy_from_slice(&body);
|
||||
pkt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_pes_payload_injects_no_header_bytes() {
|
||||
// A PUSI packet whose payload is NOT a valid PES start
|
||||
// (no 00 00 01 start code / too short) must contribute ZERO bytes to
|
||||
// the assembled elementary stream — otherwise a stray 00 00 01 in the
|
||||
// garbage masquerades as an Annex-B NAL / PES start code in the codec
|
||||
// parser. Only the following well-formed continuation bytes survive.
|
||||
let pid = 0x1011;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
|
||||
// Garbage PUSI payload with NO valid PES start code (no leading
|
||||
// 00 00 01). It must parse as malformed → header_len 0 → nothing
|
||||
// pushed. The bytes include a 00 00 01 03 sequence mid-payload that,
|
||||
// if leaked, would masquerade as an Annex-B NAL / PES start code.
|
||||
let mut garbage = vec![0xAAu8; 32];
|
||||
garbage[8] = 0x00;
|
||||
garbage[9] = 0x00;
|
||||
garbage[10] = 0x01;
|
||||
garbage[11] = 0x03;
|
||||
let mut stream = demux.feed(&data_packet(pid, true, &garbage));
|
||||
assert!(
|
||||
stream.is_empty(),
|
||||
"garbage PUSI packet must not complete a PES on its own"
|
||||
);
|
||||
|
||||
// Continuation packet (no PUSI) carrying real ES bytes.
|
||||
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
stream.extend(demux.feed(&data_packet(pid, false, &es)));
|
||||
stream.extend(demux.flush());
|
||||
|
||||
assert_eq!(stream.len(), 1, "one PES assembled from the continuation");
|
||||
let pes = &stream[0];
|
||||
// The continuation ES bytes survive…
|
||||
assert!(
|
||||
pes.data.windows(es.len()).any(|w| w == es),
|
||||
"continuation ES bytes present, got {:02X?}",
|
||||
pes.data
|
||||
);
|
||||
// …but none of the garbage PUSI payload leaked in. In particular the
|
||||
// 0xAA filler and the embedded 00 00 01 sequence must be absent — the
|
||||
// malformed PES header contributed ZERO bytes to the elementary stream.
|
||||
assert!(
|
||||
!pes.data.iter().any(|&b| b == 0xAA),
|
||||
"garbage PES-header bytes must not appear in the elementary stream"
|
||||
);
|
||||
assert!(
|
||||
!pes.data.windows(3).any(|w| w == [0x00, 0x00, 0x01]),
|
||||
"no injected start code leaked from the malformed PES header"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_streams_handles_adaptation_field_in_pmt() {
|
||||
use crate::disc::{Codec, Stream};
|
||||
let pmt_pid = 0x0100;
|
||||
let mut data = pat_packet(pmt_pid);
|
||||
// PMT carried in a packet with an adaptation field — payload base must
|
||||
// account for af_len, not assume offset+8.
|
||||
data.extend(pmt_packet_with_af(pmt_pid, &[(0x1B, 0x1011)]));
|
||||
// Follower sync byte so is_resync_point corroborates the PMT packet.
|
||||
data.extend(pat_packet(pmt_pid));
|
||||
|
||||
let streams = scan_streams(&data).expect("PMT with AF should parse");
|
||||
assert!(
|
||||
streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
|
||||
"H.264 video must be found past the adaptation field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_streams_maps_lpcm_via_from_coding_type() {
|
||||
use crate::disc::{Codec, Stream};
|
||||
@@ -673,4 +908,86 @@ mod tests {
|
||||
"H.264 video present"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a PMT whose reassembled section spans MORE than one 184-byte
|
||||
/// TS payload, returned as two BD-TS packets: a PUSI packet carrying
|
||||
/// the section head and a continuation (no-PUSI) packet carrying the
|
||||
/// tail. The reassembler must stitch them back together; a flat-slice
|
||||
/// parser would read the continuation packet's TS header as table
|
||||
/// content and mis-type or drop the trailing entries.
|
||||
fn pmt_two_packets(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> {
|
||||
// Assemble the raw PSI section (table_id + length + body + CRC).
|
||||
let entries_len = entries.len() * 5;
|
||||
let section_length = 9 + entries_len + 4; // fixed PMT fields + entries + CRC
|
||||
let mut section = Vec::new();
|
||||
section.push(0x02); // table_id
|
||||
section.push(0xB0 | (((section_length >> 8) as u8) & 0x0F));
|
||||
section.push((section_length & 0xFF) as u8);
|
||||
section.extend_from_slice(&[0x00, 0x01]); // program_number
|
||||
section.push(0xC1); // version/current_next
|
||||
section.push(0x00); // section_number
|
||||
section.push(0x00); // last_section_number
|
||||
section.extend_from_slice(&[0xE0, 0x00]); // PCR PID
|
||||
section.extend_from_slice(&[0xF0, 0x00]); // program_info_length = 0
|
||||
for &(stype, es_pid) in entries {
|
||||
section.push(stype);
|
||||
section.push(0xE0 | (((es_pid >> 8) as u8) & 0x1F));
|
||||
section.push((es_pid & 0xFF) as u8);
|
||||
section.extend_from_slice(&[0xF0, 0x00]); // ES_info_length = 0
|
||||
}
|
||||
section.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); // CRC (unchecked)
|
||||
|
||||
// First packet payload: pointer_field(0) + as much section as fits.
|
||||
let first_cap = 184 - 1; // minus pointer_field
|
||||
let head_len = first_cap.min(section.len());
|
||||
let mut p0 = [0xFFu8; 184];
|
||||
p0[0] = 0x00; // pointer_field
|
||||
p0[1..1 + head_len].copy_from_slice(§ion[..head_len]);
|
||||
let pkt0 = bdts_packet(p0, pmt_pid, true);
|
||||
|
||||
// Continuation packet (no PUSI) carries the rest.
|
||||
let mut p1 = [0xFFu8; 184];
|
||||
let tail = §ion[head_len..];
|
||||
assert!(!tail.is_empty(), "test must actually span two packets");
|
||||
p1[..tail.len()].copy_from_slice(tail);
|
||||
let pkt1 = bdts_packet(p1, pmt_pid, false);
|
||||
|
||||
let mut out = pkt0;
|
||||
out.extend(pkt1);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_streams_reassembles_pmt_across_packets() {
|
||||
use crate::disc::{Codec, Stream};
|
||||
let pmt_pid = 0x0100;
|
||||
// Enough entries that the section exceeds one 183-byte payload:
|
||||
// 12 fixed + 4*N*... at 5 bytes/entry; 40 entries = 200 bytes of
|
||||
// entries alone, forcing a continuation packet.
|
||||
let mut entries: Vec<(u8, u16)> = Vec::new();
|
||||
entries.push((0x1B, 0x1011)); // H.264 video
|
||||
for i in 0..40u16 {
|
||||
entries.push((0x80, 0x1100 + i)); // LPCM audio tracks
|
||||
}
|
||||
let mut data = pat_packet(pmt_pid);
|
||||
data.extend(pmt_two_packets(pmt_pid, &entries));
|
||||
|
||||
let streams = scan_streams(&data).expect("multi-packet PMT should parse");
|
||||
// All entries must survive reassembly (video + 40 audio).
|
||||
assert_eq!(streams.len(), entries.len(), "every PMT entry reassembled");
|
||||
assert!(
|
||||
streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
|
||||
"video survives the split"
|
||||
);
|
||||
// The LAST audio entry lives in the continuation packet — proves
|
||||
// the tail was stitched in, not read from a TS header.
|
||||
assert!(
|
||||
streams.iter().any(
|
||||
|s| matches!(s, Stream::Audio(a) if a.pid == 0x1100 + 39 && a.codec == Codec::Lpcm)
|
||||
),
|
||||
"trailing audio entry from the continuation packet survives"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+257
-87
@@ -4,17 +4,47 @@
|
||||
//! packets. Each frame is wrapped in a PES header, split into TS packets,
|
||||
//! and prepended with the 4-byte TP_extra_header.
|
||||
|
||||
use super::hevc::{hvcc_to_annex_b, length_prefixed_to_annex_b};
|
||||
use std::io::{self, Write};
|
||||
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
const TS_PAYLOAD: usize = 184;
|
||||
|
||||
/// PID range treated as video (HEVC, triggers Annex-B conversion + RAI
|
||||
/// on keyframes). Both `write_frame` and `build_pes_header` consult this
|
||||
/// so a PID's stream_id and its NAL handling can never disagree.
|
||||
const VIDEO_PID_RANGE: std::ops::RangeInclusive<u16> = 0x1011..=0x101F;
|
||||
|
||||
/// Largest PES payload that fits a bounded `PES_packet_length` (u16) on a
|
||||
/// `0xBD` (private_stream_1) stream after the 8 PES-header bytes. Frames
|
||||
/// larger than this are split into multiple PES so the length field stays
|
||||
/// spec-conformant (the unbounded `0` length is only legal for video).
|
||||
const MAX_BD_PES_PAYLOAD: usize = u16::MAX as usize - 8;
|
||||
|
||||
fn is_video_pid(pid: u16) -> bool {
|
||||
VIDEO_PID_RANGE.contains(&pid)
|
||||
}
|
||||
|
||||
/// BD-TS muxer: PES frames in, 192-byte BD-TS packets out.
|
||||
///
|
||||
/// Constructed over an output writer and a slice of per-track PIDs. The
|
||||
/// `track` index passed to [`TsMuxer::write_frame`] and
|
||||
/// [`TsMuxer::set_codec_private`] is the position in that PID slice; all
|
||||
/// per-track state vectors are sized to `pids.len()`. PIDs in
|
||||
/// `0x1011..=0x101F` are treated as video (length-prefixed NALUs in,
|
||||
/// Annex B out, with parameter-set prepend and RAI on keyframes); every
|
||||
/// other PID is carried as `private_stream_1` (`0xBD`) audio/subtitle.
|
||||
/// All tracks share one PTS origin seeded from the first video frame, so
|
||||
/// audio/video PTS offsets are preserved.
|
||||
pub struct TsMuxer<W: Write> {
|
||||
writer: W,
|
||||
pids: Vec<u16>,
|
||||
continuity: Vec<u8>, // per-PID continuity counter (0-15)
|
||||
codec_privates: Vec<Option<Vec<u8>>>, // per-track codec_private (for video parameter sets)
|
||||
params_written: Vec<bool>, // per-track: have we written parameter sets?
|
||||
/// Global PTS origin (nanoseconds), seeded by the FIRST video frame so
|
||||
/// the audio/video offset is preserved. Frames that arrive before it
|
||||
/// is set saturate to 0.
|
||||
base_pts_ns: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -33,15 +63,29 @@ impl<W: Write> TsMuxer<W> {
|
||||
|
||||
/// Set codec_private data for a track. Used to prepend VPS/SPS/PPS
|
||||
/// as Annex B NALs before the first keyframe in the transport stream.
|
||||
pub fn set_codec_private(&mut self, track: usize, data: Vec<u8>) {
|
||||
if track < self.codec_privates.len() {
|
||||
self.codec_privates[track] = Some(data);
|
||||
///
|
||||
/// `track` is the index into the PID slice passed to [`TsMuxer::new`].
|
||||
/// Returns [`Error::MuxTrackRange`](crate::error::Error::MuxTrackRange)
|
||||
/// for an out-of-range index.
|
||||
pub fn set_codec_private(&mut self, track: usize, data: Vec<u8>) -> io::Result<()> {
|
||||
if track >= self.codec_privates.len() {
|
||||
return Err(crate::error::Error::MuxTrackRange {
|
||||
track,
|
||||
tracks: self.codec_privates.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
self.codec_privates[track] = Some(data);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a PES frame as BD-TS packets.
|
||||
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
||||
/// and is converted to Annex B for transport stream.
|
||||
///
|
||||
/// `track` is the index into the PID slice passed to [`TsMuxer::new`].
|
||||
/// Returns [`Error::MuxTrackRange`](crate::error::Error::MuxTrackRange)
|
||||
/// for an out-of-range index.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track: usize,
|
||||
@@ -50,10 +94,14 @@ impl<W: Write> TsMuxer<W> {
|
||||
data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
if track >= self.pids.len() {
|
||||
return Ok(()); // unknown track, skip
|
||||
return Err(crate::error::Error::MuxTrackRange {
|
||||
track,
|
||||
tracks: self.pids.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let pid = self.pids[track];
|
||||
let is_video = (0x1011..=0x101F).contains(&pid);
|
||||
let is_video = is_video_pid(pid);
|
||||
|
||||
// Drop non-key video before any keyframe — decoder has no IDR or
|
||||
// parameter sets to anchor on.
|
||||
@@ -61,12 +109,26 @@ impl<W: Write> TsMuxer<W> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
||||
let pts_ns = pts_ns - base;
|
||||
// Seed the global PTS origin from the FIRST video frame only, so the
|
||||
// audio/video offset is preserved. A leading audio frame must not
|
||||
// pull the base up and collapse the first video IDR to t=0.
|
||||
if is_video {
|
||||
self.base_pts_ns.get_or_insert(pts_ns);
|
||||
}
|
||||
let base = self.base_pts_ns.unwrap_or(pts_ns);
|
||||
let pts_ns = pts_ns.saturating_sub(base);
|
||||
|
||||
// For video: convert length-prefixed NALUs to Annex B (start codes).
|
||||
// Prepend codec_private parameter sets on the FIRST keyframe only.
|
||||
let es_data = if is_video && !data.is_empty() {
|
||||
//
|
||||
// Arm `params_written` on the first video keyframe regardless of
|
||||
// whether it carries data: an empty-data keyframe still anchors
|
||||
// the stream, and leaving the flag unset would make every later
|
||||
// non-key frame fail the drop guard above and silently vanish.
|
||||
// For non-video the ES bytes pass through unchanged, so borrow
|
||||
// `data` directly rather than copying it; only video needs an
|
||||
// owned Annex-B conversion buffer.
|
||||
let es_data: std::borrow::Cow<'_, [u8]> = if is_video {
|
||||
let mut annex_b = Vec::new();
|
||||
if keyframe && !self.params_written[track] {
|
||||
if let Some(ref cp) = self.codec_privates[track] {
|
||||
@@ -77,25 +139,58 @@ impl<W: Write> TsMuxer<W> {
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
||||
annex_b
|
||||
std::borrow::Cow::Owned(annex_b)
|
||||
} else {
|
||||
data.to_vec()
|
||||
std::borrow::Cow::Borrowed(data)
|
||||
};
|
||||
|
||||
// Build PES packet: header + data
|
||||
let pts_90k = if pts_ns >= 0 {
|
||||
(pts_ns as u64).saturating_mul(9) / 100_000
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let pes_header = build_pes_header(pid, pts_90k, es_data.len());
|
||||
let pes_packet = [&pes_header[..], &es_data[..]].concat();
|
||||
|
||||
// Split into TS packets
|
||||
// Video PES may be unbounded (length 0); a 0xBD private_stream_1
|
||||
// PES must carry a bounded length, so split oversized audio/sub
|
||||
// access units into multiple PES packets. Each emitted PES carries
|
||||
// the same PTS and starts on its own PUSI packet (only the keyframe
|
||||
// RAI rides the first packet of the first PES).
|
||||
if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD {
|
||||
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe, &es_data)?;
|
||||
} else {
|
||||
let mut first_pes = true;
|
||||
for chunk in es_data.chunks(MAX_BD_PES_PAYLOAD) {
|
||||
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe && first_pes, chunk)?;
|
||||
first_pes = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrap `es_data` in a PES header and split it into 192-byte BD-TS
|
||||
/// packets. `keyframe` drives the RAI bit on the first packet (video
|
||||
/// only). The PES header and ES bytes are sliced in place — no second
|
||||
/// full-frame copy.
|
||||
fn write_pes_chain(
|
||||
&mut self,
|
||||
track: usize,
|
||||
pid: u16,
|
||||
pts_90k: u64,
|
||||
is_video: bool,
|
||||
keyframe: bool,
|
||||
es_data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let pes_header = build_pes_header(pid, pts_90k, es_data.len());
|
||||
|
||||
// Logical PES packet = header bytes followed by es_data. It is
|
||||
// indexed (and written) in place, without materializing the
|
||||
// concatenation, to avoid a second full-frame copy on the hot path.
|
||||
let pes_len = pes_header.len() + es_data.len();
|
||||
|
||||
let mut offset = 0;
|
||||
let mut first = true;
|
||||
while offset < pes_packet.len() {
|
||||
let remaining = pes_packet.len() - offset;
|
||||
while offset < pes_len {
|
||||
let remaining = pes_len - offset;
|
||||
|
||||
// Invariant: TP_extra(4) + TS_header(4) + AF(af_bytes) + payload(payload_len) = 192,
|
||||
// i.e. af_bytes + payload_len = TS_PAYLOAD (184).
|
||||
@@ -165,8 +260,20 @@ impl<W: Write> TsMuxer<W> {
|
||||
}
|
||||
}
|
||||
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
// Write the payload span [offset, offset+payload_len), which may
|
||||
// straddle the header/es_data boundary — emit each side in one
|
||||
// write_all rather than copying the whole frame again.
|
||||
let end = offset + payload_len;
|
||||
let hdr_len = pes_header.len();
|
||||
if offset < hdr_len {
|
||||
let hdr_end = end.min(hdr_len);
|
||||
self.writer.write_all(&pes_header[offset..hdr_end])?;
|
||||
}
|
||||
if end > hdr_len {
|
||||
let es_start = offset.max(hdr_len) - hdr_len;
|
||||
let es_end = end - hdr_len;
|
||||
self.writer.write_all(&es_data[es_start..es_end])?;
|
||||
}
|
||||
|
||||
offset += payload_len;
|
||||
first = false;
|
||||
@@ -175,6 +282,8 @@ impl<W: Write> TsMuxer<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush the underlying writer. BD-TS needs no stream trailer, so this
|
||||
/// only drains buffering; the muxer remains usable afterwards.
|
||||
pub fn finish(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
@@ -183,7 +292,7 @@ impl<W: Write> TsMuxer<W> {
|
||||
/// Build a PES packet header for a BD stream.
|
||||
fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
|
||||
// Determine stream_id from PID range
|
||||
let stream_id: u8 = if (0x1011..=0x101F).contains(&pid) {
|
||||
let stream_id: u8 = if is_video_pid(pid) {
|
||||
0xE0 // video
|
||||
} else {
|
||||
0xBD // audio, PGS subtitle, or default (private stream 1)
|
||||
@@ -198,7 +307,10 @@ fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
|
||||
header.push(0x01);
|
||||
header.push(stream_id);
|
||||
|
||||
// PES packet length (0 = unbounded for video or if too large for u16)
|
||||
// PES packet length. The unbounded form (0) is only spec-legal for
|
||||
// video; `write_frame` splits oversized 0xBD access units so a private
|
||||
// stream always fits a bounded u16 length here. The `> 65535` arm
|
||||
// remains a defensive fallback for video only.
|
||||
if stream_id == 0xE0 || pes_data_len > 65535 {
|
||||
header.push(0x00);
|
||||
header.push(0x00);
|
||||
@@ -226,72 +338,6 @@ fn build_pes_header(pid: u16, pts_90k: u64, data_len: usize) -> Vec<u8> {
|
||||
header
|
||||
}
|
||||
|
||||
/// Extract NAL arrays from HEVCDecoderConfigurationRecord and convert to Annex B.
|
||||
/// Returns VPS + SPS + PPS as Annex B NAL units (00 00 00 01 + NAL).
|
||||
fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord: 22 bytes header, then NAL arrays
|
||||
if hvcc.len() < 23 {
|
||||
return None;
|
||||
}
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 23;
|
||||
|
||||
for _ in 0..num_arrays {
|
||||
if offset + 3 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
// array: 1 byte (completeness + NAL type), 2 bytes (numNalus)
|
||||
let _nal_type = hvcc[offset] & 0x3F;
|
||||
let num_nalus = u16::from_be_bytes([hvcc[offset + 1], hvcc[offset + 2]]) as usize;
|
||||
offset += 3;
|
||||
|
||||
for _ in 0..num_nalus {
|
||||
if offset + 2 > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
let nal_len = u16::from_be_bytes([hvcc[offset], hvcc[offset + 1]]) as usize;
|
||||
offset += 2;
|
||||
if offset + nal_len > hvcc.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&hvcc[offset..offset + nal_len]);
|
||||
offset += nal_len;
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
|
||||
/// (00 00 00 01 + NAL). Used for video elementary streams in TS.
|
||||
fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut offset = 0;
|
||||
while offset + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
if offset + len > data.len() {
|
||||
break;
|
||||
}
|
||||
out.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
|
||||
out.extend_from_slice(&data[offset..offset + len]);
|
||||
offset += len;
|
||||
}
|
||||
// If data doesn't look like length-prefixed NALs (no valid parse),
|
||||
// return original data unchanged — it may already be Annex B.
|
||||
if out.is_empty() && !data.is_empty() {
|
||||
return data.to_vec();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -444,7 +490,7 @@ mod tests {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
mux.set_codec_private(0, hvcc);
|
||||
mux.set_codec_private(0, hvcc).unwrap();
|
||||
// Non-IDR before any IDR: should be dropped.
|
||||
let p = fake_hevc_nal(1, 50);
|
||||
mux.write_frame(0, 0, false, &p).unwrap();
|
||||
@@ -477,6 +523,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_data_keyframe_arms_params_so_later_frames_survive() {
|
||||
// An empty-data keyframe must still arm params_written; otherwise
|
||||
// every subsequent non-key frame would be dropped by the
|
||||
// pre-keyframe guard and the track would emit no real frames.
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
// Keyframe with empty payload (e.g. a frame whose NALs were
|
||||
// all stripped upstream) — anchors the stream.
|
||||
mux.write_frame(0, 0, true, &[]).unwrap();
|
||||
// Now a real non-key frame; it must NOT be dropped.
|
||||
let p = fake_hevc_nal(1, 80);
|
||||
mux.write_frame(0, 41_000_000, false, &p).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
// The non-key frame's NAL body byte (0x02 = (1<<1)) must appear in
|
||||
// a video payload — proof it wasn't dropped.
|
||||
let video_bytes: Vec<u8> = packets
|
||||
.iter()
|
||||
.filter(|p| p.pid == VIDEO_PID)
|
||||
.flat_map(|p| p.payload.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
video_bytes
|
||||
.windows(4)
|
||||
.any(|w| w == [0x00, 0x00, 0x00, 0x01]),
|
||||
"later non-key frame must survive after an empty-data keyframe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_key_before_first_keyframe_dropped() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
@@ -493,4 +571,96 @@ mod tests {
|
||||
"non-key before first keyframe must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
const AUDIO_PID: u16 = 0x1100;
|
||||
|
||||
/// Decode the 33-bit PTS from the first PUSI packet on `pid`. Assumes
|
||||
/// the PES header carries PTS (flags 0x80 at PES byte 7).
|
||||
fn first_pts_90k(packets: &[TsPacket], pid: u16) -> u64 {
|
||||
let pkt = packets
|
||||
.iter()
|
||||
.find(|p| p.pid == pid && p.pusi)
|
||||
.expect("PUSI packet present");
|
||||
// PES payload starts the packet payload: 00 00 01 stream_id len len
|
||||
// flags1 flags2 hdr_len then 5 PTS bytes.
|
||||
let p = &pkt.payload;
|
||||
let pts = &p[9..14];
|
||||
((((pts[0] >> 1) & 0x07) as u64) << 30)
|
||||
| ((pts[1] as u64) << 22)
|
||||
| (((pts[2] >> 1) as u64) << 15)
|
||||
| ((pts[3] as u64) << 7)
|
||||
| ((pts[4] >> 1) as u64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av_offset_preserved_with_audio_before_first_video() {
|
||||
// Audio at t=0 arrives BEFORE the first video keyframe at t=1s.
|
||||
// The global base must be seeded from the VIDEO frame so the
|
||||
// audio/video PTS offset is preserved (audio earlier ⇒ saturates to
|
||||
// 0, video lands at +1s = 90000 ticks), not both collapsed to 0.
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
||||
// Audio frame first, at PTS 0.
|
||||
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
|
||||
.unwrap();
|
||||
// Video keyframe at PTS 1s — seeds the base.
|
||||
let idr = fake_hevc_nal(19, 100);
|
||||
mux.write_frame(0, 1_000_000_000, true, &idr).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
let video_pts = first_pts_90k(&packets, VIDEO_PID);
|
||||
let audio_pts = first_pts_90k(&packets, AUDIO_PID);
|
||||
// Video keyframe is the base ⇒ its relative PTS is 0.
|
||||
assert_eq!(video_pts, 0, "video keyframe seeds the base at t=0");
|
||||
// Audio arrived 1s earlier ⇒ saturates to 0, NOT lifted past video.
|
||||
assert_eq!(audio_pts, 0, "earlier audio saturates to 0");
|
||||
assert!(
|
||||
audio_pts <= video_pts,
|
||||
"audio must not be pulled ahead of the video base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_track_errors() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let err = mux.write_frame(5, 0, true, &[0xAA]).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
let err2 = mux.set_codec_private(5, vec![0u8; 4]).unwrap_err();
|
||||
assert_eq!(err2.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_bd_audio_pes_is_split_and_bounded() {
|
||||
// A private_stream_1 (0xBD) audio frame larger than the bounded PES
|
||||
// limit must be split into multiple PES, each with a non-zero
|
||||
// PES_packet_length (never the unbounded 0 form, which is illegal
|
||||
// for 0xBD).
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let big: Vec<u8> = (0..(MAX_BD_PES_PAYLOAD + 5000))
|
||||
.map(|i| (i & 0xFF) as u8)
|
||||
.collect();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[AUDIO_PID]);
|
||||
mux.write_frame(0, 0, false, &big).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
let pusi: Vec<&TsPacket> = packets
|
||||
.iter()
|
||||
.filter(|p| p.pid == AUDIO_PID && p.pusi)
|
||||
.collect();
|
||||
assert!(
|
||||
pusi.len() >= 2,
|
||||
"oversized audio must span ≥2 PES, got {}",
|
||||
pusi.len()
|
||||
);
|
||||
for p in pusi {
|
||||
// PES length field at payload bytes [4..6] must be non-zero.
|
||||
let len = u16::from_be_bytes([p.payload[4], p.payload[5]]);
|
||||
assert_ne!(len, 0, "0xBD PES must carry a bounded length");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user