Files
libfreemkv/src/mux/mp4/mod.rs
T
Matthew Jackson f7edd4e6a9 mux/mp4: DTS audio (dtsc/dtsh + ddts)
Parse the DTS core header (SFREQ/AMODE/RATE/LFF/NBLKS/FSIZE) → a ddts box
(sample rate, channel layout mask, core size, computed bitrate, LFE);
whole access units (core + DTS-HD extension substreams) pass through, so
an HD decoder finds the extension. dtsh when an extension sync follows the
core, else dtsc. Fit oracle now carries DTS / DTS-HD MA / DTS-HD HR.

Validated on 300 (real DTS-HD MA 7.1): freemkv's mp4 DTS track is
byte-identical to ffmpeg -c copy under ffprobe (dts / 48000 / 8ch / 7.1)
and decodes clean (exit 0). Channel layout correct.
2026-07-18 22:18:35 -07:00

1100 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
// ── 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.
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,
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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, DTS, LPCM, …).
UnmappableAudio,
/// A secondary/dependent video view (e.g. MVC 3D right eye).
SecondaryVideo,
}
/// 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.
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 AC-3 / E-AC-3 track. 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 {
skipped.push((i, Mp4SkipReason::SecondaryVideo));
}
}
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,
}
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::MuxNoVideoTrack.into());
}
let mut tracks = Vec::new();
let mut route = vec![None; title.streams.len()];
let mut next_id = 1u32;
let mut video_codec = Codec::Hevc;
for &i in &report.included {
let track_id = next_id;
next_id += 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::MuxMissingCodecPrivate)?;
let (w, h) = v.resolution.pixels();
tracks.push(Track {
media: Media::Video,
track_id,
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,
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,
})
}
/// 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;
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);
}
let next_id = self.tracks.len() as u32 + 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)
};
// Build the audio sample entry from the first frame of an audio track.
if self.tracks[slot].media == Media::Audio && self.tracks[slot].audio_entry.is_none() {
if let Some(entry) = audio::dolby_sample_entry(self.tracks[slot].codec, &frame.data) {
self.tracks[slot].audio_entry = Some(entry);
} else {
// Unparseable first frame — skip until one parses (avoids a
// track with samples but no sample entry).
return Ok(());
}
}
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;
// Drop tracks that never received a sample (e.g. an audio track whose
// frames never parsed) so moov carries no empty trak.
self.tracks.retain(|t| !t.samples.is_empty());
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 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.writer.flush()
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
// ── 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,
);
let tkhd = build_tkhd(t.track_id, t.width, t.height, media_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;
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);
let tkhd = build_tkhd(t.track_id, 0, 0, media_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)` — exact integer ratios
/// so a CFR track has zero accumulated drift.
const STD_RATES: &[(u32, u32, f64)] = &[
(24000, 1001, 23.976),
(24, 1, 24.0),
(25, 1, 25.0),
(30000, 1001, 29.97),
(30, 1, 30.0),
(50, 1, 50.0),
(60000, 1001, 59.94),
(60, 1, 60.0),
];
/// 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;
for &(ts, dur, rate) in STD_RATES {
if (fps - rate).abs() < 0.5 {
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(&timescale.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(&timescale.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): (primaries, transfer, matrix,
/// full_range). `None` when the stream carries no usable colour info.
fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> {
let DiscStream::Video(v) = stream else {
return None;
};
if let Some(c) = v.measured_cicp {
return Some((
c.primaries as u16,
c.transfer as u16,
c.matrix as u16,
c.range == 2,
));
}
use crate::disc::ColorSpace::*;
let cicp = match v.color_space {
Bt709 => (1, 1, 1),
Bt2020 => (9, 16, 9),
Bt470bg => (5, 6, 5),
Smpte170m => (6, 6, 6),
Unknown => return None,
};
Some((cicp.0, cicp.1, cicp.2, false))
}
/// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` +
/// `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 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.
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
}
#[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.
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;
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));
}
}