use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use crate::cenc::CencScheme;
pub const CENC_KEYFORMAT: &str = "urn:mpeg:dash:mp4protection:2011";
pub const CENC_KEYFORMATVERSIONS: &str = "1";
pub fn cenc_ext_x_key(scheme: CencScheme, kid: &[u8; 16], key_uri: &str) -> Option<String> {
if scheme != CencScheme::Cbcs {
return None;
}
Some(format!(
"#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"{key_uri}\",KEYFORMAT=\"{CENC_KEYFORMAT}\",\
KEYFORMATVERSIONS=\"{CENC_KEYFORMATVERSIONS}\",KEYID=0x{}",
crate::rtp::hex_encode(kid)
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PartSpec {
pub uri: String,
pub duration: f64,
pub independent: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MediaSegment {
pub uri: String,
pub duration: f64,
pub discontinuous: bool,
pub parts: Vec<PartSpec>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MediaPlaylist {
pub version: u8,
pub target_duration: u32,
pub media_sequence: u64,
pub discontinuity_sequence: u64,
pub segments: Vec<MediaSegment>,
pub endlist: bool,
pub extra_tags: Vec<String>,
pub low_latency: Option<LowLatencyConfig>,
pub iframes_only: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LowLatencyConfig {
pub part_target: f64,
pub part_hold_back: f64,
pub preload_hint_part: Option<String>,
}
impl LowLatencyConfig {
pub fn effective_part_hold_back(&self) -> f64 {
let floor = 3.0 * self.part_target;
if self.part_hold_back < floor {
floor
} else {
self.part_hold_back
}
}
}
impl MediaPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
let version = if self.iframes_only {
self.version.max(4)
} else {
self.version
};
s.push_str(&format!("#EXT-X-VERSION:{version}\n"));
if self.iframes_only {
s.push_str("#EXT-X-I-FRAMES-ONLY\n");
}
s.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", self.target_duration));
s.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", self.media_sequence));
if self.discontinuity_sequence > 0 {
s.push_str(&format!(
"#EXT-X-DISCONTINUITY-SEQUENCE:{}\n",
self.discontinuity_sequence
));
}
if let Some(ll) = &self.low_latency {
s.push_str(&format!(
"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={}\n",
format_secs(ll.effective_part_hold_back()),
));
s.push_str(&format!(
"#EXT-X-PART-INF:PART-TARGET={}\n",
format_secs(ll.part_target),
));
}
for tag in &self.extra_tags {
s.push_str(tag);
s.push('\n');
}
for seg in &self.segments {
if seg.discontinuous {
s.push_str("#EXT-X-DISCONTINUITY\n");
}
if self.low_latency.is_some() {
for part in &seg.parts {
s.push_str(&format!(
"#EXT-X-PART:DURATION={},URI=\"{}\"",
format_secs(part.duration),
part.uri,
));
if part.independent {
s.push_str(",INDEPENDENT=YES");
}
s.push('\n');
}
}
s.push_str(&format!("#EXTINF:{:.3},\n", seg.duration));
s.push_str(&seg.uri);
s.push('\n');
}
if let Some(ll) = &self.low_latency {
if let Some(uri) = &ll.preload_hint_part {
s.push_str(&format!("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{uri}\"\n"));
}
}
if self.endlist {
s.push_str("#EXT-X-ENDLIST\n");
}
s
}
}
fn format_secs(v: f64) -> String {
let millis = (v * 1000.0 + 0.5) as u64;
let whole = millis / 1000;
let frac = millis % 1000;
if frac == 0 {
return format!("{whole}");
}
let mut f = format!("{frac:03}");
while f.ends_with('0') {
f.pop();
}
format!("{whole}.{f}")
}
#[derive(Debug, Clone, PartialEq)]
pub struct Variant {
pub bandwidth: u32,
pub codecs: String,
pub resolution: Option<(u32, u32)>,
pub uri: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IFrameVariant {
pub bandwidth: u32,
pub codecs: Option<String>,
pub resolution: Option<(u32, u32)>,
pub uri: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MasterPlaylist {
pub version: u8,
pub variants: Vec<Variant>,
pub iframe_variants: Vec<IFrameVariant>,
}
impl MasterPlaylist {
pub fn to_m3u8(&self) -> String {
let mut s = String::new();
s.push_str("#EXTM3U\n");
s.push_str(&format!("#EXT-X-VERSION:{}\n", self.version));
for var in &self.variants {
s.push_str(&format!(
"#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"",
var.bandwidth, var.codecs,
));
if let Some((w, h)) = var.resolution {
s.push_str(&format!(",RESOLUTION={w}x{h}"));
}
s.push('\n');
s.push_str(&var.uri);
s.push('\n');
}
for iv in &self.iframe_variants {
s.push_str(&format!(
"#EXT-X-I-FRAME-STREAM-INF:BANDWIDTH={}",
iv.bandwidth
));
if let Some(c) = &iv.codecs {
s.push_str(&format!(",CODECS=\"{c}\""));
}
if let Some((w, h)) = iv.resolution {
s.push_str(&format!(",RESOLUTION={w}x{h}"));
}
s.push_str(&format!(",URI=\"{}\"\n", iv.uri));
}
s
}
}
pub fn mark_init_discontinuities(entries: &mut [(&[u8], &mut MediaSegment)]) {
if entries.len() < 2 {
return;
}
for i in 1..entries.len() {
let (head, tail) = entries.split_at_mut(i);
let prev_init: &[u8] = head[i - 1].0;
let cur = &mut tail[0];
if cur.0 != prev_init {
cur.1.discontinuous = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(uri: &str, duration: f64) -> MediaSegment {
MediaSegment {
uri: uri.into(),
duration,
discontinuous: false,
parts: vec![],
}
}
fn seg_disc(uri: &str, duration: f64) -> MediaSegment {
MediaSegment {
uri: uri.into(),
duration,
discontinuous: true,
parts: vec![],
}
}
fn playlist(segments: Vec<MediaSegment>) -> MediaPlaylist {
MediaPlaylist {
version: 3,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
segments,
endlist: true,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
}
}
#[test]
fn media_playlist_basic() {
let pl = MediaPlaylist {
version: 3,
target_duration: 10,
media_sequence: 0,
discontinuity_sequence: 0,
segments: vec![
seg("seg0.m4s", 9.009),
seg("seg1.m4s", 9.009),
seg("seg2.m4s", 3.003),
],
endlist: true,
extra_tags: vec![
"#EXT-X-DATERANGE:ID=\"ad-1\",START-DATE=\"2024-01-01T00:00:00.000Z\",DURATION=15.0"
.into(),
],
low_latency: None,
iframes_only: false,
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert!(out.contains("#EXT-X-TARGETDURATION:10\n"));
assert!(out.contains("#EXT-X-MEDIA-SEQUENCE:0\n"));
assert_eq!(out.matches("#EXTINF:").count(), 3);
assert!(out.ends_with("#EXT-X-ENDLIST\n"));
assert!(out.contains("#EXT-X-DATERANGE:ID=\"ad-1\""));
assert!(!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"));
}
#[test]
fn media_playlist_no_endlist() {
let pl = MediaPlaylist {
version: 7,
target_duration: 6,
media_sequence: 42,
discontinuity_sequence: 0,
segments: vec![seg("seg.m4s", 6.000)],
endlist: false,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert!(out.contains("#EXT-X-VERSION:7\n"));
assert!(!out.contains("#EXT-X-ENDLIST"));
}
#[test]
fn master_playlist_basic() {
let pl = MasterPlaylist {
version: 6,
variants: vec![
Variant {
bandwidth: 300_000,
codecs: "avc1.64001e,mp4a.40.2".into(),
resolution: Some((640, 360)),
uri: "v300/index.m3u8".into(),
},
Variant {
bandwidth: 800_000,
codecs: "avc1.640028,mp4a.40.2".into(),
resolution: Some((1280, 720)),
uri: "v800/index.m3u8".into(),
},
],
iframe_variants: vec![],
};
let out = pl.to_m3u8();
assert!(out.starts_with("#EXTM3U\n"));
assert_eq!(out.matches("#EXT-X-STREAM-INF:").count(), 2);
assert!(out.contains("v300/index.m3u8"));
assert!(out.contains("v800/index.m3u8"));
assert!(out.contains("RESOLUTION=640x360"));
assert!(out.contains("RESOLUTION=1280x720"));
}
#[test]
fn master_playlist_no_resolution() {
let pl = MasterPlaylist {
version: 6,
variants: vec![Variant {
bandwidth: 1_000_000,
codecs: "avc1.640028".into(),
resolution: None,
uri: "v1k/index.m3u8".into(),
}],
iframe_variants: vec![],
};
let out = pl.to_m3u8();
assert!(!out.contains("RESOLUTION"));
assert!(out.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000000,CODECS=\"avc1.640028\""));
}
#[test]
fn extinf_three_decimals() {
let pl = playlist(vec![seg("s.m4s", 9.0)]);
let out = pl.to_m3u8();
assert!(out.contains("#EXTINF:9.000,\n"));
}
#[test]
fn discontinuity_tag_emitted_before_extinf() {
let pl = playlist(vec![
seg("s0.m4s", 5.0),
seg_disc("s1.m4s", 5.0),
seg("s2.m4s", 5.0),
]);
let out = pl.to_m3u8();
assert_eq!(out.matches("#EXT-X-DISCONTINUITY\n").count(), 1);
let disc_pos = out.find("#EXT-X-DISCONTINUITY\n").unwrap();
let extinf_pos = out.find("#EXTINF:5.000,\n#s1.m4s\n").unwrap_or_else(|| {
let s1_pos = out.find("s1.m4s\n").unwrap();
out[..s1_pos].rfind("#EXTINF:").unwrap()
});
assert!(
disc_pos < extinf_pos,
"#EXT-X-DISCONTINUITY must appear before #EXTINF of s1"
);
let tag_end = disc_pos + "#EXT-X-DISCONTINUITY\n".len();
assert!(
out[tag_end..].starts_with("#EXTINF:"),
"#EXT-X-DISCONTINUITY must be immediately before #EXTINF, got: {:?}",
&out[tag_end..tag_end + 20]
);
}
#[test]
fn no_discontinuity_tag_when_all_continuous() {
let pl = playlist(vec![
seg("s0.m4s", 5.0),
seg("s1.m4s", 5.0),
seg("s2.m4s", 5.0),
]);
let out = pl.to_m3u8();
assert!(
!out.contains("#EXT-X-DISCONTINUITY\n"),
"no tag when all segments are continuous"
);
}
#[test]
fn discontinuity_sequence_emitted_when_nonzero() {
let pl = MediaPlaylist {
version: 3,
target_duration: 6,
media_sequence: 5,
discontinuity_sequence: 2,
segments: vec![seg("s5.m4s", 6.0)],
endlist: false,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
};
let out = pl.to_m3u8();
assert!(
out.contains("#EXT-X-DISCONTINUITY-SEQUENCE:2\n"),
"header must be present when n>0"
);
}
#[test]
fn discontinuity_sequence_absent_when_zero() {
let pl = playlist(vec![seg("s0.m4s", 6.0)]);
let out = pl.to_m3u8();
assert!(
!out.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
"header must be absent when n==0"
);
}
}