aether_renderer_core/
lib.rs

1pub mod config;
2pub mod ffmpeg;
3pub mod input;
4pub mod utils;
5
6pub use config::RenderConfig;
7
8use indicatif::{ProgressBar, ProgressStyle};
9use std::process::Command;
10use std::time::Duration;
11
12/// Load configuration from file then render
13pub fn render_from_config(config_path: &str) -> Result<String, String> {
14    let args = RenderConfig::from_file(config_path)?;
15    render(args)
16}
17
18/// Orchestrate rendering from a parsed configuration
19pub fn render(args: RenderConfig) -> Result<String, String> {
20    // Check for ffmpeg availability upfront
21    match Command::new("ffmpeg").arg("-version").status() {
22        Ok(s) if s.success() => {}
23        Ok(_) => {
24            return Err("❌ ffmpeg failed to run correctly.".into());
25        }
26        Err(e) => {
27            if e.kind() == std::io::ErrorKind::NotFound {
28                return Err(
29                    "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
30                        .into(),
31                );
32            } else {
33                return Err(format!("❌ Failed to execute ffmpeg: {}", e));
34            }
35        }
36    }
37
38    if !args.input.exists() {
39        return Err(format!(
40            "❌ Input path '{}' does not exist.",
41            args.input.display()
42        ));
43    }
44
45    let input_path = &args.input;
46    let (working_input_path, _temp_guard) = if input_path
47        .extension()
48        .map(|ext| ext == "zip")
49        .unwrap_or(false)
50    {
51        let (path, guard) = utils::unzip_frames(input_path).map_err(|e| e.to_string())?;
52        (path, Some(guard))
53    } else {
54        (input_path.clone(), None)
55    };
56
57    let pattern = args
58        .file_pattern
59        .clone()
60        .unwrap_or_else(|| "*.png".to_string());
61    let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
62        .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
63    let frame_count = frames.len() as u32;
64
65    // Use the provided file pattern when building the ffmpeg input string
66    let input_pattern = working_input_path.join(&pattern);
67    let input_str = input_pattern.to_str().unwrap();
68
69    if frame_count == 0 {
70        return Err(format!(
71            "❌ No input files found in '{}' matching pattern '{}'.",
72            working_input_path.display(),
73            pattern
74        ));
75    }
76
77    let duration = frame_count as f32 / args.fps as f32;
78
79    let mut fade_filter = String::new();
80    if args.fade_in > 0.0 {
81        fade_filter.push_str(&format!("fade=t=in:st=0:d={}", args.fade_in));
82    }
83    if args.fade_out > 0.0 {
84        if !fade_filter.is_empty() {
85            fade_filter.push(',');
86        }
87        let start = (duration - args.fade_out).max(0.0);
88        fade_filter.push_str(&format!("fade=t=out:st={}:d={}", start, args.fade_out));
89    }
90
91    println!(
92        "🌿 Rendering {} → {} at {} FPS...",
93        input_str, args.output, args.fps
94    );
95
96    let maybe_spinner = if args.verbose {
97        let pb = ProgressBar::new_spinner();
98        pb.set_style(
99            ProgressStyle::with_template(
100                "{spinner:.green} 🌿 Rendering with FFmpeg... {elapsed_precise}",
101            )
102            .unwrap()
103            .tick_chars("⠁⠃⠇⠧⠷⠿⠻⠟⠯⠷⠾⠽⠻⠛⠋"),
104        );
105        pb.enable_steady_tick(Duration::from_millis(120));
106        Some(pb)
107    } else {
108        None
109    };
110
111    if args.format == "gif" {
112        ffmpeg::gif::render_gif(input_str, &args.output, args.fps, Some(&fade_filter))?;
113    } else {
114        ffmpeg::video::render_video(
115            input_str,
116            &args.output,
117            args.fps,
118            &args.format,
119            args.bitrate.as_deref(),
120            args.crf,
121            Some(&fade_filter),
122        )?;
123    }
124
125    if let Some(pb) = &maybe_spinner {
126        pb.finish_with_message("✅ FFmpeg rendering complete!");
127    }
128
129    if args.preview {
130        if let Err(e) = utils::open_output(&args.output) {
131            eprintln!("⚠️ Failed to open video preview: {}", e);
132        }
133    }
134    Ok(args.output.clone())
135}