mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b6ea8f5c4 | ||
|
|
eeba94b21d | ||
|
|
cfc12774f2 | ||
|
|
fd543c058b | ||
|
|
cd8ce708ac | ||
|
|
2eee777b8f | ||
|
|
a5962be86c | ||
|
|
7306f661b9 | ||
|
|
77f7aced83 | ||
|
|
97ae452e40 | ||
|
|
eec0594a30 | ||
|
|
dab6ea4359 | ||
|
|
9a5ed57044 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.30.0"
|
||||
version = "0.30.7"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+76
-8
@@ -91,7 +91,11 @@ pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
|
||||
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
|
||||
/// scrambles all but the first (which lives in the clear 16-byte seed).
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
||||
/// (offset 4 and every 192 bytes after). A clear or correctly-decrypted m2ts
|
||||
/// unit shows ~one per packet; an encrypted unit, or a non-content unit
|
||||
/// decrypted under a key that doesn't apply, shows ~none.
|
||||
pub fn ts_sync_count(unit: &[u8]) -> usize {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
@@ -100,13 +104,19 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
}
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// One sync byte is checked per 192-byte BD-TS packet (at offset 4 of
|
||||
// each). `total` is exactly that packet count; the old
|
||||
// `(len - 4) / TS_PACKET_LEN + 1` over-counted by one for lengths of
|
||||
// the form `4 + k·192` (harmless for the always-6144 aligned unit, but
|
||||
// wrong in general and it biased the majority threshold).
|
||||
let total = unit.len() / TS_PACKET_LEN;
|
||||
count > total / 2
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of BD-TS packets in the unit — the maximum possible sync count.
|
||||
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
||||
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
|
||||
// `(len - 4) / TS_PACKET_LEN + 1` over-counted by one for lengths of the
|
||||
// form `4 + k·192`.
|
||||
unit.len() / TS_PACKET_LEN
|
||||
}
|
||||
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
ts_sync_count(unit) > ts_packet_total(unit) / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
@@ -152,6 +162,64 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
verify_ts(unit)
|
||||
}
|
||||
|
||||
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
|
||||
///
|
||||
/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before
|
||||
/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is
|
||||
/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so
|
||||
/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC
|
||||
/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block
|
||||
/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the
|
||||
/// time for the cost of one AES block; the rare survivor is then confirmed with
|
||||
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
|
||||
/// identical to the slow path.
|
||||
///
|
||||
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
|
||||
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
|
||||
/// per-candidate scramble re-scan is intentionally skipped here.
|
||||
///
|
||||
/// NOTE: this is a search accelerator — it never writes the input and never
|
||||
/// participates in the content decrypt path. Aggregate correctness (does a key
|
||||
/// validate against *any* of the disc's units) is preserved because a true key
|
||||
/// restores offset-196 on every standard BD-TS unit.
|
||||
pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
// Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with
|
||||
// the unit key, XOR with the header (same derivation as `decrypt_unit`).
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut decrypt_key = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
decrypt_key[i] = derived[i] ^ header[i];
|
||||
}
|
||||
|
||||
// Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync).
|
||||
// The CBC region is `unit[16..]`; payload offset 196 → region offset 180 =
|
||||
// block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw
|
||||
// ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit.
|
||||
const SYNC_PAYLOAD_OFF: usize = 196;
|
||||
let region_off = SYNC_PAYLOAD_OFF - 16; // 180
|
||||
let blk = region_off / 16; // 11
|
||||
let byte = region_off % 16; // 4
|
||||
let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192)
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key));
|
||||
let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]);
|
||||
cipher.decrypt_block(&mut b);
|
||||
let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10)
|
||||
if b[byte] ^ prev != TS_SYNC {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Survivor (~1/256 of candidates): confirm with the authoritative full
|
||||
// decrypt + verify, so the verdict matches `decrypt_unit` exactly.
|
||||
let mut full = [0u8; ALIGNED_UNIT_LEN];
|
||||
full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
|
||||
decrypt_unit(&mut full, unit_key)
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ pub mod variants;
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_aacs_scrambled,
|
||||
is_aacs_scrambled, unit_key_validates,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
|
||||
+10
-2
@@ -156,8 +156,16 @@ impl Disc {
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})),
|
||||
// Stream type 4 = IG, unknown types -- skip
|
||||
_ => None,
|
||||
// Stream type 4 = IG, unknown types -- skip.
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})",
|
||||
other,
|
||||
s.pid,
|
||||
s.coding_type,
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
+69
-1
@@ -522,7 +522,7 @@ impl Codec {
|
||||
("ssa", "SSA", Codec::Ssa),
|
||||
];
|
||||
|
||||
fn from_coding_type(ct: u8) -> Self {
|
||||
pub(crate) fn from_coding_type(ct: u8) -> Self {
|
||||
match ct {
|
||||
0x24 => Codec::Hevc,
|
||||
0x1B => Codec::H264,
|
||||
@@ -540,6 +540,40 @@ impl Codec {
|
||||
ct => Codec::Unknown(ct),
|
||||
}
|
||||
}
|
||||
|
||||
/// Broad stream category for a codec. Used by demuxers to decide
|
||||
/// whether a PMT/STN entry becomes a video, audio, or subtitle
|
||||
/// `Stream` without duplicating per-codec knowledge.
|
||||
pub fn kind(&self) -> CodecKind {
|
||||
match self {
|
||||
Codec::Hevc | Codec::H264 | Codec::Vc1 | Codec::Mpeg2 | Codec::Mpeg1 | Codec::Av1 => {
|
||||
CodecKind::Video
|
||||
}
|
||||
Codec::TrueHd
|
||||
| Codec::DtsHdMa
|
||||
| Codec::DtsHdHr
|
||||
| Codec::Dts
|
||||
| Codec::Ac3
|
||||
| Codec::Ac3Plus
|
||||
| Codec::Lpcm
|
||||
| Codec::Aac
|
||||
| Codec::Mp2
|
||||
| Codec::Mp3
|
||||
| Codec::Flac
|
||||
| Codec::Opus => CodecKind::Audio,
|
||||
Codec::Pgs | Codec::DvdSub | Codec::Srt | Codec::Ssa => CodecKind::Subtitle,
|
||||
Codec::Unknown(_) => CodecKind::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Broad category of a [`Codec`] — video / audio / subtitle / unknown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CodecKind {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Codec {
|
||||
@@ -779,6 +813,22 @@ impl ColorSpace {
|
||||
ColorSpace::Unknown => "",
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_CS: &[(&'static str, ColorSpace)] = &[
|
||||
("bt709", ColorSpace::Bt709),
|
||||
("bt2020", ColorSpace::Bt2020),
|
||||
("unknown", ColorSpace::Unknown),
|
||||
];
|
||||
|
||||
/// Compact identifier for serialization (round-trips via `FromStr`).
|
||||
pub fn id(&self) -> &'static str {
|
||||
for (id, v) in Self::ALL_CS {
|
||||
if v == self {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColorSpace {
|
||||
@@ -787,6 +837,24 @@ impl std::fmt::Display for ColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ColorSpace {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||||
for (id, v) in ColorSpace::ALL_CS {
|
||||
if *id == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
// Also accept display names (e.g. "BT.2020").
|
||||
for (_id, v) in ColorSpace::ALL_CS {
|
||||
if ColorSpace::name(v) == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
Ok(ColorSpace::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
|
||||
//
|
||||
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
|
||||
|
||||
+188
-3
@@ -6,6 +6,14 @@
|
||||
|
||||
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.
|
||||
const SAMPLE_RATES: [u32; 4] = [48_000, 44_100, 32_000, 48_000];
|
||||
|
||||
/// AC-3 (legacy) always carries 6 audio blocks × 256 samples = 1536 samples.
|
||||
const AC3_SAMPLES_PER_FRAME: u32 = 1536;
|
||||
|
||||
/// Hard cap on the carry-over buffer. An AC-3/E-AC-3 frame is at most 8192
|
||||
/// bytes (the `frame_size > 8192` reject below), so a single straddling frame
|
||||
/// plus a little slack never needs more than this. If the buffer grows past
|
||||
@@ -17,6 +25,10 @@ const MAX_AC3_BUF: usize = 64 * 1024;
|
||||
pub struct Ac3Parser {
|
||||
/// Leftover bytes from previous PES (incomplete frame at end).
|
||||
buf: Vec<u8>,
|
||||
/// PTS (ns) to stamp on the frame that begins the carry-over `buf` — i.e.
|
||||
/// the running per-frame PTS at the point the partial tail was retained.
|
||||
/// Used by `flush()` to time the final buffered frame at EOS.
|
||||
flush_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for Ac3Parser {
|
||||
@@ -29,6 +41,7 @@ impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(4096),
|
||||
flush_pts_ns: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +52,12 @@ impl CodecParser for Ac3Parser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
// Base PTS for the FIRST frame emitted from this call. Each subsequent
|
||||
// frame in the same call advances by the previous frame's duration, so a
|
||||
// PES that carries several AC-3 frames stamps a monotonically increasing
|
||||
// PTS per frame instead of the same PES timestamp on all of them (which
|
||||
// collapses their timecodes and drifts A/V).
|
||||
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Prepend leftover from previous PES
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
@@ -47,6 +65,8 @@ impl CodecParser for Ac3Parser {
|
||||
let data = &self.buf;
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0;
|
||||
// Running PTS for the next frame to emit in this call.
|
||||
let mut frame_pts_ns = base_pts_ns;
|
||||
|
||||
while pos < data.len() {
|
||||
let sync = find_ac3_sync(&data[pos..]);
|
||||
@@ -80,12 +100,14 @@ impl CodecParser for Ac3Parser {
|
||||
break;
|
||||
}
|
||||
|
||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
pts_ns: frame_pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + frame_size].to_vec(),
|
||||
duration_ns: None,
|
||||
duration_ns: Some(duration_ns),
|
||||
});
|
||||
frame_pts_ns += duration_ns as i64;
|
||||
pos = start + frame_size;
|
||||
}
|
||||
|
||||
@@ -120,6 +142,10 @@ impl CodecParser for Ac3Parser {
|
||||
self.buf.clear();
|
||||
} else {
|
||||
self.buf = tail.to_vec();
|
||||
// The carried partial frame, when later completed and emitted by
|
||||
// flush() at EOS, is timed at the running per-frame PTS reached
|
||||
// here (the PTS of the next frame in presentation order).
|
||||
self.flush_pts_ns = frame_pts_ns;
|
||||
}
|
||||
} else {
|
||||
self.buf.clear();
|
||||
@@ -128,11 +154,85 @@ impl CodecParser for Ac3Parser {
|
||||
frames
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// End of stream: emit a complete final frame still buffered. During
|
||||
// streaming a final frame may sit in `buf` with no following PES to
|
||||
// complete/confirm it; without this drain the last ~32 ms of audio is
|
||||
// dropped at EOS (mirrors dts.rs::flush). Only a fully-sized frame at a
|
||||
// syncword is emitted; a partial/garbage tail is discarded.
|
||||
let buf = std::mem::take(&mut self.buf);
|
||||
let Some(off) = find_ac3_sync(&buf) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let frame = &buf[off..];
|
||||
if frame.len() < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let bsid = get_bsid(frame);
|
||||
let frame_size = if bsid >= 11 {
|
||||
eac3_frame_size(frame)
|
||||
} else {
|
||||
ac3_frame_size(frame)
|
||||
};
|
||||
if frame_size == 0 || frame_size > 8192 || off + frame_size > buf.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
vec![Frame {
|
||||
pts_ns: self.flush_pts_ns,
|
||||
keyframe: true,
|
||||
data: buf[off..off + frame_size].to_vec(),
|
||||
duration_ns: Some(duration_ns),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of samples per E-AC-3 frame from numblkscod (audio blocks × 256).
|
||||
fn eac3_samples_per_frame(data: &[u8]) -> u32 {
|
||||
if data.len() < 5 {
|
||||
return AC3_SAMPLES_PER_FRAME;
|
||||
}
|
||||
// E-AC-3 byte 4: fscod(2) | numblkscod(2) | ... — but only when fscod != 3.
|
||||
// When fscod == 3 (fscod2 / reduced rate), numblks is fixed at 6.
|
||||
let fscod = (data[4] >> 6) & 0x03;
|
||||
if fscod == 0x03 {
|
||||
return 6 * 256;
|
||||
}
|
||||
let numblkscod = (data[4] >> 4) & 0x03;
|
||||
let numblks = match numblkscod {
|
||||
0 => 1,
|
||||
1 => 2,
|
||||
2 => 3,
|
||||
_ => 6,
|
||||
};
|
||||
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 {
|
||||
if data.len() < 5 {
|
||||
return SAMPLE_RATES[0];
|
||||
}
|
||||
SAMPLE_RATES[((data[4] >> 6) & 0x03) as usize]
|
||||
}
|
||||
|
||||
/// Duration of one AC-3/E-AC-3 frame in nanoseconds: samples_per_frame /
|
||||
/// sample_rate. AC-3 is always 1536 samples; E-AC-3 derives from numblkscod.
|
||||
fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 {
|
||||
let samples = if bsid >= 11 {
|
||||
eac3_samples_per_frame(data)
|
||||
} else {
|
||||
AC3_SAMPLES_PER_FRAME
|
||||
} as u64;
|
||||
let rate = frame_sample_rate(data) as u64;
|
||||
// samples / rate seconds → ns, rounded to nearest.
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
|
||||
/// Find AC3/E-AC-3 syncword (0x0B77) in data.
|
||||
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
(0..data.len().saturating_sub(1)).find(|&i| data[i] == 0x0B && data[i + 1] == 0x77)
|
||||
@@ -388,6 +488,91 @@ mod tests {
|
||||
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_emits_complete_buffered_frame_at_eos() {
|
||||
// A complete final frame sitting in the carry-over buffer with no
|
||||
// following PES must be drained by flush() at EOS — the bug was that
|
||||
// ac3 inherited the no-op default flush and dropped the last frame.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2);
|
||||
parser.buf = frame_data.clone();
|
||||
parser.flush_pts_ns = pts_to_ns(99000);
|
||||
let f = parser.flush();
|
||||
assert_eq!(f.len(), 1, "complete buffered frame drained at EOS");
|
||||
assert_eq!(f[0].data.len(), 160);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(99000), "flush uses carried PTS");
|
||||
assert!(f[0].duration_ns.is_some(), "flush sets duration");
|
||||
assert!(parser.buf.is_empty(), "buffer consumed by flush");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_carries_running_pts_from_partial_tail() {
|
||||
// After a full frame emits in parse, the partial next frame held in the
|
||||
// buffer is timed at the running per-frame PTS; flush completing it must
|
||||
// use that, not the original PES base.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2);
|
||||
let mut data = frame_data.clone();
|
||||
data.extend_from_slice(&frame_data[..40]); // partial frame 2 held
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
|
||||
let dur = f[0].duration_ns.unwrap() as i64;
|
||||
// The held partial's flush PTS should be base + one frame duration.
|
||||
assert_eq!(parser.flush_pts_ns, pts_to_ns(90000) + dur);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_drops_partial_tail() {
|
||||
// A partial frame (cannot be sized/completed) at EOS is dropped, not
|
||||
// emitted truncated.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2);
|
||||
parser.buf = frame_data[..80].to_vec(); // half a frame
|
||||
assert!(parser.flush().is_empty(), "partial tail dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_frame_pts_increments_within_one_pes() {
|
||||
// Two AC-3 frames in a single PES must get distinct, increasing PTS —
|
||||
// one per frame, not the single PES timestamp on both.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 48kHz, 1536 samples
|
||||
let mut data = frame_data.clone();
|
||||
data.extend_from_slice(&frame_data);
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
let f = parser.parse(&pes);
|
||||
assert_eq!(f.len(), 2);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "frame 0 uses PES base PTS");
|
||||
// 1536 samples @ 48kHz = 32 ms = 32_000_000 ns.
|
||||
let expect = 1536u64 * 1_000_000_000 / 48_000;
|
||||
assert_eq!(f[0].duration_ns, Some(expect));
|
||||
assert_eq!(
|
||||
f[1].pts_ns - f[0].pts_ns,
|
||||
expect as i64,
|
||||
"frame 1 PTS advances by one frame duration, not equal to frame 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_duration_ac3_48khz() {
|
||||
// AC-3 @ 48kHz: 1536 / 48000 s = 32 ms.
|
||||
let frame = make_ac3_frame(0, 2);
|
||||
let bsid = get_bsid(&frame);
|
||||
assert!(bsid < 11, "test frame is legacy AC-3");
|
||||
assert_eq!(frame_duration_ns(&frame, bsid), 32_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_frame_size_table() {
|
||||
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
|
||||
|
||||
+157
-19
@@ -22,6 +22,17 @@ pub struct DtsParser {
|
||||
/// frame's PES first arrived; the trailing extension-substream PES
|
||||
/// packets carry their own (later) PTS which must NOT override it.
|
||||
pending_pts: i64,
|
||||
/// PTS markers attributing buffer regions to their source PES. Each entry
|
||||
/// is `(buffer_offset, pts_ns)` for the PES whose bytes begin at that
|
||||
/// offset. When an access unit is emitted from the front of `buf`, its PTS
|
||||
/// is the marker covering offset 0 — NOT the most recent PES's PTS. This is
|
||||
/// what fixes multi-AU-per-call PTS attribution: if a PES carries the
|
||||
/// extension substreams (and possibly the next core) for an AU whose own
|
||||
/// core arrived in an earlier PES, the emitted AU keeps its own core PES's
|
||||
/// timestamp instead of the later PES's. Offsets are kept relative to the
|
||||
/// current `buf` start and rebased whenever bytes are drained from the
|
||||
/// front.
|
||||
pts_marks: Vec<(usize, i64)>,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
@@ -35,8 +46,45 @@ impl DtsParser {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
pending_pts: 0,
|
||||
pts_marks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop `n` bytes from the front of `buf` and rebase the PTS markers so
|
||||
/// their offsets stay relative to the new buffer start. A marker that now
|
||||
/// sits at or before offset 0 is clamped to 0 (it still covers the front).
|
||||
/// Redundant markers all at offset 0 collapse to the last one.
|
||||
fn drain_front(&mut self, n: usize) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.buf.drain(..n);
|
||||
for m in &mut self.pts_marks {
|
||||
m.0 = m.0.saturating_sub(n);
|
||||
}
|
||||
// Collapse all leading markers that now sit at offset 0 to the last
|
||||
// such marker — that is the PES whose data currently begins the buffer.
|
||||
let last_zero = self
|
||||
.pts_marks
|
||||
.iter()
|
||||
.rposition(|&(off, _)| off == 0)
|
||||
.filter(|&i| i > 0);
|
||||
if let Some(i) = last_zero {
|
||||
self.pts_marks.drain(..i);
|
||||
}
|
||||
}
|
||||
|
||||
/// PTS that should be stamped on an access unit currently at the front of
|
||||
/// `buf` (offset 0): the most recent marker at offset 0, falling back to
|
||||
/// `pending_pts`.
|
||||
fn front_pts(&self) -> i64 {
|
||||
self.pts_marks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|&&(off, _)| off == 0)
|
||||
.map(|&(_, pts)| pts)
|
||||
.unwrap_or(self.pending_pts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on a buffered access unit (core + all its extension substreams).
|
||||
@@ -85,6 +133,12 @@ impl CodecParser for DtsParser {
|
||||
if self.buf.is_empty() || self.pending_pts == PTS_UNSET {
|
||||
self.pending_pts = pts_ns;
|
||||
}
|
||||
// Mark where THIS PES's bytes begin in the buffer, with its PTS. The
|
||||
// emitted access unit takes the PTS of the PES covering its first byte
|
||||
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
|
||||
// that core's timestamp even when its extensions / the following core
|
||||
// arrive (with a later PTS) in this same parse() call.
|
||||
self.pts_marks.push((self.buf.len(), pts_ns));
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
@@ -96,12 +150,12 @@ impl CodecParser for DtsParser {
|
||||
// sync split across PES packets can still be found next time.
|
||||
if self.buf.len() > 3 {
|
||||
let tail = self.buf.len() - 3;
|
||||
self.buf.drain(..tail);
|
||||
self.drain_front(tail);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if start > 0 {
|
||||
self.buf.drain(..start);
|
||||
self.drain_front(start);
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) {
|
||||
// Shouldn't happen, but never loop forever.
|
||||
break;
|
||||
@@ -123,7 +177,7 @@ impl CodecParser for DtsParser {
|
||||
// `> 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.buf.drain(..4);
|
||||
self.drain_front(4);
|
||||
continue;
|
||||
}
|
||||
if self.buf.len() < core_size {
|
||||
@@ -163,27 +217,38 @@ impl CodecParser for DtsParser {
|
||||
};
|
||||
|
||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
||||
// The AU takes the PTS of the PES covering its first byte — its own
|
||||
// core's PES, even if that PES preceded the one(s) carrying its
|
||||
// extensions or the next core.
|
||||
let au_pts = self.front_pts();
|
||||
frames.push(Frame {
|
||||
pts_ns: self.pending_pts,
|
||||
pts_ns: au_pts,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
});
|
||||
self.buf.drain(..au_end);
|
||||
self.drain_front(au_end);
|
||||
// After draining, the marker covering the new front (if any) carries
|
||||
// the next AU's PTS; `pending_pts` is only the fallback when no
|
||||
// marker survives. Track it so the fallback stays sensible.
|
||||
self.pending_pts = self.front_pts();
|
||||
if forced {
|
||||
// Safety-valve flush: the next access unit's real core PES has
|
||||
// not arrived. Invalidate the PTS so the next PES sets it
|
||||
// regardless of buffer state, rather than inheriting this
|
||||
// (non-core) PES's timestamp.
|
||||
self.pending_pts = PTS_UNSET;
|
||||
} else {
|
||||
// Real boundary: the next access unit (now at buf start) begins
|
||||
// at a core sync that arrived inside this PES, so this PES's PTS
|
||||
// is the correct base for it.
|
||||
self.pending_pts = pts_ns;
|
||||
self.pts_marks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Discard markers that no longer reference live buffer bytes (everything
|
||||
// past the buffer end can't happen, but collapse duplicates at offset 0
|
||||
// and drop a stale empty-buffer marker set).
|
||||
if self.buf.is_empty() {
|
||||
self.pts_marks.clear();
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
@@ -203,15 +268,12 @@ impl CodecParser for DtsParser {
|
||||
self.buf.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
// The final AU's PTS is the PES covering the buffer front (its core's
|
||||
// PES). Fall back to pending_pts, clamping the sentinel to 0.
|
||||
let front = self.front_pts();
|
||||
let pts_ns = if front == PTS_UNSET { 0 } else { front };
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
// A non-empty buffer here means a PES arrived after any prior forced
|
||||
// flush (which fully drains `buf`), so `pending_pts` was reset to that
|
||||
// PES's real PTS. Clamp the sentinel to 0 defensively all the same.
|
||||
let pts_ns = if self.pending_pts == PTS_UNSET {
|
||||
0
|
||||
} else {
|
||||
self.pending_pts
|
||||
};
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
@@ -343,16 +405,92 @@ mod tests {
|
||||
#[test]
|
||||
fn two_cores_back_to_back_emit_first_on_boundary() {
|
||||
// The first complete unit is emitted as soon as the next core sync is
|
||||
// seen; the second is held until flush.
|
||||
// seen; the second is held until flush. Both cores arrived in ONE PES,
|
||||
// so both legitimately carry that PES's PTS.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512);
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
let f = parser.parse(&make_pes(stream, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].data.len(), 512);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 keeps its PES PTS");
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"AU2 came in the same PES → same PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_aus_flushed_in_one_call_keep_their_own_pts() {
|
||||
// The real-stream trigger: core1 in PES A (pts 100), then core2 + core3
|
||||
// arrive in a LATER PES B (pts 200). Processing PES B closes both AU1
|
||||
// (core1) and AU2 (core2) in a single parse() call. AU2's core arrived
|
||||
// in PES A region? No — here AU2 (core2) is in PES B, so it should be
|
||||
// 200. The defect to guard is AU1 NOT being overwritten to 200, and AU2
|
||||
// not inheriting an unrelated timestamp.
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES A: just core1 (held — no following core yet).
|
||||
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(100)));
|
||||
assert!(f0.is_empty(), "core1 held awaiting next core");
|
||||
|
||||
// PES B: core2 + core3. Closes AU1 (core1) and AU2 (core2).
|
||||
let mut pes_b = make_dts_core(600);
|
||||
pes_b.extend_from_slice(&make_dts_core(640));
|
||||
let f = parser.parse(&make_pes(pes_b, Some(200)));
|
||||
assert_eq!(f.len(), 2, "AU1 and AU2 both close in this call");
|
||||
assert_eq!(f[0].data.len(), 512, "AU1 = core1");
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
"AU1 keeps PES A's PTS, not the later PES B PTS"
|
||||
);
|
||||
assert_eq!(f[1].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f[1].pts_ns,
|
||||
pts_to_ns(200),
|
||||
"AU2's core arrived in PES B → PES B PTS"
|
||||
);
|
||||
|
||||
// AU3 (core3) drains on flush, also PES B PTS.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_au_with_core_in_earlier_pes_keeps_that_pts() {
|
||||
// core1 + core2 both arrive in PES A (pts 100); core3 arrives in PES B
|
||||
// (pts 200). When PES B closes AU2 (core2, whose core was in PES A), AU2
|
||||
// must keep PES A's 100 — the bug was AU2 inheriting the closing PES's
|
||||
// 200.
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES A: core1 + core2. AU1 (core1) emits immediately (core2 boundary);
|
||||
// AU2 (core2) held awaiting a third core.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
pes_a.extend_from_slice(&make_dts_core(600));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(100)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(100), "AU1 PES A PTS");
|
||||
|
||||
// PES B: core3 — closes AU2 (core2). AU2's core was in PES A.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's"
|
||||
);
|
||||
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200), "AU3 = core3, PES B PTS");
|
||||
}
|
||||
|
||||
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
|
||||
|
||||
+120
-10
@@ -1,22 +1,52 @@
|
||||
//! DVD bitmap subtitle (VobSub) parser.
|
||||
//!
|
||||
//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F.
|
||||
//! Each subtitle display set may span multiple PES packets, but at the MKV level
|
||||
//! we pass through the raw VobSub packets as-is — the container wraps them.
|
||||
//! A single subpicture unit (SPU — one displayed bitmap) may span multiple PES
|
||||
//! packets: only the first PES carries a PTS, continuations carry PTS=0. The SPU
|
||||
//! begins with a 2-byte big-endian `SPU_size` giving the total byte length of the
|
||||
//! whole unit. We reassemble across PES boundaries into one Frame so large
|
||||
//! subtitles aren't split/garbled, inheriting the head PES's PTS.
|
||||
//!
|
||||
//! For MKV: codec ID "S_VOBSUB".
|
||||
//! All frames are keyframes (each is a complete bitmap).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// Upper bound on a single reassembled SPU. The SPU_size field is 16 bits, so a
|
||||
/// well-formed unit is at most 0xFFFF bytes; cap accumulation here to bound
|
||||
/// memory if the field is corrupt or the stream never completes a unit.
|
||||
const MAX_SPU_BYTES: usize = 0xFFFF;
|
||||
|
||||
pub struct DvdSubParser {
|
||||
/// Pre-formatted VobSub .idx palette header for codec_private.
|
||||
codec_data: Option<Vec<u8>>,
|
||||
/// In-progress SPU reassembly: (head PTS in ns, declared SPU_size, bytes).
|
||||
pending: Option<(i64, usize, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl DvdSubParser {
|
||||
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
|
||||
Self { codec_data }
|
||||
Self {
|
||||
codec_data,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `pending` as a Frame if it is complete (or `force` at EOF),
|
||||
/// returning it and clearing the buffer. Returns None if nothing to emit.
|
||||
fn take_if_complete(&mut self, force: bool) -> Option<Frame> {
|
||||
let (pts_ns, size, buf) = self.pending.as_ref()?;
|
||||
if force || buf.len() >= *size {
|
||||
let (pts_ns, _, data) = self.pending.take().unwrap();
|
||||
return Some(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: None,
|
||||
});
|
||||
}
|
||||
let _ = pts_ns;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +55,52 @@ impl CodecParser for DvdSubParser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
|
||||
if self.pending.is_some() {
|
||||
// Continuation of an in-progress SPU (PTS=0 on these). Append,
|
||||
// bounded by MAX_SPU_BYTES.
|
||||
if let Some((_, _, buf)) = self.pending.as_mut() {
|
||||
let room = MAX_SPU_BYTES.saturating_sub(buf.len());
|
||||
let take = room.min(pes.data.len());
|
||||
buf.extend_from_slice(&pes.data[..take]);
|
||||
}
|
||||
if let Some(frame) = self.take_if_complete(false) {
|
||||
out.push(frame);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Start of a new SPU. The first 2 bytes are the big-endian total size.
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
let declared = if pes.data.len() >= 2 {
|
||||
((pes.data[0] as usize) << 8) | pes.data[1] as usize
|
||||
} else {
|
||||
// Too short to carry SPU_size — pass through as a lone frame.
|
||||
return vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}];
|
||||
};
|
||||
|
||||
let mut buf = pes.data.clone();
|
||||
if buf.len() > MAX_SPU_BYTES {
|
||||
buf.truncate(MAX_SPU_BYTES);
|
||||
}
|
||||
self.pending = Some((pts_ns, declared, buf));
|
||||
if let Some(frame) = self.take_if_complete(false) {
|
||||
out.push(frame);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// At EOF, emit whatever SPU bytes remain even if the declared size was
|
||||
// never reached (truncated final subtitle is better than dropping it).
|
||||
self.take_if_complete(true).into_iter().collect()
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -152,12 +221,53 @@ mod tests {
|
||||
#[test]
|
||||
fn no_pts_defaults_to_zero() {
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let pes = make_pes(vec![0x01, 0x02], None);
|
||||
// SPU_size = 2, single complete PES (the 2 size bytes themselves).
|
||||
let pes = make_pes(vec![0x00, 0x02], None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_pes_spu_reassembled() {
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
// Declared SPU_size = 12 bytes total. First PES carries the 2 size
|
||||
// bytes + 4 payload bytes and the only PTS; the next two PESs are
|
||||
// continuations with PTS=0.
|
||||
let head = vec![0x00, 0x0C, 0xAA, 0xBB, 0xCC, 0xDD];
|
||||
let cont1 = vec![0x11, 0x22, 0x33];
|
||||
let cont2 = vec![0x44, 0x55, 0x66];
|
||||
|
||||
let f = parser.parse(&make_pes(head.clone(), Some(90000)));
|
||||
assert!(f.is_empty(), "incomplete SPU should not emit yet");
|
||||
let f = parser.parse(&make_pes(cont1.clone(), Some(0)));
|
||||
assert!(f.is_empty(), "still incomplete");
|
||||
let frames = parser.parse(&make_pes(cont2.clone(), Some(0)));
|
||||
assert_eq!(frames.len(), 1, "completed SPU emits exactly one frame");
|
||||
|
||||
// Reassembled bytes = head + cont1 + cont2, in order.
|
||||
let mut expected = head;
|
||||
expected.extend_from_slice(&cont1);
|
||||
expected.extend_from_slice(&cont2);
|
||||
assert_eq!(frames[0].data, expected);
|
||||
// PTS inherited from the head PES (1s = 1e9 ns), not the PTS=0 tails.
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert!(frames[0].keyframe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_emits_truncated_trailing_spu() {
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
// Declared 100 bytes but only 6 ever arrive before EOF.
|
||||
let head = vec![0x00, 0x64, 0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let f = parser.parse(&make_pes(head.clone(), Some(90000)));
|
||||
assert!(f.is_empty(), "incomplete SPU should not emit during parse");
|
||||
let frames = parser.flush();
|
||||
assert_eq!(frames.len(), 1, "EOF flush emits the partial SPU");
|
||||
assert_eq!(frames[0].data, head);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// ── YCbCr → RGB conversion tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
+158
-25
@@ -13,6 +13,14 @@ const NAL_PPS: u8 = 8;
|
||||
const NAL_AUD: u8 = 9;
|
||||
|
||||
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;
|
||||
// a player re-applies the avcC copy at each keyframe. A stream may redefine
|
||||
// a parameter set mid-title under the SAME id with a different body. Any
|
||||
// occurrence whose body DIFFERS from the codecPrivate copy must therefore be
|
||||
// emitted IN-BAND at each point it appears so it overrides the re-applied
|
||||
// avcC set; otherwise those frames decode against the wrong parameter set.
|
||||
// (Same defect class as the HEVC PPS-redefinition bug.)
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -32,6 +40,28 @@ impl H264Parser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an SPS/PPS NAL (mirrors the HEVC fix):
|
||||
/// - First of its type → seeds codecPrivate (`first`); stripped from frame data
|
||||
/// (the player gets it from avcC).
|
||||
/// - Identical to the codecPrivate copy → stripped (the player re-applies it
|
||||
/// from avcC at each keyframe; BD streams repeat param sets at every IDR).
|
||||
/// - DIFFERENT body from the codecPrivate copy (a mid-title redefinition of the
|
||||
/// same id) → emitted IN-BAND (length-prefixed) at EVERY occurrence so it
|
||||
/// overrides the avcC copy the player re-applies at each keyframe.
|
||||
fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Vec<u8>) {
|
||||
match first {
|
||||
None => {
|
||||
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
|
||||
}
|
||||
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());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for H264Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -44,7 +74,9 @@ impl CodecParser for H264Parser {
|
||||
// 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);
|
||||
|
||||
// Scan NAL units for SPS, PPS, and IDR detection
|
||||
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
|
||||
// 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();
|
||||
|
||||
@@ -52,34 +84,23 @@ impl CodecParser for H264Parser {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
NAL_SPS => {
|
||||
self.sps = Some(nal.to_vec());
|
||||
// Param sets: seed avcC, strip if identical, emit in-band if a
|
||||
// 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.
|
||||
NAL_AUD => {}
|
||||
_ => {
|
||||
if nal_type == NAL_SLICE_IDR {
|
||||
keyframe = true;
|
||||
}
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_SLICE_IDR => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B (start code prefixed) to length-prefixed NALUs.
|
||||
// MKV with AVCDecoderConfigurationRecord expects 4-byte length prefix per NALU.
|
||||
// Skip SPS/PPS/AUD NALUs — they're in codecPrivate, not in frame data.
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
// Skip parameter sets and access unit delimiters
|
||||
if nal_type == NAL_SPS || nal_type == NAL_PPS || nal_type == NAL_AUD {
|
||||
continue;
|
||||
}
|
||||
// 4-byte big-endian length prefix
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -101,6 +122,14 @@ impl CodecParser for H264Parser {
|
||||
return None;
|
||||
}
|
||||
|
||||
// avcC encodes each NAL's length in a 16-bit field. A param set larger
|
||||
// than 65535 bytes would truncate the length while the full bytes are
|
||||
// appended → mis-framed record. Refuse rather than emit a corrupt avcC
|
||||
// (param sets this large are non-conforming anyway).
|
||||
if sps.len() > 0xFFFF || pps.len() > 0xFFFF {
|
||||
return None;
|
||||
}
|
||||
|
||||
// AVCDecoderConfigurationRecord (ISO 14496-15):
|
||||
// configurationVersion = 1
|
||||
// AVCProfileIndication = SPS[1]
|
||||
@@ -476,4 +505,108 @@ mod tests {
|
||||
// PTS must be used — MKV block timecodes are presentation timestamps.
|
||||
assert_eq!(frames[0].pts_ns, 2_000_000_000);
|
||||
}
|
||||
|
||||
// --- mid-title param-set redefinition emitted in-band ---
|
||||
|
||||
/// Collect the NAL types from a length-prefixed frame_data buffer.
|
||||
fn frame_nal_types(fd: &[u8]) -> Vec<u8> {
|
||||
let mut types = Vec::new();
|
||||
let mut off = 0;
|
||||
while off + 4 <= fd.len() {
|
||||
let len = u32::from_be_bytes([fd[off], fd[off + 1], fd[off + 2], fd[off + 3]]) as usize;
|
||||
off += 4;
|
||||
if off + len > fd.len() {
|
||||
break;
|
||||
}
|
||||
types.push(fd[off] & 0x1F);
|
||||
off += len;
|
||||
}
|
||||
types
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_param_sets_stripped_redefinition_emitted_inline() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
// AU 1: SPS(id0,bodyA) + PPS(id0,bodyA) + IDR. Both param sets are the
|
||||
// first of their type → seed avcC, stripped from frame data.
|
||||
let mut au1 = Vec::new();
|
||||
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au1.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS body A
|
||||
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au1.extend_from_slice(&[0x68, 0x11]); // PPS body A
|
||||
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au1.extend_from_slice(&[0x65, 0x10, 0x20]); // IDR
|
||||
let f1 = parser.parse(&make_pes(au1, Some(0)));
|
||||
assert_eq!(f1.len(), 1);
|
||||
// Frame 1 carries only the IDR — param sets stripped (in avcC).
|
||||
assert_eq!(
|
||||
frame_nal_types(&f1[0].data),
|
||||
vec![5],
|
||||
"AU1: only IDR in-band"
|
||||
);
|
||||
|
||||
// AU 2: SPS identical to avcC, PPS REDEFINED (same id, different body) +
|
||||
// IDR. The identical SPS is stripped; the redefined PPS must be emitted
|
||||
// in-band so it overrides the avcC copy at this keyframe.
|
||||
let mut au2 = Vec::new();
|
||||
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au2.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS == body A
|
||||
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au2.extend_from_slice(&[0x68, 0x22]); // PPS body B (redefinition)
|
||||
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au2.extend_from_slice(&[0x65, 0x30, 0x40]); // IDR
|
||||
let f2 = parser.parse(&make_pes(au2, Some(90000)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
let types = frame_nal_types(&f2[0].data);
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"redefined PPS (type 8) must be emitted in-band, got {types:?}"
|
||||
);
|
||||
assert!(
|
||||
!types.contains(&7),
|
||||
"identical SPS (type 7) must stay stripped, got {types:?}"
|
||||
);
|
||||
assert!(types.contains(&5), "IDR (type 5) present, got {types:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_identical_param_sets_stay_stripped() {
|
||||
let mut parser = H264Parser::new();
|
||||
let mut au = Vec::new();
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]);
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au.extend_from_slice(&[0x68, 0x11]);
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au.extend_from_slice(&[0x65, 0x10]);
|
||||
// Two identical AUs.
|
||||
parser.parse(&make_pes(au.clone(), Some(0)));
|
||||
let f = parser.parse(&make_pes(au, Some(90000)));
|
||||
assert_eq!(
|
||||
frame_nal_types(&f[0].data),
|
||||
vec![5],
|
||||
"repeated identical SPS/PPS stay in avcC, not duplicated in-band"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_oversized_param_set_returns_none() {
|
||||
// A param set > 65535 bytes can't be length-encoded in avcC's 16-bit
|
||||
// field; codec_private must refuse rather than emit a truncated record.
|
||||
let mut parser = H264Parser::new();
|
||||
let mut data = Vec::new();
|
||||
// Oversized SPS (header byte 0x67 + 70000 filler bytes).
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67);
|
||||
data.extend_from_slice(&vec![0x11u8; 70_000]);
|
||||
// PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&[0x68, 0x11]);
|
||||
parser.parse(&make_pes(data, Some(0)));
|
||||
assert!(
|
||||
parser.codec_private().is_none(),
|
||||
"oversized SPS must not produce a truncated avcC"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+465
-9
@@ -21,6 +21,15 @@ const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
|
||||
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
|
||||
// it at every keyframe (ffmpeg's hvcC→Annex-B insertion). A stream may
|
||||
// redefine a parameter set mid-title under the SAME id with a different body
|
||||
// (Fight Club redefines PPS id 0 partway through). Any occurrence whose body
|
||||
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
||||
// each point it appears (i.e. at every keyframe of the redefined segment) so
|
||||
// it overrides the re-applied codecPrivate set; otherwise those frames decode
|
||||
// against the wrong parameter set → CABAC/cu_qp_delta desync.
|
||||
vps: Option<Vec<u8>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
@@ -42,6 +51,32 @@ impl HevcParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a VPS/SPS/PPS NAL.
|
||||
///
|
||||
/// - First of its type → seeds codecPrivate (`first`); stripped from frame data
|
||||
/// (the player gets it from hvcC).
|
||||
/// - Identical to the codecPrivate copy → stripped (the player already re-applies
|
||||
/// it from hvcC at each keyframe; BD streams repeat param sets at every IRAP).
|
||||
/// - DIFFERENT body from the codecPrivate copy (a mid-title redefinition of the
|
||||
/// same id) → emitted IN-BAND (length-prefixed) at EVERY occurrence, so it
|
||||
/// overrides the hvcC copy the player re-applies at each keyframe. Emitting it
|
||||
/// only once is not enough — the next keyframe's hvcC re-insertion would revert
|
||||
/// it. This matches what a conforming muxer produces and fixes the Fight Club
|
||||
/// PPS-id-0 redefinition.
|
||||
fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Vec<u8>) {
|
||||
match first {
|
||||
None => {
|
||||
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
|
||||
}
|
||||
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());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for HevcParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -83,13 +118,13 @@ impl CodecParser for HevcParser {
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => {
|
||||
self.vps = Some(data[nal_start..end].to_vec());
|
||||
handle_param_set(&mut self.vps, &data[nal_start..end], &mut frame_data)
|
||||
}
|
||||
NAL_SPS => {
|
||||
self.sps = Some(data[nal_start..end].to_vec());
|
||||
handle_param_set(&mut self.sps, &data[nal_start..end], &mut frame_data)
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(data[nal_start..end].to_vec());
|
||||
handle_param_set(&mut self.pps, &data[nal_start..end], &mut frame_data)
|
||||
}
|
||||
NAL_AUD => {} // Skip access unit delimiters
|
||||
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
|
||||
@@ -130,6 +165,14 @@ impl CodecParser for HevcParser {
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// hvcC encodes each NAL's length as a 16-bit field. A param set larger
|
||||
// than 65535 bytes would silently truncate the length while the full
|
||||
// bytes are appended → mis-framed record. Refuse rather than emit a
|
||||
// corrupt hvcC (param sets this large are non-conforming anyway).
|
||||
if vps.len() > 0xFFFF || sps.len() > 0xFFFF || pps.len() > 0xFFFF {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Simplified: store as arrays in Annex B format
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
let mut record = Vec::new();
|
||||
@@ -173,12 +216,21 @@ impl CodecParser for HevcParser {
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// chromaFormat / bit depths — parse the real values from the SPS RBSP.
|
||||
// A hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (essentially
|
||||
// all UHD content). Fall back to 8-bit 4:2:0 only if the SPS can't be
|
||||
// parsed (emulation-prevention is handled; sub-layer PTL is skipped).
|
||||
let chroma = parse_sps_chroma(sps).unwrap_or(SpsChroma {
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma_minus8: 0,
|
||||
bit_depth_chroma_minus8: 0,
|
||||
});
|
||||
// chromaFormat (6 reserved bits set + 2-bit chroma_format_idc)
|
||||
record.push(0xFC | (chroma.chroma_format_idc & 0x03));
|
||||
// bitDepthLumaMinus8 (5 reserved bits set + 3-bit value)
|
||||
record.push(0xF8 | (chroma.bit_depth_luma_minus8 & 0x07));
|
||||
// bitDepthChromaMinus8 (5 reserved bits set + 3-bit value)
|
||||
record.push(0xF8 | (chroma.bit_depth_chroma_minus8 & 0x07));
|
||||
// avgFrameRate
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
@@ -211,6 +263,181 @@ impl CodecParser for HevcParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// chroma_format_idc + bit depths parsed from an HEVC SPS RBSP, for the hvcC
|
||||
/// fixed header. Without these the record falsely advertised 8-bit 4:2:0, wrong
|
||||
/// for 10-bit Main 10 UHD (essentially all UHD content).
|
||||
struct SpsChroma {
|
||||
/// chroma_format_idc: 0 mono, 1 4:2:0, 2 4:2:2, 3 4:4:4.
|
||||
chroma_format_idc: u8,
|
||||
bit_depth_luma_minus8: u8,
|
||||
bit_depth_chroma_minus8: u8,
|
||||
}
|
||||
|
||||
/// Minimal MSB-first bit reader over a byte slice.
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
bit_pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, bit_pos: 0 }
|
||||
}
|
||||
|
||||
fn read_bit(&mut self) -> Option<u32> {
|
||||
let byte = self.bit_pos / 8;
|
||||
if byte >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
let shift = 7 - (self.bit_pos % 8);
|
||||
self.bit_pos += 1;
|
||||
Some(((self.data[byte] >> shift) & 1) as u32)
|
||||
}
|
||||
|
||||
fn read_bits(&mut self, n: u32) -> Option<u32> {
|
||||
let mut v = 0u32;
|
||||
for _ in 0..n {
|
||||
v = (v << 1) | self.read_bit()?;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn skip_bits(&mut self, n: u32) -> Option<()> {
|
||||
for _ in 0..n {
|
||||
self.read_bit()?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Exp-Golomb unsigned, ue(v). Bounded leading-zero count to avoid runaway
|
||||
/// on corrupt input.
|
||||
fn read_ue(&mut self) -> Option<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while self.read_bit()? == 0 {
|
||||
zeros += 1;
|
||||
if zeros > 31 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if zeros == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let rest = self.read_bits(zeros)?;
|
||||
Some((1u32 << zeros) - 1 + rest)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip HEVC/H.264 emulation-prevention bytes (00 00 03 → 00 00) from a NAL
|
||||
/// RBSP so a bit reader sees the true coded values.
|
||||
fn strip_emulation_prevention(rbsp: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(rbsp.len());
|
||||
let mut zeros = 0usize;
|
||||
for &b in rbsp {
|
||||
if zeros >= 2 && b == 0x03 {
|
||||
// Drop the emulation-prevention byte; reset the run.
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
if b == 0x00 {
|
||||
zeros += 1;
|
||||
} else {
|
||||
zeros = 0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse chroma_format_idc and bit depths from a stored SPS NAL
|
||||
/// (`[2-byte NAL header][RBSP...]`). Handles emulation-prevention and
|
||||
/// sub-layer profile_tier_level. Returns `None` if the bitstream is too short
|
||||
/// or malformed (caller falls back to the 8-bit 4:2:0 default).
|
||||
fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> {
|
||||
if sps.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// RBSP begins after the 2-byte HEVC NAL header.
|
||||
let rbsp = strip_emulation_prevention(&sps[2..]);
|
||||
let mut r = BitReader::new(&rbsp);
|
||||
|
||||
// sps_video_parameter_set_id u(4)
|
||||
r.skip_bits(4)?;
|
||||
// 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)?;
|
||||
|
||||
// profile_tier_level( 1, sps_max_sub_layers_minus1 )
|
||||
parse_profile_tier_level(&mut r, max_sub_layers_minus1)?;
|
||||
|
||||
// sps_seq_parameter_set_id ue(v)
|
||||
r.read_ue()?;
|
||||
// chroma_format_idc ue(v)
|
||||
let chroma_format_idc = r.read_ue()? as u8;
|
||||
if chroma_format_idc == 3 {
|
||||
// separate_colour_plane_flag u(1)
|
||||
r.skip_bits(1)?;
|
||||
}
|
||||
// pic_width_in_luma_samples ue(v), pic_height_in_luma_samples ue(v)
|
||||
r.read_ue()?;
|
||||
r.read_ue()?;
|
||||
// conformance_window_flag u(1) + 4× ue(v) if set
|
||||
if r.read_bit()? == 1 {
|
||||
r.read_ue()?;
|
||||
r.read_ue()?;
|
||||
r.read_ue()?;
|
||||
r.read_ue()?;
|
||||
}
|
||||
// bit_depth_luma_minus8 ue(v), bit_depth_chroma_minus8 ue(v)
|
||||
let bit_depth_luma_minus8 = r.read_ue()? as u8;
|
||||
let bit_depth_chroma_minus8 = r.read_ue()? as u8;
|
||||
|
||||
Some(SpsChroma {
|
||||
chroma_format_idc,
|
||||
bit_depth_luma_minus8,
|
||||
bit_depth_chroma_minus8,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
r.skip_bits(96)?;
|
||||
|
||||
if max_sub_layers_minus1 > 0 {
|
||||
// sub_layer_profile_present_flag[i] u(1) + sub_layer_level_present_flag[i]
|
||||
// u(1), for i in 0..max_sub_layers_minus1.
|
||||
let mut profile_present = [false; 8];
|
||||
let mut level_present = [false; 8];
|
||||
for i in 0..max_sub_layers_minus1 as usize {
|
||||
profile_present[i] = r.read_bit()? == 1;
|
||||
level_present[i] = r.read_bit()? == 1;
|
||||
}
|
||||
// reserved_zero_2bits for i in max_sub_layers_minus1..8
|
||||
if max_sub_layers_minus1 < 8 {
|
||||
for _ in max_sub_layers_minus1..8 {
|
||||
r.skip_bits(2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..max_sub_layers_minus1 as usize {
|
||||
if profile_present[i] {
|
||||
// sub_layer profile block: 8 + 32 + 48 = 88 bits.
|
||||
r.skip_bits(88)?;
|
||||
}
|
||||
if level_present[i] {
|
||||
// sub_layer_level_idc u(8)
|
||||
r.skip_bits(8)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -561,6 +788,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// --- parameter-set redefinition (Fight Club bug) ---
|
||||
|
||||
/// A parameter set REDEFINED mid-stream (same id, different body) must be
|
||||
/// emitted INLINE so the decoder re-activates it. Fight Club redefines PPS
|
||||
/// id 0 partway through the title; the old parser kept only the first PPS,
|
||||
/// so the second segment decoded against the wrong PPS (CABAC desync).
|
||||
#[test]
|
||||
fn redefined_pps_emitted_inline() {
|
||||
let mut parser = HevcParser::new();
|
||||
let pps = |body: u8| {
|
||||
let mut v = vec![0x00, 0x00, 0x01];
|
||||
v.extend_from_slice(&hevc_nal_header(34)); // PPS
|
||||
v.extend_from_slice(&[body, body]);
|
||||
v
|
||||
};
|
||||
let slice = || {
|
||||
let mut v = vec![0x00, 0x00, 0x01];
|
||||
v.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
|
||||
v.extend_from_slice(&[0x10, 0x20]);
|
||||
v
|
||||
};
|
||||
// count PPS (type 34) NALs in length-prefixed frame data
|
||||
let count_pps = |fd: &[u8]| {
|
||||
let (mut n, mut o) = (0usize, 0usize);
|
||||
while o + 4 <= fd.len() {
|
||||
let len = u32::from_be_bytes([fd[o], fd[o + 1], fd[o + 2], fd[o + 3]]) as usize;
|
||||
o += 4;
|
||||
if o < fd.len() && (fd[o] >> 1) & 0x3F == 34 {
|
||||
n += 1;
|
||||
}
|
||||
o += len;
|
||||
}
|
||||
n
|
||||
};
|
||||
|
||||
// PES1: first PPS-A → seeds codecPrivate, stripped from frame.
|
||||
let mut d = pps(0xAA);
|
||||
d.extend(slice());
|
||||
let f = parser.parse(&make_pes(d, Some(0)));
|
||||
assert_eq!(count_pps(&f[0].data), 0, "first PPS goes to codecPrivate");
|
||||
|
||||
// PES2: PPS-B (redefinition, different body) → emitted INLINE.
|
||||
let mut d = pps(0xBB);
|
||||
d.extend(slice());
|
||||
let f = parser.parse(&make_pes(d, Some(1)));
|
||||
assert_eq!(count_pps(&f[0].data), 1, "redefined PPS must be inline");
|
||||
|
||||
// PES3: PPS-B repeated — still differs from codecPrivate(A), so emitted
|
||||
// AGAIN. Every keyframe of the redefined segment must carry it, because
|
||||
// the player re-applies the hvcC (codecPrivate) copy at each keyframe;
|
||||
// emitting once would be reverted at the next keyframe.
|
||||
let mut d = pps(0xBB);
|
||||
d.extend(slice());
|
||||
let f = parser.parse(&make_pes(d, Some(2)));
|
||||
assert_eq!(
|
||||
count_pps(&f[0].data),
|
||||
1,
|
||||
"redefined PPS re-emitted every occurrence"
|
||||
);
|
||||
|
||||
// PES4: back to PPS-A (== codecPrivate) → stripped (hvcC supplies it).
|
||||
let mut d = pps(0xAA);
|
||||
d.extend(slice());
|
||||
let f = parser.parse(&make_pes(d, Some(3)));
|
||||
assert_eq!(
|
||||
count_pps(&f[0].data),
|
||||
0,
|
||||
"occurrence equal to codecPrivate stripped"
|
||||
);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
@@ -714,4 +1012,162 @@ mod tests {
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
|
||||
// --- hvcC chroma / bit-depth from SPS ---
|
||||
|
||||
/// MSB-first bit writer for building a test SPS RBSP.
|
||||
struct BitWriter {
|
||||
bytes: Vec<u8>,
|
||||
nbits: usize,
|
||||
}
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
nbits: 0,
|
||||
}
|
||||
}
|
||||
fn put_bit(&mut self, b: u32) {
|
||||
if self.nbits % 8 == 0 {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if b & 1 != 0 {
|
||||
let i = self.nbits / 8;
|
||||
let shift = 7 - (self.nbits % 8);
|
||||
self.bytes[i] |= 1 << shift;
|
||||
}
|
||||
self.nbits += 1;
|
||||
}
|
||||
fn put_bits(&mut self, v: u32, n: u32) {
|
||||
for i in (0..n).rev() {
|
||||
self.put_bit((v >> i) & 1);
|
||||
}
|
||||
}
|
||||
fn put_ue(&mut self, v: u32) {
|
||||
let val = v + 1;
|
||||
let bits = 32 - val.leading_zeros();
|
||||
for _ in 0..bits - 1 {
|
||||
self.put_bit(0);
|
||||
}
|
||||
for i in (0..bits).rev() {
|
||||
self.put_bit((val >> i) & 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a stored SPS NAL ([2-byte header][RBSP]) with the given
|
||||
/// chroma_format_idc and bit depths, max_sub_layers_minus1 = 0.
|
||||
fn make_sps_with_chroma(chroma_idc: u32, bd_luma_m8: u32, bd_chroma_m8: u32) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.put_bits(0, 4); // sps_video_parameter_set_id
|
||||
w.put_bits(0, 3); // sps_max_sub_layers_minus1 = 0
|
||||
w.put_bit(1); // sps_temporal_id_nesting_flag
|
||||
// general profile_tier_level: 96 bits (12 bytes) of zeros is fine here.
|
||||
for _ in 0..96 {
|
||||
w.put_bit(0);
|
||||
}
|
||||
w.put_ue(0); // sps_seq_parameter_set_id
|
||||
w.put_ue(chroma_idc); // chroma_format_idc
|
||||
if chroma_idc == 3 {
|
||||
w.put_bit(0); // separate_colour_plane_flag
|
||||
}
|
||||
w.put_ue(3840); // pic_width_in_luma_samples
|
||||
w.put_ue(2160); // pic_height_in_luma_samples
|
||||
w.put_bit(0); // conformance_window_flag = 0
|
||||
w.put_ue(bd_luma_m8); // bit_depth_luma_minus8
|
||||
w.put_ue(bd_chroma_m8); // bit_depth_chroma_minus8
|
||||
|
||||
let mut sps = hevc_nal_header(33).to_vec();
|
||||
sps.extend_from_slice(&w.bytes);
|
||||
sps
|
||||
}
|
||||
|
||||
fn codec_private_from_sps(sps_nal: &[u8]) -> Vec<u8> {
|
||||
let mut parser = HevcParser::new();
|
||||
// VPS + the given SPS + PPS, all length-prefixed in one PES.
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA, 0xBB]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(sps_nal);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xDD, 0xEE]);
|
||||
parser.parse(&make_pes(data, Some(0)));
|
||||
parser.codec_private().expect("codec_private")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_emits_10bit_420_from_sps() {
|
||||
// Main 10 UHD: chroma_format_idc=1 (4:2:0), bit depths = 10 (minus8 = 2).
|
||||
let sps = make_sps_with_chroma(1, 2, 2);
|
||||
let cp = codec_private_from_sps(&sps);
|
||||
// chromaFormat at cp[16], bit depths at cp[17]/cp[18].
|
||||
assert_eq!(cp[16], 0xFC | 1, "chroma_format_idc = 1 (4:2:0)");
|
||||
assert_eq!(cp[17], 0xF8 | 2, "bit_depth_luma_minus8 = 2 (10-bit)");
|
||||
assert_eq!(cp[18], 0xF8 | 2, "bit_depth_chroma_minus8 = 2 (10-bit)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_emits_8bit_420_from_sps() {
|
||||
// 8-bit 4:2:0 must still report correctly (not a regression).
|
||||
let sps = make_sps_with_chroma(1, 0, 0);
|
||||
let cp = codec_private_from_sps(&sps);
|
||||
assert_eq!(cp[16], 0xFC | 1);
|
||||
assert_eq!(cp[17], 0xF8);
|
||||
assert_eq!(cp[18], 0xF8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_emits_444_12bit_from_sps() {
|
||||
// 4:4:4 (idc=3) with 12-bit depth (minus8 = 4).
|
||||
let sps = make_sps_with_chroma(3, 4, 4);
|
||||
let cp = codec_private_from_sps(&sps);
|
||||
assert_eq!(cp[16], 0xFC | 3, "chroma_format_idc = 3 (4:4:4)");
|
||||
assert_eq!(cp[17], 0xF8 | 4, "bit_depth_luma_minus8 = 4 (12-bit)");
|
||||
assert_eq!(cp[18], 0xF8 | 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_handles_emulation_prevention_in_sps() {
|
||||
// Insert an emulation-prevention byte (00 00 03) into the SPS RBSP and
|
||||
// confirm the chroma/bit-depth parse still lands on the right values.
|
||||
// Build a 10-bit 4:2:0 SPS, then splice 00 00 03 into the RBSP tail
|
||||
// (after the fields we parse) — the strip must not corrupt earlier bits.
|
||||
let mut sps = make_sps_with_chroma(1, 2, 2);
|
||||
// Append a benign 00 00 03 sequence to the RBSP.
|
||||
sps.extend_from_slice(&[0x00, 0x00, 0x03, 0x00]);
|
||||
let cp = codec_private_from_sps(&sps);
|
||||
assert_eq!(cp[16], 0xFC | 1);
|
||||
assert_eq!(cp[17], 0xF8 | 2);
|
||||
assert_eq!(cp[18], 0xF8 | 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_oversized_param_set_returns_none() {
|
||||
// A param set larger than 65535 bytes cannot be length-encoded in hvcC's
|
||||
// 16-bit field; codec_private must refuse rather than emit a truncated,
|
||||
// mis-framed record.
|
||||
let mut parser = HevcParser::new();
|
||||
let mut data = Vec::new();
|
||||
// VPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA, 0xBB]);
|
||||
// Oversized SPS: header + 70000 bytes of payload (avoid 00 00 0x runs by
|
||||
// using 0x11 filler so it stays one NAL).
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&vec![0x11u8; 70_000]);
|
||||
// PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xDD, 0xEE]);
|
||||
parser.parse(&make_pes(data, Some(0)));
|
||||
assert!(
|
||||
parser.codec_private().is_none(),
|
||||
"oversized param set must not produce a (truncated) hvcC"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,25 @@ impl CodecParser for PgsParser {
|
||||
out
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// A display set is only emitted when the *next* PCS arrives
|
||||
// (either an empty clear PCS or a replacing display PCS). At
|
||||
// end-of-stream there is no follower, so without this the last
|
||||
// subtitle of every PGS track would be silently dropped. Emit
|
||||
// the pending set with no duration — the trailing block lingers
|
||||
// until end of file, which is exactly the desired behavior for
|
||||
// the final on-screen subtitle (see the module doc).
|
||||
match self.pending.take() {
|
||||
Some((start_pts, data)) => vec![Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: None,
|
||||
}],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
@@ -218,6 +237,30 @@ mod tests {
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_emits_final_pending_subtitle() {
|
||||
let mut parser = PgsParser::new();
|
||||
|
||||
// Display PCS at PTS 90000 — buffered as pending, no follower.
|
||||
let display = pcs_bytes(1);
|
||||
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
|
||||
assert!(frames.is_empty(), "display PCS should be pending");
|
||||
|
||||
// EOF: without flush() this last subtitle would be dropped.
|
||||
let frames = parser.flush();
|
||||
assert_eq!(frames.len(), 1, "final pending subtitle must flush");
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert_eq!(frames[0].data, display);
|
||||
// Trailing block lingers to EOF — no duration per module doc.
|
||||
assert_eq!(frames[0].duration_ns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_with_nothing_pending_is_empty() {
|
||||
let mut parser = PgsParser::new();
|
||||
assert!(parser.flush().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = PgsParser::new();
|
||||
|
||||
+54
-2
@@ -96,8 +96,17 @@ impl CodecParser for TrueHdParser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let Some(pts) = pes.pts {
|
||||
self.next_pts_ns = pts_to_ns(pts);
|
||||
// Capture the PTS base ONLY at an access-unit boundary, i.e. when no AU
|
||||
// is mid-assembly in `buf`. TrueHD access units span PES packets; a PES
|
||||
// that merely continues an AU already in progress carries its own (later)
|
||||
// PTS, which must NOT override the running per-AU timestamp. Adopting it
|
||||
// mid-AU would snap that AU's PTS backward/forward and break the
|
||||
// monotonic +AU_DURATION_NS cadence (A/V drift). Once the buffer is empty
|
||||
// the next PES legitimately begins a new AU and seeds the base.
|
||||
if self.buf.is_empty() {
|
||||
if let Some(pts) = pes.pts {
|
||||
self.next_pts_ns = pts_to_ns(pts);
|
||||
}
|
||||
}
|
||||
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
@@ -300,6 +309,49 @@ mod tests {
|
||||
assert_eq!(frames[0].data.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_pes_pts_does_not_override_au_in_progress() {
|
||||
// An AU split across two PES packets: the FIRST PES (pts 90000) begins
|
||||
// the AU; the SECOND PES (pts 99999, a later timestamp) merely continues
|
||||
// it. The emitted AU must keep the first PES's PTS, not adopt the
|
||||
// continuation PES's later timestamp.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let unit = make_truehd_unit(200);
|
||||
let mid = 100;
|
||||
|
||||
let pes1 = make_pes(unit[..mid].to_vec(), Some(90000));
|
||||
assert!(parser.parse(&pes1).is_empty(), "AU held mid-assembly");
|
||||
|
||||
// Continuation PES carries a later PTS that must be ignored for this AU.
|
||||
let pes2 = make_pes(unit[mid..].to_vec(), Some(99999));
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(
|
||||
frames[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"AU keeps the PTS of the PES that began it, not the continuation PES"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_au_after_empty_buffer_takes_new_pes_pts() {
|
||||
// After an AU fully drains (buffer empty), the next PES legitimately
|
||||
// seeds a fresh PTS base.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let f1 = parser.parse(&make_pes(make_truehd_unit(200), Some(90000)));
|
||||
assert_eq!(f1.len(), 1);
|
||||
assert_eq!(f1[0].pts_ns, pts_to_ns(90000));
|
||||
|
||||
// Buffer is now empty; a new PES with a new PTS starts a new AU.
|
||||
let f2 = parser.parse(&make_pes(make_truehd_unit(200), Some(180000)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(180000),
|
||||
"new AU after empty buffer adopts the new PES PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = TrueHdParser::new();
|
||||
|
||||
+46
-36
@@ -508,24 +508,30 @@ impl crate::pes::Stream for DiscStream {
|
||||
// PS demuxer flush (DVD)
|
||||
if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
for ps in &demuxer.flush() {
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0,
|
||||
0xC0..=0xDF => 1,
|
||||
0xBD => ps
|
||||
.sub_stream_id
|
||||
.map(|s| (s & 0x1F) as usize + 1)
|
||||
.unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track >= self.title.streams.len() {
|
||||
// Route by the REAL DVD PID (see consume_ps in
|
||||
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
};
|
||||
let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||
else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
pid,
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let pes = super::ts::PesPacket {
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
@@ -607,26 +613,30 @@ impl crate::pes::Stream for DiscStream {
|
||||
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for ps in &packets {
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0,
|
||||
0xC0..=0xDF => 1,
|
||||
0xBD => ps
|
||||
.sub_stream_id
|
||||
.map(|s| (s & 0x1F) as usize + 1)
|
||||
.unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track >= self.title.streams.len() {
|
||||
// Route by the REAL DVD PID (see consume_ps in
|
||||
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert PsPacket to PesPacket for codec parser (same as BD-TS path)
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
};
|
||||
let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||
else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
pid,
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let pes = super::ts::PesPacket {
|
||||
pid,
|
||||
|
||||
+151
-15
@@ -3,10 +3,24 @@
|
||||
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
|
||||
//! Other tools skip the header during TS sync recovery (scan for 0x47).
|
||||
|
||||
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
|
||||
use crate::disc::{
|
||||
AudioStream, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Derive the color space from the HDR format, for metadata written before the
|
||||
/// color space was persisted (pre-0.30.7). All HDR formats use BT.2020 wide
|
||||
/// gamut; SDR uses BT.709.
|
||||
fn color_space_from_hdr(hdr: HdrFormat) -> ColorSpace {
|
||||
match hdr {
|
||||
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::Hlg | HdrFormat::DolbyVision => {
|
||||
ColorSpace::Bt2020
|
||||
}
|
||||
HdrFormat::Sdr => ColorSpace::Bt709,
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
|
||||
@@ -42,6 +56,11 @@ pub enum MetaStream {
|
||||
frame_rate: String,
|
||||
#[serde(default)]
|
||||
hdr: String,
|
||||
/// Color space id (e.g. "bt709", "bt2020"). Empty/absent in pre-0.30.7
|
||||
/// metadata — `to_title` then derives it from `hdr` so HDR color
|
||||
/// primaries/transfer/matrix still round-trip.
|
||||
#[serde(default)]
|
||||
color_space: String,
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
@@ -91,6 +110,7 @@ impl M2tsMeta {
|
||||
resolution: v.resolution.to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
hdr: v.hdr.id().into(),
|
||||
color_space: v.color_space.id().into(),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: title
|
||||
@@ -137,23 +157,38 @@ impl M2tsMeta {
|
||||
resolution,
|
||||
frame_rate,
|
||||
hdr,
|
||||
color_space,
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
} => Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
}),
|
||||
} => {
|
||||
let hdr_fmt = hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr);
|
||||
// Prefer the explicitly stored color space. Pre-0.30.7
|
||||
// metadata has none, so derive it from the HDR format:
|
||||
// every HDR variant (HDR10/HDR10+/HLG/Dolby Vision) is
|
||||
// BT.2020; SDR is BT.709.
|
||||
let cs = if color_space.is_empty() {
|
||||
color_space_from_hdr(hdr_fmt)
|
||||
} else {
|
||||
color_space
|
||||
.parse::<ColorSpace>()
|
||||
.unwrap_or(ColorSpace::Unknown)
|
||||
};
|
||||
Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr_fmt,
|
||||
color_space: cs,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
})
|
||||
}
|
||||
MetaStream::Audio {
|
||||
pid,
|
||||
codec,
|
||||
@@ -289,3 +324,104 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
|
||||
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
|
||||
// Deserialization uses FromStr impls (.parse()) on each enum.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{Codec, FrameRate, Resolution};
|
||||
|
||||
fn video_title(hdr: HdrFormat, cs: ColorSpace) -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams.push(Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr,
|
||||
color_space: cs,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
t
|
||||
}
|
||||
|
||||
fn round_trip_color_space(title: &DiscTitle) -> ColorSpace {
|
||||
let meta = M2tsMeta::from_title(title);
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => v.color_space,
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_round_trips_bt2020() {
|
||||
// The regression: HDR10 / BT.2020 must survive from_title → to_title,
|
||||
// not collapse to the hardcoded BT.709.
|
||||
let cs = round_trip_color_space(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
|
||||
assert_eq!(cs, ColorSpace::Bt2020, "BT.2020 must round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_round_trips_bt709() {
|
||||
let cs = round_trip_color_space(&video_title(HdrFormat::Sdr, ColorSpace::Bt709));
|
||||
assert_eq!(cs, ColorSpace::Bt709);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_serialized_in_json() {
|
||||
let meta = M2tsMeta::from_title(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
assert!(
|
||||
json.contains("bt2020"),
|
||||
"color_space must be serialized: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_metadata_without_color_space_derives_from_hdr() {
|
||||
// Pre-0.30.7 JSON has no color_space field. to_title must derive the
|
||||
// color space from the HDR format so HDR color metadata is preserved.
|
||||
let json = r#"{
|
||||
"v": 1,
|
||||
"title": "x",
|
||||
"duration": 0.0,
|
||||
"streams": [
|
||||
{"type":"video","pid":4113,"codec":"hevc","resolution":"2160p",
|
||||
"frame_rate":"23.976","hdr":"hdr10","label":"","secondary":false}
|
||||
]
|
||||
}"#;
|
||||
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => {
|
||||
assert_eq!(v.hdr, HdrFormat::Hdr10);
|
||||
assert_eq!(
|
||||
v.color_space,
|
||||
ColorSpace::Bt2020,
|
||||
"HDR10 must derive BT.2020 when color_space absent"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sdr_without_color_space_derives_bt709() {
|
||||
let json = r#"{
|
||||
"v": 1,
|
||||
"title": "x",
|
||||
"duration": 0.0,
|
||||
"streams": [
|
||||
{"type":"video","pid":4113,"codec":"h264","resolution":"1080p",
|
||||
"frame_rate":"24","hdr":"sdr","label":"","secondary":false}
|
||||
]
|
||||
}"#;
|
||||
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => assert_eq!(v.color_space, ColorSpace::Bt709),
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,11 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
cluster_size_pos: u64,
|
||||
cluster_ts_ms: i64,
|
||||
base_pts_ms: Option<i64>,
|
||||
/// Last block timecode (ms, relative to base_pts) written PER TRACK, to
|
||||
/// enforce strictly-monotonic per-track timestamps — players/ffmpeg reject
|
||||
/// non-monotonic DTS, and some audio PES PTS land on the same millisecond
|
||||
/// (or tick back 1ms from rounding).
|
||||
last_pts_ms: std::collections::HashMap<usize, i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
seek_fixups: Vec<SeekPositionFixup>,
|
||||
@@ -229,6 +234,19 @@ const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
/// the `as i16` cast can never wrap.
|
||||
const MAX_BLOCK_REL_MS: i64 = i16::MAX 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`
|
||||
/// for the first frame). Fixes non-monotonic DTS: some audio PES PTS truncate to
|
||||
/// the same millisecond as the prior frame (or tick back 1ms from rounding),
|
||||
/// which ffmpeg/strict players reject. The nudge is at most a few ms — sub-frame
|
||||
/// and inaudible — and never moves a timestamp earlier.
|
||||
fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
match prev {
|
||||
Some(p) => pts_ms.max(p.saturating_add(1)),
|
||||
None => pts_ms,
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||||
pub fn new(
|
||||
@@ -425,6 +443,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
cluster_size_pos: 0,
|
||||
cluster_ts_ms: 0,
|
||||
base_pts_ms: None,
|
||||
last_pts_ms: std::collections::HashMap::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
seek_fixups,
|
||||
@@ -453,6 +472,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||
let pts_ms = raw_ms - base;
|
||||
|
||||
// Enforce strictly-monotonic per-track block timestamps. Some audio PES
|
||||
// PTS truncate to the same millisecond as the previous frame (or, rarely,
|
||||
// tick back 1ms), which surfaces as "non-monotonic DTS" and is rejected
|
||||
// by ffmpeg/strict players. Nudge to prev+1ms — sub-frame, inaudible,
|
||||
// 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;
|
||||
@@ -482,6 +508,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
self.start_cluster(pts_ms)?;
|
||||
}
|
||||
|
||||
// Committed to writing this frame — record its (monotonic) timestamp so
|
||||
// the next block on this track is forced strictly later.
|
||||
self.last_pts_ms.insert(track_idx, pts_ms);
|
||||
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
match duration_ns {
|
||||
Some(dur_ns) => {
|
||||
@@ -829,6 +859,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_ts_forces_strictly_increasing() {
|
||||
// First frame passes through unchanged.
|
||||
assert_eq!(monotonic_ts(None, 1000), 1000);
|
||||
// A repeated millisecond is nudged to prev+1.
|
||||
assert_eq!(monotonic_ts(Some(1000), 1000), 1001);
|
||||
// A backwards tick is nudged forward, never earlier.
|
||||
assert_eq!(monotonic_ts(Some(1001), 1000), 1002);
|
||||
// A genuine advance is left alone.
|
||||
assert_eq!(monotonic_ts(Some(1000), 1040), 1040);
|
||||
// Simulate a stream of audio PTS that round to dup/back-tick ms and
|
||||
// confirm the emitted sequence is strictly increasing.
|
||||
let raw = [1000i64, 1000, 1000, 999, 1032, 1032, 1064];
|
||||
let mut prev: Option<i64> = None;
|
||||
let mut out = Vec::new();
|
||||
for &p in &raw {
|
||||
let t = monotonic_ts(prev, p);
|
||||
out.push(t);
|
||||
prev = Some(t);
|
||||
}
|
||||
assert!(
|
||||
out.windows(2).all(|w| w[1] > w[0]),
|
||||
"not strictly monotonic: {out:?}"
|
||||
);
|
||||
assert_eq!(out, [1000, 1001, 1002, 1003, 1032, 1033, 1064]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_multiple_tracks() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
|
||||
+24
-17
@@ -124,24 +124,31 @@ impl PipelinedPesStream {
|
||||
|
||||
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
|
||||
for ps in packets {
|
||||
let track = match ps.stream_id {
|
||||
0xE0..=0xEF => 0,
|
||||
0xC0..=0xDF => 1,
|
||||
0xBD => ps
|
||||
.sub_stream_id
|
||||
.map(|s| (s & 0x1F) as usize + 1)
|
||||
.unwrap_or(1),
|
||||
_ => continue,
|
||||
};
|
||||
if track >= self.title.streams.len() {
|
||||
// Route by the REAL DVD PID (matching the PIDs that
|
||||
// `scan_dvd_titles` assigns) rather than a synthetic track
|
||||
// index. The old `(sub_id & 0x1F) + 1` heuristic collided
|
||||
// subtitle sub-id 0x20+j with audio track j+1, feeding
|
||||
// VobSub PES into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
};
|
||||
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||
else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
pid,
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let pes = PesPacket {
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
|
||||
@@ -39,6 +39,39 @@ pub struct PsPacket {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PsPacket {
|
||||
/// Map this packet to the canonical DVD PID assigned by
|
||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||
/// be looked up in the title's `pid_to_track` map.
|
||||
///
|
||||
/// The PID space mirrors `dvd.rs` exactly:
|
||||
/// - video stream id `0xE0..=0xEF` → `0xE0`
|
||||
/// - private-stream-1 audio sub-id `0x80..=0x87` (AC-3),
|
||||
/// `0x88..=0x8F` (DTS), `0xA0..=0xA7` (LPCM) → `0xBD00 + index`
|
||||
/// - private-stream-1 subtitle sub-id `0x20..=0x3F` → `0x20 + index`
|
||||
///
|
||||
/// Returns `None` for stream/sub-stream combinations the DVD title
|
||||
/// scanner does not assign a PID to (e.g. MPEG audio 0xC0-0xDF,
|
||||
/// private stream 2, unrecognized sub-stream ranges). The caller is
|
||||
/// expected to WARN-and-drop in that case rather than silently
|
||||
/// mis-routing the packet.
|
||||
pub fn dvd_pid(&self) -> Option<u16> {
|
||||
match self.stream_id {
|
||||
0xE0..=0xEF => Some(0xE0),
|
||||
0xBD => match self.sub_stream_id? {
|
||||
// AC-3 / DTS / LPCM audio → 0xBD00 + audio index.
|
||||
s @ 0x80..=0x87 => Some(0xBD00 + (s - 0x80) as u16),
|
||||
s @ 0x88..=0x8F => Some(0xBD00 + (s - 0x88) as u16),
|
||||
s @ 0xA0..=0xA7 => Some(0xBD00 + (s - 0xA0) as u16),
|
||||
// VobSub subtitle sub-id 0x20+j → PID 0x20+j (identity).
|
||||
s @ 0x20..=0x3F => Some(s as u16),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MPEG-2 Program Stream demuxer.
|
||||
///
|
||||
/// Accepts raw PS bytes via `feed()` and produces demuxed PES packets.
|
||||
@@ -549,6 +582,68 @@ mod tests {
|
||||
assert_eq!(decoded, val);
|
||||
}
|
||||
|
||||
// --- DVD PID mapping (track-routing collision regression) ---
|
||||
|
||||
fn mk(stream_id: u8, sub: Option<u8>) -> PsPacket {
|
||||
PsPacket {
|
||||
stream_id,
|
||||
sub_stream_id: sub,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: vec![0xAA],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_pid_matches_scanner_assignment() {
|
||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||
assert_eq!(mk(0xE0, None).dvd_pid(), Some(0xE0));
|
||||
// AC-3 audio stream 0/1 → 0xBD00 / 0xBD01 (matches 0xBD00 + i).
|
||||
assert_eq!(mk(0xBD, Some(0x80)).dvd_pid(), Some(0xBD00));
|
||||
assert_eq!(mk(0xBD, Some(0x81)).dvd_pid(), Some(0xBD01));
|
||||
// DTS / LPCM audio indices.
|
||||
assert_eq!(mk(0xBD, Some(0x88)).dvd_pid(), Some(0xBD00));
|
||||
assert_eq!(mk(0xBD, Some(0xA0)).dvd_pid(), Some(0xBD00));
|
||||
// VobSub subtitle 0x20/0x21 → 0x20 / 0x21 (matches 0x20 + j).
|
||||
assert_eq!(mk(0xBD, Some(0x20)).dvd_pid(), Some(0x20));
|
||||
assert_eq!(mk(0xBD, Some(0x21)).dvd_pid(), Some(0x21));
|
||||
// Unmappable: MPEG audio, private stream 2, bogus sub-id.
|
||||
assert_eq!(mk(0xC0, None).dvd_pid(), None);
|
||||
assert_eq!(mk(0xBF, None).dvd_pid(), None);
|
||||
assert_eq!(mk(0xBD, Some(0x10)).dvd_pid(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_does_not_collide_with_audio_track() {
|
||||
// Regression for the (sub_id & 0x1F)+1 bug: subtitle sub-id 0x20
|
||||
// used to alias audio track 1. With the real PID it routes to its
|
||||
// own subtitle PID (0x20), distinct from audio (0xBD00+).
|
||||
let audio0 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); // 0xBD00
|
||||
let sub0 = mk(0xBD, Some(0x20)).dvd_pid().unwrap(); // 0x20
|
||||
assert_ne!(
|
||||
audio0, sub0,
|
||||
"subtitle sub-id 0x20 must NOT map to the audio PID"
|
||||
);
|
||||
|
||||
// Mirror dvd.rs PID assignment for a title with [video, audio0,
|
||||
// audio1, sub0, sub1] and confirm each PS packet lands on its
|
||||
// own track via pid_to_track.
|
||||
let pid_to_track: Vec<(u16, usize)> =
|
||||
vec![(0xE0, 0), (0xBD00, 1), (0xBD01, 2), (0x20, 3), (0x21, 4)];
|
||||
let route = |p: PsPacket| -> Option<usize> {
|
||||
let pid = p.dvd_pid()?;
|
||||
pid_to_track
|
||||
.iter()
|
||||
.find(|(x, _)| *x == pid)
|
||||
.map(|(_, t)| *t)
|
||||
};
|
||||
assert_eq!(route(mk(0xE0, None)), Some(0));
|
||||
assert_eq!(route(mk(0xBD, Some(0x80))), Some(1));
|
||||
assert_eq!(route(mk(0xBD, Some(0x81))), Some(2));
|
||||
assert_eq!(route(mk(0xBD, Some(0x20))), Some(3)); // sub0 → track 3, NOT 1
|
||||
assert_eq!(route(mk(0xBD, Some(0x21))), Some(4)); // sub1 → track 4, NOT 2
|
||||
}
|
||||
|
||||
// --- Helper: encode PTS for tests ---
|
||||
|
||||
fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] {
|
||||
|
||||
+146
-86
@@ -466,50 +466,36 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
let es_pid = (((data[pos + 1] & 0x1F) as u16) << 8) | data[pos + 2] as u16;
|
||||
let es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize;
|
||||
|
||||
let stream = match stream_type {
|
||||
0x1B => Some(Stream::Video(VideoStream {
|
||||
// Single source of truth for stream_type → Codec: reuse
|
||||
// `Codec::from_coding_type` (the same table the BD STN /
|
||||
// disc scanner uses) so the two mappings can never drift.
|
||||
// We only retain the category (video/audio/subtitle) and
|
||||
// per-kind default attribute logic here.
|
||||
let codec = Codec::from_coding_type(stream_type);
|
||||
let stream = match codec.kind() {
|
||||
CodecKind::Video => {
|
||||
// Default resolution by codec generation (HEVC →
|
||||
// UHD, MPEG-2 → 1080i, else 1080p); refined later
|
||||
// from the actual elementary stream.
|
||||
let resolution = match codec {
|
||||
Codec::Hevc => Resolution::R2160p,
|
||||
Codec::Mpeg2 => Resolution::R1080i,
|
||||
_ => Resolution::R1080p,
|
||||
};
|
||||
Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec,
|
||||
resolution,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}))
|
||||
}
|
||||
CodecKind::Audio => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::H264,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x24 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0xEA => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Vc1,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x02 => Some(Stream::Video(VideoStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R1080i,
|
||||
frame_rate: FrameRate::Unknown,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x81 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3,
|
||||
codec,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
@@ -517,55 +503,23 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x83 => Some(Stream::Audio(AudioStream {
|
||||
CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Ac3Plus,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::DtsHdMa,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x82 => Some(Stream::Audio(AudioStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Dts,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x90 => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: es_pid,
|
||||
codec: Codec::Pgs,
|
||||
codec,
|
||||
language: "und".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
CodecKind::Unknown => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping PMT stream entry with unknown stream_type {:#04x} (PID {:#06x})",
|
||||
stream_type,
|
||||
es_pid,
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(s) = stream {
|
||||
@@ -613,4 +567,110 @@ mod tests {
|
||||
let result = demux.feed(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// ── scan_streams PMT parsing ──────────────────────────────────────────
|
||||
|
||||
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
||||
/// (4-byte timecode prefix the scanner skips).
|
||||
fn bdts_packet(body: [u8; 184], pid: u16, pusi: bool) -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
|
||||
// 4-byte timecode prefix is ignored; leave zero.
|
||||
pkt[4] = SYNC_BYTE;
|
||||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
||||
if pusi {
|
||||
pkt[5] |= 0x40;
|
||||
}
|
||||
pkt[6] = (pid & 0xFF) as u8;
|
||||
pkt[7] = 0x10; // payload only, no adaptation field
|
||||
pkt[8..8 + 184].copy_from_slice(&body);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Build a PAT TS packet pointing program 1 at `pmt_pid`.
|
||||
fn pat_packet(pmt_pid: u16) -> Vec<u8> {
|
||||
let mut body = [0xFFu8; 184];
|
||||
let mut i = 0;
|
||||
body[i] = 0x00; // pointer_field
|
||||
i += 1;
|
||||
body[i] = 0x00; // table_id = PAT
|
||||
// section_length counts bytes after the length field: tsid(2) +
|
||||
// version/current_next(1) + section_number(1) + last_section(1) +
|
||||
// one 4-byte program entry + 4-byte CRC = 13.
|
||||
body[i + 1] = 0xB0; // section_syntax + reserved + len high nibble
|
||||
body[i + 2] = 0x0D; // section_length low byte = 13
|
||||
body[i + 3] = 0x00; // tsid hi
|
||||
body[i + 4] = 0x01; // tsid lo
|
||||
body[i + 5] = 0xC1; // version/current_next
|
||||
body[i + 6] = 0x00; // section_number
|
||||
body[i + 7] = 0x00; // last_section_number
|
||||
// program entry: program_number=1 → pmt_pid
|
||||
body[i + 8] = 0x00;
|
||||
body[i + 9] = 0x01;
|
||||
body[i + 10] = 0xE0 | (((pmt_pid >> 8) as u8) & 0x1F);
|
||||
body[i + 11] = (pmt_pid & 0xFF) as u8;
|
||||
// (CRC bytes left as 0xFF — scanner doesn't validate CRC)
|
||||
let _ = &mut i;
|
||||
bdts_packet(body, 0, true)
|
||||
}
|
||||
|
||||
/// Build a PMT TS packet listing the given `(stream_type, es_pid)` entries.
|
||||
fn pmt_packet(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> {
|
||||
let mut body = [0xFFu8; 184];
|
||||
body[0] = 0x00; // pointer_field
|
||||
let s = 1; // table start
|
||||
body[s] = 0x02; // table_id = PMT
|
||||
// Fixed PMT fields after section_length: 2(prog) +1 +2 +2(pcr)
|
||||
// +2(prog_info_len=0) = 9, then per-entry 5 bytes, then 4 CRC.
|
||||
let entries_len = entries.len() * 5;
|
||||
let section_length = 9 + entries_len + 4;
|
||||
body[s + 1] = 0xB0 | (((section_length >> 8) as u8) & 0x0F);
|
||||
body[s + 2] = (section_length & 0xFF) as u8;
|
||||
body[s + 3] = 0x00; // program_number hi
|
||||
body[s + 4] = 0x01; // program_number lo
|
||||
body[s + 5] = 0xC1; // version/current_next
|
||||
body[s + 6] = 0x00; // section_number
|
||||
body[s + 7] = 0x00; // last_section_number
|
||||
body[s + 8] = 0xE0; // PCR PID hi (reserved bits)
|
||||
body[s + 9] = 0x00; // PCR PID lo
|
||||
body[s + 10] = 0xF0; // program_info_length hi (=0)
|
||||
body[s + 11] = 0x00; // program_info_length lo
|
||||
let mut p = s + 12;
|
||||
for &(stype, es_pid) in entries {
|
||||
body[p] = stype;
|
||||
body[p + 1] = 0xE0 | (((es_pid >> 8) as u8) & 0x1F);
|
||||
body[p + 2] = (es_pid & 0xFF) as u8;
|
||||
body[p + 3] = 0xF0; // ES_info_length hi (=0)
|
||||
body[p + 4] = 0x00; // ES_info_length lo
|
||||
p += 5;
|
||||
}
|
||||
bdts_packet(body, pmt_pid, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_streams_maps_lpcm_via_from_coding_type() {
|
||||
use crate::disc::{Codec, Stream};
|
||||
let pmt_pid = 0x0100;
|
||||
let mut data = pat_packet(pmt_pid);
|
||||
// 0x80 = LPCM (present in from_coding_type, was MISSING from the
|
||||
// old duplicate table in scan_streams). 0x1B = H.264 video.
|
||||
data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011), (0x80, 0x1100)]));
|
||||
|
||||
let streams = scan_streams(&data).expect("PMT should parse");
|
||||
assert_eq!(streams.len(), 2, "video + LPCM audio");
|
||||
|
||||
let lpcm = streams
|
||||
.iter()
|
||||
.find(|s| matches!(s, Stream::Audio(a) if a.pid == 0x1100))
|
||||
.expect("LPCM audio stream present");
|
||||
if let Stream::Audio(a) = lpcm {
|
||||
assert_eq!(a.codec, Codec::Lpcm, "0x80 must map to LPCM");
|
||||
}
|
||||
|
||||
assert!(
|
||||
streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
|
||||
"H.264 video present"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user