use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::core::context::ffmpeg_context::FfmpegContext;
use crate::core::context::input::Input;
use crate::core::context::output::Output;
use crate::core::recipes::hls_master::{generate_master_playlist, MasterVariant};
use crate::core::stream_info::{find_audio_stream_info, find_video_stream_info, StreamInfo};
use crate::error::{Error, Result};
const DEFAULT_SEGMENT_DURATION: f64 = 6.0;
const DEFAULT_VIDEO_CODEC: &str = "libx264";
const DEFAULT_AUDIO_CODEC: &str = "aac";
const DEFAULT_AUDIO_BITRATE: &str = "128k";
const DEFAULT_MASTER_NAME: &str = "master.m3u8";
const SEGMENT_TEMPLATE: &str = "seg_%05d.ts";
const BANDWIDTH_OVERHEAD_NUM: u64 = 11;
const BANDWIDTH_OVERHEAD_DEN: u64 = 10;
const BUFSIZE_MULTIPLIER: u64 = 2;
#[derive(Debug, Clone)]
pub struct Rendition {
pub width: u32,
pub height: u32,
pub video_bitrate: String,
pub name: Option<String>,
}
impl Rendition {
pub fn new(width: u32, height: u32, video_bitrate: impl Into<String>) -> Self {
Self {
width,
height,
video_bitrate: video_bitrate.into(),
name: None,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
fn dir_name(&self) -> String {
match &self.name {
Some(name) => name.clone(),
None => format!("{}p", self.height),
}
}
}
pub struct HlsLadder {
input: Input,
out_dir: PathBuf,
renditions: Vec<Rendition>,
segment_duration_s: f64,
gop_seconds: Option<f64>,
video_codec: String,
audio_codec: String,
audio_bitrate: String,
master_name: String,
fps: Option<(i32, i32)>,
master_codecs: Option<String>,
}
impl HlsLadder {
pub fn new(input: impl Into<Input>, out_dir: impl Into<PathBuf>) -> Self {
Self {
input: input.into(),
out_dir: out_dir.into(),
renditions: Vec::new(),
segment_duration_s: DEFAULT_SEGMENT_DURATION,
gop_seconds: None,
video_codec: DEFAULT_VIDEO_CODEC.to_string(),
audio_codec: DEFAULT_AUDIO_CODEC.to_string(),
audio_bitrate: DEFAULT_AUDIO_BITRATE.to_string(),
master_name: DEFAULT_MASTER_NAME.to_string(),
fps: None,
master_codecs: None,
}
}
pub fn rendition(self, width: u32, height: u32, video_bitrate: impl Into<String>) -> Self {
self.rendition_named(Rendition::new(width, height, video_bitrate))
}
pub fn rendition_named(mut self, rendition: Rendition) -> Self {
self.renditions.push(rendition);
self
}
pub fn segment_duration(mut self, seconds: f64) -> Self {
self.segment_duration_s = seconds;
self
}
pub fn gop_seconds(mut self, seconds: f64) -> Self {
self.gop_seconds = Some(seconds);
self
}
pub fn video_codec(mut self, codec: impl Into<String>) -> Self {
self.video_codec = codec.into();
self
}
pub fn audio_codec(mut self, codec: impl Into<String>) -> Self {
self.audio_codec = codec.into();
self
}
pub fn audio_bitrate(mut self, bitrate: impl Into<String>) -> Self {
self.audio_bitrate = bitrate.into();
self
}
pub fn master(mut self, name: impl Into<String>) -> Self {
self.master_name = name.into();
self
}
pub fn fps(mut self, num: i32, den: i32) -> Self {
self.fps = Some((num, den));
self
}
pub fn codecs(mut self, codecs: impl Into<String>) -> Self {
self.master_codecs = Some(codecs.into());
self
}
pub fn build_context(self) -> Result<FfmpegContext> {
self.validate()?;
let (fps_num, fps_den) = self.resolve_fps()?;
let gop_us = seconds_to_us(self.effective_gop_seconds());
let gop_frames = compute_gop_frames(fps_num, fps_den, gop_us).to_string();
let hls_time = format_seconds(self.segment_duration_s);
let filter_desc = build_split_desc(&self.renditions);
let HlsLadder {
input,
out_dir,
renditions,
video_codec,
audio_codec,
audio_bitrate,
..
} = self;
create_dir(&out_dir)?;
let mut builder = FfmpegContext::builder()
.input(input)
.filter_desc(filter_desc);
for (i, rendition) in renditions.iter().enumerate() {
let rendition_dir = out_dir.join(rendition.dir_name());
create_dir(&rendition_dir)?;
let playlist_path = path_to_utf8(&rendition_dir.join("index.m3u8"))?;
let segment_template = path_to_utf8(&rendition_dir.join(SEGMENT_TEMPLATE))?;
let video_bps = parse_bitrate_bps(&rendition.video_bitrate)?;
let bufsize = video_bps.saturating_mul(BUFSIZE_MULTIPLIER).to_string();
let mut output = Output::from(playlist_path)
.set_format("hls")
.add_stream_map(format!("[v{i}]"))
.add_stream_map("0:a:0?")
.set_video_codec(video_codec.as_str())
.set_video_bitrate(rendition.video_bitrate.clone())
.set_audio_codec(audio_codec.as_str())
.set_audio_bitrate(audio_bitrate.clone())
.set_pix_fmt("yuv420p")
.set_video_codec_opt("g", gop_frames.clone())
.set_video_codec_opt("keyint_min", gop_frames.clone())
.set_video_codec_opt("sc_threshold", "0")
.set_video_codec_opt("maxrate", rendition.video_bitrate.clone())
.set_video_codec_opt("bufsize", bufsize)
.set_format_opt("hls_time", hls_time.clone())
.set_format_opt("hls_playlist_type", "vod")
.set_format_opt("hls_segment_filename", segment_template);
if video_codec.eq_ignore_ascii_case("libx264") {
output = output.set_video_codec_opt("x264-params", "scenecut=0:open-gop=0");
} else if video_codec.eq_ignore_ascii_case("libx265") {
output = output.set_video_codec_opt("x265-params", "scenecut=0:open-gop=0");
}
builder = builder.output(output);
}
builder.build()
}
pub fn run(self) -> Result<()> {
self.validate()?;
let has_audio = self.resolve_has_audio()?;
let variants = self.build_master_variants(has_audio)?;
let master_text = generate_master_playlist(&variants);
let master_path = self.out_dir.join(&self.master_name);
let context = self.build_context()?;
let scheduler = context.start()?;
scheduler.wait()?;
std::fs::write(&master_path, master_text).map_err(|e| {
Error::InvalidRecipeArg(format!(
"failed to write master playlist '{}': {e}",
master_path.display()
))
})?;
Ok(())
}
fn effective_gop_seconds(&self) -> f64 {
self.gop_seconds.unwrap_or(self.segment_duration_s)
}
fn validate(&self) -> Result<()> {
if self.renditions.is_empty() {
return Err(Error::InvalidRecipeArg(
"HLS ladder requires at least one rendition".to_string(),
));
}
validate_path_segment(&self.master_name, "master playlist name")?;
if let Some(codecs) = &self.master_codecs {
if codecs.chars().any(|c| c == '"' || c.is_control()) {
return Err(Error::InvalidRecipeArg(
"codecs must not contain quotes or control characters".to_string(),
));
}
}
if !self.segment_duration_s.is_finite() || self.segment_duration_s <= 0.0 {
return Err(Error::InvalidRecipeArg(format!(
"segment_duration must be finite and positive, got {}",
self.segment_duration_s
)));
}
let gop = self.effective_gop_seconds();
if !gop.is_finite() || gop <= 0.0 {
return Err(Error::InvalidRecipeArg(format!(
"gop_seconds must be finite and positive, got {gop}"
)));
}
let ratio = self.segment_duration_s / gop;
if !ratio.is_finite() || ratio.round() < 1.0 || (ratio - ratio.round()).abs() > 1e-9 {
return Err(Error::InvalidRecipeArg(format!(
"segment_duration ({}) must be a positive integer multiple of gop_seconds ({gop})",
self.segment_duration_s
)));
}
parse_bitrate_bps(&self.audio_bitrate)?;
if let Some((num, den)) = self.fps {
if num <= 0 || den <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"fps override must be positive, got {num}/{den}"
)));
}
}
let mut names = HashSet::new();
for rendition in &self.renditions {
if rendition.width == 0
|| rendition.height == 0
|| rendition.width % 2 != 0
|| rendition.height % 2 != 0
{
return Err(Error::InvalidRecipeArg(format!(
"rendition dimensions must be positive and even, got {}x{}",
rendition.width, rendition.height
)));
}
parse_bitrate_bps(&rendition.video_bitrate)?;
let name = rendition.dir_name();
validate_rendition_name(&name)?;
if !names.insert(name.clone()) {
return Err(Error::InvalidRecipeArg(format!(
"duplicate rendition name '{name}'"
)));
}
}
if names.contains(&self.master_name) {
return Err(Error::InvalidRecipeArg(format!(
"master playlist name '{}' collides with a rendition directory name",
self.master_name
)));
}
Ok(())
}
fn resolve_fps(&self) -> Result<(i64, i64)> {
if let Some((num, den)) = self.fps {
if num <= 0 || den <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"fps override must be positive, got {num}/{den}"
)));
}
return Ok((num as i64, den as i64));
}
let url = self.input.url.as_ref().ok_or_else(|| {
Error::InvalidRecipeArg(
"cannot probe frame rate: input has no URL. Provide an explicit fps() override \
for callback inputs (CFR is required)"
.to_string(),
)
})?;
let info = find_video_stream_info(url.as_str())?.ok_or_else(|| {
Error::InvalidRecipeArg(format!("no video stream found in input '{url}'"))
})?;
match info {
StreamInfo::Video { avg_frame_rate, .. } => {
let (num, den) = (avg_frame_rate.num, avg_frame_rate.den);
if num <= 0 || den <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"input '{url}' has an unusable frame rate ({num}/{den}); ABR ladders \
require CFR — provide an explicit fps() override"
)));
}
Ok((num as i64, den as i64))
}
_ => Err(Error::InvalidRecipeArg(format!(
"no video stream found in input '{url}'"
))),
}
}
fn resolve_has_audio(&self) -> Result<bool> {
match self.input.url.as_ref() {
Some(url) => Ok(find_audio_stream_info(url.as_str())?.is_some()),
None => Ok(false),
}
}
fn build_master_variants(&self, has_audio: bool) -> Result<Vec<MasterVariant>> {
let audio_bps = if has_audio {
Some(parse_bitrate_bps(&self.audio_bitrate)?)
} else {
None
};
let mut variants = Vec::with_capacity(self.renditions.len());
for rendition in &self.renditions {
let video_bps = parse_bitrate_bps(&rendition.video_bitrate)?;
variants.push(MasterVariant {
bandwidth: compute_bandwidth(video_bps, audio_bps),
width: rendition.width,
height: rendition.height,
uri: format!("{}/index.m3u8", rendition.dir_name()),
codecs: self.master_codecs.clone(),
});
}
Ok(variants)
}
}
fn build_split_desc(renditions: &[Rendition]) -> String {
let n = renditions.len();
let mut desc = format!("[0:v]split={n}");
for i in 0..n {
desc.push_str(&format!("[s{i}]"));
}
for (i, rendition) in renditions.iter().enumerate() {
desc.push_str(&format!(
";[s{i}]scale={}:{},setsar=1,format=yuv420p[v{i}]",
rendition.width, rendition.height
));
}
desc
}
fn compute_gop_frames(fps_num: i64, fps_den: i64, dur_us: i64) -> u64 {
if fps_num <= 0 || fps_den <= 0 || dur_us <= 0 {
return 1;
}
let numerator = (fps_num as i128) * (dur_us as i128);
let denominator = (fps_den as i128) * 1_000_000i128;
let gop = (numerator + denominator - 1) / denominator;
u64::try_from(gop.max(1)).unwrap_or(u64::MAX)
}
fn compute_bandwidth(video_bps: u64, audio_bps: Option<u64>) -> u64 {
let base = u128::from(video_bps.saturating_add(audio_bps.unwrap_or(0)));
let scaled = base * u128::from(BANDWIDTH_OVERHEAD_NUM) / u128::from(BANDWIDTH_OVERHEAD_DEN);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
fn parse_bitrate_bps(spec: &str) -> Result<u64> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return Err(Error::InvalidRecipeArg(
"bitrate must not be empty".to_string(),
));
}
let (number, multiplier) = if let Some(rest) = trimmed.strip_suffix(|c| c == 'k' || c == 'K') {
(rest, 1_000f64)
} else if let Some(rest) = trimmed.strip_suffix(|c| c == 'm' || c == 'M') {
(rest, 1_000_000f64)
} else if let Some(rest) = trimmed.strip_suffix(|c| c == 'g' || c == 'G') {
(rest, 1_000_000_000f64)
} else {
(trimmed, 1f64)
};
let value: f64 = number
.trim()
.parse()
.map_err(|_| Error::InvalidRecipeArg(format!("invalid bitrate '{spec}'")))?;
if !value.is_finite() || value <= 0.0 {
return Err(Error::InvalidRecipeArg(format!(
"bitrate must be positive, got '{spec}'"
)));
}
Ok((value * multiplier).round() as u64)
}
fn validate_path_segment(name: &str, what: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidRecipeArg(format!("{what} must not be empty")));
}
if name == "." || name == ".." || name.contains("..") {
return Err(Error::InvalidRecipeArg(format!(
"{what} '{name}' must not contain '..'"
)));
}
if name.contains('/') || name.contains('\\') {
return Err(Error::InvalidRecipeArg(format!(
"{what} '{name}' must not contain a path separator"
)));
}
if name.chars().any(char::is_control) {
return Err(Error::InvalidRecipeArg(format!(
"{what} must not contain control characters"
)));
}
if name.starts_with('#') {
return Err(Error::InvalidRecipeArg(format!(
"{what} '{name}' must not start with '#' (it would break the HLS playlist URI line)"
)));
}
if Path::new(name).is_absolute() {
return Err(Error::InvalidRecipeArg(format!(
"{what} '{name}' must be a relative path"
)));
}
Ok(())
}
fn validate_rendition_name(name: &str) -> Result<()> {
validate_path_segment(name, "rendition name")
}
fn path_to_utf8(path: &Path) -> Result<String> {
path.to_str().map(str::to_string).ok_or_else(|| {
Error::InvalidRecipeArg(format!("path is not valid UTF-8: {}", path.display()))
})
}
fn create_dir(dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|e| {
Error::InvalidRecipeArg(format!(
"failed to create output directory {}: {e}",
dir.display()
))
})
}
fn seconds_to_us(seconds: f64) -> i64 {
(seconds * 1_000_000.0).round() as i64
}
fn format_seconds(seconds: f64) -> String {
seconds.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ladder() -> HlsLadder {
HlsLadder::new("input.mp4", "out")
.rendition(1280, 720, "2800k")
.rendition(640, 360, "800k")
}
#[test]
fn split_desc_single_rendition() {
let renditions = vec![Rendition::new(640, 360, "800k")];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=1[s0];[s0]scale=640:360,setsar=1,format=yuv420p[v0]"
);
}
#[test]
fn split_desc_three_renditions() {
let renditions = vec![
Rendition::new(1920, 1080, "5000k"),
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=3[s0][s1][s2];\
[s0]scale=1920:1080,setsar=1,format=yuv420p[v0];\
[s1]scale=1280:720,setsar=1,format=yuv420p[v1];\
[s2]scale=640:360,setsar=1,format=yuv420p[v2]"
);
}
#[test]
fn split_desc_every_branch_has_yuv420p() {
let renditions = vec![
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
let desc = build_split_desc(&renditions);
assert_eq!(desc.matches("format=yuv420p").count(), 2);
}
#[test]
fn gop_exact_integer_rates() {
assert_eq!(compute_gop_frames(30, 1, 6_000_000), 180);
assert_eq!(compute_gop_frames(25, 1, 6_000_000), 150);
}
#[test]
fn gop_ntsc_rational_not_broken_by_round() {
assert_eq!(compute_gop_frames(30000, 1001, 6_000_000), 180);
assert_eq!(compute_gop_frames(24000, 1001, 1_000_000), 24);
}
#[test]
fn gop_uses_ceil_not_round() {
assert_eq!(compute_gop_frames(7, 3, 1_000_000), 3);
}
#[test]
fn gop_degenerate_inputs_clamp_to_one() {
assert_eq!(compute_gop_frames(0, 1, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 0, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 1, 0), 1);
}
#[test]
fn parse_bitrate_suffixes() {
assert_eq!(parse_bitrate_bps("800000").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800k").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800K").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("5M").unwrap(), 5_000_000);
assert_eq!(parse_bitrate_bps("1.5M").unwrap(), 1_500_000);
}
#[test]
fn parse_bitrate_rejects_garbage() {
assert!(parse_bitrate_bps("").is_err());
assert!(parse_bitrate_bps("abc").is_err());
assert!(parse_bitrate_bps("0").is_err());
assert!(parse_bitrate_bps("-5M").is_err());
}
#[test]
fn bandwidth_folds_audio_and_overhead() {
assert_eq!(compute_bandwidth(5_000_000, Some(128_000)), 5_640_800);
assert_eq!(compute_bandwidth(5_000_000, None), 5_500_000);
}
#[test]
fn master_variants_carry_resolution_and_relative_uri() {
let variants = ladder().build_master_variants(true).unwrap();
assert_eq!(variants.len(), 2);
let v720 = &variants[0];
assert_eq!((v720.width, v720.height), (1280, 720));
assert_eq!(v720.uri, "720p/index.m3u8");
assert_eq!(v720.bandwidth, 3_220_800);
assert!(v720.codecs.is_none());
}
#[test]
fn master_variants_omit_audio_when_absent() {
let variants = ladder().build_master_variants(false).unwrap();
assert_eq!(variants[1].bandwidth, 880_000);
}
#[test]
fn master_variants_custom_name_becomes_uri() {
let variants = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name("low"))
.build_master_variants(false)
.unwrap();
assert_eq!(variants[0].uri, "low/index.m3u8");
}
#[test]
fn validate_accepts_default_ladder() {
assert!(ladder().validate().is_ok());
}
#[test]
fn validate_rejects_empty_ladder() {
let l = HlsLadder::new("input.mp4", "out");
assert!(matches!(l.validate(), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_odd_dimensions() {
let l = HlsLadder::new("input.mp4", "out").rendition(641, 360, "800k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_non_multiple_segment_gop() {
let l = ladder().segment_duration(6.0).gop_seconds(4.0);
assert!(l.validate().is_err());
assert!(ladder()
.segment_duration(6.0)
.gop_seconds(2.0)
.validate()
.is_ok());
}
#[test]
fn validate_rejects_bad_names() {
for bad in ["../escape", "a/b", "..", "/abs"] {
let l = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name(bad));
assert!(l.validate().is_err(), "name '{bad}' should be rejected");
}
}
#[test]
fn validate_rejects_duplicate_names() {
let l = HlsLadder::new("input.mp4", "out")
.rendition(640, 360, "800k")
.rendition(480, 360, "600k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_nonpositive_fps_override() {
assert!(ladder().fps(0, 1).validate().is_err());
assert!(ladder().fps(30, 0).validate().is_err());
assert!(ladder().fps(30, 1).validate().is_ok());
}
#[test]
fn resolve_fps_uses_override() {
assert_eq!(
ladder().fps(30000, 1001).resolve_fps().unwrap(),
(30000, 1001)
);
}
#[test]
fn compute_bandwidth_saturates_on_overflow() {
assert_eq!(compute_bandwidth(u64::MAX, Some(128_000)), u64::MAX);
}
#[test]
fn path_segment_rejects_traversal_and_control_chars() {
assert!(validate_path_segment("../../etc", "x").is_err());
assert!(validate_path_segment("a\nBANDWIDTH=9", "x").is_err());
assert!(validate_path_segment("a/b", "x").is_err());
assert!(validate_path_segment("#low", "x").is_err()); assert!(validate_path_segment("master.m3u8", "x").is_ok());
assert!(validate_path_segment("720p", "x").is_ok());
}
#[test]
fn validate_rejects_codecs_injection_and_master_collision() {
assert!(ladder().codecs("avc1.640028\"\n#EXT").validate().is_err());
assert!(ladder().master("720p").validate().is_err());
assert!(ladder().validate().is_ok());
}
}