Three pack_language mutants and two detect_rate boundary mutants survive mutation testing with no test able to close them, and it's not for lack of trying: they're equivalent by construction. Recording the proofs next to the code so nobody re-chases them: - (b[0] - 0x60) as u16, shifted << 10 then truncated to u16, is congruent mod 65536 to (b[0] + 0x60) as u16 shifted the same way, because 0x60 * 2 * 1024 is an exact multiple of 65536. The same swap on the second letter (shifted only << 5) is NOT equivalent, which is why only the first letter's mutant survives. - The two | with ^ mutations that OR the three packed fields together are equivalent because the fields (a lowercase letter minus 0x60, so 1..=26) always fit in 5 bits and never share a set bit once shifted into their 0/5/10 positions. - detect_rate's tolerance and tie-break comparisons only diverge from their <= mutants on an exact 0.5 fps distance or an exact tie, and a brute-force search over every achievable integer-nanosecond median found no case that lands on either boundary bit-exactly.
2319 lines
95 KiB
Rust
2319 lines
95 KiB
Rust
//! Progressive MP4 (ISO-BMFF) muxer — `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).
|
||
|
||
use crate::disc::{Codec, DiscTitle, Stream as DiscStream};
|
||
use crate::pes::{PesFrame, Stream};
|
||
use std::io::{self, Seek, SeekFrom, Write};
|
||
|
||
mod audio;
|
||
mod boxes;
|
||
mod read;
|
||
use boxes::{bx, fullbox};
|
||
pub use read::Mp4Reader;
|
||
|
||
/// Nanoseconds per second — PTS is carried in ns, media timescales are Hz.
|
||
const NS: i64 = 1_000_000_000;
|
||
|
||
/// Movie (mvhd) timescale in Hz. `tkhd.duration` is expressed in THIS timescale
|
||
/// (ISO/IEC 14496-12 §8.3.2), not the track's own media timescale.
|
||
const MOVIE_TIMESCALE: u32 = 90_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
|
||
/// Largest reserve expressible in a `free` box's 32-bit size field, rounded down
|
||
/// to a whole grain.
|
||
const RESERVE_CAP: u64 = (u32::MAX as u64 / RESERVE_GRAIN) * RESERVE_GRAIN;
|
||
|
||
/// Round up to `RESERVE_GRAIN`, saturating rather than wrapping. `div_ceil` then
|
||
/// multiply overflows for inputs within one grain of `u64::MAX`, which would turn
|
||
/// an enormous estimate into a tiny reserve — the opposite of the intent.
|
||
fn round_up_grain(x: u64) -> u64 {
|
||
x.div_ceil(RESERVE_GRAIN).saturating_mul(RESERVE_GRAIN)
|
||
}
|
||
|
||
/// 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.
|
||
fn faststart_fits(gap: u64) -> bool {
|
||
gap == 0 || gap >= 8
|
||
}
|
||
|
||
/// 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) => {
|
||
// Samples per frame differs sharply by codec, and 1.6.0 added DTS
|
||
// to the carried set (audio_fits now admits Dts | DtsHdMa |
|
||
// DtsHdHr). A DTS core AU is (nblks+1)*32 — commonly 512 samples,
|
||
// a third of an (E-)AC-3 frame's 1536 — so modelling every audio
|
||
// track as AC-3 under-reserved a DTS track's sample table 3x and
|
||
// pushed the mux onto the moov-at-end fallback.
|
||
let samples_per_frame = match a.codec {
|
||
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => 512.0,
|
||
_ => 1536.0,
|
||
};
|
||
est_samples += dur * (a.sample_rate.hz() / samples_per_frame);
|
||
}
|
||
DiscStream::Subtitle(_) => {}
|
||
}
|
||
}
|
||
let est = (est_samples as u64).saturating_mul(BYTES_PER_SAMPLE);
|
||
let reserve = round_up_grain(est)
|
||
.max(RESERVE_FLOOR)
|
||
.saturating_add(RESERVE_BUFFER);
|
||
// The hole is a `free` box with a 32-bit size field, so a reserve at or above
|
||
// u32::MAX cannot be expressed: writing it truncated the size and left mdat
|
||
// beyond a box that claimed to be far shorter. Clamp to the largest
|
||
// grain-aligned value the field can hold. No real title comes near this —
|
||
// a 90 GB UHD title estimates a few MiB — but truncating silently produces an
|
||
// unreadable file, so it is bounded rather than trusted.
|
||
reserve.min(RESERVE_CAP)
|
||
}
|
||
|
||
/// One accumulated sample's bookkeeping (the mdat bytes are already on disk).
|
||
struct Sample {
|
||
/// Absolute file offset of the sample's first byte.
|
||
offset: u64,
|
||
/// Sample size in bytes.
|
||
size: u32,
|
||
/// Presentation timestamp in nanoseconds (composition time).
|
||
pts_ns: i64,
|
||
/// True for a sync sample (IDR / keyframe). Always true for audio.
|
||
keyframe: bool,
|
||
}
|
||
|
||
/// Which media class a track carries (drives handler / header-box choice).
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum Media {
|
||
Video,
|
||
Audio,
|
||
}
|
||
|
||
/// One output track: its identity, the inputs its sample entry needs, and its
|
||
/// accumulated samples.
|
||
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`).
|
||
codec_private: Vec<u8>,
|
||
width: u32,
|
||
height: u32,
|
||
colr: Option<(u16, u16, u16, bool)>,
|
||
language: [u8; 2],
|
||
/// Audio sample entry (`ac-3`/`ec-3` + config), built from the first frame.
|
||
audio_entry: Option<Vec<u8>>,
|
||
/// Audio media timescale (Hz), captured with `audio_entry`.
|
||
audio_timescale: u32,
|
||
samples: Vec<Sample>,
|
||
}
|
||
|
||
/// 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,
|
||
/// An audio codec with no MP4 mapping here (TrueHD, LPCM, …). AC-3/E-AC-3 and
|
||
/// DTS/DTS-HD ARE mapped and carried.
|
||
UnmappableAudio,
|
||
/// A secondary/dependent video view (e.g. MVC 3D right eye).
|
||
SecondaryVideo,
|
||
/// 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>,
|
||
/// Excluded `(stream index, reason)`.
|
||
pub skipped: Vec<(usize, Mp4SkipReason)>,
|
||
}
|
||
|
||
/// Compute the fit plan without opening a file. Video: the first primary
|
||
/// HEVC/H.264 track. Audio: every track `audio::audio_fits` carries — the Dolby
|
||
/// family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA). Everything
|
||
/// else is skipped with a reason.
|
||
pub fn fit_report(title: &DiscTitle) -> Mp4FitReport {
|
||
let mut included = Vec::new();
|
||
let mut skipped = Vec::new();
|
||
let mut have_video = false;
|
||
for (i, s) in title.streams.iter().enumerate() {
|
||
match s {
|
||
DiscStream::Video(v) => {
|
||
if v.is_mvc_dependent() {
|
||
skipped.push((i, Mp4SkipReason::SecondaryVideo));
|
||
} else if !have_video && matches!(v.codec, Codec::Hevc | Codec::H264) {
|
||
included.push(i);
|
||
have_video = true;
|
||
} else if have_video {
|
||
// A second primary video (after one was already carried).
|
||
skipped.push((i, Mp4SkipReason::SecondaryVideo));
|
||
} else {
|
||
// First primary video, but an unsupported codec (VC-1/MPEG-2/AV1).
|
||
skipped.push((i, Mp4SkipReason::UnmappableVideo));
|
||
}
|
||
}
|
||
DiscStream::Audio(a) => {
|
||
if audio::audio_fits(a.codec) {
|
||
included.push(i);
|
||
} else {
|
||
skipped.push((i, Mp4SkipReason::UnmappableAudio));
|
||
}
|
||
}
|
||
DiscStream::Subtitle(_) => skipped.push((i, Mp4SkipReason::BitmapSubtitle)),
|
||
}
|
||
}
|
||
Mp4FitReport { included, skipped }
|
||
}
|
||
|
||
/// Pack an ISO 639-2 language ("eng") into the 15-bit mdhd form (bit 15 = 0,
|
||
/// three 5-bit values of `char - 0x60`). Falls back to "und".
|
||
fn pack_language(lang: &str) -> [u8; 2] {
|
||
let b = lang.as_bytes();
|
||
if b.len() != 3 || !b.iter().all(|c| c.is_ascii_lowercase()) {
|
||
return [0x55, 0xC4]; // 'und'
|
||
}
|
||
let v = (((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16);
|
||
v.to_be_bytes()
|
||
}
|
||
|
||
/// Progressive MP4 sink. Owns a seekable writer so it can seek back to patch the
|
||
/// `mdat` size once all samples are written. The CLI wraps the output file in a
|
||
/// bounded-cache `WritebackFile` (like the MKV muxer) so a UHD-scale mux to slow
|
||
/// / network staging doesn't hit the dirty-page burst pathology; the `mdat` patch
|
||
/// is an ordinary backpatch seek, which `WritebackFile` handles the same way it
|
||
/// handles MKV cluster backpatching.
|
||
pub struct Mp4Sink<W: Write + Seek> {
|
||
writer: W,
|
||
title: DiscTitle,
|
||
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). 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,
|
||
/// 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> {
|
||
/// Create the sink over an already-opened seekable `writer`: build the track
|
||
/// plan (fit oracle) and write `ftyp` plus the `mdat` header (64-bit size,
|
||
/// patched at `finish()`).
|
||
pub fn create(mut writer: W, title: &DiscTitle) -> io::Result<Self> {
|
||
let report = fit_report(title);
|
||
let has_video = report
|
||
.included
|
||
.iter()
|
||
.any(|&i| matches!(title.streams[i], DiscStream::Video(_)));
|
||
if !has_video {
|
||
return Err(crate::error::Error::Mp4NoVideoTrack.into());
|
||
}
|
||
|
||
let mut tracks = Vec::new();
|
||
let mut route = vec![None; title.streams.len()];
|
||
let mut video_codec = Codec::Hevc;
|
||
// Track ids are 1-based and assigned in inclusion order. `moov`'s
|
||
// next_track_id is NOT derived from this counter — it is max(track_id) + 1
|
||
// computed after the sample-less retain, since ids are handed out here
|
||
// before any track is dropped.
|
||
for (n, &i) in report.included.iter().enumerate() {
|
||
let track_id = n as u32 + 1;
|
||
route[i] = Some(tracks.len());
|
||
match &title.streams[i] {
|
||
DiscStream::Video(v) => {
|
||
video_codec = v.codec;
|
||
let cp = title
|
||
.codec_privates
|
||
.get(i)
|
||
.and_then(|c| c.clone())
|
||
.ok_or(crate::error::Error::Mp4MissingCodecPrivate)?;
|
||
// ISO/IEC 14496-12 makes width/height mandatory in tkhd
|
||
// (8.3.2) and VisualSampleEntry (12.1.3) — unlike Matroska
|
||
// there is no element to omit. Writing 0x0 yields a
|
||
// structurally complete file no player can render, with no
|
||
// error anywhere; refuse instead.
|
||
let (w, h) = v
|
||
.resolution
|
||
.pixels()
|
||
.ok_or(crate::error::Error::Mp4UnknownResolution)?;
|
||
tracks.push(Track {
|
||
media: Media::Video,
|
||
track_id,
|
||
stream_idx: i,
|
||
codec: v.codec,
|
||
codec_private: cp,
|
||
width: w,
|
||
height: h,
|
||
colr: video_colr(&title.streams[i]),
|
||
language: [0x55, 0xC4],
|
||
audio_entry: None,
|
||
audio_timescale: 0,
|
||
samples: Vec::new(),
|
||
});
|
||
}
|
||
DiscStream::Audio(a) => {
|
||
tracks.push(Track {
|
||
media: Media::Audio,
|
||
track_id,
|
||
stream_idx: i,
|
||
codec: a.codec,
|
||
codec_private: Vec::new(),
|
||
width: 0,
|
||
height: 0,
|
||
colr: None,
|
||
language: pack_language(&a.language),
|
||
audio_entry: None,
|
||
audio_timescale: a.sample_rate.hz() as u32,
|
||
samples: Vec::new(),
|
||
});
|
||
}
|
||
DiscStream::Subtitle(_) => unreachable!("fit_report never includes subtitles"),
|
||
}
|
||
}
|
||
|
||
let ftyp = build_ftyp(video_codec);
|
||
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())?;
|
||
writer.write_all(b"mdat")?;
|
||
writer.write_all(&0u64.to_be_bytes())?;
|
||
|
||
Ok(Self {
|
||
writer,
|
||
title: title.clone(),
|
||
tracks,
|
||
route,
|
||
mdat_start,
|
||
mdat_payload: 0,
|
||
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).
|
||
let movie_ts = MOVIE_TIMESCALE;
|
||
let mut movie_dur = 0u64;
|
||
let mut traks: Vec<Vec<u8>> = Vec::new();
|
||
for t in &self.tracks {
|
||
let (trak, secs) = build_trak(t);
|
||
traks.push(trak);
|
||
movie_dur = movie_dur.max((secs * movie_ts as f64) as u64);
|
||
}
|
||
// `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 {
|
||
moov.extend_from_slice(&trak);
|
||
}
|
||
bx(b"moov", &moov)
|
||
}
|
||
}
|
||
|
||
impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
|
||
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||
Err(crate::error::Error::StreamWriteOnly.into())
|
||
}
|
||
|
||
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
|
||
let Some(slot) = self.route.get(frame.track).copied().flatten() else {
|
||
return Ok(()); // excluded track (or out of range)
|
||
};
|
||
// Derive the audio sample entry opportunistically from whichever frame
|
||
// parses first. The entry is only needed at finish(), when build_moov runs
|
||
// — nothing on this path consumes it — so an unparseable frame must NOT
|
||
// cost us the frame. Dropping leading frames here lost audio silently, and
|
||
// a track whose frames never parsed vanished from the output entirely with
|
||
// no report; finish() now decides that case loudly instead.
|
||
if self.tracks[slot].media == Media::Audio
|
||
&& self.tracks[slot].audio_entry.is_none()
|
||
&& let Some(entry) = audio::dolby_sample_entry(self.tracks[slot].codec, &frame.data)
|
||
{
|
||
self.tracks[slot].audio_entry = Some(entry);
|
||
}
|
||
let pts_ns = frame.pts;
|
||
let offset = self.mdat_start + 16 + self.mdat_payload;
|
||
self.writer.write_all(&frame.data)?;
|
||
self.mdat_payload += frame.data.len() as u64;
|
||
self.tracks[slot].samples.push(Sample {
|
||
offset,
|
||
size: frame.data.len() as u32,
|
||
pts_ns,
|
||
keyframe: frame.keyframe,
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
fn finish(&mut self) -> io::Result<()> {
|
||
if self.finished {
|
||
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| {
|
||
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
|
||
// say so — this crate's policy is that a skipped track is never silent.
|
||
// Its bytes stay in mdat unreferenced, which is harmless (wasted space in a
|
||
// valid file) and preferable to failing an export whose video is fine.
|
||
self.tracks.retain(|t| {
|
||
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 \
|
||
(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());
|
||
}
|
||
// Patch the mdat 64-bit largesize: header (16) + payload.
|
||
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 faststart_fits(g) => {
|
||
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.writer.flush()
|
||
}
|
||
|
||
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 ───────────────────────────────────────────────────
|
||
|
||
/// Build a track's `trak` box and return `(bytes, duration_seconds)`.
|
||
fn build_trak(t: &Track) -> (Vec<u8>, f64) {
|
||
match t.media {
|
||
Media::Video => build_video_trak_full(t),
|
||
Media::Audio => build_audio_trak_full(t),
|
||
}
|
||
}
|
||
|
||
fn build_video_trak_full(t: &Track) -> (Vec<u8>, f64) {
|
||
let timing = VideoTiming::derive(&t.samples);
|
||
let media_dur = timing.total_duration();
|
||
let secs = media_dur as f64 / timing.timescale as f64;
|
||
|
||
let stsd = build_visual_stsd(t.codec, &t.codec_private, t.width, t.height, t.colr);
|
||
let stbl = build_video_stbl(stsd, &t.samples, &timing);
|
||
let minf = build_minf(video_vmhd(), stbl);
|
||
let mdia = build_mdia(
|
||
t.language,
|
||
timing.timescale,
|
||
media_dur,
|
||
b"vide",
|
||
"VideoHandler",
|
||
minf,
|
||
);
|
||
// tkhd.duration is in the MOVIE timescale, not `timing.timescale`.
|
||
let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64;
|
||
let tkhd = build_tkhd(t.track_id, t.width, t.height, tkhd_dur, false);
|
||
let mut body = tkhd;
|
||
body.extend_from_slice(&mdia);
|
||
(bx(b"trak", &body), secs)
|
||
}
|
||
|
||
fn build_audio_trak_full(t: &Track) -> (Vec<u8>, f64) {
|
||
let ts = t.audio_timescale.max(1);
|
||
let durs = audio_sample_durations(&t.samples, ts);
|
||
let media_dur: u64 = durs.iter().map(|&d| d as u64).sum();
|
||
let secs = media_dur as f64 / ts as f64;
|
||
|
||
// finish() drops any audio track without an entry, so this is Some for every
|
||
// track that reaches here; default only guards a future caller of build_trak.
|
||
let entry = t.audio_entry.clone().unwrap_or_default();
|
||
let stbl = build_audio_stbl(entry, &t.samples, &durs);
|
||
let minf = build_minf(audio_smhd(), stbl);
|
||
let mdia = build_mdia(t.language, ts, media_dur, b"soun", "SoundHandler", minf);
|
||
// tkhd.duration is in the MOVIE timescale, not the audio media timescale.
|
||
let tkhd_dur = (secs * MOVIE_TIMESCALE as f64) as u64;
|
||
let tkhd = build_tkhd(t.track_id, 0, 0, tkhd_dur, true);
|
||
let mut body = tkhd;
|
||
body.extend_from_slice(&mdia);
|
||
(bx(b"trak", &body), secs)
|
||
}
|
||
|
||
// ── timing ───────────────────────────────────────────────────────────────────
|
||
|
||
/// Video decode timing: constant decode duration (CFR) + per-sample composition
|
||
/// time, so `ctts[i] = CTS[i] − i·d` reproduces the B-frame reorder.
|
||
struct VideoTiming {
|
||
timescale: u32,
|
||
sample_dur: u32,
|
||
cts: Vec<i64>,
|
||
}
|
||
|
||
impl VideoTiming {
|
||
fn derive(samples: &[Sample]) -> Self {
|
||
let (timescale, sample_dur) = detect_rate(samples);
|
||
let min_pts = samples.iter().map(|s| s.pts_ns).min().unwrap_or(0);
|
||
let cts = samples
|
||
.iter()
|
||
.map(|s| ((s.pts_ns - min_pts) as i128 * timescale as i128 / NS as i128) as i64)
|
||
.collect();
|
||
Self {
|
||
timescale,
|
||
sample_dur,
|
||
cts,
|
||
}
|
||
}
|
||
fn total_duration(&self) -> u64 {
|
||
self.cts.len() as u64 * self.sample_dur as u64
|
||
}
|
||
fn ctts(&self) -> Vec<i32> {
|
||
self.cts
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, &c)| (c - (i as i64 * self.sample_dur as i64)) as i32)
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Per-sample audio decode durations from PTS deltas (audio has no reorder, so
|
||
/// composition == decode). The last sample repeats the previous duration.
|
||
fn audio_sample_durations(samples: &[Sample], timescale: u32) -> Vec<u32> {
|
||
let ticks = |ns: i64| (ns as i128 * timescale as i128 / NS as i128) as i64;
|
||
let mut durs = Vec::with_capacity(samples.len());
|
||
for w in samples.windows(2) {
|
||
durs.push((ticks(w[1].pts_ns) - ticks(w[0].pts_ns)).max(0) as u32);
|
||
}
|
||
if let Some(&last) = durs.last() {
|
||
durs.push(last);
|
||
} else if !samples.is_empty() {
|
||
durs.push(timescale / 30); // single-sample fallback
|
||
}
|
||
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.
|
||
const STD_RATES: &[(u32, u32, f64)] = &[
|
||
(24000, 1001, 23.976),
|
||
(24, 1, 24.0),
|
||
(25, 1, 25.0),
|
||
(30000, 1001, 29.97),
|
||
(30, 1, 30.0),
|
||
(50, 1, 50.0),
|
||
(60000, 1001, 59.94),
|
||
(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).
|
||
///
|
||
/// 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.
|
||
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).
|
||
fn detect_rate(samples: &[Sample]) -> (u32, u32) {
|
||
if samples.len() < 2 {
|
||
return (90_000, 3_003);
|
||
}
|
||
let mut pts: Vec<i64> = samples.iter().map(|s| s.pts_ns).collect();
|
||
pts.sort_unstable();
|
||
let mut deltas: Vec<i64> = pts
|
||
.windows(2)
|
||
.map(|w| w[1] - w[0])
|
||
.filter(|&d| d > 0)
|
||
.collect();
|
||
if deltas.is_empty() {
|
||
return (90_000, 3_003);
|
||
}
|
||
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 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)
|
||
}
|
||
|
||
// ── box builders ─────────────────────────────────────────────────────────────
|
||
|
||
/// `ftyp` — major brand `isom`, compatible brands incl. the codec brand.
|
||
fn build_ftyp(codec: Codec) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(b"isom");
|
||
body.extend_from_slice(&0x200u32.to_be_bytes());
|
||
body.extend_from_slice(b"isom");
|
||
body.extend_from_slice(b"iso2");
|
||
body.extend_from_slice(b"mp41");
|
||
match codec {
|
||
Codec::Hevc => body.extend_from_slice(b"hvc1"),
|
||
Codec::H264 => body.extend_from_slice(b"avc1"),
|
||
_ => {}
|
||
}
|
||
bx(b"ftyp", &body)
|
||
}
|
||
|
||
fn build_mvhd(timescale: u32, duration: u64, next_track_id: u32) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&0u64.to_be_bytes()); // creation_time
|
||
body.extend_from_slice(&0u64.to_be_bytes()); // modification_time
|
||
body.extend_from_slice(×cale.to_be_bytes());
|
||
body.extend_from_slice(&duration.to_be_bytes());
|
||
body.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0
|
||
body.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0
|
||
body.extend_from_slice(&[0u8; 2]);
|
||
body.extend_from_slice(&[0u8; 8]);
|
||
for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] {
|
||
body.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
body.extend_from_slice(&[0u8; 24]);
|
||
body.extend_from_slice(&next_track_id.to_be_bytes());
|
||
fullbox(b"mvhd", 1, 0, &body)
|
||
}
|
||
|
||
fn build_tkhd(track_id: u32, width: u32, height: u32, duration: u64, audio: bool) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&0u64.to_be_bytes()); // creation
|
||
body.extend_from_slice(&0u64.to_be_bytes()); // modification
|
||
body.extend_from_slice(&track_id.to_be_bytes());
|
||
body.extend_from_slice(&[0u8; 4]); // reserved
|
||
body.extend_from_slice(&duration.to_be_bytes());
|
||
body.extend_from_slice(&[0u8; 8]); // reserved
|
||
body.extend_from_slice(&0u16.to_be_bytes()); // layer
|
||
body.extend_from_slice(&0u16.to_be_bytes()); // alternate_group
|
||
body.extend_from_slice(&(if audio { 0x0100u16 } else { 0 }).to_be_bytes()); // volume
|
||
body.extend_from_slice(&[0u8; 2]);
|
||
for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] {
|
||
body.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
body.extend_from_slice(&(width << 16).to_be_bytes());
|
||
body.extend_from_slice(&(height << 16).to_be_bytes());
|
||
fullbox(b"tkhd", 1, 0x07, &body)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_mdia(
|
||
language: [u8; 2],
|
||
timescale: u32,
|
||
duration: u64,
|
||
handler: &[u8; 4],
|
||
handler_name: &str,
|
||
minf: Vec<u8>,
|
||
) -> Vec<u8> {
|
||
let mut mdhd = Vec::new();
|
||
mdhd.extend_from_slice(&0u64.to_be_bytes());
|
||
mdhd.extend_from_slice(&0u64.to_be_bytes());
|
||
mdhd.extend_from_slice(×cale.to_be_bytes());
|
||
mdhd.extend_from_slice(&duration.to_be_bytes());
|
||
mdhd.extend_from_slice(&language);
|
||
mdhd.extend_from_slice(&0u16.to_be_bytes());
|
||
let mdhd = fullbox(b"mdhd", 1, 0, &mdhd);
|
||
|
||
let hdlr = build_hdlr(handler, handler_name);
|
||
|
||
let mut body = mdhd;
|
||
body.extend_from_slice(&hdlr);
|
||
body.extend_from_slice(&minf);
|
||
bx(b"mdia", &body)
|
||
}
|
||
|
||
fn build_hdlr(handler: &[u8; 4], name: &str) -> Vec<u8> {
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&0u32.to_be_bytes());
|
||
body.extend_from_slice(handler);
|
||
body.extend_from_slice(&[0u8; 12]);
|
||
body.extend_from_slice(name.as_bytes());
|
||
body.push(0);
|
||
fullbox(b"hdlr", 0, 0, &body)
|
||
}
|
||
|
||
fn video_vmhd() -> Vec<u8> {
|
||
let mut vmhd = Vec::new();
|
||
vmhd.extend_from_slice(&0u16.to_be_bytes()); // graphicsmode
|
||
vmhd.extend_from_slice(&[0u8; 6]); // opcolor
|
||
fullbox(b"vmhd", 0, 1, &vmhd)
|
||
}
|
||
|
||
fn audio_smhd() -> Vec<u8> {
|
||
let mut smhd = Vec::new();
|
||
smhd.extend_from_slice(&0u16.to_be_bytes()); // balance
|
||
smhd.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||
fullbox(b"smhd", 0, 0, &smhd)
|
||
}
|
||
|
||
fn build_minf(header: Vec<u8>, stbl: Vec<u8>) -> Vec<u8> {
|
||
let dinf = build_dinf();
|
||
let mut body = header;
|
||
body.extend_from_slice(&dinf);
|
||
body.extend_from_slice(&stbl);
|
||
bx(b"minf", &body)
|
||
}
|
||
|
||
fn build_dinf() -> Vec<u8> {
|
||
let url = fullbox(b"url ", 0, 1, &[]);
|
||
let mut dref = Vec::new();
|
||
dref.extend_from_slice(&1u32.to_be_bytes());
|
||
dref.extend_from_slice(&url);
|
||
let dref = fullbox(b"dref", 0, 0, &dref);
|
||
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.
|
||
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;
|
||
}
|
||
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,
|
||
))
|
||
}
|
||
|
||
/// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` +
|
||
/// `stsz` + `co64`.
|
||
fn build_video_stbl(stsd: Vec<u8>, samples: &[Sample], timing: &VideoTiming) -> Vec<u8> {
|
||
let mut stts = Vec::new();
|
||
stts.extend_from_slice(&1u32.to_be_bytes());
|
||
stts.extend_from_slice(&(samples.len() as u32).to_be_bytes());
|
||
stts.extend_from_slice(&timing.sample_dur.to_be_bytes());
|
||
let stts = fullbox(b"stts", 0, 0, &stts);
|
||
|
||
let sync: Vec<u32> = samples
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, s)| s.keyframe)
|
||
.map(|(i, _)| i as u32 + 1)
|
||
.collect();
|
||
let mut stss = Vec::new();
|
||
stss.extend_from_slice(&(sync.len() as u32).to_be_bytes());
|
||
for n in &sync {
|
||
stss.extend_from_slice(&n.to_be_bytes());
|
||
}
|
||
let stss = fullbox(b"stss", 0, 0, &stss);
|
||
|
||
let ctts = build_ctts(&timing.ctts());
|
||
let stsc = build_stsc();
|
||
let stsz = build_stsz(samples);
|
||
let co64 = build_co64(samples);
|
||
|
||
let mut body = stsd;
|
||
body.extend_from_slice(&stts);
|
||
body.extend_from_slice(&stss);
|
||
body.extend_from_slice(&ctts);
|
||
body.extend_from_slice(&stsc);
|
||
body.extend_from_slice(&stsz);
|
||
body.extend_from_slice(&co64);
|
||
bx(b"stbl", &body)
|
||
}
|
||
|
||
/// Audio `stbl`: sample entry + run-length `stts` (per-sample durations) +
|
||
/// `stsc` + `stsz` + `co64`. No `stss` (every audio sample is a sync sample) and
|
||
/// no `ctts` (no reorder).
|
||
fn build_audio_stbl(sample_entry: Vec<u8>, samples: &[Sample], durs: &[u32]) -> Vec<u8> {
|
||
let mut stsd = Vec::new();
|
||
stsd.extend_from_slice(&1u32.to_be_bytes());
|
||
stsd.extend_from_slice(&sample_entry);
|
||
let stsd = fullbox(b"stsd", 0, 0, &stsd);
|
||
|
||
// Run-length coalesce equal consecutive durations.
|
||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||
for &d in durs {
|
||
match runs.last_mut() {
|
||
Some((count, val)) if *val == d => *count += 1,
|
||
_ => runs.push((1, d)),
|
||
}
|
||
}
|
||
let mut stts = Vec::new();
|
||
stts.extend_from_slice(&(runs.len() as u32).to_be_bytes());
|
||
for (count, val) in &runs {
|
||
stts.extend_from_slice(&count.to_be_bytes());
|
||
stts.extend_from_slice(&val.to_be_bytes());
|
||
}
|
||
let stts = fullbox(b"stts", 0, 0, &stts);
|
||
|
||
let stsc = build_stsc();
|
||
let stsz = build_stsz(samples);
|
||
let co64 = build_co64(samples);
|
||
|
||
let mut body = stsd;
|
||
body.extend_from_slice(&stts);
|
||
body.extend_from_slice(&stsc);
|
||
body.extend_from_slice(&stsz);
|
||
body.extend_from_slice(&co64);
|
||
bx(b"stbl", &body)
|
||
}
|
||
|
||
/// `stsc`: one sample per chunk (offsets listed one-per-sample in `co64`).
|
||
fn build_stsc() -> Vec<u8> {
|
||
let mut stsc = Vec::new();
|
||
stsc.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||
stsc.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
||
stsc.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
|
||
stsc.extend_from_slice(&1u32.to_be_bytes()); // sample_description_index
|
||
fullbox(b"stsc", 0, 0, &stsc)
|
||
}
|
||
|
||
fn build_stsz(samples: &[Sample]) -> Vec<u8> {
|
||
let mut stsz = Vec::new();
|
||
stsz.extend_from_slice(&0u32.to_be_bytes()); // per-sample sizes
|
||
stsz.extend_from_slice(&(samples.len() as u32).to_be_bytes());
|
||
for s in samples {
|
||
stsz.extend_from_slice(&s.size.to_be_bytes());
|
||
}
|
||
fullbox(b"stsz", 0, 0, &stsz)
|
||
}
|
||
|
||
fn build_co64(samples: &[Sample]) -> Vec<u8> {
|
||
let mut co64 = Vec::new();
|
||
co64.extend_from_slice(&(samples.len() as u32).to_be_bytes());
|
||
for s in samples {
|
||
co64.extend_from_slice(&s.offset.to_be_bytes());
|
||
}
|
||
fullbox(b"co64", 0, 0, &co64)
|
||
}
|
||
|
||
/// `ctts` version 1 (signed composition offsets), run-length coalesced.
|
||
fn build_ctts(offsets: &[i32]) -> Vec<u8> {
|
||
let mut runs: Vec<(u32, i32)> = Vec::new();
|
||
for &o in offsets {
|
||
match runs.last_mut() {
|
||
Some((count, val)) if *val == o => *count += 1,
|
||
_ => runs.push((1, o)),
|
||
}
|
||
}
|
||
let mut body = Vec::new();
|
||
body.extend_from_slice(&(runs.len() as u32).to_be_bytes());
|
||
for (count, val) in &runs {
|
||
body.extend_from_slice(&count.to_be_bytes());
|
||
body.extend_from_slice(&val.to_be_bytes());
|
||
}
|
||
fullbox(b"ctts", 1, 0, &body)
|
||
}
|
||
|
||
/// Visual `stsd` with one `hvc1`/`avc1` sample entry carrying the config record
|
||
/// (`hvcC`/`avcC`) and, when present, a `colr` box.
|
||
fn build_visual_stsd(
|
||
codec: Codec,
|
||
codec_private: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
colr: Option<(u16, u16, u16, bool)>,
|
||
) -> Vec<u8> {
|
||
let (fourcc, cfg_type): (&[u8; 4], &[u8; 4]) = match codec {
|
||
Codec::Hevc => (b"hvc1", b"hvcC"),
|
||
_ => (b"avc1", b"avcC"),
|
||
};
|
||
|
||
let mut entry = Vec::new();
|
||
entry.extend_from_slice(&[0u8; 6]);
|
||
entry.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
|
||
entry.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
||
entry.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||
entry.extend_from_slice(&[0u8; 12]); // pre_defined[3]
|
||
entry.extend_from_slice(&(width as u16).to_be_bytes());
|
||
entry.extend_from_slice(&(height as u16).to_be_bytes());
|
||
entry.extend_from_slice(&0x0048_0000u32.to_be_bytes());
|
||
entry.extend_from_slice(&0x0048_0000u32.to_be_bytes());
|
||
entry.extend_from_slice(&0u32.to_be_bytes());
|
||
entry.extend_from_slice(&1u16.to_be_bytes()); // frame_count
|
||
entry.extend_from_slice(&[0u8; 32]); // compressorname
|
||
entry.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
|
||
entry.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||
entry.extend_from_slice(&bx(cfg_type, codec_private));
|
||
if let Some((p, t, m, full)) = colr {
|
||
let mut c = Vec::new();
|
||
c.extend_from_slice(b"nclx");
|
||
c.extend_from_slice(&p.to_be_bytes());
|
||
c.extend_from_slice(&t.to_be_bytes());
|
||
c.extend_from_slice(&m.to_be_bytes());
|
||
c.push(if full { 0x80 } else { 0x00 });
|
||
entry.extend_from_slice(&bx(b"colr", &c));
|
||
}
|
||
let entry = bx(fourcc, &entry);
|
||
|
||
let mut stsd = Vec::new();
|
||
stsd.extend_from_slice(&1u32.to_be_bytes());
|
||
stsd.extend_from_slice(&entry);
|
||
fullbox(b"stsd", 0, 0, &stsd)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::disc::{
|
||
AudioChannels, AudioStream, Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat,
|
||
LabelPurpose, Resolution, SampleRate, Stream as DiscStream, SubtitleStream, VideoStream,
|
||
};
|
||
use crate::labels::LabelQualifier;
|
||
|
||
fn hevc_video() -> DiscStream {
|
||
DiscStream::Video(VideoStream {
|
||
pid: 0x1011,
|
||
codec: Codec::Hevc,
|
||
resolution: Resolution::R2160p,
|
||
frame_rate: FrameRate::F23_976,
|
||
hdr: HdrFormat::Hdr10,
|
||
color_space: ColorSpace::Bt2020,
|
||
display_aspect: None,
|
||
secondary: false,
|
||
label: String::new(),
|
||
measured_cicp: None,
|
||
})
|
||
}
|
||
|
||
fn audio(codec: Codec, lang: &str) -> DiscStream {
|
||
DiscStream::Audio(AudioStream {
|
||
pid: 0x1100,
|
||
codec,
|
||
channels: AudioChannels::Surround51,
|
||
language: lang.into(),
|
||
sample_rate: SampleRate::S48,
|
||
secondary: false,
|
||
purpose: LabelPurpose::Normal,
|
||
label: String::new(),
|
||
})
|
||
}
|
||
|
||
fn subtitle() -> DiscStream {
|
||
DiscStream::Subtitle(SubtitleStream {
|
||
pid: 0x1200,
|
||
codec: Codec::Pgs,
|
||
language: "eng".into(),
|
||
forced: false,
|
||
qualifier: LabelQualifier::None,
|
||
codec_data: None,
|
||
})
|
||
}
|
||
|
||
fn title(streams: Vec<DiscStream>, cps: Vec<Option<Vec<u8>>>) -> DiscTitle {
|
||
let mut t = DiscTitle::empty();
|
||
t.streams = streams;
|
||
t.codec_privates = cps;
|
||
t
|
||
}
|
||
|
||
#[test]
|
||
fn fit_report_includes_video_and_dolby_only() {
|
||
let t = title(
|
||
vec![
|
||
hevc_video(),
|
||
audio(Codec::TrueHd, "eng"),
|
||
audio(Codec::Ac3, "eng"),
|
||
audio(Codec::Ac3Plus, "fra"),
|
||
subtitle(),
|
||
],
|
||
vec![Some(vec![1, 2, 3]), None, None, None, None],
|
||
);
|
||
let r = fit_report(&t);
|
||
assert_eq!(r.included, vec![0, 2, 3], "video + AC3 + EAC3");
|
||
// TrueHD (unmappable audio) and PGS (bitmap subtitle) are skipped.
|
||
assert!(r.skipped.contains(&(1, Mp4SkipReason::UnmappableAudio)));
|
||
assert!(r.skipped.contains(&(4, Mp4SkipReason::BitmapSubtitle)));
|
||
}
|
||
|
||
#[test]
|
||
fn fit_report_labels_unsupported_primary_video() {
|
||
// A primary video whose codec the MP4 writer can't carry (VC-1) must be
|
||
// skipped as UnmappableVideo, NOT SecondaryVideo (which means an MVC view).
|
||
let mut vc1 = match hevc_video() {
|
||
DiscStream::Video(v) => v,
|
||
_ => unreachable!(),
|
||
};
|
||
vc1.codec = Codec::Vc1;
|
||
let t = title(
|
||
vec![DiscStream::Video(vc1), audio(Codec::Ac3, "eng")],
|
||
vec![None, None],
|
||
);
|
||
let r = fit_report(&t);
|
||
assert!(r.skipped.contains(&(0, Mp4SkipReason::UnmappableVideo)));
|
||
assert_eq!(r.included, vec![1], "only the AC-3 audio is carried");
|
||
}
|
||
|
||
/// A video track whose resolution never resolved must FAIL the mux, not be
|
||
/// written as a 0x0 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.
|
||
#[test]
|
||
fn a_video_track_with_no_resolved_resolution_is_an_error_not_a_zero_sized_track() {
|
||
let DiscStream::Video(mut v) = hevc_video() else {
|
||
unreachable!("hevc_video builds a video stream")
|
||
};
|
||
v.resolution = Resolution::Unknown;
|
||
let t = title(
|
||
vec![DiscStream::Video(v), audio(Codec::Ac3, "eng")],
|
||
vec![Some(vec![0x01, 0x02, 0x03]), None],
|
||
);
|
||
|
||
let err = match Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t) {
|
||
Ok(_) => panic!("an unrenderable 0x0 track must not be written silently"),
|
||
Err(e) => e,
|
||
};
|
||
// `From<Error> for io::Error` stringifies as "E<code>[: ...]", so the
|
||
// code round-trips in the message.
|
||
assert!(
|
||
err.to_string()
|
||
.starts_with(&format!("E{}", crate::error::E_MP4_UNKNOWN_RESOLUTION)),
|
||
"the failure must name the missing dimensions, not a generic mux \
|
||
error; got {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn no_video_track_is_an_error() {
|
||
let t = title(vec![audio(Codec::Ac3, "eng")], vec![None]);
|
||
let err = match Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t) {
|
||
Ok(_) => panic!("expected no-video-track error"),
|
||
Err(e) => e,
|
||
};
|
||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||
}
|
||
|
||
fn frame(track: usize, pts_ns: i64, key: bool, data: Vec<u8>) -> PesFrame {
|
||
PesFrame {
|
||
track,
|
||
pts: pts_ns,
|
||
keyframe: key,
|
||
data,
|
||
duration_ns: None,
|
||
source: None,
|
||
coding: None,
|
||
}
|
||
}
|
||
|
||
// A minimal AC-3 5.1 frame the audio parser accepts.
|
||
// The underscores in these literals mark BITFIELD boundaries in the
|
||
// bitstream header being built (e.g. a 5-bit field then a 3-bit field),
|
||
// not thousands-style digit groups. Regrouping them uniformly would
|
||
// satisfy the lint by destroying the only thing they encode.
|
||
#[allow(clippy::unusual_byte_groupings)]
|
||
fn ac3_frame() -> Vec<u8> {
|
||
vec![
|
||
0x0B,
|
||
0x77,
|
||
0x00,
|
||
0x00,
|
||
0b00_010110,
|
||
0b01000_000,
|
||
0b111_00_00_1,
|
||
0x00,
|
||
0xFF,
|
||
0xFF,
|
||
]
|
||
}
|
||
|
||
fn walk(buf: &[u8]) -> Vec<([u8; 4], usize, usize)> {
|
||
let mut out = Vec::new();
|
||
let mut pos = 0;
|
||
while pos + 8 <= buf.len() {
|
||
let size = u32::from_be_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||
let size = if size == 1 {
|
||
u64::from_be_bytes([
|
||
buf[pos + 8],
|
||
buf[pos + 9],
|
||
buf[pos + 10],
|
||
buf[pos + 11],
|
||
buf[pos + 12],
|
||
buf[pos + 13],
|
||
buf[pos + 14],
|
||
buf[pos + 15],
|
||
]) as usize
|
||
} else {
|
||
size as usize
|
||
};
|
||
let bt = [buf[pos + 4], buf[pos + 5], buf[pos + 6], buf[pos + 7]];
|
||
assert!(size >= 8 && pos + size <= buf.len(), "box {bt:?} bad size");
|
||
out.push((bt, pos, size));
|
||
pos += size;
|
||
}
|
||
assert_eq!(pos, buf.len(), "top-level boxes tile exactly");
|
||
out
|
||
}
|
||
|
||
/// Direct child lookup by box type, one level, returning its payload (bytes
|
||
/// after the 8-byte size+type header). 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.
|
||
fn find_child<'a>(buf: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
|
||
let mut pos = 0;
|
||
while pos + 8 <= buf.len() {
|
||
let size =
|
||
u32::from_be_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
|
||
if size < 8 || pos + size > buf.len() {
|
||
break;
|
||
}
|
||
if &buf[pos + 4..pos + 8] == want {
|
||
return Some(&buf[pos + 8..pos + size]);
|
||
}
|
||
pos += size;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 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]
|
||
fn audio_track_with_no_parseable_sample_entry_is_dropped_not_emitted_empty() {
|
||
let t = title(
|
||
vec![hevc_video(), audio(Codec::Ac3, "eng")],
|
||
vec![Some(vec![1, 2, 3, 4]), None],
|
||
);
|
||
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 cannot parse it, ever.
|
||
let junk = vec![0x5Au8; 64];
|
||
s.write(&frame(1, 0, true, junk.clone())).unwrap();
|
||
s.write(&frame(1, 32_000_000, true, junk.clone())).unwrap();
|
||
s.finish().unwrap();
|
||
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];
|
||
let mut traks = 0;
|
||
let mut pos = 0;
|
||
while pos + 8 <= moov.len() {
|
||
let size = u32::from_be_bytes([moov[pos], moov[pos + 1], moov[pos + 2], moov[pos + 3]])
|
||
as usize;
|
||
if &moov[pos + 4..pos + 8] == b"trak" {
|
||
traks += 1;
|
||
}
|
||
if size < 8 {
|
||
break;
|
||
}
|
||
pos += size;
|
||
}
|
||
assert_eq!(
|
||
traks, 1,
|
||
"only the video trak may be described; the undescribable audio track is dropped"
|
||
);
|
||
|
||
// The audio bytes were still WRITTEN (no silent frame loss) — they simply
|
||
// end up unreferenced in mdat rather than being discarded at write time.
|
||
assert!(
|
||
buf.windows(junk.len()).any(|w| w == &junk[..]),
|
||
"audio frames must reach mdat rather than being dropped by write()"
|
||
);
|
||
}
|
||
|
||
/// 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(
|
||
vec![hevc_video(), audio(Codec::Ac3, "eng")],
|
||
vec![Some(vec![1, 2, 3, 4]), None],
|
||
);
|
||
let d = 41_708_333;
|
||
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
|
||
// Two video frames (track 0) + two AC-3 frames (track 1).
|
||
s.write(&frame(0, 0, true, vec![0xAB; 800])).unwrap();
|
||
s.write(&frame(1, 0, true, ac3_frame())).unwrap();
|
||
s.write(&frame(0, d, false, vec![0xCD; 400])).unwrap();
|
||
s.write(&frame(1, 32_000_000, true, ac3_frame())).unwrap();
|
||
s.finish().unwrap();
|
||
let buf = s.writer.into_inner();
|
||
let boxes = walk(&buf);
|
||
let types: Vec<[u8; 4]> = boxes.iter().map(|(t, _, _)| *t).collect();
|
||
// 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];
|
||
let trak_count = {
|
||
let mut n = 0;
|
||
let mut pos = 0;
|
||
while pos + 8 <= moov.len() {
|
||
let size =
|
||
u32::from_be_bytes([moov[pos], moov[pos + 1], moov[pos + 2], moov[pos + 3]])
|
||
as usize;
|
||
if &moov[pos + 4..pos + 8] == b"trak" {
|
||
n += 1;
|
||
}
|
||
if size < 8 {
|
||
break;
|
||
}
|
||
pos += size;
|
||
}
|
||
n
|
||
};
|
||
assert_eq!(trak_count, 2, "one video + one audio trak");
|
||
// mdat = header + 800+400 video + two AC-3 frames.
|
||
let (_, _, mdat_sz) = *boxes.iter().find(|(t, _, _)| t == b"mdat").unwrap();
|
||
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.
|
||
// Saturates rather than wrapping. div_ceil(GRAIN) * GRAIN overflows within
|
||
// one grain of u64::MAX, and the wrapped product is SMALL — which would
|
||
// turn the largest possible estimate into a negligible reserve, the exact
|
||
// opposite of the intent. Only the no-wrap property matters here.
|
||
assert!(
|
||
round_up_grain(u64::MAX) >= u64::MAX - (4 << 20),
|
||
"round_up_grain must saturate near u64::MAX, not wrap to a small value"
|
||
);
|
||
// The reserve the writer emits must fit the `free` box's 32-bit size field.
|
||
assert!(RESERVE_CAP <= u32::MAX as u64);
|
||
assert_eq!(RESERVE_CAP % RESERVE_GRAIN, 0);
|
||
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.is_multiple_of(4 << 20),
|
||
"reserve is 4 MiB-aligned + 4 MiB buffer"
|
||
);
|
||
assert!(
|
||
(12 << 20..=20 << 20).contains(&r),
|
||
"≈12-16 MB for a 2h feature, got {r}"
|
||
);
|
||
|
||
// The case above is dominated by RESERVE_FLOOR + RESERVE_BUFFER: its
|
||
// per-sample term is ~6.4 MB, under the 8 MiB floor, so setting
|
||
// BYTES_PER_SAMPLE to 0 would leave it green. Pin a case where the
|
||
// per-sample estimate is what the result is MADE of.
|
||
//
|
||
// 2 hr, 23.976 fps HEVC + EIGHT AC-3 tracks (a commentary-heavy disc):
|
||
// video 7200 × 24000/1001 = 172_627 samples
|
||
// audio 8 × 7200 × (48000 / 1536) = 1_800_000 samples
|
||
// total 1_972_627 × 16 B = 31_562_032 B (30.1 MiB)
|
||
// → round up to 8 grains = 32 MiB, + 4 MiB buffer = 36 MiB exactly.
|
||
// With BYTES_PER_SAMPLE = 0 this collapses to the 12 MiB floor+buffer.
|
||
let mut streams = vec![hevc_video()];
|
||
streams.extend((0..8).map(|_| audio(Codec::Ac3, "eng")));
|
||
let mut t = title(streams, vec![]);
|
||
t.duration_secs = 7200.0;
|
||
let included: Vec<usize> = (0..9).collect();
|
||
let r = estimate_reserve(&t, &included);
|
||
assert_eq!(
|
||
r,
|
||
36 << 20,
|
||
"per-sample term must dominate: 1.97M samples × 16 B → 32 MiB + 4 MiB buffer"
|
||
);
|
||
assert!(
|
||
r > RESERVE_FLOOR + RESERVE_BUFFER,
|
||
"this case must NOT be reachable from the floor alone"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_rate_snaps_23_976() {
|
||
let d = 41_708_333;
|
||
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), (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:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── pack_language ────────────────────────────────────────────────────────
|
||
|
||
/// 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]
|
||
fn pack_language_packs_three_lowercase_letters_into_15_bits() {
|
||
// "bcd": b=2, c=3, d=4 → (2<<10)|(3<<5)|4 = 2048+96+4 = 0x0864.
|
||
assert_eq!(pack_language("bcd"), [0x08, 0x64]);
|
||
// ISO 639-2 "eng", cross-checked against read.rs's `mdhd_language`
|
||
// fixture, which decodes the same packed constant back to "eng".
|
||
assert_eq!(pack_language("eng"), [0x15, 0xC7]);
|
||
}
|
||
|
||
/// Every rejection path falls back to the fixed "und" encoding: wrong
|
||
/// length (both shorter and longer than 3) and not-all-lowercase (upper
|
||
/// case, a digit, a non-ASCII byte).
|
||
#[test]
|
||
fn pack_language_falls_back_to_und_for_anything_not_three_lowercase_letters() {
|
||
const UND: [u8; 2] = [0x55, 0xC4];
|
||
assert_eq!(pack_language("en"), UND, "too short");
|
||
assert_eq!(pack_language("engl"), UND, "too long");
|
||
assert_eq!(pack_language("ENG"), UND, "not lowercase");
|
||
assert_eq!(pack_language("e1g"), UND, "not all letters");
|
||
assert_eq!(pack_language(""), UND, "empty");
|
||
}
|
||
|
||
// ── faststart reserve sizing constants and guards ──────────────────────────
|
||
|
||
#[test]
|
||
fn reserve_floor_is_8_mebibytes() {
|
||
// A shifted-the-wrong-way `8 << 20` silently becomes 0, collapsing the
|
||
// floor. Every other reserve test builds on this constant, so pin it
|
||
// directly rather than only through their derived numbers.
|
||
assert_eq!(RESERVE_FLOOR, 8 * 1024 * 1024);
|
||
}
|
||
|
||
/// `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]
|
||
fn estimate_reserve_uses_the_streams_own_fps_not_the_24fps_fallback() {
|
||
let mut t = title(vec![hevc_video()], vec![]);
|
||
t.duration_secs = 76_500.0;
|
||
let r = estimate_reserve(&t, &[0]);
|
||
assert_eq!(
|
||
r, 33_554_432,
|
||
"23.976 fps must be used verbatim, not rounded up to a flat 24.0"
|
||
);
|
||
}
|
||
|
||
/// 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]
|
||
fn estimate_reserve_unknown_frame_rate_falls_back_to_24fps_not_zero() {
|
||
let mut vc1 = match hevc_video() {
|
||
DiscStream::Video(v) => v,
|
||
_ => unreachable!(),
|
||
};
|
||
vc1.frame_rate = FrameRate::Unknown;
|
||
let mut t = title(vec![DiscStream::Video(vc1)], vec![]);
|
||
t.duration_secs = 76_500.0;
|
||
let r = estimate_reserve(&t, &[0]);
|
||
// Same duration, taking the 24.0 fallback: lands in a DIFFERENT grain
|
||
// than the 23.976 fps case above (37_748_736 vs 33_554_432), and far
|
||
// above the floor+buffer a 0-fps collapse would produce (12_582_912).
|
||
assert_eq!(
|
||
r, 37_748_736,
|
||
"an unknown frame rate must estimate as if it were 24 fps, not 0"
|
||
);
|
||
}
|
||
|
||
/// `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]
|
||
fn estimate_reserve_models_dts_at_512_samples_per_frame_not_1536() {
|
||
let t_dts = {
|
||
let mut t = title(vec![audio(Codec::Dts, "eng")], vec![]);
|
||
t.duration_secs = 100_000.0;
|
||
t
|
||
};
|
||
let r_dts = estimate_reserve(&t_dts, &[0]);
|
||
assert_eq!(
|
||
r_dts, 155_189_248,
|
||
"a DTS track must be modelled at 512 samples/frame"
|
||
);
|
||
|
||
let t_ac3 = {
|
||
let mut t = title(vec![audio(Codec::Ac3, "eng")], vec![]);
|
||
t.duration_secs = 100_000.0;
|
||
t
|
||
};
|
||
let r_ac3 = estimate_reserve(&t_ac3, &[0]);
|
||
assert_eq!(
|
||
r_ac3, 54_525_952,
|
||
"an AC-3 track (the `_` arm) is modelled at 1536 samples/frame"
|
||
);
|
||
assert_ne!(
|
||
r_dts, r_ac3,
|
||
"DTS and AC-3 must not be modelled identically — one is a third of the other"
|
||
);
|
||
}
|
||
|
||
// ── track timing arithmetic — every operator with a concrete number ───────
|
||
//
|
||
// Nothing above this point pins a NUMBER out of the PTS→ticks / duration /
|
||
// tkhd_dur / ctts chain: every existing test asserts a box exists or that
|
||
// gross shape (trak count, mdat size) is right, never that `stts`, `ctts` or
|
||
// `tkhd.duration` hold a specific value. Every arithmetic operator in that
|
||
// chain (`*`↔`/`↔`+`↔`-`, even `/`↔`%`) could be flipped with the whole
|
||
// suite green — a title would mux "successfully" with silently corrupted
|
||
// A/V sync or a wrong total duration. These pin exact values, chosen so a
|
||
// flipped operator cannot coincidentally reproduce the right answer (e.g. a
|
||
// non-zero `min_pts` so `pts - min_pts` and a mutated `pts + min_pts` differ,
|
||
// a `sample_dur` other than 1 so `*` and `/` by it differ).
|
||
|
||
/// `VideoTiming::derive`'s composition ticks are `(pts - min_pts) * ts / NS`.
|
||
/// `min_pts` is deliberately non-zero: with min_pts == 0 a mutated `+` gives
|
||
/// the same answer as `-` and the mutant survives for free.
|
||
#[test]
|
||
fn video_timing_derive_subtracts_the_minimum_pts_not_adds_it() {
|
||
let offset = 5_000_000_000i64; // 5 s, so min_pts != 0
|
||
let d = 40_000_000i64; // 40 ms/frame — exact 25 fps
|
||
// Decode order carries a classic I-P-B-B reorder (P presents last).
|
||
let samples = vec![
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: offset,
|
||
keyframe: true,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: offset + 3 * d,
|
||
keyframe: false,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: offset + d,
|
||
keyframe: false,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: offset + 2 * d,
|
||
keyframe: false,
|
||
},
|
||
];
|
||
let timing = VideoTiming::derive(&samples);
|
||
assert_eq!(timing.timescale, 25, "exact 25 fps snaps to timescale 25");
|
||
assert_eq!(timing.sample_dur, 1);
|
||
assert_eq!(
|
||
timing.cts,
|
||
vec![0, 3, 1, 2],
|
||
"cts must be (pts - min_pts) * ts / NS, in decode order"
|
||
);
|
||
}
|
||
|
||
/// `VideoTiming::total_duration` is `sample_count * sample_dur`. `sample_dur`
|
||
/// is deliberately not 1 — `*` and `/` by 1 are the same function, so a
|
||
/// mutated `/` would survive a `sample_dur == 1` fixture for free.
|
||
#[test]
|
||
fn video_timing_total_duration_multiplies_count_by_sample_duration() {
|
||
let timing = VideoTiming {
|
||
timescale: 24_000,
|
||
sample_dur: 1001,
|
||
cts: vec![0, 1001, 2002, 3003],
|
||
};
|
||
assert_eq!(timing.total_duration(), 4 * 1001);
|
||
}
|
||
|
||
/// `VideoTiming::ctts` is `cts[i] - i * sample_dur`. `sample_dur` is again
|
||
/// not 1, so `*` and `/` disagree.
|
||
#[test]
|
||
fn video_timing_ctts_subtracts_index_times_sample_duration() {
|
||
let timing = VideoTiming {
|
||
timescale: 25,
|
||
sample_dur: 2,
|
||
cts: vec![10, 5, 20],
|
||
};
|
||
// [10 - 0*2, 5 - 1*2, 20 - 2*2] = [10, 3, 16].
|
||
assert_eq!(timing.ctts(), vec![10, 3, 16]);
|
||
}
|
||
|
||
/// `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]
|
||
fn tkhd_duration_is_seconds_times_movie_timescale_for_both_media_types() {
|
||
fn tkhd_duration(trak: &[u8]) -> u64 {
|
||
let tkhd = find_child(&trak[8..], b"tkhd").expect("tkhd");
|
||
u64::from_be_bytes(tkhd[28..36].try_into().unwrap())
|
||
}
|
||
|
||
// Video: 4 samples at exact 25 fps → total_duration 4 ticks @ ts 25 →
|
||
// secs = 0.16 → tkhd_dur = 0.16 * 90_000 = 14_400 exactly.
|
||
let video = Track {
|
||
media: Media::Video,
|
||
track_id: 1,
|
||
stream_idx: 0,
|
||
codec: Codec::Hevc,
|
||
codec_private: vec![0xAA, 0xBB],
|
||
width: 1920,
|
||
height: 1080,
|
||
colr: None,
|
||
language: [0x55, 0xC4],
|
||
audio_entry: None,
|
||
audio_timescale: 0,
|
||
samples: (0..4)
|
||
.map(|i| Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: i * 40_000_000,
|
||
keyframe: i == 0,
|
||
})
|
||
.collect(),
|
||
};
|
||
let (vtrak, vsecs) = build_trak(&video);
|
||
assert_eq!(vsecs, 0.16);
|
||
assert_eq!(
|
||
tkhd_duration(&vtrak),
|
||
14_400,
|
||
"video tkhd.duration must be secs * MOVIE_TIMESCALE"
|
||
);
|
||
|
||
// Audio: 2 samples 32 ms apart at 48 kHz → per-sample duration 1536
|
||
// ticks (exact), media_dur = 2 * 1536 = 3072, secs = 3072/48000 = 0.064
|
||
// → tkhd_dur = 0.064 * 90_000 = 5_760 exactly.
|
||
let audio = Track {
|
||
media: Media::Audio,
|
||
track_id: 2,
|
||
stream_idx: 1,
|
||
codec: Codec::Ac3,
|
||
codec_private: Vec::new(),
|
||
width: 0,
|
||
height: 0,
|
||
colr: None,
|
||
language: [0x55, 0xC4],
|
||
audio_entry: Some(vec![0x0B, 0x77]),
|
||
audio_timescale: 48_000,
|
||
samples: vec![
|
||
Sample {
|
||
offset: 0,
|
||
size: 10,
|
||
pts_ns: 0,
|
||
keyframe: true,
|
||
},
|
||
Sample {
|
||
offset: 10,
|
||
size: 10,
|
||
pts_ns: 32_000_000,
|
||
keyframe: true,
|
||
},
|
||
],
|
||
};
|
||
let (atrak, asecs) = build_trak(&audio);
|
||
assert_eq!(
|
||
asecs,
|
||
3072.0 / 48_000.0,
|
||
"audio secs must be media_dur / timescale, not any other combination"
|
||
);
|
||
assert_eq!(
|
||
tkhd_duration(&atrak),
|
||
5_760,
|
||
"audio tkhd.duration must be secs * MOVIE_TIMESCALE"
|
||
);
|
||
}
|
||
|
||
/// `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]
|
||
fn audio_sample_durations_computes_exact_tick_deltas_and_repeats_the_last() {
|
||
let samples = vec![
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 0,
|
||
keyframe: true,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 32_000_000, // 1536 ticks @ 48 kHz, exact
|
||
keyframe: true,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 64_000_000, // 3072 ticks @ 48 kHz, exact
|
||
keyframe: true,
|
||
},
|
||
];
|
||
let durs = audio_sample_durations(&samples, 48_000);
|
||
assert_eq!(
|
||
durs,
|
||
vec![1536, 1536, 1536],
|
||
"two 1536-tick deltas, and the trailing sample repeats the last"
|
||
);
|
||
}
|
||
|
||
/// 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]
|
||
fn audio_sample_durations_single_sample_uses_timescale_over_30_fallback() {
|
||
let samples = vec![Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 0,
|
||
keyframe: true,
|
||
}];
|
||
assert_eq!(
|
||
audio_sample_durations(&samples, 48_000),
|
||
vec![48_000 / 30],
|
||
"a lone sample gets one fallback duration, timescale/30"
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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]
|
||
fn detect_rate_needs_at_least_two_samples_not_more() {
|
||
assert_eq!(detect_rate(&[]), (90_000, 3_003));
|
||
assert_eq!(
|
||
detect_rate(&[Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 0,
|
||
keyframe: true
|
||
}]),
|
||
(90_000, 3_003)
|
||
);
|
||
let two = vec![
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 0,
|
||
keyframe: true,
|
||
},
|
||
Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: 40_000_000, // exact 25 fps
|
||
keyframe: false,
|
||
},
|
||
];
|
||
assert_eq!(
|
||
detect_rate(&two),
|
||
(25, 1),
|
||
"exactly 2 samples is enough to measure a real rate, not the fallback"
|
||
);
|
||
}
|
||
|
||
/// 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]
|
||
fn detect_rate_filters_zero_deltas_not_just_negative_ones() {
|
||
let samples: Vec<Sample> = vec![0, 0, 0, 40_000_000]
|
||
.into_iter()
|
||
.map(|pts_ns| Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns,
|
||
keyframe: pts_ns == 0,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
detect_rate(&samples),
|
||
(25, 1),
|
||
"the three duplicate zero deltas must be filtered out entirely, \
|
||
leaving the one real 25 fps delta as the (only, hence median) value"
|
||
);
|
||
}
|
||
|
||
/// 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.
|
||
#[test]
|
||
fn detect_rate_fallback_duration_is_median_times_90khz_over_ns() {
|
||
let samples: Vec<Sample> = (0..10)
|
||
.map(|i| Sample {
|
||
offset: 0,
|
||
size: 1,
|
||
pts_ns: i as i64 * 200_000_000, // 5 fps — far outside every STD_RATES window
|
||
keyframe: i == 0,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
detect_rate(&samples),
|
||
(90_000, 18_000),
|
||
"median 200_000_000 ns * 90_000 / 1e9 = 18_000 exactly"
|
||
);
|
||
}
|
||
|
||
// ── build_ftyp ───────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn build_ftyp_names_the_codec_specific_compatible_brand() {
|
||
let hevc = build_ftyp(Codec::Hevc);
|
||
assert!(
|
||
hevc.windows(4).any(|w| w == b"hvc1"),
|
||
"an HEVC title must declare the hvc1 compatible brand"
|
||
);
|
||
let h264 = build_ftyp(Codec::H264);
|
||
assert!(
|
||
h264.windows(4).any(|w| w == b"avc1"),
|
||
"an H.264 title must declare the avc1 compatible brand"
|
||
);
|
||
assert_ne!(
|
||
hevc, h264,
|
||
"the two codec brands must not collapse to the same ftyp"
|
||
);
|
||
}
|
||
|
||
// ── build_audio_stbl run-length coalescing ─────────────────────────────────
|
||
|
||
#[test]
|
||
fn build_audio_stbl_coalesces_equal_adjacent_durations_only() {
|
||
let samples: Vec<Sample> = (0..5)
|
||
.map(|i| Sample {
|
||
offset: i as u64 * 10,
|
||
size: 10,
|
||
pts_ns: 0,
|
||
keyframe: true,
|
||
})
|
||
.collect();
|
||
// Two distinct adjacent runs: [5,5] then [7,7,7]. If the coalescing
|
||
// guard always matches (or always mismatches), or `==`/`!=` is
|
||
// inverted, the run count and/or the runs' (count, value) pairs come
|
||
// out wrong.
|
||
let durs = vec![5u32, 5, 7, 7, 7];
|
||
let stbl = build_audio_stbl(vec![0u8; 4], &samples, &durs);
|
||
let stts = find_child(&stbl[8..], b"stts").expect("stts");
|
||
let entry_count = u32::from_be_bytes(stts[4..8].try_into().unwrap());
|
||
assert_eq!(
|
||
entry_count, 2,
|
||
"5,5,7,7,7 must coalesce into exactly two runs"
|
||
);
|
||
assert_eq!(
|
||
&stts[8..16],
|
||
&[0, 0, 0, 2, 0, 0, 0, 5][..],
|
||
"first run: count=2 value=5"
|
||
);
|
||
assert_eq!(
|
||
&stts[16..24],
|
||
&[0, 0, 0, 3, 0, 0, 0, 7][..],
|
||
"second run: count=3 value=7"
|
||
);
|
||
}
|
||
|
||
// ── faststart_fits ───────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn faststart_fits_zero_or_at_least_eight_bytes_only() {
|
||
assert!(faststart_fits(0), "an exact fill needs no free box");
|
||
for g in 1..8 {
|
||
assert!(
|
||
!faststart_fits(g),
|
||
"{g} bytes cannot be expressed as any box (min header is 8)"
|
||
);
|
||
}
|
||
assert!(faststart_fits(8), "8 bytes is exactly one empty free box");
|
||
assert!(faststart_fits(1_000_000));
|
||
}
|
||
|
||
// ── Stream::read is write-only ──────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn mp4_sink_read_is_unsupported() {
|
||
let t = title(
|
||
vec![hevc_video(), audio(Codec::Ac3, "eng")],
|
||
vec![Some(vec![1, 2, 3]), None],
|
||
);
|
||
let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap();
|
||
let err = s.read().unwrap_err();
|
||
assert!(
|
||
err.to_string()
|
||
.starts_with(&format!("E{}", crate::error::E_STREAM_WRITE_ONLY)),
|
||
"an Mp4Sink is write-only; read() must report that, not silently return Ok(None); \
|
||
got {err}"
|
||
);
|
||
}
|
||
}
|