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}
39
40fn default_fps() -> u32 {
41    30
42}
43
44fn default_format() -> String {
45    "webm".into()
46}
47
48/// The format implied by an output file name (`out.gif` -> `gif`), if it is one we render.
49pub fn format_from_extension(output: &str) -> Option<String> {
50    let ext = Path::new(output).extension()?.to_str()?.to_lowercase();
51    FORMATS.contains(&ext.as_str()).then_some(ext)
52}
53
54impl RenderConfig {
55    /// Load configuration from a `.json` or `.toml` file.
56    ///
57    /// Fields that are missing fall back to defaults; when `format` is missing it is
58    /// taken from the output file extension.
59    pub fn from_file(path: &str) -> Result<Self, String> {
60        PartialConfig::from_file(path)?.into_config()
61    }
62
63    pub fn is_preview(&self) -> bool {
64        self.preview.is_some()
65    }
66
67    pub fn preview_frame_limit(&self) -> Option<usize> {
68        self.preview
69    }
70}
71
72/// A configuration where every field is optional: what a config file or the
73/// command line actually specified. Merge several with [`PartialConfig::or`] and
74/// turn the result into a [`RenderConfig`] with [`PartialConfig::into_config`].
75#[derive(Debug, Default, Deserialize)]
76pub struct PartialConfig {
77    pub input: Option<PathBuf>,
78    pub output: Option<String>,
79    pub fps: Option<u32>,
80    pub format: Option<String>,
81    pub fade_in: Option<f32>,
82    pub fade_out: Option<f32>,
83    pub bitrate: Option<String>,
84    pub crf: Option<u32>,
85    pub open: Option<bool>,
86    /// `Some(None)` = preview the middle frame, `Some(Some(n))` = frame `n`
87    #[serde(default, deserialize_with = "preview_option")]
88    pub preview: Option<Option<usize>>,
89    pub file_pattern: Option<String>,
90    pub verbose: Option<bool>,
91    pub verbose_ffmpeg: Option<bool>,
92}
93
94impl PartialConfig {
95    /// Read a `.json` or `.toml` config file (decided by extension; JSON otherwise).
96    pub fn from_file(path: &str) -> Result<Self, String> {
97        let text = std::fs::read_to_string(path)
98            .map_err(|e| format!("❌ Could not read config file '{}': {}", path, e))?;
99        let is_toml = Path::new(path)
100            .extension()
101            .is_some_and(|e| e.eq_ignore_ascii_case("toml"));
102        let cfg: Self = if is_toml {
103            toml::from_str(&text)
104                .map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
105        } else {
106            serde_json::from_str(&text)
107                .map_err(|e| format!("❌ Failed to parse config '{}': {}", path, e))?
108        };
109        Ok(cfg)
110    }
111
112    /// Fields set in `self` win; the rest are taken from `base`.
113    pub fn or(self, base: PartialConfig) -> PartialConfig {
114        PartialConfig {
115            input: self.input.or(base.input),
116            output: self.output.or(base.output),
117            fps: self.fps.or(base.fps),
118            format: self.format.or(base.format),
119            fade_in: self.fade_in.or(base.fade_in),
120            fade_out: self.fade_out.or(base.fade_out),
121            bitrate: self.bitrate.or(base.bitrate),
122            crf: self.crf.or(base.crf),
123            open: self.open.or(base.open),
124            preview: self.preview.or(base.preview),
125            file_pattern: self.file_pattern.or(base.file_pattern),
126            verbose: self.verbose.or(base.verbose),
127            verbose_ffmpeg: self.verbose_ffmpeg.or(base.verbose_ffmpeg),
128        }
129    }
130
131    /// Fill in defaults and validate.
132    ///
133    /// - `format` defaults to the output extension, else `webm`
134    /// - `output` defaults to `output.<format>`
135    pub fn into_config(self) -> Result<RenderConfig, String> {
136        let input = self.input.ok_or(
137            "❌ No input given. Pass --input <folder or .zip> (or \"input\" in the config).",
138        )?;
139        let format = match self.format {
140            Some(f) => f.to_lowercase(),
141            None => self
142                .output
143                .as_deref()
144                .and_then(format_from_extension)
145                .unwrap_or_else(default_format),
146        };
147        if !FORMATS.contains(&format.as_str()) {
148            return Err(format!(
149                "❌ Unsupported format '{}'. Use one of: {}.",
150                format,
151                FORMATS.join(", ")
152            ));
153        }
154        let fps = self.fps.unwrap_or_else(default_fps);
155        if fps == 0 {
156            return Err("❌ fps must be greater than 0.".into());
157        }
158        let output = self.output.unwrap_or_else(|| format!("output.{format}"));
159        let fade_in = self.fade_in.unwrap_or(0.0);
160        let fade_out = self.fade_out.unwrap_or(0.0);
161        if fade_in < 0.0 || fade_out < 0.0 {
162            return Err("❌ Fade durations cannot be negative.".into());
163        }
164        Ok(RenderConfig {
165            input,
166            output,
167            fps,
168            format,
169            fade_in,
170            fade_out,
171            bitrate: self.bitrate,
172            crf: self.crf,
173            open: self.open.unwrap_or(false),
174            // Previewing without a frame number means "the middle frame",
175            // which preview_frame picks when it gets None.
176            preview: self.preview.map(|n| n.unwrap_or(PREVIEW_MIDDLE)),
177            file_pattern: self.file_pattern,
178            verbose: self.verbose.unwrap_or(false),
179            verbose_ffmpeg: self.verbose_ffmpeg.unwrap_or(false),
180        })
181    }
182}
183
184/// Accepts `"preview": false | true | null | <frame number>` in config files.
185#[derive(Deserialize)]
186#[serde(untagged)]
187enum PreviewValue {
188    Flag(bool),
189    Frame(usize),
190}
191
192/// For `RenderConfig`: `false`/`null` = no preview, `true` = middle frame, `n` = frame n.
193fn preview_value<'de, D: Deserializer<'de>>(d: D) -> Result<Option<usize>, D::Error> {
194    Ok(match Option::<PreviewValue>::deserialize(d)? {
195        None | Some(PreviewValue::Flag(false)) => None,
196        Some(PreviewValue::Flag(true)) => Some(PREVIEW_MIDDLE),
197        Some(PreviewValue::Frame(n)) => Some(n),
198    })
199}
200
201/// For `PartialConfig`: same values, but keeps "not set" apart from "no preview".
202fn preview_option<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<usize>>, D::Error> {
203    Ok(match Option::<PreviewValue>::deserialize(d)? {
204        None | Some(PreviewValue::Flag(false)) => None,
205        Some(PreviewValue::Flag(true)) => Some(None),
206        Some(PreviewValue::Frame(n)) => Some(Some(n)),
207    })
208}