mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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.
268 lines
14 KiB
Markdown
268 lines
14 KiB
Markdown
# `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.
|