Skip to main content

arcly_stream/packager/
dash.rs

1//! DASH / LL-DASH manifest (`.mpd`) generation and packaging.
2//!
3//! Reuses the [`Muxer`](super::Muxer) / [`Packager`](super::Packager) contracts:
4//! the media segments are ordinary CMAF (fMP4) — produce them with
5//! [`Fmp4Muxer`](super::Fmp4Muxer) — and [`DashManifest`] renders a dynamic
6//! (live) MPD using a `SegmentTemplate` + `SegmentTimeline`, which describes
7//! variable, keyframe-aligned segment durations exactly.
8//!
9//! Low-latency DASH is enabled with [`DashManifest::low_latency`] /
10//! [`DashPackager::low_latency`]: the MPD carries `availabilityTimeOffset` so a
11//! player can fetch the in-progress segment as it is produced (chunked CMAF —
12//! the chunks are the same `moof`+`mdat` fragments [`Muxer::take_partial`]
13//! yields; the actual chunked HTTP transfer is the delivery layer's job).
14
15use std::collections::VecDeque;
16use std::fmt::Write as _;
17use std::sync::Arc;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use super::engine::{SegmentEngine, BANDWIDTH_HINT};
21use super::{ChunkSink, Muxer, Packager};
22use crate::traits::StorageBackend;
23use crate::{MediaFrame, Result};
24use async_trait::async_trait;
25use bytes::{BufMut, Bytes, BytesMut};
26
27/// One segment's timing in the MPD `SegmentTimeline` (milliseconds).
28#[derive(Debug, Clone, Copy)]
29struct TimelineEntry {
30    start_ms: u64,
31    dur_ms: u64,
32}
33
34/// A dynamic (live) DASH media-presentation-description generator.
35///
36/// Holds a sliding window of segment timings and renders a spec-compliant MPD
37/// with a number-addressed `SegmentTemplate` and an explicit `SegmentTimeline`.
38#[derive(Debug, Clone)]
39pub struct DashManifest {
40    window: usize,
41    timescale: u64,
42    low_latency: bool,
43    part_target: f64,
44    availability_start: u64,                  // unix seconds
45    segments: VecDeque<(u64, TimelineEntry)>, // (seq, timing)
46    codecs: Option<String>,
47    width: u16,
48    height: u16,
49    bandwidth: u32,
50    init_uri: String,
51    media_template: String,
52    finished: bool,
53}
54
55impl DashManifest {
56    /// A live manifest retaining `window` segments, timed in milliseconds.
57    pub fn new(window: usize) -> Self {
58        Self {
59            window: window.max(1),
60            timescale: 1000,
61            low_latency: false,
62            part_target: 0.0,
63            availability_start: SystemTime::now()
64                .duration_since(UNIX_EPOCH)
65                .map(|d| d.as_secs())
66                .unwrap_or(0),
67            segments: VecDeque::new(),
68            codecs: None,
69            width: 0,
70            height: 0,
71            bandwidth: 0,
72            init_uri: "init.m4s".into(),
73            media_template: "seg$Number$.m4s".into(),
74            finished: false,
75        }
76    }
77
78    /// Enable LL-DASH: advertise `availabilityTimeOffset` of `part_target`
79    /// seconds (one segment minus one part), letting players read the open
80    /// segment as its chunks are produced.
81    pub fn low_latency(mut self, part_target: f64) -> Self {
82        self.low_latency = true;
83        self.part_target = part_target.max(0.05);
84        self
85    }
86
87    /// Set the codec string (`avc1.*` / `hvc1.*`), pixel dimensions, and a
88    /// nominal bandwidth (bits/sec) for the `Representation`.
89    pub fn set_media_info(
90        &mut self,
91        codecs: Option<String>,
92        width: u16,
93        height: u16,
94        bandwidth: u32,
95    ) {
96        self.codecs = codecs;
97        self.width = width;
98        self.height = height;
99        self.bandwidth = bandwidth;
100    }
101
102    /// Override the init-segment and media-segment template URIs.
103    pub fn set_uris(&mut self, init_uri: impl Into<String>, media_template: impl Into<String>) {
104        self.init_uri = init_uri.into();
105        self.media_template = media_template.into();
106    }
107
108    /// Append a segment timing, evicting the oldest beyond the window.
109    pub fn push(&mut self, seq: u64, start_ms: u64, dur_ms: u64) {
110        self.segments
111            .push_back((seq, TimelineEntry { start_ms, dur_ms }));
112        while self.segments.len() > self.window {
113            self.segments.pop_front();
114        }
115    }
116
117    /// Correct the duration of the most recently pushed segment. Used in LL-DASH:
118    /// a segment is advertised at *open* time with an estimated duration (so the
119    /// player starts fetching it chunked immediately) and its exact duration is
120    /// patched in here once the segment is cut.
121    pub fn set_last_duration(&mut self, dur_ms: u64) {
122        if let Some((_, t)) = self.segments.back_mut() {
123            t.dur_ms = dur_ms;
124        }
125    }
126
127    /// Mark the presentation static (VOD) — sets `type="static"` and a final
128    /// `minimumUpdatePeriod`-free manifest.
129    pub fn finish(&mut self) {
130        self.finished = true;
131    }
132
133    /// The media sequence number of the first segment in the window (the MPD
134    /// `startNumber`).
135    fn start_number(&self) -> u64 {
136        self.segments.front().map(|(seq, _)| *seq).unwrap_or(0)
137    }
138
139    /// Largest segment duration in the window (seconds), for `maxSegmentDuration`.
140    fn max_seg_secs(&self) -> f64 {
141        self.segments
142            .iter()
143            .map(|(_, t)| t.dur_ms)
144            .max()
145            .unwrap_or(0) as f64
146            / 1000.0
147    }
148
149    /// Render the MPD document.
150    pub fn render(&self) -> String {
151        let mut s = String::with_capacity(512 + self.segments.len() * 48);
152        s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
153
154        let mpd_type = if self.finished { "static" } else { "dynamic" };
155        let profile = "urn:mpeg:dash:profile:isoff-live:2011";
156        s.push_str("<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\" ");
157        // `write!` formats straight into the buffer; `format!` would allocate a
158        // throwaway String per line, and this renders on every segment cut.
159        let _ = write!(s, "type=\"{mpd_type}\" profiles=\"{profile}\" ");
160        s.push_str("minBufferTime=\"PT1.5S\" ");
161        if !self.finished {
162            let _ = write!(
163                s,
164                "availabilityStartTime=\"{}\" ",
165                unix_to_iso8601(self.availability_start)
166            );
167            s.push_str("minimumUpdatePeriod=\"PT1S\" ");
168        }
169        let _ = writeln!(s, "maxSegmentDuration=\"PT{:.3}S\">", self.max_seg_secs());
170
171        s.push_str("  <Period id=\"0\" start=\"PT0S\">\n");
172        s.push_str("    <AdaptationSet mimeType=\"video/mp4\" segmentAlignment=\"true\" startWithSAP=\"1\">\n");
173        let _ = write!(
174            s,
175            "      <Representation id=\"v0\" bandwidth=\"{}\"",
176            self.bandwidth
177        );
178        if self.width > 0 && self.height > 0 {
179            let _ = write!(s, " width=\"{}\" height=\"{}\"", self.width, self.height);
180        }
181        if let Some(codecs) = &self.codecs {
182            let _ = write!(s, " codecs=\"{codecs}\"");
183        }
184        s.push_str(">\n");
185
186        let _ = write!(
187            s,
188            "        <SegmentTemplate timescale=\"{}\" initialization=\"{}\" media=\"{}\" startNumber=\"{}\"",
189            self.timescale,
190            self.init_uri,
191            self.media_template,
192            self.start_number()
193        );
194        if self.low_latency {
195            let _ = write!(
196                s,
197                " availabilityTimeOffset=\"{:.3}\" availabilityTimeComplete=\"false\"",
198                (self.max_seg_secs() - self.part_target).max(0.0)
199            );
200        }
201        s.push_str(">\n");
202        s.push_str("          <SegmentTimeline>\n");
203        // Emit an explicit <S> per segment; `t` only needs restating when the
204        // timeline is non-contiguous, but stating it on the first entry anchors
205        // the window after eviction.
206        for (i, (_, t)) in self.segments.iter().enumerate() {
207            if i == 0 {
208                let _ = writeln!(
209                    s,
210                    "            <S t=\"{}\" d=\"{}\"/>",
211                    t.start_ms, t.dur_ms
212                );
213            } else {
214                let _ = writeln!(s, "            <S d=\"{}\"/>", t.dur_ms);
215            }
216        }
217        s.push_str("          </SegmentTimeline>\n");
218        s.push_str("        </SegmentTemplate>\n");
219        s.push_str("      </Representation>\n");
220        s.push_str("    </AdaptationSet>\n");
221        s.push_str("  </Period>\n");
222        s.push_str("</MPD>\n");
223        s
224    }
225}
226
227/// Convert Unix seconds to an ISO-8601 UTC timestamp (`YYYY-MM-DDThh:mm:ssZ`),
228/// dependency-free (Howard Hinnant's civil-from-days algorithm).
229fn unix_to_iso8601(secs: u64) -> String {
230    let days = (secs / 86_400) as i64;
231    let rem = secs % 86_400;
232    let (h, m, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60);
233
234    let z = days + 719_468;
235    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
236    let doe = z - era * 146_097;
237    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
238    let y = yoe + era * 400;
239    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
240    let mp = (5 * doy + 2) / 153;
241    let d = doy - (153 * mp + 2) / 5 + 1;
242    let month = if mp < 10 { mp + 3 } else { mp - 9 };
243    let year = if month <= 2 { y + 1 } else { y };
244    format!("{year:04}-{month:02}-{d:02}T{h:02}:{m:02}:{sec:02}Z")
245}
246
247/// Keyframe-boundary DASH packager writing CMAF segments + a live MPD through a
248/// [`StorageBackend`]. The DASH counterpart to
249/// [`HlsSegmenter`](super::HlsSegmenter).
250pub struct DashPackager<M: Muxer, S: StorageBackend> {
251    engine: SegmentEngine<M, S>,
252    manifest: DashManifest,
253    seg_start_ms: u64,
254    // ── LL-DASH chunked-CMAF state (inactive when `part_target_ms == 0`) ──
255    /// Part target in milliseconds; `0` disables partial-fragment emission.
256    part_target_ms: i64,
257    /// DTS (ms) at which the current part began, once a segment is open.
258    part_anchor_dts: Option<i64>,
259    /// DTS (ms) of the most recently written frame (for the final part's flush).
260    last_dts: i64,
261    /// Fragments produced for the in-progress segment, concatenated into the full
262    /// segment file at the cut so non-LL clients can fetch the whole segment.
263    seg_part_bytes: Vec<Bytes>,
264    /// Accumulated exact media duration (ms) of the in-progress segment, summed
265    /// from each fragment's `last_segment_duration_ms` so the SegmentTimeline `d`
266    /// matches the media even though parts are taken incrementally.
267    seg_dur_ms: u64,
268    /// Last completed segment's duration (ms), used as the estimate for the
269    /// provisional timeline entry of the next (open) segment in LL mode.
270    last_dur_ms: u64,
271    /// Optional live-chunk sink: receives each fragment as it is produced so a
272    /// delivery layer can stream the open segment over HTTP chunked transfer.
273    sink: Option<Arc<dyn ChunkSink>>,
274}
275
276impl<M: Muxer, S: StorageBackend> DashPackager<M, S> {
277    /// New packager writing under `prefix`, `target_duration`-second segments,
278    /// a `window`-segment live MPD.
279    pub fn new(
280        muxer: M,
281        storage: S,
282        prefix: impl Into<String>,
283        target_duration: u64,
284        window: usize,
285    ) -> Self {
286        Self {
287            engine: SegmentEngine::new(muxer, storage, prefix, target_duration),
288            manifest: DashManifest::new(window),
289            seg_start_ms: 0,
290            part_target_ms: 0,
291            part_anchor_dts: None,
292            last_dts: 0,
293            seg_part_bytes: Vec::new(),
294            seg_dur_ms: 0,
295            last_dur_ms: target_duration.max(1) * 1000,
296            sink: None,
297        }
298    }
299
300    /// Enable LL-DASH: advertise `availabilityTimeOffset` in the MPD *and* emit a
301    /// CMAF fragment roughly every `part_target` seconds (via
302    /// [`Muxer::take_partial`]) so an attached [`ChunkSink`] can stream the
303    /// in-progress segment. Requires a muxer that implements `take_partial`.
304    pub fn low_latency(mut self, part_target: f64) -> Self {
305        self.manifest = self.manifest.low_latency(part_target);
306        self.part_target_ms = (part_target.max(0.05) * 1000.0) as i64;
307        self
308    }
309
310    /// Attach a [`ChunkSink`] that receives each segment's fragments as produced.
311    /// Only meaningful together with [`low_latency`](Self::low_latency).
312    pub fn with_chunk_sink(mut self, sink: Arc<dyn ChunkSink>) -> Self {
313        self.sink = Some(sink);
314        self
315    }
316
317    /// The storage key of the MPD.
318    pub fn manifest_key(&self) -> String {
319        format!("{}/manifest.mpd", self.engine.prefix)
320    }
321
322    async fn ensure_init(&mut self, frame: &MediaFrame) -> Result<()> {
323        if let Some(uri) = self.engine.ensure_init(frame).await? {
324            self.manifest.set_uris(
325                uri,
326                format!("seg$Number$.{}", self.engine.muxer.extension()),
327            );
328            // Media info for the Representation, once the muxer can report it.
329            self.manifest
330                .set_media_info(self.engine.muxer.codec_string(), 0, 0, BANDWIDTH_HINT);
331        }
332        Ok(())
333    }
334
335    /// LL-DASH: take the buffered samples as one CMAF fragment, hand it to the
336    /// sink (for chunked streaming) and stash it for the full-segment file. Sums
337    /// the fragment's exact duration into the segment total. No-op if the muxer
338    /// has nothing buffered.
339    async fn flush_part(&mut self) -> Result<()> {
340        let Some(bytes) = self.engine.muxer.take_partial()? else {
341            return Ok(());
342        };
343        if let Some(d) = self.engine.muxer.last_segment_duration_ms() {
344            self.seg_dur_ms += d;
345        }
346        if let Some(sink) = &self.sink {
347            sink.chunk(self.engine.seq, bytes.clone());
348        }
349        self.seg_part_bytes.push(bytes);
350        self.part_anchor_dts = Some(self.last_dts);
351        Ok(())
352    }
353
354    async fn cut(&mut self, duration: f64) -> Result<()> {
355        if self.part_target_ms > 0 {
356            // LL-DASH: flush the segment's final fragment, then assemble the full
357            // segment file from its fragments (concatenation == the whole segment).
358            self.flush_part().await?;
359            let mut full = BytesMut::new();
360            for b in &self.seg_part_bytes {
361                full.put_slice(b);
362            }
363            // Defensive: if no fragments were produced, fall back to a whole-segment
364            // flush so we never write a 0-byte file.
365            let full = if full.is_empty() {
366                self.engine.muxer.finish_segment()?
367            } else {
368                full.freeze()
369            };
370            if full.is_empty() {
371                return Ok(());
372            }
373            let uri = self.engine.segment_uri(self.engine.seq);
374            let key = self.engine.key(&uri);
375            self.engine.storage.put(&key, full).await?;
376            // Summed fragment durations track the media exactly; fall back to the
377            // keyframe-span when (defensively) no fragment reported a duration.
378            let dur_ms = if self.seg_dur_ms > 0 {
379                self.seg_dur_ms
380            } else {
381                (duration * 1000.0) as u64
382            };
383            // The segment was already advertised (provisional duration) when it
384            // opened; patch in its exact duration now that it is complete.
385            self.manifest.set_last_duration(dur_ms);
386            self.seg_start_ms += dur_ms;
387            self.last_dur_ms = dur_ms;
388            self.write_manifest().await?;
389            if let Some(sink) = &self.sink {
390                sink.close(self.engine.seq);
391            }
392            self.seg_part_bytes.clear();
393            self.seg_dur_ms = 0;
394            self.engine.seq += 1;
395            return Ok(());
396        }
397
398        let bytes = self.engine.muxer.finish_segment()?;
399        if bytes.is_empty() {
400            return Ok(());
401        }
402        let uri = self.engine.segment_uri(self.engine.seq);
403        let key = self.engine.key(&uri);
404        self.engine.storage.put(&key, bytes).await?;
405        // Prefer the muxer's exact fragment duration so the SegmentTimeline `t`/`d`
406        // tracks the media's `tfdt` precisely. Falling back to the keyframe-span
407        // `duration` drifts by the last sample's fallback duration each segment,
408        // which dash.js renders as a stutter at every segment boundary.
409        let dur_ms = self
410            .engine
411            .muxer
412            .last_segment_duration_ms()
413            .filter(|&d| d > 0)
414            .unwrap_or((duration * 1000.0) as u64);
415        self.manifest
416            .push(self.engine.seq, self.seg_start_ms, dur_ms);
417        self.seg_start_ms += dur_ms;
418        self.write_manifest().await?;
419        self.engine.seq += 1;
420        Ok(())
421    }
422
423    async fn write_manifest(&mut self) -> Result<()> {
424        let body = self.manifest.render();
425        self.engine
426            .storage
427            .put(
428                &self.engine.key("manifest.mpd"),
429                Bytes::from(body.into_bytes()),
430            )
431            .await
432    }
433}
434
435#[async_trait]
436impl<M: Muxer, S: StorageBackend> Packager for DashPackager<M, S> {
437    async fn push(&mut self, frame: &MediaFrame) -> Result<()> {
438        self.ensure_init(frame).await?;
439        let decision = self.engine.observe(frame);
440        if decision.skip {
441            return Ok(());
442        }
443        if let Some(duration) = decision.cut_previous {
444            self.cut(duration).await?;
445        }
446        if decision.open_new {
447            self.engine.muxer.start_segment()?;
448            // A fresh segment anchors a new fragment run at this keyframe. Use DTS
449            // (decode/arrival order) so part timing stays monotonic with B-frames.
450            self.part_anchor_dts = Some(frame.dts);
451            self.seg_part_bytes.clear();
452            self.seg_dur_ms = 0;
453            if let Some(sink) = &self.sink {
454                sink.open(self.engine.seq);
455            }
456            // LL-DASH: advertise the segment in the SegmentTimeline as soon as it
457            // opens (with the previous segment's duration as an estimate, patched
458            // exactly at the cut), so the player discovers and starts fetching it
459            // chunked right away — instead of stalling at every segment boundary
460            // until the next manifest refresh reveals the completed segment.
461            if self.part_target_ms > 0 {
462                self.manifest
463                    .push(self.engine.seq, self.seg_start_ms, self.last_dur_ms);
464                self.write_manifest().await?;
465            }
466        }
467        self.engine.muxer.write(frame)?;
468        self.last_dts = frame.dts;
469
470        // LL-DASH: emit a CMAF fragment once the part target has elapsed. Cut at
471        // frame boundaries so a fragment never splits an access unit.
472        if self.part_target_ms > 0 {
473            if let Some(anchor) = self.part_anchor_dts {
474                if frame.dts - anchor >= self.part_target_ms {
475                    self.flush_part().await?;
476                }
477            }
478        }
479        Ok(())
480    }
481
482    async fn finish(&mut self) -> Result<()> {
483        if let Some(duration) = self.engine.flush() {
484            self.cut(duration).await?;
485        }
486        self.manifest.finish();
487        self.write_manifest().await
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::packager::PassthroughMuxer;
495    use crate::testing::{video_frame, InMemoryStorage};
496
497    #[test]
498    fn iso8601_known_vector() {
499        // 2026-06-15T11:35:43Z → seconds since epoch.
500        assert_eq!(unix_to_iso8601(0), "1970-01-01T00:00:00Z");
501        assert_eq!(unix_to_iso8601(1_609_459_200), "2021-01-01T00:00:00Z");
502        assert_eq!(
503            unix_to_iso8601(1_609_459_200 + 3661),
504            "2021-01-01T01:01:01Z"
505        );
506    }
507
508    #[test]
509    fn manifest_renders_dynamic_mpd_with_timeline() {
510        let mut m = DashManifest::new(3);
511        m.set_media_info(Some("avc1.42001f".into()), 1280, 720, 2_000_000);
512        m.push(0, 0, 2000);
513        m.push(1, 2000, 1980);
514        let out = m.render();
515        assert!(out.contains("type=\"dynamic\""));
516        assert!(out.contains("availabilityStartTime="));
517        assert!(out.contains("<SegmentTimeline>"));
518        assert!(out.contains("<S t=\"0\" d=\"2000\"/>"));
519        assert!(out.contains("<S d=\"1980\"/>"));
520        assert!(out.contains("codecs=\"avc1.42001f\""));
521        assert!(out.contains("width=\"1280\" height=\"720\""));
522    }
523
524    #[test]
525    fn low_latency_emits_availability_time_offset() {
526        let mut m = DashManifest::new(3).low_latency(0.5);
527        m.push(0, 0, 2000);
528        let out = m.render();
529        assert!(out.contains("availabilityTimeOffset="));
530        assert!(out.contains("availabilityTimeComplete=\"false\""));
531    }
532
533    #[test]
534    fn finish_marks_static() {
535        let mut m = DashManifest::new(3);
536        m.push(0, 0, 2000);
537        m.finish();
538        let out = m.render();
539        assert!(out.contains("type=\"static\""));
540        assert!(!out.contains("minimumUpdatePeriod"));
541    }
542
543    #[tokio::test]
544    async fn ll_dash_streams_fragments_through_sink() {
545        use std::sync::Mutex;
546
547        #[derive(Default)]
548        struct CollectSink {
549            chunks: Mutex<Vec<(u64, Vec<u8>)>>,
550            closed: Mutex<Vec<u64>>,
551        }
552        impl ChunkSink for CollectSink {
553            fn open(&self, _seq: u64) {}
554            fn chunk(&self, seq: u64, bytes: Bytes) {
555                self.chunks.lock().unwrap().push((seq, bytes.to_vec()));
556            }
557            fn close(&self, seq: u64) {
558                self.closed.lock().unwrap().push(seq);
559            }
560        }
561
562        let store = InMemoryStorage::new();
563        let sink = Arc::new(CollectSink::default());
564        let mut dash = DashPackager::new(
565            PassthroughMuxer::new("m4s"),
566            store.clone(),
567            "live/dash",
568            2,
569            5,
570        )
571        .low_latency(0.5)
572        .with_chunk_sink(sink.clone());
573
574        // Keyframe at 0, deltas every 250ms → several fragments; keyframe at 2000
575        // cuts segment 0.
576        dash.push(&video_frame(0, true)).await.unwrap();
577        for i in 1..8 {
578            dash.push(&video_frame(i * 250, false)).await.unwrap();
579        }
580        dash.push(&video_frame(2000, true)).await.unwrap(); // cut seg 0
581        dash.push(&video_frame(2250, false)).await.unwrap();
582        dash.finish().await.unwrap();
583
584        // Segment 0 was streamed as multiple fragments and then closed. Collect the
585        // owned bytes (and drop the lock) before the await below.
586        let (seg0_count, concat) = {
587            let chunks = sink.chunks.lock().unwrap();
588            let seg0: Vec<&Vec<u8>> = chunks
589                .iter()
590                .filter(|(s, _)| *s == 0)
591                .map(|(_, b)| b)
592                .collect();
593            let mut concat = Vec::new();
594            for b in &seg0 {
595                concat.extend_from_slice(b);
596            }
597            (seg0.len(), concat)
598        };
599        assert!(seg0_count >= 2, "segment 0 streamed in multiple fragments");
600        assert!(sink.closed.lock().unwrap().contains(&0), "segment 0 closed");
601
602        // The concatenation of the streamed fragments equals the full segment file.
603        let full = store.get("live/dash/seg0.m4s").await.unwrap();
604        assert_eq!(
605            &concat[..],
606            &full[..],
607            "streamed fragments == full segment file"
608        );
609    }
610
611    #[tokio::test]
612    async fn packager_writes_segments_and_mpd() {
613        let store = InMemoryStorage::new();
614        let mut dash = DashPackager::new(
615            PassthroughMuxer::new("m4s"),
616            store.clone(),
617            "live/dash",
618            2,
619            5,
620        );
621
622        for i in 0..4 {
623            let pts = i * 1000;
624            dash.push(&video_frame(pts, true)).await.unwrap();
625            dash.push(&video_frame(pts + 500, false)).await.unwrap();
626        }
627        dash.finish().await.unwrap();
628
629        let mpd =
630            String::from_utf8(store.get("live/dash/manifest.mpd").await.unwrap().to_vec()).unwrap();
631        assert!(mpd.contains("<MPD"));
632        assert!(mpd.contains("type=\"static\"")); // finished
633        assert!(mpd.contains("<SegmentTimeline>"));
634        assert!(store.get("live/dash/seg0.m4s").await.is_ok());
635    }
636}