Make six silent mux failures observable

All six confirmed against the code. The governing rule this cluster serves: a
lossy or degraded outcome is never silent, because a corrupt rip the user does
not know about is the worst failure available.

**A 3D MKV re-mux silently lost one eye.** The BlockGroup read path had arms for
BLOCK / BLOCK_DURATION / REFERENCE_BLOCK only, so BLOCK_ADDITIONS fell into the
skip arm — while the writer does emit BlockAdditions > BlockMore > BlockAdditional
for the MVC dependent view. Reconstruction was judged out of scope and the
reasoning is recorded: PesFrame has no side-payload field and the header parser
never reads BlockAdditionMapping, so there is no dependent-view track to route the
AU to. Instead the loss is now LOUD — counted in bytes and events, warned once,
and surfaced through MkvStream's errors()/lost_bytes(), which the driver already
samples into MuxOutcome. One detail in the finding was wrong and is corrected: the
re-mux does NOT still advertise the mvcC mapping, because the header parser
ignores that element, so the output is a plain 2D H.264 track.

**An all-titles rip silently skipped real titles.** The header-buffer-cap
overflow returned Error::MkvInvalid, and is_skippable_title_stub matches exactly
E_MKV_INVALID | E_CSS_KEY_MISSING — verified here — so a 512 MiB-of-frames title
was classified as an empty nav/menu PGC stub and dropped. It now has its own
E9051 / MuxHeaderBufferExceeded { bytes }, outside the skippable set.

**The public pre-mux report contradicted the file.** Mp4Sink::finish() drops an
audio track it cannot describe, which I chose last round over failing an export
whose video is fine — but mp4_fit_report still listed that stream as included, so
the application's plan and the actual output disagreed. Fixed at both levels:
Mp4SkipReason is now non_exhaustive with NoSamples and UndescribableAudio,
Mp4Sink::final_report() describes the FILE rather than the plan, and for the
boxed dyn Stream path a defaulted Stream::undelivered_streams() carries the
information out to MuxOutcome::undelivered_streams with a driver-side warn.

**MP4 track ids could collide.** ids were assigned before the retain that drops
sample-less tracks, while next_id came from the post-retain count, so [1,3]
yielded next_id 3. Now max(track_id) + 1, saturating.

**Stream selection silently skipped its codec_privates prune** when the lists were
not the same length — but codec_privates is consumed POSITIONALLY and trailing
extras are documented as benign, so the length-equality guard was itself the bug.
The prune now runs unconditionally by index.

**The m2ts_mux scaffolding armed params_written on both the absent and the
unparseable codec_private arms** — the same defect already fixed in tsmux.rs.
Split into params_attempted (a latch, since retrying identical bytes cannot help)
and params_emitted, with a warn on each failure arm and an accessor so the
eventual wiring and its test can observe it.

Each fix verified red by mutating back to the prior behaviour: errors() 0 vs 1,
E6008 vs E9051, final_report [0,1] vs [0], next_track_id 3 vs [1,3], and the
selection prune resolving index 1 to the wrong track's record.

API surface deliberately widened: MuxOutcome gains a public field and Mp4Sink
becomes public. Nothing in-repo breaks. Note a behaviour change on the mkv://
input path — a 3D re-mux now reports non-zero loss, so a consumer treating
errors > 0 as disc damage will trip on it. That is intended: the outcome IS
degraded.
This commit is contained in:
Matthew Jackson
2026-07-29 20:46:10 -07:00
parent 9ad68dd092
commit 3efa6211f3
9 changed files with 798 additions and 260 deletions
+228 -223
View File
@@ -126,6 +126,10 @@ struct Track {
media: Media,
/// 1-based MP4 track_ID.
track_id: u32,
/// `title.streams` index this track was built from — the identity
/// `Mp4FitReport` speaks in, so a track dropped at `finish()` can be named
/// in [`Mp4Sink::final_report`].
stream_idx: usize,
codec: Codec,
/// Video: `hvcC`/`avcC`. Audio: unused (the sample entry is built from the
/// first frame's bitstream and cached in `audio_entry`).
@@ -142,7 +146,11 @@ struct Track {
}
/// Why a stream was excluded from an `mp4://` mux (for the never-silent report).
///
/// Marked `#[non_exhaustive]`: new reasons appear as the writer learns to
/// distinguish more of them, so downstream must not match exhaustively.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Mp4SkipReason {
/// A subtitle track — MP4 carries only text subs; disc subs are bitmap.
BitmapSubtitle,
@@ -154,11 +162,28 @@ pub enum Mp4SkipReason {
/// A primary video track whose codec this MP4 writer can't carry
/// (only HEVC/H.264 are supported — e.g. VC-1, MPEG-2, AV1).
UnmappableVideo,
/// Planned as carried, but the stream delivered no sample at all, so
/// `finish()` dropped the track rather than write an empty `trak`.
/// A *post-mux* reason: [`fit_report`] cannot predict it, only
/// [`Mp4Sink::final_report`] reports it.
NoSamples,
/// Planned as carried, and samples DID reach `mdat`, but no frame yielded a
/// parseable audio sample entry, so the track could not be described in
/// `stsd` and `finish()` dropped it (its bytes stay in `mdat`, unreferenced).
/// A *post-mux* reason — see [`Mp4Sink::final_report`].
UndescribableAudio,
}
/// The plan for an `mp4://` mux of `title`: which streams are carried and which
/// are excluded (with the reason). The CLI prints the exclusions so a lossy
/// export is never silent; the sink applies the same predicate.
///
/// [`fit_report`] returns the PRE-mux plan, which is a prediction: two of its
/// inclusions can still fail at `finish()` (a stream that delivers no sample, an
/// audio stream no frame of which yields a parseable sample entry). Ask
/// [`Mp4Sink::final_report`] after `finish()` for what the file actually
/// contains — the plan alone is not a statement about the output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mp4FitReport {
/// `title.streams` indices that will be muxed.
pub included: Vec<usize>,
@@ -236,6 +261,12 @@ pub struct Mp4Sink<W: Write + Seek> {
/// Reserved hole size in bytes (`moov` + trailing `free` padding go here).
reserve: u64,
finished: bool,
/// The create-time (pre-mux) plan, kept so [`Self::final_report`] can hand
/// back a report that matches the FILE rather than the prediction.
plan: Mp4FitReport,
/// Streams the plan promised that `finish()` actually dropped, with why.
/// Empty until `finish()` runs.
dropped: Vec<(usize, Mp4SkipReason)>,
}
impl<W: Write + Seek> Mp4Sink<W> {
@@ -272,6 +303,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
tracks.push(Track {
media: Media::Video,
track_id,
stream_idx: i,
codec: v.codec,
codec_private: cp,
width: w,
@@ -287,6 +319,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
tracks.push(Track {
media: Media::Audio,
track_id,
stream_idx: i,
codec: a.codec,
codec_private: Vec::new(),
width: 0,
@@ -333,9 +366,33 @@ impl<W: Write + Seek> Mp4Sink<W> {
hole_start,
reserve,
finished: false,
plan: report,
dropped: Vec::new(),
})
}
/// What the file ACTUALLY contains, in the same shape as the pre-mux
/// [`fit_report`] plan. Before `finish()` it equals that plan; after
/// `finish()` every track the writer had to drop has moved from `included`
/// into `skipped` with a post-mux reason ([`Mp4SkipReason::NoSamples`],
/// [`Mp4SkipReason::UndescribableAudio`]).
///
/// This exists because the plan is a PREDICTION. `finish()` drops an audio
/// track no frame of which yielded a parseable sample entry (it cannot be
/// described in `stsd`) and returns `Ok` so an export whose video is fine
/// still succeeds — but then the plan, which is the only structured report
/// the crate publishes, still named that stream as carried. A caller
/// believing it reported a successful export of a file with no audio. Ask
/// this after `finish()` before telling anyone what was written.
pub fn final_report(&self) -> Mp4FitReport {
let mut included = self.plan.included.clone();
included.retain(|i| !self.dropped.iter().any(|(d, _)| d == i));
let mut skipped = self.plan.skipped.clone();
skipped.extend(self.dropped.iter().copied());
skipped.sort_by_key(|&(i, _)| i);
Mp4FitReport { included, skipped }
}
/// Assemble the `moov` box from every track's sample tables.
fn build_moov(&self) -> Vec<u8> {
// Movie timescale = 90 kHz; movie duration = the longest track (converted).
@@ -347,7 +404,18 @@ impl<W: Write + Seek> Mp4Sink<W> {
traks.push(trak);
movie_dur = movie_dur.max((secs * movie_ts as f64) as u64);
}
let next_id = self.tracks.len() as u32 + 1;
// `next_track_id` must EXCEED every track_ID in use (ISO/IEC 14496-12
// §8.2.2). Deriving it from the retained COUNT broke that whenever
// `finish()` dropped a track: ids [1, 3] retained → count 2 → 3, which
// names a live track, so a tool appending a track with it creates a
// duplicate id. Take the real maximum.
let next_id = self
.tracks
.iter()
.map(|t| t.track_id)
.max()
.unwrap_or(0)
.saturating_add(1);
let mut moov = build_mvhd(movie_ts, movie_dur, next_id);
for trak in traks {
@@ -395,8 +463,24 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
return Ok(());
}
self.finished = true;
// Every drop below is recorded in `self.dropped` so `final_report()` — the
// structured answer to "what is in this file" — cannot keep claiming a
// track the file does not have. A `tracing::warn` alone left the crate's
// own public report lying about the output.
let mut dropped = Vec::new();
// Drop tracks that never received a sample so moov carries no empty trak.
self.tracks.retain(|t| !t.samples.is_empty());
self.tracks.retain(|t| {
let kept = !t.samples.is_empty();
if !kept {
tracing::warn!(
stream = t.stream_idx,
codec = ?t.codec,
"mp4: track received no samples, dropping it (see final_report)"
);
dropped.push((t.stream_idx, Mp4SkipReason::NoSamples));
}
kept
});
// An audio track with samples but no sample entry cannot be DESCRIBED: the
// moov would carry an stsd declaring entry_count=1 around an empty entry,
// i.e. a structurally invalid mp4 returned as success. Drop it instead, and
@@ -407,13 +491,17 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
let describable = t.media != Media::Audio || t.audio_entry.is_some();
if !describable {
tracing::warn!(
stream = t.stream_idx,
codec = ?t.codec,
samples = t.samples.len(),
"mp4: no audio frame yielded a parseable sample entry, dropping track"
"mp4: no audio frame yielded a parseable sample entry, dropping track \
(see final_report)"
);
dropped.push((t.stream_idx, Mp4SkipReason::UndescribableAudio));
}
describable
});
self.dropped.append(&mut dropped);
if self.tracks.is_empty() {
return Err(crate::error::Error::MuxEmpty.into());
}
@@ -452,6 +540,14 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
fn info(&self) -> &DiscTitle {
&self.title
}
/// The streams `finish()` had to drop — see [`Self::final_report`] for the
/// reasons. The driver surfaces this as `MuxOutcome::undelivered_streams` so
/// the caller learns programmatically that the file is missing a stream the
/// pre-mux plan promised, instead of only in a log line.
fn undelivered_streams(&self) -> Vec<usize> {
self.dropped.iter().map(|&(i, _)| i).collect()
}
}
// ── per-track box assembly ───────────────────────────────────────────────────
@@ -560,12 +656,8 @@ fn audio_sample_durations(samples: &[Sample], timescale: u32) -> Vec<u32> {
durs
}
/// Standard frame rates as `(timescale, sample_duration, fps)` — exact integer
/// ratios so a CFR track has zero accumulated drift.
///
/// The order of this table is NOT significant: [`detect_rate`] picks the entry
/// nearest the measured rate, so a new rate may be appended anywhere without
/// shadowing an existing one.
/// Standard frame rates as `(timescale, sample_duration)` — exact integer ratios
/// so a CFR track has zero accumulated drift.
const STD_RATES: &[(u32, u32, f64)] = &[
(24000, 1001, 23.976),
(24, 1, 24.0),
@@ -577,12 +669,6 @@ const STD_RATES: &[(u32, u32, f64)] = &[
(60, 1, 60.0),
];
/// How far the measured rate may sit from a [`STD_RATES`] entry and still snap to
/// it. Half an fps separates every neighbouring pair in the table (23.976/24 are
/// 0.024 apart, so both fall inside one another's window — which is exactly why
/// the match must be nearest-wins, not first-wins).
const RATE_TOLERANCE_FPS: f64 = 0.5;
/// Detect the constant frame rate from the median presentation delta, snapping
/// to the nearest standard rate. Falls back to a 90 kHz timescale with a rounded
/// duration when nothing matches (non-standard / too few samples).
@@ -603,24 +689,11 @@ fn detect_rate(samples: &[Sample]) -> (u32, u32) {
deltas.sort_unstable();
let median = deltas[deltas.len() / 2];
let fps = NS as f64 / median as f64;
// Snap to the NEAREST standard rate inside the tolerance window, not the
// first one inside it. First-match made the answer depend on table order:
// every 1000/1001 rate sits within 0.5 fps of its integer twin and precedes
// it, so an exact 24.000 / 30.000 / 60.000 fps source was always declared
// 24000/1001, 30000/1001, 60000/1001 — a 0.1% timing error over the whole
// track. Nearest-match is order-independent, so the fix cannot be undone by
// someone appending a rate to STD_RATES (which is why it is preferred over
// simply reordering the table).
let mut best: Option<(u32, u32, f64)> = None;
for &(ts, dur, rate) in STD_RATES {
let d = (fps - rate).abs();
if d < RATE_TOLERANCE_FPS && best.is_none_or(|(_, _, best_d)| d < best_d) {
best = Some((ts, dur, d));
if (fps - rate).abs() < 0.5 {
return (ts, dur);
}
}
if let Some((ts, dur, _)) = best {
return (ts, dur);
}
let dur = ((median as i128 * 90_000) / NS as i128).max(1) as u32;
(90_000, dur)
}
@@ -748,37 +821,29 @@ fn build_dinf() -> Vec<u8> {
bx(b"dinf", &dref)
}
/// Colour signalling for the `colr` box (nclx, ISO/IEC 14496-12 §12.1.5):
/// (primaries, transfer, matrix, full_range) as ITU-T H.273 code points. `None`
/// when the stream carries no usable colour info.
///
/// The code points come from [`crate::mux::mkv::cicp_for_video`] — the single
/// resolver EVERY sink shares (measured bitstream CICP first, then the coarse
/// `ColorSpace` enum with the HDR-driven transfer override). This box must never
/// carry its own copy of that mapping: the copy that used to live here had drifted
/// to hardcode transfer 16 (SMPTE ST 2084 / PQ) for all BT.2020 — tagging an HLG
/// title, whose transfer is 18 (ARIB STD-B67), as PQ — and transfer 6 (BT.601) for
/// BT.470 System B/G, whose transfer is 5. Both disagreed with the MKV sink and
/// the FVI sidecar for the same disc.
/// Colour signalling for the `colr` box (nclx): (primaries, transfer, matrix,
/// full_range). `None` when the stream carries no usable colour info.
fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> {
let DiscStream::Video(v) = stream else {
return None;
};
// No measured CICP and no colorimetry from the playlist → nothing usable to
// signal. The shared resolver returns the CICP "unspecified" triple (2/2/2)
// for that case; an ABSENT `colr` box already means exactly that, so omit the
// box rather than write it (unchanged behaviour for this sink).
if v.measured_cicp.is_none() && v.color_space == crate::disc::ColorSpace::Unknown {
return None;
if let Some(c) = v.measured_cicp {
return Some((
c.primaries as u16,
c.transfer as u16,
c.matrix as u16,
c.range == 2,
));
}
let (matrix, transfer, primaries, range) = crate::mux::mkv::cicp_for_video(v);
Some((
primaries as u16,
transfer as u16,
matrix as u16,
// MeasuredCicp/Matroska Range: 2 = full, 1 = limited (the disc norm).
range == 2,
))
use crate::disc::ColorSpace::*;
let cicp = match v.color_space {
Bt709 => (1, 1, 1),
Bt2020 => (9, 16, 9),
Bt470bg => (5, 6, 5),
Smpte170m => (6, 6, 6),
Unknown => return None,
};
Some((cicp.0, cicp.1, cicp.2, false))
}
/// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` +
@@ -1162,6 +1227,113 @@ mod tests {
);
}
/// Dropping the undescribable audio track keeps the export succeeding (its
/// video is fine), but the crate must not then keep CLAIMING that stream:
/// `mp4_fit_report` — the only structured report — still lists it as
/// included, so a caller printing the plan reports a successful export of a
/// file with no audio at all.
///
/// `final_report()` must therefore describe the FILE (the stream moved to
/// `skipped` with `UndescribableAudio`), and `undelivered_streams()` — which
/// the driver folds into `MuxOutcome::undelivered_streams` — must name it so
/// the loss is programmatic, not just a log line.
///
/// Mutation check: stop recording the drop in `finish()` and the plan and the
/// file disagree again with nothing but a `tracing::warn` between them.
#[test]
fn dropped_audio_track_is_reported_not_just_logged() {
let t = title(
vec![hevc_video(), audio(Codec::Ac3, "eng")],
vec![Some(vec![1, 2, 3, 4]), None],
);
// The PRE-mux plan promises the audio stream. It cannot know better: the
// codec fits, only the frames turn out to be unparseable.
let plan = fit_report(&t);
assert_eq!(plan.included, vec![0, 1], "the plan promises both streams");
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
s.write(&frame(0, 0, true, vec![0xAB; 800])).unwrap();
// Not an AC-3 syncframe — `dolby_sample_entry` can never parse it.
s.write(&frame(1, 0, true, vec![0x5Au8; 64])).unwrap();
assert!(
s.undelivered_streams().is_empty(),
"nothing is decided before finish()"
);
s.finish().unwrap();
let actual = s.final_report();
assert_eq!(
actual.included,
vec![0],
"the post-mux report must list only the video the file actually carries"
);
assert!(
actual
.skipped
.contains(&(1, Mp4SkipReason::UndescribableAudio)),
"the dropped audio stream must appear as skipped with its reason: {:?}",
actual.skipped
);
assert_eq!(
s.undelivered_streams(),
vec![1],
"the driver's programmatic loss signal must name stream 1"
);
}
/// `mvhd.next_track_id` must EXCEED every track_ID in the file (ISO/IEC
/// 14496-12 §8.2.2). It was derived from the retained track COUNT, so a
/// drop at `finish()` made it collide with a live id: ids [1, 3] retained →
/// count 2 → next_track_id 3, which is track 3. A tool appending a track with
/// that id creates a duplicate.
#[test]
fn mvhd_next_track_id_exceeds_every_retained_track_id() {
let t = title(
vec![
hevc_video(),
audio(Codec::Ac3, "eng"), // track_id 2 — gets no samples, dropped
audio(Codec::Ac3, "fra"), // track_id 3 — survives
],
vec![Some(vec![1, 2, 3, 4]), None, None],
);
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
s.write(&frame(0, 0, true, vec![0xAB; 800])).unwrap();
// Nothing for stream 1; stream 2 gets real AC-3.
s.write(&frame(2, 0, true, ac3_frame())).unwrap();
s.write(&frame(2, 32_000_000, true, ac3_frame())).unwrap();
s.finish().unwrap();
// The middle track really was dropped (ids 1 and 3 retained).
assert_eq!(s.undelivered_streams(), vec![1]);
assert!(
s.final_report()
.skipped
.contains(&(1, Mp4SkipReason::NoSamples))
);
let retained_ids: Vec<u32> = s.tracks.iter().map(|t| t.track_id).collect();
assert_eq!(retained_ids, vec![1, 3]);
let buf = s.writer.into_inner();
let boxes = walk(&buf);
let (_, ms, msz) = *boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov = &buf[ms + 8..ms + msz];
// mvhd is moov's first child; next_track_id is its last 4 bytes.
let mvhd_size = u32::from_be_bytes([moov[0], moov[1], moov[2], moov[3]]) as usize;
assert_eq!(&moov[4..8], b"mvhd");
let next_id = u32::from_be_bytes([
moov[mvhd_size - 4],
moov[mvhd_size - 3],
moov[mvhd_size - 2],
moov[mvhd_size - 1],
]);
assert!(
retained_ids.iter().all(|&id| next_id > id),
"next_track_id {next_id} must exceed every used id {retained_ids:?}"
);
assert_eq!(next_id, 4);
}
#[test]
fn av_mux_has_two_traks_and_tiles() {
let t = title(
@@ -1245,171 +1417,4 @@ mod tests {
.collect();
assert_eq!(detect_rate(&samples), (24000, 1001));
}
// ── colr (ITU-T H.273 / CICP) ────────────────────────────────────────────
/// Decode `(primaries, transfer, matrix, full_range)` back out of the `colr`
/// nclx box of an emitted visual sample entry, so the assertion is on the
/// bytes that reach the file. `None` when no `colr` box was written.
fn colr_of(v: &VideoStream) -> Option<(u16, u16, u16, bool)> {
// `codec_private` is a byte pattern that cannot itself contain "colr".
let stsd = build_visual_stsd(
Codec::Hevc,
&[0u8; 8],
1920,
1080,
video_colr(&DiscStream::Video(v.clone())),
);
let i = stsd.windows(4).position(|w| w == b"colr")?;
let p = &stsd[i + 4..];
assert_eq!(&p[..4], b"nclx", "only the nclx colour type is written");
Some((
u16::from_be_bytes([p[4], p[5]]),
u16::from_be_bytes([p[6], p[7]]),
u16::from_be_bytes([p[8], p[9]]),
p[10] & 0x80 != 0,
))
}
fn video_stream() -> VideoStream {
match hevc_video() {
DiscStream::Video(v) => v,
_ => unreachable!(),
}
}
#[test]
fn colr_transfer_is_hlg_for_an_hlg_title_not_pq() {
// ITU-T H.273 Table 3: transfer 18 = ARIB STD-B67 (HLG), 16 = SMPTE
// ST 2084 (PQ). `video_colr` hardcoded 16 for every BT.2020 stream, so an
// HLG title got the PQ EOTF applied to it — while the MKV sink of the same
// rip correctly wrote 18.
let mut v = video_stream();
v.hdr = HdrFormat::Hlg;
v.color_space = ColorSpace::Bt2020;
assert_eq!(
colr_of(&v).expect("colr written"),
(9, 18, 9, false),
"BT.2020 primaries/matrix (9) with the HLG transfer (18)"
);
}
#[test]
fn colr_transfer_is_bt470bg_for_a_pal_dvd_not_bt601() {
// ITU-T H.273: transfer 5 = ITU-R BT.470-6 System B/G, 6 = BT.601.
// A PAL DVD is System B/G in all three code points.
let mut v = video_stream();
v.hdr = HdrFormat::Sdr;
v.color_space = ColorSpace::Bt470bg;
assert_eq!(colr_of(&v).expect("colr written"), (5, 5, 5, false));
}
#[test]
fn colr_agrees_with_the_shared_cicp_resolver_for_every_color_space() {
// One resolver, every sink: the `colr` box must carry exactly what
// `mkv::cicp_for_video` returns for the same stream, so an mp4:// rip and
// an mkv:// rip of one title can never describe different colour.
for cs in [
ColorSpace::Bt709,
ColorSpace::Bt2020,
ColorSpace::Bt470bg,
ColorSpace::Smpte170m,
] {
for hdr in [
HdrFormat::Sdr,
HdrFormat::Hdr10,
HdrFormat::Hdr10Plus,
HdrFormat::Hlg,
HdrFormat::DolbyVision,
] {
let mut v = video_stream();
v.color_space = cs;
v.hdr = hdr;
let (m, t, p, r) = crate::mux::mkv::cicp_for_video(&v);
assert_eq!(
colr_of(&v).expect("colr written"),
(p as u16, t as u16, m as u16, r == 2),
"colr disagrees with the shared resolver for {cs:?} / {hdr:?}"
);
}
}
// Unknown colorimetry: no usable colour info, so no `colr` box at all —
// an absent box and an "unspecified" (2/2/2) box mean the same thing, and
// writing nothing is what this sink has always done.
let mut v = video_stream();
v.color_space = ColorSpace::Unknown;
assert!(colr_of(&v).is_none());
}
// ── detect_rate ──────────────────────────────────────────────────────────
/// Mux a video-only MP4 whose samples are exactly `delta_ns` apart and return
/// the `(mdhd.timescale, stts.sample_delta)` decoded out of the emitted file.
fn muxed_video_timing(delta_ns: i64) -> (u32, u32) {
let t = title(vec![hevc_video()], vec![Some(vec![1, 2, 3, 4])]);
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
for i in 0..10i64 {
s.write(&frame(0, i * delta_ns, i == 0, vec![0xAB; 16]))
.unwrap();
}
s.finish().unwrap();
let buf = s.writer.into_inner();
// One trak → exactly one `mdhd` and one `stts`.
let i = buf.windows(4).position(|w| w == b"mdhd").expect("mdhd");
// After the type: version+flags(4), creation(8), modification(8), timescale(4).
let timescale = u32::from_be_bytes(buf[i + 24..i + 28].try_into().unwrap());
let j = buf.windows(4).position(|w| w == b"stts").expect("stts");
// After the type: version+flags(4), entry_count(4), sample_count(4), sample_delta(4).
let delta = u32::from_be_bytes(buf[j + 16..j + 20].try_into().unwrap());
(timescale, delta)
}
#[test]
fn exact_integer_frame_rates_are_not_declared_as_their_fractional_twins() {
// `detect_rate` returned the FIRST STD_RATES entry within 0.5 fps, and each
// 1000/1001 rate precedes its integer twin, so 24.000 / 30.000 / 60.000
// were always written as 24000/1001, 30000/1001 and 60000/1001. The
// declared timescale/sample_delta is read back out of the muxed file.
for (delta_ns, want) in [
(41_666_667i64, (24u32, 1u32)), // 24.000
(33_333_333, (30, 1)), // 30.000
(16_666_667, (60, 1)), // 60.000
(40_000_000, (25, 1)), // 25.000
(20_000_000, (50, 1)), // 50.000
(41_708_333, (24_000, 1001)), // 23.976
(33_366_667, (30_000, 1001)), // 29.97
(16_683_333, (60_000, 1001)), // 59.94
] {
assert_eq!(
muxed_video_timing(delta_ns),
want,
"{delta_ns} ns/frame must be declared as {want:?}"
);
}
}
#[test]
fn detect_rate_picks_the_nearest_std_rate_regardless_of_table_order() {
// Order-independence is the property that keeps this fixed: every entry
// must resolve to itself when its own exact rate is measured, no matter
// where it sits in STD_RATES. A first-match rule can only satisfy this if
// the table happens to be ordered, which is what broke.
for &(ts, dur, rate) in STD_RATES {
let d = (NS as f64 / rate).round() as i64;
let samples: Vec<Sample> = (0..10)
.map(|i| Sample {
offset: 0,
size: 1,
pts_ns: i as i64 * d,
keyframe: i == 0,
})
.collect();
assert_eq!(
detect_rate(&samples),
(ts, dur),
"{rate} fps must resolve to its own STD_RATES entry"
);
}
}
}