Files
libfreemkv/docs/mp4-mux.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

268 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `src/mux/mp4/mod.rs` — progressive MP4 muxer notes
Long-form rationale relocated from source comments (see `ci/comment-guard.py`
audience caps). Each section is pointed to from a short `//` comment at its
call site.
## Module overview
Progressive MP4 (ISO-BMFF) muxer for `mp4://`. Writes `ftyp` + `moov` +
`mdat` with faststart on by default: a `moov`-sized hole is reserved between
`ftyp` and `mdat` at the start (so sample offsets are fixed and never
rewritten), sample data streams into `mdat`, and at `finish()` the `moov`
index is written into the reserved hole with a trailing `free` box for the
slack. If the estimate is blown, it falls back to moov-at-end. Unlike the
fragmented `fmp4` sibling (DASH init+moof/mdat), this is a single
self-contained file — the shape people mean by "an mp4" — and moov-first
means it streams over HTTP without a pre-fetch.
### Track model
One video track (HEVC / H.264) plus every audio track whose codec has a
clean MP4 mapping (AC-3 → `ac-3`/`dac3`, E-AC-3 → `ec-3`/`dec3`, DTS/DTS-HD →
`dtsc`/`dtsh`/`ddts`). This is the fit oracle: a codec MP4 can't carry
(TrueHD, LPCM) or that has no sample entry here is excluded, never silently
dropped — `fit_report` lets the CLI enumerate exactly what was left out and
why. Video NALs pass through unchanged (the demux hands us length-prefixed
hvcC/avcC framing — already MP4's form). Decode timestamps are derived (the
pipeline carries presentation PTS only): video is constant-frame-rate on
disc, so a constant decode duration + signed `ctts` reproduces the B-frame
reorder exactly; audio has no reorder, so per-sample durations come straight
from the PTS deltas.
Reference: ISO/IEC 14496-12 (ISO base media file format), 14496-15
(avcC/hvcC).
## `faststart_fits`
Whether `gap` bytes of leftover reserved-hole slack (after `moov` is written
into it) can be closed: an exact fill (`0`) needs no `free` box at all, and
`8+` bytes is enough to hold one (an ISO-BMFF box header is 8 bytes: 4-byte
size + 4-byte type). A gap of 1–7 bytes cannot be expressed as any box, so
`Mp4Sink::finish` must fall back to moov-at-end instead of writing a `free`
box that lies about its own size.
## `Mp4Sink::final_report`
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, reports
a successful export of a file with no audio. Ask this after `finish()`
before telling anyone what was written.
## `Mp4Sink::undelivered_streams`
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.
## `STD_RATES`
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.
## `RATE_TOLERANCE_FPS`
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).
Two mutants in `detect_rate`'s snapping loop — `d < RATE_TOLERANCE_FPS` and
the tie-break `d < best_d`, each flipped to `<=` — are not closed by any test
here, and are believed unreachable rather than merely untested: both require
an f64 distance computed as `(fps - rate).abs()` to land on EXACTLY `0.5`, or
on an exact tie between two candidate distances, where `fps` is `1e9 /
median` for an INTEGER nanosecond `median`. A brute-force search (median
from 1 to 1e8 ns, every `STD_RATES` entry) found no integer median whose
measured distance is bit-exact `0.5`, nor one producing an exact tie between
neighbouring entries: the target real number (e.g. `1e9 / 24.5`) is never
itself an integer, so no integer median's true quotient rounds to a double
that is bit-identical to the boundary. If a future change makes either
boundary reachable (e.g. by accepting a caller-supplied `median` directly
instead of deriving it from PTS deltas), revisit this.
## `video_colr`
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.
## Test: `a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track`
ISO/IEC 14496-12 makes width and height mandatory in both `tkhd` (8.3.2) and
VisualSampleEntry (12.1.3), so unlike Matroska — which simply omits the
optional PixelWidth/PixelHeight elements — MP4 has nothing to leave out.
Writing zeros produces a structurally complete file that passes every
container check and that no player can render, with no error anywhere: a
wrong answer that looks like a successful rip.
`Resolution::pixels()` returns `Option` for this reason. It used to return a
fabricated 1920x1080, then `(0, 0)`; the zero pair reads as a usable value,
so this sink stored it and serialised it, and the guard that two of the
three sinks have was never needed here and so was never written.
## `find_child` (test helper)
A minimal box walker duplicated here for tests — the reader's own
box-walking (`find_box`) lives in `read.rs` and is private to that module.
## Test: `audio_track_with_no_parseable_sample_entry_is_dropped_not_emitted_empty`
An audio track whose frames never yield a parseable sample entry must be
dropped from moov, NOT written as an stsd declaring entry_count=1 around an
empty entry — that is a structurally invalid mp4 returned as success.
The video track must survive, and no audio frame may be lost from mdat on
the way: `write()` previously returned `Ok(())` without recording the
sample, so leading audio frames vanished silently.
Mutation check: restore `write()`'s early `return Ok(())` and the
unparseable bytes never reach mdat; drop `finish()`'s describability retain
and moov gains a second trak carrying an empty sample entry.
## Test: `dropped_audio_track_is_reported_not_just_logged`
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: `mvhd_next_track_id_exceeds_every_retained_track_id`
`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: `pack_language_packs_three_lowercase_letters_into_15_bits`
Every bit-twiddle in `pack_language`'s valid-input path (both shift amounts,
both `-0x60` subtractions on the first two letters, and the OR that
assembles them) pinned against hand-computed packed values, using letters
other than 'a' so a `-`↔`/` flip on the per-letter offset is visible:
`'a' - 0x60 == 1 == 'a' / 0x60`, so a fixture starting with 'a' cannot tell
subtraction from division apart on that letter.
Three mutants of `pack_language` are NOT killed by this test, or by any
other — they are equivalent, proven by construction rather than merely
unobserved:
1. `(b[0] - 0x60) as u16` → `(b[0] + 0x60) as u16` on the FIRST letter (the
one shifted `<< 10`). The two results differ by exactly `0x60 * 2 = 192 =
3 * 64`, and multiplying by `1 << 10` then truncating to `u16` is
arithmetic mod `65536`; since `192 * 1024 = 196_608 = 3 * 65536`, the `+`
and `-` forms land on the identical `u16` for every possible byte, not
just the ones this fixture picks. (The same swap on the SECOND letter,
shifted only `<< 5`, is very much NOT equivalent — `192 * 32 = 6144` is
not a multiple of 65536 — which is why that one IS caught above and only
the first letter's `+` survives.)
2. Both `|` → `^` mutations that combine the three shifted fields. The three
components are each a lowercase letter minus `0x60`, i.e. in `1..=26`,
which fits in 5 bits (`0..=31`); shifted by `0`, `5` and `10` they occupy
disjoint bit ranges for every valid input, and OR and XOR agree exactly
when their operands share no set bit.
## Test: `estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback`
`estimate_reserve`'s per-stream fps comes from `v.frame_rate.as_fraction()`
only when BOTH `n > 0 && d > 0`; otherwise it falls back to a flat 24.0.
`FrameRate::Unknown` is the only variant with `n == 0` (it reports `(0,
1)`), so it is the one real input that takes the fallback branch — every
other variant has `n, d > 0` and must use its OWN fps, not 24.0.
A film-rate title (23.976 fps) is the fixture that can tell "used its own
fps" apart from "used the 24.0 fallback": at 24 fps flat those two numbers
coincide, so a guard broken by an operator flip (`&&`→`||`, `>`→`==`/`<`/
`>=`) would be invisible on it. Duration 76_500 s is chosen so the two
answers land in different 4 MiB reserve grains, not just differ before
rounding.
## Test: `estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero`
The complementary case: `FrameRate::Unknown` (n=0) is the one real input
meant to take the fallback branch. If the guard's `n > 0` is weakened so
zero passes it (`==0`, `>=0`) or the `&&` becomes `||`, the code computes `0
/ 1 = 0.0` fps instead of falling back to 24.0 — collapsing the estimate to
near-zero samples instead of a reasonable guess.
## Test: `estimate_reserve_models_dts_at_512_samples_per_frame_not_1536`
`estimate_reserve` models a DTS/DTS-HD-MA/DTS-HD-HR audio unit as 512
samples (a DTS core AU is `(nblks+1)*32`), a THIRD of the 1536-sample
(E-)AC-3 default the `_` arm uses for everything else. Deleting the DTS
match arm silently reverts every DTS track to the AC-3 model, which
under-reserves a DTS-heavy title's sample table 3x and can push a real mux
onto the moov-at-end fallback.
## Test: `tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types`
`build_video_trak_full` and `build_audio_trak_full` both compute
`tkhd.duration = secs * MOVIE_TIMESCALE` — a SEPARATE multiplication from
the media-timescale duration in `mdhd`, because `tkhd.duration` lives in the
movie's 90 kHz clock (ISO/IEC 14496-12 §8.3.2). Read it straight out of the
emitted `tkhd` bytes (offset 28 in a version-1 tkhd: version+flags (4) +
creation(8) + modification(8) + track_id(4) + reserved(4) = 28), so the
assertion is on what the file says, not a second copy of the formula.
## Test: `audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last`
`audio_sample_durations`'s per-sample ticks are `ns * ts / NS`, and the
inter-sample delta is `ticks(next) - ticks(prev)` (clamped at 0), with the
LAST duration repeated for the trailing sample. PTS deltas are chosen so
every tick value is an exact integer and the two windows differ (32 ms then
another 32 ms from a non-zero base), so a `-`↔`+` flip on the delta is
visible on the second window even though it is invisible on the first
(whose previous tick is 0).
## Test: `audio_sample_durations_single_sample_uses_timescale_over_30_fallback`
The single-sample fallback (`durs.last()` is `None`) pushes `timescale /
30`, guarded by `!samples.is_empty()`. A single sample exercises both:
`windows(2)` yields nothing, so the fallback branch is the only source of a
duration at all.
## Test: `detect_rate_needs_at_least_two_samples_not_more`
Fewer than 2 samples can't measure a delta at all: fixed 90 kHz/3003
fallback (not 90 kHz/anything else, and not a panic on an empty median).
EXACTLY 2 samples is the boundary itself, not just "some samples fewer than
2" — a `<`→`==`/`<=` mutant on `samples.len() < 2` forces the fallback for 2
samples too, even though they carry a perfectly good 25 fps delta.
## Test: `detect_rate_filters_zero_deltas_not_just_negative_ones`
Only POSITIVE deltas are considered (`filter(|&d| d > 0)`). A `>`→`>=`
mutant lets zero deltas (duplicate/out-of-order PTS) through, which shifts
which element `deltas[deltas.len() / 2]` lands on. Three duplicate
timestamps plus one real 25 fps delta make the shift land on the zero
itself: median becomes 0, `fps` becomes `NS / 0 = inf`, no `STD_RATES` entry
is within tolerance of infinity, and the fallback path's `median` of 0
forces its duration floor of 1 — a completely different, clearly-wrong
answer from the correct (25, 1).
## Test: `detect_rate_fallback_duration_is_median_times_90khz_over_ns`
A rate with no nearby `STD_RATES` entry takes the fallback branch:
`timescale = 90_000`, `duration = (median * 90_000) / NS`. 5 fps (200
ms/frame) is chosen so `median * 90_000` is an exact multiple of `NS`,
giving a clean expected duration that a `*`↔`+`/`/` flip on either operator,
or `/`↔`%`, cannot coincidentally reproduce.