aether_renderer_core/
lib.rs1pub 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
15pub fn render_from_config(config_path: &str) -> Result<RenderReport, String> {
17 let args = RenderConfig::from_file(config_path)?;
18 render(args)
19}
20
21pub 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 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 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 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 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("䷀䷫䷌䷅䷤䷥䷄䷍䷪"),
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 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
227pub 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}