use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FfmpegCommandBuilderError {
#[error("Configuration Validation Error: {0}")]
ConfigurationError(String),
#[error("Command Build Error: {0}")]
BuildError(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Conversion Error: {0}")]
ConversionError(String),
#[error("Unexpected Internal State: {0}")]
InternalStateError(String),
#[error("FFmpeg specific setting error: {0}")]
FfmpegSettingError(String),
}
#[derive(Debug, Default)]
pub struct FfmpegCommand {
input_path: PathBuf,
output_path: PathBuf,
width: i32,
height: i32,
crf: i32,
preset: String,
hls_config: Option<HlsOutputConfig>,
}
#[derive(Debug)]
struct HlsOutputConfig {
segment_filename_pattern: String,
playlist_type: Option<String>,
encryption_config: Option<HlsOutputEncryptionConfig>,
base_url: Option<String>,
hls_time: i32,
}
#[derive(Debug)]
pub struct HlsOutputEncryptionConfig {
pub encryption_key_path: String,
pub iv: Option<String>,
}
impl FfmpegCommand {
pub fn to_args(&self) -> Vec<String> {
let mut args = vec!["ffmpeg".to_string()];
args.push("-i".to_string());
args.push(self.input_path.to_str().unwrap_or_default().to_string());
args.push("-vf".to_string());
args.push(format!("scale={}x{}", self.width, self.height));
args.push("-c:v".to_string());
args.push("libx264".to_string());
args.push("-crf".to_string());
args.push(self.crf.to_string());
args.push("-preset".to_string());
args.push(self.preset.to_string());
if let Some(hls_conf) = &self.hls_config {
args.push("-hls_time".to_string());
args.push(hls_conf.hls_time.to_string());
args.push("-hls_playlist_type".to_string());
args.push(
hls_conf
.playlist_type
.as_ref()
.cloned()
.unwrap_or("vod".to_string()),
);
args.push("-hls_segment_filename".to_string());
args.push(hls_conf.segment_filename_pattern.to_string());
if let Some(base_url) = &hls_conf.base_url {
args.push("-hls_base_url".to_string());
args.push(base_url.to_string());
}
if let Some(encryption_config) = &hls_conf.encryption_config {
args.push("-hls_key_info_file".to_string());
args.push(encryption_config.encryption_key_path.to_string());
if let Some(iv) = &encryption_config.iv {
args.push("-hls_iv".to_string());
args.push(iv.to_string());
}
}
}
args.push(self.output_path.to_str().unwrap_or_default().to_string());
args
}
}
#[derive(Debug, Default)]
pub struct FfmpegCommandBuilder {
command: FfmpegCommand,
build_errors: Vec<FfmpegCommandBuilderError>,
has_input: bool,
has_output: bool,
has_dimensions: bool,
has_crf: bool,
has_preset: bool,
}
impl FfmpegCommandBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn input<P: AsRef<Path>>(mut self, path: P) -> Self {
self.command.input_path = path.as_ref().to_path_buf();
self.has_input = true;
self
}
pub fn output<P: AsRef<Path>>(mut self, path: P) -> Self {
self.command.output_path = path.as_ref().to_path_buf();
self.has_output = true;
self
}
pub fn dimensions(mut self, width: i32, height: i32) -> Self {
if width <= 0 || height <= 0 {
self.build_errors
.push(FfmpegCommandBuilderError::FfmpegSettingError(
"Width and height must be positive values.".to_string(),
));
}
self.command.width = width;
self.command.height = height;
self.has_dimensions = true;
self
}
pub fn crf(mut self, value: i32) -> Self {
if !(0..=51).contains(&value) {
self.build_errors
.push(FfmpegCommandBuilderError::FfmpegSettingError(format!(
"CRF value {} is outside the standard range [0-51].",
value
)));
}
self.command.crf = value;
self.has_crf = true;
self
}
pub fn preset(mut self, name: &str) -> Self {
let valid_presets = [
"ultrafast",
"superfast",
"fast",
"medium",
"slow",
"slower",
"veryslow",
"none",
];
if !valid_presets.contains(&name) {
self.build_errors
.push(FfmpegCommandBuilderError::FfmpegSettingError(format!(
"Preset '{}' is not a recognized FFmpeg preset.",
name
)));
}
self.command.preset = name.to_string();
self.has_preset = true;
self
}
pub fn enable_hls(
mut self,
segment_filename_pattern: &str,
playlist_type: Option<&str>,
base_url: Option<&str>,
encryption_settings: Option<HlsOutputEncryptionConfig>,
hls_segment_duration_seconds: i32,
) -> Self {
if segment_filename_pattern.is_empty() || !segment_filename_pattern.contains('%') {
self.build_errors.push(FfmpegCommandBuilderError::FfmpegSettingError(
"HLS segment filename pattern must not be empty and should contain a format specifier (e.g., %03d).".to_string(),
));
}
if hls_segment_duration_seconds <= 0 {
self.build_errors
.push(FfmpegCommandBuilderError::FfmpegSettingError(
"HLS segment duration must be positive.".to_string(),
));
}
self.command.hls_config = Some(HlsOutputConfig {
segment_filename_pattern: segment_filename_pattern.to_string(),
hls_time: hls_segment_duration_seconds,
playlist_type: playlist_type.map(|ptype| ptype.to_string()),
base_url: base_url.map(|url| url.to_string()),
encryption_config: encryption_settings,
});
self
}
pub fn build(&mut self) -> Result<Vec<String>, FfmpegCommandBuilderError> {
if !self.build_errors.is_empty() {
let error_messages: Vec<String> =
self.build_errors.iter().map(|e| e.to_string()).collect();
return Err(FfmpegCommandBuilderError::BuildError(format!(
"Command configuration failed: [{}]",
error_messages.join("; ")
)));
}
if !self.has_input || self.command.input_path.as_os_str().is_empty() {
return Err(FfmpegCommandBuilderError::ConfigurationError(
"Input path must be set using `.input()`.".to_string(),
));
}
if !self.has_output || self.command.output_path.as_os_str().is_empty() {
return Err(FfmpegCommandBuilderError::ConfigurationError(
"Output path must be set using `.output()`.".to_string(),
));
}
if !self.has_dimensions {
return Err(FfmpegCommandBuilderError::ConfigurationError(
"Output dimensions (width and height) must be set using `.dimensions()`."
.to_string(),
));
}
if !self.has_crf {
return Err(FfmpegCommandBuilderError::ConfigurationError(
"CRF (quality) must be set using `.crf()`.".to_string(),
));
}
if !self.has_preset {
return Err(FfmpegCommandBuilderError::ConfigurationError(
"Preset must be set using `.preset()`.".to_string(),
));
}
if self.command.hls_config.is_some() && self.command.output_path.extension().is_some() {
self.build_errors.push(FfmpegCommandBuilderError::FfmpegSettingError(
"When enabling HLS, the output path should typically be a directory, not a specific file extension.".to_string(),
));
}
Ok(self.command.to_args())
}
}