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}
39
40fn default_fps() -> u32 {
41 30
42}
43
44fn default_format() -> String {
45 "webm".into()
46}
47
48pub 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 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#[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 #[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 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 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 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 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#[derive(Deserialize)]
186#[serde(untagged)]
187enum PreviewValue {
188 Flag(bool),
189 Frame(usize),
190}
191
192fn 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
201fn 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}