Skip to main content

aether_renderer_core/
lib.rs

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
17/// The `input` that means "read a PNG stream from stdin" (`--input -`).
18pub const STDIN_INPUT: &str = "-";
19
20/// Is `input` the stdin marker `-`?
21pub fn is_stdin_input(input: &Path) -> bool {
22    input.as_os_str() == STDIN_INPUT
23}
24
25/// Load configuration from file then render
26pub fn render_from_config(config_path: &str) -> Result<RenderReport, String> {
27    let args = RenderConfig::from_file(config_path)?;
28    render(args)
29}
30
31/// Orchestrate rendering from a parsed configuration
32pub fn render(args: RenderConfig) -> Result<RenderReport, String> {
33    render_with_progress(args, |_| {})
34}
35
36/// Like [`render`], but calls `on_progress` while ffmpeg works: once with frame 0
37/// when encoding starts, then about twice a second, and with 100% at the end.
38/// Use it to drive a progress bar in a GUI or another tool.
39///
40/// ```no_run
41/// # use aether_renderer_core::{render_with_progress, RenderConfig};
42/// # fn demo(cfg: RenderConfig) -> Result<(), String> {
43/// render_with_progress(cfg, |p| {
44///     println!("{:.0}% ({}/{} frames)", p.percent, p.frame, p.total_frames);
45/// })?;
46/// # Ok(()) }
47/// ```
48pub 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    // Is this a preview render?
68    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    // A numbered copy (links) of exactly these frames, readable by ffmpeg on every OS.
134    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    // ffmpeg's last report can come before the final frame is counted.
180    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    // Post-inject known input frame count after rendering
190    render_report.frames_rendered = Some(frame_count as usize);
191
192    Ok(finish(&args, render_report))
193}
194
195/// Render a PNG stream: PNG files back to back, as written by
196/// `aether-renderer-capture --stdout` or `ffmpeg -f image2pipe -c:v png -`.
197/// No temp files: the bytes go straight to ffmpeg's stdin. `args.input` is ignored.
198///
199/// `expected_frames` is optional. Without it progress reports `total_frames: 0`
200/// (unknown) and `percent: 0` until the end, and `fade_out` can't be used, since
201/// it needs to know where the stream ends.
202///
203/// ```no_run
204/// # use aether_renderer_core::{render_stream, RenderConfig};
205/// # fn demo(cfg: RenderConfig) -> Result<(), String> {
206/// let frames = std::fs::File::open("frames.pngstream").map_err(|e| e.to_string())?;
207/// render_stream(cfg, frames, Some(120), |p| println!("{}/{}", p.frame, p.total_frames))?;
208/// # Ok(()) }
209/// ```
210pub 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    // Peek at the start: an empty stream fails here with a clear message instead
243    // of inside ffmpeg, and the first frame tells whether frames are transparent.
244    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
317/// How much of a stream is read up front to check it isn't empty and to see
318/// whether the first frame is transparent.
319const STREAM_PEEK_BYTES: u64 = 64 * 1024;
320
321/// Checked config values every render needs.
322struct Prepared {
323    /// ffmpeg color to paint behind the frames, if any (always set for mp4).
324    background: Option<String>,
325    /// mp4 without a chosen background: transparent areas become black.
326    mp4_default_background: bool,
327}
328
329fn prepare(args: &RenderConfig) -> Result<Prepared, String> {
330    // Validate output path
331    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    // Normalized again here for callers that build a RenderConfig themselves.
345    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
357/// Check for ffmpeg availability upfront, with install hints if it's missing.
358fn 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
381/// The `--verbose` progress bar; a spinner when the frame count is unknown.
382fn 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
403/// Run the encoder for `args.format`.
404fn 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
437/// Warn about an output extension that doesn't match the format, and `--open`.
438fn 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
467/// The `fade` filters for the configured fade in/out. They fade the alpha channel,
468/// so a transparent animation fades to transparent, or, with a background color
469/// (always for mp4), to that color.
470fn 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
486/// Extract a single frame from an input folder or ZIP archive
487pub 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    // Zips are unpacked so frames are picked in the same (natural) order as a render.
502    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}