test: constrain MP4 composition timing, MLP substream directory, and codec-private absence
Mutation testing over src/mux/. No production change — 49 survivors killed, all proven red before green. The MP4 composition-time chain was entirely unconstrained: VideoTiming::ctts, build_ctts and parse_ctts could each return a constant and the suite stayed green. Confirmed on HEAD: build_ctts -> vec![] passes all 1,220 mux tests. A demuxed B-frame title presenting in decode order would have shipped. The cause is a test whose name asserts coverage its body does not deliver — stts_and_ctts_expand builds an stts box and never touches ctts, and write_then_read_round_trip asserts sample sizes and keyframe flags but not one PTS. Same shape as the set_speed forwarding finding, different disguise. mlp_num_substreams / mlp_substr_header_size: every TrueHD fixture in the crate uses one substream and no extraword, so both could return a constant and agree with all of them. These position mlp_parity_ok's window over the AU header, so a constant mis-windows the parity check on exactly the multi-substream AUs that carry 7.1 and Atmos. CodecPrivate absent vs empty: mkv.rs writes Some(bytes) verbatim and omits the element on None (RFC 9559 5.1.4.1.24), so a zero-length Some emits a track header asserting the config IS empty. Four parsers could return Some(vec![]) before any frame. Also: mandatory ISO/IEC 14496-12 boxes (tkhd, vmhd, smhd, dinf, mdhd) could each build empty; HEVC num_extra_slice_header_bits (H.265 7.3.2.3) was never non-zero in any fixture, so the slice-type offset skip was unexercised; chapter names from the disc go straight into <ChapterString> and the & escape must run first; a stray 0x47 in a payload must not latch a TS resync. Documented as equivalent rather than killed: CodecParser::flush and the three parser flush bodies that differ from the mutant only by a tracing call, and DropTally::log_summary.
This commit is contained in:
@@ -190,6 +190,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A dropped ADTS frame is dropped BECAUSE its header failed validation, so
|
||||
/// the very fields a duration would come from (sampling_frequency_index, and
|
||||
/// the 1024-samples-per-AAC-frame constant applied to it) are the ones known
|
||||
/// to be untrustworthy. This gate therefore reports the drop's duration as
|
||||
/// zero rather than deriving a number from a header it has just rejected —
|
||||
/// the honest answer, and the one the count alongside it must be read with.
|
||||
/// A nonzero constant here would report silence that was never measured.
|
||||
#[test]
|
||||
fn dropped_frames_are_counted_but_their_duration_is_not_invented() {
|
||||
let mut parser = AdtsParser::new();
|
||||
// Three frames whose sampling_frequency_index is a reserved value (13),
|
||||
// so `adts_verdict` rejects each one.
|
||||
let mut bad = adts_frame(32);
|
||||
bad[2] = (bad[2] & 0b1100_0011) | (13 << 2);
|
||||
for i in 0..3 {
|
||||
let out = parser.parse(&make_pes(bad.clone(), Some(i * 90_000)));
|
||||
assert!(out.is_empty(), "an invalid ADTS frame is not emitted");
|
||||
}
|
||||
assert_eq!(parser.dropped_frames(), 3, "every drop is counted");
|
||||
assert_eq!(
|
||||
parser.dropped_duration_ns(),
|
||||
0,
|
||||
"the duration comes from the header that just failed validation, so \
|
||||
it is reported as unmeasured rather than guessed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_sample_rate_index_is_dropped() {
|
||||
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
|
||||
|
||||
@@ -200,6 +200,31 @@ mod tests {
|
||||
assert!(!t.is_poisoned());
|
||||
}
|
||||
|
||||
/// The poison verdict is a RATIO — verified drops against every AU seen — so
|
||||
/// the kept count is half of it. `does_not_poison_a_mostly_good_track` above
|
||||
/// records its keeps AFTER the single drop, and `maybe_poison` only runs
|
||||
/// inside `record_drop`, so the keeps are never in the denominator when the
|
||||
/// verdict is actually computed: that test passes even with the kept count
|
||||
/// never incremented. Interleaving them puts the kept count on the critical
|
||||
/// path, where losing it turns the ratio into "verified drops vs verified
|
||||
/// drops" — always >50% — and silently discards a healthy track.
|
||||
#[test]
|
||||
fn interleaved_keeps_are_in_the_poison_denominator() {
|
||||
let mut t = DropTally::new("test");
|
||||
// 2 kept per 1 dropped, well past the minimum-AU gate: a third of the
|
||||
// track is undecodable, which is bad but nowhere near the >50% threshold.
|
||||
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
||||
t.record_kept();
|
||||
t.record_kept();
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
assert!(
|
||||
!t.is_poisoned(),
|
||||
"33% dropped must never poison, at any point in the run"
|
||||
);
|
||||
}
|
||||
assert_eq!(t.dropped_frames(), TRACK_VERDICT_MIN_AUS * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collateral_drops_never_poison_the_track() {
|
||||
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
|
||||
|
||||
@@ -1447,6 +1447,81 @@ mod tests {
|
||||
assert_eq!(h.max_display_mastering_luminance, 10_000_000);
|
||||
}
|
||||
|
||||
/// `num_extra_slice_header_bits` (H.265 §7.3.2.3) is a PPS field, and the
|
||||
/// `slice_reserved_flag[i]` bits it counts sit BETWEEN
|
||||
/// `slice_pic_parameter_set_id` and `slice_type` in the slice segment header
|
||||
/// (§7.3.6.1). Every existing fixture used a PPS with the field == 0, so the
|
||||
/// skip was never exercised: a parser that ignored the field entirely agreed
|
||||
/// with all of them, and would then read `slice_type` from the wrong bit
|
||||
/// offset on any real stream that sets it — mislabelling every picture's
|
||||
/// coding type.
|
||||
#[test]
|
||||
fn nonzero_num_extra_slice_header_bits_shifts_the_slice_type_offset() {
|
||||
use super::super::coding::CodingType;
|
||||
|
||||
// PPS body bits: pps_id ue=0 ('1'), sps_id ue=0 ('1'),
|
||||
// dependent_slice_segments_enabled_flag 0, output_flag_present_flag 0,
|
||||
// num_extra_slice_header_bits u(3).
|
||||
let pps_body = |num_extra: u8| 0b1100_0000u8 | (num_extra << 1);
|
||||
assert_eq!(pps_body(0), 0xC0, "matches the existing zero-extra fixture");
|
||||
assert_eq!(pps_body(3), 0xC6);
|
||||
|
||||
let pps_nal = |num_extra: u8| {
|
||||
let mut v = hevc_nal_header(NAL_PPS).to_vec();
|
||||
v.push(pps_body(num_extra));
|
||||
v
|
||||
};
|
||||
for n in 0..8u8 {
|
||||
assert_eq!(
|
||||
hevc_num_extra_slice_header_bits(&pps_nal(n)),
|
||||
Some(n as u32),
|
||||
"PPS must yield the value it encodes, for every u(3) code point"
|
||||
);
|
||||
}
|
||||
// A PPS truncated to just its 2-byte NAL header carries no field to read,
|
||||
// so the answer is absent — never a defaulted zero.
|
||||
assert_eq!(
|
||||
hevc_num_extra_slice_header_bits(&hevc_nal_header(NAL_PPS)),
|
||||
None
|
||||
);
|
||||
|
||||
// Slice segment header for a non-IRAP VCL NAL (TRAIL_R, type 1):
|
||||
// first_slice_segment_in_pic_flag 1, slice_pic_parameter_set_id ue=0
|
||||
// ('1'), then THREE reserved bits set to 101 (deliberately not zero, so a
|
||||
// parser that reads them instead of skipping them cannot agree), then
|
||||
// slice_type ue(v) = '011' → 2 → I.
|
||||
let slice_body = 0b1_1_101_011u8;
|
||||
assert_eq!(slice_body, 0xEB);
|
||||
|
||||
let nal = |t: u8, body: u8| {
|
||||
let mut v = vec![0x00, 0x00, 0x01];
|
||||
v.extend_from_slice(&hevc_nal_header(t));
|
||||
v.push(body);
|
||||
v
|
||||
};
|
||||
let mut data = nal(NAL_PPS, pps_body(3));
|
||||
data.extend_from_slice(&nal(1, slice_body));
|
||||
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(
|
||||
frames[0].coding.expect("PictureInfo").coding_type(),
|
||||
CodingType::I,
|
||||
"with num_extra=3 the reserved bits are skipped and slice_type reads 2 (I)"
|
||||
);
|
||||
|
||||
// Control: the SAME slice bytes under a PPS declaring num_extra=0 land on
|
||||
// a different slice_type — proving the PPS field, not the slice bytes,
|
||||
// decides the offset.
|
||||
let mut data0 = nal(NAL_PPS, pps_body(0));
|
||||
data0.extend_from_slice(&nal(1, slice_body));
|
||||
let frames0 = HevcParser::new().parse(&make_pes(data0, Some(0)));
|
||||
assert_eq!(
|
||||
frames0[0].coding.expect("PictureInfo").coding_type(),
|
||||
CodingType::B,
|
||||
"num_extra=0 reads slice_type from bit 2 instead → 0 (B)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_populates_measured_coding_type_and_source() {
|
||||
use super::super::coding::CodingType;
|
||||
|
||||
@@ -284,6 +284,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A codec parser's `codec_private()` feeds `DiscStream::codec_private`,
|
||||
/// which the MKV muxer turns directly into the track's `CodecPrivate`
|
||||
/// element (RFC 9559 §5.1.4.1.24): `Some(bytes)` writes an element holding
|
||||
/// exactly those bytes, `None` omits the element entirely. The two are NOT
|
||||
/// interchangeable — a zero-length `CodecPrivate` asserts that the codec's
|
||||
/// initialisation data IS empty, which is not true of any codec, and a
|
||||
/// one-byte one asserts a config no decoder can parse.
|
||||
///
|
||||
/// The gating parsers (ADTS, MPEG audio, FLAC) and the passthrough parser
|
||||
/// extract no configuration at all: they validate and forward frames whose
|
||||
/// configuration is carried in band (ADTS headers, MPEG-1 audio frame
|
||||
/// headers, FLAC frame headers) or supplied by the source container. Having
|
||||
/// derived nothing, the only truthful answer they can give is "absent" —
|
||||
/// any `Some` would be a value they invented. Nothing else in the suite
|
||||
/// distinguished the two, so each of these impls could have returned a
|
||||
/// fabricated `Some` and produced a malformed track header unnoticed.
|
||||
#[test]
|
||||
fn parsers_that_derive_no_config_report_absent_never_an_empty_codec_private() {
|
||||
// Codecs whose parsers do no configuration extraction, paired with a
|
||||
// payload that is a REAL frame of that codec so the gate takes its
|
||||
// keep-path (a rejected frame proves nothing about the config answer).
|
||||
let cases: [(Codec, Vec<u8>); 5] = [
|
||||
// ADTS: syncword FFF1, MPEG-4 AAC-LC, 44.1 kHz, stereo, 7-byte frame.
|
||||
(Codec::Aac, vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0xBF, 0xFC]),
|
||||
// MPEG-1 Layer II, 44.1 kHz, 128 kbit/s, stereo.
|
||||
(Codec::Mp2, vec![0xFF, 0xFD, 0x70, 0x00, 0x00, 0x00]),
|
||||
// MPEG-1 Layer III, 44.1 kHz, 128 kbit/s, stereo.
|
||||
(Codec::Mp3, vec![0xFF, 0xFB, 0x90, 0x00, 0x00, 0x00]),
|
||||
// Not a FLAC frame sync, so the gate passes it through unvalidated —
|
||||
// still the keep path, and still no configuration derived.
|
||||
(Codec::Flac, vec![0x01, 0x02, 0x03, 0x04]),
|
||||
// Opus rides the all-keyframe passthrough parser.
|
||||
(Codec::Opus, vec![0x78, 0x01, 0x02, 0x03]),
|
||||
];
|
||||
|
||||
for (codec, payload) in cases {
|
||||
let mut parser = parser_for_codec(codec, None, false);
|
||||
assert_eq!(
|
||||
parser.codec_private(),
|
||||
None,
|
||||
"{codec:?}: no config before any frame"
|
||||
);
|
||||
parser.parse(&pes(Some(0), payload.clone()));
|
||||
parser.parse(&pes(Some(90_000), payload));
|
||||
assert_eq!(
|
||||
parser.codec_private(),
|
||||
None,
|
||||
"{codec:?}: this parser derives no config, so it must report the \
|
||||
CodecPrivate as ABSENT — an empty or invented Some would be \
|
||||
written into the track header as if it were real"
|
||||
);
|
||||
// Flushing at end of stream must not conjure one either.
|
||||
parser.flush();
|
||||
assert_eq!(parser.codec_private(), None, "{codec:?}: after flush");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_codecs_emit_keyframe_frames() {
|
||||
// PES = frame audio: every frame is independently decodable → keyframe.
|
||||
|
||||
@@ -183,6 +183,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Same contract as the ADTS gate: an MPEG-audio frame is dropped because
|
||||
/// its header is invalid, and the header is exactly where a frame duration
|
||||
/// (samples-per-frame for the layer, divided by the sampling rate) would
|
||||
/// have to come from — see `mpa_verdict`'s doc comment. So the drop's
|
||||
/// duration is reported as zero, not derived from rejected fields.
|
||||
#[test]
|
||||
fn dropped_frames_are_counted_but_their_duration_is_not_invented() {
|
||||
let mut parser = MpegAudioParser::new();
|
||||
// Reserved layer field (00) — rejected per ISO/IEC 11172-3.
|
||||
let mut bad = mp3_frame(32);
|
||||
bad[1] &= !0b0000_0110;
|
||||
for i in 0..3 {
|
||||
let out = parser.parse(&make_pes(bad.clone(), Some(i * 90_000)));
|
||||
assert!(out.is_empty(), "an invalid MPEG-audio frame is not emitted");
|
||||
}
|
||||
assert_eq!(parser.dropped_frames(), 3, "every drop is counted");
|
||||
assert_eq!(
|
||||
parser.dropped_duration_ns(),
|
||||
0,
|
||||
"the duration comes from the header that just failed validation, so \
|
||||
it is reported as unmeasured rather than guessed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_version_field_is_dropped() {
|
||||
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
|
||||
|
||||
@@ -341,6 +341,40 @@ mod tests {
|
||||
assert_eq!(display_set_is_forced(&[]), None);
|
||||
}
|
||||
|
||||
/// `observed()` is the probe's "did I actually see any PGS content?" signal.
|
||||
/// When it is false the track's forced state is UNKNOWN, and the probe leaves
|
||||
/// whatever flag the disc's own metadata supplied alone; when it is true the
|
||||
/// probe overwrites that flag with its own verdict. A tracker that always
|
||||
/// claims to have observed something therefore lets an unread or undecrypted
|
||||
/// subtitle track — where `is_forced()` is vacuously false — overwrite a
|
||||
/// correct vendor "forced" flag with "not forced".
|
||||
#[test]
|
||||
fn observed_stays_false_until_a_real_display_set_is_seen() {
|
||||
let mut t = ForcedTracker::new();
|
||||
assert!(!t.observed(), "a fresh tracker has seen nothing");
|
||||
assert!(!t.is_forced(), "and has no verdict to give");
|
||||
|
||||
// Blocks that carry no display set must not count as observation: a clear
|
||||
// PCS (zero composition objects), a non-PCS segment, a truncated PCS, and
|
||||
// an empty frame.
|
||||
let mut clear = pcs_display(false);
|
||||
clear[PCS_NUM_OBJECTS_OFFSET] = 0;
|
||||
let mut ods = pcs_display(true);
|
||||
ods[0] = 0x15;
|
||||
for block in [clear, ods, pcs_display(true)[..15].to_vec(), Vec::new()] {
|
||||
t.observe(&block);
|
||||
assert!(
|
||||
!t.observed(),
|
||||
"a block with no display set leaves the verdict unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// The first real display set is what flips it.
|
||||
t.observe(&pcs_display(true));
|
||||
assert!(t.observed());
|
||||
assert!(t.is_forced(), "the only display set seen was forced");
|
||||
}
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
|
||||
@@ -752,6 +752,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The substream count and the substream-directory size are the two numbers
|
||||
/// that position `mlp_parity_ok`'s check window over the AU header. Every
|
||||
/// other fixture in this module uses the degenerate shape — one substream,
|
||||
/// no extraword — so neither function's real behaviour was ever exercised:
|
||||
/// a constant answer agreed with all of them, and would then mis-window the
|
||||
/// parity check on the multi-substream AUs that carry 7.1 and Atmos, judging
|
||||
/// clean audio corrupt (or corrupt audio clean).
|
||||
#[test]
|
||||
fn mlp_num_substreams_is_the_top_nibble_of_major_sync_byte_16() {
|
||||
// Byte 16 of the major sync: top nibble is num_substreams, bottom nibble
|
||||
// is a different field, so it must not leak into the answer.
|
||||
for n in 0..16u8 {
|
||||
let mut ms = vec![0u8; 17];
|
||||
ms[16] = (n << 4) | 0x0F;
|
||||
assert_eq!(
|
||||
mlp_num_substreams(&ms),
|
||||
Some(n),
|
||||
"num_substreams is the high nibble only"
|
||||
);
|
||||
}
|
||||
// Real counts: 1 substream for 2.0/5.1 core-only, 4 for the 7.1/Atmos
|
||||
// layouts this crate has to mux.
|
||||
let mut ms = vec![0u8; 20];
|
||||
ms[16] = 0x40;
|
||||
assert_eq!(mlp_num_substreams(&ms), Some(4));
|
||||
|
||||
// A major sync too short to contain byte 16 yields no answer — never a
|
||||
// defaulted count, which would arm the parity check against garbage.
|
||||
assert_eq!(mlp_num_substreams(&[0u8; 16]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mlp_substr_header_size_counts_the_extraword_entries() {
|
||||
// Directory entry: 2 bytes, plus 2 more when the entry's top bit
|
||||
// (extraword) is set. Build a 4-substream directory that mixes both.
|
||||
const HDR: usize = 4;
|
||||
let mut au = vec![0xAAu8; HDR];
|
||||
au.extend_from_slice(&[0x00, 0x11]); // plain → 2
|
||||
au.extend_from_slice(&[0x80, 0x22, 0x01, 0x02]); // extraword → 4
|
||||
au.extend_from_slice(&[0x00, 0x33]); // plain → 2
|
||||
au.extend_from_slice(&[0x80, 0x44, 0x03, 0x04]); // extraword → 4
|
||||
au.extend_from_slice(&[0xFFu8; 8]); // payload past the directory
|
||||
assert_eq!(
|
||||
mlp_substr_header_size(&au, HDR, 4),
|
||||
Some(12),
|
||||
"2 + 4 + 2 + 4"
|
||||
);
|
||||
|
||||
// Same AU, fewer declared substreams → only that many entries counted.
|
||||
assert_eq!(mlp_substr_header_size(&au, HDR, 1), Some(2));
|
||||
assert_eq!(mlp_substr_header_size(&au, HDR, 2), Some(6));
|
||||
assert_eq!(mlp_substr_header_size(&au, HDR, 0), Some(0));
|
||||
|
||||
// An all-plain directory is 2 bytes per substream.
|
||||
let plain = vec![0x00u8; HDR + 8];
|
||||
assert_eq!(mlp_substr_header_size(&plain, HDR, 4), Some(8));
|
||||
|
||||
// A directory that runs past the AU has no answer: the parity window
|
||||
// would otherwise be placed over bytes that are not there.
|
||||
let truncated = &au[..HDR + 9];
|
||||
assert_eq!(mlp_substr_header_size(truncated, HDR, 4), None);
|
||||
assert_eq!(mlp_substr_header_size(&plain, HDR, 5), None);
|
||||
}
|
||||
|
||||
fn make_truehd_unit(size_bytes: usize) -> Vec<u8> {
|
||||
let words = size_bytes / 2;
|
||||
let mut data = vec![0u8; size_bytes];
|
||||
@@ -994,6 +1058,15 @@ mod tests {
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 2, "the parity-broken AU is dropped");
|
||||
assert_eq!(parser.dropped_frames(), 1);
|
||||
// The drop is a SILENCE GAP, and its length is what the CLI reports as
|
||||
// lost audio. One TrueHD access unit is 40 samples; at the 48 kHz base
|
||||
// rate that is 40/48000 s = 833_333 ns. A count without a duration (or a
|
||||
// duration that ignores the AU rate) understates or invents the loss.
|
||||
assert_eq!(
|
||||
parser.dropped_duration_ns(),
|
||||
833_333,
|
||||
"one dropped AU = 40 samples at 48 kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1413,6 +1413,49 @@ mod tests {
|
||||
assert!(ogm.contains("CHAPTER02NAME=2"));
|
||||
}
|
||||
|
||||
/// Chapter names originate on the disc, which is untrusted input, and the
|
||||
/// sink drops them straight into an XML document (`<ChapterString>`). Escaping
|
||||
/// is what keeps a hostile or merely odd name from terminating the element and
|
||||
/// injecting markup — the document is character data, so XML 1.0 §2.4 requires
|
||||
/// `&` and `<`, and escaping `>` as well keeps a `]]>` sequence safe too.
|
||||
///
|
||||
/// The order matters as much as the set: `&` must be replaced FIRST, otherwise
|
||||
/// the ampersands introduced by the `<`/`>` replacements get escaped a second
|
||||
/// time and `<` renders as the literal text `<` instead of a `<`.
|
||||
#[test]
|
||||
fn chapter_names_are_xml_escaped_so_a_disc_cannot_inject_markup() {
|
||||
let chaps = vec![Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "</ChapterString><Injected/> Tom & Jerry <3 >:(".to_string(),
|
||||
}];
|
||||
let xml = chapters_xml(&chaps);
|
||||
|
||||
assert!(
|
||||
xml.contains(
|
||||
"<ChapterString></ChapterString><Injected/> \
|
||||
Tom & Jerry <3 >:(</ChapterString>"
|
||||
),
|
||||
"every metacharacter escaped, and `&` escaped first so nothing is \
|
||||
double-escaped; got:\n{xml}"
|
||||
);
|
||||
// The injected element must not survive as markup anywhere in the file.
|
||||
assert!(
|
||||
!xml.contains("<Injected/>"),
|
||||
"a chapter name must not be able to open a new element"
|
||||
);
|
||||
// Exactly one ChapterString element pair — the name did not close it early.
|
||||
assert_eq!(xml.matches("<ChapterString>").count(), 1);
|
||||
assert_eq!(xml.matches("</ChapterString>").count(), 1);
|
||||
|
||||
// A name with no metacharacters passes through byte-identical: escaping
|
||||
// must not rewrite ordinary text.
|
||||
let plain = chapters_xml(&[Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "Opening Credits".to_string(),
|
||||
}]);
|
||||
assert!(plain.contains("<ChapterString>Opening Credits</ChapterString>"));
|
||||
}
|
||||
|
||||
// ── Timeline continuity ──────────────────────────────────────────────────
|
||||
//
|
||||
// The corrector itself is tested verbatim in `crate::mux::timeline`. Here we
|
||||
|
||||
@@ -630,6 +630,93 @@ mod tests {
|
||||
assert_eq!(&body[8..12], b"vide", "handler_type must be 'vide'");
|
||||
}
|
||||
|
||||
/// `mdhd` (§8.4.2), `vmhd` (§12.1.2) and `dinf`/`dref` (§8.7.1–2) are all
|
||||
/// mandatory in a video track's media tree, and each fixes field values a
|
||||
/// player relies on. None of them is read back by this crate — the init
|
||||
/// segment is only ever written — so nothing else constrains them.
|
||||
#[test]
|
||||
fn mdhd_vmhd_and_dinf_carry_the_values_the_spec_fixes() {
|
||||
let buf = init_segment();
|
||||
let boxes = walk_boxes(&buf);
|
||||
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
|
||||
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
|
||||
let trak = child(moov_payload, b"trak").expect("trak");
|
||||
let mdia = child(&trak[8..], b"mdia").expect("mdia");
|
||||
let minf = child(&mdia[8..], b"minf").expect("minf");
|
||||
|
||||
// ── mdhd. Version 0 layout: vflags(4) creation(4) modification(4)
|
||||
// timescale(4) duration(4) language(2) pre_defined(2) = 20 bytes.
|
||||
let mdhd = child(&mdia[8..], b"mdhd").expect("mdia must carry mdhd");
|
||||
let body = &mdhd[8..];
|
||||
assert_eq!(body[0], 0, "mdhd version 0 (32-bit times)");
|
||||
assert_eq!(
|
||||
body.len(),
|
||||
24,
|
||||
"version-0 mdhd body: vflags(4) creation(4) modification(4) timescale(4) duration(4) language(2) pre_defined(2)"
|
||||
);
|
||||
let media_ts = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
|
||||
assert_ne!(
|
||||
media_ts, 0,
|
||||
"every fragment's tfdt is expressed in this timescale; zero is a divide-by-zero"
|
||||
);
|
||||
// The mvhd timescale is the movie clock; this single-track init has one
|
||||
// clock, so the media timescale must agree with it rather than carry an
|
||||
// independent copy that can drift.
|
||||
let mvhd = child(moov_payload, b"mvhd").expect("mvhd");
|
||||
let mvhd_body = &mvhd[8..];
|
||||
let movie_ts =
|
||||
u32::from_be_bytes([mvhd_body[12], mvhd_body[13], mvhd_body[14], mvhd_body[15]]);
|
||||
assert_eq!(media_ts, movie_ts, "media clock must match the movie clock");
|
||||
|
||||
// language: ISO 639-2/T packed as three 5-bit values, bit 15 = 0 (§8.4.2).
|
||||
let packed = u16::from_be_bytes([body[20], body[21]]);
|
||||
assert_eq!(packed & 0x8000, 0, "language pad bit must be 0");
|
||||
let lang: String = (0..3)
|
||||
.map(|i| (((packed >> (10 - 5 * i)) & 0x1F) as u8 + 0x60) as char)
|
||||
.collect();
|
||||
assert_eq!(lang, "und", "unknown language is 'und', not empty or 'eng'");
|
||||
|
||||
// ── vmhd. §12.1.2 fixes flags to 1 and the stub picks the neutral
|
||||
// compositing mode: graphicsmode 0 (copy) with a zero opcolor.
|
||||
let vmhd = child(&minf[8..], b"vmhd").expect("minf must carry vmhd");
|
||||
let body = &vmhd[8..];
|
||||
assert_eq!(body[0], 0, "vmhd version 0");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, body[1], body[2], body[3]]),
|
||||
1,
|
||||
"vmhd flags must be 1"
|
||||
);
|
||||
assert_eq!(body.len(), 12, "vmhd body: vflags(4) mode(2) opcolor(6)");
|
||||
assert_eq!(
|
||||
u16::from_be_bytes([body[4], body[5]]),
|
||||
0,
|
||||
"graphicsmode copy"
|
||||
);
|
||||
assert_eq!(&body[6..12], &[0u8; 6], "opcolor is black");
|
||||
|
||||
// ── dinf > dref > 'url ' with flags 1: the media lives in this same
|
||||
// file/segment, so no external location follows (§8.7.2).
|
||||
let dinf = child(&minf[8..], b"dinf").expect("minf must carry dinf");
|
||||
let dref = child(&dinf[8..], b"dref").expect("dinf must carry dref");
|
||||
let body = &dref[8..];
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([body[4], body[5], body[6], body[7]]),
|
||||
1,
|
||||
"dref entry_count"
|
||||
);
|
||||
let url = child(&body[8..], b"url ").expect("dref must carry a 'url ' entry");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, url[9], url[10], url[11]]),
|
||||
1,
|
||||
"url flags=1 means self-contained"
|
||||
);
|
||||
assert_eq!(
|
||||
url.len(),
|
||||
12,
|
||||
"a self-contained url is header(8) + vflags(4) and no location string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_box_size_includes_header() {
|
||||
// §4.2: a box's size field counts the full box including the 8-byte
|
||||
|
||||
@@ -646,6 +646,64 @@ fn mpegts_crc32(data: &[u8]) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7).
|
||||
///
|
||||
/// Audio and video differ in TWO fields that no downstream check in this
|
||||
/// module distinguishes: the stream_id, and whether `PES_packet_length` is
|
||||
/// filled in or left at the unbounded 0x0000 form. A receiver reads the
|
||||
/// length to find the end of the access unit without scanning for the next
|
||||
/// start code, so an audio PES that borrowed the video form (or emitted a
|
||||
/// stub header) still tiles into valid-looking 188-byte packets and only
|
||||
/// fails inside a decoder.
|
||||
#[test]
|
||||
fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() {
|
||||
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
// PTS = 90000 ticks = exactly 1 s at 90 kHz. Encoded across 5 bytes as
|
||||
// '0010' | PTS[32..30] | marker, PTS[29..15] | marker, PTS[14..0] | marker.
|
||||
let pes = build_audio_pes(90_000, &es);
|
||||
assert_eq!(
|
||||
pes,
|
||||
vec![
|
||||
0x00, 0x00, 0x01, // packet_start_code_prefix
|
||||
0xBD, // stream_id = private_stream_1 (BD audio)
|
||||
0x00, 0x0C, // PES_packet_length = 3 flag bytes + 5 PTS + 4 ES
|
||||
0x80, // '10' MPEG-2 marker, no scrambling/priority
|
||||
0x80, // PTS_DTS_flags = '10' (PTS only)
|
||||
0x05, // PES_header_data_length = 5
|
||||
0x21, 0x00, 0x05, 0xBF, 0x21, // PTS 90000 with marker bits
|
||||
0xDE, 0xAD, 0xBE, 0xEF, // the access unit, verbatim
|
||||
]
|
||||
);
|
||||
|
||||
// The video builder over the SAME inputs differs in exactly the two
|
||||
// fields above — proving neither is incidental.
|
||||
let vid = build_video_pes(90_000, &es);
|
||||
assert_eq!(vid[3], 0xE0, "video uses the video stream_id");
|
||||
assert_eq!(
|
||||
&vid[4..6],
|
||||
&[0x00, 0x00],
|
||||
"video length is the unbounded form (a video PES can exceed u16)"
|
||||
);
|
||||
assert_eq!(&vid[6..], &pes[6..], "everything after the length matches");
|
||||
|
||||
// A different PTS must move the timestamp bytes and nothing else.
|
||||
let later = build_audio_pes(180_000, &es);
|
||||
assert_eq!(&later[..9], &pes[..9], "header up to the PTS is unchanged");
|
||||
assert_ne!(&later[9..14], &pes[9..14], "the PTS bytes change");
|
||||
assert_eq!(&later[14..], &es, "the access unit is unchanged");
|
||||
|
||||
// An access unit too large for the u16 length field falls back to the
|
||||
// unbounded form rather than writing a truncated (wrapped) length.
|
||||
let big = vec![0x5Au8; u16::MAX as usize - 7];
|
||||
let big_pes = build_audio_pes(90_000, &big);
|
||||
assert_eq!(
|
||||
&big_pes[4..6],
|
||||
&[0x00, 0x00],
|
||||
"8 + es.len() overflows u16 → unbounded length, never a wrapped one"
|
||||
);
|
||||
assert_eq!(&big_pes[14..], &big[..]);
|
||||
}
|
||||
|
||||
/// All emitted bytes must align to 188-byte packet boundaries and
|
||||
/// every packet must start with `0x47`.
|
||||
fn assert_ts_well_formed(buf: &[u8]) {
|
||||
|
||||
@@ -1197,6 +1197,398 @@ mod tests {
|
||||
assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT), vec![1001, 1001, 1001]);
|
||||
}
|
||||
|
||||
// ── Composition offsets (`ctts`) — ISO/IEC 14496-12 §8.6.1.3.
|
||||
//
|
||||
// `ctts` is the ONLY place a reordered (B-frame) track's presentation time
|
||||
// survives the write→read cycle: `stts` carries decode duration and `stss`
|
||||
// carries sync points, so with the composition table wrong or absent every
|
||||
// frame's PTS collapses onto its DTS and the picture order is destroyed.
|
||||
// The writer's builder and this reader's parser must therefore be exact
|
||||
// inverses, over the SIGNED offsets that make it a version-1 box.
|
||||
|
||||
#[test]
|
||||
fn ctts_build_and_parse_are_exact_inverses_over_signed_offsets() {
|
||||
// Includes a negative offset (version 1 only), a repeated run, and a
|
||||
// value repeated non-adjacently — so a parser that loses the sign, drops
|
||||
// the run expansion, or returns a constant cannot agree.
|
||||
let offsets: Vec<i32> = vec![0, 2, -1, -1, 0, 3003];
|
||||
let boxed = crate::mux::mp4::build_ctts(&offsets);
|
||||
|
||||
assert_eq!(&boxed[4..8], b"ctts", "box type");
|
||||
assert_eq!(
|
||||
boxed[8], 1,
|
||||
"signed composition offsets require ctts version 1 (§8.6.1.3)"
|
||||
);
|
||||
// Run-length coalescing: the two adjacent -1s share one entry, so the
|
||||
// six offsets become five runs. Size = 8 hdr + 4 ver/flags + 4 count + 5×8.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(boxed[0..4].try_into().unwrap()) as usize,
|
||||
8 + 4 + 4 + 5 * 8,
|
||||
"equal ADJACENT offsets coalesce into a single run"
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(boxed[12..16].try_into().unwrap()),
|
||||
5,
|
||||
"entry_count is the run count, not the sample count"
|
||||
);
|
||||
|
||||
// The box header is not part of the parser's input: it takes the payload
|
||||
// from version/flags onward.
|
||||
assert_eq!(parse_ctts(&boxed[8..], MAX_SAMPLE_COUNT), offsets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_frame_presentation_order_survives_the_mp4_round_trip() {
|
||||
// Four samples in DECODE order carrying a classic I-P-B-B reorder: the
|
||||
// second sample presents last. At 25 fps (timescale 25, one tick per
|
||||
// frame) the composition offsets are [0, +2, -1, -1] — negative, so this
|
||||
// exercises the signed version-1 path end to end.
|
||||
//
|
||||
// Before this test the round trip asserted sample sizes and keyframe
|
||||
// flags only, so the entire composition-time chain (VideoTiming::ctts,
|
||||
// build_ctts, parse_ctts) was unconstrained and a demuxed B-frame title
|
||||
// could have presented in decode order with nothing noticing.
|
||||
use crate::disc::{
|
||||
Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
const FRAME_NS: i64 = 40_000_000; // 25 fps, exact
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])];
|
||||
|
||||
// (presentation time, payload byte) in decode order.
|
||||
let plan: [(i64, u8); 4] = [
|
||||
(0, 0x10), // I, presents first
|
||||
(3 * FRAME_NS, 0x20), // P, presents last
|
||||
(FRAME_NS, 0x30), // B
|
||||
(2 * FRAME_NS, 0x40), // B
|
||||
];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for (i, &(pts, fill)) in plan.iter().enumerate() {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts,
|
||||
keyframe: i == 0,
|
||||
data: vec![fill; 64 + i * 8],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "reorder".into()).unwrap();
|
||||
let mut got = Vec::new();
|
||||
while let Some(f) = rd.read().unwrap() {
|
||||
got.push((f.pts, f.data[0]));
|
||||
}
|
||||
assert_eq!(got.len(), 4);
|
||||
|
||||
// Frames come back in decode order (sorted by DTS), each still carrying
|
||||
// the presentation time it was written with — identified by payload, so
|
||||
// a reordering of the samples themselves cannot be mistaken for success.
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(0, 0x10),
|
||||
(3 * FRAME_NS, 0x20),
|
||||
(FRAME_NS, 0x30),
|
||||
(2 * FRAME_NS, 0x40),
|
||||
],
|
||||
"composition times must survive the write→read cycle"
|
||||
);
|
||||
|
||||
// The property that matters to a player: presentation order differs from
|
||||
// decode order, and sorting by PTS recovers the display sequence.
|
||||
let mut by_pts = got.clone();
|
||||
by_pts.sort_by_key(|&(pts, _)| pts);
|
||||
assert_eq!(
|
||||
by_pts.iter().map(|&(_, b)| b).collect::<Vec<_>>(),
|
||||
vec![0x10, 0x30, 0x40, 0x20],
|
||||
"display order is I,B,B,P — not the decode order I,P,B,B"
|
||||
);
|
||||
}
|
||||
|
||||
/// A track's declared duration must be its real length. `mdhd.duration` is in
|
||||
/// the MEDIA timescale and `mvhd.duration` in the movie timescale
|
||||
/// (ISO/IEC 14496-12 §8.2.2, §8.4.2); a player uses them to draw the seek bar
|
||||
/// and to decide when the title ends, so a zeroed or constant duration makes
|
||||
/// a correct file unseekable and apparently empty.
|
||||
#[test]
|
||||
fn declared_track_duration_equals_frame_count_times_frame_duration() {
|
||||
use crate::disc::{
|
||||
Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
const FRAME_NS: i64 = 40_000_000; // 25 fps, exact
|
||||
const FRAMES: usize = 5;
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for i in 0..FRAMES {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts: i as i64 * FRAME_NS,
|
||||
keyframe: i == 0,
|
||||
data: vec![0x55u8; 128],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let moov = find_box(&buf, b"moov").expect("moov");
|
||||
|
||||
// mvhd is a version-1 FullBox here: vflags(4) creation(8) modification(8)
|
||||
// timescale(4) duration(8).
|
||||
let mvhd = find_box(moov, b"mvhd").expect("mvhd");
|
||||
assert_eq!(mvhd[0], 1, "mvhd version 1");
|
||||
let movie_ts = be32(mvhd, 20);
|
||||
let movie_dur = u64::from_be_bytes(mvhd[24..32].try_into().unwrap());
|
||||
assert_eq!(movie_ts, 90_000);
|
||||
assert_eq!(
|
||||
movie_dur, 18_000,
|
||||
"5 frames at 25 fps is 0.2 s = 18000 ticks at 90 kHz"
|
||||
);
|
||||
|
||||
// mdhd, same version-1 layout, but in the media timescale the writer chose.
|
||||
let mdhd = find_box(
|
||||
find_box(find_box(moov, b"trak").expect("trak"), b"mdia").expect("mdia"),
|
||||
b"mdhd",
|
||||
)
|
||||
.expect("mdhd");
|
||||
assert_eq!(mdhd[0], 1, "mdhd version 1");
|
||||
let media_ts = be32(mdhd, 20);
|
||||
let media_dur = u64::from_be_bytes(mdhd[24..32].try_into().unwrap());
|
||||
assert_eq!(media_ts, 25, "25 fps snaps to a 25-tick timescale");
|
||||
assert_eq!(
|
||||
media_dur, FRAMES as u64,
|
||||
"duration is one tick per frame in this timescale"
|
||||
);
|
||||
assert_eq!(
|
||||
media_dur as f64 / media_ts as f64,
|
||||
movie_dur as f64 / movie_ts as f64,
|
||||
"the two declared durations must describe the same wall-clock length"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory for a
|
||||
/// playable track, with the field values the spec fixes. These live in this
|
||||
/// module rather than the writer's because the box-walking helpers
|
||||
/// ([`find_box`]) are here — asserting through them means the test reads the
|
||||
/// file the way the demuxer does, instead of re-deriving the layout.
|
||||
///
|
||||
/// Nothing in the demux path needs `tkhd`/`vmhd`/`smhd`/`dinf`, so an empty
|
||||
/// one of any of them round-trips through this crate unnoticed while making
|
||||
/// the file unplayable elsewhere.
|
||||
#[test]
|
||||
fn moov_tree_carries_the_mandatory_track_header_and_media_boxes() {
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, DiscTitle, FrameRate, HdrFormat, LabelPurpose,
|
||||
Resolution, SampleRate, Stream as DiscStreamE, VideoStream,
|
||||
};
|
||||
use crate::mux::mp4::Mp4Sink;
|
||||
use crate::pes::{PesFrame, Stream as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![
|
||||
DiscStreamE::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: crate::disc::ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}),
|
||||
DiscStreamE::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::Ac3,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}),
|
||||
];
|
||||
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), None];
|
||||
|
||||
let ac3 = vec![
|
||||
0x0B,
|
||||
0x77,
|
||||
0x00,
|
||||
0x00,
|
||||
0b00_010110,
|
||||
0b01000_000,
|
||||
0b111_00_00_1,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xFF,
|
||||
];
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||
for i in 0..4i64 {
|
||||
sink.write(&PesFrame {
|
||||
track: 0,
|
||||
pts: i * 40_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![0x55u8; 128],
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
sink.write(&PesFrame {
|
||||
track: 1,
|
||||
pts: i * 32_000_000,
|
||||
keyframe: true,
|
||||
data: ac3.clone(),
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
|
||||
let moov = find_box(&buf, b"moov").expect("moov");
|
||||
let traks = find_boxes_capped(moov, b"trak", usize::MAX);
|
||||
assert_eq!(traks.len(), 2, "one video trak + one audio trak");
|
||||
|
||||
// ── tkhd (§8.3.2). Mandatory in every trak. flags bit 0 = track_enabled;
|
||||
// a track with flags 0 is ignored by a conforming player. Width/height are
|
||||
// 16.16 fixed point.
|
||||
let vid_tkhd = find_box(traks[0], b"tkhd").expect("video tkhd");
|
||||
assert_eq!(vid_tkhd[0], 1, "tkhd version 1");
|
||||
let flags = u32::from_be_bytes([0, vid_tkhd[1], vid_tkhd[2], vid_tkhd[3]]);
|
||||
assert_eq!(flags & 0x1, 0x1, "track_enabled must be set");
|
||||
assert_eq!(be32(vid_tkhd, 20), 1, "first track_id is 1");
|
||||
assert_eq!(
|
||||
be32(vid_tkhd, 88) >> 16,
|
||||
1920,
|
||||
"tkhd width is 16.16 fixed point"
|
||||
);
|
||||
assert_eq!(be32(vid_tkhd, 92) >> 16, 1080, "tkhd height");
|
||||
|
||||
let aud_tkhd = find_box(traks[1], b"tkhd").expect("audio tkhd");
|
||||
assert_eq!(be32(aud_tkhd, 20), 2, "second track_id is 2");
|
||||
assert_eq!(
|
||||
be16(aud_tkhd, 48),
|
||||
0x0100,
|
||||
"an audio track's tkhd volume is 1.0 (8.8 fixed), not muted"
|
||||
);
|
||||
assert_eq!(
|
||||
(be32(aud_tkhd, 88), be32(aud_tkhd, 92)),
|
||||
(0, 0),
|
||||
"a sound track declares zero visual dimensions"
|
||||
);
|
||||
|
||||
// ── minf media headers: vmhd for video, smhd for audio (§12.1.2, §12.2.2).
|
||||
// Exactly one of them, and never the wrong one for the handler.
|
||||
let minf = |trak: &[u8]| -> Vec<u8> {
|
||||
find_box(find_box(trak, b"mdia").expect("mdia"), b"minf")
|
||||
.expect("minf")
|
||||
.to_vec()
|
||||
};
|
||||
let vid_minf = minf(traks[0]);
|
||||
let aud_minf = minf(traks[1]);
|
||||
|
||||
let vmhd = find_box(&vid_minf, b"vmhd").expect("video minf must carry vmhd");
|
||||
assert!(
|
||||
find_box(&vid_minf, b"smhd").is_none(),
|
||||
"a video minf must not carry smhd"
|
||||
);
|
||||
// §12.1.2 fixes vmhd flags to 1.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, vmhd[1], vmhd[2], vmhd[3]]),
|
||||
1,
|
||||
"vmhd flags must be 1"
|
||||
);
|
||||
assert_eq!(
|
||||
(be16(vmhd, 4), &vmhd[6..12]),
|
||||
(0u16, &[0u8; 6][..]),
|
||||
"graphicsmode 0 (copy) with a zero opcolor"
|
||||
);
|
||||
|
||||
let smhd = find_box(&aud_minf, b"smhd").expect("audio minf must carry smhd");
|
||||
assert!(
|
||||
find_box(&aud_minf, b"vmhd").is_none(),
|
||||
"an audio minf must not carry vmhd"
|
||||
);
|
||||
assert_eq!(be16(smhd, 4), 0, "smhd balance is centre");
|
||||
|
||||
// ── dinf > dref > "url " with flags 1 = media is in THIS file (§8.7.2).
|
||||
// Both tracks need it; a missing/empty dref makes the samples unreachable.
|
||||
for (name, m) in [("video", &vid_minf), ("audio", &aud_minf)] {
|
||||
let dinf = find_box(m, b"dinf").unwrap_or_else(|| panic!("{name} dinf"));
|
||||
let dref = find_box(dinf, b"dref").unwrap_or_else(|| panic!("{name} dref"));
|
||||
assert_eq!(be32(dref, 4), 1, "{name} dref entry_count");
|
||||
let url = find_box(&dref[8..], b"url ").unwrap_or_else(|| panic!("{name} url "));
|
||||
// `url ` payload is version(1)+flags(3) only when self-contained.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([0, url[1], url[2], url[3]]),
|
||||
1,
|
||||
"{name} url flags=1 (self-contained), so no external name follows"
|
||||
);
|
||||
assert_eq!(
|
||||
url.len(),
|
||||
4,
|
||||
"{name} self-contained url carries no location"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Untrusted-input hardening: a crafted MP4 must never panic or over-allocate.
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1370,6 +1370,73 @@ mod tests {
|
||||
assert_eq!(p[0].data, vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
/// `is_nav()` exists to separate the ONE unmappable stream a DVD is expected
|
||||
/// to contain — private_stream_2 (0xBF), the PCI/DSI navigation packs
|
||||
/// (ISO/IEC 13818-1 Table 2-22) — from every other packet whose `dvd_pid()`
|
||||
/// comes back `None`, which is an unexpected, possibly-lost real stream. The
|
||||
/// mux loops use the distinction to choose between a silent tally and a
|
||||
/// per-packet WARN, so collapsing it to a constant either buries a genuine
|
||||
/// stream loss in the nav tally, or floods the log with one warning per
|
||||
/// navigation pack on every DVD ever ripped.
|
||||
///
|
||||
/// The invariant that ties the two together: `is_nav()` may only ever be true
|
||||
/// where `dvd_pid()` is `None` — a packet that routes to a real track must
|
||||
/// never be silently classified as navigation.
|
||||
#[test]
|
||||
fn only_private_stream_2_is_navigation_and_never_a_routable_stream() {
|
||||
// Demux a program stream carrying, in order: a navigation pack, MPEG-2
|
||||
// video, an AC-3 audio substream, and an MPEG audio stream (unmappable on
|
||||
// DVD, but NOT navigation).
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let mut data = Vec::new();
|
||||
// private_stream_2: no PES extension, payload follows the 6-byte prefix.
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xBF, 0x00, 0x02, 0x00, 0x01]);
|
||||
// video 0xE0
|
||||
data.extend_from_slice(&[
|
||||
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x11, 0x22,
|
||||
]);
|
||||
// private_stream_1 with AC-3 sub-stream 0x80 (4 bytes of substream header
|
||||
// follow the sub-id on DVD).
|
||||
data.extend_from_slice(&[
|
||||
0x00, 0x00, 0x01, 0xBD, 0x00, 0x0A, 0x80, 0x00, 0x00, 0x80, 0x01, 0x00, 0x03, 0x00,
|
||||
0xAA, 0xBB,
|
||||
]);
|
||||
// MPEG audio 0xC0
|
||||
data.extend_from_slice(&[
|
||||
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x33, 0x44,
|
||||
]);
|
||||
data.extend_from_slice(&PROGRAM_END);
|
||||
|
||||
let packets = demuxer.feed(&data);
|
||||
assert_eq!(packets.len(), 4, "four PES packets demuxed");
|
||||
|
||||
let nav: Vec<u8> = packets
|
||||
.iter()
|
||||
.filter(|p| p.is_nav())
|
||||
.map(|p| p.stream_id)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
nav,
|
||||
vec![0xBF],
|
||||
"exactly the private_stream_2 pack is navigation"
|
||||
);
|
||||
|
||||
for p in &packets {
|
||||
if p.is_nav() {
|
||||
assert_eq!(
|
||||
p.dvd_pid(),
|
||||
None,
|
||||
"a navigation pack must not also route to a track"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The MPEG-audio packet is equally unroutable on DVD, yet must NOT be
|
||||
// absorbed into the nav tally — that is the distinction being drawn.
|
||||
let mpa = packets.iter().find(|p| p.stream_id == 0xC0).unwrap();
|
||||
assert_eq!(mpa.dvd_pid(), None, "MPEG audio is unmappable on DVD");
|
||||
assert!(!mpa.is_nav(), "...but it is a lost stream, not navigation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_start_code_is_skipped_not_parsed() {
|
||||
// A start code with an ID outside the known PS-layer set
|
||||
|
||||
@@ -100,7 +100,13 @@ mod tests {
|
||||
// Gap arrives on the next frame (a P referencing lost data) → drop it
|
||||
// and every inter frame until the next keyframe.
|
||||
assert!(!g.admit(true, true, false), "post-gap P dropped");
|
||||
assert!(
|
||||
g.is_armed(),
|
||||
"the gate reports itself armed WHILE it is dropping — this is what \
|
||||
the consumer reads to know the stream is in a resync hole"
|
||||
);
|
||||
assert!(!g.admit(true, false, false), "still dropping (no key yet)");
|
||||
assert!(g.is_armed(), "still armed mid-run");
|
||||
assert!(!g.admit(true, false, false));
|
||||
assert_eq!(g.dropped_in_run(), 3);
|
||||
// Next keyframe resyncs and is emitted.
|
||||
|
||||
@@ -988,6 +988,65 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The PSI scanner walks a BD-TS buffer one BYTE at a time until it finds a
|
||||
/// packet boundary, so `is_resync_point` is the only thing standing between
|
||||
/// it and a stray 0x47 in a TP_extra_header or a payload. Latching onto one
|
||||
/// puts every subsequent field read 1..191 bytes out of phase, so the PID and
|
||||
/// PUSI bits it then decodes belong to nothing — the scanner either invents a
|
||||
/// stream or loses the real PMT. Accepting every offset (the shape a constant
|
||||
/// `true` takes) guarantees it latches on the first offset it tries.
|
||||
#[test]
|
||||
fn resync_requires_a_corroborating_follower_not_just_one_sync_byte() {
|
||||
const P: usize = BD_SOURCE_PACKET_BYTES;
|
||||
|
||||
// Two well-formed source packets: sync at +4 of each.
|
||||
let mut two = vec![0u8; 2 * P];
|
||||
two[4] = SYNC_BYTE;
|
||||
two[P + 4] = SYNC_BYTE;
|
||||
assert!(
|
||||
is_resync_point(&two, 0),
|
||||
"a real boundary: sync here and 192 bytes on"
|
||||
);
|
||||
|
||||
// No sync byte at all → never a boundary.
|
||||
two[4] = 0x00;
|
||||
assert!(!is_resync_point(&two, 0));
|
||||
two[4] = SYNC_BYTE;
|
||||
|
||||
// Sync here, but the next 192-spaced position is not a sync byte: this is
|
||||
// a 0x47 that happens to sit inside a header or payload, not a boundary.
|
||||
two[P + 4] = 0x00;
|
||||
assert!(
|
||||
!is_resync_point(&two, 0),
|
||||
"an uncorroborated 0x47 must be rejected"
|
||||
);
|
||||
two[P + 4] = SYNC_BYTE;
|
||||
|
||||
// The concrete desync the corroboration prevents: a 0x47 byte sitting in
|
||||
// the payload of the first packet. Its own "sync" test passes, and the
|
||||
// scanner must still refuse the offset.
|
||||
let stray = 60usize;
|
||||
two[stray + 4] = SYNC_BYTE;
|
||||
assert!(
|
||||
!is_resync_point(&two, stray),
|
||||
"a stray 0x47 inside a payload is not a packet boundary"
|
||||
);
|
||||
// ...while the genuine boundaries either side still are.
|
||||
assert!(is_resync_point(&two, 0));
|
||||
|
||||
// A lone trailing packet has no follower to corroborate, so it is accepted
|
||||
// on its own sync byte — otherwise the last packet of every buffer would
|
||||
// be unreachable.
|
||||
let mut one = vec![0u8; P];
|
||||
one[4] = SYNC_BYTE;
|
||||
assert!(is_resync_point(&one, 0), "last packet in the buffer");
|
||||
one[4] = 0x00;
|
||||
assert!(
|
||||
!is_resync_point(&one, 0),
|
||||
"...but it still needs its own sync byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
|
||||
Reference in New Issue
Block a user