aether_renderer_core/
lib.rs

1pub mod utils;
2
3use serde::Deserialize;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use utils::unzip_frames::unzip_frames;
8
9#[derive(Debug, Deserialize)]
10pub struct RenderConfig {
11    pub input: PathBuf,
12    pub output: String,
13    #[serde(default = "default_fps")]
14    pub fps: u32,
15    #[serde(default = "default_format")]
16    pub format: String,
17    #[serde(default)]
18    pub fade_in: f32,
19    #[serde(default)]
20    pub fade_out: f32,
21    #[serde(default)]
22    pub bitrate: Option<String>,
23    #[serde(default)]
24    pub crf: Option<u32>,
25    #[serde(default)]
26    pub preview: bool,
27}
28
29fn default_fps() -> u32 {
30    30
31}
32fn default_format() -> String {
33    "webm".into()
34}
35
36pub fn render_from_config(config_path: &str) -> Result<(), String> {
37    let config_str = fs::read_to_string(config_path)
38        .map_err(|_| format!("❌ Config file '{}' not found.", config_path))?;
39    let args: RenderConfig = serde_json::from_str(&config_str)
40        .map_err(|e| format!("❌ Failed to parse config: {}", e))?;
41
42    // Check for ffmpeg availability upfront
43    match Command::new("ffmpeg").arg("-version").status() {
44        Ok(s) if s.success() => {}
45        Ok(_) => {
46            return Err("❌ ffmpeg failed to run correctly.".into());
47        }
48        Err(e) => {
49            if e.kind() == std::io::ErrorKind::NotFound {
50                return Err(
51                    "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
52                        .into(),
53                );
54            } else {
55                return Err(format!("❌ Failed to execute ffmpeg: {}", e));
56            }
57        }
58    }
59
60    if !args.input.exists() {
61        return Err(format!(
62            "❌ Input path '{}' does not exist.",
63            args.input.display()
64        ));
65    }
66
67    let input_path = &args.input;
68    let (working_input_path, _temp_guard) = if input_path
69        .extension()
70        .map(|ext| ext == "zip")
71        .unwrap_or(false)
72    {
73        let (path, guard) = unzip_frames(input_path).map_err(|e| e.to_string())?;
74        (path, Some(guard))
75    } else {
76        (input_path.clone(), None)
77    };
78
79    let input_pattern = working_input_path.join("frame_%04d.png");
80    let input_str = input_pattern.to_str().unwrap();
81
82    let frame_count = fs::read_dir(&working_input_path)
83        .map_err(|e| format!("❌ Failed to read directory: {}", e))?
84        .filter_map(|e| e.ok())
85        .filter(|e| match e.path().extension().and_then(|s| s.to_str()) {
86            Some("png") | Some("webp") => true,
87            _ => false,
88        })
89        .count() as u32;
90
91    if frame_count == 0 {
92        return Err(format!(
93            "❌ No PNG files found in '{}'.",
94            working_input_path.display()
95        ));
96    }
97
98    let duration = frame_count as f32 / args.fps as f32;
99
100    let mut fade_filter = String::new();
101    if args.fade_in > 0.0 {
102        fade_filter.push_str(&format!("fade=t=in:st=0:d={}", args.fade_in));
103    }
104    if args.fade_out > 0.0 {
105        if !fade_filter.is_empty() {
106            fade_filter.push(',');
107        }
108        let start = (duration - args.fade_out).max(0.0);
109        fade_filter.push_str(&format!("fade=t=out:st={}:d={}", start, args.fade_out));
110    }
111
112    println!(
113        "🌿 Rendering {} → {} at {} FPS...",
114        input_str, args.output, args.fps
115    );
116
117    let output_format = args.format.as_str();
118
119    if output_format == "gif" {
120        let palette_path = "palette.png";
121
122        let palette_status = match Command::new("ffmpeg")
123            .args([
124                "-i",
125                input_str,
126                "-vf",
127                "fps=30,scale=640:-1:flags=lanczos,palettegen",
128                "-y",
129                palette_path,
130            ])
131            .status()
132        {
133            Ok(s) => s,
134            Err(e) => {
135                if e.kind() == std::io::ErrorKind::NotFound {
136                    eprintln!(
137                        "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
138                    );
139                } else {
140                    eprintln!("❌ Failed to execute ffmpeg: {}", e);
141                }
142                return Ok(());
143            }
144        };
145
146        if !palette_status.success() {
147            eprintln!("❌ Failed to generate palette");
148            return Ok(());
149        }
150
151        let mut gif_filter = String::from("fps=30,scale=640:-1:flags=lanczos");
152        if !fade_filter.is_empty() {
153            gif_filter.push(',');
154            gif_filter.push_str(&fade_filter);
155        }
156        let gif_status = match Command::new("ffmpeg")
157            .args([
158                "-framerate",
159                &args.fps.to_string(),
160                "-i",
161                input_str,
162                "-i",
163                palette_path,
164                "-lavfi",
165                &format!("{} [x]; [x][1:v] paletteuse", gif_filter),
166                "-y",
167                &args.output,
168            ])
169            .status()
170        {
171            Ok(s) => s,
172            Err(e) => {
173                if e.kind() == std::io::ErrorKind::NotFound {
174                    eprintln!(
175                        "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
176                    );
177                } else {
178                    eprintln!("❌ Failed to execute ffmpeg: {}", e);
179                }
180                return Ok(());
181            }
182        };
183
184        if gif_status.success() {
185            println!("✅ GIF exported: {}", &args.output);
186            if args.preview {
187                if let Err(e) = open_output(&args.output) {
188                    eprintln!("⚠️ Failed to open video preview: {}", e);
189                }
190            }
191        } else {
192            eprintln!("❌ Failed to export GIF");
193        }
194
195        fs::remove_file(palette_path)
196            .unwrap_or_else(|e| eprintln!("⚠️ Failed to remove palette file: {}", e));
197
198        return Ok(());
199    }
200
201    let codec = match output_format {
202        "webm" => "libvpx",
203        "mp4" => "libx264",
204        _ => {
205            eprintln!(
206                "❌ Unsupported format: {}. Use 'webm', 'mp4' or 'gif'.",
207                output_format
208            );
209            return Ok(());
210        }
211    };
212
213    let pix_fmt = match output_format {
214        "webm" => "yuva420p",
215        "mp4" => "yuv420p",
216        _ => unreachable!(),
217    };
218
219    let mut ffmpeg_args = vec![
220        "-framerate".to_string(),
221        args.fps.to_string(),
222        "-i".to_string(),
223        input_str.to_string(),
224        "-c:v".to_string(),
225        codec.to_string(),
226        "-pix_fmt".to_string(),
227        pix_fmt.to_string(),
228        "-auto-alt-ref".to_string(),
229        "0".to_string(),
230    ];
231
232    if let Some(ref bitrate) = args.bitrate {
233        ffmpeg_args.push("-b:v".to_string());
234        ffmpeg_args.push(bitrate.clone());
235    }
236
237    if let Some(crf) = args.crf {
238        ffmpeg_args.push("-crf".to_string());
239        ffmpeg_args.push(crf.to_string());
240    }
241
242    if !fade_filter.is_empty() {
243        ffmpeg_args.push("-vf".to_string());
244        ffmpeg_args.push(fade_filter.clone());
245    }
246
247    ffmpeg_args.push("-y".to_string());
248    ffmpeg_args.push(args.output.clone());
249
250    let status = match Command::new("ffmpeg").args(ffmpeg_args).status() {
251        Ok(s) => s,
252        Err(e) => {
253            if e.kind() == std::io::ErrorKind::NotFound {
254                eprintln!(
255                    "❌ ffmpeg not found. Please install ffmpeg and ensure it is in your PATH."
256                );
257            } else {
258                eprintln!("❌ Failed to execute ffmpeg: {}", e);
259            }
260            return Ok(());
261        }
262    };
263
264    if status.success() {
265        println!("✅ Video exported: {}", args.output);
266        if args.preview {
267            if let Err(e) = open_output(&args.output) {
268                eprintln!("⚠️ Failed to open video preview: {}", e);
269            }
270        }
271    } else {
272        eprintln!("❌ ffmpeg failed. Check your frame pattern or input path.");
273    }
274
275    Ok(())
276}
277
278fn open_output(path: &str) -> std::io::Result<()> {
279    #[cfg(target_os = "macos")]
280    {
281        Command::new("open").arg(path).status().map(|_| ())
282    }
283    #[cfg(target_os = "linux")]
284    {
285        Command::new("xdg-open").arg(path).status().map(|_| ())
286    }
287    #[cfg(target_os = "windows")]
288    {
289        Command::new("cmd")
290            .args(["/C", "start", path])
291            .status()
292            .map(|_| ())
293    }
294}