mux: native progressive MP4 muxer (mp4://) — M1 video track

New mux/mp4: writes ftyp+mdat+moov (moov-at-end), streaming samples into
a 64-bit mdat and building the sample tables in memory, patched at
finish(). Video track (HEVC/AVC): passthrough length-prefixed NALs
(already MP4 framing), full stts/stsz/stsc/co64/stss and signed ctts,
CFR-derived decode timeline (pipeline carries presentation PTS only), and
a colr box for HDR10 colour signalling. hvc1/avc1 sample entry from the
hvcC/avcC codec_private. Fail-loud on codecs with no MP4 mapping.

Verified against ffmpeg -c copy on real discs: AVC-SDR (300) and
HEVC-HDR10 UHD (Dune) both frame-exact (8159 / 8160 frames), colour-exact
(bt2020/smpte2084/bt2020nc), and clean-decoding. Audio + fit oracle land
in M2.
This commit is contained in:
Matthew Jackson
2026-07-18 20:16:19 -07:00
parent 489545c865
commit 8aff7fe708
5 changed files with 846 additions and 0 deletions
+26
View File
@@ -150,6 +150,14 @@ pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
/// frame dropped before the first keyframe) cannot report success.
pub const E_MUX_EMPTY: u16 = 9023;
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
/// `mp4://` output but the title has no (primary) video track to carry.
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
/// `mp4://` video track uses a codec with no MP4 mapping / no config record
/// available here (e.g. VC-1, or MPEG-2 without an avcC/hvcC-style record).
pub const E_MP4_UNSUPPORTED_VIDEO_CODEC: u16 = 9049;
/// `mp4://` video track is missing its codec-configuration record
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
/// READ CAPACITY returned a short or overflowing transfer.
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
@@ -420,6 +428,12 @@ pub enum Error {
/// every frame dropped before the first keyframe — fails loudly. The
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
MuxEmpty,
/// `mp4://` target title has no primary video track to mux.
MuxNoVideoTrack,
/// `mp4://` video codec has no MP4 sample-entry mapping available here.
Mp4UnsupportedVideoCodec,
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
MuxMissingCodecPrivate,
PesFrameTooLarge {
size: usize,
},
@@ -599,6 +613,9 @@ impl Error {
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
Error::MuxEmpty => E_MUX_EMPTY,
Error::MuxNoVideoTrack => E_MP4_NO_VIDEO_TRACK,
Error::Mp4UnsupportedVideoCodec => E_MP4_UNSUPPORTED_VIDEO_CODEC,
Error::MuxMissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
@@ -1236,6 +1253,9 @@ mod tests {
E_STREAM_URL_MISSING_PORT,
E_NETWORK_ADDR_BLOCKED,
E_MUX_EMPTY,
E_MP4_NO_VIDEO_TRACK,
E_MP4_UNSUPPORTED_VIDEO_CODEC,
E_MP4_MISSING_CODEC_PRIVATE,
E_PES_FRAME_TOO_LARGE,
E_PES_INVALID_MAGIC,
E_PES_TRACK_TOO_LARGE,
@@ -1323,6 +1343,12 @@ mod tests {
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
(Error::MuxEmpty, E_MUX_EMPTY),
(Error::MuxNoVideoTrack, E_MP4_NO_VIDEO_TRACK),
(
Error::Mp4UnsupportedVideoCodec,
E_MP4_UNSUPPORTED_VIDEO_CODEC,
),
(Error::MuxMissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
+2
View File
@@ -90,6 +90,7 @@ pub(crate) mod hevc;
pub(crate) mod m2ts_mux;
pub(crate) mod mkv;
pub(crate) mod mkvstream;
pub(crate) mod mp4;
pub(crate) mod network;
pub(crate) mod null;
pub(crate) mod ps;
@@ -154,6 +155,7 @@ mod tests {
assert_eq!(parse_url("disc://").scheme(), "disc");
assert_eq!(parse_url("m2ts://f").scheme(), "m2ts");
assert_eq!(parse_url("mkv://f").scheme(), "mkv");
assert_eq!(parse_url("mp4://f").scheme(), "mp4");
assert_eq!(parse_url("network://h:1").scheme(), "network");
assert_eq!(parse_url("stdio://").scheme(), "stdio");
assert_eq!(parse_url("iso://f").scheme(), "iso");
+27
View File
@@ -0,0 +1,27 @@
//! ISO-BMFF box primitives: `[size:u32-BE][type:4][body]` (ISO/IEC 14496-12
//! §4.2), and the FullBox variant that prefixes a 1-byte version + 3-byte flags.
/// Wrap a body in a plain box `[size][type][body]`. `size` counts the 8-byte
/// header. All `moov`-tree boxes are small (the large `mdat` is written directly
/// with a 64-bit size, not through here), so a `u32` size never overflows.
pub(super) fn bx(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
let total = body.len() + 8;
debug_assert!(
total <= u32::MAX as usize,
"mp4 box {box_type:?} exceeds u32"
);
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&(total as u32).to_be_bytes());
out.extend_from_slice(box_type);
out.extend_from_slice(body);
out
}
/// Wrap a body in a FullBox: `[size][type][version:1][flags:3][body]`.
pub(super) fn fullbox(box_type: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Vec<u8> {
let mut full = Vec::with_capacity(body.len() + 4);
full.push(version);
full.extend_from_slice(&flags.to_be_bytes()[1..]); // low 3 bytes
full.extend_from_slice(body);
bx(box_type, &full)
}
+774
View File
@@ -0,0 +1,774 @@
//! Progressive MP4 (ISO-BMFF) muxer — `mp4://`.
//!
//! Writes `ftyp` + `mdat` + `moov` (moov-at-end): sample data streams straight
//! into `mdat` as frames arrive, per-track sample tables accumulate in memory,
//! and the `moov` index is written at `finish()` after seeking back to patch the
//! `mdat` size. Unlike the fragmented `fmp4` sibling (DASH init+moof/mdat), this
//! is a single self-contained file — the shape people mean by "an mp4".
//!
//! ## Milestone 1 (this file): video track only
//!
//! One video track (HEVC / H.264), passthrough NALs (the demux already hands us
//! length-prefixed hvcC/avcC-form NALs — exactly MP4's framing, no reframing),
//! full sample tables (`stts`/`stsz`/`stsc`/`co64`/`stss`/`ctts`), and a `colr`
//! box for HDR10 signalling. Decode timestamps are derived (the pipeline carries
//! presentation PTS only): the stream is constant-frame-rate on disc, so a
//! constant decode duration + signed `ctts` composition offsets reproduces the
//! B-frame reorder exactly. Audio tracks and the fit-oracle track selection land
//! in Milestone 2.
//!
//! Reference: ISO/IEC 14496-12 (ISO base media file format), 14496-15 (NAL-unit
//! structured video: `avcC`/`hvcC`).
use crate::disc::{Codec, DiscTitle, Stream as DiscStream};
use crate::pes::{PesFrame, Stream};
use std::fs::File;
use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;
mod boxes;
use boxes::{bx, fullbox};
/// Nanoseconds per second — PTS is carried in ns, media timescales are Hz.
const NS: i64 = 1_000_000_000;
/// 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).
keyframe: bool,
}
/// Progressive MP4 sink. Owns the output `File` so it can seek back to patch the
/// `mdat` size once all samples are written.
pub struct Mp4Sink {
file: File,
title: DiscTitle,
/// Index into `title.streams` of the muxed video track.
video_track: usize,
codec: Codec,
/// `hvcC` / `avcC` decoder configuration record (from `codec_privates`).
codec_private: Vec<u8>,
width: u32,
height: u32,
/// File offset of the `mdat` box header (for the 64-bit size patch).
mdat_start: u64,
/// Running `mdat` payload size in bytes.
mdat_payload: u64,
samples: Vec<Sample>,
finished: bool,
}
impl Mp4Sink {
/// Create the sink: pick the video track, open the file, and write `ftyp`
/// plus the `mdat` header (64-bit size, patched at `finish()`).
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
let (video_track, vs) = title
.streams
.iter()
.enumerate()
.find_map(|(i, s)| match s {
DiscStream::Video(v) if !v.is_mvc_dependent() => Some((i, v)),
_ => None,
})
.ok_or(crate::error::Error::MuxNoVideoTrack)?;
let codec = vs.codec;
if !matches!(codec, Codec::Hevc | Codec::H264) {
// M1 carries only the NAL-structured codecs whose codec_private is a
// ready hvcC/avcC. VC-1/MPEG-2 have no such record here (and VC-1 has
// no MP4 mapping at all) — fail loud rather than emit a broken track.
return Err(crate::error::Error::Mp4UnsupportedVideoCodec.into());
}
let codec_private = title
.codec_privates
.get(video_track)
.and_then(|c| c.clone())
.ok_or(crate::error::Error::MuxMissingCodecPrivate)?;
let (width, height) = vs.resolution.pixels();
let mut file = File::create(path)?;
file.write_all(&build_ftyp(codec))?;
let mdat_start = file.stream_position()?;
// mdat with 64-bit largesize: size=1 signals "largesize follows"; the
// 8-byte largesize placeholder is patched at finish() once known.
file.write_all(&1u32.to_be_bytes())?;
file.write_all(b"mdat")?;
file.write_all(&0u64.to_be_bytes())?;
Ok(Self {
file,
title: title.clone(),
video_track,
codec,
codec_private,
width,
height,
mdat_start,
mdat_payload: 0,
samples: Vec::new(),
finished: false,
})
}
/// Assemble and write the `moov` box from the accumulated sample tables.
fn write_moov(&mut self) -> io::Result<()> {
let timing = Timing::derive(&self.samples);
let stbl = build_stbl(
self.codec,
&self.codec_private,
self.width,
self.height,
video_colr(&self.title.streams[self.video_track]),
&self.samples,
&timing,
);
let dur = timing.total_duration();
let mut moov = Vec::new();
moov.extend_from_slice(&build_mvhd(timing.timescale, dur));
moov.extend_from_slice(&build_video_trak(
self.width,
self.height,
timing.timescale,
dur,
stbl,
));
let moov = bx(b"moov", &moov);
self.file.write_all(&moov)
}
}
impl Stream for Mp4Sink {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(crate::error::Error::StreamWriteOnly.into())
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
// M1 is video-only: drop every non-video-track frame. (Audio tracks and
// the never-silent fit report arrive in M2.)
if frame.track != self.video_track {
return Ok(());
}
let offset = self.mdat_start + 16 + self.mdat_payload;
self.file.write_all(&frame.data)?;
self.mdat_payload += frame.data.len() as u64;
self.samples.push(Sample {
offset,
size: frame.data.len() as u32,
pts_ns: frame.pts,
keyframe: frame.keyframe,
});
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
if self.finished {
return Ok(());
}
self.finished = true;
// Patch the mdat 64-bit largesize: header (16) + payload.
let mdat_total = 16 + self.mdat_payload;
self.file.seek(SeekFrom::Start(self.mdat_start + 8))?;
self.file.write_all(&mdat_total.to_be_bytes())?;
self.file.seek(SeekFrom::End(0))?;
self.write_moov()?;
self.file.flush()
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
// ── timing derivation ────────────────────────────────────────────────────────
/// Per-track decode timing derived from presentation PTS. The pipeline carries
/// presentation timestamps only; MP4 needs a monotonic decode timeline plus a
/// composition offset per sample. On disc the video is constant-frame-rate, so a
/// constant decode duration reproduces decode order and `ctts[i] = CTS[i] - i·d`
/// (signed) reproduces the B-frame reorder exactly.
struct Timing {
/// Media timescale (Hz).
timescale: u32,
/// Constant per-sample decode duration in timescale ticks.
sample_dur: u32,
/// Composition time of each sample in timescale ticks (CTS, ≥ 0, min = 0).
cts: Vec<i64>,
}
impl Timing {
fn derive(samples: &[Sample]) -> Self {
// Median presentation delta → nearest standard frame rate, so the
// integer timescale divides evenly (zero long-run drift).
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,
}
}
/// Track duration in timescale ticks: sum of the constant decode durations.
fn total_duration(&self) -> u64 {
self.cts.len() as u64 * self.sample_dur as u64
}
/// Signed composition offset `ctts[i] = CTS[i] DTS[i]`, `DTS[i] = i·d`.
fn ctts(&self) -> Vec<i32> {
self.cts
.iter()
.enumerate()
.map(|(i, &c)| (c - (i as i64 * self.sample_dur as i64)) as i32)
.collect()
}
}
/// 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); // ~29.97 placeholder; single-sample tracks are degenerate
}
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);
}
}
// Non-standard: 90 kHz with a rounded per-sample duration.
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 so
/// players recognise the video format (`hvc1` for HEVC, `avc1` for H.264).
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()); // minor_version
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) -> Vec<u8> {
// Version 1 (64-bit times/duration).
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]); // reserved
body.extend_from_slice(&[0u8; 8]); // reserved
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]); // pre_defined[6]
body.extend_from_slice(&2u32.to_be_bytes()); // next_track_ID
fullbox(b"mvhd", 1, 0, &body)
}
fn build_video_trak(
width: u32,
height: u32,
timescale: u32,
duration: u64,
stbl: Vec<u8>,
) -> Vec<u8> {
let tkhd = build_tkhd(width, height, duration);
let mdia = build_mdia(timescale, duration, stbl);
let mut body = Vec::new();
body.extend_from_slice(&tkhd);
body.extend_from_slice(&mdia);
bx(b"trak", &body)
}
fn build_tkhd(width: u32, height: u32, duration: u64) -> Vec<u8> {
// Version 1, flags 0x000007 (enabled | in_movie | in_preview).
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(&1u32.to_be_bytes()); // track_ID
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(&0u16.to_be_bytes()); // volume (video = 0)
body.extend_from_slice(&[0u8; 2]); // reserved
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)
}
fn build_mdia(timescale: u32, duration: u64, stbl: Vec<u8>) -> Vec<u8> {
let mut mdhd = Vec::new();
mdhd.extend_from_slice(&0u64.to_be_bytes()); // creation
mdhd.extend_from_slice(&0u64.to_be_bytes()); // modification
mdhd.extend_from_slice(&timescale.to_be_bytes());
mdhd.extend_from_slice(&duration.to_be_bytes());
mdhd.extend_from_slice(&[0x55, 0xC4]); // language 'und'
mdhd.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
let mdhd = fullbox(b"mdhd", 1, 0, &mdhd);
let hdlr = build_hdlr(b"vide", "VideoHandler");
let minf = build_video_minf(stbl);
let mut body = Vec::new();
body.extend_from_slice(&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()); // pre_defined
body.extend_from_slice(handler);
body.extend_from_slice(&[0u8; 12]); // reserved
body.extend_from_slice(name.as_bytes());
body.push(0);
fullbox(b"hdlr", 0, 0, &body)
}
fn build_video_minf(stbl: Vec<u8>) -> Vec<u8> {
// vmhd (version 0, flags 1).
let mut vmhd = Vec::new();
vmhd.extend_from_slice(&0u16.to_be_bytes()); // graphicsmode
vmhd.extend_from_slice(&[0u8; 6]); // opcolor
let vmhd = fullbox(b"vmhd", 0, 1, &vmhd);
let dinf = build_dinf();
let mut body = Vec::new();
body.extend_from_slice(&vmhd);
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, &[]); // self-contained
let mut dref = Vec::new();
dref.extend_from_slice(&1u32.to_be_bytes()); // entry_count
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,
));
}
// Fall back to the coarse colour-space enum → CICP code points.
use crate::disc::ColorSpace::*;
let cicp = match v.color_space {
Bt709 => (1, 1, 1),
Bt2020 => (9, 16, 9), // BT.2020 NCL; transfer 16 = PQ (HDR10 default)
Bt470bg => (5, 6, 5),
Smpte170m => (6, 6, 6),
Unknown => return None,
};
Some((cicp.0, cicp.1, cicp.2, false))
}
/// Build `stbl` — the sample table: sample entry (`hvc1`/`avc1` + config + colr),
/// `stts`, `stss`, `ctts`, `stsc`, `stsz`, `co64`.
#[allow(clippy::too_many_arguments)]
fn build_stbl(
codec: Codec,
codec_private: &[u8],
width: u32,
height: u32,
colr: Option<(u16, u16, u16, bool)>,
samples: &[Sample],
timing: &Timing,
) -> Vec<u8> {
let stsd = build_visual_stsd(codec, codec_private, width, height, colr);
// stts: one run of `count` samples, each with the constant decode duration.
let mut stts = Vec::new();
stts.extend_from_slice(&1u32.to_be_bytes()); // entry_count
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);
// stss: 1-based sample numbers of the sync samples.
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);
// ctts (version 1, signed offsets), coalesced into runs.
let ctts = build_ctts(&timing.ctts());
// stsc: one sample per chunk (offsets listed one-per-sample in co64).
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
let stsc = fullbox(b"stsc", 0, 0, &stsc);
// stsz: per-sample sizes.
let mut stsz = Vec::new();
stsz.extend_from_slice(&0u32.to_be_bytes()); // sample_size 0 = per-sample
stsz.extend_from_slice(&(samples.len() as u32).to_be_bytes());
for s in samples {
stsz.extend_from_slice(&s.size.to_be_bytes());
}
let stsz = fullbox(b"stsz", 0, 0, &stsz);
// co64: 64-bit chunk (= per-sample) offsets — movies exceed 4 GiB.
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());
}
let co64 = fullbox(b"co64", 0, 0, &co64);
let mut body = Vec::new();
body.extend_from_slice(&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)
}
/// `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()); // i32 BE
}
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 for colour/HDR signalling.
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]); // reserved
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()); // horizresolution 72dpi
entry.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // vertresolution 72dpi
entry.extend_from_slice(&0u32.to_be_bytes()); // reserved
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()); // pre_defined = -1
// Config record (hvcC / avcC) is the codec_private verbatim.
entry.extend_from_slice(&bx(cfg_type, codec_private));
if let Some((p, t, m, full)) = colr {
let mut colr_body = Vec::new();
colr_body.extend_from_slice(b"nclx");
colr_body.extend_from_slice(&p.to_be_bytes());
colr_body.extend_from_slice(&t.to_be_bytes());
colr_body.extend_from_slice(&m.to_be_bytes());
colr_body.push(if full { 0x80 } else { 0x00 }); // full_range flag in MSB
entry.extend_from_slice(&bx(b"colr", &colr_body));
}
let entry = bx(fourcc, &entry);
let mut stsd = Vec::new();
stsd.extend_from_slice(&1u32.to_be_bytes()); // entry_count
stsd.extend_from_slice(&entry);
fullbox(b"stsd", 0, 0, &stsd)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStream,
VideoStream,
};
fn video_title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams = vec![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,
})];
// A minimal but non-empty hvcC stand-in (opaque to the muxer — copied
// verbatim into the sample entry).
t.codec_privates = vec![Some(vec![0x01, 0x02, 0x03, 0x04])];
t
}
fn frame(track: usize, pts_ns: i64, key: bool, len: usize) -> PesFrame {
PesFrame {
track,
pts: pts_ns,
keyframe: key,
data: vec![0xAB; len],
duration_ns: None,
source: None,
coding: None,
}
}
/// Walk a flat box sequence, asserting each declared size tiles exactly.
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 bt = [buf[pos + 4], buf[pos + 5], buf[pos + 6], buf[pos + 7]];
let size = if size == 1 {
// 64-bit largesize (mdat).
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
};
assert!(size >= 8, "box {bt:?} size {size} < 8");
assert!(pos + size <= buf.len(), "box {bt:?} overruns buffer");
out.push((bt, pos, size));
pos += size;
}
assert_eq!(pos, buf.len(), "top-level boxes did not tile exactly");
out
}
fn child<'a>(payload: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
let mut pos = 0;
while pos + 8 <= payload.len() {
let size = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
let bt = [
payload[pos + 4],
payload[pos + 5],
payload[pos + 6],
payload[pos + 7],
];
if size < 8 || pos + size > payload.len() {
return None;
}
if &bt == want {
return Some(&payload[pos..pos + size]);
}
pos += size;
}
None
}
fn mux_to_file(frames: &[PesFrame]) -> Vec<u8> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir();
let path = dir.join(format!(
"freemkv_mp4_test_{}_{}.mp4",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
{
let mut sink = Mp4Sink::create(&path, &video_title()).unwrap();
for f in frames {
sink.write(f).unwrap();
}
sink.finish().unwrap();
}
let buf = std::fs::read(&path).unwrap();
let _ = std::fs::remove_file(&path);
buf
}
#[test]
fn top_level_is_ftyp_mdat_moov_and_tiles_exactly() {
// 3 frames: IDR, then two at increasing PTS (24000/1001 fps cadence).
let d = 41_708_333; // ~1/23.976 s in ns
let frames = [
frame(0, 0, true, 1000),
frame(0, d, false, 500),
frame(0, 2 * d, false, 400),
];
let buf = mux_to_file(&frames);
let boxes = walk(&buf);
let types: Vec<[u8; 4]> = boxes.iter().map(|(t, _, _)| *t).collect();
assert_eq!(types, vec![*b"ftyp", *b"mdat", *b"moov"]);
}
#[test]
fn mdat_carries_only_video_track_bytes() {
// Interleave an audio-track frame (track 1) that M1 must drop.
let d = 41_708_333;
let frames = [
frame(0, 0, true, 1000),
frame(1, 0, true, 9999), // audio — dropped in M1
frame(0, d, false, 500),
];
let buf = mux_to_file(&frames);
let boxes = walk(&buf);
let (_, mdat_start, mdat_size) = *boxes.iter().find(|(t, _, _)| t == b"mdat").unwrap();
// mdat payload = 16-byte header + only the two video samples (1000 + 500).
assert_eq!(mdat_size, 16 + 1000 + 500);
let _ = mdat_start;
}
#[test]
fn stbl_tables_match_the_video_samples() {
let d = 41_708_333;
let frames = [
frame(0, 0, true, 1000),
frame(0, d, false, 500),
frame(0, 2 * d, false, 400),
];
let buf = mux_to_file(&frames);
let boxes = walk(&buf);
let (_, ms, msz) = *boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov = &buf[ms + 8..ms + msz];
let trak = child(moov, b"trak").unwrap();
let mdia = child(&trak[8..], b"mdia").unwrap();
let minf = child(&mdia[8..], b"minf").unwrap();
let stbl = child(&minf[8..], b"stbl").unwrap();
// stsz sample_count == 3.
let stsz = child(&stbl[8..], b"stsz").unwrap();
let count = u32::from_be_bytes([stsz[16], stsz[17], stsz[18], stsz[19]]);
assert_eq!(count, 3, "three video samples");
// co64 has 3 offsets (per-sample chunks).
let co64 = child(&stbl[8..], b"co64").unwrap();
let co_count = u32::from_be_bytes([co64[12], co64[13], co64[14], co64[15]]);
assert_eq!(co_count, 3);
// stss: exactly one sync sample (the IDR at index 1).
let stss = child(&stbl[8..], b"stss").unwrap();
let sync_count = u32::from_be_bytes([stss[12], stss[13], stss[14], stss[15]]);
assert_eq!(sync_count, 1);
let first_sync = u32::from_be_bytes([stss[16], stss[17], stss[18], stss[19]]);
assert_eq!(first_sync, 1, "sync sample numbers are 1-based");
// hvc1 sample entry present under stsd.
let stsd = child(&stbl[8..], b"stsd").unwrap();
assert!(child(&stsd[16..], b"hvc1").is_some(), "hvc1 sample entry");
}
#[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));
}
}
+17
View File
@@ -45,6 +45,10 @@ pub enum StreamUrl {
M2ts { path: PathBuf },
/// Matroska container file.
Mkv { path: PathBuf },
/// Progressive MP4 (ISO-BMFF) mux output (`mp4://`). Like `mkv://` but writes
/// a single self-contained `.mp4` (ftyp+mdat+moov). Compatibility export —
/// carries only MP4-mappable codecs; see `mux::mp4`.
Mp4 { path: PathBuf },
/// Network stream (host:port).
Network { addr: String },
/// Standard I/O (stdin/stdout).
@@ -98,6 +102,7 @@ impl StreamUrl {
StreamUrl::Disc { .. } => "disc",
StreamUrl::M2ts { .. } => "m2ts",
StreamUrl::Mkv { .. } => "mkv",
StreamUrl::Mp4 { .. } => "mp4",
StreamUrl::Network { .. } => "network",
StreamUrl::Stdio => "stdio",
StreamUrl::Iso { .. } => "iso",
@@ -121,6 +126,7 @@ impl StreamUrl {
StreamUrl::Disc { device: None } => "",
StreamUrl::M2ts { path }
| StreamUrl::Mkv { path }
| StreamUrl::Mp4 { path }
| StreamUrl::Iso { path }
| StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path }
@@ -169,6 +175,11 @@ pub fn parse_url(url: &str) -> StreamUrl {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("mp4://") {
return StreamUrl::Mp4 {
path: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("network://") {
return StreamUrl::Network {
addr: rest.to_string(),
@@ -502,6 +513,8 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// PES source. Mirror `null://` → write-only.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
// `mp4://` is an output-only mux sink for now (no MP4 demux source).
StreamUrl::Mp4 { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
// `demux://` is an output-only sink (per-track ES files); never a source.
StreamUrl::Demux { .. }
| StreamUrl::Video { .. }
@@ -540,6 +553,10 @@ pub fn output(
));
Ok(Box::new(MkvStream::create(writer, title, Some(path))?))
}
StreamUrl::Mp4 { ref path } => {
validate_file_path(path, "mp4")?;
Ok(Box::new(super::mp4::Mp4Sink::create(path, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let writer = std::io::BufWriter::with_capacity(