From 8aff7fe7081b89808a70be6333bbe6c0f9049792 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:16:19 -0700 Subject: [PATCH] =?UTF-8?q?mux:=20native=20progressive=20MP4=20muxer=20(mp?= =?UTF-8?q?4://)=20=E2=80=94=20M1=20video=20track?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/error.rs | 26 ++ src/mux/mod.rs | 2 + src/mux/mp4/boxes.rs | 27 ++ src/mux/mp4/mod.rs | 774 +++++++++++++++++++++++++++++++++++++++++++ src/mux/resolve.rs | 17 + 5 files changed, 846 insertions(+) create mode 100644 src/mux/mp4/boxes.rs create mode 100644 src/mux/mp4/mod.rs diff --git a/src/error.rs b/src/error.rs index 08008ba..6179999 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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), diff --git a/src/mux/mod.rs b/src/mux/mod.rs index e8964b2..c629a0f 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -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"); diff --git a/src/mux/mp4/boxes.rs b/src/mux/mp4/boxes.rs new file mode 100644 index 0000000..906dbc0 --- /dev/null +++ b/src/mux/mp4/boxes.rs @@ -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 { + 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 { + 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) +} diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs new file mode 100644 index 0000000..83532ff --- /dev/null +++ b/src/mux/mp4/mod.rs @@ -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, + 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, + 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 { + 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> { + 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, +} + +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 { + 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 = samples.iter().map(|s| s.pts_ns).collect(); + pts.sort_unstable(); + let mut deltas: Vec = 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 { + 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 { + // 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(×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]); // 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, +) -> Vec { + 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 { + // 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) -> Vec { + 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(×cale.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 { + 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) -> Vec { + // 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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 = (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)); + } +} diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index eb9895d..c5254ff 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -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 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(