mux/mp4: faststart on by default (reserve-and-fill)

moov now precedes mdat. At create() reserve a moov-sized hole (a free
box) between ftyp and mdat: reserve = round_up_4MB(16 B/sample ×
est_samples) + 4MB buffer, floored at 8MB. Because the hole precedes
mdat, sample offsets are fixed from the start — no rewrite, no offset
patch. finish() writes moov into the hole and pads the slack with a free
box; if the estimate is blown it falls back to moov-at-end. Streams over
HTTP without a pre-fetch. Reserve-math + box-order tests added.
This commit is contained in:
Matthew Jackson
2026-07-18 21:43:44 -07:00
parent 8e6114cd2a
commit a947439171
+133 -13
View File
@@ -1,10 +1,13 @@
//! Progressive MP4 (ISO-BMFF) muxer — `mp4://`.
//!
//! Writes `ftyp` + `mdat` + `moov` (moov-at-end): sample data streams straight
//! into `mdat` as frames arrive, per-track sample tables accumulate in memory,
//! and the `moov` index is written at `finish()` after seeking back to patch the
//! `mdat` size. Unlike the fragmented `fmp4` sibling (DASH init+moof/mdat), this
//! is a single self-contained file — the shape people mean by "an 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
//!
@@ -34,6 +37,56 @@ pub use read::Mp4Reader;
/// Nanoseconds per second — PTS is carried in ns, media timescales are Hz.
const NS: i64 = 1_000_000_000;
// ── faststart reserve sizing ─────────────────────────────────────────────────
//
// Faststart is on by default: reserve a `moov`-sized hole between `ftyp` and
// `mdat`, then write `moov` into it at finish and pad the slack with a `free`
// box. Because the hole precedes `mdat`, sample offsets are fixed from the start
// — no rewrite, no offset patch. `moov` is dominated by the per-sample tables
// (`stsz` 4 B + `co64` 8 B + `ctts`/`stts`/`stss` ~4 B, one-sample-per-chunk), so
// its size scales with the total sample count.
/// Estimated `moov` bytes per sample (calibrated against real discs; bias high).
const BYTES_PER_SAMPLE: u64 = 16;
/// Safety buffer added on top of the rounded estimate.
const RESERVE_BUFFER: u64 = 4 << 20; // 4 MiB
/// Floor for the reserve (covers short/unknown-duration titles).
const RESERVE_FLOOR: u64 = 8 << 20; // 8 MiB
/// Rounding granularity for the reserve.
const RESERVE_GRAIN: u64 = 4 << 20; // 4 MiB
fn round_up_grain(x: u64) -> u64 {
x.div_ceil(RESERVE_GRAIN) * RESERVE_GRAIN
}
/// Estimate the faststart hole: `round_up_4MB(bytes_per_sample × est_samples)`
/// plus a 4 MiB buffer, floored at 8 MiB. `est_samples` comes from the title
/// duration × each included track's frame rate.
fn estimate_reserve(title: &DiscTitle, included: &[usize]) -> u64 {
let dur = title.duration_secs.max(1.0);
let mut est_samples = 0f64;
for &i in included {
match &title.streams[i] {
DiscStream::Video(v) => {
let (n, d) = v.frame_rate.as_fraction();
let fps = if n > 0 && d > 0 {
n as f64 / d as f64
} else {
24.0
};
est_samples += dur * fps;
}
DiscStream::Audio(a) => {
// ~1536 samples per (E-)AC-3 frame.
est_samples += dur * (a.sample_rate.hz() / 1536.0);
}
DiscStream::Subtitle(_) => {}
}
}
let est = (est_samples as u64).saturating_mul(BYTES_PER_SAMPLE);
round_up_grain(est).max(RESERVE_FLOOR) + RESERVE_BUFFER
}
/// One accumulated sample's bookkeeping (the mdat bytes are already on disk).
struct Sample {
/// Absolute file offset of the sample's first byte.
@@ -150,10 +203,15 @@ pub struct Mp4Sink<W: Write + Seek> {
tracks: Vec<Track>,
/// `title.streams` index → position in `tracks`, or `None` if excluded.
route: Vec<Option<usize>>,
/// File offset of the `mdat` box header (for the 64-bit size patch).
/// File offset of the `mdat` box header (for the 64-bit size patch). With
/// faststart this is `ftyp_len + reserve` (the hole precedes `mdat`).
mdat_start: u64,
/// Running `mdat` payload size in bytes.
mdat_payload: u64,
/// File offset where the reserved faststart hole begins (right after `ftyp`).
hole_start: u64,
/// Reserved hole size in bytes (`moov` + trailing `free` padding go here).
reserve: u64,
finished: bool,
}
@@ -222,8 +280,20 @@ impl<W: Write + Seek> Mp4Sink<W> {
}
let ftyp = build_ftyp(video_codec);
let mdat_start = ftyp.len() as u64;
writer.write_all(&ftyp)?;
let hole_start = ftyp.len() as u64;
// Faststart: reserve a `moov`-sized hole (a `free` box) between `ftyp`
// and `mdat`. Only the 8-byte `free` header is written now; the body is
// left as a hole (sparse) and overwritten at finish() by moov + a smaller
// `free`. `mdat` therefore starts at a fixed offset, so co64 offsets are
// correct as written — no rewrite, no patch.
let reserve = estimate_reserve(title, &report.included);
writer.write_all(&(reserve as u32).to_be_bytes())?;
writer.write_all(b"free")?;
let mdat_start = hole_start + reserve;
writer.seek(SeekFrom::Start(mdat_start))?;
// mdat with 64-bit largesize: size=1 signals "largesize follows"; the
// 8-byte largesize placeholder is patched at finish() once known.
writer.write_all(&1u32.to_be_bytes())?;
@@ -237,12 +307,14 @@ impl<W: Write + Seek> Mp4Sink<W> {
route,
mdat_start,
mdat_payload: 0,
hole_start,
reserve,
finished: false,
})
}
/// Assemble and write the `moov` box from every track's sample tables.
fn write_moov(&mut self) -> io::Result<()> {
/// 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).
let movie_ts = 90_000u32;
let mut movie_dur = 0u64;
@@ -258,8 +330,7 @@ impl<W: Write + Seek> Mp4Sink<W> {
for trak in traks {
moov.extend_from_slice(&trak);
}
let moov = bx(b"moov", &moov);
self.writer.write_all(&moov)
bx(b"moov", &moov)
}
}
@@ -310,8 +381,31 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
let mdat_total = 16 + self.mdat_payload;
self.writer.seek(SeekFrom::Start(self.mdat_start + 8))?;
self.writer.write_all(&mdat_total.to_be_bytes())?;
let moov = self.build_moov();
let moov_len = moov.len() as u64;
let gap = self.reserve.checked_sub(moov_len);
// Faststart when moov fits the reserved hole with either an exact fill or
// room for a valid (≥8-byte) `free` box in the slack. Otherwise fall back
// to moov-at-end (rare — the +4 MiB buffer makes this near-impossible).
match gap {
Some(g) if g == 0 || g >= 8 => {
self.writer.seek(SeekFrom::Start(self.hole_start))?;
self.writer.write_all(&moov)?;
if g >= 8 {
// Fill the slack with a `free` box (header only; body is the
// existing hole, ignored by parsers).
self.writer.write_all(&(g as u32).to_be_bytes())?;
self.writer.write_all(b"free")?;
}
}
_ => {
// Fallback: moov-at-end. The reserved hole stays a `free` box.
self.writer.seek(SeekFrom::End(0))?;
self.writer.write_all(&moov)?;
}
}
self.writer.seek(SeekFrom::End(0))?;
self.write_moov()?;
self.writer.flush()
}
@@ -935,7 +1029,12 @@ mod tests {
let buf = s.writer.into_inner();
let boxes = walk(&buf);
let types: Vec<[u8; 4]> = boxes.iter().map(|(t, _, _)| *t).collect();
assert_eq!(types, vec![*b"ftyp", *b"mdat", *b"moov"]);
// Faststart layout: ftyp, moov, free (reserve slack), mdat — moov BEFORE mdat.
assert_eq!(
types,
vec![*b"ftyp", *b"moov", *b"free", *b"mdat"],
"faststart: moov precedes mdat"
);
// moov must contain exactly two trak boxes.
let (_, ms, msz) = *boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov = &buf[ms + 8..ms + msz];
@@ -962,6 +1061,27 @@ mod tests {
assert_eq!(mdat_sz, 16 + 800 + 400 + ac3_frame().len() * 2);
}
#[test]
fn reserve_rounds_to_4mb_plus_buffer() {
// round_up_4MB(x) + 4 MiB, floored at 8 MiB.
assert_eq!(round_up_grain(1), 4 << 20);
assert_eq!(round_up_grain(4 << 20), 4 << 20);
assert_eq!(round_up_grain((4 << 20) + 1), 8 << 20);
// A 2 hr feature, 24 fps video + one AC-3 track: ~173k + ~225k samples
// × 16 B ≈ 6.4 MB → round to 8 MB → +4 MB buffer = 12 MB (floor also 8+4).
let mut t = title(vec![hevc_video(), audio(Codec::Ac3, "eng")], vec![]);
t.duration_secs = 7200.0;
let r = estimate_reserve(&t, &[0, 1]);
assert!(
r % (4 << 20) == 0,
"reserve is 4 MiB-aligned + 4 MiB buffer"
);
assert!(
r >= 12 << 20 && r <= 20 << 20,
"≈12-16 MB for a 2h feature, got {r}"
);
}
#[test]
fn detect_rate_snaps_23_976() {
let d = 41_708_333;