Skip to main content

aether_renderer_core/
config.rs

1use serde::{Deserialize, Deserializer};
2use std::path::{Path, PathBuf};
3
4/// Output formats the renderer can produce.
5pub const FORMATS: &[&str] = &["webm", "mp4", "gif", "mov"];
6
7/// `preview: Some(PREVIEW_MIDDLE)` previews the middle frame of the sequence.
8pub const PREVIEW_MIDDLE: usize = usize::MAX;
9
10/// Render configuration loaded from a JSON/TOML file or CLI arguments
11#[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    /// `None` = full render, `Some(n)` = extract frame `n` as a PNG
30    #[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    /// Solid background color behind the frames, e.g. `"#1e1e1e"` or `"white"`.
39    /// `None` (or `"transparent"`) keeps transparency; mp4 cannot store it and
40    /// uses black. See [`parse_background`].
41    #[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
53/// The format implied by an output file name (`out.gif` -> `gif`), if it is one we render.
54pub 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    /// Load configuration from a `.json` or `.toml` file.
61    ///
62    /// Fields that are missing fall back to defaults; when `format` is missing it is
63    /// taken from the output file extension.
64    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/// A configuration where every field is optional: what a config file or the
78/// command line actually specified. Merge several with [`PartialConfig::or`] and
79/// turn the result into a [`RenderConfig`] with [`PartialConfig::into_config`].
80#[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    /// `Some(None)` = preview the middle frame, `Some(Some(n))` = frame `n`
92    #[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    /// Read a `.json` or `.toml` config file (decided by extension; JSON otherwise).
102    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    /// Fields set in `self` win; the rest are taken from `base`.
119    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    /// Fill in defaults and validate.
139    ///
140    /// - `format` defaults to the output extension, else `webm`
141    /// - `output` defaults to `output.<format>`
142    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            // Previewing without a frame number means "the middle frame",
186            // which preview_frame picks when it gets None.
187            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
196/// Turn a background color as a user writes it into one ffmpeg understands.
197///
198/// - `""`, `"transparent"`, `"none"` → `None` (keep transparency)
199/// - `"#rgb"`, `"#rrggbb"`, `"rrggbb"`, `"0xrrggbb"` → `Some("0xRRGGBB")`
200/// - a color name such as `"white"` or `"DarkSlateGray"` → `Some("darkslategray")`
201///   (ffmpeg checks the name when rendering)
202pub 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/// Accepts `"preview": false | true | null | <frame number>` in config files.
230#[derive(Deserialize)]
231#[serde(untagged)]
232enum PreviewValue {
233    Flag(bool),
234    Frame(usize),
235}
236
237/// For `RenderConfig`: `false`/`null` = no preview, `true` = middle frame, `n` = frame n.
238fn 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
246/// For `PartialConfig`: same values, but keeps "not set" apart from "no preview".
247fn 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        // "bad" is valid hex but 3 letters: treated as #bbaadd
271        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}