|
3 | 3 | //! URIs are relative to the master playlist (`/<broadcast>/master.m3u8`), so a |
4 | 4 | //! rendition's `<name>/media.m3u8` resolves under the broadcast directory. |
5 | 5 |
|
| 6 | +use std::collections::BTreeMap; |
6 | 7 | use std::fmt::Write; |
7 | 8 |
|
8 | 9 | const VERSION: u32 = 9; |
@@ -32,41 +33,86 @@ pub struct AudioVariant { |
32 | 33 | pub codec: String, |
33 | 34 | } |
34 | 35 |
|
35 | | -/// Render the multivariant playlist. The first audio rendition is marked default. |
| 36 | +struct AudioGroup<'a> { |
| 37 | + id: String, |
| 38 | + bandwidth: u64, |
| 39 | + codec: &'a str, |
| 40 | + variants: Vec<&'a AudioVariant>, |
| 41 | +} |
| 42 | + |
| 43 | +fn group_audio(audio: &[AudioVariant]) -> Vec<AudioGroup<'_>> { |
| 44 | + let mut codecs = BTreeMap::<&str, Vec<&AudioVariant>>::new(); |
| 45 | + for variant in audio { |
| 46 | + codecs.entry(&variant.codec).or_default().push(variant); |
| 47 | + } |
| 48 | + |
| 49 | + let multiple = codecs.len() > 1; |
| 50 | + codecs |
| 51 | + .into_iter() |
| 52 | + .enumerate() |
| 53 | + .map(|(index, (codec, variants))| AudioGroup { |
| 54 | + id: if multiple { |
| 55 | + format!("{AUDIO_GROUP}-{index}") |
| 56 | + } else { |
| 57 | + AUDIO_GROUP.to_string() |
| 58 | + }, |
| 59 | + bandwidth: variants |
| 60 | + .iter() |
| 61 | + .map(|variant| variant.bandwidth) |
| 62 | + .max() |
| 63 | + .unwrap_or_default(), |
| 64 | + codec, |
| 65 | + variants, |
| 66 | + }) |
| 67 | + .collect() |
| 68 | +} |
| 69 | + |
| 70 | +fn render_video(out: &mut String, variant: &VideoVariant, audio: Option<&AudioGroup<'_>>) { |
| 71 | + let bandwidth = variant |
| 72 | + .bandwidth |
| 73 | + .saturating_add(audio.map_or(0, |group| group.bandwidth)); |
| 74 | + let codecs = audio.map_or_else( |
| 75 | + || variant.codec.clone(), |
| 76 | + |group| format!("{},{}", variant.codec, group.codec), |
| 77 | + ); |
| 78 | + let mut line = format!("#EXT-X-STREAM-INF:BANDWIDTH={bandwidth}"); |
| 79 | + if let (Some(w), Some(h)) = (variant.width, variant.height) { |
| 80 | + let _ = write!(line, ",RESOLUTION={w}x{h}"); |
| 81 | + } |
| 82 | + let _ = write!(line, ",CODECS=\"{codecs}\""); |
| 83 | + if let Some(group) = audio { |
| 84 | + let _ = write!(line, ",AUDIO=\"{}\"", group.id); |
| 85 | + } |
| 86 | + let _ = writeln!(out, "{line}"); |
| 87 | + let _ = writeln!(out, "{}/media.m3u8", variant.name); |
| 88 | +} |
| 89 | + |
| 90 | +/// Render the multivariant playlist. The first rendition in each audio codec group is default. |
36 | 91 | pub fn render_master(video: &[VideoVariant], audio: &[AudioVariant]) -> String { |
37 | 92 | let mut out = String::new(); |
38 | 93 | let _ = writeln!(out, "#EXTM3U"); |
39 | 94 | let _ = writeln!(out, "#EXT-X-VERSION:{VERSION}"); |
40 | 95 |
|
41 | | - let has_audio = !audio.is_empty(); |
42 | | - for (index, variant) in audio.iter().enumerate() { |
43 | | - let default = if index == 0 { "YES" } else { "NO" }; |
44 | | - let _ = writeln!( |
45 | | - out, |
46 | | - "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"{AUDIO_GROUP}\",NAME=\"{}\",DEFAULT={default},AUTOSELECT=YES,URI=\"{}/media.m3u8\"", |
47 | | - variant.name, variant.name |
48 | | - ); |
| 96 | + let audio_groups = group_audio(audio); |
| 97 | + for group in &audio_groups { |
| 98 | + for (index, variant) in group.variants.iter().enumerate() { |
| 99 | + let default = if index == 0 { "YES" } else { "NO" }; |
| 100 | + let _ = writeln!( |
| 101 | + out, |
| 102 | + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"{}\",NAME=\"{}\",DEFAULT={default},AUTOSELECT=YES,URI=\"{}/media.m3u8\"", |
| 103 | + group.id, variant.name, variant.name |
| 104 | + ); |
| 105 | + } |
49 | 106 | } |
50 | 107 |
|
51 | | - // One audio codec is enough for the combined CODECS attribute. |
52 | | - let audio_codec = audio.first().map(|a| a.codec.as_str()); |
53 | | - |
54 | 108 | for variant in video { |
55 | | - let codecs = match audio_codec { |
56 | | - Some(audio) => format!("{},{}", variant.codec, audio), |
57 | | - None => variant.codec.clone(), |
58 | | - }; |
59 | | - |
60 | | - let mut line = format!("#EXT-X-STREAM-INF:BANDWIDTH={}", variant.bandwidth); |
61 | | - if let (Some(w), Some(h)) = (variant.width, variant.height) { |
62 | | - let _ = write!(line, ",RESOLUTION={w}x{h}"); |
| 109 | + if audio_groups.is_empty() { |
| 110 | + render_video(&mut out, variant, None); |
| 111 | + } else { |
| 112 | + for group in &audio_groups { |
| 113 | + render_video(&mut out, variant, Some(group)); |
| 114 | + } |
63 | 115 | } |
64 | | - let _ = write!(line, ",CODECS=\"{codecs}\""); |
65 | | - if has_audio { |
66 | | - let _ = write!(line, ",AUDIO=\"{AUDIO_GROUP}\""); |
67 | | - } |
68 | | - let _ = writeln!(out, "{line}"); |
69 | | - let _ = writeln!(out, "{}/media.m3u8", variant.name); |
70 | 116 | } |
71 | 117 |
|
72 | 118 | // Audio-only broadcast: still expose a playable variant per audio rendition. |
@@ -109,11 +155,57 @@ mod tests { |
109 | 155 | "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"audio\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio/media.m3u8\"\n" |
110 | 156 | )); |
111 | 157 | assert!(out.contains( |
112 | | - "#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,mp4a.40.2\",AUDIO=\"aud\"\n" |
| 158 | + "#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,mp4a.40.2\",AUDIO=\"aud\"\n" |
113 | 159 | )); |
114 | 160 | assert!(out.contains("\nvideo/media.m3u8\n")); |
115 | 161 | } |
116 | 162 |
|
| 163 | + #[test] |
| 164 | + fn separates_audio_codecs_into_accurate_variants() { |
| 165 | + let video = vec![VideoVariant { |
| 166 | + name: "video".into(), |
| 167 | + bandwidth: 2_500_000, |
| 168 | + width: Some(1280), |
| 169 | + height: Some(720), |
| 170 | + codec: "avc1.42c01f".into(), |
| 171 | + }]; |
| 172 | + let audio = vec![ |
| 173 | + AudioVariant { |
| 174 | + name: "aac-low".into(), |
| 175 | + bandwidth: 96_000, |
| 176 | + codec: "mp4a.40.2".into(), |
| 177 | + }, |
| 178 | + AudioVariant { |
| 179 | + name: "aac-high".into(), |
| 180 | + bandwidth: 128_000, |
| 181 | + codec: "mp4a.40.2".into(), |
| 182 | + }, |
| 183 | + AudioVariant { |
| 184 | + name: "opus".into(), |
| 185 | + bandwidth: 160_000, |
| 186 | + codec: "opus".into(), |
| 187 | + }, |
| 188 | + ]; |
| 189 | + |
| 190 | + let out = render_master(&video, &audio); |
| 191 | + assert!(out.contains( |
| 192 | + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud-0\",NAME=\"aac-low\",DEFAULT=YES,AUTOSELECT=YES,URI=\"aac-low/media.m3u8\"\n" |
| 193 | + )); |
| 194 | + assert!(out.contains( |
| 195 | + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud-0\",NAME=\"aac-high\",DEFAULT=NO,AUTOSELECT=YES,URI=\"aac-high/media.m3u8\"\n" |
| 196 | + )); |
| 197 | + assert!(out.contains( |
| 198 | + "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud-1\",NAME=\"opus\",DEFAULT=YES,AUTOSELECT=YES,URI=\"opus/media.m3u8\"\n" |
| 199 | + )); |
| 200 | + assert!(out.contains( |
| 201 | + "#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,mp4a.40.2\",AUDIO=\"aud-0\"\n" |
| 202 | + )); |
| 203 | + assert!(out.contains( |
| 204 | + "#EXT-X-STREAM-INF:BANDWIDTH=2660000,RESOLUTION=1280x720,CODECS=\"avc1.42c01f,opus\",AUDIO=\"aud-1\"\n" |
| 205 | + )); |
| 206 | + assert_eq!(out.matches("\nvideo/media.m3u8\n").count(), 2); |
| 207 | + } |
| 208 | + |
117 | 209 | #[test] |
118 | 210 | fn audio_only_is_playable() { |
119 | 211 | let audio = vec![AudioVariant { |
|
0 commit comments