use std::fmt::Write as FmtWrite;
#[derive(Clone, Debug)]
pub struct DashSegmentTimelineEntry {
pub t: Option<u64>,
pub d: u64,
pub r: u32,
}
#[derive(Clone, Debug)]
pub struct DashSegmentTimeline {
pub entries: Vec<DashSegmentTimelineEntry>,
}
#[derive(Clone, Debug)]
pub struct DashSegmentTemplate {
pub timescale: u32,
pub duration: Option<u64>,
pub initialization: String,
pub media: String,
pub start_number: u32,
pub segment_timeline: Option<DashSegmentTimeline>,
}
#[derive(Clone, Debug)]
pub struct DashRepresentation {
pub id: String,
pub bandwidth: u64,
pub width: Option<u32>,
pub height: Option<u32>,
pub frame_rate: Option<String>,
pub audio_sampling_rate: Option<u32>,
pub segment_template: DashSegmentTemplate,
}
#[derive(Clone, Debug)]
pub struct DashAdaptationSet {
pub id: u32,
pub content_type: String,
pub mime_type: String,
pub codecs: String,
pub representations: Vec<DashRepresentation>,
}
#[derive(Clone, Debug)]
pub struct DashManifestConfig {
pub media_presentation_duration: String,
pub min_buffer_time: String,
pub base_url: Option<String>,
pub adaptation_sets: Vec<DashAdaptationSet>,
}
#[must_use]
pub fn emit_mpd(config: &DashManifestConfig) -> String {
let mut out = String::with_capacity(4096);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
writeln!(
&mut out,
"<MPD\n xmlns=\"urn:mpeg:dash:schema:mpd:2011\"\n \
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n \
xsi:schemaLocation=\"urn:mpeg:dash:schema:mpd:2011 \
http://standards.iso.org/ittf/PubliclyAvailableStandards/\
MPEG-DASH_schema_files/DASH-MPD.xsd\"\n \
profiles=\"urn:mpeg:dash:profile:isoff-on-demand:2011\"\n \
type=\"static\"\n \
mediaPresentationDuration=\"{mpd}\"\n \
minBufferTime=\"{mbt}\">",
mpd = xml_escape(&config.media_presentation_duration),
mbt = xml_escape(&config.min_buffer_time),
)
.unwrap_or_default();
if let Some(ref base) = config.base_url {
writeln!(&mut out, " <BaseURL>{}</BaseURL>", xml_escape(base)).unwrap_or_default();
}
out.push_str(" <Period id=\"1\" start=\"PT0S\">\n");
for adapt in &config.adaptation_sets {
emit_adaptation_set(&mut out, adapt);
}
out.push_str(" </Period>\n");
out.push_str("</MPD>\n");
out
}
fn emit_adaptation_set(out: &mut String, adapt: &DashAdaptationSet) {
writeln!(
out,
" <AdaptationSet\n id=\"{id}\"\n \
contentType=\"{ct}\"\n \
mimeType=\"{mt}\"\n \
codecs=\"{co}\"\n \
segmentAlignment=\"true\"\n \
subsegmentAlignment=\"true\">",
id = adapt.id,
ct = xml_escape(&adapt.content_type),
mt = xml_escape(&adapt.mime_type),
co = xml_escape(&adapt.codecs),
)
.unwrap_or_default();
for repr in &adapt.representations {
emit_representation(out, repr);
}
out.push_str(" </AdaptationSet>\n");
}
fn emit_representation(out: &mut String, repr: &DashRepresentation) {
let mut attrs = format!(
" id=\"{}\" bandwidth=\"{}\"",
xml_escape(&repr.id),
repr.bandwidth
);
if let Some(w) = repr.width {
attrs.push_str(&format!(" width=\"{w}\""));
}
if let Some(h) = repr.height {
attrs.push_str(&format!(" height=\"{h}\""));
}
if let Some(ref fr) = repr.frame_rate {
attrs.push_str(&format!(" frameRate=\"{}\"", xml_escape(fr)));
}
if let Some(asr) = repr.audio_sampling_rate {
attrs.push_str(&format!(" audioSamplingRate=\"{asr}\""));
}
writeln!(out, " <Representation\n{attrs}>").unwrap_or_default();
emit_segment_template(out, &repr.segment_template);
out.push_str(" </Representation>\n");
}
fn emit_segment_template(out: &mut String, tmpl: &DashSegmentTemplate) {
let mut attrs = format!(
" timescale=\"{}\" startNumber=\"{}\"",
tmpl.timescale, tmpl.start_number,
);
if tmpl.segment_timeline.is_none() {
if let Some(dur) = tmpl.duration {
attrs.push_str(&format!(" duration=\"{dur}\""));
}
}
attrs.push_str(&format!(
" initialization=\"{}\" media=\"{}\"",
xml_escape(&tmpl.initialization),
xml_escape(&tmpl.media),
));
if let Some(ref timeline) = tmpl.segment_timeline {
writeln!(out, " <SegmentTemplate\n{attrs}>").unwrap_or_default();
out.push_str(" <SegmentTimeline>\n");
for entry in &timeline.entries {
let mut s_attrs = format!(" d=\"{}\"", entry.d);
if let Some(t) = entry.t {
s_attrs = format!(" t=\"{t}\"{s_attrs}");
}
if entry.r > 0 {
s_attrs.push_str(&format!(" r=\"{}\"", entry.r));
}
writeln!(out, " <S{s_attrs}/>").unwrap_or_default();
}
out.push_str(" </SegmentTimeline>\n");
out.push_str(" </SegmentTemplate>\n");
} else {
writeln!(out, " <SegmentTemplate\n{attrs}/>").unwrap_or_default();
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn make_minimal_config() -> DashManifestConfig {
DashManifestConfig {
media_presentation_duration: "PT10S".to_string(),
min_buffer_time: "PT2S".to_string(),
base_url: None,
adaptation_sets: vec![DashAdaptationSet {
id: 1,
content_type: "video".to_string(),
mime_type: "video/mp4".to_string(),
codecs: "av01.0.04M.08".to_string(),
representations: vec![DashRepresentation {
id: "v1".to_string(),
bandwidth: 1_000_000,
width: Some(1280),
height: Some(720),
frame_rate: Some("25".to_string()),
audio_sampling_rate: None,
segment_template: DashSegmentTemplate {
timescale: 90000,
duration: Some(270000),
initialization: "v1/init.mp4".to_string(),
media: "v1/seg$Number$.m4s".to_string(),
start_number: 1,
segment_timeline: None,
},
}],
}],
}
}
#[test]
fn test_emit_mpd_contains_profile() {
let config = make_minimal_config();
let mpd = emit_mpd(&config);
assert!(
mpd.contains("urn:mpeg:dash:profile:isoff-on-demand:2011"),
"MPD should contain the DASH on-demand profile URI"
);
}
#[test]
fn test_emit_mpd_contains_segment_template() {
let config = make_minimal_config();
let mpd = emit_mpd(&config);
assert!(
mpd.contains("SegmentTemplate"),
"MPD should contain a SegmentTemplate element"
);
assert!(
mpd.contains("$Number$"),
"Media template should contain $Number$ placeholder"
);
}
#[test]
fn test_emit_mpd_representation_ids() {
let config = DashManifestConfig {
media_presentation_duration: "PT30S".to_string(),
min_buffer_time: "PT4S".to_string(),
base_url: None,
adaptation_sets: vec![DashAdaptationSet {
id: 1,
content_type: "video".to_string(),
mime_type: "video/mp4".to_string(),
codecs: "av01.0.08M.10".to_string(),
representations: vec![
DashRepresentation {
id: "video_1080p".to_string(),
bandwidth: 4_000_000,
width: Some(1920),
height: Some(1080),
frame_rate: Some("30000/1001".to_string()),
audio_sampling_rate: None,
segment_template: DashSegmentTemplate {
timescale: 90000,
duration: Some(270000),
initialization: "1080p/init.mp4".to_string(),
media: "1080p/$Number$.m4s".to_string(),
start_number: 1,
segment_timeline: None,
},
},
DashRepresentation {
id: "video_720p".to_string(),
bandwidth: 2_000_000,
width: Some(1280),
height: Some(720),
frame_rate: Some("30000/1001".to_string()),
audio_sampling_rate: None,
segment_template: DashSegmentTemplate {
timescale: 90000,
duration: Some(270000),
initialization: "720p/init.mp4".to_string(),
media: "720p/$Number$.m4s".to_string(),
start_number: 1,
segment_timeline: None,
},
},
],
}],
};
let mpd = emit_mpd(&config);
assert!(
mpd.contains("video_1080p"),
"MPD should contain 1080p representation ID"
);
assert!(
mpd.contains("video_720p"),
"MPD should contain 720p representation ID"
);
}
#[test]
fn test_emit_mpd_with_base_url() {
let mut config = make_minimal_config();
config.base_url = Some("https://cdn.example.com/".to_string());
let mpd = emit_mpd(&config);
assert!(mpd.contains("<BaseURL>https://cdn.example.com/</BaseURL>"));
}
#[test]
fn test_emit_mpd_audio_representation() {
let config = DashManifestConfig {
media_presentation_duration: "PT10S".to_string(),
min_buffer_time: "PT2S".to_string(),
base_url: None,
adaptation_sets: vec![DashAdaptationSet {
id: 2,
content_type: "audio".to_string(),
mime_type: "audio/mp4".to_string(),
codecs: "opus".to_string(),
representations: vec![DashRepresentation {
id: "audio_opus_48k".to_string(),
bandwidth: 128_000,
width: None,
height: None,
frame_rate: None,
audio_sampling_rate: Some(48000),
segment_template: DashSegmentTemplate {
timescale: 48000,
duration: Some(96000),
initialization: "audio/init.mp4".to_string(),
media: "audio/$Number$.m4s".to_string(),
start_number: 1,
segment_timeline: None,
},
}],
}],
};
let mpd = emit_mpd(&config);
assert!(mpd.contains("audio_opus_48k"));
assert!(mpd.contains("audioSamplingRate=\"48000\""));
assert!(mpd.contains("contentType=\"audio\""));
}
#[test]
fn test_emit_mpd_segment_timeline() {
let config = DashManifestConfig {
media_presentation_duration: "PT10S".to_string(),
min_buffer_time: "PT2S".to_string(),
base_url: None,
adaptation_sets: vec![DashAdaptationSet {
id: 1,
content_type: "video".to_string(),
mime_type: "video/mp4".to_string(),
codecs: "av01.0.04M.08".to_string(),
representations: vec![DashRepresentation {
id: "v1".to_string(),
bandwidth: 1_000_000,
width: Some(1280),
height: Some(720),
frame_rate: None,
audio_sampling_rate: None,
segment_template: DashSegmentTemplate {
timescale: 90000,
duration: None,
initialization: "v1/init.mp4".to_string(),
media: "v1/seg$Time$.m4s".to_string(),
start_number: 1,
segment_timeline: Some(DashSegmentTimeline {
entries: vec![
DashSegmentTimelineEntry {
t: Some(0),
d: 270000,
r: 2,
},
DashSegmentTimelineEntry {
t: None,
d: 180000,
r: 0,
},
],
}),
},
}],
}],
};
let mpd = emit_mpd(&config);
assert!(mpd.contains("SegmentTimeline"));
assert!(mpd.contains("<S"));
assert!(mpd.contains("d=\"270000\""));
}
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("a&b"), "a&b");
assert_eq!(xml_escape("<tag>"), "<tag>");
assert_eq!(xml_escape("\"hello\""), ""hello"");
assert_eq!(xml_escape("it's"), "it's");
assert_eq!(xml_escape("normal"), "normal");
}
}