1use anyhow::{Context, Result};
26use std::fs::{self, File};
27use std::io::{BufWriter, Write};
28use std::path::{Path, PathBuf};
29
30use crate::cmaf::CmafTrackManifest;
31
32#[derive(Debug, Clone)]
34pub struct VideoVariantSpec {
35 pub width: u32,
37 pub height: u32,
39 pub frame_rate: f64,
42 pub average_bandwidth_bps: u32,
45 pub bandwidth_bps: u32,
51 pub codec_string: String,
55 pub supplemental_codecs: Option<String>,
73 pub video_range: Option<&'static str>,
80 pub relative_dir: String,
84 pub manifest: CmafTrackManifest,
87}
88
89#[derive(Debug, Clone)]
95pub struct AudioVariantSpec {
96 pub codec_string: String,
99 pub channels: u16,
101 #[allow(dead_code)]
105 pub sample_rate: u32,
106 pub relative_dir: String,
108 pub language: String,
110 pub name: String,
112 pub manifest: CmafTrackManifest,
113}
114
115#[derive(Debug, Clone)]
119pub struct HlsManifestPaths {
120 pub master_path: PathBuf,
121 pub video_playlist_paths: Vec<PathBuf>,
122 pub audio_playlist_path: Option<PathBuf>,
126}
127
128pub fn write_hls_package(
140 output_dir: &Path,
141 video_variants: &[VideoVariantSpec],
142 audio: Option<&AudioVariantSpec>,
143 target_duration_seconds: u32,
144) -> Result<HlsManifestPaths> {
145 fs::create_dir_all(output_dir)
146 .with_context(|| format!("creating HLS output dir: {}", output_dir.display()))?;
147
148 let mut video_playlist_paths = Vec::with_capacity(video_variants.len());
150 for v in video_variants {
151 let dir = output_dir.join(&v.relative_dir);
152 fs::create_dir_all(&dir)
153 .with_context(|| format!("creating video variant dir: {}", dir.display()))?;
154 let path = dir.join("playlist.m3u8");
155 write_media_playlist(&path, &v.manifest, target_duration_seconds)
156 .with_context(|| format!("writing video media playlist: {}", path.display()))?;
157 video_playlist_paths.push(path);
158 }
159
160 let audio_playlist_path = if let Some(audio) = audio {
162 let audio_dir = output_dir.join(&audio.relative_dir);
163 fs::create_dir_all(&audio_dir)
164 .with_context(|| format!("creating audio variant dir: {}", audio_dir.display()))?;
165 let path = audio_dir.join("audio.m3u8");
166 write_media_playlist(&path, &audio.manifest, target_duration_seconds)
167 .with_context(|| format!("writing audio media playlist: {}", path.display()))?;
168 Some(path)
169 } else {
170 None
171 };
172
173 let master_path = output_dir.join("master.m3u8");
176 write_master_playlist(&master_path, video_variants, audio)
177 .with_context(|| format!("writing master playlist: {}", master_path.display()))?;
178
179 Ok(HlsManifestPaths {
180 master_path,
181 video_playlist_paths,
182 audio_playlist_path,
183 })
184}
185
186fn write_media_playlist(
203 path: &Path,
204 manifest: &CmafTrackManifest,
205 target_duration_seconds: u32,
206) -> Result<()> {
207 let file = File::create(path)?;
208 let mut w = BufWriter::new(file);
209
210 writeln!(w, "#EXTM3U")?;
211 writeln!(w, "#EXT-X-VERSION:7")?;
212 writeln!(w, "#EXT-X-TARGETDURATION:{}", target_duration_seconds)?;
213 writeln!(w, "#EXT-X-PLAYLIST-TYPE:VOD")?;
214 writeln!(
215 w,
216 "#EXT-X-MAP:URI=\"{}\"",
217 manifest
218 .init_path
219 .file_name()
220 .and_then(|s| s.to_str())
221 .unwrap_or("init.mp4")
222 )?;
223
224 for seg in &manifest.segments {
225 let dur = seg.duration_ticks as f64 / manifest.timescale as f64;
226 writeln!(w, "#EXTINF:{:.6},", dur)?;
230 let name = seg
231 .path
232 .file_name()
233 .and_then(|s| s.to_str())
234 .ok_or_else(|| anyhow::anyhow!("segment path has no filename"))?;
235 writeln!(w, "{name}")?;
236 }
237
238 writeln!(w, "#EXT-X-ENDLIST")?;
239 w.flush()?;
240 Ok(())
241}
242
243fn write_master_playlist(
259 path: &Path,
260 video_variants: &[VideoVariantSpec],
261 audio: Option<&AudioVariantSpec>,
262) -> Result<()> {
263 let body = render_master_playlist_to_string(video_variants, audio);
264 let file = File::create(path)?;
265 let mut w = BufWriter::new(file);
266 w.write_all(body.as_bytes())?;
267 w.flush()?;
268 Ok(())
269}
270
271fn render_master_playlist_to_string(
282 video_variants: &[VideoVariantSpec],
283 audio: Option<&AudioVariantSpec>,
284) -> String {
285 use std::fmt::Write;
286
287 let mut out = String::with_capacity(256 + video_variants.len() * 192);
288 let _ = writeln!(out, "#EXTM3U");
289 let _ = writeln!(out, "#EXT-X-VERSION:7");
290 let _ = writeln!(out, "#EXT-X-INDEPENDENT-SEGMENTS");
291 let _ = writeln!(out);
292
293 if let Some(audio) = audio {
298 let _ = write!(out, "#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aac\"");
299 let _ = write!(out, ",NAME=\"{}\"", escape_attr(&audio.name));
300 let _ = write!(out, ",DEFAULT=YES,AUTOSELECT=YES");
301 let _ = write!(out, ",LANGUAGE=\"{}\"", escape_attr(&audio.language));
302 let _ = write!(out, ",CHANNELS=\"{}\"", audio.channels);
303 let _ = writeln!(out, ",URI=\"{}/audio.m3u8\"", audio.relative_dir);
304 let _ = writeln!(out);
305 }
306
307 let mut sorted: Vec<&VideoVariantSpec> = video_variants.iter().collect();
309 sorted.sort_by_key(|v| v.bandwidth_bps);
310
311 for v in sorted {
312 let _ = write!(out, "#EXT-X-STREAM-INF");
313 let _ = write!(out, ":BANDWIDTH={}", v.bandwidth_bps);
314 let _ = write!(out, ",AVERAGE-BANDWIDTH={}", v.average_bandwidth_bps);
315 match audio {
320 Some(audio) => {
321 let _ = write!(out, ",CODECS=\"{},{}\"", v.codec_string, audio.codec_string);
322 }
323 None => {
324 let _ = write!(out, ",CODECS=\"{}\"", v.codec_string);
325 }
326 }
327 if let Some(supp) = v.supplemental_codecs.as_ref() {
328 let _ = write!(out, ",SUPPLEMENTAL-CODECS=\"{}\"", supp);
329 }
330 if let Some(vr) = v.video_range {
331 let _ = write!(out, ",VIDEO-RANGE={}", vr);
332 }
333 let _ = write!(out, ",RESOLUTION={}x{}", v.width, v.height);
334 let _ = write!(out, ",FRAME-RATE={:.3}", v.frame_rate);
335 if audio.is_some() {
336 let _ = writeln!(out, ",AUDIO=\"aac\"");
337 } else {
338 let _ = writeln!(out);
339 }
340 let _ = writeln!(out, "{}/playlist.m3u8", v.relative_dir);
341 }
342
343 out
344}
345
346fn escape_attr(s: &str) -> String {
351 s.chars()
352 .filter(|c| *c != '"' && *c != '\n' && *c != '\r')
353 .collect()
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::cmaf::SegmentInfo;
360
361 fn synth_manifest(timescale: u32, durations_ticks: &[u64]) -> CmafTrackManifest {
362 let segments: Vec<SegmentInfo> = durations_ticks
363 .iter()
364 .enumerate()
365 .map(|(i, &d)| SegmentInfo {
366 sequence_number: (i + 1) as u32,
367 path: PathBuf::from(format!("seg-{:05}.m4s", i + 1)),
368 byte_size: 1024,
369 duration_ticks: d,
370 })
371 .collect();
372 CmafTrackManifest {
373 init_path: PathBuf::from("init.mp4"),
374 segments,
375 timescale,
376 }
377 }
378
379 #[test]
380 fn media_playlist_includes_all_required_v7_tags() {
381 let dir = tempfile::tempdir().unwrap();
382 let path = dir.path().join("playlist.m3u8");
383 let manifest = synth_manifest(30000, &[120_000, 120_000, 120_000]);
384 write_media_playlist(&path, &manifest, 4).unwrap();
385 let body = fs::read_to_string(&path).unwrap();
386 assert!(body.starts_with("#EXTM3U\n"));
387 assert!(body.contains("#EXT-X-VERSION:7\n"));
388 assert!(body.contains("#EXT-X-TARGETDURATION:4\n"));
389 assert!(body.contains("#EXT-X-PLAYLIST-TYPE:VOD\n"));
390 assert!(body.contains("#EXT-X-MAP:URI=\"init.mp4\""));
391 assert!(body.contains("#EXTINF:4.000000,"));
392 assert!(body.contains("seg-00001.m4s\n"));
393 assert!(body.contains("seg-00003.m4s\n"));
394 assert!(body.trim_end().ends_with("#EXT-X-ENDLIST"));
395 }
396
397 #[test]
398 fn media_playlist_uses_real_segment_durations_not_nominal() {
399 let dir = tempfile::tempdir().unwrap();
400 let path = dir.path().join("playlist.m3u8");
401 let manifest = synth_manifest(30000, &[120_000, 120_000, 87_500]);
403 write_media_playlist(&path, &manifest, 4).unwrap();
404 let body = fs::read_to_string(&path).unwrap();
405 assert!(body.contains("#EXTINF:2.916667,"), "got: {body}");
407 }
408
409 #[test]
410 fn master_playlist_orders_variants_by_ascending_bandwidth() {
411 let dir = tempfile::tempdir().unwrap();
412 let path = dir.path().join("master.m3u8");
413 let video_manifest = synth_manifest(30000, &[120_000]);
414
415 let v1080 = VideoVariantSpec {
416 width: 1920,
417 height: 1080,
418 frame_rate: 30.0,
419 average_bandwidth_bps: 3_000_000,
420 bandwidth_bps: 4_500_000,
421 codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
422 supplemental_codecs: None,
423 video_range: None,
424 relative_dir: "video/1080p".into(),
425 manifest: video_manifest.clone(),
426 };
427 let v720 = VideoVariantSpec {
428 width: 1280,
429 height: 720,
430 frame_rate: 30.0,
431 average_bandwidth_bps: 1_600_000,
432 bandwidth_bps: 2_400_000,
433 codec_string: "av01.0.06M.08.0.001.001.001.0".into(),
434 supplemental_codecs: None,
435 video_range: None,
436 relative_dir: "video/720p".into(),
437 manifest: video_manifest.clone(),
438 };
439 let v480 = VideoVariantSpec {
440 width: 854,
441 height: 480,
442 frame_rate: 30.0,
443 average_bandwidth_bps: 800_000,
444 bandwidth_bps: 1_200_000,
445 codec_string: "av01.0.04M.08.0.001.001.001.0".into(),
446 supplemental_codecs: None,
447 video_range: None,
448 relative_dir: "video/480p".into(),
449 manifest: video_manifest.clone(),
450 };
451
452 let audio = AudioVariantSpec {
453 codec_string: "mp4a.40.2".into(),
454 channels: 2,
455 sample_rate: 48000,
456 relative_dir: "audio".into(),
457 language: "und".into(),
458 name: "Default".into(),
459 manifest: synth_manifest(48000, &[192_000]),
460 };
461
462 write_master_playlist(&path, &[v1080, v720, v480], Some(&audio)).unwrap();
464 let body = fs::read_to_string(&path).unwrap();
465
466 let p480 = body
468 .find("video/480p/playlist.m3u8")
469 .expect("480p variant present");
470 let p720 = body
471 .find("video/720p/playlist.m3u8")
472 .expect("720p variant present");
473 let p1080 = body
474 .find("video/1080p/playlist.m3u8")
475 .expect("1080p variant present");
476 assert!(p480 < p720, "480p must come before 720p");
477 assert!(p720 < p1080, "720p must come before 1080p");
478 }
479
480 #[test]
481 fn master_playlist_emits_required_top_level_tags() {
482 let dir = tempfile::tempdir().unwrap();
483 let path = dir.path().join("master.m3u8");
484 let video_manifest = synth_manifest(30000, &[120_000]);
485 let v = VideoVariantSpec {
486 width: 1920,
487 height: 1080,
488 frame_rate: 30.0,
489 average_bandwidth_bps: 3_000_000,
490 bandwidth_bps: 4_500_000,
491 codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
492 supplemental_codecs: None,
493 video_range: None,
494 relative_dir: "video/1080p".into(),
495 manifest: video_manifest,
496 };
497 let audio = AudioVariantSpec {
498 codec_string: "mp4a.40.2".into(),
499 channels: 2,
500 sample_rate: 48000,
501 relative_dir: "audio".into(),
502 language: "und".into(),
503 name: "Default".into(),
504 manifest: synth_manifest(48000, &[192_000]),
505 };
506 write_master_playlist(&path, &[v], Some(&audio)).unwrap();
507 let body = fs::read_to_string(&path).unwrap();
508
509 assert!(body.starts_with("#EXTM3U"));
510 assert!(body.contains("#EXT-X-VERSION:7"));
511 assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS"));
512 assert!(body.contains("#EXT-X-MEDIA:TYPE=AUDIO"));
513 assert!(body.contains("GROUP-ID=\"aac\""));
514 assert!(body.contains("DEFAULT=YES"));
515 assert!(body.contains("URI=\"audio/audio.m3u8\""));
516 assert!(body.contains("#EXT-X-STREAM-INF"));
517 assert!(body.contains("BANDWIDTH=4500000"));
518 assert!(body.contains("AVERAGE-BANDWIDTH=3000000"));
519 assert!(body.contains("CODECS=\"av01.0.08M.08.0.001.001.001.0,mp4a.40.2\""));
520 assert!(body.contains("RESOLUTION=1920x1080"));
521 assert!(body.contains("FRAME-RATE=30.000"));
522 assert!(body.contains("AUDIO=\"aac\""));
523 }
524
525 #[test]
526 fn write_hls_package_emits_full_directory_tree() {
527 let dir = tempfile::tempdir().unwrap();
528 let video_manifest = CmafTrackManifest {
529 init_path: dir.path().join("video/1080p/init.mp4"),
530 segments: vec![SegmentInfo {
531 sequence_number: 1,
532 path: dir.path().join("video/1080p/seg-00001.m4s"),
533 byte_size: 1024,
534 duration_ticks: 120_000,
535 }],
536 timescale: 30000,
537 };
538 let audio_manifest = CmafTrackManifest {
539 init_path: dir.path().join("audio/init.mp4"),
540 segments: vec![SegmentInfo {
541 sequence_number: 1,
542 path: dir.path().join("audio/seg-00001.m4s"),
543 byte_size: 256,
544 duration_ticks: 192_000,
545 }],
546 timescale: 48000,
547 };
548
549 let v = VideoVariantSpec {
550 width: 1920,
551 height: 1080,
552 frame_rate: 30.0,
553 average_bandwidth_bps: 3_000_000,
554 bandwidth_bps: 4_500_000,
555 codec_string: "av01.0.08M.08.0.001.001.001.0".into(),
556 supplemental_codecs: None,
557 video_range: None,
558 relative_dir: "video/1080p".into(),
559 manifest: video_manifest,
560 };
561 let a = AudioVariantSpec {
562 codec_string: "mp4a.40.2".into(),
563 channels: 2,
564 sample_rate: 48000,
565 relative_dir: "audio".into(),
566 language: "und".into(),
567 name: "Default".into(),
568 manifest: audio_manifest,
569 };
570
571 let paths = write_hls_package(dir.path(), &[v], Some(&a), 4).unwrap();
572
573 assert!(paths.master_path.exists());
574 assert_eq!(paths.video_playlist_paths.len(), 1);
575 assert!(paths.video_playlist_paths[0].exists());
576 let audio_pl_path = paths.audio_playlist_path.expect("audio playlist set");
577 assert!(audio_pl_path.exists());
578
579 let audio_pl = fs::read_to_string(&audio_pl_path).unwrap();
581 assert!(audio_pl.contains("#EXT-X-MAP:URI=\"init.mp4\""));
582 assert!(audio_pl.contains("#EXTINF:4.000000,"));
583 assert!(audio_pl.contains("seg-00001.m4s"));
584 }
585
586 #[test]
587 fn master_playlist_omits_audio_when_video_only() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("master.m3u8");
590 let video_manifest = synth_manifest(30000, &[120_000]);
591 let v = VideoVariantSpec {
592 width: 1920,
593 height: 1080,
594 frame_rate: 30.0,
595 average_bandwidth_bps: 3_000_000,
596 bandwidth_bps: 4_500_000,
597 codec_string: "av01.0.08M.08".into(),
598 supplemental_codecs: None,
599 video_range: None,
600 relative_dir: "video/1080p".into(),
601 manifest: video_manifest,
602 };
603 write_master_playlist(&path, &[v], None).unwrap();
604 let body = fs::read_to_string(&path).unwrap();
605
606 assert!(body.starts_with("#EXTM3U"));
607 assert!(body.contains("#EXT-X-VERSION:7"));
608 assert!(body.contains("#EXT-X-INDEPENDENT-SEGMENTS"));
609 assert!(!body.contains("#EXT-X-MEDIA:TYPE=AUDIO"), "got: {body}");
611 assert!(body.contains("CODECS=\"av01.0.08M.08\""), "got: {body}");
613 assert!(!body.contains("mp4a.40.2"), "got: {body}");
614 assert!(!body.contains("AUDIO=\"aac\""), "got: {body}");
616 }
617
618 #[test]
619 fn write_hls_package_video_only_emits_no_audio_dir() {
620 let dir = tempfile::tempdir().unwrap();
621 let video_manifest = CmafTrackManifest {
622 init_path: dir.path().join("video/720p/init.mp4"),
623 segments: vec![SegmentInfo {
624 sequence_number: 1,
625 path: dir.path().join("video/720p/seg-00001.m4s"),
626 byte_size: 1024,
627 duration_ticks: 120_000,
628 }],
629 timescale: 30000,
630 };
631 let v = VideoVariantSpec {
632 width: 1280,
633 height: 720,
634 frame_rate: 30.0,
635 average_bandwidth_bps: 1_600_000,
636 bandwidth_bps: 2_400_000,
637 codec_string: "av01.0.05M.08".into(),
638 supplemental_codecs: None,
639 video_range: None,
640 relative_dir: "video/720p".into(),
641 manifest: video_manifest,
642 };
643 let paths = write_hls_package(dir.path(), &[v], None, 4).unwrap();
644 assert!(paths.master_path.exists());
645 assert_eq!(paths.video_playlist_paths.len(), 1);
646 assert!(paths.audio_playlist_path.is_none());
647 assert!(
648 !dir.path().join("audio").exists(),
649 "no audio dir should be created"
650 );
651 }
652
653 #[test]
654 fn escape_attr_strips_disallowed_characters() {
655 assert_eq!(escape_attr(r#"hello"world"#), "helloworld");
656 assert_eq!(escape_attr("with\nnewline"), "withnewline");
657 assert_eq!(escape_attr("with\rcarriage"), "withcarriage");
658 assert_eq!(escape_attr("normal text"), "normal text");
659 }
660}