Skip to main content

sheathe_dash/
lib.rs

1//! MPEG-DASH (ISO/IEC 23009-1) manifest generation for **sheathe**.
2//!
3//! Emits static (on-demand) and dynamic (live) MPDs using `SegmentTemplate` +
4//! `SegmentTimeline`. Supports multi-period presentations, trick-play
5//! AdaptationSets, low-latency `availabilityTimeOffset`, and SCTE-35
6//! `EventStream` markers. Output is differential-tested against Shaka Packager
7//! on the VOD path; live/LL fields follow the DASH-IF live profile conventions.
8
9use sheathe_core::{MediaKind, StreamInfo};
10use std::fmt::Write as _;
11
12/// How a representation addresses its segments in the MPD.
13#[derive(Debug, Clone)]
14pub enum SegmentAddressing {
15    /// Live/VOD multi-file layout: `SegmentTemplate` + `SegmentTimeline`.
16    Template {
17        /// Initialization segment URL.
18        init: String,
19        /// Media segment URL template (may contain `$Number$`).
20        media: String,
21        /// First segment number (default 1).
22        start_number: u32,
23        /// Presentation time offset of the first timeline sample (timescale ticks).
24        presentation_time_offset: u64,
25        /// Low-latency availability time offset in seconds.
26        availability_time_offset: Option<f64>,
27    },
28    /// On-demand single-file layout: `SegmentBase` with byte ranges.
29    ///
30    /// File layout is `[init][sidx?][media…]` or `[init][seg1][seg2]…` with
31    /// explicit media ranges in [`SegmentBaseInfo::media_ranges`].
32    Base(SegmentBaseInfo),
33}
34
35/// Byte-range addressing for a single-file on-demand representation.
36#[derive(Debug, Clone)]
37pub struct SegmentBaseInfo {
38    /// Relative URL of the single media file (`BaseURL`).
39    pub base_url: String,
40    /// Inclusive byte range of the initialization segment (`a-b`).
41    pub init_range: (u64, u64),
42    /// Inclusive byte range of the segment index (`sidx`), if present.
43    pub index_range: Option<(u64, u64)>,
44    /// Inclusive byte ranges of each media segment, in order.
45    /// When non-empty, emitted as `SegmentList`/`SegmentURL@mediaRange`
46    /// (more precise than a single index for multi-fragment files).
47    pub media_ranges: Vec<(u64, u64)>,
48}
49
50/// One selectable rendition within an adaptation set.
51#[derive(Debug, Clone)]
52pub struct Representation {
53    /// A unique id within the manifest (also used in segment URLs).
54    pub id: String,
55    /// The stream this representation carries.
56    pub stream: StreamInfo,
57    /// Timescale the segment durations are expressed in.
58    pub timescale: u32,
59    /// Per-segment durations, in `timescale` ticks, in order.
60    pub segment_durations: Vec<u64>,
61    /// How segments are addressed (template vs on-demand base).
62    pub addressing: SegmentAddressing,
63    /// When set, this is a trick-play representation (`maxPlayoutRate`).
64    pub max_playout_rate: Option<f64>,
65}
66
67impl Representation {
68    /// Build a standard multi-file VOD representation starting at segment number 1.
69    pub fn new(
70        id: impl Into<String>,
71        stream: StreamInfo,
72        init: impl Into<String>,
73        media: impl Into<String>,
74        timescale: u32,
75        segment_durations: Vec<u64>,
76    ) -> Self {
77        Self {
78            id: id.into(),
79            stream,
80            timescale,
81            segment_durations,
82            addressing: SegmentAddressing::Template {
83                init: init.into(),
84                media: media.into(),
85                start_number: 1,
86                presentation_time_offset: 0,
87                availability_time_offset: None,
88            },
89            max_playout_rate: None,
90        }
91    }
92
93    /// Build an on-demand single-file representation with byte-range addressing.
94    pub fn on_demand(
95        id: impl Into<String>,
96        stream: StreamInfo,
97        timescale: u32,
98        segment_durations: Vec<u64>,
99        base: SegmentBaseInfo,
100    ) -> Self {
101        Self {
102            id: id.into(),
103            stream,
104            timescale,
105            segment_durations,
106            addressing: SegmentAddressing::Base(base),
107            max_playout_rate: None,
108        }
109    }
110
111    /// Convenience accessors for template fields (legacy package path).
112    pub fn init(&self) -> Option<&str> {
113        match &self.addressing {
114            SegmentAddressing::Template { init, .. } => Some(init.as_str()),
115            SegmentAddressing::Base(_) => None,
116        }
117    }
118
119    /// Mutable template start number (no-op for on-demand).
120    pub fn set_start_number(&mut self, n: u32) {
121        if let SegmentAddressing::Template { start_number, .. } = &mut self.addressing {
122            *start_number = n;
123        }
124    }
125
126    /// Mutable template presentation time offset (no-op for on-demand).
127    pub fn set_presentation_time_offset(&mut self, pto: u64) {
128        if let SegmentAddressing::Template { presentation_time_offset, .. } = &mut self.addressing {
129            *presentation_time_offset = pto;
130        }
131    }
132
133    /// Mutable template availability time offset (no-op for on-demand).
134    pub fn set_availability_time_offset(&mut self, ato: Option<f64>) {
135        if let SegmentAddressing::Template { availability_time_offset, .. } = &mut self.addressing {
136            *availability_time_offset = ato;
137        }
138    }
139}
140
141/// CENC protection signalling for the manifest (`ContentProtection`).
142#[derive(Debug, Clone)]
143pub struct Protection {
144    /// Scheme value: `cenc`, `cbcs`, `cbc1`, or `cens`.
145    pub scheme: String,
146    /// 16-byte default Key ID, rendered as a dashed UUID.
147    pub default_kid: [u8; 16],
148}
149
150/// MPD `@type`: static (VOD) or dynamic (live).
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum MpdType {
153    /// Finished presentation with known duration.
154    #[default]
155    Static,
156    /// Live or event presentation that may still grow.
157    Dynamic,
158}
159
160/// `UTCTiming` element for wall-clock synchronisation on dynamic MPDs.
161#[derive(Debug, Clone)]
162pub struct UtcTiming {
163    /// Scheme URI (e.g. `urn:mpeg:dash:utc:http-iso:2014`).
164    pub scheme_id_uri: String,
165    /// Scheme-specific value (URL for HTTP schemes; may be empty).
166    pub value: String,
167}
168
169impl UtcTiming {
170    /// Common HTTP-ISO wall-clock source.
171    pub fn http_iso(url: impl Into<String>) -> Self {
172        Self { scheme_id_uri: "urn:mpeg:dash:utc:http-iso:2014".into(), value: url.into() }
173    }
174
175    /// Direct wall-clock (no network fetch); value is an ISO-8601 timestamp.
176    pub fn direct(iso_time: impl Into<String>) -> Self {
177        Self { scheme_id_uri: "urn:mpeg:dash:utc:direct:2014".into(), value: iso_time.into() }
178    }
179}
180
181/// One timed event inside an [`EventStream`] (e.g. a SCTE-35 splice).
182#[derive(Debug, Clone)]
183pub struct DashEvent {
184    /// Optional event id.
185    pub id: Option<String>,
186    /// Presentation time in the event stream's timescale.
187    pub presentation_time: u64,
188    /// Optional duration in the event stream's timescale.
189    pub duration: Option<u64>,
190    /// Optional message body (often base64-encoded SCTE-35 binary).
191    pub message_data: Option<String>,
192}
193
194/// DASH `EventStream` (SCTE-35 ad markers, interstitials, …).
195#[derive(Debug, Clone)]
196pub struct EventStream {
197    /// Scheme URI — for binary SCTE-35: `urn:scte:scte35:2014:xml+bin`.
198    pub scheme_id_uri: String,
199    /// Optional scheme value.
200    pub value: Option<String>,
201    /// Timescale for event presentation times / durations.
202    pub timescale: u32,
203    /// Events in presentation order.
204    pub events: Vec<DashEvent>,
205}
206
207impl EventStream {
208    /// SCTE-35 binary event stream (`urn:scte:scte35:2014:xml+bin`).
209    pub fn scte35_bin(timescale: u32, events: Vec<DashEvent>) -> Self {
210        Self {
211            scheme_id_uri: "urn:scte:scte35:2014:xml+bin".into(),
212            value: None,
213            timescale,
214            events,
215        }
216    }
217}
218
219/// One contiguous period of content within a presentation.
220#[derive(Debug, Clone)]
221pub struct Period {
222    /// Period id (unique within the MPD).
223    pub id: String,
224    /// Start offset from the period origin in seconds (`Period@start`).
225    pub start_seconds: Option<f64>,
226    /// Period duration in seconds when known (`Period@duration`).
227    pub duration_seconds: Option<f64>,
228    /// Representations grouped into AdaptationSets by media kind at render time.
229    pub representations: Vec<Representation>,
230    /// Period-level event streams (SCTE-35, …).
231    pub event_streams: Vec<EventStream>,
232}
233
234impl Period {
235    /// Single-period VOD helper: id `"0"`, start at 0, optional duration.
236    pub fn single(
237        duration_seconds: Option<f64>,
238        representations: Vec<Representation>,
239        event_streams: Vec<EventStream>,
240    ) -> Self {
241        Self {
242            id: "0".into(),
243            start_seconds: Some(0.0),
244            duration_seconds,
245            representations,
246            event_streams,
247        }
248    }
249}
250
251/// DASH profile family for the `@profiles` attribute.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
253pub enum DashProfile {
254    /// Multi-segment live/VOD with `SegmentTemplate` (`isoff-live`).
255    #[default]
256    Live,
257    /// Single-file on-demand with `SegmentBase`/`SegmentList` (`isoff-on-demand`).
258    OnDemand,
259}
260
261/// A complete DASH presentation (static or dynamic).
262#[derive(Debug, Clone)]
263pub struct Manifest {
264    /// Static vs dynamic MPD.
265    pub mpd_type: MpdType,
266    /// Profile family (live template vs on-demand base).
267    pub profile: DashProfile,
268    /// Total media duration (static only); omitted on pure live.
269    pub duration_seconds: Option<f64>,
270    /// Wall-clock origin for dynamic timelines (`@availabilityStartTime`).
271    pub availability_start_time: Option<String>,
272    /// When this MPD was published (`@publishTime`).
273    pub publish_time: Option<String>,
274    /// Client refresh interval for dynamic MPDs (`@minimumUpdatePeriod`).
275    pub minimum_update_period: Option<f64>,
276    /// Live edge buffer depth (`@timeShiftBufferDepth`).
277    pub time_shift_buffer_depth: Option<f64>,
278    /// Suggested delay behind the live edge (`@suggestedPresentationDelay`).
279    pub suggested_presentation_delay: Option<f64>,
280    /// Wall-clock timing source for dynamic MPDs.
281    pub utc_timing: Option<UtcTiming>,
282    /// One or more periods.
283    pub periods: Vec<Period>,
284    /// When set, emit `ContentProtection` elements (encrypted content).
285    pub protection: Option<Protection>,
286}
287
288impl Default for Manifest {
289    fn default() -> Self {
290        Self {
291            mpd_type: MpdType::Static,
292            profile: DashProfile::Live,
293            duration_seconds: None,
294            availability_start_time: None,
295            publish_time: None,
296            minimum_update_period: None,
297            time_shift_buffer_depth: None,
298            suggested_presentation_delay: None,
299            utc_timing: None,
300            periods: Vec::new(),
301            protection: None,
302        }
303    }
304}
305
306impl Manifest {
307    /// Convenience constructor for a single-period static VOD presentation.
308    ///
309    /// This is the historical sheathe MPD shape and remains the default for the
310    /// VOD package path.
311    pub fn static_vod(
312        duration_seconds: f64,
313        representations: Vec<Representation>,
314        protection: Option<Protection>,
315    ) -> Self {
316        Self {
317            mpd_type: MpdType::Static,
318            duration_seconds: Some(duration_seconds),
319            periods: vec![Period::single(Some(duration_seconds), representations, Vec::new())],
320            protection,
321            ..Self::default()
322        }
323    }
324
325    /// Convenience constructor for a single-period dynamic (live) presentation.
326    pub fn dynamic_live(
327        availability_start_time: impl Into<String>,
328        publish_time: impl Into<String>,
329        time_shift_buffer_depth: f64,
330        minimum_update_period: f64,
331        suggested_presentation_delay: f64,
332        representations: Vec<Representation>,
333        protection: Option<Protection>,
334    ) -> Self {
335        Self {
336            mpd_type: MpdType::Dynamic,
337            availability_start_time: Some(availability_start_time.into()),
338            publish_time: Some(publish_time.into()),
339            minimum_update_period: Some(minimum_update_period),
340            time_shift_buffer_depth: Some(time_shift_buffer_depth),
341            suggested_presentation_delay: Some(suggested_presentation_delay),
342            utc_timing: Some(UtcTiming::http_iso("https://time.akamai.com/?iso")),
343            periods: vec![Period::single(None, representations, Vec::new())],
344            protection,
345            ..Self::default()
346        }
347    }
348
349    /// Serialize to an MPD XML string.
350    pub fn to_xml(&self) -> String {
351        let mut s = String::new();
352        s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
353        let cenc_ns =
354            if self.protection.is_some() { " xmlns:cenc=\"urn:mpeg:cenc:2013\"" } else { "" };
355        let type_str = match self.mpd_type {
356            MpdType::Static => "static",
357            MpdType::Dynamic => "dynamic",
358        };
359        let profile_uri = match self.profile {
360            DashProfile::Live => "urn:mpeg:dash:profile:isoff-live:2011",
361            DashProfile::OnDemand => "urn:mpeg:dash:profile:isoff-on-demand:2011",
362        };
363        let _ = write!(
364            s,
365            concat!(
366                "<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\"{} ",
367                "profiles=\"{}\" ",
368                "type=\"{}\" minBufferTime=\"PT2S\""
369            ),
370            cenc_ns, profile_uri, type_str,
371        );
372        if let Some(d) = self.duration_seconds {
373            let _ = write!(s, " mediaPresentationDuration=\"{}\"", iso8601_duration(d));
374        }
375        if let Some(t) = &self.availability_start_time {
376            let _ = write!(s, " availabilityStartTime=\"{}\"", xml_escape(t));
377        }
378        if let Some(t) = &self.publish_time {
379            let _ = write!(s, " publishTime=\"{}\"", xml_escape(t));
380        }
381        if let Some(p) = self.minimum_update_period {
382            let _ = write!(s, " minimumUpdatePeriod=\"{}\"", iso8601_duration(p));
383        }
384        if let Some(d) = self.time_shift_buffer_depth {
385            let _ = write!(s, " timeShiftBufferDepth=\"{}\"", iso8601_duration(d));
386        }
387        if let Some(d) = self.suggested_presentation_delay {
388            let _ = write!(s, " suggestedPresentationDelay=\"{}\"", iso8601_duration(d));
389        }
390        s.push_str(">\n");
391
392        if let Some(utc) = &self.utc_timing {
393            let _ = writeln!(
394                s,
395                "  <UTCTiming schemeIdUri=\"{}\" value=\"{}\"/>",
396                xml_escape(&utc.scheme_id_uri),
397                xml_escape(&utc.value),
398            );
399        }
400
401        for period in &self.periods {
402            render_period(&mut s, period, self.protection.as_ref());
403        }
404
405        s.push_str("</MPD>\n");
406        s
407    }
408}
409
410fn render_period(s: &mut String, period: &Period, protection: Option<&Protection>) {
411    let _ = write!(s, "  <Period id=\"{}\"", xml_escape(&period.id));
412    if let Some(start) = period.start_seconds {
413        let _ = write!(s, " start=\"{}\"", iso8601_duration(start));
414    }
415    if let Some(dur) = period.duration_seconds {
416        let _ = write!(s, " duration=\"{}\"", iso8601_duration(dur));
417    }
418    s.push_str(">\n");
419
420    for es in &period.event_streams {
421        render_event_stream(s, es);
422    }
423
424    // Regular AdaptationSets by kind, then a separate trick-play set for video
425    // representations that carry maxPlayoutRate.
426    for (kind, content_type) in
427        [(MediaKind::Video, "video"), (MediaKind::Audio, "audio"), (MediaKind::Text, "text")]
428    {
429        let normal: Vec<&Representation> = period
430            .representations
431            .iter()
432            .filter(|r| r.stream.kind == kind && r.max_playout_rate.is_none())
433            .collect();
434        if !normal.is_empty() {
435            render_adaptation_set(s, content_type, &normal, protection, false);
436        }
437
438        if kind == MediaKind::Video {
439            let trick: Vec<&Representation> = period
440                .representations
441                .iter()
442                .filter(|r| r.stream.kind == MediaKind::Video && r.max_playout_rate.is_some())
443                .collect();
444            if !trick.is_empty() {
445                render_adaptation_set(s, "video", &trick, protection, true);
446            }
447        }
448    }
449
450    s.push_str("  </Period>\n");
451}
452
453fn render_adaptation_set(
454    s: &mut String,
455    content_type: &str,
456    reps: &[&Representation],
457    protection: Option<&Protection>,
458    trick_play: bool,
459) {
460    let _ = writeln!(
461        s,
462        "    <AdaptationSet contentType=\"{}\" segmentAlignment=\"true\">",
463        content_type
464    );
465    if trick_play {
466        // DASH-IF IOP: trick-mode AdaptationSet is marked with EssentialProperty
467        // referencing the main content AdaptationSet id. We use value="1" as a
468        // stable placeholder when AdaptationSet ids are not assigned.
469        s.push_str(
470            "      <EssentialProperty schemeIdUri=\"http://dashif.org/guidelines/trickmode\" value=\"1\"/>\n",
471        );
472    }
473    if let Some(p) = protection {
474        render_content_protection(s, p);
475    }
476    for r in reps {
477        render_representation(s, r);
478    }
479    s.push_str("    </AdaptationSet>\n");
480}
481
482fn render_content_protection(s: &mut String, p: &Protection) {
483    let _ = writeln!(
484        s,
485        concat!(
486            "      <ContentProtection ",
487            "schemeIdUri=\"urn:mpeg:dash:mp4protection:2011\" ",
488            "value=\"{}\" cenc:default_KID=\"{}\"/>"
489        ),
490        p.scheme,
491        kid_uuid(&p.default_kid),
492    );
493}
494
495fn render_event_stream(s: &mut String, es: &EventStream) {
496    let _ = write!(
497        s,
498        "    <EventStream schemeIdUri=\"{}\" timescale=\"{}\"",
499        xml_escape(&es.scheme_id_uri),
500        es.timescale
501    );
502    if let Some(v) = &es.value {
503        let _ = write!(s, " value=\"{}\"", xml_escape(v));
504    }
505    s.push_str(">\n");
506    for ev in &es.events {
507        let _ = write!(s, "      <Event presentationTime=\"{}\"", ev.presentation_time);
508        if let Some(d) = ev.duration {
509            let _ = write!(s, " duration=\"{d}\"");
510        }
511        if let Some(id) = &ev.id {
512            let _ = write!(s, " id=\"{}\"", xml_escape(id));
513        }
514        if let Some(msg) = &ev.message_data {
515            let _ = writeln!(s, ">{}</Event>", xml_escape(msg));
516        } else {
517            s.push_str("/>\n");
518        }
519    }
520    s.push_str("    </EventStream>\n");
521}
522
523/// Format a 16-byte KID as a dashed UUID (8-4-4-4-12).
524fn kid_uuid(kid: &[u8; 16]) -> String {
525    let h: String = kid.iter().map(|b| format!("{b:02x}")).collect();
526    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
527}
528
529fn render_representation(s: &mut String, r: &Representation) {
530    let codec = r.stream.rfc6381();
531    let bandwidth = r.stream.bitrate.unwrap_or(0);
532    let _ = write!(s, "      <Representation id=\"{}\" codecs=\"{}\"", r.id, codec);
533    if let Some((w, h)) = r.stream.resolution {
534        let _ = write!(s, " width=\"{}\" height=\"{}\"", w, h);
535    }
536    if let Some(rate) = r.stream.sample_rate {
537        let _ = write!(s, " audioSamplingRate=\"{}\"", rate);
538    }
539    if let Some(mpr) = r.max_playout_rate {
540        let _ = write!(s, " maxPlayoutRate=\"{mpr}\"");
541    }
542    let _ = writeln!(s, " bandwidth=\"{}\">", bandwidth);
543
544    match &r.addressing {
545        SegmentAddressing::Template {
546            init,
547            media,
548            start_number,
549            presentation_time_offset,
550            availability_time_offset,
551        } => {
552            let _ = write!(
553                s,
554                "        <SegmentTemplate timescale=\"{}\" initialization=\"{}\" media=\"{}\" startNumber=\"{}\"",
555                r.timescale, init, media, start_number
556            );
557            if *presentation_time_offset > 0 {
558                let _ = write!(s, " presentationTimeOffset=\"{presentation_time_offset}\"");
559            }
560            if let Some(ato) = availability_time_offset {
561                let _ = write!(
562                    s,
563                    " availabilityTimeOffset=\"{ato}\" availabilityTimeComplete=\"false\""
564                );
565            }
566            s.push_str(">\n");
567            s.push_str("          <SegmentTimeline>\n");
568            render_timeline(s, &r.segment_durations, *presentation_time_offset);
569            s.push_str("          </SegmentTimeline>\n");
570            s.push_str("        </SegmentTemplate>\n");
571        }
572        SegmentAddressing::Base(base) => {
573            let _ = writeln!(s, "        <BaseURL>{}</BaseURL>", xml_escape(&base.base_url));
574            if base.media_ranges.is_empty() {
575                // Pure SegmentBase (init + optional index only).
576                let _ = write!(s, "        <SegmentBase timescale=\"{}\"", r.timescale);
577                if let Some((a, b)) = base.index_range {
578                    let _ = write!(s, " indexRange=\"{a}-{b}\"");
579                }
580                s.push_str(">\n");
581                let (ia, ib) = base.init_range;
582                let _ = writeln!(s, "          <Initialization range=\"{ia}-{ib}\"/>");
583                s.push_str("        </SegmentBase>\n");
584            } else {
585                // SegmentList with explicit media ranges — preferred for
586                // multi-fragment single files.
587                let _ = writeln!(s, "        <SegmentList timescale=\"{}\">", r.timescale);
588                let (ia, ib) = base.init_range;
589                let _ = writeln!(s, "          <Initialization range=\"{ia}-{ib}\"/>");
590                for (a, b) in &base.media_ranges {
591                    let _ = writeln!(s, "          <SegmentURL mediaRange=\"{a}-{b}\"/>");
592                }
593                s.push_str("        </SegmentList>\n");
594            }
595        }
596    }
597    s.push_str("      </Representation>\n");
598}
599
600/// Emit `<S>` entries, collapsing runs of equal durations with `r=`.
601fn render_timeline(s: &mut String, durations: &[u64], start_t: u64) {
602    let mut t = start_t;
603    let mut i = 0;
604    let mut first = true;
605    while i < durations.len() {
606        let d = durations[i];
607        let mut run = 1;
608        while i + run < durations.len() && durations[i + run] == d {
609            run += 1;
610        }
611        s.push_str("            <S");
612        if first {
613            let _ = write!(s, " t=\"{t}\"");
614            first = false;
615        }
616        let _ = write!(s, " d=\"{d}\"");
617        if run > 1 {
618            let _ = write!(s, " r=\"{}\"", run - 1);
619        }
620        s.push_str("/>\n");
621        t += d * run as u64;
622        i += run;
623    }
624}
625
626/// Format seconds as an ISO 8601 duration (e.g. `PT1M30.500S`).
627pub fn iso8601_duration(seconds: f64) -> String {
628    let total_ms = (seconds * 1000.0).round() as i64;
629    let sign = if total_ms < 0 { "-" } else { "" };
630    let total_ms = total_ms.unsigned_abs();
631    let h = total_ms / 3_600_000;
632    let m = (total_ms % 3_600_000) / 60_000;
633    let sec = (total_ms % 60_000) as f64 / 1000.0;
634    let mut out = format!("{sign}PT");
635    if h > 0 {
636        let _ = write!(out, "{h}H");
637    }
638    if m > 0 {
639        let _ = write!(out, "{m}M");
640    }
641    let _ = write!(out, "{sec}S");
642    out
643}
644
645fn xml_escape(s: &str) -> String {
646    s.replace('&', "&amp;")
647        .replace('<', "&lt;")
648        .replace('>', "&gt;")
649        .replace('"', "&quot;")
650        .replace('\'', "&apos;")
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use sheathe_core::{Codec, Timescale};
657
658    fn video_stream() -> StreamInfo {
659        StreamInfo {
660            kind: MediaKind::Video,
661            codec: Codec::H264,
662            timescale: Timescale(90000),
663            resolution: Some((1280, 720)),
664            sample_rate: None,
665            bitrate: Some(2_500_000),
666            codec_string: Some("avc1.64001f".into()),
667        }
668    }
669
670    fn audio_stream() -> StreamInfo {
671        StreamInfo {
672            kind: MediaKind::Audio,
673            codec: Codec::Aac,
674            timescale: Timescale(48000),
675            resolution: None,
676            sample_rate: Some(48000),
677            bitrate: Some(128_000),
678            codec_string: Some("mp4a.40.2".into()),
679        }
680    }
681
682    #[test]
683    fn static_vod_mpd_shape() {
684        let rep = Representation::new(
685            "0",
686            video_stream(),
687            "init_0.mp4",
688            "seg_0_$Number$.m4s",
689            90000,
690            vec![540_000, 540_000, 270_000],
691        );
692        let xml = Manifest::static_vod(15.0, vec![rep], None).to_xml();
693        assert!(xml.contains("type=\"static\""));
694        assert!(xml.contains("mediaPresentationDuration=\"PT15S\""));
695        assert!(xml.contains("<Period id=\"0\""));
696        assert!(xml.contains("startNumber=\"1\""));
697        assert!(xml.contains("d=\"540000\" r=\"1\""));
698        assert!(xml.contains("d=\"270000\""));
699        assert!(!xml.contains("availabilityStartTime"));
700    }
701
702    #[test]
703    fn dynamic_live_mpd_shape() {
704        let mut rep = Representation::new(
705            "0",
706            video_stream(),
707            "init_0.mp4",
708            "seg_0_$Number$.m4s",
709            90000,
710            vec![540_000, 540_000, 540_000],
711        );
712        rep.set_start_number(10);
713        rep.set_presentation_time_offset(9 * 540_000);
714        let xml = Manifest::dynamic_live(
715            "2026-07-18T00:00:00Z",
716            "2026-07-18T00:01:00Z",
717            30.0,
718            2.0,
719            10.0,
720            vec![rep],
721            None,
722        )
723        .to_xml();
724        assert!(xml.contains("type=\"dynamic\""));
725        assert!(xml.contains("availabilityStartTime=\"2026-07-18T00:00:00Z\""));
726        assert!(xml.contains("publishTime=\"2026-07-18T00:01:00Z\""));
727        assert!(xml.contains("minimumUpdatePeriod=\"PT2S\""));
728        assert!(xml.contains("timeShiftBufferDepth=\"PT30S\""));
729        assert!(xml.contains("suggestedPresentationDelay=\"PT10S\""));
730        assert!(xml.contains("<UTCTiming"));
731        assert!(xml.contains("startNumber=\"10\""));
732        assert!(xml.contains("presentationTimeOffset=\"4860000\""));
733        assert!(!xml.contains("mediaPresentationDuration"));
734        assert!(!xml.contains("#EXT-X-ENDLIST")); // sanity: not HLS
735    }
736
737    #[test]
738    fn multi_period_and_scte35_event_stream() {
739        let v = Representation::new(
740            "v0",
741            video_stream(),
742            "init_v0.mp4",
743            "seg_v0_$Number$.m4s",
744            90000,
745            vec![900_000],
746        );
747        let a = Representation::new(
748            "a0",
749            audio_stream(),
750            "init_a0.mp4",
751            "seg_a0_$Number$.m4s",
752            48000,
753            vec![480_000],
754        );
755        let events = EventStream::scte35_bin(
756            90000,
757            vec![DashEvent {
758                id: Some("1".into()),
759                presentation_time: 0,
760                duration: Some(900_000),
761                message_data: Some("BASE64SCTE".into()),
762            }],
763        );
764        let m = Manifest {
765            mpd_type: MpdType::Static,
766            duration_seconds: Some(20.0),
767            periods: vec![
768                Period {
769                    id: "p0".into(),
770                    start_seconds: Some(0.0),
771                    duration_seconds: Some(10.0),
772                    representations: vec![v.clone()],
773                    event_streams: vec![events],
774                },
775                Period {
776                    id: "p1".into(),
777                    start_seconds: Some(10.0),
778                    duration_seconds: Some(10.0),
779                    representations: vec![a],
780                    event_streams: Vec::new(),
781                },
782            ],
783            ..Manifest::default()
784        };
785        let xml = m.to_xml();
786        assert!(xml.contains("<Period id=\"p0\""));
787        assert!(xml.contains("<Period id=\"p1\""));
788        assert!(xml.contains("start=\"PT10S\""));
789        assert!(xml.contains("schemeIdUri=\"urn:scte:scte35:2014:xml+bin\""));
790        assert!(xml.contains("BASE64SCTE"));
791    }
792
793    #[test]
794    fn trick_play_and_ll_dash_fields() {
795        let mut main = Representation::new(
796            "0",
797            video_stream(),
798            "init_0.mp4",
799            "seg_0_$Number$.m4s",
800            90000,
801            vec![540_000],
802        );
803        main.set_availability_time_offset(Some(3.5));
804        let mut trick = Representation::new(
805            "0_trick",
806            video_stream(),
807            "init_0_trick.mp4",
808            "seg_0_trick_$Number$.m4s",
809            90000,
810            vec![540_000],
811        );
812        trick.max_playout_rate = Some(8.0);
813        let xml = Manifest::static_vod(6.0, vec![main, trick], None).to_xml();
814        assert!(xml.contains("availabilityTimeOffset=\"3.5\""));
815        assert!(xml.contains("availabilityTimeComplete=\"false\""));
816        assert!(xml.contains("maxPlayoutRate=\"8\""));
817        assert!(xml.contains("http://dashif.org/guidelines/trickmode"));
818        // Two AdaptationSets for video (normal + trick).
819        assert_eq!(xml.matches("contentType=\"video\"").count(), 2);
820    }
821
822    #[test]
823    fn on_demand_segment_list_shape() {
824        let rep = Representation::on_demand(
825            "0",
826            video_stream(),
827            90000,
828            vec![540_000, 540_000],
829            SegmentBaseInfo {
830                base_url: "rep_0.mp4".into(),
831                init_range: (0, 799),
832                index_range: None,
833                media_ranges: vec![(800, 1999), (2000, 3199)],
834            },
835        );
836        let mut m = Manifest::static_vod(12.0, vec![rep], None);
837        m.profile = DashProfile::OnDemand;
838        let xml = m.to_xml();
839        assert!(xml.contains("isoff-on-demand:2011"));
840        assert!(xml.contains("<BaseURL>rep_0.mp4</BaseURL>"));
841        assert!(xml.contains("<SegmentList"));
842        assert!(xml.contains("range=\"0-799\""));
843        assert!(xml.contains("mediaRange=\"800-1999\""));
844        assert!(xml.contains("mediaRange=\"2000-3199\""));
845        assert!(!xml.contains("SegmentTemplate"));
846    }
847
848    #[test]
849    fn iso8601_formats() {
850        assert_eq!(iso8601_duration(0.0), "PT0S");
851        assert_eq!(iso8601_duration(90.5), "PT1M30.5S");
852        assert_eq!(iso8601_duration(3661.0), "PT1H1M1S");
853    }
854}