Skip to main content

arcly_stream/packager/
fmp4.rs

1//! A native, dependency-free **fragmented-MP4 / CMAF** muxer (`feature = "fmp4"`).
2//!
3//! Implements the [`Muxer`](super::Muxer) trait, producing the two artifacts
4//! LL-HLS fMP4 egress needs:
5//!
6//! * an **initialization segment** (`ftyp` + `moov`, carrying the decoder
7//!   configuration, e.g. `avcC`/`hvcC`) via
8//!   [`init_segment`](super::Muxer::init_segment), referenced once from the
9//!   playlist with `#EXT-X-MAP`; and
10//! * **media fragments** (`moof` + `mdat`) per segment, with a `trun` sample
11//!   table, signed composition-time offsets, and a `tfdt` base-media-decode-time
12//!   for seamless concatenation.
13//!
14//! The **fragment path here is codec-generic** — samples are length-prefixed and
15//! described structurally — so the same `moof`/`mdat` machinery serves any
16//! codec; only the init segment's sample entry is codec-specific. This release
17//! builds init segments for **H.264 (`avc1`)** and, behind their codec features,
18//! **H.265 (`hvc1`, `codec-h265`)**, **AV1 (`av01`, `codec-av1`)** and **VVC
19//! (`vvc1`, `codec-vvc`)** — all via one shared box writer. For H.265 and VVC the
20//! profile-tier-level is read from the SPS, so the `hvcC`/`vvcC` and the HLS
21//! codec string carry real values.
22//!
23//! ```no_run
24//! # #[cfg(feature = "storage-fs")]
25//! # async fn demo(handle: arcly_stream::StreamHandle) -> arcly_stream::Result<()> {
26//! use arcly_stream::packager::{Fmp4Muxer, HlsSegmenter, Packager};
27//! use arcly_stream::storage::FsStorage;
28//!
29//! // 1s parts, 6 segments of LL-HLS, written as init.m4s + segN.m4s.
30//! let mut seg = HlsSegmenter::new(Fmp4Muxer::new(), FsStorage::new("/var/hls"), "live/cam", 6, 5)
31//!     .low_latency(1.0);
32//! let mut sub = handle.subscribe_resilient();
33//! while let Some(frame) = sub.recv().await { seg.push(&frame).await?; }
34//! seg.finish().await?;
35//! # Ok(())
36//! # }
37//! ```
38
39use super::Muxer;
40use crate::{CodecId, MediaFrame, Result, StreamError};
41use bytes::Bytes;
42
43const TIMESCALE: u32 = 1000; // milliseconds — matches MediaFrame pts/dts units.
44const TRACK_ID: u32 = 1;
45const FALLBACK_DURATION: u64 = TIMESCALE as u64 / 30; // ~33 ms when unknown.
46
47/// A buffered sample within the current fragment (stored length-prefixed / AVCC).
48struct Sample {
49    data: Bytes,
50    dts: i64,
51    cts: i32, // pts − dts (composition offset)
52    is_key: bool,
53    duration: Option<u64>,
54}
55
56/// Native fragmented-MP4 muxer for a single video track.
57///
58/// Construct with [`new`](Self::new), hand to an
59/// [`HlsSegmenter`](super::HlsSegmenter) (or drive the [`Muxer`] trait directly).
60/// The init segment is built for **H.264** (`avc1`) and, behind their codec
61/// features, **H.265** (`hvc1`), **AV1** (`av01`) and **VVC** (`vvc1`); the
62/// `moof`/`mdat` fragment path is codec-generic.
63/// `Send` — owned by a single segmenter task. Audio frames are skipped (use a
64/// dedicated audio rendition).
65pub struct Fmp4Muxer {
66    codec: Option<CodecId>,
67    width: u16,
68    height: u16,
69    codec_str: Option<String>,
70    seq: u32,
71    base_decode_time: u64,
72    samples: Vec<Sample>,
73}
74
75impl Default for Fmp4Muxer {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl Fmp4Muxer {
82    /// A new muxer, awaiting the first CONFIG access unit to build its init
83    /// segment.
84    pub fn new() -> Self {
85        Self {
86            codec: None,
87            width: 0,
88            height: 0,
89            codec_str: None,
90            seq: 1,
91            base_decode_time: 0,
92            samples: Vec::new(),
93        }
94    }
95
96    /// Build the `avc1` sample-entry box (with `avcC`) and codec string from an
97    /// Annex-B SPS/PPS config.
98    fn ingest_h264_config(&mut self, config_record: &[u8]) -> Result<Vec<u8>> {
99        let mut sps: Option<&[u8]> = None;
100        let mut pps: Option<&[u8]> = None;
101        for nal in crate::codec::h264::iter_nals_annexb(config_record) {
102            match nal.first().map(|b| b & 0x1f) {
103                Some(t) if t == crate::codec::h264::NAL_SPS => sps = Some(nal),
104                Some(t) if t == crate::codec::h264::NAL_PPS => pps = Some(nal),
105                _ => {}
106            }
107        }
108        let sps = sps.ok_or_else(|| StreamError::codec("fmp4: no SPS in H.264 config"))?;
109        let pps = pps.ok_or_else(|| StreamError::codec("fmp4: no PPS in H.264 config"))?;
110        if sps.len() < 4 {
111            return Err(StreamError::codec("fmp4: truncated SPS"));
112        }
113        let info = crate::codec::h264::parse_sps(sps)
114            .ok_or_else(|| StreamError::codec("fmp4: unparseable SPS"))?;
115        self.width = info.width as u16;
116        self.height = info.height as u16;
117        // avc1.PPCCLL — profile / constraint flags / level, from the SPS bytes.
118        self.codec_str = Some(format!("avc1.{:02X}{:02X}{:02X}", sps[1], sps[2], sps[3]));
119
120        // avcC (AVCDecoderConfigurationRecord).
121        let mut c = Vec::with_capacity(16 + sps.len() + pps.len());
122        c.push(1); // configurationVersion
123        c.push(sps[1]); // AVCProfileIndication
124        c.push(sps[2]); // profile_compatibility
125        c.push(sps[3]); // AVCLevelIndication
126        c.push(0xFF); // 6 reserved bits | lengthSizeMinusOne = 3
127        c.push(0xE1); // 3 reserved bits | numOfSPS = 1
128        c.extend_from_slice(&(sps.len() as u16).to_be_bytes());
129        c.extend_from_slice(sps);
130        c.push(1); // numOfPPS
131        c.extend_from_slice(&(pps.len() as u16).to_be_bytes());
132        c.extend_from_slice(pps);
133        Ok(build_avc1(self.width, self.height, &c))
134    }
135
136    /// Build the `hvc1` sample-entry box (with `hvcC`) and codec string from an
137    /// Annex-B VPS/SPS/PPS config access unit.
138    #[cfg(feature = "codec-h265")]
139    fn ingest_h265_config(&mut self, config_record: &[u8]) -> Result<Vec<u8>> {
140        use crate::codec::{h265::H265, CodecParser};
141        let (params, hvcc) = crate::codec::h265::hvcc_config_record(config_record)
142            .ok_or_else(|| StreamError::codec("fmp4: no parsable SPS in H.265 config"))?;
143        self.width = params.width as u16;
144        self.height = params.height as u16;
145        self.codec_str = Some(H265::hls_codec_string(&params));
146        Ok(build_hvc1(self.width, self.height, &hvcc))
147    }
148
149    /// Build the `av01` sample-entry box (with `av1C`) and codec string from an
150    /// AV1 sequence-header config access unit.
151    #[cfg(feature = "codec-av1")]
152    fn ingest_av1_config(&mut self, config_record: &[u8]) -> Result<Vec<u8>> {
153        use crate::codec::{av1::Av1, CodecParser};
154        let (params, av1c) = crate::codec::av1::av1c_config_record(config_record)
155            .ok_or_else(|| StreamError::codec("fmp4: no AV1 sequence header in config"))?;
156        self.width = params.width as u16;
157        self.height = params.height as u16;
158        self.codec_str = Some(Av1::hls_codec_string(&params));
159        Ok(build_av01(self.width, self.height, &av1c))
160    }
161
162    /// Build the `vvc1` sample-entry box (with `vvcC`) and codec string from a
163    /// VVC parameter-set config access unit.
164    #[cfg(feature = "codec-vvc")]
165    fn ingest_vvc_config(&mut self, config_record: &[u8]) -> Result<Vec<u8>> {
166        use crate::codec::{vvc::Vvc, CodecParser};
167        let (params, vvcc) =
168            crate::codec::vvc::vvcc_config_record(config_record).ok_or_else(|| {
169                StreamError::codec(
170                    "fmp4: VVC config needs an SPS without a PTL block (parser limit)",
171                )
172            })?;
173        self.width = params.width as u16;
174        self.height = params.height as u16;
175        self.codec_str = Some(Vvc::hls_codec_string(&params));
176        Ok(build_vvc1(self.width, self.height, &vvcc))
177    }
178
179    /// Per-sample durations: inter-DTS deltas, with the last sample falling back
180    /// to its declared frame duration (or the previous delta).
181    fn durations(&self) -> Vec<u64> {
182        let n = self.samples.len();
183        let mut out = Vec::with_capacity(n);
184        for i in 0..n {
185            let dur = if i + 1 < n {
186                (self.samples[i + 1].dts - self.samples[i].dts).max(0) as u64
187            } else {
188                self.samples[i]
189                    .duration
190                    .or_else(|| out.last().copied())
191                    .unwrap_or(FALLBACK_DURATION)
192            };
193            out.push(dur);
194        }
195        out
196    }
197}
198
199impl Muxer for Fmp4Muxer {
200    fn extension(&self) -> &'static str {
201        "m4s"
202    }
203
204    fn start_segment(&mut self) -> Result<()> {
205        self.samples.clear();
206        Ok(())
207    }
208
209    fn write(&mut self, frame: &MediaFrame) -> Result<()> {
210        // Single video track; skip audio (use a separate audio rendition).
211        if !frame.is_video() {
212            return Ok(());
213        }
214        // NAL codecs (H.264, VVC) are length-prefixed (4-byte) in mp4, not
215        // Annex-B; AV1 temporal units are already in the size-delimited form
216        // mp4 carries.
217        let is_nal = matches!(
218            self.codec,
219            Some(CodecId::H264) | Some(CodecId::H265) | Some(CodecId::VVC)
220        );
221        let data = if is_nal {
222            crate::codec::h264::annexb_to_avcc(&frame.data) // codec-agnostic NAL → 4-byte length
223        } else {
224            frame.data.clone()
225        };
226        if data.is_empty() {
227            return Ok(());
228        }
229        self.samples.push(Sample {
230            data,
231            dts: frame.dts,
232            cts: (frame.pts - frame.dts) as i32,
233            is_key: frame.is_keyframe(),
234            duration: frame.duration,
235        });
236        Ok(())
237    }
238
239    fn finish_segment(&mut self) -> Result<Bytes> {
240        if self.samples.is_empty() {
241            return Ok(Bytes::new());
242        }
243        let durations = self.durations();
244        let total: u64 = durations.iter().sum();
245
246        let moof = build_moof(self.seq, self.base_decode_time, &self.samples, &durations);
247        let mut out = moof;
248        // mdat box wrapping the concatenated sample data.
249        let mdat_len: usize = self.samples.iter().map(|s| s.data.len()).sum();
250        out.extend_from_slice(&((mdat_len + 8) as u32).to_be_bytes());
251        out.extend_from_slice(b"mdat");
252        for s in &self.samples {
253            out.extend_from_slice(&s.data);
254        }
255
256        self.seq += 1;
257        self.base_decode_time += total;
258        self.samples.clear();
259        Ok(Bytes::from(out))
260    }
261
262    fn init_segment(&mut self, codec: CodecId, config_record: &[u8]) -> Result<Option<Bytes>> {
263        let sample_entry = match codec {
264            CodecId::H264 => self.ingest_h264_config(config_record)?,
265            #[cfg(feature = "codec-h265")]
266            CodecId::H265 => self.ingest_h265_config(config_record)?,
267            #[cfg(feature = "codec-av1")]
268            CodecId::AV1 => self.ingest_av1_config(config_record)?,
269            #[cfg(feature = "codec-vvc")]
270            CodecId::VVC => self.ingest_vvc_config(config_record)?,
271            _ => {
272                return Err(StreamError::UnsupportedCodec(format!(
273                    "fmp4 init segment: {codec:?} not supported in this build"
274                )))
275            }
276        };
277        self.codec = Some(codec);
278        let mut seg = build_ftyp();
279        seg.extend_from_slice(&build_moov(self.width, self.height, &sample_entry));
280        Ok(Some(Bytes::from(seg)))
281    }
282
283    fn codec_string(&self) -> Option<String> {
284        self.codec_str.clone()
285    }
286}
287
288// ── ISO-BMFF box writers ─────────────────────────────────────────────────────
289
290/// `size(4) + type(4) + body`.
291fn bx(typ: &[u8; 4], body: &[u8]) -> Vec<u8> {
292    let mut v = Vec::with_capacity(8 + body.len());
293    v.extend_from_slice(&((body.len() + 8) as u32).to_be_bytes());
294    v.extend_from_slice(typ);
295    v.extend_from_slice(body);
296    v
297}
298
299/// A FullBox: `size + type + (version<<24 | flags) + body`.
300fn full(typ: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Vec<u8> {
301    let mut b = Vec::with_capacity(4 + body.len());
302    b.extend_from_slice(&(((version as u32) << 24) | (flags & 0x00FF_FFFF)).to_be_bytes());
303    b.extend_from_slice(body);
304    bx(typ, &b)
305}
306
307const MATRIX_IDENTITY: [u32; 9] = [0x0001_0000, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000];
308
309fn put_matrix(v: &mut Vec<u8>) {
310    for m in MATRIX_IDENTITY {
311        v.extend_from_slice(&m.to_be_bytes());
312    }
313}
314
315fn build_ftyp() -> Vec<u8> {
316    let mut b = Vec::new();
317    b.extend_from_slice(b"iso5"); // major_brand
318    b.extend_from_slice(&0u32.to_be_bytes()); // minor_version
319    for brand in [b"iso5", b"iso6", b"mp41"] {
320        b.extend_from_slice(brand);
321    }
322    bx(b"ftyp", &b)
323}
324
325/// Build `moov` around a prebuilt visual sample entry (`avc1` / `av01` box).
326fn build_moov(width: u16, height: u16, entry: &[u8]) -> Vec<u8> {
327    let mut body = Vec::new();
328    body.extend_from_slice(&build_mvhd());
329    body.extend_from_slice(&build_trak(width, height, entry));
330    body.extend_from_slice(&build_mvex());
331    bx(b"moov", &body)
332}
333
334fn build_mvhd() -> Vec<u8> {
335    let mut b = Vec::new();
336    b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
337    b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
338    b.extend_from_slice(&TIMESCALE.to_be_bytes());
339    b.extend_from_slice(&0u32.to_be_bytes()); // duration (fragmented → 0)
340    b.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0
341    b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0
342    b.extend_from_slice(&0u16.to_be_bytes()); // reserved
343    b.extend_from_slice(&[0u8; 8]); // reserved
344    put_matrix(&mut b);
345    b.extend_from_slice(&[0u8; 24]); // pre_defined
346    b.extend_from_slice(&2u32.to_be_bytes()); // next_track_ID
347    full(b"mvhd", 0, 0, &b)
348}
349
350fn build_trak(width: u16, height: u16, entry: &[u8]) -> Vec<u8> {
351    let mut b = Vec::new();
352    b.extend_from_slice(&build_tkhd(width, height));
353    b.extend_from_slice(&build_mdia(entry));
354    bx(b"trak", &b)
355}
356
357fn build_tkhd(width: u16, height: u16) -> Vec<u8> {
358    let mut b = Vec::new();
359    b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
360    b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
361    b.extend_from_slice(&TRACK_ID.to_be_bytes());
362    b.extend_from_slice(&0u32.to_be_bytes()); // reserved
363    b.extend_from_slice(&0u32.to_be_bytes()); // duration
364    b.extend_from_slice(&[0u8; 8]); // reserved
365    b.extend_from_slice(&0u16.to_be_bytes()); // layer
366    b.extend_from_slice(&0u16.to_be_bytes()); // alternate_group
367    b.extend_from_slice(&0u16.to_be_bytes()); // volume (0 for video)
368    b.extend_from_slice(&0u16.to_be_bytes()); // reserved
369    put_matrix(&mut b);
370    b.extend_from_slice(&((width as u32) << 16).to_be_bytes()); // 16.16 width
371    b.extend_from_slice(&((height as u32) << 16).to_be_bytes()); // 16.16 height
372    full(b"tkhd", 0, 0x0000_0007, &b) // enabled | in_movie | in_preview
373}
374
375fn build_mdia(entry: &[u8]) -> Vec<u8> {
376    let mut b = Vec::new();
377    b.extend_from_slice(&build_mdhd());
378    b.extend_from_slice(&build_hdlr());
379    b.extend_from_slice(&build_minf(entry));
380    bx(b"mdia", &b)
381}
382
383fn build_mdhd() -> Vec<u8> {
384    let mut b = Vec::new();
385    b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
386    b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
387    b.extend_from_slice(&TIMESCALE.to_be_bytes());
388    b.extend_from_slice(&0u32.to_be_bytes()); // duration
389    b.extend_from_slice(&0x55C4u16.to_be_bytes()); // language 'und'
390    b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
391    full(b"mdhd", 0, 0, &b)
392}
393
394fn build_hdlr() -> Vec<u8> {
395    let mut b = Vec::new();
396    b.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
397    b.extend_from_slice(b"vide"); // handler_type
398    b.extend_from_slice(&[0u8; 12]); // reserved
399    b.extend_from_slice(b"VideoHandler\0");
400    full(b"hdlr", 0, 0, &b)
401}
402
403fn build_minf(entry: &[u8]) -> Vec<u8> {
404    let mut b = Vec::new();
405    b.extend_from_slice(&full(b"vmhd", 0, 1, &[0u8; 8])); // graphicsmode + opcolor
406    b.extend_from_slice(&build_dinf());
407    b.extend_from_slice(&build_stbl(entry));
408    bx(b"minf", &b)
409}
410
411fn build_dinf() -> Vec<u8> {
412    // dref with a single self-contained 'url ' entry (flags = 1).
413    let url = full(b"url ", 0, 1, &[]);
414    let mut dref_body = Vec::new();
415    dref_body.extend_from_slice(&1u32.to_be_bytes()); // entry_count
416    dref_body.extend_from_slice(&url);
417    let dref = full(b"dref", 0, 0, &dref_body);
418    bx(b"dinf", &dref)
419}
420
421fn build_stbl(entry: &[u8]) -> Vec<u8> {
422    let mut b = Vec::new();
423    b.extend_from_slice(&build_stsd(entry));
424    b.extend_from_slice(&full(b"stts", 0, 0, &0u32.to_be_bytes())); // entry_count 0
425    b.extend_from_slice(&full(b"stsc", 0, 0, &0u32.to_be_bytes()));
426    let mut stsz = Vec::new();
427    stsz.extend_from_slice(&0u32.to_be_bytes()); // sample_size
428    stsz.extend_from_slice(&0u32.to_be_bytes()); // sample_count
429    b.extend_from_slice(&full(b"stsz", 0, 0, &stsz));
430    b.extend_from_slice(&full(b"stco", 0, 0, &0u32.to_be_bytes()));
431    bx(b"stbl", &b)
432}
433
434fn build_stsd(entry: &[u8]) -> Vec<u8> {
435    let mut b = Vec::new();
436    b.extend_from_slice(&1u32.to_be_bytes()); // entry_count
437    b.extend_from_slice(entry);
438    full(b"stsd", 0, 0, &b)
439}
440
441/// The common `VisualSampleEntry` header shared by `avc1` / `av01`, ending with
442/// the codec-specific configuration box (`avcC` / `av1C`).
443fn visual_sample_entry(typ: &[u8; 4], width: u16, height: u16, config_box: &[u8]) -> Vec<u8> {
444    let mut b = Vec::new();
445    b.extend_from_slice(&[0u8; 6]); // reserved
446    b.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
447    b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
448    b.extend_from_slice(&0u16.to_be_bytes()); // reserved
449    b.extend_from_slice(&[0u8; 12]); // pre_defined[3]
450    b.extend_from_slice(&width.to_be_bytes());
451    b.extend_from_slice(&height.to_be_bytes());
452    b.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // horizresolution 72dpi
453    b.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // vertresolution 72dpi
454    b.extend_from_slice(&0u32.to_be_bytes()); // reserved
455    b.extend_from_slice(&1u16.to_be_bytes()); // frame_count
456    b.extend_from_slice(&[0u8; 32]); // compressorname
457    b.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
458    b.extend_from_slice(&0xFFFFu16.to_be_bytes()); // pre_defined = -1
459    b.extend_from_slice(config_box);
460    bx(typ, &b)
461}
462
463/// `avc1` sample entry wrapping an `avcC` (AVCDecoderConfigurationRecord) body.
464fn build_avc1(width: u16, height: u16, avcc: &[u8]) -> Vec<u8> {
465    visual_sample_entry(b"avc1", width, height, &bx(b"avcC", avcc))
466}
467
468/// `av01` sample entry wrapping an `av1C` (AV1CodecConfigurationRecord) body.
469#[cfg(feature = "codec-av1")]
470fn build_av01(width: u16, height: u16, av1c: &[u8]) -> Vec<u8> {
471    visual_sample_entry(b"av01", width, height, &bx(b"av1C", av1c))
472}
473
474/// `vvc1` sample entry wrapping a `vvcC` (VvcDecoderConfigurationRecord) body.
475#[cfg(feature = "codec-vvc")]
476fn build_vvc1(width: u16, height: u16, vvcc: &[u8]) -> Vec<u8> {
477    visual_sample_entry(b"vvc1", width, height, &bx(b"vvcC", vvcc))
478}
479
480/// `hvc1` sample entry wrapping an `hvcC` (HEVCDecoderConfigurationRecord) body.
481#[cfg(feature = "codec-h265")]
482fn build_hvc1(width: u16, height: u16, hvcc: &[u8]) -> Vec<u8> {
483    visual_sample_entry(b"hvc1", width, height, &bx(b"hvcC", hvcc))
484}
485
486fn build_mvex() -> Vec<u8> {
487    let mut trex = Vec::new();
488    trex.extend_from_slice(&TRACK_ID.to_be_bytes());
489    trex.extend_from_slice(&1u32.to_be_bytes()); // default_sample_description_index
490    trex.extend_from_slice(&0u32.to_be_bytes()); // default_sample_duration
491    trex.extend_from_slice(&0u32.to_be_bytes()); // default_sample_size
492    trex.extend_from_slice(&0u32.to_be_bytes()); // default_sample_flags
493    bx(b"mvex", &full(b"trex", 0, 0, &trex))
494}
495
496/// Sync sample (keyframe) flags vs. a non-sync delta sample.
497fn sample_flags(is_key: bool) -> u32 {
498    if is_key {
499        0x0200_0000 // sample_depends_on = 2 (I-frame), is_non_sync = 0
500    } else {
501        0x0101_0000 // sample_depends_on = 1, sample_is_non_sync_sample = 1
502    }
503}
504
505fn build_moof(seq: u32, base_decode_time: u64, samples: &[Sample], durations: &[u64]) -> Vec<u8> {
506    // mfhd
507    let mfhd = full(b"mfhd", 0, 0, &seq.to_be_bytes());
508
509    // tfhd: default-base-is-moof so data_offset is relative to the moof start.
510    let tfhd = full(b"tfhd", 0, 0x0002_0000, &TRACK_ID.to_be_bytes());
511
512    // tfdt v1: base media decode time.
513    let tfdt = full(b"tfdt", 1, 0, &base_decode_time.to_be_bytes());
514
515    // trun v1: per-sample duration, size, flags, signed composition offset.
516    let trun_flags: u32 = 0x0001 | 0x0100 | 0x0200 | 0x0400 | 0x0800;
517    let mut trun_body = Vec::new();
518    trun_body.extend_from_slice(&(samples.len() as u32).to_be_bytes()); // sample_count
519    let data_offset_pos_in_body = trun_body.len();
520    trun_body.extend_from_slice(&0i32.to_be_bytes()); // data_offset (patched below)
521    for (s, &dur) in samples.iter().zip(durations) {
522        trun_body.extend_from_slice(&(dur as u32).to_be_bytes());
523        trun_body.extend_from_slice(&(s.data.len() as u32).to_be_bytes());
524        trun_body.extend_from_slice(&sample_flags(s.is_key).to_be_bytes());
525        trun_body.extend_from_slice(&s.cts.to_be_bytes());
526    }
527    let trun = full(b"trun", 1, trun_flags, &trun_body);
528
529    // traf = tfhd + tfdt + trun
530    let mut traf_body = Vec::new();
531    traf_body.extend_from_slice(&tfhd);
532    traf_body.extend_from_slice(&tfdt);
533    let trun_pos_in_traf_body = traf_body.len();
534    traf_body.extend_from_slice(&trun);
535    let traf = bx(b"traf", &traf_body);
536
537    // moof = mfhd + traf
538    let mut moof_body = Vec::new();
539    moof_body.extend_from_slice(&mfhd);
540    let traf_pos_in_moof_body = moof_body.len();
541    moof_body.extend_from_slice(&traf);
542    let mut moof = bx(b"moof", &moof_body);
543
544    // Patch trun.data_offset = moof size + mdat header (8) so it points at the
545    // first sample byte in the following mdat.
546    // Absolute index of the data_offset field within `moof`:
547    //   moof header(8) + traf_pos_in_moof_body + traf header(8)
548    //     + trun_pos_in_traf_body + trun header+fullbox(12) + data_offset_pos_in_body
549    let off = 8 + traf_pos_in_moof_body + 8 + trun_pos_in_traf_body + 12 + data_offset_pos_in_body;
550    let data_offset = (moof.len() + 8) as i32;
551    moof[off..off + 4].copy_from_slice(&data_offset.to_be_bytes());
552    moof
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::{CodecId, FrameFlags, MediaFrame};
559    use bytes::Bytes;
560
561    // A real baseline SPS (profile 66, level 31) + a minimal PPS, Annex-B framed.
562    fn h264_config() -> Vec<u8> {
563        let sps = [0x67u8, 0x42, 0x00, 0x1F, 0xF4, 0x02, 0x80, 0x2D, 0x80];
564        let pps = [0x68u8, 0xCE, 0x3C, 0x80];
565        let mut v = vec![0, 0, 0, 1];
566        v.extend_from_slice(&sps);
567        v.extend_from_slice(&[0, 0, 0, 1]);
568        v.extend_from_slice(&pps);
569        v
570    }
571
572    fn annexb_frame(pts: i64, is_key: bool) -> MediaFrame {
573        let nal_type = if is_key { 0x65 } else { 0x41 }; // IDR vs non-IDR slice
574        let data = Bytes::from(vec![0, 0, 0, 1, nal_type, 0xAA, 0xBB]);
575        MediaFrame::new_video(pts, pts, data, CodecId::H264, is_key)
576    }
577
578    /// Walk the top-level boxes and assert their sizes tile the buffer exactly.
579    fn box_types(buf: &[u8]) -> Vec<String> {
580        let mut types = Vec::new();
581        let mut i = 0;
582        while i + 8 <= buf.len() {
583            let size = u32::from_be_bytes(buf[i..i + 4].try_into().unwrap()) as usize;
584            let typ = String::from_utf8_lossy(&buf[i + 4..i + 8]).to_string();
585            types.push(typ);
586            assert!(size >= 8 && i + size <= buf.len(), "box size out of range");
587            i += size;
588        }
589        assert_eq!(i, buf.len(), "boxes must tile the buffer exactly");
590        types
591    }
592
593    #[test]
594    fn init_segment_has_ftyp_moov_and_codec_string() {
595        let mut m = Fmp4Muxer::new();
596        let init = m
597            .init_segment(CodecId::H264, &h264_config())
598            .unwrap()
599            .expect("init segment");
600        assert_eq!(box_types(&init), vec!["ftyp", "moov"]);
601        // Sample entry + decoder config present.
602        assert!(find(&init, b"avc1"));
603        assert!(find(&init, b"avcC"));
604        assert!(find(&init, b"mvex"));
605        assert_eq!(m.codec_string().as_deref(), Some("avc1.42001F"));
606    }
607
608    #[test]
609    fn unsupported_codec_init_is_rejected() {
610        let mut m = Fmp4Muxer::new();
611        // VP9 has no fMP4 init-segment support in this release.
612        assert!(m.init_segment(CodecId::VP9, &[0, 0]).is_err());
613    }
614
615    #[cfg(feature = "codec-h265")]
616    #[test]
617    fn h265_init_segment_has_hvc1_and_hvcc() {
618        use crate::codec::testutil::BitWriter;
619
620        // Minimal HEVC SPS for 1920x1088 (Main, level 4.0), mirroring the codec
621        // module's fixture, plus a VPS and PPS so the hvcC carries all arrays.
622        let mut w = BitWriter::default();
623        w.bits(0, 4); // sps_video_parameter_set_id
624        w.bits(0, 3); // sps_max_sub_layers_minus1
625        w.bit(0); // sps_temporal_id_nesting_flag
626        w.bits(0, 2); // general_profile_space
627        w.bit(0); // general_tier_flag
628        w.bits(1, 5); // general_profile_idc = Main
629        w.bits(0, 32); // compatibility flags
630        w.bits(0, 32); // constraint (hi)
631        w.bits(0, 16); // constraint (lo)
632        w.bits(120, 8); // general_level_idc
633        w.ue(0); // sps_seq_parameter_set_id
634        w.ue(1); // chroma_format_idc
635        w.ue(1920); // pic_width
636        w.ue(1080); // pic_height
637        w.bit(0); // conformance_window_flag
638        let mut sps = vec![0x42u8, 0x01];
639        sps.extend_from_slice(&w.bytes());
640
641        let mut config = vec![0, 0, 0, 1, 0x40, 0x01, 0xAA]; // VPS
642        config.extend_from_slice(&[0, 0, 0, 1]);
643        config.extend_from_slice(&sps);
644        config.extend_from_slice(&[0, 0, 0, 1, 0x44, 0x01, 0xBB]); // PPS
645
646        let mut m = Fmp4Muxer::new();
647        let init = m
648            .init_segment(CodecId::H265, &config)
649            .unwrap()
650            .expect("init segment");
651        assert_eq!(box_types(&init), vec!["ftyp", "moov"]);
652        assert!(find(&init, b"hvc1"), "hvc1 sample entry present");
653        assert!(find(&init, b"hvcC"), "hvcC decoder config present");
654        assert!(
655            m.codec_string()
656                .as_deref()
657                .is_some_and(|s| s.starts_with("hvc1.")),
658            "HEVC codec string"
659        );
660    }
661
662    #[cfg(feature = "codec-av1")]
663    #[test]
664    fn av1_init_segment_has_av01_and_av1c() {
665        use crate::codec::testutil::BitWriter;
666
667        // A sequence-header OBU for 1920x1080, profile 0 (mirrors the AV1 parser
668        // test), wrapped as a size-delimited temporal unit.
669        let mut w = BitWriter::default();
670        w.bits(0, 3); // seq_profile
671        w.bit(0); // still_picture
672        w.bit(0); // reduced_still_picture_header
673        w.bit(0); // timing_info_present_flag
674        w.bit(0); // initial_display_delay_present_flag
675        w.bits(0, 5); // operating_points_cnt_minus1
676        w.bits(0, 12); // operating_point_idc[0]
677        w.bits(1, 5); // seq_level_idx[0]
678        w.bits(11, 4); // frame_width_bits_minus_1
679        w.bits(11, 4); // frame_height_bits_minus_1
680        w.bits(1919, 12);
681        w.bits(1079, 12);
682        w.align();
683        let payload = w.bytes();
684        let mut config = vec![0x0A, payload.len() as u8];
685        config.extend_from_slice(&payload);
686
687        let mut m = Fmp4Muxer::new();
688        let init = m
689            .init_segment(CodecId::AV1, &config)
690            .unwrap()
691            .expect("av1 init segment");
692        assert_eq!(box_types(&init), vec!["ftyp", "moov"]);
693        assert!(find(&init, b"av01"), "av01 sample entry");
694        assert!(find(&init, b"av1C"), "av1C config box");
695        assert_eq!(m.codec_string().as_deref(), Some("av01.0.01M.08"));
696
697        // AV1 temporal units are muxed verbatim (no Annex-B → AVCC rewrite).
698        m.start_segment().unwrap();
699        let mut frame = MediaFrame::new_video(
700            0,
701            0,
702            Bytes::from(vec![0x32, 0x02, 0xAA, 0xBB]), // OBU_FRAME
703            CodecId::AV1,
704            true,
705        );
706        frame.duration = Some(33);
707        m.write(&frame).unwrap();
708        let frag = m.finish_segment().unwrap();
709        assert_eq!(box_types(&frag), vec!["moof", "mdat"]);
710        // mdat carries the OBU bytes unchanged.
711        assert!(find(&frag, &[0x32, 0x02, 0xAA, 0xBB]));
712    }
713
714    #[test]
715    fn fragment_has_moof_mdat_and_correct_sample_count() {
716        let mut m = Fmp4Muxer::new();
717        m.init_segment(CodecId::H264, &h264_config()).unwrap();
718        m.start_segment().unwrap();
719        m.write(&annexb_frame(0, true)).unwrap();
720        m.write(&annexb_frame(40, false)).unwrap();
721        m.write(&annexb_frame(80, false)).unwrap();
722        // An audio frame is skipped, not muxed into the video track.
723        let audio = MediaFrame::new_audio(80, Bytes::from_static(b"aac"), CodecId::AAC);
724        m.write(&audio).unwrap();
725
726        let frag = m.finish_segment().unwrap();
727        assert_eq!(box_types(&frag), vec!["moof", "mdat"]);
728        assert!(find(&frag, b"tfdt"));
729        assert!(find(&frag, b"trun"));
730
731        // sample_count is the u32 right after the trun full-box header.
732        let trun_at = position(&frag, b"trun").expect("trun present");
733        let count = u32::from_be_bytes(frag[trun_at + 8..trun_at + 12].try_into().unwrap());
734        assert_eq!(count, 3, "three video samples, audio skipped");
735    }
736
737    #[test]
738    fn base_decode_time_advances_across_fragments() {
739        let mut m = Fmp4Muxer::new();
740        m.init_segment(CodecId::H264, &h264_config()).unwrap();
741
742        m.start_segment().unwrap();
743        m.write(&annexb_frame(0, true)).unwrap();
744        m.write(&annexb_frame(40, false)).unwrap();
745        let _ = m.finish_segment().unwrap();
746        // Sample 0 = 40ms (DTS delta); last sample falls back to the prior delta
747        // (40ms) → fragment total 80ms.
748        assert_eq!(m.base_decode_time, 80);
749
750        m.start_segment().unwrap();
751        m.write(&annexb_frame(80, true)).unwrap();
752        let frag2 = m.finish_segment().unwrap();
753        // tfdt in fragment 2 carries the advanced base decode time.
754        let tfdt_at = position(&frag2, b"tfdt").unwrap();
755        let base = u64::from_be_bytes(frag2[tfdt_at + 8..tfdt_at + 16].try_into().unwrap());
756        assert_eq!(base, 80);
757    }
758
759    #[cfg(feature = "codec-vvc")]
760    #[test]
761    fn vvc_init_segment_has_vvc1_and_vvcc() {
762        use crate::codec::testutil::BitWriter;
763
764        // VVC SPS for 1920x1080, 4:2:0, PTL block absent (mirrors the VVC test).
765        let mut w = BitWriter::default();
766        w.bits(0, 4); // sps_seq_parameter_set_id
767        w.bits(0, 4); // sps_video_parameter_set_id
768        w.bits(0, 3); // sps_max_sublayers_minus1
769        w.bits(1, 2); // chroma_format_idc = 4:2:0
770        w.bits(0, 2); // sps_log2_ctu_size_minus5
771        w.bit(0); // sps_ptl_dpb_hrd_params_present_flag = 0
772        w.bit(0); // sps_gdr_enabled_flag
773        w.bit(0); // sps_ref_pic_resampling_enabled_flag
774        w.ue(1920);
775        w.ue(1080);
776        w.bit(0); // sps_conformance_window_flag
777        let mut sps = vec![0x00u8, 0x79]; // NAL header: nuh_unit_type 15 (SPS)
778        sps.extend_from_slice(&w.bytes());
779
780        let mut config = vec![0, 0, 0, 1];
781        config.extend_from_slice(&sps);
782
783        let mut m = Fmp4Muxer::new();
784        let init = m
785            .init_segment(CodecId::VVC, &config)
786            .unwrap()
787            .expect("vvc init segment");
788        assert_eq!(box_types(&init), vec!["ftyp", "moov"]);
789        assert!(find(&init, b"vvc1"), "vvc1 sample entry");
790        assert!(find(&init, b"vvcC"), "vvcC config box");
791        assert_eq!(m.codec_string().as_deref(), Some("vvc1.0.L0"));
792
793        // VVC samples are length-prefixed (4-byte) like H.264.
794        m.start_segment().unwrap();
795        let mut frame = MediaFrame::new_video(
796            0,
797            0,
798            Bytes::from(vec![0, 0, 0, 1, 0x00, 0x39, 0xAA]), // IDR_W_RADL Annex-B
799            CodecId::VVC,
800            true,
801        );
802        frame.duration = Some(40);
803        m.write(&frame).unwrap();
804        let frag = m.finish_segment().unwrap();
805        assert_eq!(box_types(&frag), vec!["moof", "mdat"]);
806        // The NAL was rewritten to a 4-byte length prefix (len 3 → 00 00 00 03).
807        assert!(find(&frag, &[0x00, 0x00, 0x00, 0x03, 0x00, 0x39, 0xAA]));
808    }
809
810    #[test]
811    fn config_without_sps_errors() {
812        let mut m = Fmp4Muxer::new();
813        // PPS only — no SPS.
814        let cfg = vec![0, 0, 0, 1, 0x68, 0xCE, 0x3C, 0x80];
815        assert!(m.init_segment(CodecId::H264, &cfg).is_err());
816    }
817
818    #[test]
819    fn empty_fragment_yields_no_bytes() {
820        let mut m = Fmp4Muxer::new();
821        m.start_segment().unwrap();
822        assert!(m.finish_segment().unwrap().is_empty());
823    }
824
825    fn find(buf: &[u8], needle: &[u8]) -> bool {
826        position(buf, needle).is_some()
827    }
828    fn position(buf: &[u8], needle: &[u8]) -> Option<usize> {
829        buf.windows(needle.len()).position(|w| w == needle)
830    }
831
832    #[test]
833    fn integrates_with_segmenter_via_ext_x_map() {
834        use crate::packager::{HlsSegmenter, Packager};
835        use crate::testing::InMemoryStorage;
836        use crate::traits::StorageBackend;
837
838        tokio_test_block(async {
839            let store = InMemoryStorage::new();
840            let mut seg = HlsSegmenter::new(Fmp4Muxer::new(), store.clone(), "live/fmp4", 2, 5);
841
842            let mut cfg =
843                MediaFrame::new_video(0, 0, Bytes::from(h264_config()), CodecId::H264, true);
844            cfg.flags |= FrameFlags::CONFIG;
845            seg.push(&cfg).await.unwrap();
846            for i in 0..6 {
847                seg.push(&annexb_frame(i * 1000, true)).await.unwrap();
848            }
849            seg.finish().await.unwrap();
850
851            assert!(store.get("live/fmp4/init.m4s").await.is_ok());
852            let pl = String::from_utf8(store.get("live/fmp4/index.m3u8").await.unwrap().to_vec())
853                .unwrap();
854            assert!(pl.contains("#EXT-X-MAP:URI=\"init.m4s\""));
855        });
856    }
857
858    fn tokio_test_block<F: std::future::Future>(fut: F) -> F::Output {
859        tokio::runtime::Builder::new_current_thread()
860            .enable_all()
861            .build()
862            .unwrap()
863            .block_on(fut)
864    }
865}