Skip to main content

pixelflux/recorder/
mp4.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Pure-Rust fragmented-MP4 (fMP4) muxer for the built-in recorder.
8//!
9//! Hand-rolled rather than pulled in as a dependency for two reasons: libavformat would be a hard
10//! runtime dependency of every build, and the pure-Rust mp4 crates only write moov-trailing
11//! progressive files, which lose everything on a crash. Fragmented MP4 needs no trailer and no
12//! seeking — each `moof`+`mdat` pair is self-contained — so a file truncated by a crash or SIGKILL
13//! stays playable up to the last fragment, and the writer works on any `Write` sink.
14//!
15//! Timestamps are caller-supplied wall-clock microseconds (damage-driven capture emits sparse,
16//! irregular frames), carried at a 90 kHz track timescale with one sample per fragment: `tfdt`
17//! anchors every sample at its true capture time, so variable framerate needs no constant-rate
18//! lie. The sample duration is only known once the NEXT frame arrives, so one sample is always
19//! buffered and flushed a frame behind (a clean stop closes it with the median observed
20//! duration); on SIGKILL at most that one buffered frame is lost — every fragment already
21//! written remains playable.
22//!
23//! Codec support is deliberately split: [`annexb_to_sample`] and the H.264-specific parameter-set
24//! capture live in [`H264SampleBuilder`], while the fragment/box writer below is codec-agnostic
25//! (bytes + sync flag + timestamps + a ready-made `stsd` sample entry). HEVC or AV1 recording
26//! later means a new sample builder emitting an `hvc1`/`av01` entry, not a new muxer.
27//!
28//! An optional Opus audio track (track 2) takes packets with their decode time on the
29//! 48 kHz clock and each one's duration from its own TOC byte; they go out in fragments of
30//! their own, so a still desktop that yields no video fragment still keeps the file growing.
31
32use std::io::Write;
33
34/// 90 kHz: the conventional H.264 track timescale, exactly representing common frame intervals.
35const TIMESCALE: u32 = 90_000;
36
37/// Fallback duration for the final buffered sample when only one frame was ever written
38/// (no observed inter-frame delta to take a median of): 1/30 s.
39const DEFAULT_LAST_DURATION: u32 = TIMESCALE / 30;
40
41/// Opus in ISOBMFF runs its track at Opus's own 48 kHz whatever the input rate.
42pub const OPUS_TIMESCALE: u32 = 48_000;
43/// Audio packets buffered before they go out as a fragment of their own: half a second of
44/// 20 ms packets.
45const AUDIO_FLUSH_SAMPLES: usize = 25;
46
47/// The 48 kHz samples an Opus packet decodes to, from its TOC byte: the configuration's
48/// frame duration times the frame count its code carries.
49pub fn opus_packet_samples(packet: &[u8]) -> u32 {
50    let Some(&toc) = packet.first() else { return 0 };
51    let config = toc >> 3;
52    let tenths_ms = match config {
53        0..=11 => [100, 200, 400, 600][(config % 4) as usize],
54        12..=15 => [100, 200][(config % 2) as usize],
55        _ => [25, 50, 100, 200][(config % 4) as usize],
56    };
57    let frames = match toc & 3 {
58        0 => 1,
59        1 | 2 => 2,
60        _ => packet.get(1).map_or(0, |&b| (b & 0x3f) as u32),
61    };
62    frames * tenths_ms * OPUS_TIMESCALE / 10_000
63}
64
65/// Split an Annex-B elementary stream into NAL payloads (start codes removed, emulation
66/// prevention bytes kept — the RBSP layer is only unescaped where a parser needs it).
67pub fn split_annexb(data: &[u8]) -> Vec<&[u8]> {
68    let mut nals = Vec::new();
69    let mut i = 0usize;
70    let mut nal_start: Option<usize> = None;
71    while i + 2 < data.len() {
72        if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
73            let code_start = if i > 0 && data[i - 1] == 0 { i - 1 } else { i };
74            if let Some(s) = nal_start
75                && code_start > s
76            {
77                nals.push(&data[s..code_start]);
78            }
79            i += 3;
80            nal_start = Some(i);
81        } else if data[i + 2] == 0 {
82            // A zero at i+2 can begin the next start code; only advance one byte.
83            i += 1;
84        } else {
85            i += 3;
86        }
87    }
88    if let Some(s) = nal_start
89        && data.len() > s
90    {
91        nals.push(&data[s..]);
92    }
93    nals
94}
95
96/// Unescape an H.264 RBSP: drop the emulation-prevention byte from every `00 00 03` run.
97fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
98    let mut out = Vec::with_capacity(data.len());
99    let mut zeros = 0u32;
100    for &b in data {
101        if zeros >= 2 && b == 3 {
102            zeros = 0;
103            continue;
104        }
105        if b == 0 {
106            zeros += 1;
107        } else {
108            zeros = 0;
109        }
110        out.push(b);
111    }
112    out
113}
114
115/// MSB-first bit reader over an unescaped RBSP, with Exp-Golomb decode for SPS parsing.
116struct BitReader<'a> {
117    data: &'a [u8],
118    pos: usize,
119}
120
121impl<'a> BitReader<'a> {
122    fn new(data: &'a [u8]) -> Self {
123        Self { data, pos: 0 }
124    }
125
126    fn bit(&mut self) -> Option<u32> {
127        let byte = *self.data.get(self.pos / 8)?;
128        let bit = (byte >> (7 - (self.pos % 8))) & 1;
129        self.pos += 1;
130        Some(bit as u32)
131    }
132
133    fn bits(&mut self, n: u32) -> Option<u32> {
134        let mut v = 0u32;
135        for _ in 0..n {
136            v = (v << 1) | self.bit()?;
137        }
138        Some(v)
139    }
140
141    /// ue(v): count leading zeros, then read that many bits after the marker one.
142    fn ue(&mut self) -> Option<u32> {
143        let mut zeros = 0u32;
144        while self.bit()? == 0 {
145            zeros += 1;
146            if zeros > 31 {
147                return None;
148            }
149        }
150        let rest = self.bits(zeros)?;
151        Some((1u32 << zeros) - 1 + rest)
152    }
153
154    fn se(&mut self) -> Option<i32> {
155        let k = self.ue()? as i64;
156        Some(if k % 2 == 0 {
157            -(k / 2) as i32
158        } else {
159            ((k + 1) / 2) as i32
160        })
161    }
162}
163
164/// Coded frame dimensions parsed out of an H.264 SPS NAL (with its NAL header byte).
165///
166/// Walks every field ahead of `pic_width_in_mbs_minus1` — including the high-profile
167/// chroma/bit-depth/scaling-list block — and applies the frame-cropping rectangle with the
168/// chroma-format-dependent crop units, so 4:2:0, 4:2:2, and 4:4:4 streams from any of the
169/// project's encoders all report their true display size.
170pub fn parse_sps_dimensions(sps_nal: &[u8]) -> Option<(u32, u32)> {
171    if sps_nal.len() < 4 || sps_nal[0] & 0x1f != 7 {
172        return None;
173    }
174    let profile_idc = sps_nal[1];
175    let rbsp = unescape_rbsp(&sps_nal[4..]);
176    let mut r = BitReader::new(&rbsp);
177    // seq_parameter_set_id
178    r.ue()?;
179
180    let mut chroma_format_idc = 1u32;
181    if matches!(
182        profile_idc,
183        100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135
184    ) {
185        chroma_format_idc = r.ue()?;
186        if chroma_format_idc == 3 {
187            // separate_colour_plane_flag
188            r.bit()?;
189        }
190        // bit_depth_luma_minus8, bit_depth_chroma_minus8, qpprime_y_zero_transform_bypass_flag
191        r.ue()?;
192        r.ue()?;
193        r.bit()?;
194        if r.bit()? == 1 {
195            // seq_scaling_matrix_present_flag
196            let lists = if chroma_format_idc == 3 { 12 } else { 8 };
197            for i in 0..lists {
198                if r.bit()? == 1 {
199                    let size = if i < 6 { 16 } else { 64 };
200                    let mut next_scale = 8i32;
201                    let mut last_scale = 8i32;
202                    for _ in 0..size {
203                        if next_scale != 0 {
204                            let delta = r.se()?;
205                            next_scale = (last_scale + delta + 256) % 256;
206                        }
207                        if next_scale != 0 {
208                            last_scale = next_scale;
209                        }
210                    }
211                }
212            }
213        }
214    }
215
216    // log2_max_frame_num_minus4
217    r.ue()?;
218    let pic_order_cnt_type = r.ue()?;
219    if pic_order_cnt_type == 0 {
220        // log2_max_pic_order_cnt_lsb_minus4
221        r.ue()?;
222    } else if pic_order_cnt_type == 1 {
223        // delta_pic_order_always_zero_flag, offset_for_non_ref_pic,
224        // offset_for_top_to_bottom_field, then the offset_for_ref_frame list
225        r.bit()?;
226        r.se()?;
227        r.se()?;
228        let n = r.ue()?;
229        for _ in 0..n {
230            r.se()?;
231        }
232    }
233    // max_num_ref_frames, gaps_in_frame_num_value_allowed_flag
234    r.ue()?;
235    r.bit()?;
236    let pic_width_in_mbs = r.ue()? + 1;
237    let pic_height_in_map_units = r.ue()? + 1;
238    let frame_mbs_only = r.bit()?;
239    if frame_mbs_only == 0 {
240        // mb_adaptive_frame_field_flag
241        r.bit()?;
242    }
243    // direct_8x8_inference_flag
244    r.bit()?;
245
246    let (mut crop_l, mut crop_r, mut crop_t, mut crop_b) = (0u32, 0u32, 0u32, 0u32);
247    if r.bit()? == 1 {
248        crop_l = r.ue()?;
249        crop_r = r.ue()?;
250        crop_t = r.ue()?;
251        crop_b = r.ue()?;
252    }
253
254    let (sub_w, sub_h) = match chroma_format_idc {
255        0 | 3 => (1u32, 1u32),
256        2 => (2, 1),
257        _ => (2, 2),
258    };
259    let crop_unit_x = sub_w;
260    let crop_unit_y = sub_h * (2 - frame_mbs_only);
261    let width = pic_width_in_mbs * 16 - crop_unit_x * (crop_l + crop_r);
262    let height =
263        (2 - frame_mbs_only) * pic_height_in_map_units * 16 - crop_unit_y * (crop_t + crop_b);
264    Some((width, height))
265}
266
267fn mk_box(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
268    let mut b = Vec::with_capacity(8 + payload.len());
269    b.extend_from_slice(&((8 + payload.len()) as u32).to_be_bytes());
270    b.extend_from_slice(fourcc);
271    b.extend_from_slice(payload);
272    b
273}
274
275fn mk_full_box(fourcc: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
276    let mut p = Vec::with_capacity(4 + payload.len());
277    p.push(version);
278    p.extend_from_slice(&flags.to_be_bytes()[1..]);
279    p.extend_from_slice(payload);
280    mk_box(fourcc, &p)
281}
282
283/// The unity transformation matrix `mvhd` and `tkhd` carry: 0x00010000, 0x00010000, and
284/// 0x40000000 on the diagonal (16.16 fixed point for the first two, 2.30 for the last).
285const MATRIX_IDENTITY: [u8; 36] = {
286    let mut m = [0u8; 36];
287    m[1] = 0x01;
288    m[17] = 0x01;
289    m[32] = 0x40;
290    m
291};
292
293/// A codec's contribution to the init segment: its `stsd` sample entry (with decoder
294/// configuration record) plus the display dimensions for `tkhd`.
295pub struct TrackConfig {
296    pub sample_entry: Vec<u8>,
297    pub width: u32,
298    pub height: u32,
299}
300
301/// The audio track's `stsd` sample entry.
302pub struct AudioTrackConfig {
303    pub sample_entry: Vec<u8>,
304}
305
306/// The `Opus` sample entry with its `dOps` record, the head's own fields.
307pub fn opus_sample_entry(head: &super::ogg::OpusHead) -> Vec<u8> {
308    // AudioSampleEntry in field order: reserved, data_reference_index, reserved,
309    // channelcount, samplesize, pre_defined, reserved, samplerate as 16.16.
310    let mut entry_p = Vec::new();
311    entry_p.extend_from_slice(&[0u8; 6]);
312    entry_p.extend_from_slice(&1u16.to_be_bytes());
313    entry_p.extend_from_slice(&[0u8; 8]);
314    entry_p.extend_from_slice(&(head.channels as u16).to_be_bytes());
315    entry_p.extend_from_slice(&16u16.to_be_bytes());
316    entry_p.extend_from_slice(&[0u8; 4]);
317    entry_p.extend_from_slice(&(OPUS_TIMESCALE << 16).to_be_bytes());
318    // dOps in field order: Version, OutputChannelCount, PreSkip, InputSampleRate,
319    // OutputGain, ChannelMappingFamily, then the stream and coupled counts and the
320    // channel table for any family but 0.
321    let mut dops_p = vec![0u8, head.channels];
322    dops_p.extend_from_slice(&head.pre_skip.to_be_bytes());
323    dops_p.extend_from_slice(&head.input_sample_rate.to_be_bytes());
324    dops_p.extend_from_slice(&head.output_gain.to_be_bytes());
325    dops_p.push(head.mapping_family);
326    dops_p.extend_from_slice(&head.mapping);
327    entry_p.extend_from_slice(&mk_box(b"dOps", &dops_p));
328    mk_box(b"Opus", &entry_p)
329}
330
331/// One buffered access unit awaiting its duration (known when the next one arrives).
332struct PendingSample {
333    data: Vec<u8>,
334    sync: bool,
335    dts: u64,
336}
337
338/// Aggregate counters reported when the writer finishes.
339#[derive(Clone, Copy, Debug, Default)]
340pub struct Mp4Stats {
341    pub samples: u64,
342    pub sync_samples: u64,
343    pub audio_samples: u64,
344    pub bytes: u64,
345    /// End of whichever track runs longer.
346    pub duration_us: u64,
347}
348
349/// Codec-agnostic fMP4 fragment writer: `ftyp`+`moov` once, then one `moof`+`mdat` pair
350/// per video sample, each anchored at its wall-clock decode time via `tfdt`, and one per
351/// batch of audio samples.
352pub struct FragmentWriter<W: Write> {
353    out: W,
354    seq: u32,
355    wrote_init: bool,
356    pending: Option<PendingSample>,
357    last_dts: Option<u64>,
358    /// Every flushed inter-frame duration, kept so the final buffered sample (whose
359    /// successor never arrives) can close on the MEDIAN — damage-driven capture makes the
360    /// last observed delta an outlier as often as not (a long static gap, a burst pair).
361    durations: Vec<u32>,
362    audio: Option<AudioTrackConfig>,
363    /// Pending audio packets with their durations; contiguous from `audio_batch_dts`.
364    audio_pending: Vec<(Vec<u8>, u32)>,
365    audio_batch_dts: u64,
366    audio_next_dts: u64,
367    stats: Mp4Stats,
368}
369
370/// The data reference every track shares: an empty `url ` entry flagged self-contained.
371fn dinf() -> Vec<u8> {
372    let url = mk_full_box(b"url ", 0, 1, &[]);
373    let mut dref_p = 1u32.to_be_bytes().to_vec();
374    dref_p.extend_from_slice(&url);
375    mk_box(b"dinf", &mk_full_box(b"dref", 0, 0, &dref_p))
376}
377
378/// A sample table holding only the sample entry: every sample lives in a fragment.
379fn stbl(sample_entry: &[u8]) -> Vec<u8> {
380    let mut stsd_p = 1u32.to_be_bytes().to_vec();
381    stsd_p.extend_from_slice(sample_entry);
382    let stsd = mk_full_box(b"stsd", 0, 0, &stsd_p);
383    let stts = mk_full_box(b"stts", 0, 0, &0u32.to_be_bytes());
384    let stsc = mk_full_box(b"stsc", 0, 0, &0u32.to_be_bytes());
385    let stsz = mk_full_box(b"stsz", 0, 0, &[0u8; 8]);
386    let stco = mk_full_box(b"stco", 0, 0, &0u32.to_be_bytes());
387    mk_box(b"stbl", &[stsd, stts, stsc, stsz, stco].concat())
388}
389
390/// The `tkhd` fields that differ by media: a video track states its display size, an audio
391/// track its volume.
392enum TrakMedia {
393    Video { width: u32, height: u32 },
394    Audio { volume: u16 },
395}
396
397/// One `trak`: `tkhd` (enabled, in_movie) with the display size or, for audio, the volume;
398/// `mdhd` at `timescale`; the handler; then the media header given and the sample table.
399fn trak(
400    track_id: u32,
401    timescale: u32,
402    handler: &[u8; 4],
403    media_header: Vec<u8>,
404    sample_entry: &[u8],
405    media: TrakMedia,
406) -> Vec<u8> {
407    let (width, height, volume) = match media {
408        TrakMedia::Video { width, height } => (width, height, 0),
409        TrakMedia::Audio { volume } => (0, 0, volume),
410    };
411    // tkhd payload in field order: creation/modification time, track_ID, reserved, duration,
412    // reserved, layer, alternate_group, volume, reserved, the unity matrix, then the 16.16
413    // display width and height.
414    let mut tkhd_p = Vec::new();
415    tkhd_p.extend_from_slice(&[0u8; 8]);
416    tkhd_p.extend_from_slice(&track_id.to_be_bytes());
417    tkhd_p.extend_from_slice(&[0u8; 4]);
418    tkhd_p.extend_from_slice(&0u32.to_be_bytes());
419    tkhd_p.extend_from_slice(&[0u8; 12]);
420    tkhd_p.extend_from_slice(&volume.to_be_bytes());
421    tkhd_p.extend_from_slice(&[0u8; 2]);
422    tkhd_p.extend_from_slice(&MATRIX_IDENTITY);
423    tkhd_p.extend_from_slice(&(width << 16).to_be_bytes());
424    tkhd_p.extend_from_slice(&(height << 16).to_be_bytes());
425    let tkhd = mk_full_box(b"tkhd", 0, 3, &tkhd_p);
426
427    // mdhd payload in field order: creation/modification time, timescale, duration,
428    // language (0x55c4 = "und"), pre_defined.
429    let mut mdhd_p = Vec::new();
430    mdhd_p.extend_from_slice(&[0u8; 8]);
431    mdhd_p.extend_from_slice(&timescale.to_be_bytes());
432    mdhd_p.extend_from_slice(&0u32.to_be_bytes());
433    mdhd_p.extend_from_slice(&0x55c4u16.to_be_bytes());
434    mdhd_p.extend_from_slice(&[0u8; 2]);
435    let mdhd = mk_full_box(b"mdhd", 0, 0, &mdhd_p);
436
437    // hdlr payload in field order: pre_defined, the handler type, reserved, the name.
438    let mut hdlr_p = Vec::new();
439    hdlr_p.extend_from_slice(&[0u8; 4]);
440    hdlr_p.extend_from_slice(handler);
441    hdlr_p.extend_from_slice(&[0u8; 12]);
442    hdlr_p.extend_from_slice(b"pixelflux\0");
443    let hdlr = mk_full_box(b"hdlr", 0, 0, &hdlr_p);
444
445    let minf = mk_box(
446        b"minf",
447        &[media_header, dinf(), stbl(sample_entry)].concat(),
448    );
449    let mdia = mk_box(b"mdia", &[mdhd, hdlr, minf].concat());
450    mk_box(b"trak", &[tkhd, mdia].concat())
451}
452
453/// A `trex` whose fragments state their own durations, sizes, and flags unless
454/// `default_duration` is set, which the audio track's fixed packet length is.
455fn trex(track_id: u32, default_duration: u32) -> Vec<u8> {
456    let mut trex_p = Vec::new();
457    trex_p.extend_from_slice(&track_id.to_be_bytes());
458    trex_p.extend_from_slice(&1u32.to_be_bytes());
459    trex_p.extend_from_slice(&default_duration.to_be_bytes());
460    trex_p.extend_from_slice(&[0u8; 8]);
461    mk_full_box(b"trex", 0, 0, &trex_p)
462}
463
464impl<W: Write> FragmentWriter<W> {
465    pub fn new(out: W) -> Self {
466        Self {
467            out,
468            seq: 0,
469            wrote_init: false,
470            pending: None,
471            last_dts: None,
472            durations: Vec::new(),
473            audio: None,
474            audio_pending: Vec::new(),
475            audio_batch_dts: 0,
476            audio_next_dts: 0,
477            stats: Mp4Stats::default(),
478        }
479    }
480
481    /// Declare the audio track; must precede `write_init`.
482    pub fn with_audio(mut self, cfg: AudioTrackConfig) -> Self {
483        self.audio = Some(cfg);
484        self
485    }
486
487    pub fn init_written(&self) -> bool {
488        self.wrote_init
489    }
490
491    /// Counters for the fragments written so far (the buffered pending sample is not yet
492    /// included; `finish` folds it in).
493    pub fn stats(&self) -> Mp4Stats {
494        self.stats
495    }
496
497    /// Write `ftyp` + `moov` (the video track, the audio track when declared, and the
498    /// `mvex`/`trex` marking the movie fragmented). Must be called once, before the first
499    /// sample.
500    pub fn write_init(&mut self, cfg: &TrackConfig) -> std::io::Result<()> {
501        let mut ftyp_p = Vec::new();
502        ftyp_p.extend_from_slice(b"isom");
503        ftyp_p.extend_from_slice(&0x200u32.to_be_bytes());
504        for brand in [b"isom", b"iso5", b"iso6", b"avc1", b"mp41"] {
505            ftyp_p.extend_from_slice(brand);
506        }
507        let ftyp = mk_box(b"ftyp", &ftyp_p);
508
509        // mvhd payload in field order: creation/modification time, timescale, duration
510        // (0 = unknown, as it must be in a fragmented movie), rate 1.0, volume 1.0, reserved,
511        // the unity matrix, pre_defined, next_track_ID.
512        let mut mvhd_p = Vec::new();
513        mvhd_p.extend_from_slice(&[0u8; 8]);
514        mvhd_p.extend_from_slice(&TIMESCALE.to_be_bytes());
515        mvhd_p.extend_from_slice(&0u32.to_be_bytes());
516        mvhd_p.extend_from_slice(&0x00010000u32.to_be_bytes());
517        mvhd_p.extend_from_slice(&0x0100u16.to_be_bytes());
518        mvhd_p.extend_from_slice(&[0u8; 10]);
519        mvhd_p.extend_from_slice(&MATRIX_IDENTITY);
520        mvhd_p.extend_from_slice(&[0u8; 24]);
521        mvhd_p.extend_from_slice(&(if self.audio.is_some() { 3u32 } else { 2u32 }).to_be_bytes());
522        let mvhd = mk_full_box(b"mvhd", 0, 0, &mvhd_p);
523
524        let vmhd = mk_full_box(b"vmhd", 0, 1, &[0u8; 8]);
525        let mut traks = trak(
526            1,
527            TIMESCALE,
528            b"vide",
529            vmhd,
530            &cfg.sample_entry,
531            TrakMedia::Video {
532                width: cfg.width,
533                height: cfg.height,
534            },
535        );
536        let mut mvex_p = trex(1, 0);
537        if let Some(audio) = &self.audio {
538            let smhd = mk_full_box(b"smhd", 0, 0, &[0u8; 4]);
539            traks.extend(trak(
540                2,
541                OPUS_TIMESCALE,
542                b"soun",
543                smhd,
544                &audio.sample_entry,
545                TrakMedia::Audio { volume: 0x0100 },
546            ));
547            mvex_p.extend(trex(2, 0));
548        }
549        let mvex = mk_box(b"mvex", &mvex_p);
550
551        let moov = mk_box(b"moov", &[mvhd, traks, mvex].concat());
552
553        self.out.write_all(&ftyp)?;
554        self.out.write_all(&moov)?;
555        self.stats.bytes += (ftyp.len() + moov.len()) as u64;
556        self.wrote_init = true;
557        Ok(())
558    }
559
560    /// Queue one sample at `pts_us` (wall-clock microseconds since recording start),
561    /// flushing the previously buffered sample with its now-known duration. Timestamps are
562    /// clamped strictly monotonic so a repeated or reordered clock can never emit a
563    /// zero/negative duration.
564    pub fn push_sample(&mut self, data: Vec<u8>, sync: bool, pts_us: u64) -> std::io::Result<()> {
565        let mut dts = pts_us * (TIMESCALE as u64 / 1000) / 1000;
566        if let Some(last) = self.last_dts
567            && dts <= last
568        {
569            dts = last + 1;
570        }
571        self.last_dts = Some(dts);
572        if let Some(prev) = self.pending.take() {
573            let duration = (dts - prev.dts).min(u32::MAX as u64) as u32;
574            self.durations.push(duration);
575            self.write_fragment(&prev, duration)?;
576        }
577        self.pending = Some(PendingSample { data, sync, dts });
578        Ok(())
579    }
580
581    /// Median of the observed inter-frame durations (default 1/30 s when fewer than two
582    /// frames were pushed), used to close the final sample.
583    fn median_duration(&self) -> u32 {
584        if self.durations.is_empty() {
585            return DEFAULT_LAST_DURATION;
586        }
587        let mut sorted = self.durations.clone();
588        sorted.sort_unstable();
589        sorted[sorted.len() / 2]
590    }
591
592    fn write_fragment(&mut self, s: &PendingSample, duration: u32) -> std::io::Result<()> {
593        self.seq += 1;
594
595        // tfhd carries only the track_ID; its flags set default-base-is-moof, so sample offsets
596        // are relative to the start of this moof.
597        let mut tfhd_p = Vec::new();
598        tfhd_p.extend_from_slice(&1u32.to_be_bytes());
599        let tfhd = mk_full_box(b"tfhd", 0, 0x020000, &tfhd_p);
600
601        let mut tfdt_p = Vec::new();
602        tfdt_p.extend_from_slice(&s.dts.to_be_bytes());
603        let tfdt = mk_full_box(b"tfdt", 1, 0, &tfdt_p);
604
605        // sample flags: sync = "depends on nothing"; non-sync also sets the non-sync bit.
606        let sample_flags: u32 = if s.sync { 0x0200_0000 } else { 0x0101_0000 };
607        // trun payload in field order: sample_count, a data_offset placeholder patched in below,
608        // then this sample's duration, size, and flags — exactly the fields its flags select
609        // (data-offset | sample-duration | sample-size | sample-flags).
610        let mut trun_p = Vec::new();
611        trun_p.extend_from_slice(&1u32.to_be_bytes());
612        trun_p.extend_from_slice(&0i32.to_be_bytes());
613        trun_p.extend_from_slice(&duration.to_be_bytes());
614        trun_p.extend_from_slice(&(s.data.len() as u32).to_be_bytes());
615        trun_p.extend_from_slice(&sample_flags.to_be_bytes());
616        let mut trun = mk_full_box(b"trun", 0, 0x000701, &trun_p);
617
618        let traf_len = 8 + tfhd.len() + tfdt.len() + trun.len();
619        // A moof box header is 8 bytes and the mfhd inside it 16.
620        let moof_len = 8 + 16 + traf_len;
621        // First sample byte sits just past the mdat header, relative to moof start.
622        let data_offset = (moof_len + 8) as i32;
623        // The data_offset field sits in the trun payload directly after sample_count.
624        let off_pos = trun.len() - trun_p.len() + 4;
625        trun[off_pos..off_pos + 4].copy_from_slice(&data_offset.to_be_bytes());
626
627        let mfhd = mk_full_box(b"mfhd", 0, 0, &self.seq.to_be_bytes());
628        let traf = mk_box(b"traf", &[tfhd, tfdt, trun].concat());
629        let moof = mk_box(b"moof", &[mfhd, traf].concat());
630        debug_assert_eq!(moof.len(), moof_len);
631
632        self.out.write_all(&moof)?;
633        self.out
634            .write_all(&((8 + s.data.len()) as u32).to_be_bytes())?;
635        self.out.write_all(b"mdat")?;
636        self.out.write_all(&s.data)?;
637        self.out.flush()?;
638
639        self.stats.samples += 1;
640        if s.sync {
641            self.stats.sync_samples += 1;
642        }
643        self.stats.bytes += (moof.len() + 8 + s.data.len()) as u64;
644        self.stats.duration_us = self
645            .stats
646            .duration_us
647            .max((s.dts + duration as u64) * 1000 / (TIMESCALE as u64 / 1000));
648        Ok(())
649    }
650
651    /// Queue one audio packet decoding at `dts` on the 48 kHz clock. A packet that does
652    /// not follow the pending ones directly (a gap in the stream) closes their fragment
653    /// first, so every fragment's samples are contiguous from its `tfdt`.
654    pub fn push_audio(&mut self, data: Vec<u8>, dts: u64) -> std::io::Result<()> {
655        if self.audio.is_none() {
656            return Ok(());
657        }
658        let samples = opus_packet_samples(&data);
659        if !self.audio_pending.is_empty() && dts != self.audio_next_dts {
660            self.flush_audio()?;
661        }
662        if self.audio_pending.is_empty() {
663            self.audio_batch_dts = dts;
664        }
665        self.audio_pending.push((data, samples));
666        self.audio_next_dts = dts + samples as u64;
667        if self.audio_pending.len() >= AUDIO_FLUSH_SAMPLES {
668            self.flush_audio()?;
669        }
670        Ok(())
671    }
672
673    /// Write the pending audio packets as one fragment of track 2, every sample a sync
674    /// sample with its own duration and size.
675    fn flush_audio(&mut self) -> std::io::Result<()> {
676        if self.audio_pending.is_empty() {
677            return Ok(());
678        }
679        self.seq += 1;
680        let samples = std::mem::take(&mut self.audio_pending);
681
682        // tfhd payload: track_ID and the default sample flags (sync) its flags select,
683        // with default-base-is-moof.
684        let mut tfhd_p = 2u32.to_be_bytes().to_vec();
685        tfhd_p.extend_from_slice(&0x0200_0000u32.to_be_bytes());
686        let tfhd = mk_full_box(b"tfhd", 0, 0x020020, &tfhd_p);
687        let tfdt = mk_full_box(b"tfdt", 1, 0, &self.audio_batch_dts.to_be_bytes());
688        // trun payload in field order: sample_count, a data_offset placeholder patched in
689        // below, then each sample's duration and size (data-offset | sample-duration |
690        // sample-size).
691        let mut trun_p = Vec::new();
692        trun_p.extend_from_slice(&(samples.len() as u32).to_be_bytes());
693        trun_p.extend_from_slice(&0i32.to_be_bytes());
694        for (s, duration) in &samples {
695            trun_p.extend_from_slice(&duration.to_be_bytes());
696            trun_p.extend_from_slice(&(s.len() as u32).to_be_bytes());
697        }
698        let mut trun = mk_full_box(b"trun", 0, 0x000301, &trun_p);
699
700        let traf_len = 8 + tfhd.len() + tfdt.len() + trun.len();
701        let moof_len = 8 + 16 + traf_len;
702        let data_offset = (moof_len + 8) as i32;
703        let off_pos = trun.len() - trun_p.len() + 4;
704        trun[off_pos..off_pos + 4].copy_from_slice(&data_offset.to_be_bytes());
705
706        let mfhd = mk_full_box(b"mfhd", 0, 0, &self.seq.to_be_bytes());
707        let traf = mk_box(b"traf", &[tfhd, tfdt, trun].concat());
708        let moof = mk_box(b"moof", &[mfhd, traf].concat());
709        debug_assert_eq!(moof.len(), moof_len);
710
711        let data_len: usize = samples.iter().map(|(s, _)| s.len()).sum();
712        self.out.write_all(&moof)?;
713        self.out.write_all(&((8 + data_len) as u32).to_be_bytes())?;
714        self.out.write_all(b"mdat")?;
715        for (s, _) in &samples {
716            self.out.write_all(s)?;
717        }
718        self.out.flush()?;
719        self.stats.audio_samples += samples.len() as u64;
720        self.stats.bytes += (moof.len() + 8 + data_len) as u64;
721        let end = self.audio_batch_dts + samples.iter().map(|(_, d)| *d as u64).sum::<u64>();
722        self.stats.duration_us = self
723            .stats
724            .duration_us
725            .max(end * 1_000_000 / OPUS_TIMESCALE as u64);
726        Ok(())
727    }
728
729    /// Flush the pending audio and the final buffered video sample, closed with the MEDIAN
730    /// observed inter-frame duration (its successor never arrives), and return the
731    /// aggregate counters.
732    pub fn finish(mut self) -> std::io::Result<Mp4Stats> {
733        self.flush_audio()?;
734        if let Some(prev) = self.pending.take() {
735            let d = self.median_duration();
736            self.write_fragment(&prev, d)?;
737        }
738        self.out.flush()?;
739        Ok(self.stats)
740    }
741}
742
743/// H.264-specific front end: captures SPS/PPS from the stream, gates output on the first
744/// IDR, converts Annex-B access units to AVCC samples, and builds the `avc1` sample entry.
745pub struct H264SampleBuilder {
746    sps: Option<Vec<u8>>,
747    pps: Option<Vec<u8>>,
748}
749
750/// One converted access unit ready for the fragment writer.
751pub struct BuiltSample {
752    pub data: Vec<u8>,
753    pub sync: bool,
754}
755
756impl Default for H264SampleBuilder {
757    fn default() -> Self {
758        Self::new()
759    }
760}
761
762impl H264SampleBuilder {
763    pub fn new() -> Self {
764        Self {
765            sps: None,
766            pps: None,
767        }
768    }
769
770    pub fn have_parameter_sets(&self) -> bool {
771        self.sps.is_some() && self.pps.is_some()
772    }
773
774    /// Convert one Annex-B access unit into a length-prefixed AVCC sample, harvesting
775    /// SPS/PPS on the way. Returns `None` for an AU with no slice data (e.g. bare parameter
776    /// sets). `sync` is true when the AU contains an IDR slice.
777    pub fn build_sample(&mut self, annexb: &[u8]) -> Option<BuiltSample> {
778        let nals = split_annexb(annexb);
779        let mut data = Vec::with_capacity(annexb.len() + 8);
780        let mut sync = false;
781        let mut has_slice = false;
782        for nal in nals {
783            if nal.is_empty() {
784                continue;
785            }
786            match nal[0] & 0x1f {
787                7 => {
788                    if self.sps.as_deref() != Some(nal) {
789                        self.sps = Some(nal.to_vec());
790                    }
791                }
792                8 => {
793                    if self.pps.as_deref() != Some(nal) {
794                        self.pps = Some(nal.to_vec());
795                    }
796                }
797                5 => {
798                    sync = true;
799                    has_slice = true;
800                }
801                1 => has_slice = true,
802                _ => {}
803            }
804            data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
805            data.extend_from_slice(nal);
806        }
807        if !has_slice {
808            return None;
809        }
810        Some(BuiltSample { data, sync })
811    }
812
813    /// Build the `avc1` sample entry + `avcC` record from the captured parameter sets, with
814    /// the display dimensions parsed from the SPS.
815    pub fn track_config(&self) -> Option<TrackConfig> {
816        let sps = self.sps.as_deref()?;
817        let pps = self.pps.as_deref()?;
818        let (width, height) = parse_sps_dimensions(sps)?;
819
820        // avcC header in field order: configurationVersion, AVCProfileIndication,
821        // profile_compatibility, and AVCLevelIndication taken straight from the SPS,
822        // lengthSizeMinusOne = 3 (the 4-byte NAL lengths this muxer writes), and
823        // numOfSequenceParameterSets = 1. The SPS then the PPS follow, each length-prefixed.
824        let mut avcc_p = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1];
825        avcc_p.extend_from_slice(&(sps.len() as u16).to_be_bytes());
826        avcc_p.extend_from_slice(sps);
827        // numOfPictureParameterSets
828        avcc_p.push(1);
829        avcc_p.extend_from_slice(&(pps.len() as u16).to_be_bytes());
830        avcc_p.extend_from_slice(pps);
831        let avcc = mk_box(b"avcC", &avcc_p);
832
833        // avc1 sample entry in field order: reserved, data_reference_index, the
834        // pre_defined/reserved block, width and height, horizontal and vertical resolution
835        // (72 dpi), reserved, frame_count, compressorname, depth 24, pre_defined.
836        let mut entry_p = Vec::new();
837        entry_p.extend_from_slice(&[0u8; 6]);
838        entry_p.extend_from_slice(&1u16.to_be_bytes());
839        entry_p.extend_from_slice(&[0u8; 16]);
840        entry_p.extend_from_slice(&(width as u16).to_be_bytes());
841        entry_p.extend_from_slice(&(height as u16).to_be_bytes());
842        entry_p.extend_from_slice(&0x0048_0000u32.to_be_bytes());
843        entry_p.extend_from_slice(&0x0048_0000u32.to_be_bytes());
844        entry_p.extend_from_slice(&[0u8; 4]);
845        entry_p.extend_from_slice(&1u16.to_be_bytes());
846        entry_p.extend_from_slice(&[0u8; 32]);
847        entry_p.extend_from_slice(&0x0018u16.to_be_bytes());
848        entry_p.extend_from_slice(&(-1i16).to_be_bytes());
849        entry_p.extend_from_slice(&avcc);
850        let entry = mk_box(b"avc1", &entry_p);
851
852        Some(TrackConfig {
853            sample_entry: entry,
854            width,
855            height,
856        })
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    // x264 SPS at 1284x722 (High 4:2:0: cropping in both axes on odd-macroblock dims).
865    const SPS_HIGH_1284X722: &str = "67640020acd9405105de788c0440000003004000000f03c60c6580";
866    // x264 SPS at 640x360 (Constrained Baseline).
867    const SPS_BASE_640X360: &str = "6742c01ed900a02ff970110000030001000003003c0f162e48";
868    // x264 SPS at 1920x1080 (High 4:4:4 Predictive: chroma_format_idc == 3 path).
869    const SPS_444_1920X1080: &str = "67f40028919b280f0044fc4e0220000003002000000781e30632c0";
870
871    fn hex(s: &str) -> Vec<u8> {
872        (0..s.len())
873            .step_by(2)
874            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
875            .collect()
876    }
877
878    /// Both start-code lengths delimit NALs; payloads come back exactly, with no framing.
879    #[test]
880    fn split_annexb_handles_mixed_start_codes() {
881        let mut data = Vec::new();
882        data.extend_from_slice(&[0, 0, 0, 1, 0x67, 0xAA, 0xBB]);
883        data.extend_from_slice(&[0, 0, 1, 0x68, 0xCC]);
884        data.extend_from_slice(&[0, 0, 0, 1, 0x65, 0x00, 0x00, 0x03, 0x01, 0xDD]);
885        let nals = split_annexb(&data);
886        assert_eq!(nals.len(), 3);
887        assert_eq!(nals[0], &[0x67, 0xAA, 0xBB]);
888        assert_eq!(nals[1], &[0x68, 0xCC]);
889        assert_eq!(nals[2], &[0x65, 0x00, 0x00, 0x03, 0x01, 0xDD]);
890    }
891
892    /// Nothing to split yields no NALs, and a stream cut right after a start code
893    /// yields no empty one either -- an empty payload would be written as a
894    /// zero-length sample.
895    #[test]
896    fn split_annexb_emits_no_empty_nals() {
897        assert!(split_annexb(&[]).is_empty());
898        assert!(split_annexb(&[0x67, 0xAA, 0xBB]).is_empty());
899        assert!(split_annexb(&[0, 0, 0, 1]).is_empty());
900        assert_eq!(
901            split_annexb(&[0, 0, 1, 0x68, 0, 0, 0, 1]),
902            vec![&[0x68u8][..]]
903        );
904    }
905
906    /// Dimensions from real x264 SPS across the profiles the project's encoders emit,
907    /// including the frame-cropping and 4:4:4 chroma paths.
908    #[test]
909    fn sps_dimensions_across_profiles() {
910        assert_eq!(
911            parse_sps_dimensions(&hex(SPS_HIGH_1284X722)),
912            Some((1284, 722))
913        );
914        assert_eq!(
915            parse_sps_dimensions(&hex(SPS_BASE_640X360)),
916            Some((640, 360))
917        );
918        assert_eq!(
919            parse_sps_dimensions(&hex(SPS_444_1920X1080)),
920            Some((1920, 1080))
921        );
922    }
923
924    /// Annex-B -> AVCC: every NAL is length-prefixed, IDR marks sync, parameter sets are
925    /// harvested, and a parameter-set-only AU yields no sample.
926    #[test]
927    fn annexb_to_avcc_sample() {
928        let sps = hex(SPS_BASE_640X360);
929        let mut au = Vec::new();
930        au.extend_from_slice(&[0, 0, 0, 1]);
931        au.extend_from_slice(&sps);
932        au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
933        au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1, 2, 3, 4]);
934
935        let mut b = H264SampleBuilder::new();
936        assert!(
937            b.build_sample(&[0u8, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1e, 0xd9])
938                .is_none()
939        );
940        let s = b.build_sample(&au).expect("IDR AU builds a sample");
941        assert!(s.sync);
942        assert!(b.have_parameter_sets());
943        // Sample = 3 length-prefixed NALs, sizes preserved.
944        let mut off = 0usize;
945        let mut sizes = Vec::new();
946        while off < s.data.len() {
947            let n = u32::from_be_bytes(s.data[off..off + 4].try_into().unwrap()) as usize;
948            sizes.push(n);
949            off += 4 + n;
950        }
951        assert_eq!(off, s.data.len());
952        assert_eq!(sizes, vec![sps.len(), 4, 5]);
953
954        let p = b.build_sample(&[0u8, 0, 0, 1, 0x41, 9, 9]).unwrap();
955        assert!(!p.sync);
956    }
957
958    fn top_level(buf: &[u8]) -> Vec<(String, Vec<u8>)> {
959        let mut out = Vec::new();
960        let mut at = 0;
961        while at + 8 <= buf.len() {
962            let size = u32::from_be_bytes(buf[at..at + 4].try_into().unwrap()) as usize;
963            out.push((
964                String::from_utf8_lossy(&buf[at + 4..at + 8]).into_owned(),
965                buf[at + 8..at + size].to_vec(),
966            ));
967            at += size;
968        }
969        assert_eq!(at, buf.len(), "boxes tile the buffer");
970        out
971    }
972
973    fn count(haystack: &[u8], needle: &[u8]) -> usize {
974        haystack
975            .windows(needle.len())
976            .filter(|w| *w == needle)
977            .count()
978    }
979
980    fn idr_au() -> Vec<u8> {
981        let mut au = vec![0, 0, 0, 1];
982        au.extend_from_slice(&hex(SPS_BASE_640X360));
983        au.extend_from_slice(&[
984            0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80, 0, 0, 0, 1, 0x65, 1, 2, 3, 4,
985        ]);
986        au
987    }
988
989    /// Opus packet lengths from the TOC: 20 ms CELT fullband, 2.5 ms, a 60 ms SILK, and
990    /// code-3 packets counting their frames.
991    #[test]
992    fn opus_packet_durations() {
993        assert_eq!(opus_packet_samples(&[0xfc]), 960);
994        assert_eq!(opus_packet_samples(&[0xe0]), 120);
995        assert_eq!(opus_packet_samples(&[0x18]), 2880);
996        assert_eq!(opus_packet_samples(&[0xfd]), 1920);
997        assert_eq!(opus_packet_samples(&[0xff, 0x03]), 2880);
998        assert_eq!(opus_packet_samples(&[]), 0);
999    }
1000
1001    /// With an audio track declared the init carries two `trak`s and two `trex`es and the
1002    /// `Opus` entry's `dOps` repeats the head; packets go out contiguously in audio
1003    /// fragments at the flush size, a gap in the stream closes a fragment early, and the
1004    /// fragments' decode times are the packets' own.
1005    #[test]
1006    fn audio_track_fragments() {
1007        let head = super::super::ogg::OpusHead {
1008            channels: 2,
1009            pre_skip: 312,
1010            input_sample_rate: 48000,
1011            output_gain: 0,
1012            mapping_family: 0,
1013            mapping: vec![],
1014        };
1015        let entry = opus_sample_entry(&head);
1016        assert_eq!(&entry[4..8], b"Opus");
1017        assert_eq!(count(&entry, b"dOps"), 1);
1018        let dops = entry.windows(4).position(|w| w == b"dOps").unwrap() + 4;
1019        assert_eq!(&entry[dops..dops + 4], &[0, 2, 0x01, 0x38]);
1020
1021        let mut b = H264SampleBuilder::new();
1022        let sample = b.build_sample(&idr_au()).unwrap();
1023        let mut out = Vec::new();
1024        {
1025            let mut w = FragmentWriter::new(&mut out).with_audio(AudioTrackConfig {
1026                sample_entry: entry,
1027            });
1028            w.write_init(&b.track_config().unwrap()).unwrap();
1029            for i in 0..AUDIO_FLUSH_SAMPLES as u64 + 2 {
1030                w.push_audio(vec![0xfc, i as u8], 1000 + i * 960).unwrap();
1031            }
1032            // A gap of a second: the two pending packets close, the next opens a fragment.
1033            w.push_audio(vec![0xfc, 0xee], 1000 + 28 * 960 + 48_000)
1034                .unwrap();
1035            w.push_sample(sample.data.clone(), true, 0).unwrap();
1036            w.push_sample(sample.data, true, 33_000).unwrap();
1037            let st = w.finish().unwrap();
1038            assert_eq!(st.audio_samples, AUDIO_FLUSH_SAMPLES as u64 + 3);
1039            assert_eq!(st.samples, 2);
1040            assert_eq!(
1041                st.duration_us,
1042                (1000 + 28 * 960 + 48_000 + 960) * 1_000_000 / 48_000
1043            );
1044        }
1045        let boxes = top_level(&out);
1046        let kinds: Vec<&str> = boxes.iter().map(|(k, _)| k.as_str()).collect();
1047        assert_eq!(kinds[..2], ["ftyp", "moov"]);
1048        assert_eq!(count(&boxes[1].1, b"trak"), 2);
1049        assert_eq!(count(&boxes[1].1, b"trex"), 2);
1050        assert_eq!(count(&boxes[1].1, b"soun"), 1);
1051        assert_eq!(
1052            kinds.iter().filter(|k| **k == "moof").count(),
1053            5,
1054            "{kinds:?}"
1055        );
1056        let tfdts: Vec<u64> = boxes
1057            .iter()
1058            .filter(|(k, _)| k == "moof")
1059            .map(|(_, m)| {
1060                let at = m.windows(4).position(|w| w == b"tfdt").unwrap() + 8;
1061                u64::from_be_bytes(m[at..at + 8].try_into().unwrap())
1062            })
1063            .collect();
1064        // Audio: the full batch, then the two before the gap when the one after it
1065        // arrives; the first video sample goes out when the second arrives; the packet
1066        // after the gap and the last video sample at finish.
1067        assert_eq!(
1068            tfdts,
1069            vec![
1070                1000,
1071                1000 + AUDIO_FLUSH_SAMPLES as u64 * 960,
1072                0,
1073                1000 + 28 * 960 + 48_000,
1074                2970
1075            ]
1076        );
1077        let first_audio = &boxes[2].1;
1078        let trun = first_audio.windows(4).position(|w| w == b"trun").unwrap() + 8;
1079        assert_eq!(
1080            u32::from_be_bytes(first_audio[trun..trun + 4].try_into().unwrap()),
1081            AUDIO_FLUSH_SAMPLES as u32
1082        );
1083        assert_eq!(
1084            u32::from_be_bytes(first_audio[trun + 8..trun + 12].try_into().unwrap()),
1085            960
1086        );
1087    }
1088
1089    /// Walk the top-level boxes of a finished two-sample stream: init once, then one
1090    /// moof+mdat pair per sample, with sizes that exactly tile the buffer.
1091    #[test]
1092    fn fragment_stream_box_layout() {
1093        let mut b = H264SampleBuilder::new();
1094        let mut au = Vec::new();
1095        au.extend_from_slice(&[0, 0, 0, 1]);
1096        au.extend_from_slice(&hex(SPS_BASE_640X360));
1097        au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
1098        au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1, 2, 3, 4]);
1099        let s1 = b.build_sample(&au).unwrap();
1100        let s2 = b.build_sample(&[0u8, 0, 0, 1, 0x41, 5, 6, 7]).unwrap();
1101
1102        let mut buf = Vec::new();
1103        let mut w = FragmentWriter::new(&mut buf);
1104        w.write_init(&b.track_config().unwrap()).unwrap();
1105        w.push_sample(s1.data, s1.sync, 0).unwrap();
1106        w.push_sample(s2.data, s2.sync, 33_000).unwrap();
1107        let stats = w.finish().unwrap();
1108        assert_eq!(stats.samples, 2);
1109        assert_eq!(stats.sync_samples, 1);
1110        assert_eq!(stats.bytes as usize, buf.len());
1111
1112        let mut kinds = Vec::new();
1113        let mut off = 0usize;
1114        while off < buf.len() {
1115            let size = u32::from_be_bytes(buf[off..off + 4].try_into().unwrap()) as usize;
1116            kinds.push(buf[off + 4..off + 8].to_vec());
1117            assert!(size >= 8 && off + size <= buf.len());
1118            off += size;
1119        }
1120        assert_eq!(off, buf.len());
1121        let names: Vec<&str> = kinds
1122            .iter()
1123            .map(|k| std::str::from_utf8(k).unwrap())
1124            .collect();
1125        assert_eq!(names, vec!["ftyp", "moov", "moof", "mdat", "moof", "mdat"]);
1126    }
1127
1128    /// Every trun sample_duration in stream order (the writer emits one sample per trun).
1129    fn trun_durations(buf: &[u8]) -> Vec<u32> {
1130        let mut out = Vec::new();
1131        let mut off = 0usize;
1132        while off + 24 <= buf.len() {
1133            if &buf[off + 4..off + 8] == b"trun" {
1134                // [size][fourcc][ver+flags][sample_count][data_offset][duration]
1135                out.push(u32::from_be_bytes(
1136                    buf[off + 20..off + 24].try_into().unwrap(),
1137                ));
1138            }
1139            off += 1;
1140        }
1141        out
1142    }
1143
1144    /// Each flushed sample's duration is the pts delta to its successor, and the final
1145    /// buffered sample closes with the MEDIAN observed duration — not the last delta, which
1146    /// under damage-driven capture is an outlier as often as not.
1147    #[test]
1148    fn sample_durations_are_pts_deltas_with_median_tail() {
1149        let mut buf = Vec::new();
1150        let mut w = FragmentWriter::new(&mut buf);
1151        let cfg = {
1152            let mut b = H264SampleBuilder::new();
1153            let mut au = vec![0, 0, 0, 1];
1154            au.extend_from_slice(&hex(SPS_BASE_640X360));
1155            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
1156            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
1157            b.build_sample(&au);
1158            b.track_config().unwrap()
1159        };
1160        w.write_init(&cfg).unwrap();
1161        // 33 ms, 33 ms, then a 300 ms static gap before the final frame.
1162        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 0).unwrap();
1163        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 33_000)
1164            .unwrap();
1165        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 66_000)
1166            .unwrap();
1167        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 366_000)
1168            .unwrap();
1169        let stats = w.finish().unwrap();
1170        assert_eq!(stats.samples, 4);
1171        // 90 kHz ticks: 33 ms = 2970. The tail closes at the median (2970), not 27000.
1172        assert_eq!(trun_durations(&buf), vec![2970, 2970, 27_000, 2970]);
1173    }
1174
1175    /// A single-frame recording still closes with a sane nonzero duration.
1176    #[test]
1177    fn single_sample_uses_default_duration() {
1178        let mut buf = Vec::new();
1179        let mut w = FragmentWriter::new(&mut buf);
1180        let cfg = {
1181            let mut b = H264SampleBuilder::new();
1182            let mut au = vec![0, 0, 0, 1];
1183            au.extend_from_slice(&hex(SPS_BASE_640X360));
1184            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
1185            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
1186            b.build_sample(&au);
1187            b.track_config().unwrap()
1188        };
1189        w.write_init(&cfg).unwrap();
1190        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 0).unwrap();
1191        let stats = w.finish().unwrap();
1192        assert_eq!(stats.samples, 1);
1193        assert_eq!(trun_durations(&buf), vec![DEFAULT_LAST_DURATION]);
1194    }
1195
1196    /// Non-monotonic wall-clock input is clamped to strictly increasing decode times, so no
1197    /// fragment can carry a zero or negative duration.
1198    #[test]
1199    fn pts_clamped_strictly_monotonic() {
1200        let mut buf = Vec::new();
1201        let mut w = FragmentWriter::new(&mut buf);
1202        let cfg = {
1203            let mut b = H264SampleBuilder::new();
1204            let mut au = vec![0, 0, 0, 1];
1205            au.extend_from_slice(&hex(SPS_BASE_640X360));
1206            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
1207            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
1208            b.build_sample(&au);
1209            b.track_config().unwrap()
1210        };
1211        w.write_init(&cfg).unwrap();
1212        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 1000).unwrap();
1213        // The second sample repeats the clock and the third winds it backwards.
1214        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 1000).unwrap();
1215        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 500).unwrap();
1216        let stats = w.finish().unwrap();
1217        assert_eq!(stats.samples, 3);
1218
1219        // Extract each tfdt baseMediaDecodeTime and check strict monotonicity.
1220        let mut times = Vec::new();
1221        let mut off = 0usize;
1222        while off + 8 <= buf.len() {
1223            if &buf[off + 4..off + 8] == b"tfdt" {
1224                let t = u64::from_be_bytes(buf[off + 12..off + 20].try_into().unwrap());
1225                times.push(t);
1226            }
1227            off += 1;
1228        }
1229        assert_eq!(times.len(), 3);
1230        assert!(
1231            times.windows(2).all(|w| w[1] > w[0]),
1232            "tfdt times: {times:?}"
1233        );
1234    }
1235}