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::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    if args.verbose {
24        let version = env!("CARGO_PKG_VERSION");
25        eprintln!("🪼 Aether Renderer v{version} starting...");
26    }
27    // Validate output path
28    if args.output.is_empty() {
29        return Err("❌ Output path cannot be empty.".into());
30    }
31
32    if !config::FORMATS.contains(&args.format.as_str()) {
33        return Err(format!(
34            "❌ Unsupported format '{}'. Use one of: {}.",
35            args.format,
36            config::FORMATS.join(", ")
37        ));
38    }
39    if args.fps == 0 {
40        return Err("❌ fps must be greater than 0.".into());
41    }
42
43    // Is this a preview render?
44    if args.is_preview() {
45        if args.open {
46            eprintln!("⚠️ '--open' is only supported for full render. Ignoring for preview.");
47        }
48        let mut out_path = PathBuf::from(&args.output);
49        if out_path.extension().is_some() {
50            out_path.set_extension("png");
51        } else {
52            out_path = out_path.with_extension("png");
53        }
54        preview_frame(
55            &args.input,
56            args.file_pattern.clone(),
57            args.preview_frame_limit()
58                .filter(|&n| n != config::PREVIEW_MIDDLE),
59            &out_path,
60            args.verbose,
61        )?;
62        return Ok(RenderReport {
63            output_path: PathBuf::from(out_path.to_string_lossy().into_owned()),
64            frames_rendered: Some(1),
65            ffmpeg_warnings: Vec::new(),
66            preview: true,
67            notes: Some("Preview complete.".into()),
68        });
69    }
70
71    // Check for ffmpeg availability upfront
72    if args.verbose_ffmpeg {
73        println!("🔍 Checking for ffmpeg...");
74    }
75    let mut ffmpeg_check = Command::new("ffmpeg");
76    ffmpeg_check.arg("-version");
77    if !args.verbose_ffmpeg {
78        ffmpeg_check.stdout(std::process::Stdio::null());
79        ffmpeg_check.stderr(std::process::Stdio::null());
80    }
81    match ffmpeg_check.status() {
82        Ok(s) if s.success() => {}
83        Ok(_) => {
84            return Err("❌ ffmpeg failed to run correctly.".into());
85        }
86        Err(e) => {
87            if e.kind() == std::io::ErrorKind::NotFound {
88                return Err(
89                    "❌ ffmpeg not found. Install it and make sure it is on your PATH:\n   \
90                     macOS: brew install ffmpeg · Ubuntu/Debian: sudo apt install ffmpeg · \
91                     Windows: winget install Gyan.FFmpeg"
92                        .into(),
93                );
94            } else {
95                return Err(format!("❌ Failed to execute ffmpeg: {}", e));
96            }
97        }
98    }
99
100    if !args.input.exists() {
101        return Err(format!(
102            "❌ Input path '{}' does not exist.",
103            args.input.display()
104        ));
105    }
106
107    let input_path = &args.input;
108    let (working_input_path, _temp_guard) = if input_path
109        .extension()
110        .map(|ext| ext == "zip")
111        .unwrap_or(false)
112    {
113        let (path, guard) =
114            utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
115        (path, Some(guard))
116    } else {
117        (input_path.clone(), None)
118    };
119
120    let pattern = args
121        .file_pattern
122        .clone()
123        .unwrap_or_else(|| "*.png".to_string());
124    let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
125        .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
126    let frame_count = frames.len() as u32;
127
128    if frame_count == 0 {
129        return Err(format!(
130            "❌ No input files found in '{}' matching pattern '{}'.",
131            working_input_path.display(),
132            pattern
133        ));
134    }
135
136    // A numbered copy (links) of exactly these frames, readable by ffmpeg on every OS.
137    let sequence = input::FrameSequence::new(&frames)
138        .map_err(|e| format!("❌ Failed to prepare frames: {}", e))?;
139    let input_str = sequence.pattern().to_string_lossy().into_owned();
140
141    let duration = frame_count as f32 / args.fps as f32;
142    let fade_filter = fade_filter(&args, duration);
143
144    if args.verbose {
145        println!(
146            "🌿 Rendering {} frames from {} → {} ({}, {} fps, {:.2} s)...",
147            frame_count,
148            working_input_path.display(),
149            args.output,
150            args.format,
151            args.fps,
152            duration
153        );
154    }
155
156    let maybe_spinner = if args.verbose {
157        let pb = ProgressBar::new_spinner();
158        pb.set_style(
159            ProgressStyle::with_template(
160                "{spinner:.green} 🌿 Rendering with FFmpeg... {elapsed_precise}",
161            )
162            .unwrap()
163            .tick_chars("䷀䷫䷌䷅䷤䷥䷄䷍䷪"),
164        );
165        pb.enable_steady_tick(Duration::from_millis(120));
166        Some(pb)
167    } else {
168        None
169    };
170
171    let result = if args.format == "gif" {
172        ffmpeg::gif::render_gif(
173            &input_str,
174            &args.output,
175            args.fps,
176            Some(&fade_filter),
177            args.verbose_ffmpeg,
178        )
179    } else {
180        ffmpeg::video::render_video(
181            &input_str,
182            &args.output,
183            args.fps,
184            &args.format,
185            args.bitrate.as_deref(),
186            args.crf,
187            Some(&fade_filter),
188            args.verbose_ffmpeg,
189        )
190    };
191    if let Some(pb) = &maybe_spinner {
192        pb.finish_and_clear();
193    }
194    let mut render_report = result?;
195
196    // Post-inject known input frame count after rendering
197    render_report.frames_rendered = Some(frame_count as usize);
198
199    if let Some(ext) = Path::new(&args.output).extension().and_then(|s| s.to_str()) {
200        let ext = ext.to_lowercase();
201        let expected_ext = match args.format.as_str() {
202            "webm" => "webm",
203            "mp4" => "mp4",
204            "gif" => "gif",
205            "mov" => "mov",
206            _ => "",
207        };
208
209        if ext != expected_ext {
210            let warning = format!(
211                "⚠️ Warning: Output extension '{}' does not match format '{}'",
212                ext, args.format
213            );
214            render_report.notes =
215                Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", warning));
216        }
217    }
218
219    if args.open {
220        if let Err(e) = utils::open_output(&args.output) {
221            eprintln!("⚠️ Failed to open video preview: {}", e);
222        }
223    }
224    Ok(render_report)
225}
226
227/// The `fade` filters for the configured fade in/out. Formats with transparency
228/// fade the alpha channel, so a transparent animation fades to transparent
229/// instead of to black.
230fn fade_filter(args: &RenderConfig, duration: f32) -> String {
231    let alpha = if args.format == "mp4" { "" } else { ":alpha=1" };
232    let mut fades = Vec::new();
233    if args.fade_in > 0.0 {
234        fades.push(format!("fade=t=in:st=0:d={}{}", args.fade_in, alpha));
235    }
236    if args.fade_out > 0.0 {
237        let start = (duration - args.fade_out).max(0.0);
238        fades.push(format!(
239            "fade=t=out:st={}:d={}{}",
240            start, args.fade_out, alpha
241        ));
242    }
243    fades.join(",")
244}
245
246/// Extract a single frame from an input folder or ZIP archive
247pub fn preview_frame(
248    input: &std::path::Path,
249    file_pattern: Option<String>,
250    frame_index: Option<usize>,
251    output: &std::path::Path,
252    verbose: bool,
253) -> Result<String, String> {
254    if !input.exists() {
255        return Err(format!(
256            "❌ Input path '{}' does not exist.",
257            input.display()
258        ));
259    }
260
261    // Zips are unpacked so frames are picked in the same (natural) order as a render.
262    let (folder, _temp_guard) = if input.extension().is_some_and(|ext| ext == "zip") {
263        let (path, guard) = utils::unzip_frames(input, verbose).map_err(|e| e.to_string())?;
264        (path, Some(guard))
265    } else {
266        (input.to_path_buf(), None)
267    };
268    let pattern = file_pattern.unwrap_or_else(|| "*.png".to_string());
269    let frames = input::collect_input_frames(&folder, Some(pattern.clone()))
270        .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
271    if frames.is_empty() {
272        return Err(format!(
273            "❌ No input files found in '{}' matching pattern '{}'",
274            input.display(),
275            pattern
276        ));
277    }
278    let idx = frame_index.unwrap_or(frames.len() / 2);
279    if idx >= frames.len() {
280        return Err(format!(
281            "❌ Frame index {} out of range (0..{})",
282            idx,
283            frames.len() - 1
284        ));
285    }
286    std::fs::copy(&frames[idx], output).map_err(|e| format!("❌ Failed to copy frame: {}", e))?;
287
288    if verbose {
289        println!("🖼️ Preview saved to: {}", output.display());
290    }
291    Ok(output.to_string_lossy().into_owned())
292}