use std::fmt::Write as FmtWrite;
#[derive(Debug, Clone)]
pub struct LlDashConfig {
pub segment_duration_ms: u32,
pub fragment_duration_ms: u32,
pub availability_time_offset_sec: f32,
}
impl Default for LlDashConfig {
fn default() -> Self {
Self {
segment_duration_ms: 2000,
fragment_duration_ms: 500,
availability_time_offset_sec: 1.5,
}
}
}
impl LlDashConfig {
#[must_use]
pub fn derived_ato(&self) -> f32 {
let diff = self
.segment_duration_ms
.saturating_sub(self.fragment_duration_ms);
diff as f32 / 1000.0
}
pub fn validate(&self) -> Result<(), String> {
if self.fragment_duration_ms == 0 {
return Err("fragment_duration_ms must be > 0".to_owned());
}
if self.fragment_duration_ms > self.segment_duration_ms {
return Err(format!(
"fragment_duration_ms ({}) must not exceed segment_duration_ms ({})",
self.fragment_duration_ms, self.segment_duration_ms
));
}
if self.segment_duration_ms % self.fragment_duration_ms != 0 {
return Err(format!(
"fragment_duration_ms ({}) must evenly divide segment_duration_ms ({})",
self.fragment_duration_ms, self.segment_duration_ms
));
}
Ok(())
}
#[must_use]
pub fn fragments_per_segment(&self) -> u32 {
if self.fragment_duration_ms == 0 {
return 1;
}
self.segment_duration_ms / self.fragment_duration_ms
}
#[must_use]
pub fn segment_duration_secs(&self) -> f64 {
self.segment_duration_ms as f64 / 1000.0
}
#[must_use]
pub fn fragment_duration_secs(&self) -> f64 {
self.fragment_duration_ms as f64 / 1000.0
}
}
#[derive(Debug, Clone)]
pub struct DashRepresentation {
pub id: String,
pub bandwidth: u32,
pub codecs: String,
pub width: u32,
pub height: u32,
}
impl DashRepresentation {
#[must_use]
pub fn avc_high(id: impl Into<String>, width: u32, height: u32, bandwidth: u32) -> Self {
Self {
id: id.into(),
bandwidth,
codecs: "avc1.640028".to_owned(),
width,
height,
}
}
#[must_use]
pub fn av1(id: impl Into<String>, width: u32, height: u32, bandwidth: u32) -> Self {
Self {
id: id.into(),
bandwidth,
codecs: "av01.0.08M.08".to_owned(),
width,
height,
}
}
#[must_use]
pub fn to_xml(&self) -> String {
format!(
" <Representation id=\"{}\" bandwidth=\"{}\" codecs=\"{}\" width=\"{}\" height=\"{}\"/>",
self.id, self.bandwidth, self.codecs, self.width, self.height
)
}
}
pub struct LlDashMpd {
pub config: LlDashConfig,
pub representations: Vec<DashRepresentation>,
}
impl LlDashMpd {
#[must_use]
pub fn new(config: LlDashConfig) -> Self {
Self {
config,
representations: Vec::new(),
}
}
pub fn add_representation(&mut self, rep: DashRepresentation) {
self.representations.push(rep);
}
#[must_use]
pub fn build_mpd_xml(&self) -> String {
let mut xml = String::with_capacity(2048);
let seg_dur = self.config.segment_duration_secs();
let frag_dur = self.config.fragment_duration_secs();
let ato = self.config.availability_time_offset_sec;
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\"\n");
xml.push_str(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.push_str(" type=\"dynamic\"\n");
let _ = writeln!(xml, " minimumUpdatePeriod=\"PT{frag_dur:.3}S\"");
let _ = writeln!(xml, " minBufferTime=\"PT{seg_dur:.3}S\"");
xml.push_str(
" profiles=\"urn:mpeg:dash:profile:isoff-live:2011,urn:mpeg:dash:profile:cmaf:2019\">\n",
);
let target_latency_ms = (seg_dur * 1.5 * 1000.0) as u32;
xml.push_str(" <ServiceDescription id=\"0\">\n");
let _ = writeln!(
xml,
" <Latency target=\"{target_latency_ms}\" min=\"{}\" max=\"{}\"/>",
(target_latency_ms as f64 * 0.5) as u32,
target_latency_ms * 3
);
xml.push_str(" <PlaybackRate min=\"0.96\" max=\"1.04\"/>\n");
xml.push_str(" </ServiceDescription>\n");
xml.push_str(" <Period id=\"0\" start=\"PT0S\">\n");
xml.push_str(" <AdaptationSet mimeType=\"video/mp4\" contentType=\"video\">\n");
let _ = writeln!(
xml,
" <SegmentTemplate timescale=\"90000\"\n media=\"segment_$Number$.m4s\"\n initialization=\"init.mp4\"\n duration=\"{}\"\n availabilityTimeComplete=\"false\"\n availabilityTimeOffset=\"{ato:.3}\">",
(seg_dur * 90000.0) as u64
);
xml.push_str(" <SegmentTimeline/>\n");
xml.push_str(" </SegmentTemplate>\n");
for rep in &self.representations {
let _ = writeln!(xml, "{}", rep.to_xml());
}
xml.push_str(" </AdaptationSet>\n");
xml.push_str(" </Period>\n");
xml.push_str("</MPD>\n");
xml
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> LlDashConfig {
LlDashConfig::default()
}
fn make_rep() -> DashRepresentation {
DashRepresentation::avc_high("1080p", 1920, 1080, 4_000_000)
}
#[test]
fn test_default_config_values() {
let cfg = default_config();
assert_eq!(cfg.segment_duration_ms, 2000);
assert_eq!(cfg.fragment_duration_ms, 500);
assert!((cfg.availability_time_offset_sec - 1.5).abs() < 1e-6);
}
#[test]
fn test_validate_default_ok() {
assert!(default_config().validate().is_ok());
}
#[test]
fn test_validate_zero_fragment() {
let mut cfg = default_config();
cfg.fragment_duration_ms = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_non_divisible() {
let mut cfg = default_config();
cfg.fragment_duration_ms = 300;
assert!(cfg.validate().is_err());
}
#[test]
fn test_validate_fragment_exceeds_segment() {
let mut cfg = default_config();
cfg.fragment_duration_ms = 3000;
assert!(cfg.validate().is_err());
}
#[test]
fn test_fragments_per_segment() {
let cfg = default_config(); assert_eq!(cfg.fragments_per_segment(), 4);
}
#[test]
fn test_derived_ato() {
let cfg = default_config(); assert!((cfg.derived_ato() - 1.5_f32).abs() < 1e-6);
}
#[test]
fn test_avc_high_codecs() {
let rep = DashRepresentation::avc_high("r1", 1920, 1080, 4_000_000);
assert_eq!(rep.codecs, "avc1.640028");
assert_eq!(rep.width, 1920);
assert_eq!(rep.height, 1080);
assert_eq!(rep.bandwidth, 4_000_000);
}
#[test]
fn test_av1_codecs() {
let rep = DashRepresentation::av1("r2", 1280, 720, 2_000_000);
assert!(rep.codecs.starts_with("av01"));
}
#[test]
fn test_representation_to_xml() {
let rep = make_rep();
let xml = rep.to_xml();
assert!(xml.contains("id=\"1080p\""));
assert!(xml.contains("bandwidth=\"4000000\""));
assert!(xml.contains("codecs=\"avc1.640028\""));
}
#[test]
fn test_mpd_xml_opening() {
let mpd = LlDashMpd {
config: default_config(),
representations: vec![make_rep()],
};
let xml = mpd.build_mpd_xml();
assert!(xml.contains("<MPD"));
assert!(xml.contains("type=\"dynamic\""));
}
#[test]
fn test_mpd_xml_atc_false() {
let mpd = LlDashMpd {
config: default_config(),
representations: vec![],
};
let xml = mpd.build_mpd_xml();
assert!(xml.contains("availabilityTimeComplete=\"false\""));
}
#[test]
fn test_mpd_xml_ato() {
let cfg = LlDashConfig {
segment_duration_ms: 2000,
fragment_duration_ms: 500,
availability_time_offset_sec: 1.5,
};
let mpd = LlDashMpd {
config: cfg,
representations: vec![],
};
let xml = mpd.build_mpd_xml();
assert!(xml.contains("availabilityTimeOffset=\"1.500\""));
}
#[test]
fn test_mpd_xml_service_description() {
let mpd = LlDashMpd {
config: default_config(),
representations: vec![],
};
let xml = mpd.build_mpd_xml();
assert!(xml.contains("<ServiceDescription"));
assert!(xml.contains("<Latency"));
}
#[test]
fn test_mpd_xml_representations() {
let mut mpd = LlDashMpd::new(default_config());
mpd.add_representation(DashRepresentation::avc_high("1080p", 1920, 1080, 4_000_000));
mpd.add_representation(DashRepresentation::avc_high("720p", 1280, 720, 2_000_000));
let xml = mpd.build_mpd_xml();
assert!(xml.contains("id=\"1080p\""));
assert!(xml.contains("id=\"720p\""));
}
#[test]
fn test_add_representation() {
let mut mpd = LlDashMpd::new(default_config());
assert_eq!(mpd.representations.len(), 0);
mpd.add_representation(make_rep());
assert_eq!(mpd.representations.len(), 1);
}
#[test]
fn test_segment_duration_secs() {
let cfg = default_config();
assert!((cfg.segment_duration_secs() - 2.0).abs() < 1e-9);
}
#[test]
fn test_fragment_duration_secs() {
let cfg = default_config();
assert!((cfg.fragment_duration_secs() - 0.5).abs() < 1e-9);
}
#[test]
fn test_mpd_xml_closes_mpd_element() {
let mpd = LlDashMpd {
config: default_config(),
representations: vec![make_rep()],
};
let xml = mpd.build_mpd_xml();
assert!(xml.ends_with("</MPD>\n"));
}
#[test]
fn test_ull_config() {
let cfg = LlDashConfig {
segment_duration_ms: 1000,
fragment_duration_ms: 250,
availability_time_offset_sec: 0.75,
};
assert!(cfg.validate().is_ok());
assert_eq!(cfg.fragments_per_segment(), 4);
assert!((cfg.derived_ato() - 0.75_f32).abs() < 1e-5);
}
}