#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdaptationSetKind {
Video,
Audio,
Subtitle,
}
impl AdaptationSetKind {
fn content_type(&self) -> &'static str {
match self {
Self::Video => "video",
Self::Audio => "audio",
Self::Subtitle => "text",
}
}
fn mime_type(&self) -> &'static str {
match self {
Self::Video => "video/mp4",
Self::Audio => "audio/mp4",
Self::Subtitle => "application/mp4",
}
}
}
#[derive(Debug, Clone)]
pub struct DashRepresentation {
pub id: String,
pub bandwidth: u64,
pub codecs: Option<String>,
pub init_url: String,
pub media_url: String,
pub segment_duration_ms: u64,
pub width: Option<u32>,
pub height: Option<u32>,
pub sample_rate: Option<u32>,
}
impl DashRepresentation {
#[must_use]
pub fn video(
id: impl Into<String>,
bandwidth: u64,
init_url: impl Into<String>,
media_url: impl Into<String>,
) -> Self {
Self {
id: id.into(),
bandwidth,
codecs: None,
init_url: init_url.into(),
media_url: media_url.into(),
segment_duration_ms: 4000,
width: None,
height: None,
sample_rate: None,
}
}
#[must_use]
pub fn audio(
id: impl Into<String>,
bandwidth: u64,
init_url: impl Into<String>,
media_url: impl Into<String>,
) -> Self {
Self {
id: id.into(),
bandwidth,
codecs: None,
init_url: init_url.into(),
media_url: media_url.into(),
segment_duration_ms: 4000,
width: None,
height: None,
sample_rate: Some(48000),
}
}
}
#[derive(Debug, Clone)]
pub struct DashAdaptationSet {
pub kind: AdaptationSetKind,
pub lang: Option<String>,
pub representations: Vec<DashRepresentation>,
}
impl DashAdaptationSet {
#[must_use]
pub fn new(kind: AdaptationSetKind) -> Self {
Self {
kind,
lang: None,
representations: Vec::new(),
}
}
#[must_use]
pub fn with_lang(mut self, lang: impl Into<String>) -> Self {
self.lang = Some(lang.into());
self
}
pub fn add_representation(&mut self, rep: DashRepresentation) {
self.representations.push(rep);
}
}
#[derive(Debug, Clone, Default)]
pub struct DashManifest {
pub media_duration: String,
pub adaptation_sets: Vec<DashAdaptationSet>,
pub min_buffer_time: Option<String>,
}
impl DashManifest {
#[must_use]
pub fn new(media_duration: impl Into<String>) -> Self {
Self {
media_duration: media_duration.into(),
..Default::default()
}
}
pub fn add_video_adaptation(
&mut self,
resolution: impl Into<String>,
bandwidth: u64,
init_url: impl Into<String>,
media_url: impl Into<String>,
) {
let mut aset = DashAdaptationSet::new(AdaptationSetKind::Video);
aset.add_representation(DashRepresentation::video(
resolution, bandwidth, init_url, media_url,
));
self.adaptation_sets.push(aset);
}
pub fn add_audio_adaptation(
&mut self,
id: impl Into<String>,
bandwidth: u64,
init_url: impl Into<String>,
media_url: impl Into<String>,
) {
let mut aset = DashAdaptationSet::new(AdaptationSetKind::Audio);
aset.add_representation(DashRepresentation::audio(
id, bandwidth, init_url, media_url,
));
self.adaptation_sets.push(aset);
}
#[must_use]
pub fn build_mpd(&self) -> String {
let min_buf = self
.min_buffer_time
.as_deref()
.unwrap_or("PT2S");
let mut out = String::with_capacity(1024);
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<MPD\n");
out.push_str(" xmlns=\"urn:mpeg:dash:schema:mpd:2011\"\n");
out.push_str(" profiles=\"urn:mpeg:dash:profile:isoff-on-demand:2011\"\n");
out.push_str(" type=\"static\"\n");
out.push_str(&format!(
" mediaPresentationDuration=\"{}\"\n",
self.media_duration
));
out.push_str(&format!(" minBufferTime=\"{min_buf}\">\n"));
out.push_str(" <Period start=\"PT0S\">\n");
for (idx, aset) in self.adaptation_sets.iter().enumerate() {
let content_type = aset.kind.content_type();
let mime_type = aset.kind.mime_type();
out.push_str(&format!(
" <AdaptationSet id=\"{idx}\" contentType=\"{content_type}\" mimeType=\"{mime_type}\""
));
if let Some(ref lang) = aset.lang {
out.push_str(&format!(" lang=\"{lang}\""));
}
out.push_str(">\n");
for rep in &aset.representations {
out.push_str(&format!(
" <Representation id=\"{}\" bandwidth=\"{}\"",
rep.id, rep.bandwidth
));
if let Some(ref codecs) = rep.codecs {
out.push_str(&format!(" codecs=\"{codecs}\""));
}
if let (Some(w), Some(h)) = (rep.width, rep.height) {
out.push_str(&format!(" width=\"{w}\" height=\"{h}\""));
}
if let Some(sr) = rep.sample_rate {
out.push_str(&format!(" audioSamplingRate=\"{sr}\""));
}
out.push_str(">\n");
let dur_ms = rep.segment_duration_ms;
out.push_str(&format!(
" <SegmentTemplate\n initialization=\"{}\"\n media=\"{}\"\n duration=\"{dur_ms}\"\n timescale=\"1000\"\n startNumber=\"1\"/>\n",
rep.init_url, rep.media_url
));
out.push_str(" </Representation>\n");
}
out.push_str(" </AdaptationSet>\n");
}
out.push_str(" </Period>\n");
out.push_str("</MPD>\n");
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_manifest() {
let manifest = DashManifest::new("PT0H0M30S");
let mpd = manifest.build_mpd();
assert!(mpd.contains("<MPD"));
assert!(mpd.contains("mediaPresentationDuration=\"PT0H0M30S\""));
assert!(mpd.contains("</MPD>"));
}
#[test]
fn test_manifest_with_video_and_audio() {
let mut manifest = DashManifest::new("PT0H1M0S".to_string());
manifest.add_video_adaptation(
"1280x720",
2_500_000,
"init-video.mp4",
"chunk-video-$Number$.m4s",
);
manifest.add_audio_adaptation(
"stereo",
128_000,
"init-audio.mp4",
"chunk-audio-$Number$.m4s",
);
let mpd = manifest.build_mpd();
assert!(mpd.contains("contentType=\"video\""));
assert!(mpd.contains("contentType=\"audio\""));
assert!(mpd.contains("SegmentTemplate"));
assert!(mpd.contains("init-video.mp4"));
assert!(mpd.contains("chunk-video-$Number$.m4s"));
assert!(mpd.contains("bandwidth=\"2500000\""));
assert!(mpd.contains("bandwidth=\"128000\""));
}
#[test]
fn test_manifest_audio_sample_rate() {
let mut manifest = DashManifest::new("PT30S");
manifest.add_audio_adaptation("audio", 128_000, "init.mp4", "seg-$Number$.m4s");
let mpd = manifest.build_mpd();
assert!(mpd.contains("audioSamplingRate=\"48000\""));
}
#[test]
fn test_adaptation_set_with_lang() {
let mut aset = DashAdaptationSet::new(AdaptationSetKind::Audio)
.with_lang("eng");
aset.add_representation(DashRepresentation::audio(
"audio-eng", 128_000, "init.mp4", "seg-$N$.m4s",
));
let mut manifest = DashManifest::new("PT30S");
manifest.adaptation_sets.push(aset);
let mpd = manifest.build_mpd();
assert!(mpd.contains("lang=\"eng\""));
}
#[test]
fn test_representation_video_dimensions() {
let mut rep = DashRepresentation::video("v1", 5_000_000, "init.mp4", "seg-$N$.m4s");
rep.width = Some(1920);
rep.height = Some(1080);
let mut aset = DashAdaptationSet::new(AdaptationSetKind::Video);
aset.add_representation(rep);
let mut manifest = DashManifest::new("PT60S");
manifest.adaptation_sets.push(aset);
let mpd = manifest.build_mpd();
assert!(mpd.contains("width=\"1920\""));
assert!(mpd.contains("height=\"1080\""));
}
#[test]
fn test_mpd_is_valid_xml_structure() {
let mut manifest = DashManifest::new("PT30S");
manifest.add_video_adaptation("720p", 2_000_000, "i.mp4", "s-$N$.m4s");
let mpd = manifest.build_mpd();
let mpd_open = mpd.find("<MPD").expect("<MPD");
let period_open = mpd.find("<Period").expect("<Period");
let aset_open = mpd.find("<AdaptationSet").expect("<AdaptationSet");
let period_close = mpd.find("</Period>").expect("</Period>");
let mpd_close = mpd.find("</MPD>").expect("</MPD>");
assert!(mpd_open < period_open);
assert!(period_open < aset_open);
assert!(aset_open < period_close);
assert!(period_close < mpd_close);
}
}