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