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