Skip to main content

arcly_stream/packager/
mod.rs

1//! Egress packaging: turn a live frame stream into HLS/LL-HLS segments.
2//!
3//! Gated behind `hls`. Provides the [`Muxer`] and [`Packager`] contracts, a
4//! sliding-window [`HlsPlaylist`] generator, and an [`HlsSegmenter`] that cuts
5//! segments on keyframe boundaries and writes both media segments and the
6//! `.m3u8` playlist through any [`StorageBackend`].
7//!
8//! The container byte-format is pluggable via [`Muxer`]: a [`PassthroughMuxer`]
9//! (elementary-stream concatenation) ships here; production MPEG-TS or fMP4/CMAF
10//! muxers implement the same trait and drop in unchanged.
11//!
12//! ```no_run
13//! # #[cfg(feature = "storage-fs")]
14//! # async fn demo(handle: arcly_stream::StreamHandle) -> arcly_stream::Result<()> {
15//! use arcly_stream::packager::{HlsSegmenter, Packager, PassthroughMuxer};
16//! use arcly_stream::storage::FsStorage;
17//!
18//! let storage = FsStorage::new("/var/hls");
19//! let mut seg = HlsSegmenter::new(PassthroughMuxer::new("ts"), storage, "live/cam", 6, 5);
20//! let mut sub = handle.subscribe_resilient();
21//! while let Some(frame) = sub.recv().await {
22//!     seg.push(&frame).await?;
23//! }
24//! seg.finish().await?;
25//! # Ok(())
26//! # }
27//! ```
28
29mod engine;
30mod playlist;
31
32use engine::{SegmentEngine, BANDWIDTH_HINT};
33
34#[cfg(feature = "mpegts")]
35#[cfg_attr(docsrs, doc(cfg(feature = "mpegts")))]
36mod mpegts;
37
38#[cfg(feature = "mpegts")]
39#[cfg_attr(docsrs, doc(cfg(feature = "mpegts")))]
40pub use mpegts::MpegTsMuxer;
41
42#[cfg(feature = "fmp4")]
43#[cfg_attr(docsrs, doc(cfg(feature = "fmp4")))]
44mod fmp4;
45
46#[cfg(feature = "fmp4")]
47#[cfg_attr(docsrs, doc(cfg(feature = "fmp4")))]
48pub use fmp4::Fmp4Muxer;
49
50pub use playlist::{render_master, HlsPlaylist, Part, Segment};
51
52#[cfg(feature = "dash")]
53#[cfg_attr(docsrs, doc(cfg(feature = "dash")))]
54mod dash;
55
56#[cfg(feature = "dash")]
57#[cfg_attr(docsrs, doc(cfg(feature = "dash")))]
58pub use dash::{DashManifest, DashPackager};
59
60use crate::traits::StorageBackend;
61use crate::{CodecId, FrameFlags, MediaFrame, Result};
62use async_trait::async_trait;
63use bytes::{BufMut, Bytes, BytesMut};
64
65/// Container muxer: accumulates frames into one segment's bytes.
66///
67/// Implement this for MPEG-TS, fMP4/CMAF, etc. The segmenter calls
68/// [`start_segment`](Self::start_segment) at each boundary, [`write`](Self::write)
69/// per frame, and [`finish_segment`](Self::finish_segment) to take the bytes.
70pub trait Muxer: Send {
71    /// File extension for produced segments (e.g. `"ts"`, `"m4s"`).
72    fn extension(&self) -> &'static str;
73
74    /// Begin a fresh segment, discarding any partial state.
75    fn start_segment(&mut self) -> Result<()>;
76
77    /// Append one frame to the current segment.
78    fn write(&mut self, frame: &MediaFrame) -> Result<()>;
79
80    /// Take the finished segment's bytes, resetting for the next one.
81    fn finish_segment(&mut self) -> Result<Bytes>;
82
83    /// Flush the frames buffered since the last part/segment start as an
84    /// independently-appendable **partial segment** (LL-HLS `#EXT-X-PART`),
85    /// *without* ending the current segment. Returns `None` when nothing is
86    /// buffered or the container doesn't support parts.
87    ///
88    /// The concatenation of a segment's parts must equal the full segment, so a
89    /// container's parts are self-contained chunks (fMP4: one `moof`+`mdat`
90    /// fragment each). The default returns `None` (no LL-HLS parts).
91    fn take_partial(&mut self) -> Result<Option<Bytes>> {
92        Ok(None)
93    }
94
95    /// Build an fMP4 initialization segment (`ftyp`+`moov` carrying the
96    /// `avcC`/`hvcC`/`av1C`/`vvcC` decoder config) from a CONFIG access unit.
97    ///
98    /// Returning `Some` makes [`HlsSegmenter`] write it once and reference it via
99    /// `#EXT-X-MAP`. The default returns `None` — correct for self-initializing
100    /// containers like MPEG-TS and the [`PassthroughMuxer`].
101    fn init_segment(&mut self, _codec: CodecId, _config_record: &[u8]) -> Result<Option<Bytes>> {
102        Ok(None)
103    }
104
105    /// Build the initialization segment from **all** the stream's CONFIG access
106    /// units seen before the first media sample — e.g. an `(H264, avc)` video
107    /// config plus an `(AAC, asc)` audio config — so a container that carries
108    /// multiple tracks (fMP4 with muxed audio) can emit a multi-track `moov`.
109    ///
110    /// The default delegates to [`init_segment`](Self::init_segment) with the
111    /// first config, so single-track and self-initializing muxers (MPEG-TS,
112    /// passthrough) need no changes. The [`Fmp4Muxer`] overrides this
113    /// to add an audio track when an AAC config is present.
114    fn build_init_from(&mut self, configs: &[(CodecId, Bytes)]) -> Result<Option<Bytes>> {
115        match configs.first() {
116            Some((codec, data)) => self.init_segment(*codec, data),
117            None => Ok(None),
118        }
119    }
120
121    /// The HLS `CODECS` attribute for this rendition's master-playlist entry
122    /// (e.g. `"hvc1.1.6.L120.B0"`), once known.
123    fn codec_string(&self) -> Option<String> {
124        None
125    }
126}
127
128/// A trivial muxer that concatenates frame payloads (elementary stream).
129///
130/// Useful for testing and raw recording; not a real container. Swap in a TS or
131/// fMP4 muxer for player-compatible output.
132pub struct PassthroughMuxer {
133    ext: &'static str,
134    buf: BytesMut,
135}
136
137impl PassthroughMuxer {
138    /// New passthrough muxer producing segments with the given extension.
139    pub fn new(ext: &'static str) -> Self {
140        Self {
141            ext,
142            buf: BytesMut::new(),
143        }
144    }
145}
146
147impl Muxer for PassthroughMuxer {
148    fn extension(&self) -> &'static str {
149        self.ext
150    }
151    fn start_segment(&mut self) -> Result<()> {
152        self.buf.clear();
153        Ok(())
154    }
155    fn write(&mut self, frame: &MediaFrame) -> Result<()> {
156        self.buf.put_slice(&frame.data);
157        Ok(())
158    }
159    fn finish_segment(&mut self) -> Result<Bytes> {
160        Ok(std::mem::take(&mut self.buf).freeze())
161    }
162    fn take_partial(&mut self) -> Result<Option<Bytes>> {
163        if self.buf.is_empty() {
164            return Ok(None);
165        }
166        Ok(Some(std::mem::take(&mut self.buf).freeze()))
167    }
168}
169
170/// Consumes a live frame stream and produces a packaged rendition.
171#[async_trait]
172pub trait Packager: Send {
173    /// Feed one frame; may finalize and emit a segment as a side effect.
174    async fn push(&mut self, frame: &MediaFrame) -> Result<()>;
175    /// Flush the final segment and close the rendition.
176    async fn finish(&mut self) -> Result<()>;
177}
178
179/// Keyframe-boundary HLS segmenter writing through a [`StorageBackend`].
180///
181/// Starts at the first keyframe, cuts a new segment at the first keyframe at or
182/// after `target_duration` seconds, and after each cut writes the media segment
183/// and the regenerated `index.m3u8` to storage under `prefix`.
184pub struct HlsSegmenter<M: Muxer, S: StorageBackend> {
185    engine: SegmentEngine<M, S>,
186    playlist: HlsPlaylist,
187    /// Whether the multivariant (master) playlist has been written yet. It is
188    /// emitted once, as soon as the rendition's `CODECS` string is known.
189    master_written: bool,
190    /// The HLS `CODECS` string, derived from the CONFIG access unit. Cached here
191    /// (not just read from the muxer) because the segment clock skips the CONFIG
192    /// frame before it reaches a self-initializing muxer like MPEG-TS, so the
193    /// muxer would never see it — yet the master playlist still needs it.
194    codec_string: Option<String>,
195    // ── LL-HLS partial-segment state (inactive when `part_target_ms == 0`) ──
196    /// Part target in milliseconds; `0` disables LL-HLS part emission.
197    part_target_ms: i64,
198    /// Index of the next part within the in-progress segment.
199    part_idx: u64,
200    /// PTS (ms) at which the current part began, once a segment is open.
201    part_anchor_pts: Option<i64>,
202    /// PTS (ms) of the most recently written frame (for the final part's length).
203    last_pts: i64,
204    /// Byte chunks of the in-progress segment's parts, concatenated into the full
205    /// segment file at the cut so non-LL clients can fetch the whole segment.
206    seg_part_bytes: Vec<Bytes>,
207}
208
209impl<M: Muxer, S: StorageBackend> HlsSegmenter<M, S> {
210    /// New segmenter writing under `prefix`, targeting `target_duration`-second
211    /// segments and a `window`-segment live playlist.
212    pub fn new(
213        muxer: M,
214        storage: S,
215        prefix: impl Into<String>,
216        target_duration: u64,
217        window: usize,
218    ) -> Self {
219        Self {
220            engine: SegmentEngine::new(muxer, storage, prefix, target_duration),
221            playlist: HlsPlaylist::new(target_duration, window),
222            master_written: false,
223            codec_string: None,
224            part_target_ms: 0,
225            part_idx: 0,
226            part_anchor_pts: None,
227            last_pts: 0,
228            seg_part_bytes: Vec::new(),
229        }
230    }
231
232    /// The HLS `CODECS` attribute for this rendition's master-playlist entry.
233    pub fn codec_string(&self) -> Option<String> {
234        self.codec_string
235            .clone()
236            .or_else(|| self.engine.muxer.codec_string())
237    }
238
239    /// On the first CONFIG access unit: write the muxer's fMP4 init segment (if
240    /// any) and reference it via `#EXT-X-MAP`, and derive the HLS `CODECS` string
241    /// from the parameter sets. The codec string is read straight from the CONFIG
242    /// here — not from the muxer — because the segment clock skips the CONFIG
243    /// frame before a self-initializing muxer (MPEG-TS) ever sees it.
244    async fn ensure_init_segment(&mut self, frame: &MediaFrame) -> Result<()> {
245        if self.codec_string.is_none()
246            && frame.is_video()
247            && frame.flags.contains(FrameFlags::CONFIG)
248        {
249            self.codec_string = crate::codec::dispatch::parse_config(frame.codec, &frame.data)
250                .and_then(|p| crate::codec::dispatch::hls_codec_string(frame.codec, &p));
251        }
252        if let Some(uri) = self.engine.ensure_init(frame).await? {
253            self.playlist.set_map(uri);
254        }
255        Ok(())
256    }
257
258    /// Enable LL-HLS output: the playlist advertises parts and the segmenter
259    /// emits a partial segment roughly every `part_target` seconds (writing the
260    /// part files and `#EXT-X-PART`/preload-hint lines) in addition to full
261    /// segments. Requires a muxer that implements
262    /// [`take_partial`](Muxer::take_partial) (fMP4 / passthrough).
263    pub fn low_latency(mut self, part_target: f64) -> Self {
264        self.playlist = self.playlist.low_latency(part_target);
265        self.part_target_ms = (part_target.max(0.05) * 1000.0) as i64;
266        self
267    }
268
269    /// The storage key of the media playlist.
270    pub fn playlist_key(&self) -> String {
271        format!("{}/index.m3u8", self.engine.prefix)
272    }
273
274    /// The storage key of the multivariant (master) playlist.
275    pub fn master_key(&self) -> String {
276        format!("{}/master.m3u8", self.engine.prefix)
277    }
278
279    /// Once the muxer can report a `CODECS` string, write the multivariant
280    /// (master) playlist a single time. This is what advertises `CODECS="hvc1.*"`
281    /// for HEVC (and `av01.*`/`vvc1.*`); without it players such as Safari will
282    /// not decode H.265, falling back as if the rendition were unplayable.
283    async fn ensure_master_playlist(&mut self) -> Result<()> {
284        if self.master_written {
285            return Ok(());
286        }
287        let Some(codecs) = self.codec_string() else {
288            return Ok(());
289        };
290        let body = playlist::render_master("index.m3u8", Some(&codecs), BANDWIDTH_HINT);
291        self.engine
292            .storage
293            .put(
294                &self.engine.key("master.m3u8"),
295                Bytes::from(body.into_bytes()),
296            )
297            .await?;
298        self.master_written = true;
299        Ok(())
300    }
301
302    /// Flush the buffered samples as one LL-HLS partial segment: write the part
303    /// file, append `#EXT-X-PART` + a preload hint for the next part, and stash
304    /// the bytes so the full segment file can be assembled at the cut.
305    async fn flush_part(&mut self, end_pts: i64) -> Result<()> {
306        let Some(bytes) = self.engine.muxer.take_partial()? else {
307            return Ok(());
308        };
309        let anchor = self.part_anchor_pts.unwrap_or(end_pts);
310        let duration = (end_pts - anchor).max(0) as f64 / 1000.0;
311        let uri = self.engine.part_uri(self.engine.seq, self.part_idx);
312        let key = self.engine.key(&uri);
313        self.engine.storage.put(&key, bytes.clone()).await?;
314        self.seg_part_bytes.push(bytes);
315        self.playlist.add_pending_part(Part {
316            uri,
317            duration,
318            // The first part of a segment begins on the anchoring keyframe.
319            independent: self.part_idx == 0,
320        });
321        self.playlist
322            .set_preload_hint(self.engine.part_uri(self.engine.seq, self.part_idx + 1));
323        self.part_idx += 1;
324        self.part_anchor_pts = Some(end_pts);
325        self.write_playlist().await
326    }
327
328    async fn cut(&mut self, duration: f64) -> Result<()> {
329        let uri = self.engine.segment_uri(self.engine.seq);
330
331        if self.part_target_ms > 0 {
332            // LL-HLS: flush the segment's final part, then assemble the full
333            // segment file from its parts (concatenation == the whole segment).
334            self.flush_part(self.last_pts).await?;
335            let mut full = BytesMut::new();
336            for b in &self.seg_part_bytes {
337                full.put_slice(b);
338            }
339            // Defensive: if the muxer produced no parts (it doesn't implement
340            // `take_partial`), fall back to a whole-segment flush so LL-HLS still
341            // emits a non-empty segment instead of a 0-byte file.
342            let full = if full.is_empty() {
343                self.engine.muxer.finish_segment()?
344            } else {
345                full.freeze()
346            };
347            let key = self.engine.key(&uri);
348            self.engine.storage.put(&key, full).await?;
349            self.playlist.commit_segment(Segment {
350                seq: self.engine.seq,
351                duration,
352                uri,
353                discontinuity: false,
354                parts: Vec::new(), // filled from pending parts by commit_segment
355            });
356            self.seg_part_bytes.clear();
357            self.part_idx = 0;
358            self.part_anchor_pts = None;
359        } else {
360            let bytes = self.engine.muxer.finish_segment()?;
361            let key = self.engine.key(&uri);
362            self.engine.storage.put(&key, bytes).await?;
363            self.playlist.push(Segment {
364                seq: self.engine.seq,
365                duration,
366                uri,
367                discontinuity: false,
368                parts: Vec::new(),
369            });
370        }
371        self.write_playlist().await?;
372        self.engine.seq += 1;
373        Ok(())
374    }
375
376    async fn write_playlist(&mut self) -> Result<()> {
377        let body = self.playlist.render();
378        self.engine
379            .storage
380            .put(
381                &self.engine.key("index.m3u8"),
382                Bytes::from(body.into_bytes()),
383            )
384            .await
385    }
386}
387
388#[async_trait]
389impl<M: Muxer, S: StorageBackend> Packager for HlsSegmenter<M, S> {
390    async fn push(&mut self, frame: &MediaFrame) -> Result<()> {
391        self.ensure_init_segment(frame).await?;
392        // Emit the master playlist as soon as the codec string is known — which
393        // `ensure_init_segment` derives from the CONFIG frame. This must run
394        // before the clock-skip below, since the clock skips that very CONFIG
395        // frame (it is not a keyframe), and HEVC/AV1 are unviewable without it.
396        self.ensure_master_playlist().await?;
397        let decision = self.engine.observe(frame);
398        if decision.skip {
399            return Ok(());
400        }
401        if let Some(duration) = decision.cut_previous {
402            self.cut(duration).await?;
403        }
404        if decision.open_new {
405            self.engine.muxer.start_segment()?;
406            // A fresh segment anchors a new part run at this keyframe.
407            self.part_anchor_pts = Some(frame.pts);
408            self.part_idx = 0;
409            self.seg_part_bytes.clear();
410        }
411        self.engine.muxer.write(frame)?;
412        self.last_pts = frame.pts;
413
414        // LL-HLS: emit a partial segment once the part target has elapsed. Parts
415        // are cut at frame boundaries, so a part never splits an access unit.
416        if self.part_target_ms > 0 {
417            if let Some(anchor) = self.part_anchor_pts {
418                if frame.pts - anchor >= self.part_target_ms {
419                    self.flush_part(frame.pts).await?;
420                }
421            }
422        }
423        Ok(())
424    }
425
426    async fn finish(&mut self) -> Result<()> {
427        if let Some(duration) = self.engine.flush() {
428            self.cut(duration).await?;
429        }
430        self.playlist.finish();
431        self.write_playlist().await
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::testing::{video_frame, InMemoryStorage};
439
440    #[tokio::test]
441    async fn segments_on_keyframe_after_target_and_writes_playlist() {
442        let store = InMemoryStorage::new();
443        let mut seg =
444            HlsSegmenter::new(PassthroughMuxer::new("ts"), store.clone(), "live/cam", 2, 5);
445
446        // Keyframes every 1s; with a 2s target, a cut happens at the keyframe
447        // at/after 2s. pts in ms.
448        for i in 0..5 {
449            let pts = i * 1000;
450            seg.push(&video_frame(pts, true)).await.unwrap();
451            // a delta in between
452            seg.push(&video_frame(pts + 500, false)).await.unwrap();
453        }
454        seg.finish().await.unwrap();
455
456        let playlist = store.get("live/cam/index.m3u8").await.unwrap();
457        let text = String::from_utf8(playlist.to_vec()).unwrap();
458        assert!(text.contains("#EXTM3U"));
459        assert!(text.contains("#EXT-X-ENDLIST"));
460        // At least the first full segment must have been written to storage.
461        assert!(store.get("live/cam/seg0.ts").await.is_ok());
462        // Segment payload is the concatenation of its frames' bytes.
463        assert!(!store.get("live/cam/seg0.ts").await.unwrap().is_empty());
464    }
465
466    #[tokio::test]
467    async fn ll_hls_emits_part_files_and_ext_x_part() {
468        let store = InMemoryStorage::new();
469        // 2s segments, parts every ~0.5s.
470        let mut seg =
471            HlsSegmenter::new(PassthroughMuxer::new("m4s"), store.clone(), "live/ll", 2, 5)
472                .low_latency(0.5);
473
474        // Keyframe at 0, deltas every 250ms; next keyframe at 2000 cuts segment 0.
475        seg.push(&video_frame(0, true)).await.unwrap();
476        for i in 1..8 {
477            seg.push(&video_frame(i * 250, false)).await.unwrap();
478        }
479        seg.push(&video_frame(2000, true)).await.unwrap(); // cut seg 0
480        seg.push(&video_frame(2250, false)).await.unwrap();
481        seg.finish().await.unwrap();
482
483        let text =
484            String::from_utf8(store.get("live/ll/index.m3u8").await.unwrap().to_vec()).unwrap();
485        // LL-HLS headers + actual part lines for segment 0.
486        assert!(text.contains("#EXT-X-PART-INF:PART-TARGET=0.500"));
487        assert!(
488            text.contains("#EXT-X-PART:DURATION="),
489            "renders EXT-X-PART lines"
490        );
491        assert!(
492            text.contains("INDEPENDENT=YES"),
493            "first part is independent"
494        );
495        // Part files were written, and the first full segment too.
496        assert!(
497            store.get("live/ll/seg0.0.m4s").await.is_ok(),
498            "part 0 written"
499        );
500        assert!(
501            store.get("live/ll/seg0.1.m4s").await.is_ok(),
502            "part 1 written"
503        );
504        assert!(
505            store.get("live/ll/seg0.m4s").await.is_ok(),
506            "full segment 0 written"
507        );
508
509        // The full segment equals the concatenation of its parts.
510        let mut concat = Vec::new();
511        let mut p = 0;
512        while let Ok(part) = store.get(&format!("live/ll/seg0.{p}.m4s")).await {
513            concat.extend_from_slice(&part);
514            p += 1;
515        }
516        assert!(p >= 2, "segment 0 had multiple parts");
517        let full = store.get("live/ll/seg0.m4s").await.unwrap();
518        assert_eq!(&concat[..], &full[..], "full segment == concat of parts");
519    }
520
521    #[tokio::test]
522    async fn ll_hls_falls_back_to_full_segment_when_muxer_has_no_parts() {
523        // InitMuxer does NOT implement `take_partial`; in LL-HLS mode the cut path
524        // must still write a non-empty segment (regression: 0-byte .ts segments).
525        let store = InMemoryStorage::new();
526        let mut seg = HlsSegmenter::new(
527            InitMuxer {
528                buf: BytesMut::new(),
529            },
530            store.clone(),
531            "live/nopart",
532            2,
533            5,
534        )
535        .low_latency(0.5);
536
537        seg.push(&video_frame(0, true)).await.unwrap();
538        for i in 1..6 {
539            seg.push(&video_frame(i * 1000, i % 2 == 0)).await.unwrap();
540        }
541        seg.finish().await.unwrap();
542
543        let seg0 = store.get("live/nopart/seg0.m4s").await.unwrap();
544        assert!(!seg0.is_empty(), "LL-HLS segment must not be empty");
545    }
546
547    #[tokio::test]
548    async fn writes_master_playlist_with_hevc_codecs() {
549        use crate::FrameFlags;
550        let store = InMemoryStorage::new();
551        let mut seg = HlsSegmenter::new(
552            InitMuxer {
553                buf: BytesMut::new(),
554            },
555            store.clone(),
556            "live/hevc",
557            2,
558            5,
559        );
560
561        let mut cfg = video_frame(0, true);
562        cfg.codec = CodecId::H265;
563        cfg.flags |= FrameFlags::CONFIG;
564        seg.push(&cfg).await.unwrap();
565        for i in 1..4 {
566            seg.push(&video_frame(i * 1000, true)).await.unwrap();
567        }
568        seg.finish().await.unwrap();
569
570        let master =
571            String::from_utf8(store.get("live/hevc/master.m3u8").await.unwrap().to_vec()).unwrap();
572        assert!(master.contains("#EXT-X-STREAM-INF:"));
573        assert!(
574            master.contains("CODECS=\"hvc1.1.6.L120.B0\""),
575            "master advertises HEVC codec string: {master}"
576        );
577        assert!(master.contains("index.m3u8"));
578    }
579
580    /// A muxer that emits an fMP4-style init segment, to exercise EXT-X-MAP.
581    struct InitMuxer {
582        buf: BytesMut,
583    }
584    impl Muxer for InitMuxer {
585        fn extension(&self) -> &'static str {
586            "m4s"
587        }
588        fn start_segment(&mut self) -> Result<()> {
589            self.buf.clear();
590            Ok(())
591        }
592        fn write(&mut self, frame: &MediaFrame) -> Result<()> {
593            self.buf.put_slice(&frame.data);
594            Ok(())
595        }
596        fn finish_segment(&mut self) -> Result<Bytes> {
597            Ok(std::mem::take(&mut self.buf).freeze())
598        }
599        fn init_segment(&mut self, _codec: CodecId, config_record: &[u8]) -> Result<Option<Bytes>> {
600            // A real muxer would wrap this in ftyp+moov; here we just echo it.
601            Ok(Some(Bytes::copy_from_slice(config_record)))
602        }
603        fn codec_string(&self) -> Option<String> {
604            Some("hvc1.1.6.L120.B0".into())
605        }
606    }
607
608    #[tokio::test]
609    async fn fmp4_muxer_writes_init_segment_and_ext_x_map() {
610        use crate::FrameFlags;
611        let store = InMemoryStorage::new();
612        let mut seg = HlsSegmenter::new(
613            InitMuxer {
614                buf: BytesMut::new(),
615            },
616            store.clone(),
617            "live/hevc",
618            2,
619            5,
620        );
621        assert_eq!(seg.codec_string().as_deref(), Some("hvc1.1.6.L120.B0"));
622
623        // First frame carries CONFIG → triggers the init segment.
624        let mut cfg = video_frame(0, true);
625        cfg.codec = CodecId::H265;
626        cfg.flags |= FrameFlags::CONFIG;
627        seg.push(&cfg).await.unwrap();
628        for i in 1..6 {
629            seg.push(&video_frame(i * 1000, true)).await.unwrap();
630        }
631        seg.finish().await.unwrap();
632
633        assert!(store.get("live/hevc/init.m4s").await.is_ok());
634        let pl =
635            String::from_utf8(store.get("live/hevc/index.m3u8").await.unwrap().to_vec()).unwrap();
636        assert!(pl.contains("#EXT-X-MAP:URI=\"init.m4s\""));
637    }
638}