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: `ffmpeg-sys-next`'s
10//! `avformat` feature would add libavformat as a hard runtime dependency of every build, and the
11//! pure-Rust mp4 crates only write moov-trailing progressive files, which lose everything on a
12//! crash. Fragmented MP4 needs no trailer and no seeking — each `moof`+`mdat` pair is
13//! self-contained — so a file truncated by a crash or SIGKILL stays playable up to the last
14//! fragment, and the writer works on any `Write` sink.
15//!
16//! Timestamps are caller-supplied wall-clock microseconds (damage-driven capture emits sparse,
17//! irregular frames), carried at a 90 kHz track timescale with one sample per fragment: `tfdt`
18//! anchors every sample at its true capture time, so variable framerate needs no constant-rate
19//! lie. The sample duration is only known once the NEXT frame arrives, so one sample is always
20//! buffered and flushed a frame behind (a clean stop closes it with the median observed
21//! duration); on SIGKILL at most that one buffered frame is lost — every fragment already
22//! written remains playable.
23//!
24//! Codec support is deliberately split: [`annexb_to_sample`] and the H.264-specific parameter-set
25//! capture live in [`H264SampleBuilder`], while the fragment/box writer below is codec-agnostic
26//! (bytes + sync flag + timestamps + a ready-made `stsd` sample entry). HEVC or AV1 recording
27//! later means a new sample builder emitting an `hvc1`/`av01` entry, not a new muxer.
28
29use std::io::Write;
30
31/// 90 kHz: the conventional H.264 track timescale, exactly representing common frame intervals.
32const TIMESCALE: u32 = 90_000;
33
34/// Fallback duration for the final buffered sample when only one frame was ever written
35/// (no observed inter-frame delta to take a median of): 1/30 s.
36const DEFAULT_LAST_DURATION: u32 = TIMESCALE / 30;
37
38/// Split an Annex-B elementary stream into NAL payloads (start codes removed, emulation
39/// prevention bytes kept — the RBSP layer is only unescaped where a parser needs it).
40pub fn split_annexb(data: &[u8]) -> Vec<&[u8]> {
41    let mut nals = Vec::new();
42    let mut i = 0usize;
43    let mut nal_start: Option<usize> = None;
44    while i + 2 < data.len() {
45        if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
46            let code_start = if i > 0 && data[i - 1] == 0 { i - 1 } else { i };
47            if let Some(s) = nal_start
48                && code_start > s {
49                    nals.push(&data[s..code_start]);
50                }
51            i += 3;
52            nal_start = Some(i);
53        } else if data[i + 2] == 0 {
54            // A zero at i+2 can begin the next start code; only advance one byte.
55            i += 1;
56        } else {
57            i += 3;
58        }
59    }
60    if let Some(s) = nal_start
61        && data.len() > s {
62            nals.push(&data[s..]);
63        }
64    nals
65}
66
67/// Unescape an H.264 RBSP: drop the emulation-prevention byte from every `00 00 03` run.
68fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
69    let mut out = Vec::with_capacity(data.len());
70    let mut zeros = 0u32;
71    for &b in data {
72        if zeros >= 2 && b == 3 {
73            zeros = 0;
74            continue;
75        }
76        if b == 0 {
77            zeros += 1;
78        } else {
79            zeros = 0;
80        }
81        out.push(b);
82    }
83    out
84}
85
86/// MSB-first bit reader over an unescaped RBSP, with Exp-Golomb decode for SPS parsing.
87struct BitReader<'a> {
88    data: &'a [u8],
89    pos: usize,
90}
91
92impl<'a> BitReader<'a> {
93    fn new(data: &'a [u8]) -> Self {
94        Self { data, pos: 0 }
95    }
96
97    fn bit(&mut self) -> Option<u32> {
98        let byte = *self.data.get(self.pos / 8)?;
99        let bit = (byte >> (7 - (self.pos % 8))) & 1;
100        self.pos += 1;
101        Some(bit as u32)
102    }
103
104    fn bits(&mut self, n: u32) -> Option<u32> {
105        let mut v = 0u32;
106        for _ in 0..n {
107            v = (v << 1) | self.bit()?;
108        }
109        Some(v)
110    }
111
112    /// ue(v): count leading zeros, then read that many bits after the marker one.
113    fn ue(&mut self) -> Option<u32> {
114        let mut zeros = 0u32;
115        while self.bit()? == 0 {
116            zeros += 1;
117            if zeros > 31 {
118                return None;
119            }
120        }
121        let rest = self.bits(zeros)?;
122        Some((1u32 << zeros) - 1 + rest)
123    }
124
125    fn se(&mut self) -> Option<i32> {
126        let k = self.ue()? as i64;
127        Some(if k % 2 == 0 { -(k / 2) as i32 } else { ((k + 1) / 2) as i32 })
128    }
129}
130
131/// Coded frame dimensions parsed out of an H.264 SPS NAL (with its NAL header byte).
132///
133/// Walks every field ahead of `pic_width_in_mbs_minus1` — including the high-profile
134/// chroma/bit-depth/scaling-list block — and applies the frame-cropping rectangle with the
135/// chroma-format-dependent crop units, so 4:2:0, 4:2:2 and 4:4:4 streams from any of the
136/// project's encoders all report their true display size.
137pub fn parse_sps_dimensions(sps_nal: &[u8]) -> Option<(u32, u32)> {
138    if sps_nal.len() < 4 || sps_nal[0] & 0x1f != 7 {
139        return None;
140    }
141    let profile_idc = sps_nal[1];
142    let rbsp = unescape_rbsp(&sps_nal[4..]);
143    let mut r = BitReader::new(&rbsp);
144    // seq_parameter_set_id
145    r.ue()?;
146
147    let mut chroma_format_idc = 1u32;
148    if matches!(
149        profile_idc,
150        100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135
151    ) {
152        chroma_format_idc = r.ue()?;
153        if chroma_format_idc == 3 {
154            // separate_colour_plane_flag
155            r.bit()?;
156        }
157        // bit_depth_luma_minus8, bit_depth_chroma_minus8, qpprime_y_zero_transform_bypass_flag
158        r.ue()?;
159        r.ue()?;
160        r.bit()?;
161        if r.bit()? == 1 {
162            // seq_scaling_matrix_present_flag
163            let lists = if chroma_format_idc == 3 { 12 } else { 8 };
164            for i in 0..lists {
165                if r.bit()? == 1 {
166                    let size = if i < 6 { 16 } else { 64 };
167                    let mut next_scale = 8i32;
168                    let mut last_scale = 8i32;
169                    for _ in 0..size {
170                        if next_scale != 0 {
171                            let delta = r.se()?;
172                            next_scale = (last_scale + delta + 256) % 256;
173                        }
174                        if next_scale != 0 {
175                            last_scale = next_scale;
176                        }
177                    }
178                }
179            }
180        }
181    }
182
183    // log2_max_frame_num_minus4
184    r.ue()?;
185    let pic_order_cnt_type = r.ue()?;
186    if pic_order_cnt_type == 0 {
187        // log2_max_pic_order_cnt_lsb_minus4
188        r.ue()?;
189    } else if pic_order_cnt_type == 1 {
190        // delta_pic_order_always_zero_flag, offset_for_non_ref_pic,
191        // offset_for_top_to_bottom_field, then the offset_for_ref_frame list
192        r.bit()?;
193        r.se()?;
194        r.se()?;
195        let n = r.ue()?;
196        for _ in 0..n {
197            r.se()?;
198        }
199    }
200    // max_num_ref_frames, gaps_in_frame_num_value_allowed_flag
201    r.ue()?;
202    r.bit()?;
203    let pic_width_in_mbs = r.ue()? + 1;
204    let pic_height_in_map_units = r.ue()? + 1;
205    let frame_mbs_only = r.bit()?;
206    if frame_mbs_only == 0 {
207        // mb_adaptive_frame_field_flag
208        r.bit()?;
209    }
210    // direct_8x8_inference_flag
211    r.bit()?;
212
213    let (mut crop_l, mut crop_r, mut crop_t, mut crop_b) = (0u32, 0u32, 0u32, 0u32);
214    if r.bit()? == 1 {
215        crop_l = r.ue()?;
216        crop_r = r.ue()?;
217        crop_t = r.ue()?;
218        crop_b = r.ue()?;
219    }
220
221    let (sub_w, sub_h) = match chroma_format_idc {
222        0 | 3 => (1u32, 1u32),
223        2 => (2, 1),
224        _ => (2, 2),
225    };
226    let crop_unit_x = sub_w;
227    let crop_unit_y = sub_h * (2 - frame_mbs_only);
228    let width = pic_width_in_mbs * 16 - crop_unit_x * (crop_l + crop_r);
229    let height = (2 - frame_mbs_only) * pic_height_in_map_units * 16 - crop_unit_y * (crop_t + crop_b);
230    Some((width, height))
231}
232
233fn mk_box(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
234    let mut b = Vec::with_capacity(8 + payload.len());
235    b.extend_from_slice(&((8 + payload.len()) as u32).to_be_bytes());
236    b.extend_from_slice(fourcc);
237    b.extend_from_slice(payload);
238    b
239}
240
241fn mk_full_box(fourcc: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
242    let mut p = Vec::with_capacity(4 + payload.len());
243    p.push(version);
244    p.extend_from_slice(&flags.to_be_bytes()[1..]);
245    p.extend_from_slice(payload);
246    mk_box(fourcc, &p)
247}
248
249/// The unity transformation matrix `mvhd` and `tkhd` carry: 0x00010000, 0x00010000 and
250/// 0x40000000 on the diagonal (16.16 fixed point for the first two, 2.30 for the last).
251const MATRIX_IDENTITY: [u8; 36] = {
252    let mut m = [0u8; 36];
253    m[1] = 0x01;
254    m[17] = 0x01;
255    m[32] = 0x40;
256    m
257};
258
259/// A codec's contribution to the init segment: its `stsd` sample entry (with decoder
260/// configuration record) plus the display dimensions for `tkhd`.
261pub struct TrackConfig {
262    pub sample_entry: Vec<u8>,
263    pub width: u32,
264    pub height: u32,
265}
266
267/// One buffered access unit awaiting its duration (known when the next one arrives).
268struct PendingSample {
269    data: Vec<u8>,
270    sync: bool,
271    dts: u64,
272}
273
274/// Aggregate counters reported when the writer finishes.
275#[derive(Clone, Copy, Debug, Default)]
276pub struct Mp4Stats {
277    pub samples: u64,
278    pub sync_samples: u64,
279    pub bytes: u64,
280    pub duration_us: u64,
281}
282
283/// Codec-agnostic fMP4 fragment writer: `ftyp`+`moov` once, then one `moof`+`mdat` pair
284/// per sample, each anchored at its wall-clock decode time via `tfdt`.
285pub struct FragmentWriter<W: Write> {
286    out: W,
287    seq: u32,
288    wrote_init: bool,
289    pending: Option<PendingSample>,
290    last_dts: Option<u64>,
291    /// Every flushed inter-frame duration, kept so the final buffered sample (whose
292    /// successor never arrives) can close on the MEDIAN — damage-driven capture makes the
293    /// last observed delta an outlier as often as not (a long static gap, a burst pair).
294    durations: Vec<u32>,
295    stats: Mp4Stats,
296}
297
298impl<W: Write> FragmentWriter<W> {
299    pub fn new(out: W) -> Self {
300        Self {
301            out,
302            seq: 0,
303            wrote_init: false,
304            pending: None,
305            last_dts: None,
306            durations: Vec::new(),
307            stats: Mp4Stats::default(),
308        }
309    }
310
311    pub fn init_written(&self) -> bool {
312        self.wrote_init
313    }
314
315    /// Counters for the fragments written so far (the buffered pending sample is not yet
316    /// included; `finish` folds it in).
317    pub fn stats(&self) -> Mp4Stats {
318        self.stats
319    }
320
321    /// Write `ftyp` + `moov` (track 1, `mvex`/`trex` marking the movie fragmented). Must be
322    /// called once, before the first sample.
323    pub fn write_init(&mut self, cfg: &TrackConfig) -> std::io::Result<()> {
324        let mut ftyp_p = Vec::new();
325        ftyp_p.extend_from_slice(b"isom");
326        ftyp_p.extend_from_slice(&0x200u32.to_be_bytes());
327        for brand in [b"isom", b"iso5", b"iso6", b"avc1", b"mp41"] {
328            ftyp_p.extend_from_slice(brand);
329        }
330        let ftyp = mk_box(b"ftyp", &ftyp_p);
331
332        // mvhd payload in field order: creation/modification time, timescale, duration
333        // (0 = unknown, as it must be in a fragmented movie), rate 1.0, volume 1.0, reserved,
334        // the unity matrix, pre_defined, next_track_ID.
335        let mut mvhd_p = Vec::new();
336        mvhd_p.extend_from_slice(&[0u8; 8]);
337        mvhd_p.extend_from_slice(&TIMESCALE.to_be_bytes());
338        mvhd_p.extend_from_slice(&0u32.to_be_bytes());
339        mvhd_p.extend_from_slice(&0x00010000u32.to_be_bytes());
340        mvhd_p.extend_from_slice(&0x0100u16.to_be_bytes());
341        mvhd_p.extend_from_slice(&[0u8; 10]);
342        mvhd_p.extend_from_slice(&MATRIX_IDENTITY);
343        mvhd_p.extend_from_slice(&[0u8; 24]);
344        mvhd_p.extend_from_slice(&2u32.to_be_bytes());
345        let mvhd = mk_full_box(b"mvhd", 0, 0, &mvhd_p);
346
347        // tkhd payload in field order: creation/modification time, track_ID, reserved, duration,
348        // the reserved/layer/alternate_group/volume block, the unity matrix, then the 16.16
349        // display width and height. Its flags mark the track enabled and in_movie.
350        let mut tkhd_p = Vec::new();
351        tkhd_p.extend_from_slice(&[0u8; 8]);
352        tkhd_p.extend_from_slice(&1u32.to_be_bytes());
353        tkhd_p.extend_from_slice(&[0u8; 4]);
354        tkhd_p.extend_from_slice(&0u32.to_be_bytes());
355        tkhd_p.extend_from_slice(&[0u8; 16]);
356        tkhd_p.extend_from_slice(&MATRIX_IDENTITY);
357        tkhd_p.extend_from_slice(&(cfg.width << 16).to_be_bytes());
358        tkhd_p.extend_from_slice(&(cfg.height << 16).to_be_bytes());
359        let tkhd = mk_full_box(b"tkhd", 0, 3, &tkhd_p);
360
361        // mdhd payload in field order: creation/modification time, timescale, duration,
362        // language (0x55c4 = "und"), pre_defined.
363        let mut mdhd_p = Vec::new();
364        mdhd_p.extend_from_slice(&[0u8; 8]);
365        mdhd_p.extend_from_slice(&TIMESCALE.to_be_bytes());
366        mdhd_p.extend_from_slice(&0u32.to_be_bytes());
367        mdhd_p.extend_from_slice(&0x55c4u16.to_be_bytes());
368        mdhd_p.extend_from_slice(&[0u8; 2]);
369        let mdhd = mk_full_box(b"mdhd", 0, 0, &mdhd_p);
370
371        // hdlr payload in field order: pre_defined, the 'vide' handler type, reserved, and the
372        // handler name.
373        let mut hdlr_p = Vec::new();
374        hdlr_p.extend_from_slice(&[0u8; 4]);
375        hdlr_p.extend_from_slice(b"vide");
376        hdlr_p.extend_from_slice(&[0u8; 12]);
377        hdlr_p.extend_from_slice(b"pixelflux\0");
378        let hdlr = mk_full_box(b"hdlr", 0, 0, &hdlr_p);
379
380        let vmhd = mk_full_box(b"vmhd", 0, 1, &[0u8; 8]);
381        // An empty 'url ' entry with flag 1 declares the media self-contained.
382        let url = mk_full_box(b"url ", 0, 1, &[]);
383        let mut dref_p = 1u32.to_be_bytes().to_vec();
384        dref_p.extend_from_slice(&url);
385        let dref = mk_full_box(b"dref", 0, 0, &dref_p);
386        let dinf = mk_box(b"dinf", &dref);
387
388        let mut stsd_p = 1u32.to_be_bytes().to_vec();
389        stsd_p.extend_from_slice(&cfg.sample_entry);
390        let stsd = mk_full_box(b"stsd", 0, 0, &stsd_p);
391        let stts = mk_full_box(b"stts", 0, 0, &0u32.to_be_bytes());
392        let stsc = mk_full_box(b"stsc", 0, 0, &0u32.to_be_bytes());
393        let stsz = mk_full_box(b"stsz", 0, 0, &[0u8; 8]);
394        let stco = mk_full_box(b"stco", 0, 0, &0u32.to_be_bytes());
395        let stbl = mk_box(
396            b"stbl",
397            &[stsd, stts, stsc, stsz, stco].concat(),
398        );
399
400        let minf = mk_box(b"minf", &[vmhd, dinf, stbl].concat());
401        let mdia = mk_box(b"mdia", &[mdhd, hdlr, minf].concat());
402        let trak = mk_box(b"trak", &[tkhd, mdia].concat());
403
404        // trex payload in field order: track_ID, default_sample_description_index, then zeroed
405        // default sample duration, size and flags (each fragment states its own).
406        let mut trex_p = Vec::new();
407        trex_p.extend_from_slice(&1u32.to_be_bytes());
408        trex_p.extend_from_slice(&1u32.to_be_bytes());
409        trex_p.extend_from_slice(&[0u8; 12]);
410        let trex = mk_full_box(b"trex", 0, 0, &trex_p);
411        let mvex = mk_box(b"mvex", &trex);
412
413        let moov = mk_box(b"moov", &[mvhd, trak, mvex].concat());
414
415        self.out.write_all(&ftyp)?;
416        self.out.write_all(&moov)?;
417        self.stats.bytes += (ftyp.len() + moov.len()) as u64;
418        self.wrote_init = true;
419        Ok(())
420    }
421
422    /// Queue one sample at `pts_us` (wall-clock microseconds since recording start),
423    /// flushing the previously buffered sample with its now-known duration. Timestamps are
424    /// clamped strictly monotonic so a repeated or reordered clock can never emit a
425    /// zero/negative duration.
426    pub fn push_sample(&mut self, data: Vec<u8>, sync: bool, pts_us: u64) -> std::io::Result<()> {
427        let mut dts = pts_us * (TIMESCALE as u64 / 1000) / 1000;
428        if let Some(last) = self.last_dts
429            && dts <= last {
430                dts = last + 1;
431            }
432        self.last_dts = Some(dts);
433        if let Some(prev) = self.pending.take() {
434            let duration = (dts - prev.dts).min(u32::MAX as u64) as u32;
435            self.durations.push(duration);
436            self.write_fragment(&prev, duration)?;
437        }
438        self.pending = Some(PendingSample { data, sync, dts });
439        Ok(())
440    }
441
442    /// Median of the observed inter-frame durations (default 1/30 s when fewer than two
443    /// frames were pushed), used to close the final sample.
444    fn median_duration(&self) -> u32 {
445        if self.durations.is_empty() {
446            return DEFAULT_LAST_DURATION;
447        }
448        let mut sorted = self.durations.clone();
449        sorted.sort_unstable();
450        sorted[sorted.len() / 2]
451    }
452
453    fn write_fragment(&mut self, s: &PendingSample, duration: u32) -> std::io::Result<()> {
454        self.seq += 1;
455
456        // tfhd carries only the track_ID; its flags set default-base-is-moof, so sample offsets
457        // are relative to the start of this moof.
458        let mut tfhd_p = Vec::new();
459        tfhd_p.extend_from_slice(&1u32.to_be_bytes());
460        let tfhd = mk_full_box(b"tfhd", 0, 0x020000, &tfhd_p);
461
462        let mut tfdt_p = Vec::new();
463        tfdt_p.extend_from_slice(&s.dts.to_be_bytes());
464        let tfdt = mk_full_box(b"tfdt", 1, 0, &tfdt_p);
465
466        // sample flags: sync = "depends on nothing"; non-sync also sets the non-sync bit.
467        let sample_flags: u32 = if s.sync { 0x0200_0000 } else { 0x0101_0000 };
468        // trun payload in field order: sample_count, a data_offset placeholder patched in below,
469        // then this sample's duration, size and flags — exactly the fields its flags select
470        // (data-offset | sample-duration | sample-size | sample-flags).
471        let mut trun_p = Vec::new();
472        trun_p.extend_from_slice(&1u32.to_be_bytes());
473        trun_p.extend_from_slice(&0i32.to_be_bytes());
474        trun_p.extend_from_slice(&duration.to_be_bytes());
475        trun_p.extend_from_slice(&(s.data.len() as u32).to_be_bytes());
476        trun_p.extend_from_slice(&sample_flags.to_be_bytes());
477        let mut trun = mk_full_box(b"trun", 0, 0x000701, &trun_p);
478
479        let traf_len = 8 + tfhd.len() + tfdt.len() + trun.len();
480        // A moof box header is 8 bytes and the mfhd inside it 16.
481        let moof_len = 8 + 16 + traf_len;
482        // First sample byte sits just past the mdat header, relative to moof start.
483        let data_offset = (moof_len + 8) as i32;
484        // The data_offset field sits in the trun payload directly after sample_count.
485        let off_pos = trun.len() - trun_p.len() + 4;
486        trun[off_pos..off_pos + 4].copy_from_slice(&data_offset.to_be_bytes());
487
488        let mfhd = mk_full_box(b"mfhd", 0, 0, &self.seq.to_be_bytes());
489        let traf = mk_box(b"traf", &[tfhd, tfdt, trun].concat());
490        let moof = mk_box(b"moof", &[mfhd, traf].concat());
491        debug_assert_eq!(moof.len(), moof_len);
492
493        self.out.write_all(&moof)?;
494        self.out.write_all(&((8 + s.data.len()) as u32).to_be_bytes())?;
495        self.out.write_all(b"mdat")?;
496        self.out.write_all(&s.data)?;
497        self.out.flush()?;
498
499        self.stats.samples += 1;
500        if s.sync {
501            self.stats.sync_samples += 1;
502        }
503        self.stats.bytes += (moof.len() + 8 + s.data.len()) as u64;
504        self.stats.duration_us = (s.dts + duration as u64) * 1000 / (TIMESCALE as u64 / 1000);
505        Ok(())
506    }
507
508    /// Flush the final buffered sample, closed with the MEDIAN observed inter-frame
509    /// duration (its successor never arrives), and return the aggregate counters.
510    pub fn finish(mut self) -> std::io::Result<Mp4Stats> {
511        if let Some(prev) = self.pending.take() {
512            let d = self.median_duration();
513            self.write_fragment(&prev, d)?;
514        }
515        self.out.flush()?;
516        Ok(self.stats)
517    }
518}
519
520/// H.264-specific front end: captures SPS/PPS from the stream, gates output on the first
521/// IDR, converts Annex-B access units to AVCC samples, and builds the `avc1` sample entry.
522pub struct H264SampleBuilder {
523    sps: Option<Vec<u8>>,
524    pps: Option<Vec<u8>>,
525}
526
527/// One converted access unit ready for the fragment writer.
528pub struct BuiltSample {
529    pub data: Vec<u8>,
530    pub sync: bool,
531}
532
533impl Default for H264SampleBuilder {
534    fn default() -> Self {
535        Self::new()
536    }
537}
538
539impl H264SampleBuilder {
540    pub fn new() -> Self {
541        Self { sps: None, pps: None }
542    }
543
544    pub fn have_parameter_sets(&self) -> bool {
545        self.sps.is_some() && self.pps.is_some()
546    }
547
548    /// Convert one Annex-B access unit into a length-prefixed AVCC sample, harvesting
549    /// SPS/PPS on the way. Returns `None` for an AU with no slice data (e.g. bare parameter
550    /// sets). `sync` is true when the AU contains an IDR slice.
551    pub fn build_sample(&mut self, annexb: &[u8]) -> Option<BuiltSample> {
552        let nals = split_annexb(annexb);
553        let mut data = Vec::with_capacity(annexb.len() + 8);
554        let mut sync = false;
555        let mut has_slice = false;
556        for nal in nals {
557            if nal.is_empty() {
558                continue;
559            }
560            match nal[0] & 0x1f {
561                7 => {
562                    if self.sps.as_deref() != Some(nal) {
563                        self.sps = Some(nal.to_vec());
564                    }
565                }
566                8 => {
567                    if self.pps.as_deref() != Some(nal) {
568                        self.pps = Some(nal.to_vec());
569                    }
570                }
571                5 => {
572                    sync = true;
573                    has_slice = true;
574                }
575                1 => has_slice = true,
576                _ => {}
577            }
578            data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
579            data.extend_from_slice(nal);
580        }
581        if !has_slice {
582            return None;
583        }
584        Some(BuiltSample { data, sync })
585    }
586
587    /// Build the `avc1` sample entry + `avcC` record from the captured parameter sets, with
588    /// the display dimensions parsed from the SPS.
589    pub fn track_config(&self) -> Option<TrackConfig> {
590        let sps = self.sps.as_deref()?;
591        let pps = self.pps.as_deref()?;
592        let (width, height) = parse_sps_dimensions(sps)?;
593
594        // avcC header in field order: configurationVersion, AVCProfileIndication,
595        // profile_compatibility and AVCLevelIndication taken straight from the SPS,
596        // lengthSizeMinusOne = 3 (the 4-byte NAL lengths this muxer writes), and
597        // numOfSequenceParameterSets = 1. The SPS then the PPS follow, each length-prefixed.
598        let mut avcc_p = vec![1, sps[1], sps[2], sps[3], 0xff, 0xe1];
599        avcc_p.extend_from_slice(&(sps.len() as u16).to_be_bytes());
600        avcc_p.extend_from_slice(sps);
601        // numOfPictureParameterSets
602        avcc_p.push(1);
603        avcc_p.extend_from_slice(&(pps.len() as u16).to_be_bytes());
604        avcc_p.extend_from_slice(pps);
605        let avcc = mk_box(b"avcC", &avcc_p);
606
607        // avc1 sample entry in field order: reserved, data_reference_index, the
608        // pre_defined/reserved block, width and height, horizontal and vertical resolution
609        // (72 dpi), reserved, frame_count, compressorname, depth 24, pre_defined.
610        let mut entry_p = Vec::new();
611        entry_p.extend_from_slice(&[0u8; 6]);
612        entry_p.extend_from_slice(&1u16.to_be_bytes());
613        entry_p.extend_from_slice(&[0u8; 16]);
614        entry_p.extend_from_slice(&(width as u16).to_be_bytes());
615        entry_p.extend_from_slice(&(height as u16).to_be_bytes());
616        entry_p.extend_from_slice(&0x0048_0000u32.to_be_bytes());
617        entry_p.extend_from_slice(&0x0048_0000u32.to_be_bytes());
618        entry_p.extend_from_slice(&[0u8; 4]);
619        entry_p.extend_from_slice(&1u16.to_be_bytes());
620        entry_p.extend_from_slice(&[0u8; 32]);
621        entry_p.extend_from_slice(&0x0018u16.to_be_bytes());
622        entry_p.extend_from_slice(&(-1i16).to_be_bytes());
623        entry_p.extend_from_slice(&avcc);
624        let entry = mk_box(b"avc1", &entry_p);
625
626        Some(TrackConfig { sample_entry: entry, width, height })
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    // x264 SPS at 1284x722 (High 4:2:0: cropping in both axes on odd-macroblock dims).
635    const SPS_HIGH_1284X722: &str = "67640020acd9405105de788c0440000003004000000f03c60c6580";
636    // x264 SPS at 640x360 (Constrained Baseline).
637    const SPS_BASE_640X360: &str = "6742c01ed900a02ff970110000030001000003003c0f162e48";
638    // x264 SPS at 1920x1080 (High 4:4:4 Predictive: chroma_format_idc == 3 path).
639    const SPS_444_1920X1080: &str = "67f40028919b280f0044fc4e0220000003002000000781e30632c0";
640
641    fn hex(s: &str) -> Vec<u8> {
642        (0..s.len())
643            .step_by(2)
644            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
645            .collect()
646    }
647
648    /// Both start-code lengths delimit NALs; payloads come back exactly, with no framing.
649    #[test]
650    fn split_annexb_handles_mixed_start_codes() {
651        let mut data = Vec::new();
652        data.extend_from_slice(&[0, 0, 0, 1, 0x67, 0xAA, 0xBB]);
653        data.extend_from_slice(&[0, 0, 1, 0x68, 0xCC]);
654        data.extend_from_slice(&[0, 0, 0, 1, 0x65, 0x00, 0x00, 0x03, 0x01, 0xDD]);
655        let nals = split_annexb(&data);
656        assert_eq!(nals.len(), 3);
657        assert_eq!(nals[0], &[0x67, 0xAA, 0xBB]);
658        assert_eq!(nals[1], &[0x68, 0xCC]);
659        assert_eq!(nals[2], &[0x65, 0x00, 0x00, 0x03, 0x01, 0xDD]);
660    }
661
662    /// Dimensions from real x264 SPS across the profiles the project's encoders emit,
663    /// including the frame-cropping and 4:4:4 chroma paths.
664    #[test]
665    fn sps_dimensions_across_profiles() {
666        assert_eq!(parse_sps_dimensions(&hex(SPS_HIGH_1284X722)), Some((1284, 722)));
667        assert_eq!(parse_sps_dimensions(&hex(SPS_BASE_640X360)), Some((640, 360)));
668        assert_eq!(parse_sps_dimensions(&hex(SPS_444_1920X1080)), Some((1920, 1080)));
669    }
670
671    /// Annex-B -> AVCC: every NAL is length-prefixed, IDR marks sync, parameter sets are
672    /// harvested, and a parameter-set-only AU yields no sample.
673    #[test]
674    fn annexb_to_avcc_sample() {
675        let sps = hex(SPS_BASE_640X360);
676        let mut au = Vec::new();
677        au.extend_from_slice(&[0, 0, 0, 1]);
678        au.extend_from_slice(&sps);
679        au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
680        au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1, 2, 3, 4]);
681
682        let mut b = H264SampleBuilder::new();
683        assert!(b.build_sample(&[0u8, 0, 0, 1, 0x67, 0x42, 0xc0, 0x1e, 0xd9]).is_none());
684        let s = b.build_sample(&au).expect("IDR AU builds a sample");
685        assert!(s.sync);
686        assert!(b.have_parameter_sets());
687        // Sample = 3 length-prefixed NALs, sizes preserved.
688        let mut off = 0usize;
689        let mut sizes = Vec::new();
690        while off < s.data.len() {
691            let n = u32::from_be_bytes(s.data[off..off + 4].try_into().unwrap()) as usize;
692            sizes.push(n);
693            off += 4 + n;
694        }
695        assert_eq!(off, s.data.len());
696        assert_eq!(sizes, vec![sps.len(), 4, 5]);
697
698        let p = b.build_sample(&[0u8, 0, 0, 1, 0x41, 9, 9]).unwrap();
699        assert!(!p.sync);
700    }
701
702    /// Walk the top-level boxes of a finished two-sample stream: init once, then one
703    /// moof+mdat pair per sample, with sizes that exactly tile the buffer.
704    #[test]
705    fn fragment_stream_box_layout() {
706        let mut b = H264SampleBuilder::new();
707        let mut au = Vec::new();
708        au.extend_from_slice(&[0, 0, 0, 1]);
709        au.extend_from_slice(&hex(SPS_BASE_640X360));
710        au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
711        au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1, 2, 3, 4]);
712        let s1 = b.build_sample(&au).unwrap();
713        let s2 = b.build_sample(&[0u8, 0, 0, 1, 0x41, 5, 6, 7]).unwrap();
714
715        let mut buf = Vec::new();
716        let mut w = FragmentWriter::new(&mut buf);
717        w.write_init(&b.track_config().unwrap()).unwrap();
718        w.push_sample(s1.data, s1.sync, 0).unwrap();
719        w.push_sample(s2.data, s2.sync, 33_000).unwrap();
720        let stats = w.finish().unwrap();
721        assert_eq!(stats.samples, 2);
722        assert_eq!(stats.sync_samples, 1);
723        assert_eq!(stats.bytes as usize, buf.len());
724
725        let mut kinds = Vec::new();
726        let mut off = 0usize;
727        while off < buf.len() {
728            let size = u32::from_be_bytes(buf[off..off + 4].try_into().unwrap()) as usize;
729            kinds.push(buf[off + 4..off + 8].to_vec());
730            assert!(size >= 8 && off + size <= buf.len());
731            off += size;
732        }
733        assert_eq!(off, buf.len());
734        let names: Vec<&str> = kinds.iter().map(|k| std::str::from_utf8(k).unwrap()).collect();
735        assert_eq!(names, vec!["ftyp", "moov", "moof", "mdat", "moof", "mdat"]);
736    }
737
738    /// Every trun sample_duration in stream order (the writer emits one sample per trun).
739    fn trun_durations(buf: &[u8]) -> Vec<u32> {
740        let mut out = Vec::new();
741        let mut off = 0usize;
742        while off + 24 <= buf.len() {
743            if &buf[off + 4..off + 8] == b"trun" {
744                // [size][fourcc][ver+flags][sample_count][data_offset][duration]
745                out.push(u32::from_be_bytes(buf[off + 20..off + 24].try_into().unwrap()));
746            }
747            off += 1;
748        }
749        out
750    }
751
752    /// Each flushed sample's duration is the pts delta to its successor, and the final
753    /// buffered sample closes with the MEDIAN observed duration — not the last delta, which
754    /// under damage-driven capture is an outlier as often as not.
755    #[test]
756    fn sample_durations_are_pts_deltas_with_median_tail() {
757        let mut buf = Vec::new();
758        let mut w = FragmentWriter::new(&mut buf);
759        let cfg = {
760            let mut b = H264SampleBuilder::new();
761            let mut au = vec![0, 0, 0, 1];
762            au.extend_from_slice(&hex(SPS_BASE_640X360));
763            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
764            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
765            b.build_sample(&au);
766            b.track_config().unwrap()
767        };
768        w.write_init(&cfg).unwrap();
769        // 33 ms, 33 ms, then a 300 ms static gap before the final frame.
770        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 0).unwrap();
771        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 33_000).unwrap();
772        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 66_000).unwrap();
773        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 366_000).unwrap();
774        let stats = w.finish().unwrap();
775        assert_eq!(stats.samples, 4);
776        // 90 kHz ticks: 33 ms = 2970. The tail closes at the median (2970), not 27000.
777        assert_eq!(trun_durations(&buf), vec![2970, 2970, 27_000, 2970]);
778    }
779
780    /// A single-frame recording still closes with a sane nonzero duration.
781    #[test]
782    fn single_sample_uses_default_duration() {
783        let mut buf = Vec::new();
784        let mut w = FragmentWriter::new(&mut buf);
785        let cfg = {
786            let mut b = H264SampleBuilder::new();
787            let mut au = vec![0, 0, 0, 1];
788            au.extend_from_slice(&hex(SPS_BASE_640X360));
789            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
790            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
791            b.build_sample(&au);
792            b.track_config().unwrap()
793        };
794        w.write_init(&cfg).unwrap();
795        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 0).unwrap();
796        let stats = w.finish().unwrap();
797        assert_eq!(stats.samples, 1);
798        assert_eq!(trun_durations(&buf), vec![DEFAULT_LAST_DURATION]);
799    }
800
801    /// Non-monotonic wall-clock input is clamped to strictly increasing decode times, so no
802    /// fragment can carry a zero or negative duration.
803    #[test]
804    fn pts_clamped_strictly_monotonic() {
805        let mut buf = Vec::new();
806        let mut w = FragmentWriter::new(&mut buf);
807        let cfg = {
808            let mut b = H264SampleBuilder::new();
809            let mut au = vec![0, 0, 0, 1];
810            au.extend_from_slice(&hex(SPS_BASE_640X360));
811            au.extend_from_slice(&[0, 0, 0, 1, 0x68, 0xCE, 0x38, 0x80]);
812            au.extend_from_slice(&[0, 0, 0, 1, 0x65, 1]);
813            b.build_sample(&au);
814            b.track_config().unwrap()
815        };
816        w.write_init(&cfg).unwrap();
817        w.push_sample(vec![0, 0, 0, 1, 0x65], true, 1000).unwrap();
818        // The second sample repeats the clock and the third winds it backwards.
819        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 1000).unwrap();
820        w.push_sample(vec![0, 0, 0, 1, 0x41], false, 500).unwrap();
821        let stats = w.finish().unwrap();
822        assert_eq!(stats.samples, 3);
823
824        // Extract each tfdt baseMediaDecodeTime and check strict monotonicity.
825        let mut times = Vec::new();
826        let mut off = 0usize;
827        while off + 8 <= buf.len() {
828            if &buf[off + 4..off + 8] == b"tfdt" {
829                let t = u64::from_be_bytes(buf[off + 12..off + 20].try_into().unwrap());
830                times.push(t);
831            }
832            off += 1;
833        }
834        assert_eq!(times.len(), 3);
835        assert!(times.windows(2).all(|w| w[1] > w[0]), "tfdt times: {times:?}");
836    }
837}