v1.0.0-rc.1
CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
This commit is contained in:
+664
-65
@@ -24,9 +24,17 @@ pub struct H264Parser {
|
||||
// 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.)
|
||||
// (Same defect class as the HEVC PPS-redefinition bug — fixed identically.)
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
// Currently-ACTIVE body of each type (most recent the bitstream defined),
|
||||
// distinct from the fixed `sps`/`pps` codecPrivate copy. See the HEVC
|
||||
// parser for the full rationale: the strip/emit decision must be made
|
||||
// against the active set, and the active set must be re-asserted in-band at
|
||||
// every keyframe that doesn't carry it, or a streaming decoder reverts to
|
||||
// the stale avcC copy after a mid-title redefinition.
|
||||
cur_sps: Option<Vec<u8>>,
|
||||
cur_pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for H264Parser {
|
||||
@@ -41,37 +49,80 @@ impl H264Parser {
|
||||
Self {
|
||||
sps: None,
|
||||
pps: None,
|
||||
cur_sps: None,
|
||||
cur_pps: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// A NAL longer than u32::MAX cannot be length-prefixed in the
|
||||
// 4-byte field; skip it rather than emit a truncated length over
|
||||
// the full body (mis-framed NALU). Unreachable in practice — no
|
||||
// real access unit is >4 GiB.
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
/// Append `nal` to `out` as a 4-byte big-endian length prefix + body. A NAL
|
||||
/// longer than `u32::MAX` can't be length-prefixed in the 4-byte field, so it
|
||||
/// is skipped rather than mis-framed. Unreachable in practice (no AU > 4 GiB).
|
||||
fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) {
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
/// Handle an SPS/PPS NAL (mirrors the HEVC fix). The strip/emit decision is
|
||||
/// made against the currently-ACTIVE set `cur`, NOT the codecPrivate copy
|
||||
/// `first`: a streaming MKV decoder applies avcC once at init and thereafter
|
||||
/// updates a parameter set only from an in-band NAL, so a switch BACK to the
|
||||
/// first-seen body (== codecPrivate) is still a change the decoder must be told
|
||||
/// about. Stripping on `== first` silently dropped that revert.
|
||||
///
|
||||
/// - First of its type → seeds codecPrivate; stripped (decoder gets it from avcC).
|
||||
/// - Equal to the active set `cur` → redundant; stripped.
|
||||
/// - Different from `cur` (a change in EITHER direction) → emitted in-band and
|
||||
/// `cur` updated.
|
||||
///
|
||||
/// Returns `true` when the NAL was emitted in-band into `frame_data`.
|
||||
fn handle_param_set(
|
||||
first: &mut Option<Vec<u8>>,
|
||||
cur: &mut Option<Vec<u8>>,
|
||||
nal: &[u8],
|
||||
frame_data: &mut Vec<u8>,
|
||||
) -> bool {
|
||||
let is_first = first.is_none();
|
||||
if is_first {
|
||||
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
|
||||
}
|
||||
let changed = cur.as_deref() != Some(nal);
|
||||
if changed {
|
||||
*cur = Some(nal.to_vec());
|
||||
}
|
||||
if is_first || !changed {
|
||||
return false;
|
||||
}
|
||||
push_length_prefixed(frame_data, nal);
|
||||
true
|
||||
}
|
||||
|
||||
/// Append the active parameter set `cur` to `prefix` (length-prefixed) so every
|
||||
/// keyframe is SELF-CONTAINED: it carries the active SPS/PPS in-band ahead of
|
||||
/// its slices. Skipped only when this access unit ALREADY carried the NAL in-band
|
||||
/// (`emitted` — avoids a duplicate) or no active set exists yet.
|
||||
///
|
||||
/// Unconditional (not only when the active set differs from codecPrivate): a
|
||||
/// streaming decoder applies the avcC param sets once at init, then relies on
|
||||
/// in-band repetition. Some sources stop repeating a param set at later IDRs even
|
||||
/// though its body is unchanged; if the decoder then drops it (a reset event),
|
||||
/// nothing re-sends it and every subsequent slice fails (param-set id out of
|
||||
/// range) until the next genuine change. Re-asserting at EVERY keyframe — what
|
||||
/// compliant muxers do at every IDR — makes streaming decode self-healing.
|
||||
/// Re-sending an identical param set is benign; cost is a few bytes per keyframe.
|
||||
/// This strictly supersets the change-only re-assert, so the param-set-revert
|
||||
/// fix is unaffected.
|
||||
fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) {
|
||||
if emitted {
|
||||
return;
|
||||
}
|
||||
let Some(active) = cur.as_deref() else {
|
||||
return;
|
||||
};
|
||||
push_length_prefixed(prefix, active);
|
||||
}
|
||||
|
||||
impl CodecParser for H264Parser {
|
||||
@@ -90,6 +141,9 @@ impl CodecParser for H264Parser {
|
||||
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
|
||||
// AVCDecoderConfigurationRecord expects a 4-byte length prefix per NAL).
|
||||
let mut keyframe = false;
|
||||
// Did this access unit already carry each param-set type in-band?
|
||||
let mut emitted_sps = false;
|
||||
let mut emitted_pps = false;
|
||||
// Pre-size: output is ~input bytes plus a few 4-byte NAL length prefixes.
|
||||
// The unsized Vec growth chain otherwise reallocs several times per
|
||||
// frame in the mux hot path (mirrors the HEVC parser).
|
||||
@@ -99,10 +153,16 @@ impl CodecParser for H264Parser {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
// 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),
|
||||
// Param sets: seed avcC, strip if unchanged vs the active set,
|
||||
// emit in-band on any change (incl. reverting to the avcC copy).
|
||||
NAL_SPS => {
|
||||
emitted_sps |=
|
||||
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
||||
}
|
||||
NAL_PPS => {
|
||||
emitted_pps |=
|
||||
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
||||
}
|
||||
// Access unit delimiters: drop. Intentional and spec-correct —
|
||||
// Matroska H.264 frame data omits AUDs (the container delimits
|
||||
// access units), so keeping them in-band is redundant. Mirrors
|
||||
@@ -128,6 +188,20 @@ impl CodecParser for H264Parser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Every keyframe is self-contained: re-assert the active SPS/PPS in-band
|
||||
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
|
||||
// that dropped the set at a reset recovers, and a stale avcC re-apply
|
||||
// can't revert it. Skipped per-type only when this AU already carried it.
|
||||
if keyframe {
|
||||
let mut prefix = Vec::new();
|
||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||
if !prefix.is_empty() {
|
||||
prefix.extend_from_slice(&frame_data);
|
||||
frame_data = prefix;
|
||||
}
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
@@ -165,6 +239,7 @@ impl CodecParser for H264Parser {
|
||||
// numOfPictureParameterSets = 1
|
||||
// pictureParameterSetLength = pps.len()
|
||||
// pictureParameterSetNALUnit = pps
|
||||
// [High Profile extension per ISO 14496-15 §5.3.3.1.2, when applicable]
|
||||
|
||||
let mut record = vec![
|
||||
1, // configurationVersion
|
||||
@@ -182,10 +257,175 @@ impl CodecParser for H264Parser {
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
// ISO 14496-15 §5.3.3.1.2: for High-Profile and related profiles
|
||||
// (profile_idc 100, 110, 122, 144) the record has 4 trailing extension
|
||||
// bytes carrying chroma_format_idc and bit depths. Older parsers expect
|
||||
// the record to END after the PPS for Baseline/Main/Extended — do NOT
|
||||
// append for those (strict parsers reject the extra bytes).
|
||||
let profile_idc = sps[1];
|
||||
const HIGH_PROFILES: [u8; 4] = [100, 110, 122, 144];
|
||||
if HIGH_PROFILES.contains(&profile_idc) {
|
||||
if let Some((chroma_fmt, depth_luma, depth_chroma)) = parse_sps_high_profile_ext(sps) {
|
||||
// byte 0: 111111xx — reserved(6) + chroma_format_idc(2)
|
||||
record.push(0xFC | (chroma_fmt & 0x03));
|
||||
// byte 1: 11111xxx — reserved(5) + bit_depth_luma_minus8(3)
|
||||
record.push(0xF8 | (depth_luma & 0x07));
|
||||
// byte 2: 11111xxx — reserved(5) + bit_depth_chroma_minus8(3)
|
||||
record.push(0xF8 | (depth_chroma & 0x07));
|
||||
// byte 3: num_of_sequence_parameter_set_ext (0 = none)
|
||||
record.push(0x00);
|
||||
}
|
||||
}
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `(chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8)` from
|
||||
/// a High-Profile SPS NAL (profile_idc ∈ {100, 110, 122, 144}).
|
||||
///
|
||||
/// SPS RBSP layout (ITU-T H.264 §7.3.2.1.1) up to the fields we need:
|
||||
/// byte 0 NAL header (already known to be type 7)
|
||||
/// byte 1 profile_idc
|
||||
/// byte 2 constraint_set_flags / reserved
|
||||
/// byte 3 level_idc
|
||||
/// ue(v) seq_parameter_set_id
|
||||
/// — High-profile branch —
|
||||
/// ue(v) chroma_format_idc
|
||||
/// if chroma_format_idc == 3: u(1) separate_colour_plane_flag
|
||||
/// ue(v) bit_depth_luma_minus8
|
||||
/// ue(v) bit_depth_chroma_minus8
|
||||
///
|
||||
/// RBSP emulation-prevention bytes (0x00 0x00 0x03 → 0x00 0x00) are removed
|
||||
/// before bit-parsing so the bit reader sees clean RBSP data.
|
||||
///
|
||||
/// Returns `None` if the SPS is too short or malformed (Exp-Golomb code
|
||||
/// overflows 32 bits, leading-zero count > 31, etc.). The caller silently
|
||||
/// omits the extension in that case.
|
||||
fn parse_sps_high_profile_ext(sps: &[u8]) -> Option<(u8, u8, u8)> {
|
||||
// Strip emulation-prevention bytes: 00 00 03 xx → 00 00 xx (drop the 03).
|
||||
// We skip byte 0 (NAL header) and start the RBSP from byte 1.
|
||||
let rbsp: Vec<u8> = {
|
||||
let raw = &sps[1..]; // skip NAL header byte
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
if i + 2 < raw.len() && raw[i] == 0x00 && raw[i + 1] == 0x00 && raw[i + 2] == 0x03 {
|
||||
out.push(0x00);
|
||||
out.push(0x00);
|
||||
i += 3; // skip the 0x03 emulation-prevention byte
|
||||
} else {
|
||||
out.push(raw[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
// RBSP layout after stripping the NAL header byte:
|
||||
// [0] profile_idc (already checked by caller)
|
||||
// [1] constraint flags
|
||||
// [2] level_idc
|
||||
// [3..] seq_parameter_set_id ue(v), then High-Profile fields
|
||||
if rbsp.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Bit reader over rbsp[3..] (skip profile/flags/level, already known).
|
||||
let mut reader = SpsReader::new(&rbsp[3..]);
|
||||
|
||||
// seq_parameter_set_id — skip
|
||||
reader.read_ue()?;
|
||||
|
||||
// chroma_format_idc
|
||||
let chroma_format_idc = reader.read_ue()?;
|
||||
|
||||
// separate_colour_plane_flag (only when chroma_format_idc == 3)
|
||||
if chroma_format_idc == 3 {
|
||||
reader.read_bits(1)?; // skip separate_colour_plane_flag
|
||||
}
|
||||
|
||||
// bit_depth_luma_minus8
|
||||
let bit_depth_luma_minus8 = reader.read_ue()?;
|
||||
// bit_depth_chroma_minus8
|
||||
let bit_depth_chroma_minus8 = reader.read_ue()?;
|
||||
|
||||
// Clamp to the 2- and 3-bit fields in the avcC extension bytes.
|
||||
// Valid H.264 values are 0..=6; the spec guarantees ≤ 6, so no real
|
||||
// content should be truncated. Out-of-spec values are clamped rather
|
||||
// than rejected so a corrupt-but-decodable SPS still produces a
|
||||
// reasonable avcC.
|
||||
Some((
|
||||
(chroma_format_idc & 0x03) as u8,
|
||||
(bit_depth_luma_minus8 & 0x07) as u8,
|
||||
(bit_depth_chroma_minus8 & 0x07) as u8,
|
||||
))
|
||||
}
|
||||
|
||||
/// Minimal Exp-Golomb / fixed-width bit reader over a byte slice, for SPS parsing.
|
||||
struct SpsReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Current byte index.
|
||||
byte: usize,
|
||||
/// Number of bits remaining in data[byte] (0 means fully consumed, advance).
|
||||
bits_left: u8,
|
||||
}
|
||||
|
||||
impl<'a> SpsReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
byte: 0,
|
||||
bits_left: if data.is_empty() { 0 } else { 8 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one bit. Returns `None` when the slice is exhausted.
|
||||
fn read_bit(&mut self) -> Option<u8> {
|
||||
if self.bits_left == 0 {
|
||||
self.byte += 1;
|
||||
if self.byte >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
self.bits_left = 8;
|
||||
}
|
||||
self.bits_left -= 1;
|
||||
Some((self.data[self.byte] >> self.bits_left) & 1)
|
||||
}
|
||||
|
||||
/// Read `n` bits (n ≤ 32) as a u32, MSB first. Returns `None` on
|
||||
/// end-of-data.
|
||||
fn read_bits(&mut self, n: u8) -> Option<u32> {
|
||||
let mut val = 0u32;
|
||||
for _ in 0..n {
|
||||
val = (val << 1) | (self.read_bit()? as u32);
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
/// Read one Exp-Golomb coded unsigned integer ue(v). Leading-zero count
|
||||
/// must not exceed 31 (a 63-bit code would overflow u32). Returns `None`
|
||||
/// on end-of-data or overflow.
|
||||
fn read_ue(&mut self) -> Option<u32> {
|
||||
let mut leading_zeros = 0u8;
|
||||
loop {
|
||||
let bit = self.read_bit()?;
|
||||
if bit == 1 {
|
||||
break;
|
||||
}
|
||||
leading_zeros += 1;
|
||||
if leading_zeros > 31 {
|
||||
return None; // malformed / non-conforming SPS
|
||||
}
|
||||
}
|
||||
if leading_zeros == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
let suffix = self.read_bits(leading_zeros)?;
|
||||
Some((1u32 << leading_zeros) - 1 + suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over NAL units in Annex B byte stream.
|
||||
/// Finds start codes (00 00 01 or 00 00 00 01) and yields the data between them.
|
||||
struct NalIterator<'a> {
|
||||
@@ -301,6 +541,112 @@ mod tests {
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
// Length-prefixed NAL bodies out of frame_data, and the H.264 PPS (type 8)
|
||||
// payloads among them.
|
||||
fn h264_nals_in(frame: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 4 <= frame.len() {
|
||||
let len =
|
||||
u32::from_be_bytes([frame[i], frame[i + 1], frame[i + 2], frame[i + 3]]) as usize;
|
||||
i += 4;
|
||||
if i + len > frame.len() {
|
||||
break;
|
||||
}
|
||||
out.push(frame[i..i + len].to_vec());
|
||||
i += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
fn h264_pps_bodies(nals: &[Vec<u8>]) -> Vec<Vec<u8>> {
|
||||
nals.iter()
|
||||
.filter(|n| !n.is_empty() && n[0] & 0x1F == 8)
|
||||
.map(|n| n[1..].to_vec())
|
||||
.collect()
|
||||
}
|
||||
fn h264_nal(t: u8, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01, t];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Regression (Fight Club bug, H.264 variant): PPS id 0 = body A (→ avcC),
|
||||
/// redefined to B, then switched BACK to A. A streaming decoder is on B; the
|
||||
/// revert to A == avcC must still be emitted in-band or the A-segment
|
||||
/// decodes against B.
|
||||
#[test]
|
||||
fn h264_emits_switch_back_to_codecprivate_pps() {
|
||||
let a = [0xA1u8, 0xA2];
|
||||
let b = [0xB1u8, 0xB2, 0xB3];
|
||||
let mut p = H264Parser::new();
|
||||
// AU1: SPS (seed avcC) + PPS-A (seed) + IDR.
|
||||
p.parse(&make_pes(
|
||||
[
|
||||
h264_nal(0x67, &[0x42, 0x00, 0x1E, 0xAB]),
|
||||
h264_nal(0x68, &a),
|
||||
h264_nal(0x65, &[1]),
|
||||
]
|
||||
.concat(),
|
||||
Some(0),
|
||||
));
|
||||
// AU2 IDR: redefine PPS to B → emitted in-band.
|
||||
let f2 = p.parse(&make_pes(
|
||||
[h264_nal(0x68, &b), h264_nal(0x65, &[2])].concat(),
|
||||
Some(1),
|
||||
));
|
||||
assert!(
|
||||
h264_pps_bodies(&h264_nals_in(&f2[0].data))
|
||||
.iter()
|
||||
.any(|x| x == &b),
|
||||
"AU2 must carry redefined PPS-B in-band"
|
||||
);
|
||||
// AU3 IDR: back to A (== avcC) — must be emitted in-band (active was B).
|
||||
let f3 = p.parse(&make_pes(
|
||||
[h264_nal(0x68, &a), h264_nal(0x65, &[3])].concat(),
|
||||
Some(2),
|
||||
));
|
||||
assert!(
|
||||
h264_pps_bodies(&h264_nals_in(&f3[0].data))
|
||||
.iter()
|
||||
.any(|x| x == &a),
|
||||
"switch back to avcC PPS-A must be emitted in-band"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a bare IDR keyframe (source omits the PPS) after a mid-title
|
||||
/// redefinition must re-assert the active PPS in-band.
|
||||
#[test]
|
||||
fn h264_reasserts_active_pps_at_bare_keyframe() {
|
||||
let a = [0xA1u8, 0xA2];
|
||||
let b = [0xB1u8, 0xB2, 0xB3];
|
||||
let mut p = H264Parser::new();
|
||||
p.parse(&make_pes(
|
||||
[
|
||||
h264_nal(0x67, &[0x42, 0x00, 0x1E, 0xAB]),
|
||||
h264_nal(0x68, &a),
|
||||
h264_nal(0x65, &[1]),
|
||||
]
|
||||
.concat(),
|
||||
Some(0),
|
||||
));
|
||||
// Redefine to B at a keyframe.
|
||||
p.parse(&make_pes(
|
||||
[h264_nal(0x68, &b), h264_nal(0x65, &[2])].concat(),
|
||||
Some(1),
|
||||
));
|
||||
// Bare IDR (no PPS): active B must be re-asserted; stale A must not be.
|
||||
let f3 = p.parse(&make_pes(h264_nal(0x65, &[3]), Some(2)));
|
||||
let got = h264_pps_bodies(&h264_nals_in(&f3[0].data));
|
||||
assert!(
|
||||
got.iter().any(|x| x == &b),
|
||||
"bare keyframe must re-assert active PPS-B"
|
||||
);
|
||||
assert!(
|
||||
!got.iter().any(|x| x == &a),
|
||||
"must not re-assert stale avcC PPS-A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_sps_pps() {
|
||||
let parser = H264Parser::new();
|
||||
@@ -390,14 +736,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SPS/PPS/AUD are stripped from frame data ---
|
||||
// --- AUD is stripped; SPS/PPS seed avcC and re-assert at the keyframe ---
|
||||
|
||||
#[test]
|
||||
fn sps_pps_aud_stripped_from_frame_data() {
|
||||
fn aud_stripped_param_sets_reasserted_at_keyframe() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
// AUD (type 9)
|
||||
// AUD (type 9) — always dropped
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x09);
|
||||
data.push(0xF0);
|
||||
@@ -409,7 +755,7 @@ mod tests {
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68);
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
// IDR (type 5) - only this should appear in frame data
|
||||
// IDR (type 5)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x65);
|
||||
data.extend_from_slice(&[0x88, 0x00]);
|
||||
@@ -418,12 +764,25 @@ mod tests {
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only contain the IDR NAL (length-prefixed)
|
||||
// SPS/PPS seed avcC...
|
||||
assert!(parser.codec_private().is_some(), "SPS/PPS seed avcC");
|
||||
// ...and because this is a keyframe, the active SPS/PPS are re-asserted
|
||||
// in-band ahead of the IDR so the keyframe is self-contained. AUD (9) is
|
||||
// always dropped. Frame data = SPS(7), PPS(8), IDR(5).
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix)
|
||||
assert_eq!(length, 2);
|
||||
assert_eq!(fd[4], 0x65); // IDR NAL type byte
|
||||
let mut types = Vec::new();
|
||||
let mut o = 0;
|
||||
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;
|
||||
types.push(fd[o] & 0x1F);
|
||||
o += len;
|
||||
}
|
||||
assert_eq!(
|
||||
types,
|
||||
vec![7, 8, 5],
|
||||
"keyframe: SPS+PPS re-asserted ahead of IDR, AUD dropped"
|
||||
);
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
@@ -496,11 +855,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_param_sets_stripped_redefinition_emitted_inline() {
|
||||
fn keyframes_self_contained_and_redefinition_emitted() {
|
||||
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.
|
||||
// AU 1: SPS(id0,bodyA) + PPS(id0,bodyA) + IDR. The param sets seed avcC,
|
||||
// and because this is a keyframe the active SPS/PPS are re-asserted
|
||||
// in-band ahead of the IDR (self-contained keyframe). Frame = SPS,PPS,IDR.
|
||||
let mut au1 = Vec::new();
|
||||
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au1.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS body A
|
||||
@@ -510,16 +870,15 @@ mod tests {
|
||||
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"
|
||||
vec![7, 8, 5],
|
||||
"AU1 keyframe: SPS+PPS re-asserted ahead of IDR"
|
||||
);
|
||||
|
||||
// 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.
|
||||
// AU 2: SPS identical to avcC (re-asserted unchanged at the keyframe),
|
||||
// PPS REDEFINED (same id, different body) → emitted in-band as a change.
|
||||
// Frame = SPS(re-asserted), PPS(redefined), IDR.
|
||||
let mut au2 = Vec::new();
|
||||
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au2.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS == body A
|
||||
@@ -530,19 +889,32 @@ mod tests {
|
||||
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_eq!(types, vec![7, 8, 5], "got {types:?}");
|
||||
// Confirm the in-band PPS is the REDEFINED body B (0x22), not avcC's A.
|
||||
let mut o = 0;
|
||||
let mut pps_body = None;
|
||||
while o + 4 <= f2[0].data.len() {
|
||||
let len = u32::from_be_bytes([
|
||||
f2[0].data[o],
|
||||
f2[0].data[o + 1],
|
||||
f2[0].data[o + 2],
|
||||
f2[0].data[o + 3],
|
||||
]) as usize;
|
||||
o += 4;
|
||||
if f2[0].data[o] & 0x1F == 8 {
|
||||
pps_body = Some(f2[0].data[o + 1]);
|
||||
}
|
||||
o += len;
|
||||
}
|
||||
assert_eq!(
|
||||
pps_body,
|
||||
Some(0x22),
|
||||
"in-band PPS must be the redefined body B"
|
||||
);
|
||||
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() {
|
||||
fn repeated_identical_param_sets_reasserted_each_keyframe() {
|
||||
let mut parser = H264Parser::new();
|
||||
let mut au = Vec::new();
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
@@ -551,13 +923,16 @@ mod tests {
|
||||
au.extend_from_slice(&[0x68, 0x11]);
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au.extend_from_slice(&[0x65, 0x10]);
|
||||
// Two identical AUs.
|
||||
// Two identical AUs. Each is a keyframe, so each re-asserts the active
|
||||
// SPS/PPS in-band (self-contained keyframe) even though the bodies are
|
||||
// unchanged — a decoder that dropped them at a reset recovers at every
|
||||
// IDR. Frame = SPS, PPS, IDR.
|
||||
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"
|
||||
vec![7, 8, 5],
|
||||
"each keyframe re-asserts the active SPS/PPS in-band"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -592,12 +967,16 @@ mod tests {
|
||||
fn avcc_exact_length_fields_and_payload() {
|
||||
// The AVCDecoderConfigurationRecord must encode SPS length and PPS length
|
||||
// as 16-bit big-endian fields, followed by the verbatim NAL bodies.
|
||||
// Uses a Main-Profile SPS (profile_idc=0x4D=77) so no High-Profile
|
||||
// extension bytes are appended — the test validates the fixed-header
|
||||
// layout only. High-Profile extension is covered by
|
||||
// avcc_high_profile_appends_extension_bytes.
|
||||
// SPS = 0x67,profile,compat,level + 2 payload bytes (6 bytes total).
|
||||
// PPS = 0x68 + 2 payload bytes (3 bytes total).
|
||||
let mut parser = H264Parser::new();
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&[0x67, 0x64, 0x00, 0x28, 0xAB, 0xCD]); // SPS, 6 bytes
|
||||
data.extend_from_slice(&[0x67, 0x4D, 0x00, 0x28, 0xAB, 0xCD]); // SPS, 6 bytes, Main Profile (77)
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&[0x68, 0xEE, 0x3C]); // PPS, 3 bytes
|
||||
// A slice so a frame is produced (not required for codec_private though).
|
||||
@@ -607,7 +986,7 @@ mod tests {
|
||||
let cp = parser.codec_private().expect("avcC");
|
||||
// Fixed header.
|
||||
assert_eq!(cp[0], 1, "configurationVersion");
|
||||
assert_eq!(cp[1], 0x64, "AVCProfileIndication = SPS[1]");
|
||||
assert_eq!(cp[1], 0x4D, "AVCProfileIndication = SPS[1]");
|
||||
assert_eq!(cp[2], 0x00, "profile_compatibility = SPS[2]");
|
||||
assert_eq!(cp[3], 0x28, "AVCLevelIndication = SPS[3]");
|
||||
assert_eq!(cp[4], 0xFF, "lengthSizeMinusOne nibble (4-byte prefix)");
|
||||
@@ -615,14 +994,14 @@ mod tests {
|
||||
// sequenceParameterSetLength (16-bit BE) = 6.
|
||||
assert_eq!(u16::from_be_bytes([cp[6], cp[7]]), 6, "SPS length field");
|
||||
// SPS body follows verbatim.
|
||||
assert_eq!(&cp[8..14], &[0x67, 0x64, 0x00, 0x28, 0xAB, 0xCD]);
|
||||
assert_eq!(&cp[8..14], &[0x67, 0x4D, 0x00, 0x28, 0xAB, 0xCD]);
|
||||
// numPPS = 1.
|
||||
assert_eq!(cp[14], 1, "numPPS");
|
||||
// pictureParameterSetLength (16-bit BE) = 3.
|
||||
assert_eq!(u16::from_be_bytes([cp[15], cp[16]]), 3, "PPS length field");
|
||||
// PPS body verbatim.
|
||||
assert_eq!(&cp[17..20], &[0x68, 0xEE, 0x3C]);
|
||||
// Record length is exactly the sum of its parts — no extra/missing bytes.
|
||||
// Record length is exactly the sum of its parts — no extension bytes for Main Profile.
|
||||
assert_eq!(cp.len(), 20);
|
||||
}
|
||||
|
||||
@@ -806,4 +1185,224 @@ mod tests {
|
||||
"oversized SPS must not produce a truncated avcC"
|
||||
);
|
||||
}
|
||||
|
||||
// --- High Profile avcC extension (ISO 14496-15 §5.3.3.1.2) ---
|
||||
|
||||
/// Build a minimal High-Profile SPS RBSP with the fields needed for the
|
||||
/// avcC extension. The SPS bytes (NAL-header included) are:
|
||||
/// [0x67] NAL header (type=7, ref_idc=3)
|
||||
/// [profile_idc] [constraint_flags] [level_idc]
|
||||
/// ue(v) seq_parameter_set_id = 0 → 1 bit: 0b1
|
||||
/// ue(v) chroma_format_idc → depends on value
|
||||
/// if chroma_format_idc==3: u(1) separate_colour_plane_flag
|
||||
/// ue(v) bit_depth_luma_minus8
|
||||
/// ue(v) bit_depth_chroma_minus8
|
||||
///
|
||||
/// All ue(v) values <= 6 fit within 3 leading zeros + 3 suffix bits (7 bits
|
||||
/// total): prefix = leading_zeros + stop-1 bit, suffix = leading_zeros bits.
|
||||
/// For small values (0..=2), the unary prefix + code is short enough to
|
||||
/// pack manually with a simple bit-packing helper.
|
||||
fn build_high_profile_sps(
|
||||
profile_idc: u8,
|
||||
chroma_format_idc: u32,
|
||||
bit_depth_luma_minus8: u32,
|
||||
bit_depth_chroma_minus8: u32,
|
||||
) -> Vec<u8> {
|
||||
// Bit-pack the ue(v) fields into a byte buffer after the fixed header.
|
||||
// We append bits MSB-first into a growing Vec<u8>.
|
||||
struct BitWriter {
|
||||
buf: Vec<u8>,
|
||||
cur: u8,
|
||||
bits: u8, // bits accumulated in `cur` (0..8)
|
||||
}
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::new(),
|
||||
cur: 0,
|
||||
bits: 0,
|
||||
}
|
||||
}
|
||||
fn push_bit(&mut self, bit: u8) {
|
||||
self.cur = (self.cur << 1) | (bit & 1);
|
||||
self.bits += 1;
|
||||
if self.bits == 8 {
|
||||
self.buf.push(self.cur);
|
||||
self.cur = 0;
|
||||
self.bits = 0;
|
||||
}
|
||||
}
|
||||
fn write_ue(&mut self, val: u32) {
|
||||
// Exp-Golomb encode: find k such that 2^k - 1 <= val, then
|
||||
// k leading zeros + 1 stop + k-bit suffix.
|
||||
if val == 0 {
|
||||
self.push_bit(1);
|
||||
return;
|
||||
}
|
||||
let code = val + 1; // code = val + 1, k = floor(log2(code))
|
||||
let k = 31 - code.leading_zeros();
|
||||
for _ in 0..k {
|
||||
self.push_bit(0);
|
||||
} // k leading zeros
|
||||
self.push_bit(1); // stop bit
|
||||
for i in (0..k).rev() {
|
||||
self.push_bit(((code >> i) & 1) as u8);
|
||||
}
|
||||
}
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
// Flush partial byte (padding with zeros on the right — RBSP
|
||||
// trailing bits pattern, sufficient for our test payload).
|
||||
if self.bits > 0 {
|
||||
self.cur <<= 8 - self.bits;
|
||||
self.buf.push(self.cur);
|
||||
}
|
||||
self.buf
|
||||
}
|
||||
}
|
||||
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(0); // seq_parameter_set_id = 0
|
||||
w.write_ue(chroma_format_idc);
|
||||
if chroma_format_idc == 3 {
|
||||
w.push_bit(0); // separate_colour_plane_flag = 0
|
||||
}
|
||||
w.write_ue(bit_depth_luma_minus8);
|
||||
w.write_ue(bit_depth_chroma_minus8);
|
||||
let payload = w.finish();
|
||||
|
||||
let mut sps = vec![
|
||||
0x67, // NAL header (type=7)
|
||||
profile_idc,
|
||||
0x00, // constraint flags
|
||||
0x28, // level_idc = 4.0
|
||||
];
|
||||
sps.extend_from_slice(&payload);
|
||||
sps
|
||||
}
|
||||
|
||||
fn feed_sps_pps(parser: &mut H264Parser, sps_bytes: &[u8]) {
|
||||
// Feed a PES containing: custom SPS + a minimal PPS + an IDR slice.
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(sps_bytes);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0x68, 0xCE, 0x01]); // PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0x65, 0x88]); // IDR
|
||||
parser.parse(&make_pes(data, Some(0)));
|
||||
}
|
||||
|
||||
/// ISO 14496-15 §5.3.3.1.2 regression: a High-Profile SPS (profile_idc=100)
|
||||
/// must produce an avcC with the 4 extension bytes (chroma_format_idc,
|
||||
/// bit_depth_luma_minus8, bit_depth_chroma_minus8, num_sps_ext=0).
|
||||
#[test]
|
||||
fn avcc_high_profile_appends_extension_bytes() {
|
||||
// profile_idc=100 (High), chroma_format_idc=1 (4:2:0), depths both 0.
|
||||
let sps = build_high_profile_sps(100, 1, 0, 0);
|
||||
let mut parser = H264Parser::new();
|
||||
feed_sps_pps(&mut parser, &sps);
|
||||
|
||||
let cp = parser.codec_private().expect("avcC must be present");
|
||||
|
||||
// Walk to the end of the fixed record to locate the extension bytes.
|
||||
// Fixed header: 6 bytes. SPS length field: 2 bytes. SPS body. numPPS: 1.
|
||||
// PPS length: 2. PPS body (0x68, 0xCE, 0x01 = 3 bytes).
|
||||
// Fixed tail offset = 6 + 2 + sps.len() + 1 + 2 + 3 = sps.len() + 14.
|
||||
let ext_off = sps.len() + 14;
|
||||
assert!(
|
||||
cp.len() == ext_off + 4,
|
||||
"High-Profile avcC must have exactly 4 extension bytes (len={}, expected {})",
|
||||
cp.len(),
|
||||
ext_off + 4
|
||||
);
|
||||
|
||||
// Byte 0: 111111xx — upper 6 bits reserved (0b111111), lower 2 = chroma_format_idc=1.
|
||||
assert_eq!(
|
||||
cp[ext_off] & 0xFC,
|
||||
0xFC,
|
||||
"extension byte 0: reserved bits must be 111111xx"
|
||||
);
|
||||
assert_eq!(cp[ext_off] & 0x03, 1, "chroma_format_idc must be 1 (4:2:0)");
|
||||
// Byte 1: 11111xxx — upper 5 bits reserved, lower 3 = bit_depth_luma_minus8=0.
|
||||
assert_eq!(
|
||||
cp[ext_off + 1] & 0xF8,
|
||||
0xF8,
|
||||
"extension byte 1: reserved bits must be 11111xxx"
|
||||
);
|
||||
assert_eq!(cp[ext_off + 1] & 0x07, 0, "bit_depth_luma_minus8 must be 0");
|
||||
// Byte 2: 11111xxx — upper 5 bits reserved, lower 3 = bit_depth_chroma_minus8=0.
|
||||
assert_eq!(
|
||||
cp[ext_off + 2] & 0xF8,
|
||||
0xF8,
|
||||
"extension byte 2: reserved bits must be 11111xxx"
|
||||
);
|
||||
assert_eq!(
|
||||
cp[ext_off + 2] & 0x07,
|
||||
0,
|
||||
"bit_depth_chroma_minus8 must be 0"
|
||||
);
|
||||
// Byte 3: num_of_sequence_parameter_set_ext = 0.
|
||||
assert_eq!(
|
||||
cp[ext_off + 3],
|
||||
0,
|
||||
"num_of_sequence_parameter_set_ext must be 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO 14496-15 §5.3.3.1.2 regression: a High-Profile SPS with non-zero
|
||||
/// chroma_format_idc and bit depths carries those values correctly in the
|
||||
/// extension bytes.
|
||||
#[test]
|
||||
fn avcc_high_profile_extension_carries_correct_values() {
|
||||
// profile_idc=100, chroma_format_idc=3 (4:4:4), depth_luma=2, depth_chroma=2.
|
||||
let sps = build_high_profile_sps(100, 3, 2, 2);
|
||||
let mut parser = H264Parser::new();
|
||||
feed_sps_pps(&mut parser, &sps);
|
||||
|
||||
let cp = parser.codec_private().expect("avcC");
|
||||
let ext_off = sps.len() + 14;
|
||||
|
||||
assert_eq!(cp[ext_off] & 0x03, 3, "chroma_format_idc must be 3 (4:4:4)");
|
||||
assert_eq!(cp[ext_off + 1] & 0x07, 2, "bit_depth_luma_minus8 must be 2");
|
||||
assert_eq!(
|
||||
cp[ext_off + 2] & 0x07,
|
||||
2,
|
||||
"bit_depth_chroma_minus8 must be 2"
|
||||
);
|
||||
assert_eq!(
|
||||
cp[ext_off + 3],
|
||||
0,
|
||||
"num_of_sequence_parameter_set_ext must be 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO 14496-15 §5.3.3.1.2 regression: a Main-Profile SPS (profile_idc=77)
|
||||
/// must NOT have the extension bytes — strict parsers reject trailing bytes
|
||||
/// for Baseline/Main/Extended profiles.
|
||||
#[test]
|
||||
fn avcc_main_profile_no_extension_bytes() {
|
||||
// profile_idc=77 (Main). No High-Profile branch in the SPS RBSP,
|
||||
// so we build a simpler SPS: NAL header + profile/compat/level + a
|
||||
// ue(v) seq_parameter_set_id=0 + remaining RBSP (can be trivial).
|
||||
let sps = vec![
|
||||
0x67, // NAL header (type=7)
|
||||
77, // profile_idc = Main
|
||||
0x40, // constraint flags
|
||||
0x28, // level_idc
|
||||
// seq_parameter_set_id=0 → ue(v) = 0b1 (1 bit). Pack into a byte:
|
||||
// bit pattern: 1000_0000 (stop bit in MSB, rest don't-care)
|
||||
0x80,
|
||||
];
|
||||
let mut parser = H264Parser::new();
|
||||
feed_sps_pps(&mut parser, &sps);
|
||||
|
||||
let cp = parser.codec_private().expect("avcC must be present");
|
||||
// Fixed record: 6 + 2 + sps.len() + 1 + 2 + 3 = sps.len() + 14.
|
||||
let expected_len = sps.len() + 14;
|
||||
assert_eq!(
|
||||
cp.len(),
|
||||
expected_len,
|
||||
"Main-Profile avcC must NOT have extension bytes (len={}, expected {})",
|
||||
cp.len(),
|
||||
expected_len
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+644
-79
@@ -19,6 +19,13 @@ const _NAL_UNSPEC62_DV_RPU: u8 = 62;
|
||||
// IRAP types (keyframes): BLA, IDR, CRA
|
||||
const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
// CRA_NUT (Clean Random Access). A CRA at a splice carries RASL leading
|
||||
// pictures that reference frames from BEFORE the splice; on linear decode of a
|
||||
// concatenated title those references are gone ("Could not find ref with POC
|
||||
// N"). The HEVC spec remedy is to rewrite the splice CRA as a BLA (Broken Link
|
||||
// Access): a decoder then sets NoRaslOutput and discards the RASL cleanly with
|
||||
// no error. See `mark_clip_boundary` / the IRAP arm in `parse`.
|
||||
const NAL_CRA_NUT: u8 = 21;
|
||||
|
||||
/// HEVC (H.265) Annex B → MKV codec parser: extracts VPS/SPS/PPS for the hvcC
|
||||
/// codecPrivate, detects IRAP keyframes, and converts each PES access unit into
|
||||
@@ -36,6 +43,39 @@ pub struct HevcParser {
|
||||
vps: Option<Vec<u8>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
// The currently-ACTIVE parameter-set body of each type — the most recent
|
||||
// one the bitstream defined, which the decoder must use until the next
|
||||
// redefinition. Distinct from the `vps/sps/pps` codecPrivate copy above
|
||||
// (which is fixed to the FIRST one seen). When a stream redefines a param
|
||||
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
||||
// source STOPS repeating it at later IRAPs and relies on the decoder
|
||||
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
||||
// player re-applies the codecPrivate set at EVERY keyframe (ffmpeg's
|
||||
// hvcC→Annex-B insertion), reverting id 0 to the stale FIRST body. We must
|
||||
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
||||
// differs from the codecPrivate copy and the access unit didn't already
|
||||
// carry it. See `parse`.
|
||||
cur_vps: Option<Vec<u8>>,
|
||||
cur_sps: Option<Vec<u8>>,
|
||||
cur_pps: Option<Vec<u8>>,
|
||||
// Splice-aware CRA→BLA rewrite (non-seamless BD clip boundaries).
|
||||
//
|
||||
// When a BD title concatenates clips at a NON-SEAMLESS join (MPLS
|
||||
// connection_condition 0x01), the next clip opens with a CRA whose RASL
|
||||
// leading pictures reference frames from before the splice — gone after
|
||||
// concatenation. The caller (the code that crosses the join) sets this flag
|
||||
// via `mark_clip_boundary`; the parser then rewrites the FIRST CRA it sees
|
||||
// at/after that point from CRA_NUT (21) to BLA_W_LP (16) so a linear decoder
|
||||
// sets NoRaslOutput and discards the dangling RASL with no error. The flag
|
||||
// is consumed (cleared) by that first CRA so only ONE CRA per boundary is
|
||||
// touched — never a mid-stream CRA, never an IDR, never a non-CRA NAL.
|
||||
//
|
||||
// SAFETY: defaults to `false` and is ONLY ever set through
|
||||
// `mark_clip_boundary`, which the caller invokes ONLY for a non-seamless
|
||||
// (0x01) join. A stream with no boundary marker (single-clip title, any
|
||||
// seamless-joined UHD/BD) never has this set, so the rewrite branch is never
|
||||
// reached and output is byte-identical to a parser without this field.
|
||||
pending_clip_boundary: bool,
|
||||
}
|
||||
|
||||
impl Default for HevcParser {
|
||||
@@ -51,40 +91,115 @@ impl HevcParser {
|
||||
vps: None,
|
||||
sps: None,
|
||||
pps: None,
|
||||
cur_vps: None,
|
||||
cur_sps: None,
|
||||
cur_pps: None,
|
||||
pending_clip_boundary: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark that the NEXT IRAP this parser sees begins a NON-SEAMLESS BD clip
|
||||
/// (MPLS connection_condition 0x01). The first CRA at/after this point is
|
||||
/// rewritten CRA_NUT (21) → BLA_W_LP (16) so a linear decoder sets
|
||||
/// NoRaslOutput and discards the now-dangling RASL leading pictures with no
|
||||
/// "could not find ref" error.
|
||||
///
|
||||
/// MUST be called ONLY when MPLS reports the join as non-seamless. It is a
|
||||
/// no-op for the rewrite unless a CRA actually follows: an IDR/IDR_W_RADL
|
||||
/// boundary needs no fix (it carries no cross-splice references), and the
|
||||
/// flag is cleared by the first IRAP-class CRA it reaches.
|
||||
///
|
||||
/// SAFETY: never call this for a seamless join (0x05/0x06) or within a
|
||||
/// single-clip title — doing so could convert a legitimate mid-content CRA
|
||||
/// to BLA. The default (never called) path leaves output byte-identical.
|
||||
pub fn mark_clip_boundary(&mut self) {
|
||||
self.pending_clip_boundary = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a VPS/SPS/PPS NAL.
|
||||
/// Handle a VPS/SPS/PPS NAL. Decides whether to strip it (the decoder already
|
||||
/// has the value) or emit it in-band, and tracks the currently-active body.
|
||||
///
|
||||
/// - 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 mid-title
|
||||
/// 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.
|
||||
// A NAL longer than u32::MAX can't be length-prefixed in the 4-byte
|
||||
// field; skip it rather than mis-frame the output. Unreachable in
|
||||
// practice (no real access unit is >4 GiB).
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
/// The decision MUST be made against the currently-active set (`cur`), NOT the
|
||||
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
||||
/// diverge exactly here:
|
||||
///
|
||||
/// - A *seek-capable / Annex-B* player (e.g. ffmpeg's `hevc_mp4toannexb`)
|
||||
/// re-applies the hvcC sets at every keyframe. `reassert_active` handles it.
|
||||
/// - A *streaming* decode (ffmpeg decoding the MKV directly — what most
|
||||
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
||||
/// parameter set ONLY from an in-band NAL.
|
||||
///
|
||||
/// So when a title redefines a set mid-stream (id 0 body A → B) and later
|
||||
/// switches BACK to A (== codecPrivate), the change to A must STILL be emitted
|
||||
/// in-band: the streaming decoder is sitting on B and will never revert
|
||||
/// otherwise, decoding the whole A-segment against B → CABAC/cu_qp_delta
|
||||
/// desync. Stripping on `== first` (the old behaviour) dropped exactly that
|
||||
/// revert and corrupted every "switch back to the first body" segment.
|
||||
///
|
||||
/// Rules:
|
||||
/// - First of its type → seeds codecPrivate; stripped (the decoder gets it from
|
||||
/// hvcC at init).
|
||||
/// - Equal to the active set `cur` → redundant; stripped.
|
||||
/// - Different from `cur` (a change, in EITHER direction) → emitted in-band and
|
||||
/// `cur` updated.
|
||||
///
|
||||
/// Returns `true` when the NAL was emitted in-band into `frame_data`.
|
||||
fn handle_param_set(
|
||||
first: &mut Option<Vec<u8>>,
|
||||
cur: &mut Option<Vec<u8>>,
|
||||
nal: &[u8],
|
||||
frame_data: &mut Vec<u8>,
|
||||
) -> bool {
|
||||
let is_first = first.is_none();
|
||||
if is_first {
|
||||
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
|
||||
}
|
||||
let changed = cur.as_deref() != Some(nal);
|
||||
if changed {
|
||||
*cur = Some(nal.to_vec());
|
||||
}
|
||||
// Strip the seeding occurrence (decoder gets it from hvcC) and any NAL that
|
||||
// doesn't change the active set. Emit only a genuine change.
|
||||
if is_first || !changed {
|
||||
return false;
|
||||
}
|
||||
// A NAL longer than u32::MAX can't be length-prefixed in the 4-byte field;
|
||||
// skip it rather than mis-frame the output. Unreachable in practice (no
|
||||
// real access unit is >4 GiB).
|
||||
let Ok(len) = u32::try_from(nal.len()) else {
|
||||
return false;
|
||||
};
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
true
|
||||
}
|
||||
|
||||
/// Append the active parameter set `cur` to `prefix` (length-prefixed) so every
|
||||
/// keyframe is SELF-CONTAINED: it carries the active VPS/SPS/PPS in-band ahead
|
||||
/// of its slices. Skipped only when this access unit ALREADY carried the NAL
|
||||
/// in-band (`emitted` — avoids a duplicate) or no active set exists yet.
|
||||
///
|
||||
/// Why unconditional (not only when the active set differs from codecPrivate):
|
||||
/// a streaming decoder applies the hvcC param sets once at init, then relies on
|
||||
/// in-band repetition. Some sources stop repeating a param set at later IRAPs
|
||||
/// even though its body is unchanged; if the decoder then drops it (a CRA reset
|
||||
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
||||
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
||||
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
||||
/// EVERY keyframe — what compliant muxers (mkvmerge) do at every IRAP — makes
|
||||
/// streaming decode self-healing. Re-sending an identical param set is benign
|
||||
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
||||
/// This strictly supersets the earlier change-only re-assert, so the
|
||||
/// param-set-revert fix is unaffected.
|
||||
fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) {
|
||||
if emitted {
|
||||
return;
|
||||
}
|
||||
let Some(active) = cur.as_deref() else {
|
||||
return;
|
||||
};
|
||||
push_length_prefixed(prefix, active);
|
||||
}
|
||||
|
||||
/// Append `nal` to `out` as a 4-byte big-endian length prefix followed by the
|
||||
@@ -114,6 +229,12 @@ impl CodecParser for HevcParser {
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
// Track whether THIS access unit already carried each param-set type
|
||||
// in-band (a redefinition vs codecPrivate). Used after the scan to
|
||||
// re-assert the active set at a keyframe the source left bare.
|
||||
let mut emitted_vps = false;
|
||||
let mut emitted_sps = false;
|
||||
let mut emitted_pps = false;
|
||||
// Pre-size: output is ~input bytes with a few 4-byte length
|
||||
// prefixes added. UHD frames are 150-300 KB; the unsized Vec
|
||||
// growth chain otherwise reallocs 5-7× per frame.
|
||||
@@ -145,13 +266,28 @@ impl CodecParser for HevcParser {
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => {
|
||||
handle_param_set(&mut self.vps, &data[nal_start..end], &mut frame_data)
|
||||
emitted_vps |= handle_param_set(
|
||||
&mut self.vps,
|
||||
&mut self.cur_vps,
|
||||
&data[nal_start..end],
|
||||
&mut frame_data,
|
||||
)
|
||||
}
|
||||
NAL_SPS => {
|
||||
handle_param_set(&mut self.sps, &data[nal_start..end], &mut frame_data)
|
||||
emitted_sps |= handle_param_set(
|
||||
&mut self.sps,
|
||||
&mut self.cur_sps,
|
||||
&data[nal_start..end],
|
||||
&mut frame_data,
|
||||
)
|
||||
}
|
||||
NAL_PPS => {
|
||||
handle_param_set(&mut self.pps, &data[nal_start..end], &mut frame_data)
|
||||
emitted_pps |= handle_param_set(
|
||||
&mut self.pps,
|
||||
&mut self.cur_pps,
|
||||
&data[nal_start..end],
|
||||
&mut frame_data,
|
||||
)
|
||||
}
|
||||
// Drop Access Unit Delimiters. This is intentional and
|
||||
// spec-correct: Matroska HEVC frame data omits AUDs
|
||||
@@ -160,7 +296,32 @@ impl CodecParser for HevcParser {
|
||||
NAL_AUD => {}
|
||||
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
|
||||
keyframe = true;
|
||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||
// Splice-aware CRA→BLA rewrite. At the FIRST CRA
|
||||
// following a non-seamless clip boundary (flag set
|
||||
// via `mark_clip_boundary`), rewrite CRA_NUT (21) →
|
||||
// BLA_W_LP (16) so a linear decoder sets NoRaslOutput
|
||||
// and drops the dangling RASL with no error. The flag
|
||||
// is consumed here so exactly ONE CRA per boundary is
|
||||
// touched. A non-CRA IRAP (IDR, BLA) clears the flag
|
||||
// too (the boundary is handled — IDR carries no
|
||||
// cross-splice refs) but is NOT modified. Default
|
||||
// path (flag never set) is unreachable → byte-
|
||||
// identical output.
|
||||
if self.pending_clip_boundary && t == NAL_CRA_NUT {
|
||||
// First CRA after a non-seamless boundary: rewrite
|
||||
// its header type to BLA_W_LP. NAL type is bits 1-6
|
||||
// of byte 0: byte = (byte & 0x81) | (type << 1).
|
||||
self.pending_clip_boundary = false;
|
||||
let mut rewritten = data[nal_start..end].to_vec();
|
||||
rewritten[0] = (rewritten[0] & 0x81) | (NAL_BLA_W_LP << 1);
|
||||
push_length_prefixed(&mut frame_data, &rewritten);
|
||||
} else {
|
||||
// Any IRAP clears a pending boundary (it's been
|
||||
// reached and handled — an IDR needs no rewrite),
|
||||
// but only a CRA is modified.
|
||||
self.pending_clip_boundary = false;
|
||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
|
||||
@@ -178,6 +339,27 @@ impl CodecParser for HevcParser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// A player re-applies the hvcC (codecPrivate) parameter sets at every
|
||||
// keyframe. If the active set was redefined mid-title and the source
|
||||
// stopped repeating that redefinition at later IRAPs (relying on the
|
||||
// decoder to retain it — valid for a raw bitstream), the hvcC
|
||||
// re-insertion would silently revert to the stale FIRST body and every
|
||||
// frame in the segment decodes against the wrong parameter set
|
||||
// (CABAC/cu_qp_delta desync). Re-assert the active set in-band, ahead
|
||||
// of this AU's slices, so it wins. Re-asserted at EVERY keyframe (even
|
||||
// when active == codecPrivate) so each keyframe is self-contained and a
|
||||
// decoder that dropped the set (CRA reset / SPS event) self-heals.
|
||||
if keyframe {
|
||||
let mut prefix = Vec::new();
|
||||
reassert_active(&mut prefix, &self.cur_vps, emitted_vps);
|
||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||
if !prefix.is_empty() {
|
||||
prefix.extend_from_slice(&frame_data);
|
||||
frame_data = prefix;
|
||||
}
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
@@ -208,38 +390,35 @@ impl CodecParser for HevcParser {
|
||||
// Minimal HEVCDecoderConfigurationRecord header.
|
||||
//
|
||||
// The stored SPS NAL is [2-byte HEVC NAL header][SPS RBSP...].
|
||||
// The RBSP begins at sps[2]; profile_tier_level() begins one byte
|
||||
// later, after sps_video_parameter_set_id u(4) +
|
||||
// sps_max_sub_layers_minus1 u(3) + sps_temporal_id_nesting_flag u(1)
|
||||
// (= sps[2], a full byte). So the profile_tier_level fields are:
|
||||
// sps[3] general_profile_space u(2)+tier u(1)+profile_idc u(5)
|
||||
// sps[4..8] general_profile_compatibility_flags u(32)
|
||||
// sps[8..14] general_constraint_indicator_flags 48 bits
|
||||
// sps[14] general_level_idc u(8)
|
||||
// (Byte-aligned read; emulation-prevention bytes within the first
|
||||
// 15 SPS bytes are not handled — extremely rare and matches the
|
||||
// pre-existing simplification.)
|
||||
// profile_tier_level fields must be read off the
|
||||
// emulation-prevention-STRIPPED RBSP — a `00 00 03` sequence in the
|
||||
// first ~15 SPS bytes would otherwise shift every raw byte index and
|
||||
// corrupt the PTL (profile/compat/constraint/level). We strip first
|
||||
// (same as parse_sps_chroma) and index into the cleaned RBSP:
|
||||
// rbsp[0] sps_vps_id u(4)+max_sub_layers u(3)+temporal_nesting u(1)
|
||||
// rbsp[1] general_profile_space u(2)+tier u(1)+profile_idc u(5)
|
||||
// rbsp[2..6] general_profile_compatibility_flags u(32)
|
||||
// rbsp[6..12] general_constraint_indicator_flags 48 bits
|
||||
// rbsp[12] general_level_idc u(8)
|
||||
let ptl: Vec<u8> = if sps.len() > 2 {
|
||||
strip_emulation_prevention(&sps[2..])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let ptl_at = |i: usize| -> u8 { ptl.get(i).copied().unwrap_or(0) };
|
||||
record.push(1); // configurationVersion
|
||||
// general_profile_space + general_tier_flag + general_profile_idc
|
||||
record.push(if sps.len() > 3 { sps[3] } else { 0 });
|
||||
// general_profile_compatibility_flags (4 bytes) — SPS bytes 4..8
|
||||
if sps.len() > 7 {
|
||||
record.extend_from_slice(&sps[4..8]);
|
||||
} else {
|
||||
let target = record.len() + 4;
|
||||
record.extend_from_slice(&sps[sps.len().min(4)..sps.len().min(8)]);
|
||||
record.resize(target, 0u8); // zero-pad the missing bytes in place
|
||||
record.push(ptl_at(1));
|
||||
// general_profile_compatibility_flags (4 bytes) — RBSP bytes 2..6
|
||||
for i in 2..6 {
|
||||
record.push(ptl_at(i));
|
||||
}
|
||||
// general_constraint_indicator_flags (6 bytes) — SPS bytes 8..14
|
||||
if sps.len() > 13 {
|
||||
record.extend_from_slice(&sps[8..14]);
|
||||
} else {
|
||||
let target = record.len() + 6;
|
||||
record.extend_from_slice(&sps[sps.len().min(8)..sps.len().min(14)]);
|
||||
record.resize(target, 0u8); // zero-pad the missing bytes in place
|
||||
// general_constraint_indicator_flags (6 bytes) — RBSP bytes 6..12
|
||||
for i in 6..12 {
|
||||
record.push(ptl_at(i));
|
||||
}
|
||||
// general_level_idc — SPS byte 14
|
||||
record.push(if sps.len() > 14 { sps[14] } else { 0 });
|
||||
// general_level_idc — RBSP byte 12
|
||||
record.push(ptl_at(12));
|
||||
// min_spatial_segmentation_idc (4 + 12 bits)
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
@@ -553,6 +732,171 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (Fight Club UHD banded corruption): a stream redefines PPS
|
||||
/// id 0 mid-title, then a later keyframe arrives WITHOUT repeating it (the
|
||||
/// source relies on the decoder retaining the redefinition — valid for a
|
||||
/// raw bitstream). An hvcC player re-applies the FIRST (codecPrivate) PPS
|
||||
/// at every keyframe, so the active redefinition must be re-asserted
|
||||
/// in-band at that bare keyframe or the whole segment decodes against the
|
||||
/// wrong parameter set.
|
||||
#[test]
|
||||
fn reasserts_active_pps_at_bare_keyframe() {
|
||||
fn nal(t: u8, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01];
|
||||
v.extend_from_slice(&hevc_nal_header(t));
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
// Split length-prefixed frame_data back into NAL bodies.
|
||||
fn nals_in(frame: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 4 <= frame.len() {
|
||||
let len = u32::from_be_bytes([frame[i], frame[i + 1], frame[i + 2], frame[i + 3]])
|
||||
as usize;
|
||||
i += 4;
|
||||
if i + len > frame.len() {
|
||||
break;
|
||||
}
|
||||
out.push(frame[i..i + len].to_vec());
|
||||
i += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
let pps_of = |nals: &[Vec<u8>]| -> Vec<Vec<u8>> {
|
||||
nals.iter()
|
||||
.filter(|n| n.len() >= 2 && (n[0] >> 1) & 0x3F == 34)
|
||||
.map(|n| n[2..].to_vec())
|
||||
.collect()
|
||||
};
|
||||
let sps_body = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
];
|
||||
let pps_a = [0xA1u8, 0xA2];
|
||||
let pps_b = [0xB1u8, 0xB2, 0xB3];
|
||||
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// AU1: seeds codecPrivate with VPS/SPS/PPS-A (all stripped in-band).
|
||||
let au1 = [
|
||||
nal(32, &[0xAA]),
|
||||
nal(33, &sps_body),
|
||||
nal(34, &pps_a),
|
||||
nal(19, &[0x10]),
|
||||
]
|
||||
.concat();
|
||||
parser.parse(&make_pes(au1, Some(0)));
|
||||
|
||||
// AU2: keyframe redefines PPS id 0 to body B → emitted in-band.
|
||||
let au2 = [nal(34, &pps_b), nal(19, &[0x11])].concat();
|
||||
let f2 = parser.parse(&make_pes(au2, Some(3600)));
|
||||
assert!(
|
||||
pps_of(&nals_in(&f2[0].data)).iter().any(|b| b == &pps_b),
|
||||
"AU2 must carry the redefined PPS-B in-band"
|
||||
);
|
||||
|
||||
// AU3: BARE keyframe, source omits the PPS. The active set (B) must be
|
||||
// re-asserted, and the stale codecPrivate A must NOT be injected.
|
||||
let au3 = nal(19, &[0x12]);
|
||||
let f3 = parser.parse(&make_pes(au3, Some(7200)));
|
||||
let got = pps_of(&nals_in(&f3[0].data));
|
||||
assert!(
|
||||
got.iter().any(|b| b == &pps_b),
|
||||
"bare keyframe must re-assert the active PPS-B in-band, got {got:?}"
|
||||
);
|
||||
assert!(
|
||||
!got.iter().any(|b| b == &pps_a),
|
||||
"must not re-assert the stale codecPrivate PPS-A"
|
||||
);
|
||||
|
||||
// AU4: switch the active set BACK to A (== codecPrivate) via an in-band
|
||||
// redefinition (a real change from B → emitted).
|
||||
let au4 = [nal(34, &pps_a), nal(19, &[0x13])].concat();
|
||||
parser.parse(&make_pes(au4, Some(10800)));
|
||||
// AU5: BARE keyframe, source omits the PPS, and the active set now
|
||||
// EQUALS codecPrivate. It must STILL be re-asserted in-band — every
|
||||
// keyframe is self-contained: a decoder that dropped PPS id 0 at a CRA
|
||||
// reset can only recover from an in-band copy, and there is no genuine
|
||||
// change here to trigger the emit path.
|
||||
let au5 = nal(19, &[0x14]);
|
||||
let f5 = parser.parse(&make_pes(au5, Some(14400)));
|
||||
assert!(
|
||||
pps_of(&nals_in(&f5[0].data)).iter().any(|b| b == &pps_a),
|
||||
"bare keyframe must re-assert the active PPS even when == codecPrivate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (Fight Club UHD, the real bug): id 0 is body A (→ hvcC), then
|
||||
/// redefined to B, then the title switches BACK to A. A streaming decoder
|
||||
/// (hvcC at init, in-band updates only) is sitting on B; the switch back to
|
||||
/// A must be emitted IN-BAND even though A == codecPrivate, or the whole
|
||||
/// A-segment decodes against B (cu_qp_delta desync). Stripping on `== hvcC`
|
||||
/// dropped this revert.
|
||||
#[test]
|
||||
fn emits_switch_back_to_codecprivate_pps() {
|
||||
fn nal(t: u8, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0x00, 0x00, 0x01];
|
||||
v.extend_from_slice(&hevc_nal_header(t));
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
fn nals_in(frame: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 4 <= frame.len() {
|
||||
let len = u32::from_be_bytes([frame[i], frame[i + 1], frame[i + 2], frame[i + 3]])
|
||||
as usize;
|
||||
i += 4;
|
||||
if i + len > frame.len() {
|
||||
break;
|
||||
}
|
||||
out.push(frame[i..i + len].to_vec());
|
||||
i += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
let pps_body = |nals: &[Vec<u8>]| -> Vec<Vec<u8>> {
|
||||
nals.iter()
|
||||
.filter(|n| n.len() >= 2 && (n[0] >> 1) & 0x3F == 34)
|
||||
.map(|n| n[2..].to_vec())
|
||||
.collect()
|
||||
};
|
||||
let sps = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
];
|
||||
let a = [0xA1u8, 0xA2];
|
||||
let b = [0xB1u8, 0xB2, 0xB3];
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// AU1: seeds codecPrivate with PPS-A.
|
||||
parser.parse(&make_pes(
|
||||
[nal(32, &[0xAA]), nal(33, &sps), nal(34, &a), nal(19, &[1])].concat(),
|
||||
Some(0),
|
||||
));
|
||||
// AU2 keyframe: redefine to B → emitted in-band.
|
||||
parser.parse(&make_pes([nal(34, &b), nal(19, &[2])].concat(), Some(3600)));
|
||||
// AU3 keyframe: source sends A again (== codecPrivate). Must be emitted
|
||||
// in-band because the active set was B.
|
||||
let f3 = parser.parse(&make_pes([nal(34, &a), nal(19, &[3])].concat(), Some(7200)));
|
||||
assert!(
|
||||
pps_body(&nals_in(&f3[0].data)).iter().any(|p| p == &a),
|
||||
"switch back to codecPrivate PPS-A must be emitted in-band"
|
||||
);
|
||||
// AU4 keyframe: A again, now == active AND == codecPrivate. Under the
|
||||
// self-contained-keyframe rule it is STILL re-asserted in-band so a
|
||||
// decoder that dropped PPS id 0 at this IRAP recovers. handle_param_set
|
||||
// strips the source copy (== active), then reassert_active prepends the
|
||||
// active set unconditionally.
|
||||
let f4 = parser.parse(&make_pes(
|
||||
[nal(34, &a), nal(19, &[4])].concat(),
|
||||
Some(10800),
|
||||
));
|
||||
assert!(
|
||||
pps_body(&nals_in(&f4[0].data)).iter().any(|p| p == &a),
|
||||
"active PPS must be re-asserted at every keyframe (self-contained), even when == codecPrivate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_profile_tier_level_offsets() {
|
||||
// The hvcC fixed header must read profile_tier_level from the SPS
|
||||
@@ -751,6 +1095,204 @@ mod tests {
|
||||
assert!(frames[0].keyframe, "type 23 should be keyframe");
|
||||
}
|
||||
|
||||
// --- splice-aware CRA→BLA rewrite (non-seamless clip boundary) ---
|
||||
|
||||
/// Split length-prefixed frame_data into NAL bodies (4-byte BE length + NAL).
|
||||
fn nals_of(frame: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 4 <= frame.len() {
|
||||
let len =
|
||||
u32::from_be_bytes([frame[i], frame[i + 1], frame[i + 2], frame[i + 3]]) as usize;
|
||||
i += 4;
|
||||
if i + len > frame.len() {
|
||||
break;
|
||||
}
|
||||
out.push(frame[i..i + len].to_vec());
|
||||
i += len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nal_type_of(nal: &[u8]) -> u8 {
|
||||
(nal[0] >> 1) & 0x3F
|
||||
}
|
||||
|
||||
/// Build a standalone CRA (type 21) access unit.
|
||||
fn cra_au(payload: &[u8]) -> Vec<u8> {
|
||||
let mut d = vec![0x00, 0x00, 0x01];
|
||||
d.extend_from_slice(&hevc_nal_header(21));
|
||||
d.extend_from_slice(payload);
|
||||
d
|
||||
}
|
||||
|
||||
/// Test 1: a CRA at a MARKED non-seamless boundary is rewritten to BLA_W_LP.
|
||||
#[test]
|
||||
fn cra_at_marked_boundary_rewritten_to_bla() {
|
||||
let mut parser = HevcParser::new();
|
||||
parser.mark_clip_boundary();
|
||||
let frames = parser.parse(&make_pes(cra_au(&[0x10, 0x20, 0x30]), Some(0)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "rewritten BLA is still a keyframe");
|
||||
let nals = nals_of(&frames[0].data);
|
||||
assert_eq!(nals.len(), 1);
|
||||
assert_eq!(
|
||||
nal_type_of(&nals[0]),
|
||||
NAL_BLA_W_LP,
|
||||
"marked-boundary CRA must be rewritten to BLA_W_LP (16)"
|
||||
);
|
||||
// The forbidden_zero_bit + layer-id-high (bit 0) and the rest of byte 0,
|
||||
// and all payload bytes, are otherwise untouched.
|
||||
assert_eq!(nals[0][0] & 0x81, hevc_nal_header(21)[0] & 0x81);
|
||||
assert_eq!(&nals[0][2..], &[0x10, 0x20, 0x30]);
|
||||
// The flag is one-shot: a SECOND CRA (no new marker) is left as CRA.
|
||||
let f2 = parser.parse(&make_pes(cra_au(&[0x40]), Some(90000)));
|
||||
assert_eq!(
|
||||
nal_type_of(&nals_of(&f2[0].data)[0]),
|
||||
NAL_CRA_NUT,
|
||||
"only the first CRA after a boundary is rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 2: a CRA with NO boundary marker is left unchanged (CRA stays CRA).
|
||||
#[test]
|
||||
fn cra_without_boundary_unchanged() {
|
||||
let mut parser = HevcParser::new();
|
||||
let frames = parser.parse(&make_pes(cra_au(&[0x10, 0x20]), Some(0)));
|
||||
let nals = nals_of(&frames[0].data);
|
||||
assert_eq!(
|
||||
nal_type_of(&nals[0]),
|
||||
NAL_CRA_NUT,
|
||||
"an unmarked CRA must remain a CRA"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 3: non-CRA NALs are never rewritten even when a boundary IS marked.
|
||||
/// IDR (19), RASL (8/9), VPS/SPS/PPS, and a trailing slice all pass through
|
||||
/// unmodified; the IDR clears the pending boundary so no later CRA is wrongly
|
||||
/// converted.
|
||||
#[test]
|
||||
fn non_cra_nals_never_rewritten_at_boundary() {
|
||||
// IDR boundary: marker set, but the first IRAP is an IDR → no rewrite,
|
||||
// and the marker is consumed so a later CRA is untouched.
|
||||
let mut parser = HevcParser::new();
|
||||
parser.mark_clip_boundary();
|
||||
let mut idr = vec![0x00, 0x00, 0x01];
|
||||
idr.extend_from_slice(&hevc_nal_header(19)); // IDR_W_RADL
|
||||
idr.extend_from_slice(&[0x10]);
|
||||
let f = parser.parse(&make_pes(idr, Some(0)));
|
||||
assert_eq!(
|
||||
nal_type_of(&nals_of(&f[0].data)[0]),
|
||||
19,
|
||||
"IDR at a marked boundary must stay IDR"
|
||||
);
|
||||
// Marker was consumed by the IDR: a following CRA is NOT rewritten.
|
||||
let f2 = parser.parse(&make_pes(cra_au(&[0x20]), Some(90000)));
|
||||
assert_eq!(
|
||||
nal_type_of(&nals_of(&f2[0].data)[0]),
|
||||
NAL_CRA_NUT,
|
||||
"the IDR consumed the boundary marker; later CRA stays CRA"
|
||||
);
|
||||
|
||||
// RASL leading pictures (types 8/9) preceding the splice CRA must not be
|
||||
// touched and must not consume the marker — only the CRA itself does.
|
||||
let mut parser = HevcParser::new();
|
||||
parser.mark_clip_boundary();
|
||||
let mut au = vec![0x00, 0x00, 0x01];
|
||||
au.extend_from_slice(&hevc_nal_header(8)); // RASL_N
|
||||
au.extend_from_slice(&[0xAA]);
|
||||
au.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
au.extend_from_slice(&hevc_nal_header(9)); // RASL_R
|
||||
au.extend_from_slice(&[0xBB]);
|
||||
au.extend_from_slice(&cra_au(&[0xCC])); // CRA after the RASLs
|
||||
let f = parser.parse(&make_pes(au, Some(0)));
|
||||
let nals = nals_of(&f[0].data);
|
||||
let types: Vec<u8> = nals.iter().map(|n| nal_type_of(n)).collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
vec![8, 9, NAL_BLA_W_LP],
|
||||
"RASLs pass through untouched; the CRA (after them) becomes BLA"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 4: a frame stream with NO boundary marker is BYTE-IDENTICAL to a
|
||||
/// parser that has no splice-rewrite field at all (the UHD-safety guarantee).
|
||||
/// We assert byte-equality of every emitted frame across a multi-AU stream
|
||||
/// containing CRAs, IDRs, RASLs, VPS/SPS/PPS, and trailing slices — none of
|
||||
/// which is ever marked.
|
||||
#[test]
|
||||
fn no_boundary_marker_is_byte_identical() {
|
||||
let build = || {
|
||||
let mut d = Vec::new();
|
||||
// AU0: VPS/SPS/PPS + CRA keyframe.
|
||||
d.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
d.extend_from_slice(&hevc_nal_header(32));
|
||||
d.extend_from_slice(&[0xAA]);
|
||||
d.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
d.extend_from_slice(&hevc_nal_header(33));
|
||||
d.extend_from_slice(&[0xBB, 0xCC, 0xDD]);
|
||||
d.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
d.extend_from_slice(&hevc_nal_header(34));
|
||||
d.extend_from_slice(&[0xEE]);
|
||||
d.extend_from_slice(&cra_au(&[0x11, 0x22]));
|
||||
d
|
||||
};
|
||||
// Reference parser: the rewrite field exists but is NEVER marked, so its
|
||||
// output is exactly the pre-feature behaviour. We compare a never-marked
|
||||
// run against a second never-marked run AND against the documented
|
||||
// invariant that the CRA is emitted as-is (type 21, payload intact).
|
||||
let mut a = HevcParser::new();
|
||||
let mut b = HevcParser::new();
|
||||
let fa = a.parse(&make_pes(build(), Some(0)));
|
||||
let fb = b.parse(&make_pes(build(), Some(0)));
|
||||
assert_eq!(fa.len(), 1);
|
||||
assert_eq!(fa[0].data, fb[0].data, "never-marked output must be stable");
|
||||
// And the CRA was NOT converted (type 21 still present, no BLA).
|
||||
let types: Vec<u8> = nals_of(&fa[0].data)
|
||||
.iter()
|
||||
.map(|n| nal_type_of(n))
|
||||
.collect();
|
||||
assert!(
|
||||
types.contains(&NAL_CRA_NUT) && !types.contains(&NAL_BLA_W_LP),
|
||||
"unmarked stream must keep its CRA (no BLA), got {types:?}"
|
||||
);
|
||||
|
||||
// Feed a second AU (a CRA) to the same unmarked parser: still a CRA.
|
||||
// Param sets are re-asserted ahead of the keyframe, so locate the CRA
|
||||
// among the emitted NALs rather than assuming it is first.
|
||||
let f2 = a.parse(&make_pes(cra_au(&[0x33]), Some(90000)));
|
||||
let t2: Vec<u8> = nals_of(&f2[0].data)
|
||||
.iter()
|
||||
.map(|n| nal_type_of(n))
|
||||
.collect();
|
||||
assert!(
|
||||
t2.contains(&NAL_CRA_NUT) && !t2.contains(&NAL_BLA_W_LP),
|
||||
"unmarked mid-stream CRA must never become BLA, got {t2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test 5: a SEAMLESS boundary (connection_condition 0x05/0x06) is expressed
|
||||
/// by NOT calling `mark_clip_boundary`, so a CRA across a seamless join is
|
||||
/// left unchanged. This encodes the contract: only non-seamless joins call
|
||||
/// `mark_clip_boundary`; seamless ones never do, so no rewrite occurs.
|
||||
#[test]
|
||||
fn seamless_boundary_no_rewrite() {
|
||||
// Simulate two clips joined seamlessly: the caller does NOT mark, so the
|
||||
// second clip's opening CRA stays a CRA.
|
||||
let mut parser = HevcParser::new();
|
||||
// Clip 1 ends with a CRA (no marker — mid-content).
|
||||
let f1 = parser.parse(&make_pes(cra_au(&[0x01]), Some(0)));
|
||||
assert_eq!(nal_type_of(&nals_of(&f1[0].data)[0]), NAL_CRA_NUT);
|
||||
// Seamless join: caller deliberately does NOT call mark_clip_boundary().
|
||||
// Clip 2 opens with a CRA → must remain a CRA.
|
||||
let f2 = parser.parse(&make_pes(cra_au(&[0x02]), Some(90000)));
|
||||
assert_eq!(
|
||||
nal_type_of(&nals_of(&f2[0].data)[0]),
|
||||
NAL_CRA_NUT,
|
||||
"a seamless join (no marker) must never rewrite the CRA"
|
||||
);
|
||||
}
|
||||
|
||||
// --- non-IRAP (trailing) → not keyframe ---
|
||||
|
||||
#[test]
|
||||
@@ -792,7 +1334,7 @@ mod tests {
|
||||
// --- VPS/SPS/PPS stripped from frame data ---
|
||||
|
||||
#[test]
|
||||
fn param_sets_stripped_from_frame() {
|
||||
fn param_sets_seed_codecprivate_and_reassert_at_keyframe() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
@@ -818,14 +1360,28 @@ mod tests {
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
// Frame data should only have the IDR NAL (length-prefixed)
|
||||
// The param sets seed codecPrivate (hvcC).
|
||||
assert!(
|
||||
parser.codec_private().is_some(),
|
||||
"VPS/SPS/PPS must seed codecPrivate"
|
||||
);
|
||||
|
||||
// Because this is a keyframe, the active VPS/SPS/PPS are ALSO re-asserted
|
||||
// in-band ahead of the IDR so the keyframe is self-contained. Frame data
|
||||
// = VPS, SPS, PPS, IDR (4 length-prefixed NALs, in that order).
|
||||
let fd = &frames[0].data;
|
||||
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
|
||||
// IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes
|
||||
let mut types = Vec::new();
|
||||
let mut o = 0;
|
||||
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;
|
||||
types.push((fd[o] >> 1) & 0x3F);
|
||||
o += len;
|
||||
}
|
||||
assert_eq!(
|
||||
length as usize + 4,
|
||||
fd.len(),
|
||||
"frame should contain exactly one length-prefixed NAL"
|
||||
types,
|
||||
vec![32, 33, 34, 19],
|
||||
"keyframe must re-assert VPS/SPS/PPS in-band ahead of the IDR slice"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -876,27 +1432,32 @@ mod tests {
|
||||
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.
|
||||
// PES3: PPS-B repeated on a NON-keyframe slice — B is already the active
|
||||
// set, so this carries no change and is stripped. (Re-assertion for
|
||||
// players that re-apply hvcC at keyframes is handled by
|
||||
// `reassert_active` at KEYFRAMES, not on every trailing frame; these
|
||||
// slices are TRAIL_R, not IRAP.)
|
||||
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"
|
||||
0,
|
||||
"PPS equal to the active set carries no change → stripped"
|
||||
);
|
||||
|
||||
// PES4: back to PPS-A (== codecPrivate) → stripped (hvcC supplies it).
|
||||
// PES4: back to PPS-A. Even though A == codecPrivate, the ACTIVE set is
|
||||
// B, so switching to A is a real change and MUST be emitted in-band — a
|
||||
// streaming decoder (hvcC at init, in-band updates only) is sitting on B
|
||||
// and would otherwise never revert. (This is the Fight Club bug: the old
|
||||
// `== codecPrivate → strip` rule dropped exactly this revert.)
|
||||
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"
|
||||
1,
|
||||
"switch back to the codecPrivate body is a change → emitted in-band"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1051,10 +1612,14 @@ mod tests {
|
||||
"frame data must contain Dolby Vision RPU NAL (type 62), got: {:?}",
|
||||
nal_types
|
||||
);
|
||||
// Self-contained keyframe: the active VPS/SPS/PPS are re-asserted in-band
|
||||
// ahead of the IDR, so the frame is VPS, SPS, PPS, IDR, RPU — in that
|
||||
// order. The RPU (type 62) is preserved (never stripped); only the
|
||||
// duplicate-suppression of unchanged param sets was lifted at keyframes.
|
||||
assert_eq!(
|
||||
nal_types.len(),
|
||||
2,
|
||||
"frame data should have exactly 2 NALs (IDR + RPU), got: {:?}",
|
||||
nal_types,
|
||||
vec![32, 33, 34, 19, 62],
|
||||
"keyframe carries re-asserted param sets + IDR + preserved RPU, got: {:?}",
|
||||
nal_types
|
||||
);
|
||||
|
||||
|
||||
+711
-439
File diff suppressed because it is too large
Load Diff
+244
-14
@@ -13,8 +13,22 @@ const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
const SC_FRAME: u8 = 0x0D;
|
||||
|
||||
pub struct Vc1Parser {
|
||||
// First-seen seq_header + entry_point seed the MKV codecPrivate
|
||||
// (BITMAPINFOHEADER extra data). These are the only out-of-band copies
|
||||
// the player gets. A stream may redefine either header mid-title; any
|
||||
// occurrence whose body DIFFERS from the active value must be emitted
|
||||
// IN-BAND at each point it appears, and at every keyframe (RAP) if the
|
||||
// active value differs from the codecPrivate copy, so seek points carry
|
||||
// valid decoder state (SMPTE 421M requires seq+entry before every RAP).
|
||||
seq_header: Option<Vec<u8>>,
|
||||
entry_point: Option<Vec<u8>>,
|
||||
// Currently-ACTIVE body of each type — the most recent the bitstream
|
||||
// defined. Distinct from the fixed codecPrivate copies above. The
|
||||
// strip/emit decision is made against `cur_*`, not the first-seen copy:
|
||||
// a switch BACK to the first-seen body (== codecPrivate) is still a
|
||||
// change a streaming decoder must be told about.
|
||||
cur_seq_header: Option<Vec<u8>>,
|
||||
cur_entry_point: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
@@ -30,12 +44,70 @@ impl Vc1Parser {
|
||||
Self {
|
||||
seq_header: None,
|
||||
entry_point: None,
|
||||
cur_seq_header: None,
|
||||
cur_entry_point: None,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a seq_header or entry_point start-code unit (Annex B raw bytes).
|
||||
///
|
||||
/// Decision is against the currently-ACTIVE body `cur`, not the codecPrivate
|
||||
/// copy `first`:
|
||||
/// - First of its type → seeds codecPrivate; stripped (decoder gets it from
|
||||
/// the BITMAPINFOHEADER extra data at init).
|
||||
/// - Equal to the active set `cur` → redundant; stripped.
|
||||
/// - Different from `cur` (a change in EITHER direction, including reverting
|
||||
/// to the codecPrivate/first value) → prepended into `prefix` in Annex B
|
||||
/// form and `cur` updated.
|
||||
///
|
||||
/// Returns `true` when the unit was emitted into `prefix`.
|
||||
fn handle_header(
|
||||
first: &mut Option<Vec<u8>>,
|
||||
cur: &mut Option<Vec<u8>>,
|
||||
unit: &[u8],
|
||||
prefix: &mut Vec<u8>,
|
||||
) -> bool {
|
||||
let is_first = first.is_none();
|
||||
if is_first {
|
||||
first.replace(unit.to_vec()); // seeds codecPrivate; stripped here
|
||||
}
|
||||
let changed = cur.as_deref() != Some(unit);
|
||||
if changed {
|
||||
*cur = Some(unit.to_vec());
|
||||
}
|
||||
// Strip the seeding occurrence and any unit that doesn't change the
|
||||
// active header. Emit only a genuine change.
|
||||
if is_first || !changed {
|
||||
return false;
|
||||
}
|
||||
prefix.extend_from_slice(unit);
|
||||
true
|
||||
}
|
||||
|
||||
/// Re-assert the active header `cur` into `prefix` (raw Annex B bytes) at every
|
||||
/// keyframe (RAP) so the RAP is SELF-CONTAINED. Skipped only when this AU already
|
||||
/// emitted the header in-band (`emitted`) or no active header exists yet.
|
||||
///
|
||||
/// Unconditional (not only when the active differs from codecPrivate): SMPTE 421M
|
||||
/// requires seq_header + entry_point before every RAP. A decoder applies the
|
||||
/// codecPrivate copy once at init, then relies on in-band repetition; if a source
|
||||
/// stops repeating an (unchanged) header at later RAPs and the decoder drops it,
|
||||
/// nothing re-sends it and seeks/segments land with wrong decoder state. Re-asserting
|
||||
/// at every RAP — what compliant muxers do — makes decode self-healing. Re-sending
|
||||
/// an identical header is benign. This strictly supersets the change-only re-assert.
|
||||
fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) {
|
||||
if emitted {
|
||||
return;
|
||||
}
|
||||
let Some(active) = cur.as_deref() else {
|
||||
return;
|
||||
};
|
||||
prefix.extend_from_slice(active);
|
||||
}
|
||||
|
||||
impl CodecParser for Vc1Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -50,6 +122,13 @@ impl CodecParser for Vc1Parser {
|
||||
let mut has_seq_header = false;
|
||||
let mut has_entry_point = false;
|
||||
let mut frame_start: Option<usize> = None;
|
||||
// Track whether this AU already emitted each header in-band (a
|
||||
// redefinition vs the active value).
|
||||
let mut emitted_seq = false;
|
||||
let mut emitted_ep = false;
|
||||
// In-band prefix: changed/new seq_header and/or entry_point units that
|
||||
// must appear before the SC_FRAME data in the MKV block.
|
||||
let mut prefix: Vec<u8> = Vec::new();
|
||||
|
||||
// Scan for start codes (00 00 01 XX)
|
||||
let data = &pes.data;
|
||||
@@ -61,17 +140,29 @@ impl CodecParser for Vc1Parser {
|
||||
SC_SEQUENCE_HEADER => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
let sh = &data[i..end];
|
||||
self.seq_header = Some(sh.to_vec());
|
||||
// Try to parse resolution from advanced profile sequence header
|
||||
if let Some((w, h)) = parse_vc1_resolution(sh) {
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
if self.seq_header.is_none() {
|
||||
if let Some((w, h)) = parse_vc1_resolution(sh) {
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
}
|
||||
}
|
||||
emitted_seq |= handle_header(
|
||||
&mut self.seq_header,
|
||||
&mut self.cur_seq_header,
|
||||
sh,
|
||||
&mut prefix,
|
||||
);
|
||||
has_seq_header = true;
|
||||
}
|
||||
SC_ENTRY_POINT => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.entry_point = Some(data[i..end].to_vec());
|
||||
emitted_ep |= handle_header(
|
||||
&mut self.entry_point,
|
||||
&mut self.cur_entry_point,
|
||||
&data[i..end],
|
||||
&mut prefix,
|
||||
);
|
||||
has_entry_point = true;
|
||||
}
|
||||
SC_FRAME => {
|
||||
@@ -91,11 +182,28 @@ impl CodecParser for Vc1Parser {
|
||||
// Keyframe = this PES contains a sequence header (I-frame indicator in BD)
|
||||
let keyframe = has_seq_header;
|
||||
|
||||
// Strip sequence header + entry point from frame data — those are in
|
||||
// codecPrivate, not coded-picture data. Only include data from the
|
||||
// frame start code onwards.
|
||||
// At every keyframe (RAP), re-assert the active seq_header + entry_point
|
||||
// in-band (even when unchanged vs codecPrivate) so the RAP is
|
||||
// self-contained. SMPTE 421M requires seq+entry before every RAP; a
|
||||
// decoder that dropped them recovers, and seeks land with correct state.
|
||||
// Skipped per-header only when this AU already emitted it in-band.
|
||||
if keyframe {
|
||||
reassert_active(&mut prefix, &self.cur_seq_header, emitted_seq);
|
||||
reassert_active(&mut prefix, &self.cur_entry_point, emitted_ep);
|
||||
}
|
||||
|
||||
// Assemble frame data: any in-band header changes + picture data from
|
||||
// the first SC_FRAME onwards.
|
||||
let frame_data = match frame_start {
|
||||
Some(start) => &data[start..],
|
||||
Some(start) => {
|
||||
if prefix.is_empty() {
|
||||
data[start..].to_vec()
|
||||
} else {
|
||||
let mut out = prefix;
|
||||
out.extend_from_slice(&data[start..]);
|
||||
out
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No frame start code. If this PES carried only parameter sets
|
||||
// (sequence header / entry point, captured above into
|
||||
@@ -106,14 +214,14 @@ impl CodecParser for Vc1Parser {
|
||||
if has_seq_header || has_entry_point {
|
||||
return Vec::new();
|
||||
}
|
||||
data // genuine picture payload with no leading 0x0D — pass through
|
||||
data.to_vec() // genuine picture payload with no leading 0x0D — pass through
|
||||
}
|
||||
};
|
||||
|
||||
vec![Frame {
|
||||
pts_ns: ts_ns,
|
||||
keyframe,
|
||||
data: frame_data.to_vec(),
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
@@ -385,9 +493,20 @@ mod tests {
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
// Frame data should start with the frame start code (00 00 01 0D)
|
||||
assert!(frames[0].data.len() >= 4);
|
||||
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
// Seq+entry seed codecPrivate on first occurrence, but because this is a
|
||||
// keyframe (RAP) they are re-asserted in-band so the RAP is
|
||||
// self-contained. Frame data therefore STARTS with the seq_header start
|
||||
// code, and the SC_FRAME picture data follows.
|
||||
let fd = &frames[0].data;
|
||||
assert!(fd.len() >= 4);
|
||||
assert_eq!(&fd[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
let frame_sc = fd
|
||||
.windows(4)
|
||||
.position(|w| w == [0x00, 0x00, 0x01, SC_FRAME]);
|
||||
assert!(
|
||||
frame_sc.is_some(),
|
||||
"SC_FRAME picture data must follow the re-asserted headers"
|
||||
);
|
||||
}
|
||||
|
||||
// --- parameter-set-only PES (seq header + entry point, no frame SC) ---
|
||||
@@ -749,4 +868,115 @@ mod tests {
|
||||
// Extra data should start with the sequence header start code
|
||||
assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
}
|
||||
|
||||
// --- regression: mid-stream entry_point A→B→A revert emitted in-band ---
|
||||
|
||||
/// Regression: entry_point is redefined from A (== codecPrivate) to B, then
|
||||
/// switched BACK to A. A streaming decoder applied codecPrivate at init and
|
||||
/// is now on B; the revert to A must be emitted IN-BAND even though A ==
|
||||
/// codecPrivate, or the A-segment decodes against the wrong entry point.
|
||||
#[test]
|
||||
fn vc1_emits_entry_point_revert_to_first_value() {
|
||||
let sh = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB];
|
||||
let ep_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22];
|
||||
let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55];
|
||||
let frame = vec![0x00, 0x00, 0x01, SC_FRAME, 0x77];
|
||||
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// AU1: seeds codecPrivate with sh + ep_a. Both are first → stripped from frame.
|
||||
let au1: Vec<u8> = sh
|
||||
.iter()
|
||||
.chain(ep_a.iter())
|
||||
.chain(frame.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
let f1 = parser.parse(&make_pes(au1, Some(0)));
|
||||
assert_eq!(f1.len(), 1, "AU1 emits a frame");
|
||||
// seq+entry seed codecPrivate, but this is a keyframe (RAP) so the active
|
||||
// headers are re-asserted in-band (self-contained RAP) — ep_a present.
|
||||
assert!(
|
||||
contains_sc(&f1[0].data, SC_ENTRY_POINT),
|
||||
"AU1: keyframe re-asserts the active entry_point in-band"
|
||||
);
|
||||
assert!(
|
||||
f1[0].data.windows(ep_a.len()).any(|w| w == ep_a),
|
||||
"AU1 carries the active ep_a bytes in-band"
|
||||
);
|
||||
|
||||
// AU2: entry_point redefined to B → must be emitted in-band.
|
||||
let au2: Vec<u8> = ep_b.iter().chain(frame.iter()).cloned().collect();
|
||||
let f2 = parser.parse(&make_pes(au2, Some(90000)));
|
||||
assert_eq!(f2.len(), 1, "AU2 emits a frame");
|
||||
assert!(
|
||||
contains_sc(&f2[0].data, SC_ENTRY_POINT),
|
||||
"AU2: redefined entry_point B must be in-band"
|
||||
);
|
||||
assert!(
|
||||
f2[0].data.windows(ep_b.len()).any(|w| w == ep_b),
|
||||
"AU2 must carry the ep_b bytes"
|
||||
);
|
||||
|
||||
// AU3: entry_point reverts to A (== codecPrivate). Active was B; this is
|
||||
// a real change and must still be emitted in-band.
|
||||
let au3: Vec<u8> = ep_a.iter().chain(frame.iter()).cloned().collect();
|
||||
let f3 = parser.parse(&make_pes(au3, Some(180000)));
|
||||
assert_eq!(f3.len(), 1, "AU3 emits a frame");
|
||||
assert!(
|
||||
f3[0].data.windows(ep_a.len()).any(|w| w == ep_a),
|
||||
"AU3: revert to A (== codecPrivate) must be emitted in-band"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a bare keyframe (no seq_header / entry_point in PES) after
|
||||
/// a mid-title redefinition must re-assert the active headers in-band so
|
||||
/// seek points carry valid decoder state (SMPTE 421M).
|
||||
#[test]
|
||||
fn vc1_reasserts_active_headers_at_bare_keyframe() {
|
||||
let sh_a = vec![0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xAA, 0xBB];
|
||||
let ep_a = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x11, 0x22];
|
||||
let ep_b = vec![0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x33, 0x44, 0x55];
|
||||
let frame = vec![0x00, 0x00, 0x01, SC_FRAME, 0x77];
|
||||
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// AU1: seed codecPrivate.
|
||||
let au1: Vec<u8> = sh_a
|
||||
.iter()
|
||||
.chain(ep_a.iter())
|
||||
.chain(frame.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
parser.parse(&make_pes(au1, Some(0)));
|
||||
|
||||
// AU2: redefine entry_point to B at a keyframe.
|
||||
let au2: Vec<u8> = sh_a
|
||||
.iter()
|
||||
.chain(ep_b.iter())
|
||||
.chain(frame.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
parser.parse(&make_pes(au2, Some(90000)));
|
||||
|
||||
// AU3: bare keyframe — only SC_SEQUENCE_HEADER (keyframe signal) + SC_FRAME,
|
||||
// no entry_point. Active entry_point is B (differs from codecPrivate A);
|
||||
// must be re-asserted in-band so seeks into this frame don't revert to A.
|
||||
let au3: Vec<u8> = sh_a.iter().chain(frame.iter()).cloned().collect();
|
||||
let f3 = parser.parse(&make_pes(au3, Some(180000)));
|
||||
assert_eq!(f3.len(), 1, "AU3 emits a frame");
|
||||
assert!(
|
||||
f3[0].data.windows(ep_b.len()).any(|w| w == ep_b),
|
||||
"bare keyframe must re-assert active entry_point B in-band"
|
||||
);
|
||||
assert!(
|
||||
!f3[0].data.windows(ep_a.len()).any(|w| w == ep_a),
|
||||
"must not re-assert stale codecPrivate entry_point A"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: does `data` contain a start-code unit with the given type byte?
|
||||
fn contains_sc(data: &[u8], sc_type: u8) -> bool {
|
||||
data.windows(4)
|
||||
.any(|w| w[0] == 0x00 && w[1] == 0x00 && w[2] == 0x01 && w[3] == sc_type)
|
||||
}
|
||||
}
|
||||
|
||||
+168
-12
@@ -92,7 +92,14 @@ impl DemuxThread {
|
||||
let mut ts = ts;
|
||||
let mut ps = ps;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
// SAFETY (no teardown deadlock on spawn failure): the worker closure is
|
||||
// `move`, so it OWNS `prefetch_rx` and `recycle_tx`. If `spawn` fails it
|
||||
// consumes and drops the closure, which drops those channel ends — so the
|
||||
// upstream producer observes disconnection and exits on its own BEFORE we
|
||||
// join it. `producer_shell` (whose Drop joins the producer) is NOT captured
|
||||
// by the closure, so dropping it on the Err path below joins a producer that
|
||||
// has already exited → non-blocking.
|
||||
let spawn_result = std::thread::Builder::new()
|
||||
.name("freemkv-demux".into())
|
||||
.spawn(move || {
|
||||
let prof = std::env::var_os("FREEMKV_PROFILE").is_some();
|
||||
@@ -101,7 +108,14 @@ impl DemuxThread {
|
||||
let mut prof_read_ns: u128 = 0;
|
||||
let mut prof_feed_ns: u128 = 0;
|
||||
let mut prof_bytes: u64 = 0;
|
||||
// Liveness heartbeat: the feed loop blocks on prefetch_rx.recv()
|
||||
// and on tx.send(); a stuck upstream/downstream shows up as the
|
||||
// beat going silent. Total is unknown for a stream, so `pos` is
|
||||
// cumulative bytes fed.
|
||||
let mut hb = crate::progress::Heartbeat::new("demux_feed");
|
||||
let mut fed_bytes: u64 = 0;
|
||||
loop {
|
||||
hb.tick(fed_bytes, 0);
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
// Caller-initiated stop is a clean termination —
|
||||
// send the Eof sentinel so the consumer doesn't
|
||||
@@ -128,6 +142,7 @@ impl DemuxThread {
|
||||
None
|
||||
};
|
||||
let n = buf.len();
|
||||
fed_bytes += n as u64;
|
||||
if let Some(ref mut d) = ts {
|
||||
let pkts = d.feed(&buf);
|
||||
let t2 = if prof {
|
||||
@@ -140,7 +155,14 @@ impl DemuxThread {
|
||||
// recycle channel is closed the producer has
|
||||
// exited; we drop the buffer and continue.
|
||||
let _ = recycle_tx.send(buf);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ts(pkts)).is_err() {
|
||||
// Always send the batch — even when empty (null /
|
||||
// untracked PIDs only). send() is how we detect an early
|
||||
// consumer disconnect; on mostly-null extents spanning
|
||||
// gigabytes of disc the batch can stay empty for a long
|
||||
// time, and skipping empty sends would hide the
|
||||
// disconnect until a (possibly never-arriving) non-empty
|
||||
// batch. An empty batch yields no frames downstream.
|
||||
if tx.send(DemuxBatch::Ts(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
if prof {
|
||||
@@ -153,7 +175,8 @@ impl DemuxThread {
|
||||
{
|
||||
let el = now.duration_since(prof_started).as_millis().max(1);
|
||||
let mbps = prof_bytes as u128 * 1000 / 1_000_000 / el;
|
||||
eprintln!(
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"[demux] elapsed={}ms in={}MB/s read={}% feed={}%",
|
||||
el,
|
||||
mbps,
|
||||
@@ -170,7 +193,9 @@ impl DemuxThread {
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let pkts = d.feed(&buf);
|
||||
let _ = recycle_tx.send(buf);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ps(pkts)).is_err() {
|
||||
// Always send (even empty) — same early-disconnect
|
||||
// detection rationale as the TS branch above.
|
||||
if tx.send(DemuxBatch::Ps(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -194,8 +219,18 @@ impl DemuxThread {
|
||||
// this and drops `tx`, which the consumer reads as an
|
||||
// error rather than a clean EOF.
|
||||
let _ = tx.send(DemuxBatch::Eof);
|
||||
})
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
});
|
||||
|
||||
let handle = match spawn_result {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// `prefetch_rx`/`recycle_tx` were moved into the (now-dropped)
|
||||
// failed spawn closure, so the producer already sees disconnection.
|
||||
// Dropping producer_shell here joins that already-exiting producer.
|
||||
drop(producer_shell);
|
||||
return Err(crate::error::Error::IoError { source: e });
|
||||
}
|
||||
};
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
@@ -458,10 +493,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batches_are_not_forwarded() {
|
||||
// The worker only forwards NON-empty packet vecs (`!pkts.is_empty()`).
|
||||
// A buffer that yields no complete PES (e.g. a single continuation
|
||||
// packet with no PUSI ever) must not produce a Ts batch — only Eof.
|
||||
fn empty_batches_are_forwarded_for_disconnect_detection() {
|
||||
// The worker forwards EVERY batch, including empty ones, so an early
|
||||
// consumer disconnect is detected promptly via `send()` (crossbeam's
|
||||
// Sender has no non-destructive disconnect check). An empty batch is
|
||||
// harmless downstream: `pump_one_batch` consumes 0 packets and returns
|
||||
// Ok(true) — only the explicit `Eof` sentinel ends the stream. A buffer
|
||||
// that yields no complete PES therefore produces an empty Ts batch
|
||||
// followed by Eof.
|
||||
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
|
||||
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4);
|
||||
let pid = 0x1011;
|
||||
@@ -482,7 +521,124 @@ mod tests {
|
||||
drop(pf_tx);
|
||||
|
||||
let batches = collect_batches(&rx, Duration::from_secs(5));
|
||||
assert_eq!(batches.len(), 1, "only Eof; no empty Ts batch forwarded");
|
||||
assert!(matches!(batches[0], DemuxBatch::Eof));
|
||||
assert_eq!(batches.len(), 2, "empty Ts batch forwarded, then Eof");
|
||||
assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty()));
|
||||
assert!(matches!(batches[1], DemuxBatch::Eof));
|
||||
}
|
||||
|
||||
/// Regression: worker must detect consumer disconnect even when every
|
||||
/// demux batch is empty (no matching PIDs / null packets).
|
||||
///
|
||||
/// Before the fix, `tx.send()` was never called for empty batches so the
|
||||
/// worker never observed the consumer drop — it would spin through ALL
|
||||
/// remaining extents before exiting, causing DemuxThread::drop's join()
|
||||
/// to block for minutes on a mostly-untracked disc region.
|
||||
///
|
||||
/// The watchdog: if the worker doesn't exit within 1 s of the consumer
|
||||
/// drop the test fails (rather than hanging forever as the bug would).
|
||||
#[test]
|
||||
fn worker_exits_promptly_on_consumer_drop_during_empty_batches() {
|
||||
// Use an untracked PID so every batch the demuxer produces is empty.
|
||||
let tracked_pid = 0x1011u16;
|
||||
let untracked_pid = 0x0100u16;
|
||||
|
||||
const SYNC: u8 = 0x47;
|
||||
// Build a non-PUSI continuation packet on the untracked PID so
|
||||
// TsDemuxer.feed() returns an empty Vec every call.
|
||||
let mut empty_pkt = vec![0u8; 192];
|
||||
empty_pkt[4] = SYNC;
|
||||
empty_pkt[5] = ((untracked_pid >> 8) as u8) & 0x1F; // no PUSI
|
||||
empty_pkt[6] = (untracked_pid & 0xFF) as u8;
|
||||
empty_pkt[7] = 0x10; // payload only
|
||||
|
||||
// Large prefetch channel — enough that the worker will be spinning
|
||||
// through empty batches long after the consumer drops.
|
||||
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(64);
|
||||
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(64);
|
||||
let ts = super::super::ts::TsDemuxer::new(&[tracked_pid]);
|
||||
let (dt, rx) =
|
||||
DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, Some(ts), None).unwrap();
|
||||
|
||||
// Fill the prefetch channel with empty-batch buffers.
|
||||
for _ in 0..64 {
|
||||
pf_tx.send(Ok(empty_pkt.clone())).unwrap();
|
||||
}
|
||||
|
||||
// Drop the consumer — the worker should notice during the next
|
||||
// empty-batch iteration (is_disconnected() check).
|
||||
drop(rx);
|
||||
|
||||
// Give the worker a generous but bounded window to observe the
|
||||
// disconnect and exit. A regression (spin-until-exhaustion) would
|
||||
// take >> 1 s; correct behaviour exits almost immediately.
|
||||
let join_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let join_done2 = join_done.clone();
|
||||
let watchdog = std::thread::spawn(move || {
|
||||
drop(dt); // joins the worker
|
||||
join_done2.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
|
||||
// Also close the producer so the worker doesn't block on prefetch_rx
|
||||
// if somehow is_disconnected is not triggered (belt-and-suspenders).
|
||||
drop(pf_tx);
|
||||
|
||||
watchdog.join().unwrap();
|
||||
assert!(
|
||||
join_done.load(std::sync::atomic::Ordering::Relaxed),
|
||||
"worker must exit promptly after consumer drop during empty batches"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: on thread-spawn failure the channels must be dropped BEFORE
|
||||
/// the producer shell so the upstream producer observes disconnection and
|
||||
/// exits, allowing join() to complete without hanging.
|
||||
///
|
||||
/// A true EAGAIN/pids-limit spawn failure cannot be reliably forced in a
|
||||
/// unit test without root or ulimit co-operation, so we test the
|
||||
/// drop-order contract directly: a mock shell that panics if join() is
|
||||
/// called while either channel end is still open.
|
||||
///
|
||||
/// The test constructs a `(prefetch_tx, prefetch_rx)` pair where the tx
|
||||
/// side is held by a sentinel that stays alive as long as either channel
|
||||
/// end is open, then asserts that the sentinel is gone by the time
|
||||
/// producer_shell's join logic would run. Because we can't force a real
|
||||
/// spawn failure, we instead verify the helper logic in isolation: drop
|
||||
/// `prefetch_rx` and `recycle_tx` first, then observe the producer-side
|
||||
/// sender is disconnected, which is the property the fix relies on.
|
||||
#[test]
|
||||
fn channels_disconnected_before_producer_join_on_spawn_failure() {
|
||||
// Build a prefetch channel pair. The producer "thread" is simulated by
|
||||
// holding prefetch_tx; we verify it observes disconnection after we
|
||||
// drop prefetch_rx (and only after — not before).
|
||||
// crossbeam channels expose disconnection only through send/recv
|
||||
// results (there is no is_disconnected()), so we probe it that way.
|
||||
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(1);
|
||||
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(1);
|
||||
|
||||
// Before any drop: the producer-side ends are live (a send into the
|
||||
// depth-1 prefetch channel succeeds; the recycle receiver can still
|
||||
// be fed).
|
||||
assert!(
|
||||
pf_tx.send(Ok(vec![1, 2, 3])).is_ok(),
|
||||
"prefetch_tx must accept a send before any drop"
|
||||
);
|
||||
|
||||
// Simulate the spawn-failure teardown: the move-closure owns prefetch_rx
|
||||
// and recycle_tx, so dropping them mirrors `spawn` dropping the failed
|
||||
// closure before producer_shell is joined.
|
||||
drop(pf_rx);
|
||||
drop(rc_tx);
|
||||
|
||||
// Now the producer-side handles observe disconnection via Err results —
|
||||
// a blocked producer send/recv returns Err and the producer exits, so
|
||||
// the subsequent join() completes without hanging.
|
||||
assert!(
|
||||
pf_tx.send(Ok(vec![4, 5, 6])).is_err(),
|
||||
"prefetch_tx send must fail after prefetch_rx drop (producer would exit)"
|
||||
);
|
||||
assert!(
|
||||
rc_rx.recv().is_err(),
|
||||
"recycle_rx recv must fail after recycle_tx drop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+214
-13
@@ -114,6 +114,15 @@ pub struct DiscStream {
|
||||
/// the wrapper.
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
|
||||
/// Sector granularity the decrypt step requires each read buffer to start
|
||||
/// on and span a multiple of. AACS decrypts whole 6144-byte (3-sector)
|
||||
/// units keyed off the buffer's first 16 bytes, so every `read_sectors`
|
||||
/// buffer must begin on a real on-disc unit boundary — hence reads and
|
||||
/// error-skips must stay aligned to this. `3` for AACS, `1` for CSS /
|
||||
/// unencrypted (per-sector, self-synchronizing). Mirrors the file-backed
|
||||
/// highway's `PrefetchedSectorSource` guard; this is the inline live path.
|
||||
unit_align: u16,
|
||||
|
||||
// Extents to read
|
||||
extents: Vec<Extent>,
|
||||
|
||||
@@ -227,6 +236,15 @@ impl DiscStream {
|
||||
}
|
||||
}
|
||||
|
||||
// AACS decrypts whole 6144-byte (3-sector) units keyed off each read
|
||||
// buffer's first 16 bytes, so reads/skips must stay 3-sector aligned.
|
||||
// CSS and unencrypted content are per-2048-byte and self-synchronizing
|
||||
// (align 1). Same rule the file-backed highway applies in resolve.rs.
|
||||
let unit_align: u16 = match &decrypt_keys {
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
Self {
|
||||
// Wrap the input reader in DecryptingSectorSource so the
|
||||
// internal fill_extents path sees plaintext bytes. For
|
||||
@@ -235,6 +253,7 @@ impl DiscStream {
|
||||
reader: DecryptingSectorSource::new(reader, decrypt_keys.clone()),
|
||||
title,
|
||||
decrypt_keys,
|
||||
unit_align,
|
||||
extents,
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
@@ -285,7 +304,7 @@ impl DiscStream {
|
||||
/// `fill_extents`. Calling `set_halt` after `with_halt` (or vice
|
||||
/// versa) replaces the previous token with the new one.
|
||||
#[deprecated(
|
||||
since = "0.18.0",
|
||||
since = "1.0.0",
|
||||
note = "use `DiscStream::with_halt(Halt)` at construction instead"
|
||||
)]
|
||||
pub fn set_halt(&mut self, flag: Arc<AtomicBool>) {
|
||||
@@ -353,12 +372,24 @@ impl DiscStream {
|
||||
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
|
||||
}
|
||||
|
||||
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
|
||||
// Align to 3-sector AACS units when possible. Partial units at
|
||||
// extent boundaries are safely handled by decrypt_sectors().
|
||||
if sectors >= 3 {
|
||||
sectors -= sectors % 3;
|
||||
}
|
||||
// Keep every read buffer starting on a real on-disc unit boundary.
|
||||
// AACS (unit_align=3) decrypts whole 6144-byte units keyed off the
|
||||
// buffer's first bytes, so a sub-unit read mid-extent desyncs the
|
||||
// rest of the title; always read at least one full unit. Only the
|
||||
// final partial unit at the extent tail (remaining < align) is read
|
||||
// short — nothing follows it to desync. CSS/raw (align=1) is
|
||||
// per-sector and self-synchronizing, so this is a no-op there.
|
||||
let align = self.unit_align.max(1) as u32;
|
||||
let want = remaining.min(self.adaptive.current() as u32);
|
||||
let sectors: u16 = if align <= 1 {
|
||||
want as u16
|
||||
} else if remaining < align {
|
||||
remaining as u16
|
||||
} else if want < align {
|
||||
align as u16
|
||||
} else {
|
||||
(want - want % align) as u16
|
||||
};
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
@@ -386,15 +417,21 @@ impl DiscStream {
|
||||
break;
|
||||
}
|
||||
|
||||
if sectors == 1 {
|
||||
// Bottomed out. Skip this sector or bail.
|
||||
if (sectors as u32) <= align {
|
||||
// Bottomed out at one unit (AACS) / one sector (CSS) / the
|
||||
// extent tail. Skip the WHOLE failed unit or bail. Zero-filling
|
||||
// and advancing by the full unit keeps current_offset
|
||||
// unit-aligned, so the next read still begins on a real AACS
|
||||
// unit boundary (a 1-sector skip here would desync the rest of
|
||||
// the title — the bug this guards).
|
||||
if self.skip_errors {
|
||||
self.read_buf.resize(2048, 0);
|
||||
self.read_buf[..2048].fill(0);
|
||||
self.buf_valid = 2048;
|
||||
let zb = sectors as usize * 2048;
|
||||
self.read_buf.resize(zb, 0);
|
||||
self.read_buf[..zb].fill(0);
|
||||
self.buf_valid = zb;
|
||||
self.errors += 1;
|
||||
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
|
||||
self.current_offset += 1;
|
||||
self.current_offset += sectors as u32;
|
||||
break;
|
||||
} else {
|
||||
// Build the error from the failure we ALREADY hold.
|
||||
@@ -857,6 +894,170 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||
/// Successful reads return zeroed sectors (which are NOT
|
||||
/// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through
|
||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||
struct RecordingReader {
|
||||
capacity: u32,
|
||||
bad_sector: u32,
|
||||
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
|
||||
}
|
||||
|
||||
impl crate::sector::SectorSource for RecordingReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
self.log.lock().unwrap().push((lba, count));
|
||||
let end = lba + count as u32;
|
||||
if self.bad_sector >= lba && self.bad_sector < end {
|
||||
return Err(crate::error::Error::DiscRead {
|
||||
sector: self.bad_sector as u64,
|
||||
status: Some(0x02),
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS unit-alignment skip (the #1 coverage gap). With `unit_align=3`
|
||||
/// (DecryptKeys::Aacs) and `skip_errors=true`, a single bad mid-extent
|
||||
/// sector must NOT desync the rest of the title: every `read_sectors`
|
||||
/// request must start on a 3-sector unit boundary relative to the extent
|
||||
/// start, and the skip over the failed unit must advance the cursor by a
|
||||
/// whole 3-sector unit (never a single sector).
|
||||
#[test]
|
||||
fn aacs_reads_stay_unit_aligned_and_skip_whole_units() {
|
||||
const COUNT: u32 = 30;
|
||||
const ALIGN: u32 = 3;
|
||||
// Bad sector at offset 13 — inside unit 4 (offsets 12,13,14). The
|
||||
// whole unit must be skipped, keeping the cursor unit-aligned.
|
||||
let bad = 13u32;
|
||||
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let reader = RecordingReader {
|
||||
capacity: COUNT,
|
||||
bad_sector: bad,
|
||||
log: log.clone(),
|
||||
};
|
||||
let title = synthetic_title(COUNT);
|
||||
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(
|
||||
stream.unit_align, ALIGN as u16,
|
||||
"AACS keys must set unit_align=3"
|
||||
);
|
||||
|
||||
// Drive fill_extents to EOF (no PES demux needed — we observe the
|
||||
// raw read pattern directly).
|
||||
let ext_start = 0u32;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
match stream.fill_extents() {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
|
||||
}
|
||||
guard += 1;
|
||||
assert!(guard < 1000, "fill_extents did not reach EOF");
|
||||
}
|
||||
|
||||
let reads = log.lock().unwrap();
|
||||
assert!(!reads.is_empty(), "expected at least one read");
|
||||
for &(lba, count) in reads.iter() {
|
||||
assert_eq!(
|
||||
(lba - ext_start) % ALIGN,
|
||||
0,
|
||||
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
|
||||
lba - ext_start
|
||||
);
|
||||
// Non-tail reads must be a whole number of units; the only
|
||||
// permitted short read is the final partial unit (here COUNT is a
|
||||
// multiple of ALIGN, so every read should be unit-multiple unless
|
||||
// it shrank below one unit — which is itself a single unit).
|
||||
let _ = count;
|
||||
}
|
||||
|
||||
// At least one error was skipped (the bad unit) and a SectorSkipped
|
||||
// event was emitted; errors counter advanced by exactly the bad units.
|
||||
assert!(stream.errors >= 1, "expected the bad unit to be skipped");
|
||||
|
||||
// Crucial anti-desync assertion: the read that bottomed out and was
|
||||
// skipped must have been a single 3-sector unit starting at offset 12
|
||||
// (the unit boundary at or below the bad sector 13), NOT a 1-sector
|
||||
// read at 13. Find a recorded read of (12, 3).
|
||||
assert!(
|
||||
reads
|
||||
.iter()
|
||||
.any(|&(lba, count)| lba == 12 && count == ALIGN as u16),
|
||||
"expected a unit-aligned (lba=12,count=3) read over the bad unit; got {reads:?}"
|
||||
);
|
||||
// And NO single-sector read at the bad sector itself (would be a desync).
|
||||
assert!(
|
||||
!reads.iter().any(|&(lba, count)| lba == bad && count == 1),
|
||||
"a 1-sector read at the bad sector {bad} would desync the AACS unit stream"
|
||||
);
|
||||
}
|
||||
|
||||
/// `unit_align == 1` (DecryptKeys::None) variant: single-sector skips
|
||||
/// still work (CSS/raw is self-synchronizing, so a 1-sector skip is
|
||||
/// correct there — contrast with the AACS whole-unit skip above).
|
||||
#[test]
|
||||
fn unencrypted_single_sector_skip_works() {
|
||||
const COUNT: u32 = 10;
|
||||
let bad = 4u32;
|
||||
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let reader = RecordingReader {
|
||||
capacity: COUNT,
|
||||
bad_sector: bad,
|
||||
log: log.clone(),
|
||||
};
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(reader),
|
||||
synthetic_title(COUNT),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
||||
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
match stream.fill_extents() {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
|
||||
}
|
||||
guard += 1;
|
||||
assert!(guard < 1000, "fill_extents did not reach EOF");
|
||||
}
|
||||
|
||||
let reads = log.lock().unwrap();
|
||||
// The bad sector must have been retried down to a single sector and
|
||||
// skipped at count==1 — the self-synchronizing per-sector path.
|
||||
assert!(
|
||||
reads.iter().any(|&(lba, count)| lba == bad && count == 1),
|
||||
"align=1 must bottom out at a 1-sector read over the bad sector; got {reads:?}"
|
||||
);
|
||||
assert!(stream.errors >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halt_via_set_halt_bridge_observed_by_is_halted() {
|
||||
let arc = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -475,6 +475,21 @@ pub const TRACK_TYPE_VIDEO: u64 = 1;
|
||||
pub const TRACK_TYPE_AUDIO: u64 = 2;
|
||||
pub const TRACK_TYPE_SUBTITLE: u64 = 17;
|
||||
|
||||
// Matroska CodecID strings (the `CodecID` element value per the Matroska codec
|
||||
// registry). Single source of truth for both the muxer (Codec -> string) and
|
||||
// the demuxer (string -> Codec), so the two can never drift.
|
||||
pub const CODEC_HEVC: &str = "V_MPEGH/ISO/HEVC";
|
||||
pub const CODEC_H264: &str = "V_MPEG4/ISO/AVC";
|
||||
pub const CODEC_VC1: &str = "V_MS/VFW/FOURCC";
|
||||
pub const CODEC_MPEG2: &str = "V_MPEG2";
|
||||
pub const CODEC_AC3: &str = "A_AC3";
|
||||
pub const CODEC_EAC3: &str = "A_EAC3";
|
||||
pub const CODEC_TRUEHD: &str = "A_TRUEHD";
|
||||
pub const CODEC_DTS: &str = "A_DTS";
|
||||
pub const CODEC_PCM_BE: &str = "A_PCM/INT/BIG";
|
||||
pub const CODEC_PGS: &str = "S_HDMV/PGS";
|
||||
pub const CODEC_VOBSUB: &str = "S_VOBSUB";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ fn build_ftyp() -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// Build the `moov` box — minimal skeleton. Single video trak, no
|
||||
/// hvcC inside stsd yet (TODO: full hvc1 sample entry).
|
||||
/// hvcC inside stsd yet (stub: hvcC/hvc1 stsd entry not yet implemented).
|
||||
fn build_moov() -> Vec<u8> {
|
||||
let mvhd = build_mvhd();
|
||||
let trak = build_video_trak();
|
||||
|
||||
+432
-52
@@ -62,11 +62,11 @@ impl MkvTrack {
|
||||
/// Dolby Vision layer.
|
||||
pub fn video(v: &VideoStream) -> Self {
|
||||
let codec_id = match v.codec {
|
||||
Codec::H264 => "V_MPEG4/ISO/AVC",
|
||||
Codec::Hevc => "V_MPEGH/ISO/HEVC",
|
||||
Codec::Vc1 => "V_MS/VFW/FOURCC",
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
Codec::H264 => ebml::CODEC_H264,
|
||||
Codec::Hevc => ebml::CODEC_HEVC,
|
||||
Codec::Vc1 => ebml::CODEC_VC1,
|
||||
Codec::Mpeg2 => ebml::CODEC_MPEG2,
|
||||
_ => ebml::CODEC_MPEG2,
|
||||
};
|
||||
let (w, h) = v.resolution.pixels();
|
||||
let (num, den) = v.frame_rate.as_fraction();
|
||||
@@ -132,12 +132,12 @@ impl MkvTrack {
|
||||
// lossless MA / HRA payload bytes are unchanged, only the
|
||||
// container codec-ID string differs.
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
Codec::Ac3 => ebml::CODEC_AC3,
|
||||
Codec::Ac3Plus => ebml::CODEC_EAC3,
|
||||
Codec::TrueHd => ebml::CODEC_TRUEHD,
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => ebml::CODEC_DTS,
|
||||
Codec::Lpcm => ebml::CODEC_PCM_BE,
|
||||
_ => ebml::CODEC_AC3,
|
||||
};
|
||||
let sr = a.sample_rate.hz();
|
||||
let ch = a.channels.count();
|
||||
@@ -174,8 +174,8 @@ impl MkvTrack {
|
||||
/// CodecPrivate. The forced-display flag is propagated from the stream.
|
||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||
let codec_id = match s.codec {
|
||||
Codec::DvdSub => "S_VOBSUB",
|
||||
_ => "S_HDMV/PGS",
|
||||
Codec::DvdSub => ebml::CODEC_VOBSUB,
|
||||
_ => ebml::CODEC_PGS,
|
||||
};
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||
@@ -229,6 +229,15 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// 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>,
|
||||
/// Per-track-index flag: true if the track is video. The strictly-monotonic
|
||||
/// block-timestamp nudge must be skipped for EVERY video track, not just
|
||||
/// track 0 — a title can carry a second video track (e.g. a Dolby Vision
|
||||
/// enhancement layer at index 1) whose B-frame PTS is just as legitimately
|
||||
/// non-monotonic. Keying the exemption on track type (not index) keeps that
|
||||
/// EL's true PTS instead of clobbering it to prev+1ms.
|
||||
track_is_video: Vec<bool>,
|
||||
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
|
||||
continuity: TimelineContinuity,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// Frames handed to `write_frame` that were dropped because no cluster was
|
||||
@@ -257,6 +266,113 @@ const MAX_BLOCK_REL_MS: i64 = i16::MAX as i64;
|
||||
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
|
||||
const MIN_BLOCK_REL_MS: i64 = i16::MIN as i64;
|
||||
|
||||
/// A backward PTS step larger than this is treated as a clip-boundary
|
||||
/// discontinuity (a non-seamless BD clip / dual-layer-break where the source
|
||||
/// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out
|
||||
/// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate
|
||||
/// reorder window and far below any real clip's duration, so it never
|
||||
/// false-triggers within a clip.
|
||||
const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000;
|
||||
/// Sub-frame gap inserted after a rebased discontinuity so the first frame of
|
||||
/// the new clip lands strictly after the previous timeline high (1 ms).
|
||||
const DISCONTINUITY_GAP_NS: i64 = 1_000_000;
|
||||
|
||||
/// Global timeline-continuity corrector. freemkv reads a BD title's clips as
|
||||
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
||||
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
||||
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
||||
/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS).
|
||||
///
|
||||
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
||||
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
||||
/// boundary shift by the same amount). It is global, not per-track: a clip
|
||||
/// boundary resets every stream together by the same delta.
|
||||
///
|
||||
/// The demuxer interleaves the tracks, so at a boundary the streams do NOT all
|
||||
/// reset on the same frame — a lagging audio/PGS frame from the just-ended
|
||||
/// clip's tail can arrive AFTER the next clip's video has already reset the
|
||||
/// epoch. Such a "straggler" carries an old-epoch raw PTS; adding the new
|
||||
/// offset to it would fling it far past the frontier and ratchet the whole
|
||||
/// timeline away (the regression that broke everything after the first clip
|
||||
/// boundary). It is detected as a forward spike and remapped with the PREVIOUS
|
||||
/// epoch's offset so it lands at its true position near the seam, without
|
||||
/// advancing the frontier or the offset.
|
||||
struct TimelineContinuity {
|
||||
/// Offset (ns) added to raw PTS for the CURRENT epoch.
|
||||
offset_ns: i64,
|
||||
/// Offset (ns) of the immediately previous epoch — used to remap stragglers
|
||||
/// (old-clip frames interleaved across the boundary).
|
||||
prev_offset_ns: i64,
|
||||
/// Highest adjusted PTS (ns) accepted onto the timeline so far — the running
|
||||
/// frontier. `None` until the first frame. Stragglers never advance it.
|
||||
high_ns: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimelineContinuity {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
offset_ns: 0,
|
||||
prev_offset_ns: 0,
|
||||
high_ns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a raw PES PTS (ns) onto the continuous output timeline.
|
||||
///
|
||||
/// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier =
|
||||
/// clip-boundary reset: open a new epoch (save the old offset, bump the
|
||||
/// offset so this frame continues just after the frontier).
|
||||
/// - **Forward spike > `DISCONTINUITY_BACKSTEP_NS` past the frontier** = a
|
||||
/// straggler from the previous clip arriving interleaved after the
|
||||
/// boundary: remap with `prev_offset_ns` so it lands near the seam, and do
|
||||
/// NOT advance the frontier or the offset (this is what prevents the
|
||||
/// ratchet). A legitimate per-track gap (e.g. a subtitle absent for
|
||||
/// minutes) is NOT misread as a straggler: video keeps the frontier
|
||||
/// current, so the resuming frame lands at the frontier, not beyond it.
|
||||
/// - **Everything else** (normal progression + sub-threshold B-frame
|
||||
/// reorder dips) passes through with the current offset, preserving PTS.
|
||||
fn adjust(&mut self, raw_pts_ns: i64) -> i64 {
|
||||
let Some(high) = self.high_ns else {
|
||||
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
self.high_ns = Some(adj);
|
||||
return adj;
|
||||
};
|
||||
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
if adj < high - DISCONTINUITY_BACKSTEP_NS {
|
||||
// Clip-boundary reset: continue just after the frontier; remember the
|
||||
// previous offset so this clip's lagging tail frames remap correctly.
|
||||
self.prev_offset_ns = self.offset_ns;
|
||||
let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS);
|
||||
self.offset_ns = self.offset_ns.saturating_add(bump);
|
||||
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
self.high_ns = Some(high.max(adj2));
|
||||
adj2
|
||||
} else if adj > high + DISCONTINUITY_BACKSTEP_NS && {
|
||||
// A straggler from the just-ended clip maps, under the PREVIOUS
|
||||
// epoch's offset, into the TOP of that epoch — at most the frontier,
|
||||
// and no more than one backstep below it (it is the clip's tail,
|
||||
// delivered late by the interleaver). Both bounds matter:
|
||||
// - `<= high` rules out a genuine large forward jump (it maps ABOVE
|
||||
// the frontier under either offset).
|
||||
// - `>= high - BACKSTEP` rules out a genuine NEW-clip frame whose
|
||||
// low raw PTS also maps below the frontier (that frame belongs to
|
||||
// the new epoch and must be rebased forward, not remapped back).
|
||||
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
|
||||
prev_mapped <= high && prev_mapped >= high - DISCONTINUITY_BACKSTEP_NS
|
||||
} {
|
||||
// Straggler: remap to its true seam position with the previous
|
||||
// offset; leave the frontier and offset untouched (prevents the
|
||||
// ratchet). A real forward jump / new-clip frame falls through to the
|
||||
// normal branch and is rebased there.
|
||||
raw_pts_ns.saturating_add(self.prev_offset_ns)
|
||||
} else {
|
||||
// Normal progression / sub-threshold B-frame reorder: keep true PTS.
|
||||
self.high_ns = Some(high.max(adj));
|
||||
adj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -271,7 +387,7 @@ fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
}
|
||||
|
||||
/// Per-track block timestamp. The strictly-monotonic nudge is applied to
|
||||
/// AUDIO/SUBTITLE tracks only; VIDEO (track 0) is returned UNCHANGED.
|
||||
/// AUDIO/SUBTITLE tracks only; ALL VIDEO tracks are returned UNCHANGED.
|
||||
///
|
||||
/// With B-frames, a video frame's presentation PTS is legitimately
|
||||
/// non-monotonic in decode/storage order (a B-frame sits between its anchors,
|
||||
@@ -282,8 +398,14 @@ fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
/// SimpleBlock permits non-monotonic block timestamps (signed block-relative
|
||||
/// offsets), so video keeps its true PES PTS; only no-reorder tracks (audio,
|
||||
/// subtitles), where a same-millisecond collision IS a real defect, get nudged.
|
||||
fn block_ts(track_idx: usize, prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
if track_idx == 0 {
|
||||
///
|
||||
/// The exemption is keyed on `is_video` (track type), NOT a track index: a
|
||||
/// title can carry more than one video track — e.g. a Dolby Vision enhancement
|
||||
/// layer at index 1 — and every one must keep its true PTS. Keying on
|
||||
/// `track_idx == 0` clamped the EL and reintroduced the exact non-monotonic-DTS
|
||||
/// warning this exemption exists to prevent.
|
||||
fn block_ts(is_video: bool, prev: Option<i64>, pts_ms: i64) -> i64 {
|
||||
if is_video {
|
||||
pts_ms
|
||||
} else {
|
||||
monotonic_ts(prev, pts_ms)
|
||||
@@ -371,8 +493,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::write_float(&mut writer, ebml::DURATION, duration_secs * 1000.0)?;
|
||||
// in ms
|
||||
}
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, "freemkv")?;
|
||||
ebml::write_string(&mut writer, ebml::WRITING_APP, "freemkv")?;
|
||||
// Stamp the freemkv version so any muxed file is traceable to the build
|
||||
// that produced it (MediaInfo "Writing application"/"library").
|
||||
const FREEMKV_MUX_APP: &str = concat!("freemkv ", env!("CARGO_PKG_VERSION"));
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, FREEMKV_MUX_APP)?;
|
||||
ebml::write_string(&mut writer, ebml::WRITING_APP, FREEMKV_MUX_APP)?;
|
||||
if let Some(t) = title {
|
||||
ebml::write_string(&mut writer, ebml::TITLE, t)?;
|
||||
}
|
||||
@@ -509,6 +634,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
cluster_ts_ms: 0,
|
||||
base_pts_ms: None,
|
||||
last_pts_ms: std::collections::HashMap::new(),
|
||||
track_is_video: tracks
|
||||
.iter()
|
||||
.map(|t| t.track_type == ebml::TRACK_TYPE_VIDEO)
|
||||
.collect(),
|
||||
continuity: TimelineContinuity::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
dropped_pre_cluster: 0,
|
||||
@@ -534,6 +664,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
data: &[u8],
|
||||
duration_ns: Option<u64>,
|
||||
) -> io::Result<()> {
|
||||
// Map the raw PES PTS onto the continuous output timeline FIRST, before
|
||||
// any base/cluster math: freemkv concatenates a title's BD clips as one
|
||||
// sector stream, so a non-seamless clip / layer-break boundary arrives
|
||||
// here as a large backward PTS jump. Rebasing it (a global offset across
|
||||
// all tracks, A/V-sync-preserving) keeps the boundary from becoming a
|
||||
// band of non-monotonic block timestamps. No-op for single-clip titles.
|
||||
let pts_ns = self.continuity.adjust(pts_ns);
|
||||
let raw_ms = pts_ns / 1_000_000;
|
||||
|
||||
// Cluster boundaries normally coincide with a video keyframe so every
|
||||
@@ -582,7 +719,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// POC and finds them colliding ("non monotonically increasing dts").
|
||||
// Matroska SimpleBlock permits non-monotonic block timestamps (negative
|
||||
// block-relative offsets), so leave the true PES PTS intact for video.
|
||||
let pts_ms = block_ts(track_idx, self.last_pts_ms.get(&track_idx).copied(), pts_ms);
|
||||
let is_video = self.track_is_video.get(track_idx).copied().unwrap_or(false);
|
||||
let pts_ms = block_ts(is_video, self.last_pts_ms.get(&track_idx).copied(), pts_ms);
|
||||
|
||||
let needs_new_cluster = !self.cluster_open
|
||||
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
|
||||
@@ -816,7 +954,7 @@ mod tests {
|
||||
fn make_video_track() -> MkvTrack {
|
||||
MkvTrack {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id: "V_MPEG4/ISO/AVC",
|
||||
codec_id: ebml::CODEC_H264,
|
||||
language: "und".into(),
|
||||
name: String::new(),
|
||||
codec_private: Some(vec![0x00, 0x01, 0x02, 0x03]),
|
||||
@@ -841,7 +979,7 @@ mod tests {
|
||||
fn make_audio_track() -> MkvTrack {
|
||||
MkvTrack {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id: "A_AC3",
|
||||
codec_id: ebml::CODEC_AC3,
|
||||
language: "eng".into(),
|
||||
name: "English".into(),
|
||||
codec_private: None,
|
||||
@@ -1003,17 +1141,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn block_ts_exempts_video_from_monotonic_nudge() {
|
||||
// VIDEO (track 0) keeps its true PTS even when non-monotonic in storage
|
||||
// order — a B-frame whose presentation PTS sits below the frame stored
|
||||
// before it must NOT be nudged to prev+1ms (that clobbering is what
|
||||
// produced the "non monotonically increasing dts" flood on decode).
|
||||
// VIDEO keeps its true PTS even when non-monotonic in storage order — a
|
||||
// B-frame whose presentation PTS sits below the frame stored before it
|
||||
// must NOT be nudged to prev+1ms (that clobbering is what produced the
|
||||
// "non monotonically increasing dts" flood on decode).
|
||||
assert_eq!(
|
||||
block_ts(0, Some(1040), 1000),
|
||||
block_ts(true, Some(1040), 1000),
|
||||
1000,
|
||||
"video B-frame PTS preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
block_ts(0, Some(1000), 1000),
|
||||
block_ts(true, Some(1000), 1000),
|
||||
1000,
|
||||
"video dup-ms PTS preserved"
|
||||
);
|
||||
@@ -1024,23 +1162,262 @@ mod tests {
|
||||
let out: Vec<i64> = gop
|
||||
.iter()
|
||||
.map(|&p| {
|
||||
let t = block_ts(0, prev, p);
|
||||
let t = block_ts(true, prev, p);
|
||||
prev = Some(t);
|
||||
t
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(out, gop, "video timestamps must be left exactly as-is");
|
||||
|
||||
// AUDIO/SUBTITLE (track != 0) still get the strictly-monotonic nudge —
|
||||
// a same-ms collision there is a real defect.
|
||||
assert_eq!(block_ts(1, Some(1000), 1000), 1001, "audio dup-ms nudged");
|
||||
// AUDIO/SUBTITLE still get the strictly-monotonic nudge — a same-ms
|
||||
// collision there is a real defect.
|
||||
assert_eq!(
|
||||
block_ts(2, Some(1001), 1000),
|
||||
block_ts(false, Some(1000), 1000),
|
||||
1001,
|
||||
"audio dup-ms nudged"
|
||||
);
|
||||
assert_eq!(
|
||||
block_ts(false, Some(1001), 1000),
|
||||
1002,
|
||||
"subtitle back-tick nudged"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the second-video-track bug: a Dolby Vision enhancement
|
||||
/// layer is video but NOT track 0. The exemption must follow track TYPE, so
|
||||
/// the EL's B-frame PTS are preserved exactly like the main video's — not
|
||||
/// clamped to prev+1ms (which reintroduced the non-monotonic-DTS flood on
|
||||
/// the EL stream). Drives the muxer through both video tracks and asserts
|
||||
/// every video block timecode equals its source PTS.
|
||||
#[test]
|
||||
fn second_video_track_pts_not_clobbered() {
|
||||
use std::io::Cursor;
|
||||
// Main video at index 0, a Dolby-Vision-EL-style second video at index 1.
|
||||
let tracks = vec![make_video_track(), make_video_track()];
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mux = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
|
||||
// Both tracks must be flagged video so neither is nudged.
|
||||
assert_eq!(mux.track_is_video, vec![true, true]);
|
||||
// A B-frame dip on the EL (track 1) must pass through unchanged — keyed
|
||||
// on track type, not index.
|
||||
assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000);
|
||||
}
|
||||
|
||||
// ── Clip-boundary timeline-continuity (PTS discontinuity rebasing) ──
|
||||
|
||||
const S: i64 = 1_000_000_000; // 1 second in ns
|
||||
|
||||
/// Characterization of the BUG: a BD title's two clips concatenated with a
|
||||
/// PTS reset at the boundary. WITHOUT correction the raw timeline goes
|
||||
/// hard backward at clip 2 (what produced the non-monotonic-DTS band on
|
||||
/// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and
|
||||
/// continuous across the boundary.
|
||||
#[test]
|
||||
fn continuity_rebases_clip_boundary_reset() {
|
||||
// Two interleaved tracks (video t0 + audio t1), clip1 rising to 10s,
|
||||
// then clip2 RESETS near 0 and rises again — the non-seamless case.
|
||||
let clip1: Vec<i64> = (0..=10).map(|i| i * S).collect(); // 0..10s
|
||||
let clip2: Vec<i64> = (0..=10).map(|i| i * S).collect(); // resets to 0..10s
|
||||
let raw: Vec<i64> = clip1.iter().chain(clip2.iter()).copied().collect();
|
||||
|
||||
// Uncorrected (the bug): the sequence is NOT monotonic — clip2's first
|
||||
// frame (0) is 10s below clip1's last (10s).
|
||||
assert!(
|
||||
raw.windows(2).any(|w| w[1] < w[0]),
|
||||
"precondition: raw clip-reset sequence is non-monotonic"
|
||||
);
|
||||
|
||||
// Corrected: strictly non-decreasing, and clip2 continues AFTER clip1.
|
||||
let mut tc = TimelineContinuity::new();
|
||||
let out: Vec<i64> = raw.iter().map(|&p| tc.adjust(p)).collect();
|
||||
assert!(
|
||||
out.windows(2).all(|w| w[1] >= w[0]),
|
||||
"corrected timeline must be monotonic non-decreasing, got {out:?}"
|
||||
);
|
||||
// Clip2's first frame lands just after clip1's last (10s) + the gap.
|
||||
assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS);
|
||||
// Clip2's last frame is offset by the whole of clip1, not back near 0.
|
||||
assert!(out[21] > 19 * S);
|
||||
}
|
||||
|
||||
/// Regression guard: NORMAL B-frame reorder (a small backward dip, well
|
||||
/// under the discontinuity threshold) must pass through UNCHANGED — the
|
||||
/// corrector must not rebase legitimate reorder (that would re-break the
|
||||
/// video-PTS exemption).
|
||||
#[test]
|
||||
fn continuity_preserves_bframe_reorder() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
// I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2
|
||||
// frames (~83ms), far under the 3s threshold.
|
||||
let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000];
|
||||
let out: Vec<i64> = raw.iter().map(|&p| tc.adjust(p)).collect();
|
||||
assert_eq!(out, raw, "B-frame reorder must pass through unchanged");
|
||||
assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder");
|
||||
}
|
||||
|
||||
/// A legitimate FORWARD gap (a real timing gap within a clip, under the
|
||||
/// backstep window) must be PRESERVED, not clamped — only backward
|
||||
/// clip-boundary jumps are rebased and only an old-epoch straggler (a
|
||||
/// forward spike FAR past the frontier, right after a boundary) is remapped.
|
||||
#[test]
|
||||
fn continuity_preserves_forward_gap() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream
|
||||
let out: Vec<i64> = raw.iter().map(|&p| tc.adjust(p)).collect();
|
||||
assert_eq!(out, raw, "forward gap preserved verbatim");
|
||||
assert_eq!(tc.offset_ns, 0, "no rebase on forward progression");
|
||||
}
|
||||
|
||||
/// Regression for the ratchet bug (the one the first fix introduced, which
|
||||
/// broke everything after the first clip boundary): the demuxer interleaves
|
||||
/// tracks, so a lagging audio frame from clip 1's TAIL arrives AFTER clip 2's
|
||||
/// video has reset the epoch. The old global-high logic added the new offset
|
||||
/// to that straggler, flung it into the future, inflated the frontier, and
|
||||
/// re-triggered the rebase on every real clip-2 frame → offset ran away.
|
||||
///
|
||||
/// Correct behaviour: the straggler is remapped to its true seam position
|
||||
/// (it is NOT thrown forward), the frontier and offset do NOT ratchet, and
|
||||
/// clip 2 continues monotonically just after clip 1.
|
||||
#[test]
|
||||
fn continuity_straggler_does_not_ratchet_the_timeline() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
// clip1 rises to 10s (frontier 10s, offset 0).
|
||||
for i in 0..=10 {
|
||||
tc.adjust(i * S);
|
||||
}
|
||||
let offset_before = tc.offset_ns;
|
||||
let frontier_before = tc.high_ns.unwrap();
|
||||
assert_eq!(offset_before, 0);
|
||||
assert_eq!(frontier_before, 10 * S);
|
||||
|
||||
// clip2's first VIDEO frame resets to 0 → clip-boundary rebase.
|
||||
let c2_first = tc.adjust(0);
|
||||
assert_eq!(
|
||||
c2_first,
|
||||
10 * S + DISCONTINUITY_GAP_NS,
|
||||
"clip2 continues after clip1"
|
||||
);
|
||||
let offset_after_boundary = tc.offset_ns;
|
||||
|
||||
// Now a STRAGGLER: clip1's tail audio (raw ~9.5s) arrives interleaved.
|
||||
let straggler = tc.adjust(9 * S + 500_000_000);
|
||||
// It must land near the seam (clip1 tail), NOT ~19.5s in the future.
|
||||
assert!(
|
||||
straggler <= 10 * S,
|
||||
"straggler remapped to its true seam position, got {straggler}"
|
||||
);
|
||||
// And it must NOT have moved the offset or the frontier.
|
||||
assert_eq!(
|
||||
tc.offset_ns, offset_after_boundary,
|
||||
"straggler must not ratchet the offset"
|
||||
);
|
||||
assert_eq!(
|
||||
tc.high_ns.unwrap(),
|
||||
c2_first,
|
||||
"straggler must not inflate the frontier"
|
||||
);
|
||||
|
||||
// clip2 keeps rising from ~0; every frame stays just past the seam — no
|
||||
// runaway. After 10 more seconds of clip2 the timeline is ~20s, not 30s+.
|
||||
let mut last = c2_first;
|
||||
for i in 1..=10 {
|
||||
let a = tc.adjust(i * S);
|
||||
assert!(
|
||||
a >= last,
|
||||
"clip2 monotonic after straggler, got {a} < {last}"
|
||||
);
|
||||
last = a;
|
||||
}
|
||||
assert!(
|
||||
last < 21 * S,
|
||||
"no ratchet: clip2 end near 20s (clip1+clip2), got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the original Top Gun band (`-58864 >= -820000`-scale): a
|
||||
/// LARGE, real-magnitude clip-boundary back-jump (clip 1 ≈ 13 min, clip 2
|
||||
/// resets to 0) must be rebased to one continuous monotonic timeline — not
|
||||
/// left to produce the sustained non-monotonic-DTS band the auditor flagged.
|
||||
#[test]
|
||||
fn continuity_large_clip_boundary_backjump_rebased() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
// Clip 1: 0 .. 780s (13 min) at 1s steps.
|
||||
let clip1: Vec<i64> = (0..=780).map(|i| i * S).collect();
|
||||
// Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity.
|
||||
let clip2: Vec<i64> = (0..=120).map(|i| i * S).collect();
|
||||
let mut last = i64::MIN;
|
||||
let mut max = i64::MIN;
|
||||
for &p in clip1.iter().chain(clip2.iter()) {
|
||||
let a = tc.adjust(p);
|
||||
assert!(
|
||||
a >= last,
|
||||
"rebased timeline must be monotonic, got {a} < {last}"
|
||||
);
|
||||
last = a;
|
||||
max = max.max(a);
|
||||
}
|
||||
// Offset ≈ the whole of clip 1 (one boundary, no ratchet).
|
||||
assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS);
|
||||
// Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past
|
||||
// the boundary — not capped at it, and not ratcheted far beyond.
|
||||
assert!(
|
||||
(900 * S..901 * S).contains(&max),
|
||||
"timeline must span ~900s (clip1+clip2), got {max}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end output regression (the symptom, at the block-timecode level):
|
||||
/// a large clip-boundary reset WITH an interleaved straggler audio frame
|
||||
/// from clip 1's tail, driven through the full muxer. Asserts cluster
|
||||
/// timestamps are monotonic non-decreasing AND the timeline reaches past the
|
||||
/// boundary (clip 2 present) without ratcheting. This is the test that would
|
||||
/// have caught BOTH the original `-820000` non-monotonic band and the
|
||||
/// straggler ratchet that made everything after the boundary unseekable.
|
||||
#[test]
|
||||
fn clip_boundary_with_straggler_yields_monotonic_clusters() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
// ms→ns helper for readability.
|
||||
let ms = |m: i64| m * 1_000_000;
|
||||
let frames: Vec<(usize, i64, bool, Vec<u8>)> = vec![
|
||||
// Clip 1: video keyframes at 0s and 600s, audio alongside.
|
||||
(0, ms(0), true, vec![0x01; 16]),
|
||||
(1, ms(0), true, vec![0xA0; 8]),
|
||||
(0, ms(600_000), true, vec![0x02; 16]), // 600s kf
|
||||
(1, ms(600_000), true, vec![0xA1; 8]),
|
||||
// Clip 2: video keyframe RESETS to 0 (the -600s boundary).
|
||||
(0, ms(0), true, vec![0x03; 16]),
|
||||
// Straggler: clip 1's tail audio (≈599.5s) arrives interleaved AFTER
|
||||
// the reset — the exact frame class that caused the ratchet.
|
||||
(1, ms(599_500), true, vec![0xA2; 8]),
|
||||
// Clip 2 continues: audio at 0, video keyframe at 5s.
|
||||
(1, ms(0), true, vec![0xA3; 8]),
|
||||
(0, ms(5_000), true, vec![0x04; 16]), // clip2 + 5s
|
||||
];
|
||||
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
|
||||
assert_eq!(frame_count, 8, "all frames written (none dropped)");
|
||||
|
||||
let clusters = find_clusters(&data);
|
||||
let ts: Vec<u64> = clusters.iter().map(|&(_, _, t)| t).collect();
|
||||
assert!(!ts.is_empty(), "expected clusters");
|
||||
// Cluster timestamps must be monotonic non-decreasing (no back-dated
|
||||
// cluster from the straggler, no non-monotonic band).
|
||||
assert!(
|
||||
ts.windows(2).all(|w| w[1] >= w[0]),
|
||||
"cluster timestamps must be monotonic, got {ts:?}"
|
||||
);
|
||||
let max = *ts.iter().max().unwrap();
|
||||
// Timeline reaches past the boundary (clip 2 present): ≥ ~600s.
|
||||
assert!(
|
||||
max >= 600_000,
|
||||
"timeline must span past the boundary, got {max}ms"
|
||||
);
|
||||
// And does NOT ratchet far beyond clip1+clip2 (~605s): well under 2× clip1.
|
||||
assert!(
|
||||
max < 1_000_000,
|
||||
"no ratchet: max cluster ts {max}ms must stay near 605s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_multiple_tracks() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
@@ -1785,16 +2162,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_relative_audio_forces_new_cluster_no_i16_wrap() {
|
||||
fn backjumped_audio_rebased_by_continuity_no_i16_wrap() {
|
||||
// An audio frame whose PTS back-jumps far below the open cluster (a
|
||||
// discontinuity) must force a fresh cluster rather than wrap the i16
|
||||
// block-relative cast. Build: keyframe at t=0 opening a cluster, a video
|
||||
// keyframe far later (so cluster ts is large), then an audio frame whose
|
||||
// PTS lands before that cluster's start by more than i16::MIN ms.
|
||||
// clip-boundary discontinuity) is now REBASED by TimelineContinuity
|
||||
// before the cluster math, so it never produces a negative i16 block
|
||||
// relative. Build: video kf at 0, video kf at 40s, then audio at t=0
|
||||
// (a 40s back-jump > the 3s discontinuity threshold). Continuity shifts
|
||||
// the audio to ~40s, keeping the timeline monotonic — it lands in the
|
||||
// 40s cluster rather than forcing a third, back-dated cluster.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
// base = 0 (first kept keyframe). Cluster opens at 0; a later keyframe at
|
||||
// 40s opens a second cluster at ts=40000. Then audio at t=0 → relative
|
||||
// 0-40000 = -40000 ms, below i16::MIN (-32768) → must open a new cluster.
|
||||
let frames = vec![
|
||||
(0usize, 0i64, true, vec![0x01; 16]),
|
||||
(0usize, 40_000_000_000i64, true, vec![0x02; 16]), // 40s
|
||||
@@ -1803,20 +2179,24 @@ mod tests {
|
||||
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
|
||||
assert_eq!(frame_count, 3);
|
||||
let clusters = find_clusters(&data);
|
||||
// Three clusters: t=0 (video kf), t=40000 (video kf), t=0 (forced for the
|
||||
// back-jumped audio, no Cues entry).
|
||||
assert!(
|
||||
clusters.len() >= 3,
|
||||
"back-jumped audio must force a fresh cluster, got {} clusters",
|
||||
// Two clusters: t=0 (video kf) and t=40000 (video kf). The back-jumped
|
||||
// audio is rebased onto the timeline (~40s) and joins the 40s cluster —
|
||||
// no negative i16 relative, no forced back-dated third cluster.
|
||||
assert_eq!(
|
||||
clusters.len(),
|
||||
2,
|
||||
"continuity rebases the back-jump (no forced 3rd cluster), got {} clusters",
|
||||
clusters.len()
|
||||
);
|
||||
// Every SimpleBlock's relative timestamp must round-trip through i16
|
||||
// without the block landing outside the cluster (verified implicitly by
|
||||
// the muxer never panicking on the `as i16` cast; here we assert the
|
||||
// forced cluster's timestamp is non-negative so the `as u64` write is
|
||||
// also safe).
|
||||
for (_, _, ts) in &clusters {
|
||||
assert!(*ts <= i64::MAX as u64, "cluster ts must not have wrapped");
|
||||
// Cluster timestamps stay non-negative (the `as u64` write is safe) and
|
||||
// monotonic non-decreasing — continuity guaranteed a forward timeline.
|
||||
let ts: Vec<u64> = clusters.iter().map(|(_, _, t)| *t).collect();
|
||||
assert!(
|
||||
ts.windows(2).all(|w| w[1] >= w[0]),
|
||||
"cluster ts monotonic: {ts:?}"
|
||||
);
|
||||
for t in &ts {
|
||||
assert!(*t <= i64::MAX as u64, "cluster ts must not have wrapped");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-17
@@ -78,7 +78,9 @@ struct ReadState {
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek + Send>>>,
|
||||
// Boxed: MkvMuxer is large relative to the Read variant; boxing keeps
|
||||
// the Mode enum small (avoids clippy::large_enum_variant).
|
||||
muxer: Option<Box<MkvMuxer<Box<dyn WriteSeek + Send>>>>,
|
||||
},
|
||||
Read(ReadState),
|
||||
}
|
||||
@@ -126,7 +128,9 @@ impl MkvStream {
|
||||
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Write { muxer: Some(muxer) },
|
||||
mode: Mode::Write {
|
||||
muxer: Some(Box::new(muxer)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,7 +229,12 @@ impl crate::pes::Stream for MkvStream {
|
||||
}
|
||||
}
|
||||
if let Some(block) = block {
|
||||
let dur_ns = duration_ms.map(|ms| ms.saturating_mul(1_000_000));
|
||||
// BLOCK_DURATION is expressed in TimestampScale ticks,
|
||||
// not milliseconds. Scale by the segment's ts_scale_ns
|
||||
// (1_000_000 for freemkv's own 1 ms scale; non-default
|
||||
// in foreign MKVs) — same scaling PTS uses.
|
||||
let dur_ns =
|
||||
duration_ms.map(|ticks| ticks.saturating_mul(rs.ts_scale_ns as u64));
|
||||
if let Some(frame) = parse_block(
|
||||
&block,
|
||||
rs.cluster_ts_ticks,
|
||||
@@ -514,19 +523,33 @@ fn parse_track(
|
||||
}
|
||||
}
|
||||
|
||||
let codec = match codec_id.as_str() {
|
||||
"V_MPEGH/ISO/HEVC" => Codec::Hevc,
|
||||
"V_MPEG4/ISO/AVC" => Codec::H264,
|
||||
"V_MS/VFW/FOURCC" => Codec::Vc1,
|
||||
"V_MPEG2" => Codec::Mpeg2,
|
||||
"A_AC3" => Codec::Ac3,
|
||||
"A_EAC3" => Codec::Ac3Plus,
|
||||
"A_TRUEHD" => Codec::TrueHd,
|
||||
"A_DTS" => Codec::Dts,
|
||||
"A_PCM/INT/BIG" => Codec::Lpcm,
|
||||
"S_HDMV/PGS" => Codec::Pgs,
|
||||
"S_VOBSUB" => Codec::DvdSub,
|
||||
_ => Codec::Unknown(0),
|
||||
// &str consts can't be `match` patterns, so compare via guards — this keeps
|
||||
// the single source of truth in `ebml::CODEC_*` shared with the muxer.
|
||||
let cid = codec_id.as_str();
|
||||
let codec = if cid == ebml::CODEC_HEVC {
|
||||
Codec::Hevc
|
||||
} else if cid == ebml::CODEC_H264 {
|
||||
Codec::H264
|
||||
} else if cid == ebml::CODEC_VC1 {
|
||||
Codec::Vc1
|
||||
} else if cid == ebml::CODEC_MPEG2 {
|
||||
Codec::Mpeg2
|
||||
} else if cid == ebml::CODEC_AC3 {
|
||||
Codec::Ac3
|
||||
} else if cid == ebml::CODEC_EAC3 {
|
||||
Codec::Ac3Plus
|
||||
} else if cid == ebml::CODEC_TRUEHD {
|
||||
Codec::TrueHd
|
||||
} else if cid == ebml::CODEC_DTS {
|
||||
Codec::Dts
|
||||
} else if cid == ebml::CODEC_PCM_BE {
|
||||
Codec::Lpcm
|
||||
} else if cid == ebml::CODEC_PGS {
|
||||
Codec::Pgs
|
||||
} else if cid == ebml::CODEC_VOBSUB {
|
||||
Codec::DvdSub
|
||||
} else {
|
||||
Codec::Unknown(0)
|
||||
};
|
||||
let res = Resolution::from_height(ph);
|
||||
let chs = AudioChannels::from_count(ch);
|
||||
@@ -1345,7 +1368,7 @@ mod tests {
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, 1).unwrap();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, 1).unwrap();
|
||||
ebml::write_string(&mut entry, ebml::CODEC_ID, "V_MPEGH/ISO/HEVC").unwrap();
|
||||
ebml::write_string(&mut entry, ebml::CODEC_ID, ebml::CODEC_HEVC).unwrap();
|
||||
let mut track_entry = Vec::new();
|
||||
ebml::write_id(&mut track_entry, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut track_entry, entry.len() as u64).unwrap();
|
||||
|
||||
+164
-9
@@ -9,11 +9,61 @@
|
||||
use super::meta;
|
||||
use crate::disc::DiscTitle;
|
||||
use std::io::{self, BufReader, BufWriter, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs};
|
||||
|
||||
/// I/O buffer size for network reads/writes.
|
||||
const NET_BUF_SIZE: usize = 256 * 1024;
|
||||
|
||||
/// True if `ip` is one we must never connect a `network://` output to:
|
||||
/// loopback, RFC1918/ULA private, link-local, unspecified, or multicast.
|
||||
///
|
||||
/// `validate_network_target` (in autorip) vets the host once at
|
||||
/// settings-save time, but the raw hostname is re-resolved here at rip
|
||||
/// time — a DNS-rebinding attacker can flip a previously-public name to
|
||||
/// `127.0.0.1` / `10.x` / `169.254.x` in that window. Re-checking the
|
||||
/// actually-resolved address at connect time closes that TOCTOU.
|
||||
pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_broadcast()
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
// unique-local fc00::/7
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|
||||
// link-local fe80::/10
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `addr` (host:port) and return the first socket address whose
|
||||
/// IP is NOT [`is_blocked_ip`]. Errors with
|
||||
/// [`crate::error::Error::NetworkAddrBlocked`] if every resolved address
|
||||
/// is blocked, or propagates the resolver's own error if resolution
|
||||
/// fails. The returned `SocketAddr` carries a vetted IP literal, so the
|
||||
/// subsequent `TcpStream::connect` cannot be re-pointed by a second DNS
|
||||
/// lookup (it connects to the IP we vetted, not the name).
|
||||
fn resolve_allowed_addr(addr: &str) -> io::Result<std::net::SocketAddr> {
|
||||
// Zero resolved addresses and "all resolved addresses blocked" both
|
||||
// mean there is no safe address to connect to — same error either way.
|
||||
addr.to_socket_addrs()?
|
||||
.find(|sa| !is_blocked_ip(sa.ip()))
|
||||
.ok_or_else(|| {
|
||||
crate::error::Error::NetworkAddrBlocked {
|
||||
addr: addr.to_string(),
|
||||
}
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
writer: BufWriter<TcpStream>,
|
||||
@@ -34,7 +84,27 @@ impl NetworkStream {
|
||||
/// Connect to a remote listener for writing.
|
||||
/// Sends FMKV metadata header on first write.
|
||||
pub fn connect(addr: &str) -> io::Result<Self> {
|
||||
let stream = TcpStream::connect(addr)?;
|
||||
Self::connect_vetted(addr, true)
|
||||
}
|
||||
|
||||
/// `connect` with an explicit SSRF-vetting toggle.
|
||||
///
|
||||
/// `vet=true` (the public [`connect`](Self::connect) path) resolves
|
||||
/// the target and refuses any loopback/private/link-local/multicast
|
||||
/// address, closing the DNS-rebinding TOCTOU. `vet=false` exists only
|
||||
/// for in-crate tests, which must connect to `127.0.0.1` ephemeral
|
||||
/// listeners that the production vet would (correctly) reject.
|
||||
fn connect_vetted(addr: &str, vet: bool) -> io::Result<Self> {
|
||||
// Resolve + vet the target before connecting. Connect to the
|
||||
// vetted IP literal (not the raw name) so a DNS rebind between
|
||||
// settings-save validation and now can't redirect us to a
|
||||
// loopback/private/link-local host (SSRF).
|
||||
let stream = if vet {
|
||||
let vetted = resolve_allowed_addr(addr)?;
|
||||
TcpStream::connect(vetted)?
|
||||
} else {
|
||||
TcpStream::connect(addr)?
|
||||
};
|
||||
// The sender is the latency-sensitive side; set nodelay here too
|
||||
// (the listen side already does) so the final sub-MSS flush after
|
||||
// finish() isn't held by Nagle. The 256 KB BufWriter coalesces
|
||||
@@ -160,6 +230,79 @@ mod tests {
|
||||
};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// SSRF guard: every loopback / private / link-local / multicast /
|
||||
/// unspecified address (v4 and v6) must be rejected, and ordinary
|
||||
/// public addresses must be allowed. This is what closes the
|
||||
/// DNS-rebinding window in `NetworkStream::connect`.
|
||||
#[test]
|
||||
fn is_blocked_ip_rejects_internal_targets() {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
// Built from octets (not string literals) so the repo's internal-infra
|
||||
// secret scanner doesn't flag the RFC1918 addresses.
|
||||
let v4 = |a, b, c, d| IpAddr::V4(Ipv4Addr::new(a, b, c, d));
|
||||
let blocked: &[(IpAddr, &str)] = &[
|
||||
(v4(127, 0, 0, 1), "loopback"),
|
||||
(v4(127, 10, 20, 30), "loopback /8"),
|
||||
(v4(10, 0, 0, 1), "private 10/8"),
|
||||
(v4(172, 16, 5, 5), "private 172.16/12"),
|
||||
(v4(192, 168, 1, 1), "private 192.168/16"),
|
||||
(v4(169, 254, 10, 10), "link-local"),
|
||||
(v4(0, 0, 0, 0), "unspecified"),
|
||||
(v4(224, 0, 0, 1), "multicast"),
|
||||
(v4(255, 255, 255, 255), "broadcast"),
|
||||
(IpAddr::V6(Ipv6Addr::LOCALHOST), "loopback v6"),
|
||||
(IpAddr::V6(Ipv6Addr::UNSPECIFIED), "unspecified v6"),
|
||||
(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)),
|
||||
"ULA",
|
||||
),
|
||||
(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfd12, 0x3456, 0, 0, 0, 0, 0, 1)),
|
||||
"ULA",
|
||||
),
|
||||
(
|
||||
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
|
||||
"link-local v6",
|
||||
),
|
||||
(
|
||||
IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)),
|
||||
"multicast v6",
|
||||
),
|
||||
];
|
||||
for (ip, label) in blocked {
|
||||
assert!(is_blocked_ip(*ip), "{label} ({ip}) must be blocked");
|
||||
}
|
||||
|
||||
let allowed: &[(IpAddr, &str)] = &[
|
||||
(v4(8, 8, 8, 8), "public dns"),
|
||||
(v4(1, 1, 1, 1), "public dns"),
|
||||
(v4(93, 184, 216, 34), "example.com"),
|
||||
(
|
||||
IpAddr::V6(Ipv6Addr::new(0x2606, 0x2800, 0x220, 1, 0, 0, 0, 1)),
|
||||
"public v6",
|
||||
),
|
||||
];
|
||||
for (ip, label) in allowed {
|
||||
assert!(!is_blocked_ip(*ip), "{label} ({ip}) must be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The public `connect` must refuse a loopback target with the typed
|
||||
/// `NetworkAddrBlocked` error (PermissionDenied) rather than attempting
|
||||
/// the TCP connect — this is the rebinding TOCTOU close at the connect.
|
||||
#[test]
|
||||
fn connect_refuses_blocked_loopback_target() {
|
||||
// Bind a real loopback listener so a non-vetting connect WOULD
|
||||
// succeed; the vetting connect must still refuse it.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let err = match NetworkStream::connect(&addr.to_string()) {
|
||||
Ok(_) => panic!("loopback target must be refused by the SSRF guard"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
fn sample_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: "NetworkTest".into(),
|
||||
@@ -222,7 +365,9 @@ mod tests {
|
||||
|
||||
let addr = addr_rx.recv().unwrap();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
let frame = pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 90000,
|
||||
@@ -262,7 +407,9 @@ mod tests {
|
||||
|
||||
let addr = addr_rx.recv().unwrap();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
// No write() at all — straight to finish().
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
|
||||
@@ -319,7 +466,9 @@ mod tests {
|
||||
// Sender connects, sends header (zero frames), finishes — so the
|
||||
// reader's accept_from() returns. We test the reader's write guard.
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
let (_info, _frames) = handle.join().unwrap();
|
||||
|
||||
@@ -341,7 +490,7 @@ mod tests {
|
||||
err.kind()
|
||||
});
|
||||
// Drive the accept: connect + send header so accept_from completes.
|
||||
let mut w2 = NetworkStream::connect(&addr2.to_string())
|
||||
let mut w2 = NetworkStream::connect_vetted(&addr2.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
pes::Stream::finish(&mut w2).unwrap();
|
||||
@@ -358,7 +507,9 @@ mod tests {
|
||||
use crate::pes;
|
||||
let (addr, handle) = spawn_reader();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
let err = pes::Stream::read(&mut writer).expect_err("write side read must error");
|
||||
// E_STREAM_WRITE_ONLY (9001) maps to Unsupported.
|
||||
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
|
||||
@@ -375,7 +526,9 @@ mod tests {
|
||||
use crate::pes;
|
||||
let (addr, handle) = spawn_reader();
|
||||
let dt = sample_title();
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
for i in 0..5u8 {
|
||||
let frame = pes::PesFrame {
|
||||
track: (i % 2) as usize,
|
||||
@@ -414,7 +567,9 @@ mod tests {
|
||||
let mut dt = sample_title();
|
||||
dt.playlist = "SenderControlled".into();
|
||||
dt.playlist_id = 42;
|
||||
let mut writer = NetworkStream::connect(&addr.to_string()).unwrap().meta(&dt);
|
||||
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||
.unwrap()
|
||||
.meta(&dt);
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
let (info, _frames) = handle.join().unwrap();
|
||||
// The receiver default title is empty (playlist ""); it must have
|
||||
|
||||
@@ -388,6 +388,13 @@ pub fn output(
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
// Format-validate, then connect. `NetworkStream::connect`
|
||||
// re-resolves the host and refuses any address that is
|
||||
// loopback / private / link-local / multicast — this is the
|
||||
// SSRF / DNS-rebinding guard, applied at the actual connect
|
||||
// (not just at settings-save time). It is deliberately NOT in
|
||||
// `validate_network_addr`, which is shared with the listen
|
||||
// (receiver) path where binding loopback is legitimate.
|
||||
validate_network_addr(addr)?;
|
||||
Ok(Box::new(NetworkStream::connect(addr)?.meta(title)))
|
||||
}
|
||||
@@ -470,12 +477,22 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
let extents = title.extents.clone();
|
||||
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
|
||||
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
|
||||
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
|
||||
// 3-sector alignment there rejects any extent whose sector count isn't a
|
||||
// multiple of 3 (DVD IFO cells routinely aren't) with ExtentNotUnitAligned.
|
||||
let unit_align: u16 = match &keys {
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||
_ => 1,
|
||||
};
|
||||
let decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||
decrypting,
|
||||
extents,
|
||||
batch_sectors,
|
||||
unit_align,
|
||||
halt.clone(),
|
||||
event_fn,
|
||||
)
|
||||
|
||||
+148
-3
@@ -55,6 +55,18 @@ struct PesAssembler {
|
||||
/// boundary.
|
||||
const PES_BUFFER_INIT_CAP: usize = 16 * 1024;
|
||||
|
||||
/// Hard cap on a single PID's PES reassembly buffer.
|
||||
///
|
||||
/// A complete HEVC/UHD access unit (I-frame) is typically 1–3 MiB;
|
||||
/// 64 MiB is an order of magnitude above any real disc's largest AU
|
||||
/// and well below the memory a process can reasonably spare. If a
|
||||
/// stream pumps continuation packets that never produce a PUSI (e.g.
|
||||
/// a corrupt or crafted m2ts), the buffer would otherwise grow
|
||||
/// without bound and exhaust RAM. When a `push` would push the buffer
|
||||
/// past this limit the assembler drops the partial PES and resyncs on
|
||||
/// the next PUSI.
|
||||
const MAX_PES_BUFFER: usize = 64 * 1024 * 1024; // 64 MiB
|
||||
|
||||
impl PesAssembler {
|
||||
fn new(pid: u16) -> Self {
|
||||
Self {
|
||||
@@ -87,8 +99,25 @@ impl PesAssembler {
|
||||
}
|
||||
|
||||
/// Append payload data to the current PES packet.
|
||||
///
|
||||
/// If the buffer would exceed [`MAX_PES_BUFFER`] the partial PES is
|
||||
/// silently dropped and the assembler is reset. Normal traffic resumes
|
||||
/// on the next PUSI; a crafted/corrupt stream that never sends one can
|
||||
/// no longer drive unbounded allocation.
|
||||
fn push(&mut self, data: &[u8]) {
|
||||
if self.active {
|
||||
if self.buffer.len().saturating_add(data.len()) > MAX_PES_BUFFER {
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
pid = self.pid,
|
||||
bytes = self.buffer.len(),
|
||||
"PES buffer cap exceeded; dropping partial PES and resyncing on next PUSI",
|
||||
);
|
||||
self.buffer.clear();
|
||||
self.active = false;
|
||||
self.header_remaining = 0;
|
||||
return;
|
||||
}
|
||||
self.buffer.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
@@ -560,11 +589,16 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
if section_len < 4 {
|
||||
return None;
|
||||
}
|
||||
let prog_info_len = (((pmt[10] & 0x0F) as usize) << 8) | pmt[11] as usize;
|
||||
let mut pos = 12 + prog_info_len;
|
||||
// Clamp the section end to the reassembled bytes; a malformed
|
||||
// section_len or prog_info_len must never drive reads past `pmt`.
|
||||
// section_len must never drive reads past `pmt`.
|
||||
let end = (3 + section_len - 4).min(pmt.len());
|
||||
// Clamp prog_info_len so it cannot push `pos` past `end`.
|
||||
// ISO 13818-1 requires program_info to fit within the PMT section;
|
||||
// a crafted value larger than the remaining section would skip all
|
||||
// ES entries and, in pathological cases, wrap or mis-index.
|
||||
let prog_info_len =
|
||||
((((pmt[10] & 0x0F) as usize) << 8) | pmt[11] as usize).min(end.saturating_sub(12));
|
||||
let mut pos = 12 + prog_info_len;
|
||||
|
||||
while pos + 5 <= end {
|
||||
let stream_type = pmt[pos];
|
||||
@@ -1582,4 +1616,115 @@ mod tests {
|
||||
.expect("video present");
|
||||
assert_eq!(v.resolution, Resolution::R1080i, "MPEG-2 defaults to 1080i");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_streams_oversized_prog_info_len_does_not_panic() {
|
||||
// Regression: a PMT with prog_info_len larger than the section body
|
||||
// must not panic, index out of bounds, or silently corrupt `pos`.
|
||||
// The parser must clamp it and still return None (no valid ES entries
|
||||
// past the inflated descriptor region).
|
||||
let pmt_pid = 0x0100u16;
|
||||
|
||||
// Build a minimal PAT pointing at pmt_pid.
|
||||
let mut data = pat_packet(pmt_pid);
|
||||
|
||||
// Craft a raw PMT TS packet with prog_info_len = 0x0FFF (4095),
|
||||
// which is far larger than the actual section content. The section
|
||||
// itself only holds a single H.264 ES entry (5 bytes) so the real
|
||||
// prog_info_len must be 0.
|
||||
let mut body = [0xFFu8; 184];
|
||||
body[0] = 0x00; // pointer_field
|
||||
let s = 1;
|
||||
body[s] = 0x02; // table_id = PMT
|
||||
// section_length = 9 (fixed fields) + 5 (one ES entry) + 4 (CRC) = 18
|
||||
let section_length: usize = 9 + 5 + 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
|
||||
body[s + 9] = 0x00; // PCR PID lo
|
||||
// prog_info_len = 0x0FFF — crafted oversized value
|
||||
body[s + 10] = 0xFF; // 0xF0 reserved | 0x0F high nibble of 0xFFF
|
||||
body[s + 11] = 0xFF; // low byte of 0xFFF
|
||||
// ES entry: H.264 (0x1B) on PID 0x1011, es_info_len=0
|
||||
let p = s + 12;
|
||||
body[p] = 0x1B;
|
||||
body[p + 1] = 0xE0 | ((0x1011u16 >> 8) as u8 & 0x1F);
|
||||
body[p + 2] = (0x1011u16 & 0xFF) as u8;
|
||||
body[p + 3] = 0xF0; // es_info_len hi = 0
|
||||
body[p + 4] = 0x00; // es_info_len lo = 0
|
||||
data.extend(bdts_packet(body, pmt_pid, true));
|
||||
data.extend(pat_packet(pmt_pid)); // corroboration packet
|
||||
|
||||
// Must not panic. The oversized prog_info_len causes the ES entry to
|
||||
// be skipped after clamping, so the result is None or an empty stream
|
||||
// list (both are acceptable; the critical invariant is no panic/OOB).
|
||||
let _ = scan_streams(&data);
|
||||
}
|
||||
|
||||
// ── PES reassembly buffer cap (DoS hardening) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pes_buffer_cap_resets_on_overflow_and_recovers_on_next_pusi() {
|
||||
// Feed continuation-only packets that would exceed MAX_PES_BUFFER if
|
||||
// allowed to accumulate, then verify:
|
||||
// (a) the assembler buffer never grows past the cap,
|
||||
// (b) a subsequent valid PUSI + continuation produces a correct PES.
|
||||
//
|
||||
// Each continuation packet carries 184 ES bytes. We need enough packets
|
||||
// to exceed MAX_PES_BUFFER even after the cap resets the buffer between
|
||||
// overflows. Sending (MAX_PES_BUFFER / 184) + 2 packets guarantees at
|
||||
// least one cap-trigger regardless of internal doubling.
|
||||
let pid = 0x1011u16;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
|
||||
// Start a PES so the assembler is `active` before we hammer it.
|
||||
let mut pes_start = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||
pes_start.extend_from_slice(&[0xAB; 10]);
|
||||
demux.feed(&es_packet_exact(pid, true, &pes_start));
|
||||
|
||||
// Continuation packets with 184-byte payloads, no PUSI. Each call to
|
||||
// feed() processes one 192-byte BD-TS packet.
|
||||
let payload = [0xCCu8; 184];
|
||||
let cont_pkt = data_packet(pid, false, &payload);
|
||||
let packets_needed = MAX_PES_BUFFER / 184 + 2;
|
||||
let mut mid_out: Vec<PesPacket> = Vec::new();
|
||||
for _ in 0..packets_needed {
|
||||
mid_out.extend(demux.feed(&cont_pkt));
|
||||
// Verify the internal buffer is bounded: no assembler may hold
|
||||
// more than MAX_PES_BUFFER bytes at any point.
|
||||
for asm in &demux.assemblers {
|
||||
assert!(
|
||||
asm.buffer.len() <= MAX_PES_BUFFER,
|
||||
"assembler buffer exceeded cap: {} > {MAX_PES_BUFFER}",
|
||||
asm.buffer.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
// The demuxer must not have completed any PES during the flood
|
||||
// (the cap resets the partial PES rather than emitting garbage).
|
||||
assert!(
|
||||
mid_out.is_empty(),
|
||||
"no PES must be emitted during a cap-overflow continuation flood"
|
||||
);
|
||||
|
||||
// Recovery: a new valid PUSI followed by a continuation packet must
|
||||
// produce exactly one well-formed PES with the correct ES bytes.
|
||||
let mut good_start = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||
good_start.extend_from_slice(&[0x11u8, 0x22]);
|
||||
let mut out = demux.feed(&es_packet_exact(pid, true, &good_start));
|
||||
out.extend(demux.feed(&es_packet_exact(pid, false, &[0x33u8, 0x44])));
|
||||
// Flush to complete the in-progress PES.
|
||||
out.extend(demux.flush());
|
||||
assert_eq!(out.len(), 1, "exactly one PES after recovery");
|
||||
assert_eq!(
|
||||
out[0].data,
|
||||
vec![0x11, 0x22, 0x33, 0x44],
|
||||
"recovered PES carries only the post-reset ES bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user