1pub mod config;
2pub mod ffmpeg;
3pub mod input;
4pub mod report;
5pub mod utils;
6
7pub use config::RenderConfig;
8pub use report::{RenderProgress, RenderReport};
9
10use indicatif::{ProgressBar, ProgressStyle};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::Duration;
14
15pub fn render_from_config(config_path: &str) -> Result<RenderReport, String> {
17 let args = RenderConfig::from_file(config_path)?;
18 render(args)
19}
20
21pub fn render(args: RenderConfig) -> Result<RenderReport, String> {
23 render_with_progress(args, |_| {})
24}
25
26pub fn render_with_progress<F>(
39 args: RenderConfig,
40 mut on_progress: F,
41) -> Result<RenderReport, String>
42where
43 F: FnMut(RenderProgress),
44{
45 if args.verbose {
46 let version = env!("CARGO_PKG_VERSION");
47 eprintln!("đĒŧ Aether Renderer v{version} starting...");
48 }
49 if args.output.is_empty() {
51 return Err("â Output path cannot be empty.".into());
52 }
53
54 if !config::FORMATS.contains(&args.format.as_str()) {
55 return Err(format!(
56 "â Unsupported format '{}'. Use one of: {}.",
57 args.format,
58 config::FORMATS.join(", ")
59 ));
60 }
61 if args.fps == 0 {
62 return Err("â fps must be greater than 0.".into());
63 }
64 let chosen_background = config::parse_background(args.background.as_deref().unwrap_or(""))?;
66 let mp4_default_background = chosen_background.is_none() && args.format == "mp4";
67 let background = if mp4_default_background {
68 Some("black".to_string())
69 } else {
70 chosen_background
71 };
72
73 if args.is_preview() {
75 if args.open {
76 eprintln!("â ī¸ '--open' is only supported for full render. Ignoring for preview.");
77 }
78 let mut out_path = PathBuf::from(&args.output);
79 if out_path.extension().is_some() {
80 out_path.set_extension("png");
81 } else {
82 out_path = out_path.with_extension("png");
83 }
84 preview_frame(
85 &args.input,
86 args.file_pattern.clone(),
87 args.preview_frame_limit()
88 .filter(|&n| n != config::PREVIEW_MIDDLE),
89 &out_path,
90 args.verbose,
91 )?;
92 return Ok(RenderReport {
93 output_path: PathBuf::from(out_path.to_string_lossy().into_owned()),
94 frames_rendered: Some(1),
95 ffmpeg_warnings: Vec::new(),
96 preview: true,
97 notes: Some("Preview complete.".into()),
98 });
99 }
100
101 if args.verbose_ffmpeg {
103 println!("đ Checking for ffmpeg...");
104 }
105 let mut ffmpeg_check = Command::new("ffmpeg");
106 ffmpeg_check.arg("-version");
107 if !args.verbose_ffmpeg {
108 ffmpeg_check.stdout(std::process::Stdio::null());
109 ffmpeg_check.stderr(std::process::Stdio::null());
110 }
111 match ffmpeg_check.status() {
112 Ok(s) if s.success() => {}
113 Ok(_) => {
114 return Err("â ffmpeg failed to run correctly.".into());
115 }
116 Err(e) => {
117 if e.kind() == std::io::ErrorKind::NotFound {
118 return Err(
119 "â ffmpeg not found. Install it and make sure it is on your PATH:\n \
120 macOS: brew install ffmpeg ¡ Ubuntu/Debian: sudo apt install ffmpeg ¡ \
121 Windows: winget install Gyan.FFmpeg"
122 .into(),
123 );
124 } else {
125 return Err(format!("â Failed to execute ffmpeg: {}", e));
126 }
127 }
128 }
129
130 if !args.input.exists() {
131 return Err(format!(
132 "â Input path '{}' does not exist.",
133 args.input.display()
134 ));
135 }
136
137 let input_path = &args.input;
138 let (working_input_path, _temp_guard) = if input_path
139 .extension()
140 .map(|ext| ext == "zip")
141 .unwrap_or(false)
142 {
143 let (path, guard) =
144 utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
145 (path, Some(guard))
146 } else {
147 (input_path.clone(), None)
148 };
149
150 let pattern = args
151 .file_pattern
152 .clone()
153 .unwrap_or_else(|| "*.png".to_string());
154 let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
155 .map_err(|e| format!("â Failed to read frames: {}", e))?;
156 let frame_count = frames.len() as u32;
157
158 if frame_count == 0 {
159 return Err(format!(
160 "â No input files found in '{}' matching pattern '{}'.",
161 working_input_path.display(),
162 pattern
163 ));
164 }
165
166 let sequence = input::FrameSequence::new(&frames)
168 .map_err(|e| format!("â Failed to prepare frames: {}", e))?;
169 let input_str = sequence.pattern().to_string_lossy().into_owned();
170
171 let duration = frame_count as f32 / args.fps as f32;
172 let fade_filter = fade_filter(&args, duration);
173
174 if args.verbose {
175 println!(
176 "đŋ Rendering {} frames from {} â {} ({}, {} fps, {:.2} s{})...",
177 frame_count,
178 working_input_path.display(),
179 args.output,
180 args.format,
181 args.fps,
182 duration,
183 match &background {
184 Some(c) => format!(", background {c}"),
185 None => String::new(),
186 }
187 );
188 }
189
190 let maybe_bar = if args.verbose {
191 let pb = ProgressBar::new(frame_count as u64);
192 pb.set_style(
193 ProgressStyle::with_template(
194 "{spinner:.green} đŋ Rendering {bar:30.cyan/blue} {pos}/{len} frames {percent:>3}% {elapsed_precise}",
195 )
196 .unwrap()
197 .tick_chars("äˇäˇĢäˇäˇ
䡤äˇĨäˇäˇäˇĒ")
198 .progress_chars("âââ"),
199 );
200 pb.enable_steady_tick(Duration::from_millis(120));
201 Some(pb)
202 } else {
203 None
204 };
205
206 on_progress(RenderProgress::new(0, frame_count));
207 let mut on_frame = |n: u32| {
208 let progress = RenderProgress::new(n, frame_count);
209 if let Some(pb) = &maybe_bar {
210 pb.set_position(progress.frame as u64);
211 }
212 on_progress(progress);
213 };
214
215 let result = if args.format == "gif" {
216 ffmpeg::gif::render_gif(
217 &input_str,
218 &args.output,
219 args.fps,
220 Some(&fade_filter),
221 background.as_deref(),
222 args.verbose_ffmpeg,
223 &mut on_frame,
224 )
225 } else {
226 ffmpeg::video::render_video(
227 &input_str,
228 &args.output,
229 args.fps,
230 &args.format,
231 args.bitrate.as_deref(),
232 args.crf,
233 Some(&fade_filter),
234 background.as_deref(),
235 args.verbose_ffmpeg,
236 &mut on_frame,
237 )
238 };
239 if let Some(pb) = &maybe_bar {
240 pb.finish_and_clear();
241 }
242 let mut render_report = result?;
243 on_progress(RenderProgress::new(frame_count, frame_count));
245
246 if mp4_default_background && input::png_has_alpha(&frames[0]) {
247 let note = "âšī¸ mp4 cannot be transparent, so transparent areas are black. \
248 Set a background color (--background) to choose another.";
249 render_report.notes =
250 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", note));
251 }
252
253 render_report.frames_rendered = Some(frame_count as usize);
255
256 if let Some(ext) = Path::new(&args.output).extension().and_then(|s| s.to_str()) {
257 let ext = ext.to_lowercase();
258 let expected_ext = match args.format.as_str() {
259 "webm" => "webm",
260 "mp4" => "mp4",
261 "gif" => "gif",
262 "mov" => "mov",
263 _ => "",
264 };
265
266 if ext != expected_ext {
267 let warning = format!(
268 "â ī¸ Warning: Output extension '{}' does not match format '{}'",
269 ext, args.format
270 );
271 render_report.notes =
272 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", warning));
273 }
274 }
275
276 if args.open {
277 if let Err(e) = utils::open_output(&args.output) {
278 eprintln!("â ī¸ Failed to open video preview: {}", e);
279 }
280 }
281 Ok(render_report)
282}
283
284fn fade_filter(args: &RenderConfig, duration: f32) -> String {
288 let alpha = ":alpha=1";
289 let mut fades = Vec::new();
290 if args.fade_in > 0.0 {
291 fades.push(format!("fade=t=in:st=0:d={}{}", args.fade_in, alpha));
292 }
293 if args.fade_out > 0.0 {
294 let start = (duration - args.fade_out).max(0.0);
295 fades.push(format!(
296 "fade=t=out:st={}:d={}{}",
297 start, args.fade_out, alpha
298 ));
299 }
300 fades.join(",")
301}
302
303pub fn preview_frame(
305 input: &std::path::Path,
306 file_pattern: Option<String>,
307 frame_index: Option<usize>,
308 output: &std::path::Path,
309 verbose: bool,
310) -> Result<String, String> {
311 if !input.exists() {
312 return Err(format!(
313 "â Input path '{}' does not exist.",
314 input.display()
315 ));
316 }
317
318 let (folder, _temp_guard) = if input.extension().is_some_and(|ext| ext == "zip") {
320 let (path, guard) = utils::unzip_frames(input, verbose).map_err(|e| e.to_string())?;
321 (path, Some(guard))
322 } else {
323 (input.to_path_buf(), None)
324 };
325 let pattern = file_pattern.unwrap_or_else(|| "*.png".to_string());
326 let frames = input::collect_input_frames(&folder, Some(pattern.clone()))
327 .map_err(|e| format!("â Failed to read frames: {}", e))?;
328 if frames.is_empty() {
329 return Err(format!(
330 "â No input files found in '{}' matching pattern '{}'",
331 input.display(),
332 pattern
333 ));
334 }
335 let idx = frame_index.unwrap_or(frames.len() / 2);
336 if idx >= frames.len() {
337 return Err(format!(
338 "â Frame index {} out of range (0..{})",
339 idx,
340 frames.len() - 1
341 ));
342 }
343 std::fs::copy(&frames[idx], output).map_err(|e| format!("â Failed to copy frame: {}", e))?;
344
345 if verbose {
346 println!("đŧī¸ Preview saved to: {}", output.display());
347 }
348 Ok(output.to_string_lossy().into_owned())
349}