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_TS: &str = "seg_%05d.ts";
const SEGMENT_TEMPLATE_FMP4: &str = "seg_%05d.m4s";
const FMP4_INIT_FILENAME: &str = "init.mp4";
const BANDWIDTH_OVERHEAD_NUM: u64 = 11;
const BANDWIDTH_OVERHEAD_DEN: u64 = 10;
const BUFSIZE_MULTIPLIER: u64 = 2;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HlsSegmentType {
#[default]
MpegTs,
Fmp4,
}
impl HlsSegmentType {
fn segment_template(self) -> &'static str {
match self {
HlsSegmentType::MpegTs => SEGMENT_TEMPLATE_TS,
HlsSegmentType::Fmp4 => SEGMENT_TEMPLATE_FMP4,
}
}
fn master_playlist_version(self) -> u32 {
match self {
HlsSegmentType::MpegTs => 3,
HlsSegmentType::Fmp4 => 7,
}
}
}
#[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>,
segment_type: HlsSegmentType,
}
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,
segment_type: HlsSegmentType::default(),
}
}
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 segment_type(mut self, segment_type: HlsSegmentType) -> Self {
self.segment_type = segment_type;
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 mut outputs = Vec::with_capacity(self.renditions.len());
for (i, rendition) in self.renditions.iter().enumerate() {
outputs.push(self.rendition_output(i, rendition, &gop_frames, &hls_time)?);
}
let rendition_dirs: Vec<PathBuf> = self
.renditions
.iter()
.map(|rendition| self.out_dir.join(rendition.dir_name()))
.collect();
let HlsLadder { input, out_dir, .. } = self;
let mut builder = FfmpegContext::builder()
.input(input)
.filter_desc(filter_desc);
for output in outputs {
builder = builder.output(output);
}
let context = builder.build()?;
create_dir(&out_dir)?;
for rendition_dir in &rendition_dirs {
create_dir(rendition_dir)?;
}
Ok(context)
}
fn rendition_output(
&self,
index: usize,
rendition: &Rendition,
gop_frames: &str,
hls_time: &str,
) -> Result<Output> {
let rendition_dir = self.out_dir.join(rendition.dir_name());
let playlist_path = path_to_utf8(&rendition_dir.join("index.m3u8"))?;
let segment_template =
path_to_utf8(&rendition_dir.join(self.segment_type.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{index}]"))
.add_stream_map("0:a:0?")
.set_video_codec(self.video_codec.as_str())
.set_video_bitrate(rendition.video_bitrate.clone())
.set_audio_codec(self.audio_codec.as_str())
.set_audio_bitrate(self.audio_bitrate.clone())
.set_pix_fmt("yuv420p")
.set_video_codec_opt("g", gop_frames)
.set_video_codec_opt("keyint_min", gop_frames)
.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)
.set_format_opt("hls_playlist_type", "vod")
.set_format_opt("hls_segment_filename", segment_template);
if self.segment_type == HlsSegmentType::Fmp4 {
output = output
.set_format_opt("hls_segment_type", "fmp4")
.set_format_opt("hls_fmp4_init_filename", FMP4_INIT_FILENAME);
}
if self.video_codec.eq_ignore_ascii_case("libx264") {
output = output.set_video_codec_opt("x264-params", "scenecut=0:open-gop=0");
} else if self.video_codec.eq_ignore_ascii_case("libx265") {
output = output.set_video_codec_opt("x265-params", "scenecut=0:open-gop=0");
}
Ok(output)
}
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, self.segment_type.master_playlist_version());
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> {
let utf8 = path.to_str().map(str::to_string).ok_or_else(|| {
Error::InvalidRecipeArg(format!("path is not valid UTF-8: {}", path.display()))
})?;
#[cfg(windows)]
let utf8 = if utf8.starts_with(r"\\?\") {
utf8
} else {
utf8.replace('\\', "/")
};
Ok(utf8)
}
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;