use serde::{Deserialize, Deserializer};
use std::path::{Path, PathBuf};
pub const FORMATS: &[&str] = &["webm", "mp4", "gif", "mov"];
pub const PREVIEW_MIDDLE: usize = usize::MAX;
#[derive(Debug, Deserialize)]
pub struct RenderConfig {
pub input: PathBuf,
pub output: String,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub fade_in: f32,
#[serde(default)]
pub fade_out: f32,
#[serde(default)]
pub bitrate: Option<String>,
#[serde(default)]
pub crf: Option<u32>,
#[serde(default)]
pub open: bool,
#[serde(default, deserialize_with = "preview_value")]
pub preview: Option<usize>,
#[serde(default)]
pub file_pattern: Option<String>,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub verbose_ffmpeg: bool,
#[serde(default)]
pub background: Option<String>,
}
fn default_fps() -> u32 {
30
}
fn default_format() -> String {
"webm".into()
}
pub fn format_from_extension(output: &str) -> Option<String> {
let ext = Path::new(output).extension()?.to_str()?.to_lowercase();
FORMATS.contains(&ext.as_str()).then_some(ext)
}
impl RenderConfig {
pub fn from_file(path: &str) -> Result<Self, String> {
PartialConfig::from_file(path)?.into_config()
}
pub fn is_preview(&self) -> bool {
self.preview.is_some()
}
pub fn preview_frame_limit(&self) -> Option<usize> {
self.preview
}
}
#[derive(Debug, Default, Deserialize)]
pub struct PartialConfig {
pub input: Option<PathBuf>,
pub output: Option<String>,
pub fps: Option<u32>,
pub format: Option<String>,
pub fade_in: Option<f32>,
pub fade_out: Option<f32>,
pub bitrate: Option<String>,
pub crf: Option<u32>,
pub open: Option<bool>,
#[serde(default, deserialize_with = "preview_option")]
pub preview: Option<Option<usize>>,
pub file_pattern: Option<String>,
pub verbose: Option<bool>,
pub verbose_ffmpeg: Option<bool>,
pub background: Option<String>,
}
impl PartialConfig {
pub fn from_file(path: &str) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("❌ Could not read config file '{}': {}", path, e))?;
let is_toml = Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("toml"));
let cfg: Self = if is_toml {
toml::from_str(&text)
.map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
} else {
serde_json::from_str(&text)
.map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
};
Ok(cfg)
}
pub fn or(self, base: PartialConfig) -> PartialConfig {
PartialConfig {
input: self.input.or(base.input),
output: self.output.or(base.output),
fps: self.fps.or(base.fps),
format: self.format.or(base.format),
fade_in: self.fade_in.or(base.fade_in),
fade_out: self.fade_out.or(base.fade_out),
bitrate: self.bitrate.or(base.bitrate),
crf: self.crf.or(base.crf),
open: self.open.or(base.open),
preview: self.preview.or(base.preview),
file_pattern: self.file_pattern.or(base.file_pattern),
verbose: self.verbose.or(base.verbose),
verbose_ffmpeg: self.verbose_ffmpeg.or(base.verbose_ffmpeg),
background: self.background.or(base.background),
}
}
pub fn into_config(self) -> Result<RenderConfig, String> {
let input = self.input.ok_or(
"❌ No input given. Pass --input <folder or .zip> (or \"input\" in the config).",
)?;
let format = match self.format {
Some(f) => f.to_lowercase(),
None => self
.output
.as_deref()
.and_then(format_from_extension)
.unwrap_or_else(default_format),
};
if !FORMATS.contains(&format.as_str()) {
return Err(format!(
"❌ Unsupported format '{}'. Use one of: {}.",
format,
FORMATS.join(", ")
));
}
let fps = self.fps.unwrap_or_else(default_fps);
if fps == 0 {
return Err("❌ fps must be greater than 0.".into());
}
let output = self.output.unwrap_or_else(|| format!("output.{format}"));
let fade_in = self.fade_in.unwrap_or(0.0);
let fade_out = self.fade_out.unwrap_or(0.0);
if fade_in < 0.0 || fade_out < 0.0 {
return Err("❌ Fade durations cannot be negative.".into());
}
let background = match self.background {
Some(b) => parse_background(&b)?,
None => None,
};
Ok(RenderConfig {
input,
output,
fps,
format,
fade_in,
fade_out,
bitrate: self.bitrate,
crf: self.crf,
open: self.open.unwrap_or(false),
preview: self.preview.map(|n| n.unwrap_or(PREVIEW_MIDDLE)),
file_pattern: self.file_pattern,
verbose: self.verbose.unwrap_or(false),
verbose_ffmpeg: self.verbose_ffmpeg.unwrap_or(false),
background,
})
}
}
pub fn parse_background(value: &str) -> Result<Option<String>, String> {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("transparent") || v.eq_ignore_ascii_case("none") {
return Ok(None);
}
let hex = v
.strip_prefix('#')
.or_else(|| v.strip_prefix("0x"))
.or_else(|| v.strip_prefix("0X"))
.unwrap_or(v);
let is_hex = hex.chars().all(|c| c.is_ascii_hexdigit());
if is_hex && hex.len() == 6 {
return Ok(Some(format!("0x{}", hex.to_uppercase())));
}
if is_hex && hex.len() == 3 {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Ok(Some(format!("0x{}", long.to_uppercase())));
}
if hex.len() == v.len() && v.chars().all(|c| c.is_ascii_alphabetic()) {
return Ok(Some(v.to_lowercase()));
}
Err(format!(
"❌ Invalid background color '{}'. Use a hex color like '#1e1e1e', a name like 'white', or 'transparent'.",
value
))
}
#[derive(Deserialize)]
#[serde(untagged)]
enum PreviewValue {
Flag(bool),
Frame(usize),
}
fn preview_value<'de, D: Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
Ok(match Option::<PreviewValue>::deserialize(d)? {
None | Some(PreviewValue::Flag(false)) => None,
Some(PreviewValue::Flag(true)) => Some(PREVIEW_MIDDLE),
Some(PreviewValue::Frame(n)) => Some(n),
})
}
fn preview_option<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<usize>>, D::Error> {
Ok(match Option::<PreviewValue>::deserialize(d)? {
None | Some(PreviewValue::Flag(false)) => None,
Some(PreviewValue::Flag(true)) => Some(None),
Some(PreviewValue::Frame(n)) => Some(Some(n)),
})
}
#[cfg(test)]
mod tests {
use super::parse_background;
#[test]
fn background_values() {
let p = |v: &str| parse_background(v).unwrap();
assert_eq!(p(""), None);
assert_eq!(p(" transparent "), None);
assert_eq!(p("None"), None);
assert_eq!(p("#1e1e1e").as_deref(), Some("0x1E1E1E"));
assert_eq!(p("1e1e1e").as_deref(), Some("0x1E1E1E"));
assert_eq!(p("0xff8800").as_deref(), Some("0xFF8800"));
assert_eq!(p("#fff").as_deref(), Some("0xFFFFFF"));
assert_eq!(p("White").as_deref(), Some("white"));
assert_eq!(p("#bad").as_deref(), Some("0xBBAADD"));
assert!(parse_background("#12345").is_err());
assert!(parse_background("red; rm -rf").is_err());
assert!(parse_background("#1e1e1e80").is_err());
}
}