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    match args.format.as_str() {
33        "webm" | "mp4" | "gif" => Ok::<(), String>(()),
34        _ => Err("Unsupported format".into()),
35    }?;
36
37    // Is this a preview render?
38    if args.is_preview() {
39        if args.open {
40            eprintln!("⚠️ '--open' is only supported for full render. Ignoring for preview.");
41        }
42        let mut out_path = PathBuf::from(&args.output);
43        if out_path.extension().is_some() {
44            out_path.set_extension("png");
45        } else {
46            out_path = out_path.with_extension("png");
47        }
48        preview_frame(
49            &args.input,
50            args.file_pattern.clone(),
51            args.preview_frame_limit(),
52            &out_path,
53            args.verbose,
54        )?;
55        return Ok(RenderReport {
56            output_path: PathBuf::from(out_path.to_string_lossy().into_owned()),
57            frames_rendered: Some(1),
58            ffmpeg_warnings: Vec::new(),
59            preview: true,
60            notes: Some("Preview complete.".into()),
61        });
62    }
63
64    // Check for ffmpeg availability upfront
65    if args.verbose_ffmpeg {
66        println!("🔍 Checking for ffmpeg...");
67    }
68    match {
69        let mut cmd = Command::new("ffmpeg");
70        cmd.arg("-version");
71
72        if !args.verbose_ffmpeg {
73            cmd.stdout(std::process::Stdio::null());
74            cmd.stderr(std::process::Stdio::null());
75        }
76
77        cmd.status()
78    } {
79        Ok(s) if s.success() => {}
80        Ok(_) => {
81            return Err("❌ ffmpeg failed to run correctly.".into());
82        }
83        Err(e) => {
84            if e.kind() == std::io::ErrorKind::NotFound {
85                return Err(
86                    "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
87                        .into(),
88                );
89            } else {
90                return Err(format!("❌ Failed to execute ffmpeg: {}", e));
91            }
92        }
93    }
94
95    if !args.input.exists() {
96        return Err(format!(
97            "❌ Input path '{}' does not exist.",
98            args.input.display()
99        ));
100    }
101
102    let input_path = &args.input;
103    let (working_input_path, _temp_guard) = if input_path
104        .extension()
105        .map(|ext| ext == "zip")
106        .unwrap_or(false)
107    {
108        let (path, guard) =
109            utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
110        (path, Some(guard))
111    } else {
112        (input_path.clone(), None)
113    };
114
115    let pattern = args
116        .file_pattern
117        .clone()
118        .unwrap_or_else(|| "*.png".to_string());
119    let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
120        .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
121    let frame_count = frames.len() as u32;
122
123    // Use the provided file pattern when building the ffmpeg input string
124    let input_pattern = working_input_path.join(&pattern);
125    let input_str = input_pattern.to_str().unwrap();
126
127    if frame_count == 0 {
128        return Err(format!(
129            "❌ No input files found in '{}' matching pattern '{}'.",
130            working_input_path.display(),
131            pattern
132        ));
133    }
134
135    let duration = frame_count as f32 / args.fps as f32;
136
137    let mut fade_filter = String::new();
138    if args.fade_in > 0.0 {
139        fade_filter.push_str(&format!("fade=t=in:st=0:d={}", args.fade_in));
140    }
141    if args.fade_out > 0.0 {
142        if !fade_filter.is_empty() {
143            fade_filter.push(',');
144        }
145        let start = (duration - args.fade_out).max(0.0);
146        fade_filter.push_str(&format!("fade=t=out:st={}:d={}", start, args.fade_out));
147    }
148
149    if args.verbose {
150        println!(
151            "🌿 Rendering {} → {} at {} FPS...",
152            input_str, args.output, args.fps
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            .tick_chars("䷀䷫䷌䷅䷤䷥䷄䷍䷪"),
165        );
166        pb.enable_steady_tick(Duration::from_millis(120));
167        Some(pb)
168    } else {
169        None
170    };
171
172    let mut render_report = if args.format == "gif" {
173        ffmpeg::gif::render_gif(
174            input_str,
175            &args.output,
176            args.fps,
177            Some(&fade_filter),
178            args.verbose_ffmpeg,
179        )
180    } else {
181        ffmpeg::video::render_video(
182            input_str,
183            &args.output,
184            args.fps,
185            &args.format,
186            args.bitrate.as_deref(),
187            args.crf,
188            Some(&fade_filter),
189            args.verbose_ffmpeg,
190        )
191    }?;
192
193    // Post-inject known input frame count after rendering
194    render_report.frames_rendered = Some(frame_count as usize);
195
196    if let Some(ext) = Path::new(&args.output).extension().and_then(|s| s.to_str()) {
197        let ext = ext.to_lowercase();
198        let expected_ext = match args.format.as_str() {
199            "webm" => "webm",
200            "mp4" => "mp4",
201            "gif" => "gif",
202            _ => "",
203        };
204
205        if ext != expected_ext {
206            let warning = format!(
207                "⚠️ Warning: Output extension '{}' does not match format '{}'",
208                ext, args.format
209            );
210            render_report.notes =
211                Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", warning));
212        }
213    }
214
215    if let Some(pb) = &maybe_spinner {
216        pb.finish_with_message("✅ FFmpeg rendering complete!");
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/// Extract a single frame from an input folder or ZIP archive
228pub fn preview_frame(
229    input: &std::path::Path,
230    file_pattern: Option<String>,
231    frame_index: Option<usize>,
232    output: &std::path::Path,
233    verbose: bool,
234) -> Result<String, String> {
235    if !input.exists() {
236        return Err(format!(
237            "❌ Input path '{}' does not exist.",
238            input.display()
239        ));
240    }
241
242    if input.extension().map(|ext| ext == "zip").unwrap_or(false) {
243        let count = utils::count_pngs_in_zip(input).map_err(|e| e.to_string())?;
244        if count == 0 {
245            return Err("❌ No PNG files found in zip archive".into());
246        }
247        let idx = frame_index.unwrap_or(count / 2);
248        if idx >= count {
249            return Err(format!(
250                "❌ Frame index {} out of range (0..{})",
251                idx,
252                count - 1
253            ));
254        }
255        utils::extract_frame_from_zip(input, idx, output).map_err(|e| e.to_string())?;
256    } else {
257        let pattern = file_pattern.clone().unwrap_or_else(|| "*.png".to_string());
258        let frames = input::collect_input_frames(input, Some(pattern.clone()))
259            .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
260        if frames.is_empty() {
261            return Err(format!(
262                "❌ No input files found in '{}' matching pattern '{}'",
263                input.display(),
264                pattern
265            ));
266        }
267        let idx = frame_index.unwrap_or(frames.len() / 2);
268        if idx >= frames.len() {
269            return Err(format!(
270                "❌ Frame index {} out of range (0..{})",
271                idx,
272                frames.len() - 1
273            ));
274        }
275        std::fs::copy(&frames[idx], output)
276            .map_err(|e| format!("❌ Failed to copy frame: {}", e))?;
277    }
278
279    if verbose {
280        println!("🖼️ Preview saved to: {}", output.display());
281    }
282    Ok(output.to_string_lossy().into_owned())
283}