//! 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". //! //! ## 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`). This is the fit //! oracle: a codec MP4 can't carry (TrueHD, DTS, 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::fs::File; use std::io::{self, Seek, SeekFrom, Write}; use std::path::Path; mod audio; 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). 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, 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>, /// Audio media timescale (Hz), captured with `audio_entry`. audio_timescale: u32, samples: Vec, } /// 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, /// 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 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, tracks: Vec, /// `title.streams` index → position in `tracks`, or `None` if excluded. route: Vec>, /// 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, finished: bool, } impl Mp4Sink { /// Create the sink: build the track plan (fit oracle), 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 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 mut file = File::create(path)?; file.write_all(&build_ftyp(video_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(), tracks, route, mdat_start, mdat_payload: 0, finished: false, }) } /// Assemble and write the `moov` box from every track's sample tables. fn write_moov(&mut self) -> io::Result<()> { // 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::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); } 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<()> { 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.file.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.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 } } // ── per-track box assembly ─────────────────────────────────────────────────── /// Build a track's `trak` box and return `(bytes, duration_seconds)`. fn build_trak(t: &Track) -> (Vec, 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, 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, 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, } 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 { 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 { 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 = 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); } } 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 { 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 { let mut body = Vec::new(); body.extend_from_slice(&0u64.to_be_bytes()); // creation_time body.extend_from_slice(&0u64.to_be_bytes()); // modification_time body.extend_from_slice(×cale.to_be_bytes()); body.extend_from_slice(&duration.to_be_bytes()); body.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 body.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 body.extend_from_slice(&[0u8; 2]); body.extend_from_slice(&[0u8; 8]); for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] { body.extend_from_slice(&v.to_be_bytes()); } body.extend_from_slice(&[0u8; 24]); body.extend_from_slice(&next_track_id.to_be_bytes()); fullbox(b"mvhd", 1, 0, &body) } fn build_tkhd(track_id: u32, width: u32, height: u32, duration: u64, audio: bool) -> Vec { 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, ) -> Vec { let mut mdhd = Vec::new(); mdhd.extend_from_slice(&0u64.to_be_bytes()); mdhd.extend_from_slice(&0u64.to_be_bytes()); mdhd.extend_from_slice(×cale.to_be_bytes()); mdhd.extend_from_slice(&duration.to_be_bytes()); mdhd.extend_from_slice(&language); mdhd.extend_from_slice(&0u16.to_be_bytes()); let mdhd = fullbox(b"mdhd", 1, 0, &mdhd); let hdlr = build_hdlr(handler, handler_name); let mut body = mdhd; body.extend_from_slice(&hdlr); body.extend_from_slice(&minf); bx(b"mdia", &body) } fn build_hdlr(handler: &[u8; 4], name: &str) -> Vec { 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 { 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 { 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, stbl: Vec) -> Vec { 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 { 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, samples: &[Sample], timing: &VideoTiming) -> Vec { 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 = 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, samples: &[Sample], durs: &[u32]) -> Vec { 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 { 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 { 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 { 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 { 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 { 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, cps: Vec>>) -> 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 dir = std::env::temp_dir(); let path = dir.join(format!("fmkv_mp4_novid_{}.mp4", std::process::id())); let err = match Mp4Sink::create(&path, &t) { Ok(_) => panic!("expected no-video-track error"), Err(e) => e, }; assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); let _ = std::fs::remove_file(&path); } fn frame(track: usize, pts_ns: i64, key: bool, data: Vec) -> 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 { 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 dir = std::env::temp_dir(); use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); let path = dir.join(format!( "fmkv_mp4_av_{}_{}.mp4", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); let d = 41_708_333; { let mut s = Mp4Sink::create(&path, &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 = std::fs::read(&path).unwrap(); let _ = std::fs::remove_file(&path); 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"]); // 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 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)); } }