1use serde::{Deserialize, Deserializer};
2use std::path::{Path, PathBuf};
3
4pub const FORMATS: &[&str] = &["webm", "mp4", "gif", "mov"];
6
7pub const PREVIEW_MIDDLE: usize = usize::MAX;
9
10#[derive(Debug, Deserialize)]
12pub struct RenderConfig {
13 pub input: PathBuf,
14 pub output: String,
15 #[serde(default = "default_fps")]
16 pub fps: u32,
17 #[serde(default = "default_format")]
18 pub format: String,
19 #[serde(default)]
20 pub fade_in: f32,
21 #[serde(default)]
22 pub fade_out: f32,
23 #[serde(default)]
24 pub bitrate: Option<String>,
25 #[serde(default)]
26 pub crf: Option<u32>,
27 #[serde(default)]
28 pub open: bool,
29 #[serde(default, deserialize_with = "preview_value")]
31 pub preview: Option<usize>,
32 #[serde(default)]
33 pub file_pattern: Option<String>,
34 #[serde(default)]
35 pub verbose: bool,
36 #[serde(default)]
37 pub verbose_ffmpeg: bool,
38 #[serde(default)]
42 pub background: Option<String>,
43}
44
45fn default_fps() -> u32 {
46 30
47}
48
49fn default_format() -> String {
50 "webm".into()
51}
52
53pub fn format_from_extension(output: &str) -> Option<String> {
55 let ext = Path::new(output).extension()?.to_str()?.to_lowercase();
56 FORMATS.contains(&ext.as_str()).then_some(ext)
57}
58
59impl RenderConfig {
60 pub fn from_file(path: &str) -> Result<Self, String> {
65 PartialConfig::from_file(path)?.into_config()
66 }
67
68 pub fn is_preview(&self) -> bool {
69 self.preview.is_some()
70 }
71
72 pub fn preview_frame_limit(&self) -> Option<usize> {
73 self.preview
74 }
75}
76
77#[derive(Debug, Default, Deserialize)]
81pub struct PartialConfig {
82 pub input: Option<PathBuf>,
83 pub output: Option<String>,
84 pub fps: Option<u32>,
85 pub format: Option<String>,
86 pub fade_in: Option<f32>,
87 pub fade_out: Option<f32>,
88 pub bitrate: Option<String>,
89 pub crf: Option<u32>,
90 pub open: Option<bool>,
91 #[serde(default, deserialize_with = "preview_option")]
93 pub preview: Option<Option<usize>>,
94 pub file_pattern: Option<String>,
95 pub verbose: Option<bool>,
96 pub verbose_ffmpeg: Option<bool>,
97 pub background: Option<String>,
98}
99
100impl PartialConfig {
101 pub fn from_file(path: &str) -> Result<Self, String> {
103 let text = std::fs::read_to_string(path)
104 .map_err(|e| format!("❌ Could not read config file '{}': {}", path, e))?;
105 let is_toml = Path::new(path)
106 .extension()
107 .is_some_and(|e| e.eq_ignore_ascii_case("toml"));
108 let cfg: Self = if is_toml {
109 toml::from_str(&text)
110 .map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
111 } else {
112 serde_json::from_str(&text)
113 .map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
114 };
115 Ok(cfg)
116 }
117
118 pub fn or(self, base: PartialConfig) -> PartialConfig {
120 PartialConfig {
121 input: self.input.or(base.input),
122 output: self.output.or(base.output),
123 fps: self.fps.or(base.fps),
124 format: self.format.or(base.format),
125 fade_in: self.fade_in.or(base.fade_in),
126 fade_out: self.fade_out.or(base.fade_out),
127 bitrate: self.bitrate.or(base.bitrate),
128 crf: self.crf.or(base.crf),
129 open: self.open.or(base.open),
130 preview: self.preview.or(base.preview),
131 file_pattern: self.file_pattern.or(base.file_pattern),
132 verbose: self.verbose.or(base.verbose),
133 verbose_ffmpeg: self.verbose_ffmpeg.or(base.verbose_ffmpeg),
134 background: self.background.or(base.background),
135 }
136 }
137
138 pub fn into_config(self) -> Result<RenderConfig, String> {
143 let input = self.input.ok_or(
144 "❌ No input given. Pass --input <folder or .zip> (or \"input\" in the config).",
145 )?;
146 let format = match self.format {
147 Some(f) => f.to_lowercase(),
148 None => self
149 .output
150 .as_deref()
151 .and_then(format_from_extension)
152 .unwrap_or_else(default_format),
153 };
154 if !FORMATS.contains(&format.as_str()) {
155 return Err(format!(
156 "❌ Unsupported format '{}'. Use one of: {}.",
157 format,
158 FORMATS.join(", ")
159 ));
160 }
161 let fps = self.fps.unwrap_or_else(default_fps);
162 if fps == 0 {
163 return Err("❌ fps must be greater than 0.".into());
164 }
165 let output = self.output.unwrap_or_else(|| format!("output.{format}"));
166 let fade_in = self.fade_in.unwrap_or(0.0);
167 let fade_out = self.fade_out.unwrap_or(0.0);
168 if fade_in < 0.0 || fade_out < 0.0 {
169 return Err("❌ Fade durations cannot be negative.".into());
170 }
171 let background = match self.background {
172 Some(b) => parse_background(&b)?,
173 None => None,
174 };
175 Ok(RenderConfig {
176 input,
177 output,
178 fps,
179 format,
180 fade_in,
181 fade_out,
182 bitrate: self.bitrate,
183 crf: self.crf,
184 open: self.open.unwrap_or(false),
185 preview: self.preview.map(|n| n.unwrap_or(PREVIEW_MIDDLE)),
188 file_pattern: self.file_pattern,
189 verbose: self.verbose.unwrap_or(false),
190 verbose_ffmpeg: self.verbose_ffmpeg.unwrap_or(false),
191 background,
192 })
193 }
194}
195
196pub fn parse_background(value: &str) -> Result<Option<String>, String> {
203 let v = value.trim();
204 if v.is_empty() || v.eq_ignore_ascii_case("transparent") || v.eq_ignore_ascii_case("none") {
205 return Ok(None);
206 }
207 let hex = v
208 .strip_prefix('#')
209 .or_else(|| v.strip_prefix("0x"))
210 .or_else(|| v.strip_prefix("0X"))
211 .unwrap_or(v);
212 let is_hex = hex.chars().all(|c| c.is_ascii_hexdigit());
213 if is_hex && hex.len() == 6 {
214 return Ok(Some(format!("0x{}", hex.to_uppercase())));
215 }
216 if is_hex && hex.len() == 3 {
217 let long: String = hex.chars().flat_map(|c| [c, c]).collect();
218 return Ok(Some(format!("0x{}", long.to_uppercase())));
219 }
220 if hex.len() == v.len() && v.chars().all(|c| c.is_ascii_alphabetic()) {
221 return Ok(Some(v.to_lowercase()));
222 }
223 Err(format!(
224 "❌ Invalid background color '{}'. Use a hex color like '#1e1e1e', a name like 'white', or 'transparent'.",
225 value
226 ))
227}
228
229#[derive(Deserialize)]
231#[serde(untagged)]
232enum PreviewValue {
233 Flag(bool),
234 Frame(usize),
235}
236
237fn preview_value<'de, D: Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
239 Ok(match Option::<PreviewValue>::deserialize(d)? {
240 None | Some(PreviewValue::Flag(false)) => None,
241 Some(PreviewValue::Flag(true)) => Some(PREVIEW_MIDDLE),
242 Some(PreviewValue::Frame(n)) => Some(n),
243 })
244}
245
246fn preview_option<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<usize>>, D::Error> {
248 Ok(match Option::<PreviewValue>::deserialize(d)? {
249 None | Some(PreviewValue::Flag(false)) => None,
250 Some(PreviewValue::Flag(true)) => Some(None),
251 Some(PreviewValue::Frame(n)) => Some(Some(n)),
252 })
253}
254
255#[cfg(test)]
256mod tests {
257 use super::parse_background;
258
259 #[test]
260 fn background_values() {
261 let p = |v: &str| parse_background(v).unwrap();
262 assert_eq!(p(""), None);
263 assert_eq!(p(" transparent "), None);
264 assert_eq!(p("None"), None);
265 assert_eq!(p("#1e1e1e").as_deref(), Some("0x1E1E1E"));
266 assert_eq!(p("1e1e1e").as_deref(), Some("0x1E1E1E"));
267 assert_eq!(p("0xff8800").as_deref(), Some("0xFF8800"));
268 assert_eq!(p("#fff").as_deref(), Some("0xFFFFFF"));
269 assert_eq!(p("White").as_deref(), Some("white"));
270 assert_eq!(p("#bad").as_deref(), Some("0xBBAADD"));
272 assert!(parse_background("#12345").is_err());
273 assert!(parse_background("red; rm -rf").is_err());
274 assert!(parse_background("#1e1e1e80").is_err());
275 }
276}