Parse MKV lacing, route by real TrackNumber, honour NAL length size and edit lists

Four conformance defects in the read paths, two of them silent corruption.

**Lacing was ignored entirely.** RFC 9559 §10.2 defines Xiph, EBML and
fixed-size lacing, where one Block carries several frames; the reader took the
Block payload verbatim, so a laced Block became a single "frame" consisting of a
lacing header followed by concatenated frames — garbage to the codec parser, no
error. Audio tracks from other muxers commonly use lacing, so an ordinary
foreign MKV was silently mangled.

All three modes are now parsed: Xiph 255-run sizes including the trailing-zero
rule for exact multiples of 255, EBML unsigned first size plus SIGNED VINT deltas
with the 2^((7*n)-1)-1 bias of §10.3.3, and fixed-size even division, with the
last frame's size deduced from the remainder. Laced timestamps follow §10.3.5:
the first frame takes the Block timestamp and the rest are spaced by the track's
DefaultDuration, else BlockDuration/count, else shared with a warn.

Parsing was chosen over refusing because refusal would leave freemkv unable to
remux common foreign audio at all, and each mode is about fifteen lines.

A malformed lacing header now raises a NEW code, E_MKV_LACING_INVALID = 9052,
deliberately NOT MkvInvalid — because is_skippable_title_stub classifies
MkvInvalid as a skippable nav stub, so reusing it would have recreated the exact
conflation that is still open as a separate finding. A test asserts the new code
is not skippable.

**TrackNumber was assumed to be 1..N in TrackEntry order.** RFC 9559 §5.1.4.1.1
only requires it to be non-zero and unique, so sparse or unordered numbers are
legal. Block routing and codec_private both computed track + 1. A real
TrackNumber map is now built, recorded only for TrackEntries that yield a stream
so dropped track types no longer shift the mapping.

Verified red here independently, and the failure mode is worse than mis-routing:
with track + 1 restored, a buttons track's payload was attributed to the AUDIO
stream — wrong payload into the wrong codec parser.

**The NAL length prefix was hardcoded to 4 bytes.** lengthSizeMinusOne lives in
avcC byte 4 and hvcC byte 21 (ISO/IEC 14496-15 §5.3.3.1.2, §8.3.3.1.2) and was
never read, so a source declaring 1- or 2-byte prefixes had its raw prefixed
bytes emitted verbatim with no start codes. All four conversion sites now derive
the width from the track's own configuration record.

**Edit lists were ignored.** No edts/elst was parsed, so the presentation
timeline an edit list defines (ISO/IEC 14496-12 §8.6.5/§8.6.6) was dropped —
which is how encoder delay is normally expressed. Leading empty edits and the
first media edit's media_time are now applied to both dts and pts, with the movie
vs media timescale distinction respected. A list needing more than a constant
shift applies the leading edit and warns rather than presenting the result as
faithful.

17 tests. I reproduced the lacing mutant independently: returning the body whole
kills five of them, including the exact-payload and malformed-header cases.

Still open and deliberately untouched: the MkvInvalid / is_skippable_title_stub
conflation across ~20 reader raise sites. It is a cross-cutting error.rs change
and E_MKV_LACING_INVALID is the template for it.
This commit is contained in:
Matthew Jackson
2026-07-29 21:53:19 -07:00
parent 9527bc1e13
commit 4fcd28b487
7 changed files with 1347 additions and 88 deletions
+51 -4
View File
@@ -23,7 +23,9 @@
//! The sink does NOT touch the MKV mux path; it is purely additive.
use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream};
use crate::mux::hevc::{append_length_prefixed_as_annex_b, avcc_to_annex_b, hvcc_to_annex_b};
use crate::mux::hevc::{
append_length_prefixed_as_annex_b_sized, avcc_to_annex_b, hvcc_to_annex_b, nal_length_size,
};
use crate::mux::timeline::TimelineContinuity;
use crate::pes::{PesFrame, Stream};
use std::fs::File;
@@ -207,12 +209,17 @@ impl EsWriter for PassthroughWriter {
}
}
/// HEVC/H.264 writer: reframes 4-byte-length-prefixed NALs (the hvcC/avcC form
/// the parsers emit) into Annex-B, prepending the parameter sets once.
/// HEVC/H.264 writer: reframes length-prefixed NALs (the hvcC/avcC form the
/// parsers emit) into Annex-B, prepending the parameter sets once.
struct AnnexBWriter {
/// Annex-B-framed VPS/SPS/PPS (or SPS/PPS), parsed from the hvcC/avcC.
params: Vec<u8>,
wrote_params: bool,
/// Octets per NAL length prefix, from the configuration record's
/// `lengthSizeMinusOne` (ISO/IEC 14496-15). NOT assumed to be 4: a legal
/// avcC/hvcC may declare 1 or 2, and reading those as u32-BE parses no NALs
/// at all, so the raw prefixed bytes would be emitted as if already Annex B.
length_size: usize,
}
impl AnnexBWriter {
@@ -223,6 +230,7 @@ impl AnnexBWriter {
Self {
params,
wrote_params: false,
length_size: nal_length_size(codec, codec_private),
}
}
}
@@ -244,7 +252,7 @@ impl EsWriter for AnnexBWriter {
// zero-length NALs and drops a truncated trailing NAL without panicking,
// rather than `break`ing on the first zero-length NAL.
let mut scratch = Vec::with_capacity(f.data.len() + (f.data.len() / 32) + 4);
append_length_prefixed_as_annex_b(&mut scratch, &f.data);
append_length_prefixed_as_annex_b_sized(&mut scratch, &f.data, self.length_size);
w.write_all(&scratch)?;
n += scratch.len();
Ok(n)
@@ -982,6 +990,45 @@ mod tests {
);
}
/// Regression (silent corruption): the sink reframed every frame as if the
/// NAL length prefixes were 4 octets wide. ISO/IEC 14496-15 §5.3.3.1.2 lets
/// an avcC declare `lengthSizeMinusOne = 1` (2-octet prefixes); reading those
/// as one u32-BE gives an absurd length, so nothing parsed and the raw
/// prefixed bytes were written to the `demux://` ES verbatim — no start
/// codes, undecodable video, and no error anywhere.
#[test]
fn annexb_writer_honours_the_records_declared_nal_length_size() {
// avcC with byte 4 = 0xFD → lengthSizeMinusOne 1 → 2-octet prefixes.
// numSPS = 1 (0xE1), SPS len 2 = [0x67 0x42], numPPS = 1, PPS len 1.
let rec = [
1, 0x42, 0x00, 0x1F, 0xFD, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68,
];
assert_eq!(nal_length_size(Codec::H264, Some(&rec)), 2);
let mut w = AnnexBWriter::new(Codec::H264, Some(&rec));
let mut out = Vec::new();
let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
// Two NALs with 2-octet length prefixes.
data: vec![0, 2, 0xAA, 0xBB, 0, 3, 0x01, 0x02, 0x03],
duration_ns: None,
};
w.write_frame(&mut out, &f, 0).unwrap();
assert_eq!(
out,
vec![
0, 0, 0, 1, 0x67, 0x42, // SPS
0, 0, 0, 1, 0x68, // PPS
0, 0, 0, 1, 0xAA, 0xBB, // frame NAL #1
0, 0, 0, 1, 0x01, 0x02, 0x03, // frame NAL #2
],
"2-octet-prefixed NALs must reach the ES as Annex B"
);
}
#[test]
fn annexb_writer_prepends_params_once() {
let rec = [