aether_renderer_core/
lib.rs1pub 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 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}