diff --git a/CHANGELOG.md b/CHANGELOG.md index 5125c1a..66e06a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`fd::each_child` / `hint::each_child` box-walker overflow** (ISO/IEC 14496-12 §4.2): a hostile `largesize`-encoded child near `u64::MAX` (or an oversize `size32`) overflowed the `pos + total` bound check before it could reject the child. Both walkers now bound with `checked_add`, dropping the malformed child and terminating the walk cleanly. - Daily `Fuzz` workflow (fleet `crate-fuzz.yml` shim, 05:23 UTC) — previously absent for this crate. +- Header timestamps on the muxer (ISO/IEC 14496-12 §8.2.2 `mvhd` / §8.3.2 `tkhd` / §8.4.2 `mdhd`), previously hardcoded to zero: `Mp4MuxerOptions::creation_time` stamps all three in the header epoch — seconds since 1904-01-01 UTC, converted by the public `mp4_secs_from_unix_secs` / `mp4_secs_from_system_time` (`None` before 1904), its inverse `unix_secs_from_mp4_secs`, and the `MP4_EPOCH_OFFSET_SECS` constant. One value fills both `creation_time` and `modification_time` of each box; `Mp4MuxerOptions::modification_time` drives them apart. `Mp4MuxerOptions::track_header_timestamps` (new `TrackHeaderTimestamps` record — `track` is the `tkhd` pair, `media` the `mdhd` one) replaces the pair for the stream it names rather than inheriting either half, `media: None` reusing `track` being the only defaulting step; `mvhd` keeps the movie-wide values and out-of-range indices fail at `open`. A value past 2040-02-06 in either half promotes that box to version 1 with 64-bit time + duration fields, per box and automatic. Plain, faststart and fragmented init-segment paths all honour it; the default `None` keeps the all-zero version-0 headers byte-identical to the historical output + +- Header-timestamp read side: `Mp4Demuxer::mvhd_timestamps()` / `::tkhd_timestamps(stream)` / `::mdhd_timestamps(stream)` return the new `demux::HeaderTimestamps` (`creation_time` + `modification_time`, `is_unset()` for the all-zero encoding; re-exported at the crate root, since the muxer options take it too) in the boxes' own units, so a value read here feeds straight back into the muxer — the two surfaces reach the same six fields, making a remux carry-across lossless. `tkhd` and `mdhd` stay separate rather than collapsing, since a producer may date the media apart from the track; v0 and v1 layouts both widen to `u64`, read by one shared reader for all three boxes. Flat metadata mirrors as `mvhd_creation_time` / `mvhd_modification_time` and the per-stream `tkhd_*` / `mdhd_*`, emitted only when non-zero so an unstamped file adds no keys. 15 integration tests (six distinct values byte-patched into the six fields, so a swapped or shared read cannot pass; a PATH-gated cross-check reading back a stamp written by `ffmpeg`; v1 promotion across the plain, faststart and fragmented paths) + - `mfro` surfacing (ISO/IEC 14496-12 §8.8.12): the trailer's declared enclosing-`mfra` size appears as the `mfro_size` metadata key, with a `mfro_size_mismatch` key (`declared= actual=`) when it disagrees with the mfra measured on disk — validating the last-16-bytes locator shortcut without affecting the open or the seek table. Integration test covers own-muxer match + byte-corrupted mismatch - Hostile-input hardening from a corpus-refreshed fuzz campaign — five distinct unbacked-allocation (OOM) shapes found and fixed, each pinned as a fuzz-corpus regression + covered by tests: (1) a defaults-only `trun` (zero wire bytes per sample) claiming ~10^9 samples — every fragmented `sample_count` is now charged against a whole-file sample budget (one input byte per sample floor); (2) constant-size `stsz` whose `sample_count × sample_size` exceeds the file size; (3) `next_packet` allocating a forged multi-GiB sample size before reading — sizes are validated against the input length first; (4) `tfra` reserving its entry table before the entry-count/body validation; (5) `senc` zero-width entries (IV size 0, no subsample flag — syntactically legal §7.2.2) with a forged count — bounded at 2^20 entries; plus body-backed `Vec::with_capacity` clamps across the moov table parsers (`stts` / `stsc` / `stsz` / `stz2` / `stss` / `ctts` / `stco` / `co64` / `elst`) and a pre-allocation cap on the `resolve_sai_aux_info` sizes scratch vector. Fuzz target now drives `open_typed` + `resolve_sai_aux_info` + `empty_duration_records`; corpus gains muxer-produced fMP4 seeds (sealed `mehd` + empty-time gap fragment, senc-stripped CENC saiz/saio carriage) diff --git a/README.md b/README.md index 83f5497..10342da 100644 --- a/README.md +++ b/README.md @@ -1576,6 +1576,44 @@ subsequent movie fragments") — e.g. a `media_time = 1024, duration = 0` entry cuts AAC priming ahead of the first presented CMAF sample across every fragment. +Header timestamps (`mvhd` §8.2.2, `tkhd` §8.3.2, `mdhd` §8.4.2) +default to zero — the historical output and the "unset" reading. +`Mp4MuxerOptions::creation_time` stamps all three, in the ISOBMFF +epoch: seconds since 1904-01-01 UTC, not the Unix one +(`mp4_secs_from_unix_secs` / `mp4_secs_from_system_time` convert and +reject pre-1904 instants; `unix_secs_from_mp4_secs` goes back; +`MP4_EPOCH_OFFSET_SECS` is the raw constant). One value fills both +`creation_time` and `modification_time` of each box — the common case +of a file created and last modified at one instant — while +`Mp4MuxerOptions::modification_time` drives the two apart. Per-track +overrides go in `Mp4MuxerOptions::track_header_timestamps`, a list of +`TrackHeaderTimestamps` keyed by `stream_index` whose `track` is the +`tkhd` pair and `media` the `mdhd` one (§8.4.2 dates the media, §8.3.2 +the track, and a producer may stamp them apart). An entry replaces the +pair it names rather than inheriting either half from the movie-wide +options; `media: None` reusing `track` is the only defaulting step. +`mvhd` keeps the movie-wide values, and an out-of-range index fails at +`open`. A timestamp past 2040-02-06 in either half — where the value +crosses `u32::MAX` — promotes that box from version 0 to version 1 +with 64-bit time and duration fields, the same auto-promotion the +over-32-bit durations get. The plain, faststart and fragmented +(init-segment) paths all honour it. + +The demuxer surfaces the same three pairs in the same units: +`Mp4Demuxer::mvhd_timestamps()`, `::tkhd_timestamps(stream)` and +`::mdhd_timestamps(stream)` (the latter two `None` for an unknown +stream) return a `demux::HeaderTimestamps`, with the version 0 and +version 1 layouts both widened to `u64` on intake. The same values +appear on the flat metadata channel as `mvhd_creation_time` / +`mvhd_modification_time` and the per-stream `tkhd_*` / `mdhd_*` +equivalents, each emitted only when non-zero — zero is the "unset" +encoding most files carry, and `HeaderTimestamps::is_unset` is the +typed form of that check. The two surfaces reach the same six fields, +so a remuxer carries a source's stamps across unchanged: same epoch, +same units, no conversion. The HEIF item properties `crtt` / `mdft` +are a separate surface — same 1904 origin, but microseconds, so these +helpers do not apply to them. + Sample groups (`sbgp` / `sgpd` / `csgp`, ISO/IEC 14496-12 §8.9.2 / §8.9.3 / §8.9.5) are emitted per-track when supplied via `Mp4MuxerOptions::track_sample_groups` (a list of `TrackSampleGroups`, diff --git a/src/demux.rs b/src/demux.rs index 7f002a8..68aed71 100644 --- a/src/demux.rs +++ b/src/demux.rs @@ -371,6 +371,27 @@ pub fn open_typed(mut input: Box, codecs: &dyn CodecResolver) -> R metadata.push(("mehd_fragment_duration".to_string(), d.to_string())); } + // §8.2.2 mvhd creation / modification timestamps, in the box's own + // units (seconds since 1904-01-01 UTC) so the flat channel says + // exactly what the file says. Both keys are emitted only when the + // value is non-zero: zero is the "producer left it unset" encoding + // that the majority of files (including this crate's own default + // output) carry, and emitting it everywhere would be noise rather + // than information. The typed pair is always available through + // `Mp4Demuxer::mvhd_timestamps`. + if parsed.movie_timestamps.creation_time != 0 { + metadata.push(( + "mvhd_creation_time".to_string(), + parsed.movie_timestamps.creation_time.to_string(), + )); + } + if parsed.movie_timestamps.modification_time != 0 { + metadata.push(( + "mvhd_modification_time".to_string(), + parsed.movie_timestamps.modification_time.to_string(), + )); + } + // Surface a parsed `pdin` (ProgressiveDownloadInfoBox, §8.1.3) on // the flat metadata channel. Quantity is zero or one per file // (§8.1.3.1); we emit: @@ -847,6 +868,9 @@ pub fn open_typed(mut input: Box, codecs: &dyn CodecResolver) -> R .iter() .map(|t| t.elst.iter().map(elst_entry_to_public).collect()) .collect(), + mvhd_timestamps: parsed.movie_timestamps, + tkhd_timestamps: parsed.tracks.iter().map(|t| t.tkhd_timestamps).collect(), + mdhd_timestamps: parsed.tracks.iter().map(|t| t.mdhd_timestamps).collect(), last_sdi: None, }) } @@ -1459,11 +1483,47 @@ pub struct TrepRecord { pub children: Vec, } +/// The `creation_time` / `modification_time` pair carried by each of +/// the three ISOBMFF header boxes — `mvhd` (§8.2.2), `tkhd` (§8.3.2) +/// and `mdhd` (§8.4.2). +/// +/// Both values are seconds since 1904-01-01 UTC — the ISOBMFF header +/// epoch, exactly as the box records them, matching the write side's +/// [`Mp4MuxerOptions::creation_time`](crate::options::Mp4MuxerOptions::creation_time). +/// [`unix_secs_from_mp4_secs`](crate::options::unix_secs_from_mp4_secs) +/// reaches a Unix timestamp. Version 0 carries the pair as 32-bit +/// fields and version 1 as 64-bit; both widen to `u64` here. Zero is +/// the conventional "unset" value — see [`is_unset`](Self::is_unset). +/// +/// Not to be confused with the ISO/IEC 23008-12 `crtt` / `mdft` item +/// properties, which share the 1904 origin but count microseconds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HeaderTimestamps { + /// `creation_time` — seconds since 1904-01-01 UTC. + pub creation_time: u64, + /// `modification_time` — seconds since 1904-01-01 UTC. + pub modification_time: u64, +} + +impl HeaderTimestamps { + /// `true` when both fields are zero — the "producer did not stamp + /// this box" encoding. Distinguishing it from a genuine + /// 1904-01-01 stamp is not possible in the format, and no real + /// file means the latter. + pub fn is_unset(&self) -> bool { + self.creation_time == 0 && self.modification_time == 0 + } +} + #[derive(Default)] struct ParsedMoov { tracks: Vec, movie_timescale: u32, movie_duration: u64, + /// §8.2.2 `mvhd` header timestamps for the movie as a whole. + /// Defaults to the all-zero "unset" pair when the file leaves them + /// blank, which is the common case. + movie_timestamps: HeaderTimestamps, /// §8.8.2 (`mehd` MovieExtendsHeaderBox) — overall presentation /// duration of a fragmented movie, including fragments, in the /// movie timescale. `None` when the box is absent (per the spec @@ -1523,6 +1583,13 @@ struct Track { /// Per-track timescale (ticks per second). timescale: u32, duration: Option, + /// §8.3.2 `tkhd` header timestamps for this track. + tkhd_timestamps: HeaderTimestamps, + /// §8.4.2 `mdhd` header timestamps for this track's media. A + /// producer may stamp these differently from the `tkhd` pair + /// (`mdhd` describes when the *media* was created, `tkhd` the + /// track), so both are kept rather than collapsed. + mdhd_timestamps: HeaderTimestamps, // Audio channels: Option, sample_rate: Option, @@ -3126,8 +3193,41 @@ fn parse_trex(body: &[u8], tracks: &mut [Track]) -> Result<()> { Ok(()) } +/// Read the `creation_time` / `modification_time` pair that opens the +/// body of every ISOBMFF header box (`mvhd` §8.2.2, `tkhd` §8.3.2, +/// `mdhd` §8.4.2) right after the 4-byte FullBox preamble: two u32s at +/// version 0, two u64s at version 1. All three boxes share this +/// prefix, so one reader serves them. +/// +/// Returns the all-zero (unset) pair when the body is too short to +/// hold it. No caller can reach that path today — each parser's own +/// length check is stricter than this one — but the guard keeps the +/// helper correct on its own terms rather than on its callers'. +fn read_header_timestamps(body: &[u8], version: u8) -> HeaderTimestamps { + let need = if version == 0 { 12 } else { 20 }; + if body.len() < need { + return HeaderTimestamps::default(); + } + if version == 0 { + HeaderTimestamps { + creation_time: u32::from_be_bytes([body[4], body[5], body[6], body[7]]) as u64, + modification_time: u32::from_be_bytes([body[8], body[9], body[10], body[11]]) as u64, + } + } else { + HeaderTimestamps { + creation_time: u64::from_be_bytes([ + body[4], body[5], body[6], body[7], body[8], body[9], body[10], body[11], + ]), + modification_time: u64::from_be_bytes([ + body[12], body[13], body[14], body[15], body[16], body[17], body[18], body[19], + ]), + } + } +} + /// ISO/IEC 14496-12 §8.2.2 Movie Header box. Carries the movie-wide -/// timescale and duration (in that timescale). +/// timescale and duration (in that timescale), plus the movie's +/// creation / modification timestamps. fn parse_mvhd(body: &[u8], out: &mut ParsedMoov) -> Result<()> { if body.is_empty() { return Err(Error::invalid("MP4: mvhd empty")); @@ -3152,6 +3252,7 @@ fn parse_mvhd(body: &[u8], out: &mut ParsedMoov) -> Result<()> { }; out.movie_timescale = timescale; out.movie_duration = duration; + out.movie_timestamps = read_header_timestamps(body, version); Ok(()) } @@ -5337,6 +5438,8 @@ fn parse_trak(body: &[u8], file_size: u64) -> Result> { codec_id_fourcc: [0; 4], timescale: 0, duration: None, + tkhd_timestamps: HeaderTimestamps::default(), + mdhd_timestamps: HeaderTimestamps::default(), channels: None, sample_rate: None, sample_size_bits: None, @@ -6021,6 +6124,7 @@ fn parse_tkhd(body: &[u8], t: &mut Track) -> Result<()> { return Err(Error::invalid("MP4: tkhd too short")); } t.track_id = u32::from_be_bytes([body[off], body[off + 1], body[off + 2], body[off + 3]]); + t.tkhd_timestamps = read_header_timestamps(body, version); Ok(()) } @@ -6496,6 +6600,7 @@ fn parse_mdhd(body: &[u8], t: &mut Track) -> Result<()> { }; t.timescale = timescale; t.duration = Some(duration); + t.mdhd_timestamps = read_header_timestamps(body, version); Ok(()) } @@ -11933,6 +12038,30 @@ fn build_stream_info(index: u32, t: &Track, codecs: &dyn CodecResolver) -> Strea params.options.insert(format!("subtrack_{n}"), s); } + // ISO/IEC 14496-12 §8.3.2 / §8.4.2: the track's `tkhd` and `mdhd` + // creation / modification timestamps, in the boxes' own units + // (seconds since 1904-01-01 UTC — *not* the Unix epoch; see + // `options::unix_secs_from_mp4_secs`). The two boxes are stamped + // separately by some producers (`mdhd` dating the media, `tkhd` + // the track), so both pairs are surfaced rather than collapsed. + // Each key appears only when its value is non-zero — zero is the + // "unset" encoding most files carry. The typed form is + // `Mp4Demuxer::tkhd_timestamps` / `::mdhd_timestamps`. + for (prefix, ts) in [("tkhd", &t.tkhd_timestamps), ("mdhd", &t.mdhd_timestamps)] { + if ts.creation_time != 0 { + params.options.insert( + format!("{prefix}_creation_time"), + ts.creation_time.to_string(), + ); + } + if ts.modification_time != 0 { + params.options.insert( + format!("{prefix}_modification_time"), + ts.modification_time.to_string(), + ); + } + } + // ISO/IEC 14496-12 §8.6.1.4: when the track carries a `cslg` // (CompositionToDecodeBox), surface its five timeline-relation // fields on `params.options` as `cslg_`. These document the @@ -13118,6 +13247,13 @@ pub struct Mp4Demuxer { /// tooling that wants the declared list itself (e.g. a remuxer /// carrying the elst across, or a validator). edit_lists: Vec>, + /// §8.2.2 `mvhd` creation / modification timestamps for the movie. + /// See [`Self::mvhd_timestamps`]. + mvhd_timestamps: HeaderTimestamps, + /// Per-stream §8.3.2 `tkhd` timestamps (parallel to `streams`). + tkhd_timestamps: Vec, + /// Per-stream §8.4.2 `mdhd` timestamps (parallel to `streams`). + mdhd_timestamps: Vec, /// `sample_description_index` of the packet most recently returned /// by `next_packet` (`None` before the first packet). See /// [`Self::sample_description_index_of_last_packet`]. @@ -13563,6 +13699,46 @@ impl Mp4Demuxer { .unwrap_or(&[]) } + /// The movie-wide §8.2.2 `mvhd` creation / modification + /// timestamps, in seconds since 1904-01-01 UTC. + /// + /// `mvhd` is mandatory, so this always returns a pair; most files + /// leave it unstamped, which reads back as the all-zero + /// [`HeaderTimestamps::is_unset`] value. The same data appears on + /// the flat metadata channel as `mvhd_creation_time` / + /// `mvhd_modification_time` (emitted only when non-zero). + /// + /// This is the read-side dual of + /// [`Mp4MuxerOptions::creation_time`](crate::options::Mp4MuxerOptions::creation_time): + /// a remuxer preserves a source's stamp by feeding this value + /// straight back in — same epoch, same units, no conversion. + pub fn mvhd_timestamps(&self) -> HeaderTimestamps { + self.mvhd_timestamps + } + + /// The §8.3.2 `tkhd` creation / modification timestamps of + /// `stream_index`'s track, in seconds since 1904-01-01 UTC. + /// `None` when the index names no stream. + /// + /// Surfaced flat on the stream's `params.options` as + /// `tkhd_creation_time` / `tkhd_modification_time` (only when + /// non-zero). See [`Self::mdhd_timestamps`] for the `mdhd` pair, + /// which a producer may stamp differently. + pub fn tkhd_timestamps(&self, stream_index: u32) -> Option { + self.tkhd_timestamps.get(stream_index as usize).copied() + } + + /// The §8.4.2 `mdhd` creation / modification timestamps of + /// `stream_index`'s track media, in seconds since 1904-01-01 UTC. + /// `None` when the index names no stream. + /// + /// Surfaced flat on the stream's `params.options` as + /// `mdhd_creation_time` / `mdhd_modification_time` (only when + /// non-zero). + pub fn mdhd_timestamps(&self, stream_index: u32) -> Option { + self.mdhd_timestamps.get(stream_index as usize).copied() + } + /// The 1-based §8.5.2 `sample_description_index` of the packet /// most recently returned by `next_packet` (`None` before the /// first packet). Resolved from the covering `stsc` entry (§8.7.4) @@ -14617,6 +14793,8 @@ mod tests { codec_id_fourcc: [0; 4], timescale: 0, duration: None, + tkhd_timestamps: super::HeaderTimestamps::default(), + mdhd_timestamps: super::HeaderTimestamps::default(), channels: None, sample_rate: None, sample_size_bits: None, diff --git a/src/frag.rs b/src/frag.rs index 3b21d3f..a3c42e4 100644 --- a/src/frag.rs +++ b/src/frag.rs @@ -224,6 +224,7 @@ pub fn open_fragmented_typed( if streams.is_empty() { return Err(Error::invalid("mp4 muxer: need at least one stream")); } + options.validate_track_header_timestamps(streams.len())?; let mut tracks = Vec::with_capacity(streams.len()); for (i, s) in streams.iter().enumerate() { let mut entry = sample_entry_for(&s.params)?; @@ -249,6 +250,10 @@ pub fn open_fragmented_typed( // chunking-target field is irrelevant — we only carry it to keep // TrackState happy. base.samples_per_chunk_target = default_samples_per_chunk(&base.stream); + // §8.3.2 / §8.4.2 header timestamps for the init segment's + // tkhd / mdhd: movie-wide values unless overridden per track. + base.tkhd_timestamps = options.resolve_track_timestamps(i); + base.mdhd_timestamps = options.resolve_media_timestamps(i); tracks.push(FragTrackState::new(base, (i as u32) + 1, protection)); } // ISO/IEC 14496-12 §8.6.6: validate explicit edit lists up front so @@ -361,6 +366,7 @@ impl Muxer for FragmentedMuxer { &self.frag_options.treps, &self.options.pssh, self.frag_options.write_mehd, + self.options.resolve_movie_timestamps(), )?; // §8.8.2 — remember where the reserved mehd fragment_duration // bytes landed in the file (ftyp precedes the moov) so @@ -1380,12 +1386,18 @@ fn build_init_moov( treps: &[crate::demux::TrepRecord], pssh: &[crate::cenc::PsshBox], write_mehd: bool, + movie_times: crate::demux::HeaderTimestamps, ) -> Result<(Vec, Option)> { // Movie timescale: pick 1000 (matches the non-fragmented path). let movie_timescale: u32 = 1000; let mut moov_body = Vec::new(); - moov_body.extend_from_slice(&build_mvhd(movie_timescale, 0, (tracks.len() as u32) + 1)); + moov_body.extend_from_slice(&build_mvhd( + movie_timescale, + 0, + (tracks.len() as u32) + 1, + movie_times, + )); for t in tracks { // §8.6.6: explicit edit list for this track (stream index is // track_id - 1 — track IDs are assigned 1-based in open order). @@ -1424,7 +1436,7 @@ fn build_trak_init( let mut body = Vec::new(); // Duration is unknown at init time — use 0 so players read tfhd/trun // for actual timing. Per §8.2.2 a zero duration means "indefinite". - body.extend_from_slice(&build_tkhd(track_id, 0, &t.stream)); + body.extend_from_slice(&build_tkhd(track_id, 0, &t.stream, t.tkhd_timestamps)); // §8.6.5–6: a caller-supplied edit list goes between tkhd and mdia. // The §8.6.6.1 zero-duration form makes it cover the whole // fragmented presentation ("from media_time onwards"). diff --git a/src/lib.rs b/src/lib.rs index 9c7046b..ff83846 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,9 +27,14 @@ pub mod sample_groups; pub mod styp; pub use options::{ - BrandPreset, FragmentCadence, FragmentedOptions, Mp4MuxerOptions, TrackEditList, - TrackProtection, TrackSampleGroups, + mp4_secs_from_system_time, mp4_secs_from_unix_secs, unix_secs_from_mp4_secs, BrandPreset, + FragmentCadence, FragmentedOptions, Mp4MuxerOptions, TrackEditList, TrackHeaderTimestamps, + TrackProtection, TrackSampleGroups, MP4_EPOCH_OFFSET_SECS, }; +// The header-timestamp pair is written through `Mp4MuxerOptions` and +// read back off `Mp4Demuxer`, so it belongs at the root alongside the +// options types even though it is defined in `demux`. +pub use demux::HeaderTimestamps; use oxideav_core::ContainerRegistry; diff --git a/src/muxer.rs b/src/muxer.rs index ef16e71..6070bc7 100644 --- a/src/muxer.rs +++ b/src/muxer.rs @@ -85,6 +85,17 @@ pub(crate) struct TrackState { pub(crate) prev_pts_in_ts: Option, /// First PTS in media time scale (for duration calculation + elst). pub(crate) first_pts_in_ts: Option, + + /// Header timestamps for this track's `tkhd` (§8.3.2), in seconds + /// since 1904-01-01 UTC. Resolved at `open` from + /// `Mp4MuxerOptions::creation_time` / `modification_time` plus any + /// `track_header_timestamps` override, which replaces the pair + /// outright; the all-zero pair means "unset" (the default). + pub(crate) tkhd_timestamps: crate::demux::HeaderTimestamps, + /// Header timestamps for this track's `mdhd` (§8.4.2). Same + /// resolution, but a `TrackHeaderTimestamps::media` override can date + /// the media apart from the track that carries it. + pub(crate) mdhd_timestamps: crate::demux::HeaderTimestamps, } impl TrackState { @@ -113,6 +124,8 @@ impl TrackState { cumulative_duration: 0, prev_pts_in_ts: None, first_pts_in_ts: None, + tkhd_timestamps: crate::demux::HeaderTimestamps::default(), + mdhd_timestamps: crate::demux::HeaderTimestamps::default(), } } @@ -193,8 +206,12 @@ pub fn open_with_options( )); } if let Some(frag_opts) = options.fragmented.clone() { + // The fragmented path runs its own copy of the checks below + // (`open_fragmented_typed` is a public entry point in its own + // right), so validate once per path rather than twice here. return crate::frag::open_fragmented(output, streams, options, frag_opts); } + options.validate_track_header_timestamps(streams.len())?; let mut tracks = Vec::with_capacity(streams.len()); for (i, s) in streams.iter().enumerate() { let mut entry = sample_entry_for(&s.params)?; @@ -207,7 +224,12 @@ pub fn open_with_options( { entry = apply_protection(entry, s.params.media_type, prot)?; } - tracks.push(TrackState::new(s.clone(), entry)); + let mut track = TrackState::new(s.clone(), entry); + // §8.3.2 / §8.4.2 header timestamps: movie-wide value unless a + // per-track override targets this stream. + track.tkhd_timestamps = options.resolve_track_timestamps(i); + track.mdhd_timestamps = options.resolve_media_timestamps(i); + tracks.push(track); } // ISO/IEC 14496-12 §8.6.6: validate explicit edit lists up front so // a list that would not round-trip (§8.6.6.3 rate / final-empty-edit @@ -473,6 +495,7 @@ impl Mp4Muxer { &self.options.track_edit_lists, &self.options.track_sample_groups, &self.options.pssh, + self.options.resolve_movie_timestamps(), )?; self.output.write_all(&moov)?; Ok(()) @@ -530,6 +553,7 @@ impl Mp4Muxer { &self.options.track_edit_lists, &self.options.track_sample_groups, &self.options.pssh, + self.options.resolve_movie_timestamps(), )?; let candidate_size = candidate.len() as u64; let converged = candidate_size == moov_size; @@ -573,6 +597,7 @@ fn build_moov( track_edit_lists: &[TrackEditList], track_sample_groups: &[TrackSampleGroups], pssh: &[crate::cenc::PsshBox], + movie_times: crate::demux::HeaderTimestamps, ) -> Result> { // mvhd: use the largest media-time-scale duration as a rough movie // duration at timescale 1000. @@ -591,7 +616,12 @@ fn build_moov( let next_track_id = (tracks.len() as u32) + 1; let mut moov_body = Vec::new(); - moov_body.extend_from_slice(&build_mvhd(movie_timescale, movie_duration, next_track_id)); + moov_body.extend_from_slice(&build_mvhd( + movie_timescale, + movie_duration, + next_track_id, + movie_times, + )); for (i, t) in tracks.iter().enumerate() { // Gather all sample-group directives that target this stream index. // Multiple directives for the same stream accumulate in encounter @@ -629,22 +659,37 @@ fn build_moov( Ok(wrap_box(b"moov", &moov_body)) } -pub(crate) fn build_mvhd(timescale: u32, duration: u64, next_track_id: u32) -> Vec { - // Choose version 0 if duration fits in u32, else version 1. - let use_v1 = duration > u32::MAX as u64; +/// `true` when either half of a header-timestamp pair overflows the +/// version-0 32-bit field, i.e. the box must be written as version 1. +/// Shared by all three header builders so they promote alike. +fn needs_64_bit_time(times: crate::demux::HeaderTimestamps) -> bool { + times.creation_time > u32::MAX as u64 || times.modification_time > u32::MAX as u64 +} + +pub(crate) fn build_mvhd( + timescale: u32, + duration: u64, + next_track_id: u32, + times: crate::demux::HeaderTimestamps, +) -> Vec { + // Choose version 0 if every 32-bit field fits, else version 1. The + // §8.2.2 v0 layout carries duration *and* both timestamps as u32, + // so a post-2040 timestamp forces the promotion just as a + // >u32::MAX duration does. + let use_v1 = duration > u32::MAX as u64 || needs_64_bit_time(times); let mut body = Vec::with_capacity(120); if use_v1 { body.push(1); // version body.extend_from_slice(&[0, 0, 0]); // flags - body.extend_from_slice(&0u64.to_be_bytes()); // creation_time - body.extend_from_slice(&0u64.to_be_bytes()); // modification_time + body.extend_from_slice(×.creation_time.to_be_bytes()); + body.extend_from_slice(×.modification_time.to_be_bytes()); body.extend_from_slice(×cale.to_be_bytes()); body.extend_from_slice(&duration.to_be_bytes()); } else { body.push(0); // version body.extend_from_slice(&[0, 0, 0]); // flags - body.extend_from_slice(&0u32.to_be_bytes()); // creation_time - body.extend_from_slice(&0u32.to_be_bytes()); // modification_time + body.extend_from_slice(&(times.creation_time as u32).to_be_bytes()); + body.extend_from_slice(&(times.modification_time as u32).to_be_bytes()); body.extend_from_slice(×cale.to_be_bytes()); body.extend_from_slice(&(duration as u32).to_be_bytes()); } @@ -678,7 +723,12 @@ fn build_trak( let mut body = Vec::new(); let track_duration_movie = rescale_u64(t.cumulative_duration, t.media_time_scale, movie_timescale); - body.extend_from_slice(&build_tkhd(track_id, track_duration_movie, &t.stream)); + body.extend_from_slice(&build_tkhd( + track_id, + track_duration_movie, + &t.stream, + t.tkhd_timestamps, + )); // edts/elst (ISO/IEC 14496-12 §8.6.5–6) goes between tkhd and mdia. An // explicit caller-supplied list is emitted verbatim (and regardless of // `write_edit_list` — the flag governs only the automatic start-delay @@ -762,23 +812,30 @@ fn push_elst_entry( out.extend_from_slice(&media_rate.to_be_bytes()); } -pub(crate) fn build_tkhd(track_id: u32, duration: u64, stream: &StreamInfo) -> Vec { - let use_v1 = duration > u32::MAX as u64; +pub(crate) fn build_tkhd( + track_id: u32, + duration: u64, + stream: &StreamInfo, + times: crate::demux::HeaderTimestamps, +) -> Vec { + // §8.3.2: as in mvhd, the v0 layout is u32 for duration and both + // timestamps — any of them overflowing promotes the box to v1. + let use_v1 = duration > u32::MAX as u64 || needs_64_bit_time(times); let mut body = Vec::new(); let flags: u32 = 0x0000_0007; // track_enabled | track_in_movie | track_in_preview if use_v1 { body.push(1); body.extend_from_slice(&flags.to_be_bytes()[1..4]); - body.extend_from_slice(&0u64.to_be_bytes()); // creation_time - body.extend_from_slice(&0u64.to_be_bytes()); // modification_time + body.extend_from_slice(×.creation_time.to_be_bytes()); + body.extend_from_slice(×.modification_time.to_be_bytes()); body.extend_from_slice(&track_id.to_be_bytes()); body.extend_from_slice(&0u32.to_be_bytes()); // reserved body.extend_from_slice(&duration.to_be_bytes()); } else { body.push(0); body.extend_from_slice(&flags.to_be_bytes()[1..4]); - body.extend_from_slice(&0u32.to_be_bytes()); // creation_time - body.extend_from_slice(&0u32.to_be_bytes()); // modification_time + body.extend_from_slice(&(times.creation_time as u32).to_be_bytes()); + body.extend_from_slice(&(times.modification_time as u32).to_be_bytes()); body.extend_from_slice(&track_id.to_be_bytes()); body.extend_from_slice(&0u32.to_be_bytes()); // reserved body.extend_from_slice(&(duration as u32).to_be_bytes()); @@ -831,20 +888,22 @@ fn build_mdia_with_sample_groups( fn build_mdhd(t: &TrackState) -> Vec { let duration = t.cumulative_duration; - let use_v1 = duration > u32::MAX as u64; + let times = t.mdhd_timestamps; + // §8.4.2: same v0/v1 field-width rule as mvhd and tkhd. + let use_v1 = duration > u32::MAX as u64 || needs_64_bit_time(times); let mut body = Vec::new(); if use_v1 { body.push(1); body.extend_from_slice(&[0, 0, 0]); // flags - body.extend_from_slice(&0u64.to_be_bytes()); // creation - body.extend_from_slice(&0u64.to_be_bytes()); // modification + body.extend_from_slice(×.creation_time.to_be_bytes()); + body.extend_from_slice(×.modification_time.to_be_bytes()); body.extend_from_slice(&t.media_time_scale.to_be_bytes()); body.extend_from_slice(&duration.to_be_bytes()); } else { body.push(0); body.extend_from_slice(&[0, 0, 0]); // flags - body.extend_from_slice(&0u32.to_be_bytes()); - body.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(&(times.creation_time as u32).to_be_bytes()); + body.extend_from_slice(&(times.modification_time as u32).to_be_bytes()); body.extend_from_slice(&t.media_time_scale.to_be_bytes()); body.extend_from_slice(&(duration as u32).to_be_bytes()); } diff --git a/src/options.rs b/src/options.rs index 85dfc7c..bfea488 100644 --- a/src/options.rs +++ b/src/options.rs @@ -327,6 +327,117 @@ pub struct TrackProtection { pub tenc: crate::cenc::TencBox, } +/// Seconds between the ISO/IEC 14496-12 epoch (1904-01-01 00:00:00 UTC, +/// the QuickTime/ISOBMFF header-timestamp origin) and the Unix epoch +/// (1970-01-01 00:00:00 UTC). +/// +/// Header timestamps (`mvhd` / `tkhd` / `mdhd` `creation_time` and +/// `modification_time`) count seconds from the *1904* origin, so a Unix +/// timestamp has to be shifted by this constant before it goes into a +/// box. [`mp4_secs_from_unix_secs`] does that with the range checks. +/// +/// Note the unit: these three header fields count *seconds*. The +/// ISO/IEC 23008-12 item properties `crtt` / `mdft` share the same 1904 +/// origin but count **microseconds**, so this constant does not convert +/// them. +pub const MP4_EPOCH_OFFSET_SECS: u64 = 2_082_844_800; + +/// Convert a Unix timestamp (seconds since 1970-01-01 UTC) into the +/// ISO/IEC 14496-12 header form (seconds since 1904-01-01 UTC) for +/// [`Mp4MuxerOptions::creation_time`]. +/// +/// Returns `None` for instants before 1904-01-01, which the unsigned +/// box field cannot represent. [`unix_secs_from_mp4_secs`] is the +/// inverse, for timestamps read back off a file. +pub fn mp4_secs_from_unix_secs(unix_secs: i64) -> Option { + // i128 keeps the add exact for every i64 input; the result is then + // range-checked back into the unsigned box field. + let shifted = unix_secs as i128 + MP4_EPOCH_OFFSET_SECS as i128; + u64::try_from(shifted).ok() +} + +/// Convert an ISO/IEC 14496-12 header timestamp (seconds since +/// 1904-01-01 UTC, as carried by `mvhd` / `tkhd` / `mdhd` and surfaced +/// by the demuxer's header-timestamp accessors) back to a Unix +/// timestamp. +/// +/// Never fails: values above `i64::MAX + MP4_EPOCH_OFFSET_SECS` +/// saturate at `i64::MAX` rather than wrapping. The inverse of +/// [`mp4_secs_from_unix_secs`]. +pub fn unix_secs_from_mp4_secs(mp4_secs: u64) -> i64 { + // Only the top needs clamping: shifting a u64 down by the offset + // floors the result at -MP4_EPOCH_OFFSET_SECS, so the i64 lower + // bound cannot be reached. + let shifted = mp4_secs as i128 - MP4_EPOCH_OFFSET_SECS as i128; + shifted.min(i64::MAX as i128) as i64 +} + +/// Convert a [`std::time::SystemTime`] into the ISO/IEC 14496-12 header +/// form (seconds since 1904-01-01 UTC) for +/// [`Mp4MuxerOptions::creation_time`]. +/// +/// Sub-second precision is floored — the whole-second field always +/// names the second the instant falls *inside*, on both sides of 1970. +/// Returns `None` for instants before 1904-01-01. +pub fn mp4_secs_from_system_time(t: std::time::SystemTime) -> Option { + match t.duration_since(std::time::UNIX_EPOCH) { + Ok(d) => MP4_EPOCH_OFFSET_SECS.checked_add(d.as_secs()), + // Before 1970: representable only if it is still at or after + // 1904. `e.duration()` counts *backwards* from the Unix epoch, + // so a sub-second remainder means the instant sits one second + // earlier than `as_secs()` alone would say — subtract it to + // floor rather than round toward the future. + Err(e) => { + let back = e.duration(); + let secs = back + .as_secs() + .saturating_add(u64::from(back.subsec_nanos() > 0)); + MP4_EPOCH_OFFSET_SECS.checked_sub(secs) + } + } +} + +/// A per-track override for the header timestamps (ISO/IEC 14496-12 +/// §8.3.2 `tkhd` / §8.4.2 `mdhd`). +/// +/// When a [`Mp4MuxerOptions::track_header_timestamps`] entry targets a +/// stream, that track's `tkhd` and `mdhd` carry these timestamps +/// instead of the movie-wide [`Mp4MuxerOptions::creation_time`] / +/// [`Mp4MuxerOptions::modification_time`]. The movie header (`mvhd`) +/// is unaffected — it only ever carries the movie-wide values. Tracks +/// with no entry keep the movie-wide values. +/// +/// All values are in the header epoch: seconds since 1904-01-01 UTC +/// (see [`mp4_secs_from_unix_secs`]). +/// +/// An entry is read as written — there is no per-field inheritance from +/// the movie-wide values. [`Self::track`] *is* the `tkhd` pair, and +/// [`Self::media`] is the `mdhd` pair when it carries one. The only +/// defaulting is the common case of a track whose media shares its +/// track's instants: `media: None` reuses [`Self::track`] rather than +/// making callers write the same pair twice. +/// +/// A track that wants the movie-wide values in one box and its own in +/// the other spells both out — e.g. `track: movie_pair` alongside a +/// `media` override, rather than leaving a field unset and relying on +/// where it inherits from. +#[derive(Clone, Debug, Default)] +pub struct TrackHeaderTimestamps { + /// Index into the muxer's `streams` slice (the stream these + /// timestamps apply to). Out-of-range indices fail at `open`. + pub stream_index: usize, + /// The §8.3.2 `tkhd` pair for this track, verbatim. The all-zero + /// [`HeaderTimestamps::default`](crate::demux::HeaderTimestamps) + /// is a real "unstamped" value, not "inherit the movie's". + pub track: crate::demux::HeaderTimestamps, + /// The §8.4.2 `mdhd` pair, for a source whose *media* was created + /// or last modified at a different instant from the track that + /// carries it (§8.4.2 dates the media, §8.3.2 the track — a + /// producer may legitimately stamp them apart). `None` — the + /// common case — reuses [`Self::track`]. + pub media: Option, +} + /// Runtime options controlling how the MP4 muxer shapes its output. /// /// Call [`Mp4MuxerOptions::default`] for the historical behavior of the @@ -398,6 +509,45 @@ pub struct Mp4MuxerOptions { /// envelope. See [`TrackProtection`] for the caller's encryption /// responsibilities. pub track_protection: Vec, + /// Header creation timestamp written into `mvhd` (§8.2.2), `tkhd` + /// (§8.3.2) and `mdhd` (§8.4.2), in **seconds since 1904-01-01 + /// UTC** — the ISOBMFF header epoch, not the Unix one. Build the + /// value with [`mp4_secs_from_unix_secs`] / + /// [`mp4_secs_from_system_time`] rather than by hand. + /// + /// `None` (the default) leaves the creation half zero — the + /// historical behaviour and the §8.2.2 "unset" reading; the header + /// is all-zero only when nothing else stamps it, since + /// [`Self::modification_time`] and the per-track entries are + /// written independently. `Some(t)` writes `t` into both + /// `creation_time` and `modification_time` of all three headers, + /// matching `ffmpeg -metadata creation_time=…`; set + /// [`Self::modification_time`] to drive the two apart, and + /// [`Self::track_header_timestamps`] to override `tkhd` / `mdhd` + /// per track. + /// + /// A timestamp past 2040-02-06 exceeds `u32::MAX` and so cannot fit + /// the version-0 layout; the affected box is promoted to version 1 + /// (64-bit time and duration fields) automatically, per box. + pub creation_time: Option, + /// Header modification timestamp for `mvhd` / `tkhd` / `mdhd`, in + /// seconds since 1904-01-01 UTC. + /// + /// `None` (the default) reuses [`Self::creation_time`] — a freshly + /// written file is created and last modified at one instant. Set + /// it when the two differ, e.g. a remux preserving a source's + /// history. Drives the version-1 promotion the same way + /// `creation_time` does, and is written even when `creation_time` + /// is `None`: the resulting `creation = 0, modification = t` is a + /// shape real files carry and the demuxer reports. + pub modification_time: Option, + /// Per-track overrides for the `tkhd` / `mdhd` timestamps. Empty by + /// default — [`Self::creation_time`] covers the common case of one + /// instant for the whole movie. An entry replaces both halves of + /// the pair for the track it names, rather than inheriting either + /// from the movie-wide values. First matching entry per stream + /// wins; see [`TrackHeaderTimestamps`]. + pub track_header_timestamps: Vec, /// `pssh` (ProtectionSystemSpecificHeaderBox, ISO/IEC 23001-7 /// §8.1) boxes emitted at `moov` level, after the `trak` boxes — /// one per DRM system the content keys are provisioned for. @@ -420,7 +570,75 @@ impl Default for Mp4MuxerOptions { track_edit_lists: Vec::new(), large_mdat: false, track_protection: Vec::new(), + creation_time: None, + modification_time: None, + track_header_timestamps: Vec::new(), pssh: Vec::new(), } } } + +impl Mp4MuxerOptions { + /// The `mvhd` (§8.2.2) pair. Movie-wide values only — a per-track + /// override never reaches the movie header. Unset collapses to 0, + /// the "not stamped" encoding. + pub(crate) fn resolve_movie_timestamps(&self) -> crate::demux::HeaderTimestamps { + crate::demux::HeaderTimestamps { + creation_time: self.creation_time.unwrap_or(0), + // A modification time set without a creation time is + // unusual but representable, and real files carry the + // shape — so write it rather than second-guessing. + modification_time: self.modification_time.or(self.creation_time).unwrap_or(0), + } + } + + /// The `tkhd` (§8.3.2) pair for one track: the entry's `track` + /// pair verbatim when one targets this stream, else the movie-wide + /// pair. + pub(crate) fn resolve_track_timestamps( + &self, + stream_index: usize, + ) -> crate::demux::HeaderTimestamps { + match self.track_entry(stream_index) { + Some(e) => e.track, + None => self.resolve_movie_timestamps(), + } + } + + /// The `mdhd` (§8.4.2) pair for one track: the entry's `media` + /// override if it carries one, else the same pair its `tkhd` got. + pub(crate) fn resolve_media_timestamps( + &self, + stream_index: usize, + ) -> crate::demux::HeaderTimestamps { + match self.track_entry(stream_index) { + Some(e) => e.media.unwrap_or(e.track), + None => self.resolve_movie_timestamps(), + } + } + + /// First [`TrackHeaderTimestamps`] targeting `stream_index`, if any. + fn track_entry(&self, stream_index: usize) -> Option<&TrackHeaderTimestamps> { + self.track_header_timestamps + .iter() + .find(|c| c.stream_index == stream_index) + } + + /// Reject [`TrackHeaderTimestamps`] entries that name a stream slot + /// that does not exist, at `open` rather than silently dropping + /// the override at `write_trailer`. + pub(crate) fn validate_track_header_timestamps( + &self, + stream_count: usize, + ) -> oxideav_core::Result<()> { + for c in &self.track_header_timestamps { + if c.stream_index >= stream_count { + return Err(oxideav_core::Error::invalid(format!( + "mp4 muxer: track_header_timestamps stream_index {} out of range ({} streams)", + c.stream_index, stream_count + ))); + } + } + Ok(()) + } +} diff --git a/tests/header_timestamps.rs b/tests/header_timestamps.rs new file mode 100644 index 0000000..ad5633e --- /dev/null +++ b/tests/header_timestamps.rs @@ -0,0 +1,933 @@ +//! Integration tests for the header timestamps (ISO/IEC 14496-12 +//! §8.2.2 `mvhd`, §8.3.2 `tkhd`, §8.4.2 `mdhd`), read and write. +//! +//! What is asserted: +//! +//! * the default (`creation_time: None`) still writes the historical +//! all-zero, version-0 headers, +//! * all six fields the demuxer can report are independently +//! settable, and a per-track entry replaces the pair it names +//! rather than inheriting either half, `media` unset reusing the +//! track's pair being the sole defaulting step, +//! * a timestamp past the 32-bit horizon (2040-02-06) in either half +//! promotes its box to version 1 and the file still demuxes — on +//! the plain, faststart and fragmented paths, the last two being +//! where the extra 36 bytes could disturb chunk offsets or the +//! sealed `mehd` patch, +//! * an out-of-range `stream_index` fails at `open`, +//! * a stamped file round-trips back through the typed accessors and +//! flat metadata keys, while an unstamped one reads as unset and +//! emits no keys. +//! +//! A PATH-gated black-box cross-check has `ffmpeg` — an independent +//! writer — stamp a file and asserts our reader recovers the same +//! instant, which a self-round-trip cannot establish. + +use oxideav_core::{CodecId, CodecParameters, Demuxer, Packet, SampleFormat, StreamInfo, TimeBase}; +use oxideav_core::{ReadSeek, WriteSeek}; +// Deliberately through the crate-root re-exports, which are the paths +// the README hands callers — importing from `demux::` / `options::` +// here would leave those `pub use`s unexercised by any compiled code. +use oxideav_mp4::{ + mp4_secs_from_system_time, mp4_secs_from_unix_secs, unix_secs_from_mp4_secs, FragmentedOptions, + Mp4MuxerOptions, MP4_EPOCH_OFFSET_SECS, +}; +use oxideav_mp4::{HeaderTimestamps, TrackHeaderTimestamps}; + +/// The pair a single-value stamp produces: the same instant in both +/// `creation_time` and `modification_time`. +fn stamped(t: u64) -> HeaderTimestamps { + HeaderTimestamps { + creation_time: t, + modification_time: t, + } +} + +/// A distinct pair, for asserting the two fields stay apart. +fn pair(creation_time: u64, modification_time: u64) -> HeaderTimestamps { + HeaderTimestamps { + creation_time, + modification_time, + } +} + +/// The common per-track override: one instant in both halves of the +/// `tkhd` pair, with `mdhd` reusing it. +fn track_time(stream_index: usize, creation_time: u64) -> TrackHeaderTimestamps { + TrackHeaderTimestamps { + stream_index, + track: stamped(creation_time), + media: None, + } +} + +// --- Box walking --------------------------------------------------------- + +/// Split a buffer of concatenated boxes into `(fourcc, body offset +/// within `buf`, body)` triples. Structural, not a FourCC grep: a +/// `tkhd` byte pattern inside sample payload must not be mistaken for +/// a box header. +fn boxes(buf: &[u8]) -> Vec<([u8; 4], usize, &[u8])> { + let mut out = Vec::new(); + let mut i = 0usize; + while i + 8 <= buf.len() { + let size = u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]) as usize; + let fourcc = [buf[i + 4], buf[i + 5], buf[i + 6], buf[i + 7]]; + // §4.2 size forms: 1 = 64-bit largesize follows, 0 = to EOF. + let (header, total) = match size { + 1 => { + if i + 16 > buf.len() { + break; + } + let large = u64::from_be_bytes([ + buf[i + 8], + buf[i + 9], + buf[i + 10], + buf[i + 11], + buf[i + 12], + buf[i + 13], + buf[i + 14], + buf[i + 15], + ]) as usize; + (16, large) + } + 0 => (8, buf.len() - i), + n => (8, n), + }; + if total < header || i + total > buf.len() { + break; + } + out.push((fourcc, i + header, &buf[i + header..i + total])); + i += total; + } + out +} + +/// First child box of `body` with the given FourCC, as `(offset of its +/// body within `body`, that body)`. +fn child_at<'a>(body: &'a [u8], want: &[u8; 4]) -> Option<(usize, &'a [u8])> { + boxes(body) + .into_iter() + .find(|(fourcc, _, _)| fourcc == want) + .map(|(_, at, b)| (at, b)) +} + +/// First child box of `body` with the given FourCC. +fn child<'a>(body: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> { + child_at(body, want).map(|(_, b)| b) +} + +/// Every child box of `body` with the given FourCC, in file order. +fn children<'a>(body: &'a [u8], want: &[u8; 4]) -> Vec<&'a [u8]> { + boxes(body) + .into_iter() + .filter(|(fourcc, _, _)| fourcc == want) + .map(|(_, _, b)| b) + .collect() +} + +/// `(version, creation_time, modification_time)` of an `mvhd` / `tkhd` / +/// `mdhd` body. All three put the timestamp pair immediately after the +/// FullBox preamble, u32 each at version 0 and u64 each at version 1. +fn header_times(body: &[u8]) -> (u8, u64, u64) { + let version = body[0]; + if version == 0 { + let c = u32::from_be_bytes([body[4], body[5], body[6], body[7]]) as u64; + let m = u32::from_be_bytes([body[8], body[9], body[10], body[11]]) as u64; + (0, c, m) + } else { + let c = u64::from_be_bytes([ + body[4], body[5], body[6], body[7], body[8], body[9], body[10], body[11], + ]); + let m = u64::from_be_bytes([ + body[12], body[13], body[14], body[15], body[16], body[17], body[18], body[19], + ]); + (1, c, m) + } +} + +/// The `mvhd` body of a muxed file. +fn mvhd(file: &[u8]) -> &[u8] { + let moov = child(file, b"moov").expect("moov"); + child(moov, b"mvhd").expect("mvhd") +} + +/// Byte offset of a sub-slice within the buffer it was carved from. +/// Lets the box walkers above double as an offset finder for tests +/// that patch header fields in place. +fn header_body_offsets(file: &[u8]) -> (usize, usize, usize) { + // Walks from the file root accumulating each body's offset. + // Differencing `as_ptr()` values would be shorter, but the org's + // manual miri job runs `-Zmiri-strict-provenance`, which rejects + // the pointer-to-integer cast. + let (moov_at, moov) = child_at(file, b"moov").expect("moov"); + let (mvhd_at, _) = child_at(moov, b"mvhd").expect("mvhd"); + let (trak_at, trak) = child_at(moov, b"trak").expect("trak"); + let (tkhd_at, _) = child_at(trak, b"tkhd").expect("tkhd"); + let (mdia_at, mdia) = child_at(trak, b"mdia").expect("mdia"); + let (mdhd_at, _) = child_at(mdia, b"mdhd").expect("mdhd"); + let trak_abs = moov_at + trak_at; + ( + moov_at + mvhd_at, + trak_abs + tkhd_at, + trak_abs + mdia_at + mdhd_at, + ) +} + +/// Per-track `(tkhd body, mdhd body)` pairs, in `trak` order. +fn track_headers(file: &[u8]) -> Vec<(&[u8], &[u8])> { + let moov = child(file, b"moov").expect("moov"); + children(moov, b"trak") + .into_iter() + .map(|trak| { + let tkhd = child(trak, b"tkhd").expect("tkhd"); + let mdia = child(trak, b"mdia").expect("mdia"); + let mdhd = child(mdia, b"mdhd").expect("mdhd"); + (tkhd, mdhd) + }) + .collect() +} + +// --- Muxing helpers ------------------------------------------------------ + +fn pcm_stream_info(index: u32) -> StreamInfo { + let mut params = CodecParameters::audio(CodecId::new("pcm_s16le")); + params.channels = Some(2); + params.sample_rate = Some(48_000); + params.sample_format = Some(SampleFormat::S16); + StreamInfo { + index, + time_base: TimeBase::new(1, 48_000), + duration: None, + start_time: Some(0), + params, + } +} + +fn packet(stream_index: u32, i: i64) -> Packet { + let frames: i64 = 1024; + let mut p = Packet::new( + stream_index, + TimeBase::new(1, 48_000), + vec![0u8; frames as usize * 4], + ); + p.pts = Some(i * frames); + p.dts = Some(i * frames); + p.duration = Some(frames); + p.flags.keyframe = true; + p +} + +/// Mux `streams.len()` PCM tracks (3 packets each) with the given +/// options and return the file bytes. Routed through a temp file +/// because the muxer owns the `Box`. +fn mux(streams: &[StreamInfo], opts: Mp4MuxerOptions, tag: &str) -> Vec { + let tmp = std::env::temp_dir().join(format!( + "oxideav-mp4-header-timestamps-{tag}-{}.mp4", + std::process::id() + )); + { + let f = std::fs::File::create(&tmp).unwrap(); + let ws: Box = Box::new(f); + let mut m = oxideav_mp4::muxer::open_with_options(ws, streams, opts).unwrap(); + m.write_header().unwrap(); + for i in 0..3i64 { + for s in streams { + m.write_packet(&packet(s.index, i)).unwrap(); + } + } + m.write_trailer().unwrap(); + } + let bytes = std::fs::read(&tmp).unwrap(); + let _ = std::fs::remove_file(&tmp); + bytes +} + +/// A representative stamp: 2023-11-14T22:13:20Z, comfortably inside the +/// 32-bit horizon. +const UNIX_2023: i64 = 1_700_000_000; +/// 2049-03-22T04:26:40Z — past 2040-02-06, so version-1 headers. +const UNIX_2049: i64 = 2_500_000_000; + +// --- Tests --------------------------------------------------------------- + +#[test] +fn epoch_conversion_matches_the_1904_origin() { + // The Unix epoch itself is exactly the offset. + assert_eq!(mp4_secs_from_unix_secs(0), Some(MP4_EPOCH_OFFSET_SECS)); + assert_eq!( + mp4_secs_from_system_time(std::time::UNIX_EPOCH), + Some(MP4_EPOCH_OFFSET_SECS) + ); + // 1904-01-01 itself is representable as 0; one second earlier is not. + let epoch_1904 = -(MP4_EPOCH_OFFSET_SECS as i64); + assert_eq!(mp4_secs_from_unix_secs(epoch_1904), Some(0)); + assert_eq!(mp4_secs_from_unix_secs(epoch_1904 - 1), None); + assert_eq!(mp4_secs_from_unix_secs(i64::MIN), None); + // A pre-1970 but post-1904 SystemTime still converts. + let pre_unix = std::time::UNIX_EPOCH - std::time::Duration::from_secs(1_000_000); + assert_eq!( + mp4_secs_from_system_time(pre_unix), + Some(MP4_EPOCH_OFFSET_SECS - 1_000_000) + ); + // Sub-second remainders floor on both sides of 1970: each instant + // names the second it falls inside, never the next one up. + let after = std::time::UNIX_EPOCH + std::time::Duration::from_millis(1_500); + assert_eq!( + mp4_secs_from_system_time(after), + Some(MP4_EPOCH_OFFSET_SECS + 1) + ); + let before = std::time::UNIX_EPOCH - std::time::Duration::from_millis(1_500); + assert_eq!( + mp4_secs_from_system_time(before), + Some(MP4_EPOCH_OFFSET_SECS - 2) + ); + // 1904 boundary via SystemTime, including the floor at the edge. + let epoch_1904_st = + std::time::UNIX_EPOCH - std::time::Duration::from_secs(MP4_EPOCH_OFFSET_SECS); + assert_eq!(mp4_secs_from_system_time(epoch_1904_st), Some(0)); + assert_eq!( + mp4_secs_from_system_time(epoch_1904_st - std::time::Duration::from_millis(1)), + None + ); + // `unix_secs_from_mp4_secs` is the exact inverse across the range, + // and saturates instead of wrapping at the far end of u64. + assert_eq!(unix_secs_from_mp4_secs(MP4_EPOCH_OFFSET_SECS), 0); + assert_eq!(unix_secs_from_mp4_secs(0), epoch_1904); + for unix in [epoch_1904, -1, 0, 1, UNIX_2023, UNIX_2049] { + let mp4 = mp4_secs_from_unix_secs(unix).unwrap(); + assert_eq!(unix_secs_from_mp4_secs(mp4), unix, "round trip {unix}"); + } + assert_eq!(unix_secs_from_mp4_secs(u64::MAX), i64::MAX); +} + +#[test] +fn default_options_keep_the_historical_zero_headers() { + let streams = [pcm_stream_info(0)]; + let file = mux(&streams, Mp4MuxerOptions::default(), "default"); + + assert_eq!(header_times(mvhd(&file)), (0, 0, 0)); + for (tkhd, mdhd) in track_headers(&file) { + assert_eq!(header_times(tkhd), (0, 0, 0), "tkhd"); + assert_eq!(header_times(mdhd), (0, 0, 0), "mdhd"); + } +} + +#[test] +fn post_2040_timestamp_promotes_headers_to_version_1() { + let t = mp4_secs_from_unix_secs(UNIX_2049).unwrap(); + assert!(t > u32::MAX as u64, "test stamp must exceed the v0 horizon"); + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + creation_time: Some(t), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "v1"); + + assert_eq!(header_times(mvhd(&file)), (1, t, t)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (1, t, t), "tkhd"); + assert_eq!(header_times(headers[0].1), (1, t, t), "mdhd"); + + // The promoted headers must still parse. Assert on fields that come + // *from those boxes* — v1 shifts timescale and duration 12 bytes + // later in each — not on the sample entry: `params.sample_rate` is + // read out of `stsd` and would survive a broken mdhd offset. + // `time_base` is 1/mdhd.timescale; `duration_micros` is mvhd's + // duration over mvhd's timescale (3072 ticks @ 48 kHz = 64 ms). + let rs: Box = Box::new(std::io::Cursor::new(file)); + let mut dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.streams().len(), 1); + assert_eq!(dmx.streams()[0].time_base, TimeBase::new(1, 48_000)); + assert_eq!(dmx.duration_micros(), Some(64_000)); + // And the 64-bit timestamps themselves read back: the v1 branch of + // the header-timestamp reader is a different code path from v0. + assert_eq!(dmx.mvhd_timestamps(), stamped(t)); + assert_eq!(dmx.tkhd_timestamps(0), Some(stamped(t))); + assert_eq!(dmx.mdhd_timestamps(0), Some(stamped(t))); + let mut packets = 0; + loop { + match dmx.next_packet() { + Ok(_) => packets += 1, + Err(oxideav_core::Error::Eof) => break, + Err(e) => panic!("demux error: {e}"), + } + } + assert_eq!(packets, 3); + + // The modification half drives promotion on its own: a v0-range + // creation time next to a post-2040 modification time still needs + // 64-bit fields, since the v0 layout can hold neither. + let c = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + assert!(c <= u32::MAX as u64); + let opts = Mp4MuxerOptions { + creation_time: Some(c), + modification_time: Some(t), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "promote-on-modification"); + assert_eq!(header_times(mvhd(&file)), (1, c, t)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (1, c, t), "tkhd"); + assert_eq!(header_times(headers[0].1), (1, c, t), "mdhd"); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), pair(c, t)); + assert_eq!(dmx.mdhd_timestamps(0), Some(pair(c, t))); +} + +#[test] +fn version_promotion_is_per_box() { + // A movie-wide stamp inside the 32-bit horizon plus an override + // past it: mvhd and the unoverridden track stay version 0 while + // only the overridden track's tkhd/mdhd promote. The promotion is + // decided per box, not once per file. + let movie = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let late = mp4_secs_from_unix_secs(UNIX_2049).unwrap(); + assert!(movie <= u32::MAX as u64 && late > u32::MAX as u64); + let streams = [pcm_stream_info(0), pcm_stream_info(1)]; + let opts = Mp4MuxerOptions { + creation_time: Some(movie), + track_header_timestamps: vec![track_time(1, late)], + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "mixed-version"); + + assert_eq!(header_times(mvhd(&file)), (0, movie, movie)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (0, movie, movie), "trak0 tkhd"); + assert_eq!(header_times(headers[0].1), (0, movie, movie), "trak0 mdhd"); + assert_eq!(header_times(headers[1].0), (1, late, late), "trak1 tkhd"); + assert_eq!(header_times(headers[1].1), (1, late, late), "trak1 mdhd"); + + // A moov mixing box versions still demuxes both tracks. + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.streams().len(), 2); + for s in dmx.streams() { + assert_eq!(s.time_base, TimeBase::new(1, 48_000)); + } +} + +#[test] +fn promoted_init_headers_do_not_dislodge_the_mehd_patch() { + // §8.8.2: with `write_mehd` the trailer patches eight + // `fragment_duration` bytes at an offset remembered when the init + // moov was built. A version-1 mvhd + tkhd + mdhd pushes the mvex + // 36 bytes further into that moov, so this is the case where a + // hardcoded offset would write over eight unrelated bytes instead. + let t = mp4_secs_from_unix_secs(UNIX_2049).unwrap(); + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + fragmented: Some(FragmentedOptions { + write_mehd: true, + ..FragmentedOptions::default() + }), + creation_time: Some(t), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "fragmented-mehd-v1"); + + // The promotion really did happen — otherwise the test proves nothing. + assert_eq!(header_times(mvhd(&file)), (1, t, t)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (1, t, t), "tkhd"); + assert_eq!(header_times(headers[0].1), (1, t, t), "mdhd"); + + // The sealed duration landed in the mehd's own field, walked + // structurally rather than by fourcc search: 3 × 1024 media ticks + // @ 48 kHz = 64 movie ticks at timescale 1000. + let moov = child(&file, b"moov").expect("moov"); + let mvex = child(moov, b"mvex").expect("mvex"); + let mehd = child(mvex, b"mehd").expect("mehd"); + assert_eq!(mehd[0], 1, "version 1 (64-bit fragment_duration)"); + let dur = u64::from_be_bytes(mehd[4..12].try_into().unwrap()); + assert_eq!(dur, 64, "sealed fragment_duration in movie timescale"); + + // And the demuxer agrees, which it would not if the patch had + // clipped the surrounding boxes. + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.duration_micros(), Some(64_000)); +} + +#[test] +fn faststart_layout_stamps_the_same_values() { + let t = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + faststart: true, + creation_time: Some(t), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "faststart"); + + // Sanity: moov really does precede mdat in this layout. + let order: Vec<[u8; 4]> = boxes(&file).into_iter().map(|(f, _, _)| f).collect(); + let moov_at = order.iter().position(|f| f == b"moov").unwrap(); + let mdat_at = order.iter().position(|f| f == b"mdat").unwrap(); + assert!(moov_at < mdat_at); + + assert_eq!(header_times(mvhd(&file)), (0, t, t)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (0, t, t), "tkhd"); + assert_eq!(header_times(headers[0].1), (0, t, t), "mdhd"); + + // Again with a v1-promoted stamp. Faststart is the one layout + // where the moov's size feeds back into the chunk offsets it + // contains, via the convergence loop — so the 36 bytes the + // promotion adds have to settle before the offsets are baked. A + // clean demux of every packet is what proves they did. + let late = mp4_secs_from_unix_secs(UNIX_2049).unwrap(); + let opts = Mp4MuxerOptions { + faststart: true, + creation_time: Some(late), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "faststart-v1"); + assert_eq!(header_times(mvhd(&file)), (1, late, late)); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let mut dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), stamped(late)); + let mut packets = 0; + loop { + match dmx.next_packet() { + Ok(_) => packets += 1, + Err(oxideav_core::Error::Eof) => break, + Err(e) => panic!("demux error: {e}"), + } + } + assert_eq!(packets, 3, "chunk offsets survived the v1 moov growth"); +} + +#[test] +fn fragmented_init_segment_stamps_the_headers() { + let movie = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let track1 = mp4_secs_from_unix_secs(UNIX_2023 + 3_600).unwrap(); + let media1 = track1 + 7; + let streams = [pcm_stream_info(0), pcm_stream_info(1)]; + let opts = Mp4MuxerOptions { + fragmented: Some(FragmentedOptions::default()), + creation_time: Some(movie), + // Track 1 dates its media apart from its track, so the init + // segment's tkhd and mdhd must come out different — a path + // that would look identical under the one-value shorthand. + track_header_timestamps: vec![TrackHeaderTimestamps { + stream_index: 1, + track: stamped(track1), + media: Some(stamped(media1)), + }], + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "fragmented"); + + assert_eq!(header_times(mvhd(&file)), (0, movie, movie)); + let headers = track_headers(&file); + assert_eq!(headers.len(), 2); + assert_eq!(header_times(headers[0].0), (0, movie, movie), "trak0 tkhd"); + assert_eq!(header_times(headers[0].1), (0, movie, movie), "trak0 mdhd"); + assert_eq!( + header_times(headers[1].0), + (0, track1, track1), + "trak1 tkhd" + ); + assert_eq!( + header_times(headers[1].1), + (0, media1, media1), + "trak1 mdhd" + ); + + // The init segment writes zero durations, so here the timestamp is + // the only thing that can trigger the version-1 promotion. + let late = mp4_secs_from_unix_secs(UNIX_2049).unwrap(); + let opts = Mp4MuxerOptions { + fragmented: Some(FragmentedOptions::default()), + creation_time: Some(late), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "fragmented-v1"); + assert_eq!(header_times(mvhd(&file)), (1, late, late)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (1, late, late), "tkhd"); + assert_eq!(header_times(headers[0].1), (1, late, late), "mdhd"); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), stamped(late)); + assert_eq!(dmx.streams()[0].time_base, TimeBase::new(1, 48_000)); +} + +#[test] +fn out_of_range_track_override_is_rejected_at_open() { + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + track_header_timestamps: vec![track_time(3, 1)], + ..Mp4MuxerOptions::default() + }; + let ws: Box = Box::new(std::io::Cursor::new(Vec::new())); + let err = match oxideav_mp4::muxer::open_with_options(ws, &streams, opts.clone()) { + Ok(_) => panic!("out-of-range track_header_timestamps accepted"), + Err(e) => e, + }; + assert!( + format!("{err}").contains("track_header_timestamps"), + "unexpected error: {err}" + ); + + // Same guard on the fragmented entry point. + let frag_opts = Mp4MuxerOptions { + fragmented: Some(FragmentedOptions::default()), + ..opts + }; + let ws: Box = Box::new(std::io::Cursor::new(Vec::new())); + let err = match oxideav_mp4::muxer::open_with_options(ws, &streams, frag_opts) { + Ok(_) => panic!("out-of-range track_header_timestamps accepted (fragmented)"), + Err(e) => e, + }; + assert!( + format!("{err}").contains("track_header_timestamps"), + "unexpected error: {err}" + ); +} + +// --- Read side ----------------------------------------------------------- + +/// Look up a flat metadata key on the movie. +fn meta<'a>(dmx: &'a oxideav_mp4::demux::Mp4Demuxer, key: &str) -> Option<&'a str> { + dmx.metadata() + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) +} + +#[test] +fn stamped_file_round_trips_through_the_demuxer() { + // Write → read with no conversion anywhere: what the option set is + // what the accessors return, in the same 1904-second units. + let movie = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let track1 = mp4_secs_from_unix_secs(UNIX_2023 + 86_400).unwrap(); + let streams = [pcm_stream_info(0), pcm_stream_info(1)]; + let opts = Mp4MuxerOptions { + creation_time: Some(movie), + track_header_timestamps: vec![track_time(1, track1)], + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "roundtrip"); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + + // Typed surface: mvhd movie-wide, the override on track 1 only. + assert_eq!(dmx.mvhd_timestamps(), stamped(movie)); + assert_eq!(dmx.tkhd_timestamps(0), Some(stamped(movie))); + assert_eq!(dmx.mdhd_timestamps(0), Some(stamped(movie))); + assert_eq!(dmx.tkhd_timestamps(1), Some(stamped(track1))); + assert_eq!(dmx.mdhd_timestamps(1), Some(stamped(track1))); + // Out-of-range stream index. + assert_eq!(dmx.tkhd_timestamps(2), None); + assert_eq!(dmx.mdhd_timestamps(2), None); + + // Flat surface, movie-level and per-stream, same units. + let movie_s = movie.to_string(); + let track1_s = track1.to_string(); + assert_eq!(meta(&dmx, "mvhd_creation_time"), Some(movie_s.as_str())); + assert_eq!(meta(&dmx, "mvhd_modification_time"), Some(movie_s.as_str())); + let opts0 = &dmx.streams()[0].params.options; + assert_eq!(opts0.get("tkhd_creation_time"), Some(movie_s.as_str())); + assert_eq!(opts0.get("mdhd_creation_time"), Some(movie_s.as_str())); + let opts1 = &dmx.streams()[1].params.options; + assert_eq!(opts1.get("tkhd_creation_time"), Some(track1_s.as_str())); + assert_eq!(opts1.get("mdhd_modification_time"), Some(track1_s.as_str())); + + // And back to a Unix timestamp, ending where the caller started. + assert_eq!( + unix_secs_from_mp4_secs(dmx.mvhd_timestamps().creation_time), + UNIX_2023 + ); +} + +/// PATH-gated black-box cross-check: an *independent writer* stamps a +/// file, and our reader must recover the same instant. Proves the +/// intake agrees with the wider ecosystem on epoch and field offsets, +/// which no self-round-trip can establish — a reader and writer that +/// share a mistake still agree with each other. +#[test] +fn foreign_stamped_file_reads_back_the_same_instant() { + use std::process::Command; + + if Command::new("ffmpeg") + .arg("-version") + .output() + .map(|o| !o.status.success()) + .unwrap_or(true) + { + eprintln!("skipping: ffmpeg not on PATH"); + return; + } + + let tmp = std::env::temp_dir().join(format!( + "oxideav-mp4-header-timestamps-foreign-{}.mp4", + std::process::id() + )); + // `-metadata creation_time` takes an RFC 3339 instant; UNIX_2023 is + // that same instant, so the file should read back as exactly it. + let made = Command::new("ffmpeg") + .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i"]) + .arg("sine=frequency=440:duration=1") + .args(["-c:a", "aac", "-metadata"]) + .arg("creation_time=2023-11-14T22:13:20Z") + .arg(&tmp) + .output() + .expect("run ffmpeg"); + assert!(made.status.success(), "ffmpeg failed to write the fixture"); + + let rs: Box = Box::new(std::fs::File::open(&tmp).unwrap()); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + let expected = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + + assert_eq!( + dmx.mvhd_timestamps().creation_time, + expected, + "mvhd from an independent writer" + ); + assert_eq!( + dmx.tkhd_timestamps(0).unwrap().creation_time, + expected, + "tkhd from an independent writer" + ); + assert_eq!( + dmx.mdhd_timestamps(0).unwrap().creation_time, + expected, + "mdhd from an independent writer" + ); + assert_eq!( + unix_secs_from_mp4_secs(dmx.mvhd_timestamps().creation_time), + UNIX_2023 + ); + let _ = std::fs::remove_file(&tmp); +} + +/// Every other test stamps `creation_time` and `modification_time` +/// with the same value, because that is all the muxer can write — so +/// a reader that filled both slots from one field, or swapped the +/// pair, or crossed `tkhd` with `mdhd`, would pass all of them. Patch +/// six distinct values straight into the bytes and demand six distinct +/// values back. Run for both header versions, since v0 and v1 are +/// separate branches of the reader. +#[test] +fn each_header_field_is_read_from_its_own_bytes() { + for version in [0u8, 1u8] { + // A stamp past the 32-bit horizon forces the v1 layout. + let base = if version == 0 { + mp4_secs_from_unix_secs(UNIX_2023).unwrap() + } else { + mp4_secs_from_unix_secs(UNIX_2049).unwrap() + }; + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + creation_time: Some(base), + ..Mp4MuxerOptions::default() + }; + let mut file = mux(&streams, opts, &format!("distinct-v{version}")); + + // Six values, each offset differently from the base so no two + // fields can be confused and every one stays in its version's + // representable range. + let want: [u64; 6] = [base + 1, base + 2, base + 3, base + 4, base + 5, base + 6]; + let (mvhd_at, tkhd_at, mdhd_at) = header_body_offsets(&file); + for (box_at, pair) in [ + (mvhd_at, [want[0], want[1]]), + (tkhd_at, [want[2], want[3]]), + (mdhd_at, [want[4], want[5]]), + ] { + assert_eq!(file[box_at], version, "fixture should be v{version}"); + // creation_time then modification_time, immediately after + // the 4-byte FullBox preamble. + for (i, v) in pair.iter().enumerate() { + if version == 0 { + let at = box_at + 4 + i * 4; + file[at..at + 4].copy_from_slice(&(*v as u32).to_be_bytes()); + } else { + let at = box_at + 4 + i * 8; + file[at..at + 8].copy_from_slice(&v.to_be_bytes()); + } + } + } + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + let pair = |c: u64, m: u64| HeaderTimestamps { + creation_time: c, + modification_time: m, + }; + assert_eq!( + dmx.mvhd_timestamps(), + pair(want[0], want[1]), + "mvhd v{version}" + ); + assert_eq!( + dmx.tkhd_timestamps(0), + Some(pair(want[2], want[3])), + "tkhd v{version}" + ); + assert_eq!( + dmx.mdhd_timestamps(0), + Some(pair(want[4], want[5])), + "mdhd v{version}" + ); + } +} + +/// The write surface reaches every field the read surface reports: +/// six independently-set values survive a mux→demux round trip. This +/// is the parity check — anything the demuxer can observe, the muxer +/// can produce. +#[test] +fn every_readable_field_is_independently_writable() { + let base = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + // Six distinct instants, one per field. + let (mv_c, mv_m) = (base + 1, base + 2); + let (tk_c, tk_m) = (base + 3, base + 4); + let (md_c, md_m) = (base + 5, base + 6); + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + creation_time: Some(mv_c), + modification_time: Some(mv_m), + track_header_timestamps: vec![TrackHeaderTimestamps { + stream_index: 0, + track: pair(tk_c, tk_m), + media: Some(pair(md_c, md_m)), + }], + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "full-parity"); + + // On the wire. + assert_eq!(header_times(mvhd(&file)), (0, mv_c, mv_m)); + let headers = track_headers(&file); + assert_eq!(header_times(headers[0].0), (0, tk_c, tk_m), "tkhd"); + assert_eq!(header_times(headers[0].1), (0, md_c, md_m), "mdhd"); + + // And back through the reader. + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), pair(mv_c, mv_m)); + assert_eq!(dmx.tkhd_timestamps(0), Some(pair(tk_c, tk_m))); + assert_eq!(dmx.mdhd_timestamps(0), Some(pair(md_c, md_m))); +} + +#[test] +fn an_entry_replaces_the_pair_and_media_defaults_to_track() { + // The only defaulting in a `TrackHeaderTimestamps` is `media: + // None` reusing the `track` pair. Everything else is stated + // outright: an entry never takes half its value from the + // movie-wide options, so no field can be perturbed by a + // neighbouring one. + let movie_c = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let movie_m = movie_c + 10; + let track_c = movie_c + 20; + let media3 = movie_c + 30; + let streams = [ + pcm_stream_info(0), + pcm_stream_info(1), + pcm_stream_info(2), + pcm_stream_info(3), + ]; + let opts = Mp4MuxerOptions { + creation_time: Some(movie_c), + modification_time: Some(movie_m), + track_header_timestamps: vec![ + // Track 0: one instant in both halves, mdhd mirroring tkhd. + track_time(0, track_c), + // Track 2: "created, never modified" — a zero modification + // half is an ordinary value here, not a fallback trigger. + TrackHeaderTimestamps { + stream_index: 2, + track: pair(track_c, 0), + media: None, + }, + // Track 3 overrides only its *media* dates while its track + // keeps the movie's pair. Because `track` is stated rather + // than inherited, the movie's modification half survives — + // the case a per-field inheritance cascade got wrong by + // collapsing tkhd's modification time onto its creation. + TrackHeaderTimestamps { + stream_index: 3, + track: pair(movie_c, movie_m), + media: Some(stamped(media3)), + }, + ], + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "entry-shapes"); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), pair(movie_c, movie_m)); + assert_eq!(dmx.tkhd_timestamps(0), Some(stamped(track_c))); + assert_eq!(dmx.mdhd_timestamps(0), Some(stamped(track_c))); + // Track 1 has no entry at all — both boxes take the movie pair. + assert_eq!(dmx.tkhd_timestamps(1), Some(pair(movie_c, movie_m))); + assert_eq!(dmx.mdhd_timestamps(1), Some(pair(movie_c, movie_m))); + assert_eq!(dmx.tkhd_timestamps(2), Some(pair(track_c, 0))); + assert_eq!(dmx.mdhd_timestamps(2), Some(pair(track_c, 0))); + assert_eq!( + dmx.tkhd_timestamps(3), + Some(pair(movie_c, movie_m)), + "a media-only override must not disturb tkhd" + ); + assert_eq!(dmx.mdhd_timestamps(3), Some(stamped(media3))); +} + +#[test] +fn modification_time_alone_is_written_without_a_creation_time() { + // The `creation = 0, modification = t` shape: unusual for a new + // file, but real files carry it and the demuxer reports it, so the + // muxer must be able to produce it. + let m = mp4_secs_from_unix_secs(UNIX_2023).unwrap(); + let streams = [pcm_stream_info(0)]; + let opts = Mp4MuxerOptions { + modification_time: Some(m), + ..Mp4MuxerOptions::default() + }; + let file = mux(&streams, opts, "modification-only"); + + assert_eq!(header_times(mvhd(&file)), (0, 0, m)); + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + assert_eq!(dmx.mvhd_timestamps(), pair(0, m)); + assert!(!dmx.mvhd_timestamps().is_unset()); + // The flat channel emits per field, so only the non-zero half + // shows up. + assert_eq!(meta(&dmx, "mvhd_creation_time"), None); + assert_eq!(meta(&dmx, "mvhd_modification_time"), Some(&*m.to_string())); +} + +#[test] +fn unstamped_file_reads_back_unset_and_emits_no_keys() { + // Zero is "the producer didn't stamp this", so the flat channel + // stays quiet rather than reporting 1904 for every default file. + let streams = [pcm_stream_info(0)]; + let file = mux(&streams, Mp4MuxerOptions::default(), "roundtrip-unset"); + + let rs: Box = Box::new(std::io::Cursor::new(file)); + let dmx = oxideav_mp4::demux::open_typed(rs, &oxideav_core::NullCodecResolver).unwrap(); + + assert!(dmx.mvhd_timestamps().is_unset()); + assert!(dmx.tkhd_timestamps(0).unwrap().is_unset()); + assert!(dmx.mdhd_timestamps(0).unwrap().is_unset()); + for key in ["mvhd_creation_time", "mvhd_modification_time"] { + assert_eq!(meta(&dmx, key), None, "{key} should be absent"); + } + let opts = &dmx.streams()[0].params.options; + for key in [ + "tkhd_creation_time", + "tkhd_modification_time", + "mdhd_creation_time", + "mdhd_modification_time", + ] { + assert!(opts.get(key).is_none(), "{key} should be absent"); + } +}