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 indicatif::{ProgressBar, ProgressStyle};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::Duration;
14
15/// Load configuration from file then render
16pub fn render_from_config(config_path: &str) -> Result<RenderReport, String> {
17    let args = RenderConfig::from_file(config_path)?;
18    render(args)
19}
20
21/// Orchestrate rendering from a parsed configuration
22pub fn render(args: RenderConfig) -> Result<RenderReport, String> {
23    render_with_progress(args, |_| {})
24}
25
26/// Like [`render`], but calls `on_progress` while ffmpeg works: once with frame 0
27/// when encoding starts, then about twice a second, and with 100% at the end.
28/// Use it to drive a progress bar in a GUI or another tool.
29///
30/// ```no_run
31/// # use aether_renderer_core::{render_with_progress, RenderConfig};
32/// # fn demo(cfg: RenderConfig) -> Result<(), String> {
33/// render_with_progress(cfg, |p| {
34///     println!("{:.0}% ({}/{} frames)", p.percent, p.frame, p.total_frames);
35/// })?;
36/// # Ok(()) }
37/// ```
38pub 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    // Validate output path
50    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    // Normalized again here for callers that build a RenderConfig themselves.
65    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    // Is this a preview render?
74    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    // Check for ffmpeg availability upfront
102    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    // A numbered copy (links) of exactly these frames, readable by ffmpeg on every OS.
167    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    // ffmpeg's last report can come before the final frame is counted.
244    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    // Post-inject known input frame count after rendering
254    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
284/// The `fade` filters for the configured fade in/out. They fade the alpha channel,
285/// so a transparent animation fades to transparent, or, with a background color
286/// (always for mp4), to that color.
287fn 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
303/// Extract a single frame from an input folder or ZIP archive
304pub 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    // Zips are unpacked so frames are picked in the same (natural) order as a render.
319    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}