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 ffmpeg::FfmpegInput;
11use indicatif::{ProgressBar, ProgressStyle};
12use std::io::Read;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::time::Duration;
16
17pub const STDIN_INPUT: &str = "-";
19
20pub fn is_stdin_input(input: &Path) -> bool {
22 input.as_os_str() == STDIN_INPUT
23}
24
25pub fn render_from_config(config_path: &str) -> Result<RenderReport, String> {
27 let args = RenderConfig::from_file(config_path)?;
28 render(args)
29}
30
31pub fn render(args: RenderConfig) -> Result<RenderReport, String> {
33 render_with_progress(args, |_| {})
34}
35
36pub fn render_with_progress<F>(
49 args: RenderConfig,
50 mut on_progress: F,
51) -> Result<RenderReport, String>
52where
53 F: FnMut(RenderProgress),
54{
55 if is_stdin_input(&args.input) {
56 return render_stream(args, std::io::stdin(), None, on_progress);
57 }
58 if args.verbose {
59 let version = env!("CARGO_PKG_VERSION");
60 eprintln!("๐ชผ Aether Renderer v{version} starting...");
61 }
62 let Prepared {
63 background,
64 mp4_default_background,
65 } = prepare(&args)?;
66
67 if args.is_preview() {
69 if args.open {
70 eprintln!("โ ๏ธ '--open' is only supported for full render. Ignoring for preview.");
71 }
72 let mut out_path = PathBuf::from(&args.output);
73 if out_path.extension().is_some() {
74 out_path.set_extension("png");
75 } else {
76 out_path = out_path.with_extension("png");
77 }
78 preview_frame(
79 &args.input,
80 args.file_pattern.clone(),
81 args.preview_frame_limit()
82 .filter(|&n| n != config::PREVIEW_MIDDLE),
83 &out_path,
84 args.verbose,
85 )?;
86 return Ok(RenderReport {
87 output_path: PathBuf::from(out_path.to_string_lossy().into_owned()),
88 frames_rendered: Some(1),
89 ffmpeg_warnings: Vec::new(),
90 preview: true,
91 notes: Some("Preview complete.".into()),
92 });
93 }
94
95 check_ffmpeg(args.verbose_ffmpeg)?;
96
97 if !args.input.exists() {
98 return Err(format!(
99 "โ Input path '{}' does not exist.",
100 args.input.display()
101 ));
102 }
103
104 let input_path = &args.input;
105 let (working_input_path, _temp_guard) = if input_path
106 .extension()
107 .map(|ext| ext == "zip")
108 .unwrap_or(false)
109 {
110 let (path, guard) =
111 utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
112 (path, Some(guard))
113 } else {
114 (input_path.clone(), None)
115 };
116
117 let pattern = args
118 .file_pattern
119 .clone()
120 .unwrap_or_else(|| "*.png".to_string());
121 let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
122 .map_err(|e| format!("โ Failed to read frames: {}", e))?;
123 let frame_count = frames.len() as u32;
124
125 if frame_count == 0 {
126 return Err(format!(
127 "โ No input files found in '{}' matching pattern '{}'.",
128 working_input_path.display(),
129 pattern
130 ));
131 }
132
133 let sequence = input::FrameSequence::new(&frames)
135 .map_err(|e| format!("โ Failed to prepare frames: {}", e))?;
136 let input_str = sequence.pattern().to_string_lossy().into_owned();
137
138 let duration = frame_count as f32 / args.fps as f32;
139 let fade_filter = fade_filter(&args, duration);
140
141 if args.verbose {
142 println!(
143 "๐ฟ Rendering {} frames from {} โ {} ({}, {} fps, {:.2} s{})...",
144 frame_count,
145 working_input_path.display(),
146 args.output,
147 args.format,
148 args.fps,
149 duration,
150 match &background {
151 Some(c) => format!(", background {c}"),
152 None => String::new(),
153 }
154 );
155 }
156
157 let maybe_bar = args.verbose.then(|| progress_bar(Some(frame_count)));
158
159 on_progress(RenderProgress::new(0, frame_count));
160 let mut on_frame = |n: u32| {
161 let progress = RenderProgress::new(n, frame_count);
162 if let Some(pb) = &maybe_bar {
163 pb.set_position(progress.frame as u64);
164 }
165 on_progress(progress);
166 };
167
168 let result = encode(
169 FfmpegInput::Sequence(&input_str),
170 &args,
171 &fade_filter,
172 background.as_deref(),
173 &mut on_frame,
174 );
175 if let Some(pb) = &maybe_bar {
176 pb.finish_and_clear();
177 }
178 let mut render_report = result?;
179 on_progress(RenderProgress::new(frame_count, frame_count));
181
182 if mp4_default_background && input::png_has_alpha(&frames[0]) {
183 let note = "โน๏ธ mp4 cannot be transparent, so transparent areas are black. \
184 Set a background color (--background) to choose another.";
185 render_report.notes =
186 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", note));
187 }
188
189 render_report.frames_rendered = Some(frame_count as usize);
191
192 Ok(finish(&args, render_report))
193}
194
195pub fn render_stream<R, F>(
211 args: RenderConfig,
212 frames: R,
213 expected_frames: Option<u32>,
214 mut on_progress: F,
215) -> Result<RenderReport, String>
216where
217 R: Read + Send + 'static,
218 F: FnMut(RenderProgress),
219{
220 if args.verbose {
221 let version = env!("CARGO_PKG_VERSION");
222 eprintln!("๐ชผ Aether Renderer v{version} starting...");
223 }
224 let Prepared {
225 background,
226 mp4_default_background,
227 } = prepare(&args)?;
228 if args.is_preview() {
229 return Err("โ --preview needs a folder or .zip input, not a stream.".into());
230 }
231 if expected_frames == Some(0) {
232 return Err("โ --frames must be greater than 0.".into());
233 }
234 if args.fade_out > 0.0 && expected_frames.is_none() {
235 return Err(
236 "โ --fade-out needs to know where the stream ends: pass the frame count with --frames N."
237 .into(),
238 );
239 }
240 check_ffmpeg(args.verbose_ffmpeg)?;
241
242 let mut frames = frames;
245 let mut head = Vec::new();
246 (&mut frames)
247 .take(STREAM_PEEK_BYTES)
248 .read_to_end(&mut head)
249 .map_err(|e| format!("โ Failed to read the input stream: {e}"))?;
250 if head.is_empty() {
251 return Err("โ No frames received: the input stream is empty.".into());
252 }
253 let has_alpha = input::png_bytes_have_alpha(&head);
254 let stream: Box<dyn Read + Send> = Box::new(std::io::Cursor::new(head).chain(frames));
255
256 let duration = expected_frames.map(|n| n as f32 / args.fps as f32);
257 let fade_filter = fade_filter(&args, duration.unwrap_or(0.0));
258 if args.verbose {
259 println!(
260 "๐ฟ Rendering a PNG stream โ {} ({}, {} fps{}{})...",
261 args.output,
262 args.format,
263 args.fps,
264 match (expected_frames, duration) {
265 (Some(n), Some(d)) => format!(", {n} frames, {d:.2} s"),
266 _ => String::new(),
267 },
268 match &background {
269 Some(c) => format!(", background {c}"),
270 None => String::new(),
271 }
272 );
273 }
274
275 let maybe_bar = args.verbose.then(|| progress_bar(expected_frames));
276 let progress = |frame: u32| match expected_frames {
277 Some(total) => RenderProgress::new(frame, total),
278 None => RenderProgress {
279 frame,
280 total_frames: 0,
281 percent: 0.0,
282 },
283 };
284 on_progress(progress(0));
285 let mut frames_seen = 0u32;
286 let mut on_frame = |n: u32| {
287 frames_seen = frames_seen.max(n);
288 if let Some(pb) = &maybe_bar {
289 pb.set_position(n as u64);
290 }
291 on_progress(progress(n));
292 };
293 let result = encode(
294 FfmpegInput::PngStream(stream),
295 &args,
296 &fade_filter,
297 background.as_deref(),
298 &mut on_frame,
299 );
300 if let Some(pb) = &maybe_bar {
301 pb.finish_and_clear();
302 }
303 let mut render_report = result?;
304 let total = expected_frames.unwrap_or(frames_seen);
305 on_progress(RenderProgress::new(total, total));
306
307 if mp4_default_background && has_alpha {
308 let note = "โน๏ธ mp4 cannot be transparent, so transparent areas are black. \
309 Set a background color (--background) to choose another.";
310 render_report.notes =
311 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", note));
312 }
313 render_report.frames_rendered = Some(frames_seen as usize);
314 Ok(finish(&args, render_report))
315}
316
317const STREAM_PEEK_BYTES: u64 = 64 * 1024;
320
321struct Prepared {
323 background: Option<String>,
325 mp4_default_background: bool,
327}
328
329fn prepare(args: &RenderConfig) -> Result<Prepared, String> {
330 if args.output.is_empty() {
332 return Err("โ Output path cannot be empty.".into());
333 }
334 if !config::FORMATS.contains(&args.format.as_str()) {
335 return Err(format!(
336 "โ Unsupported format '{}'. Use one of: {}.",
337 args.format,
338 config::FORMATS.join(", ")
339 ));
340 }
341 if args.fps == 0 {
342 return Err("โ fps must be greater than 0.".into());
343 }
344 let chosen_background = config::parse_background(args.background.as_deref().unwrap_or(""))?;
346 let mp4_default_background = chosen_background.is_none() && args.format == "mp4";
347 Ok(Prepared {
348 background: if mp4_default_background {
349 Some("black".to_string())
350 } else {
351 chosen_background
352 },
353 mp4_default_background,
354 })
355}
356
357fn check_ffmpeg(verbose_ffmpeg: bool) -> Result<(), String> {
359 if verbose_ffmpeg {
360 println!("๐ Checking for ffmpeg...");
361 }
362 let mut ffmpeg_check = Command::new("ffmpeg");
363 ffmpeg_check.arg("-version");
364 if !verbose_ffmpeg {
365 ffmpeg_check.stdout(std::process::Stdio::null());
366 ffmpeg_check.stderr(std::process::Stdio::null());
367 }
368 match ffmpeg_check.status() {
369 Ok(s) if s.success() => Ok(()),
370 Ok(_) => Err("โ ffmpeg failed to run correctly.".into()),
371 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(
372 "โ ffmpeg not found. Install it and make sure it is on your PATH:\n \
373 macOS: brew install ffmpeg ยท Ubuntu/Debian: sudo apt install ffmpeg ยท \
374 Windows: winget install Gyan.FFmpeg"
375 .into(),
376 ),
377 Err(e) => Err(format!("โ Failed to execute ffmpeg: {}", e)),
378 }
379}
380
381fn progress_bar(frames: Option<u32>) -> ProgressBar {
383 let (pb, template) = match frames {
384 Some(n) => (
385 ProgressBar::new(n as u64),
386 "{spinner:.green} ๐ฟ Rendering {bar:30.cyan/blue} {pos}/{len} frames {percent:>3}% {elapsed_precise}",
387 ),
388 None => (
389 ProgressBar::new_spinner(),
390 "{spinner:.green} ๐ฟ Rendering {pos} frames {elapsed_precise}",
391 ),
392 };
393 pb.set_style(
394 ProgressStyle::with_template(template)
395 .unwrap()
396 .tick_chars("ไทไทซไทไท
ไทคไทฅไทไทไทช")
397 .progress_chars("โโโ"),
398 );
399 pb.enable_steady_tick(Duration::from_millis(120));
400 pb
401}
402
403fn encode(
405 input: FfmpegInput,
406 args: &RenderConfig,
407 fade_filter: &str,
408 background: Option<&str>,
409 on_frame: &mut dyn FnMut(u32),
410) -> Result<RenderReport, String> {
411 if args.format == "gif" {
412 ffmpeg::gif::render_gif_from(
413 input,
414 &args.output,
415 args.fps,
416 Some(fade_filter),
417 background,
418 args.verbose_ffmpeg,
419 on_frame,
420 )
421 } else {
422 ffmpeg::video::render_video_from(
423 input,
424 &args.output,
425 args.fps,
426 &args.format,
427 args.bitrate.as_deref(),
428 args.crf,
429 Some(fade_filter),
430 background,
431 args.verbose_ffmpeg,
432 on_frame,
433 )
434 }
435}
436
437fn finish(args: &RenderConfig, mut render_report: RenderReport) -> RenderReport {
439 if let Some(ext) = Path::new(&args.output).extension().and_then(|s| s.to_str()) {
440 let ext = ext.to_lowercase();
441 let expected_ext = match args.format.as_str() {
442 "webm" => "webm",
443 "mp4" => "mp4",
444 "gif" => "gif",
445 "mov" => "mov",
446 _ => "",
447 };
448
449 if ext != expected_ext {
450 let warning = format!(
451 "โ ๏ธ Warning: Output extension '{}' does not match format '{}'",
452 ext, args.format
453 );
454 render_report.notes =
455 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", warning));
456 }
457 }
458
459 if args.open {
460 if let Err(e) = utils::open_output(&args.output) {
461 eprintln!("โ ๏ธ Failed to open video preview: {}", e);
462 }
463 }
464 render_report
465}
466
467fn fade_filter(args: &RenderConfig, duration: f32) -> String {
471 let alpha = ":alpha=1";
472 let mut fades = Vec::new();
473 if args.fade_in > 0.0 {
474 fades.push(format!("fade=t=in:st=0:d={}{}", args.fade_in, alpha));
475 }
476 if args.fade_out > 0.0 {
477 let start = (duration - args.fade_out).max(0.0);
478 fades.push(format!(
479 "fade=t=out:st={}:d={}{}",
480 start, args.fade_out, alpha
481 ));
482 }
483 fades.join(",")
484}
485
486pub fn preview_frame(
488 input: &std::path::Path,
489 file_pattern: Option<String>,
490 frame_index: Option<usize>,
491 output: &std::path::Path,
492 verbose: bool,
493) -> Result<String, String> {
494 if !input.exists() {
495 return Err(format!(
496 "โ Input path '{}' does not exist.",
497 input.display()
498 ));
499 }
500
501 let (folder, _temp_guard) = if input.extension().is_some_and(|ext| ext == "zip") {
503 let (path, guard) = utils::unzip_frames(input, verbose).map_err(|e| e.to_string())?;
504 (path, Some(guard))
505 } else {
506 (input.to_path_buf(), None)
507 };
508 let pattern = file_pattern.unwrap_or_else(|| "*.png".to_string());
509 let frames = input::collect_input_frames(&folder, Some(pattern.clone()))
510 .map_err(|e| format!("โ Failed to read frames: {}", e))?;
511 if frames.is_empty() {
512 return Err(format!(
513 "โ No input files found in '{}' matching pattern '{}'",
514 input.display(),
515 pattern
516 ));
517 }
518 let idx = frame_index.unwrap_or(frames.len() / 2);
519 if idx >= frames.len() {
520 return Err(format!(
521 "โ Frame index {} out of range (0..{})",
522 idx,
523 frames.len() - 1
524 ));
525 }
526 std::fs::copy(&frames[idx], output).map_err(|e| format!("โ Failed to copy frame: {}", e))?;
527
528 if verbose {
529 println!("๐ผ๏ธ Preview saved to: {}", output.display());
530 }
531 Ok(output.to_string_lossy().into_owned())
532}