1pub 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 if !config::FORMATS.contains(&args.format.as_str()) {
33 return Err(format!(
34 "❌ Unsupported format '{}'. Use one of: {}.",
35 args.format,
36 config::FORMATS.join(", ")
37 ));
38 }
39 if args.fps == 0 {
40 return Err("❌ fps must be greater than 0.".into());
41 }
42
43 if args.is_preview() {
45 if args.open {
46 eprintln!("⚠️ '--open' is only supported for full render. Ignoring for preview.");
47 }
48 let mut out_path = PathBuf::from(&args.output);
49 if out_path.extension().is_some() {
50 out_path.set_extension("png");
51 } else {
52 out_path = out_path.with_extension("png");
53 }
54 preview_frame(
55 &args.input,
56 args.file_pattern.clone(),
57 args.preview_frame_limit()
58 .filter(|&n| n != config::PREVIEW_MIDDLE),
59 &out_path,
60 args.verbose,
61 )?;
62 return Ok(RenderReport {
63 output_path: PathBuf::from(out_path.to_string_lossy().into_owned()),
64 frames_rendered: Some(1),
65 ffmpeg_warnings: Vec::new(),
66 preview: true,
67 notes: Some("Preview complete.".into()),
68 });
69 }
70
71 if args.verbose_ffmpeg {
73 println!("🔍 Checking for ffmpeg...");
74 }
75 let mut ffmpeg_check = Command::new("ffmpeg");
76 ffmpeg_check.arg("-version");
77 if !args.verbose_ffmpeg {
78 ffmpeg_check.stdout(std::process::Stdio::null());
79 ffmpeg_check.stderr(std::process::Stdio::null());
80 }
81 match ffmpeg_check.status() {
82 Ok(s) if s.success() => {}
83 Ok(_) => {
84 return Err("❌ ffmpeg failed to run correctly.".into());
85 }
86 Err(e) => {
87 if e.kind() == std::io::ErrorKind::NotFound {
88 return Err(
89 "❌ ffmpeg not found. Install it and make sure it is on your PATH:\n \
90 macOS: brew install ffmpeg · Ubuntu/Debian: sudo apt install ffmpeg · \
91 Windows: winget install Gyan.FFmpeg"
92 .into(),
93 );
94 } else {
95 return Err(format!("❌ Failed to execute ffmpeg: {}", e));
96 }
97 }
98 }
99
100 if !args.input.exists() {
101 return Err(format!(
102 "❌ Input path '{}' does not exist.",
103 args.input.display()
104 ));
105 }
106
107 let input_path = &args.input;
108 let (working_input_path, _temp_guard) = if input_path
109 .extension()
110 .map(|ext| ext == "zip")
111 .unwrap_or(false)
112 {
113 let (path, guard) =
114 utils::unzip_frames(input_path, args.verbose).map_err(|e| e.to_string())?;
115 (path, Some(guard))
116 } else {
117 (input_path.clone(), None)
118 };
119
120 let pattern = args
121 .file_pattern
122 .clone()
123 .unwrap_or_else(|| "*.png".to_string());
124 let frames = input::collect_input_frames(&working_input_path, Some(pattern.clone()))
125 .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
126 let frame_count = frames.len() as u32;
127
128 if frame_count == 0 {
129 return Err(format!(
130 "❌ No input files found in '{}' matching pattern '{}'.",
131 working_input_path.display(),
132 pattern
133 ));
134 }
135
136 let sequence = input::FrameSequence::new(&frames)
138 .map_err(|e| format!("❌ Failed to prepare frames: {}", e))?;
139 let input_str = sequence.pattern().to_string_lossy().into_owned();
140
141 let duration = frame_count as f32 / args.fps as f32;
142 let fade_filter = fade_filter(&args, duration);
143
144 if args.verbose {
145 println!(
146 "🌿 Rendering {} frames from {} → {} ({}, {} fps, {:.2} s)...",
147 frame_count,
148 working_input_path.display(),
149 args.output,
150 args.format,
151 args.fps,
152 duration
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("䷀䷫䷌䷅䷤䷥䷄䷍䷪"),
164 );
165 pb.enable_steady_tick(Duration::from_millis(120));
166 Some(pb)
167 } else {
168 None
169 };
170
171 let result = if args.format == "gif" {
172 ffmpeg::gif::render_gif(
173 &input_str,
174 &args.output,
175 args.fps,
176 Some(&fade_filter),
177 args.verbose_ffmpeg,
178 )
179 } else {
180 ffmpeg::video::render_video(
181 &input_str,
182 &args.output,
183 args.fps,
184 &args.format,
185 args.bitrate.as_deref(),
186 args.crf,
187 Some(&fade_filter),
188 args.verbose_ffmpeg,
189 )
190 };
191 if let Some(pb) = &maybe_spinner {
192 pb.finish_and_clear();
193 }
194 let mut render_report = result?;
195
196 render_report.frames_rendered = Some(frame_count as usize);
198
199 if let Some(ext) = Path::new(&args.output).extension().and_then(|s| s.to_str()) {
200 let ext = ext.to_lowercase();
201 let expected_ext = match args.format.as_str() {
202 "webm" => "webm",
203 "mp4" => "mp4",
204 "gif" => "gif",
205 "mov" => "mov",
206 _ => "",
207 };
208
209 if ext != expected_ext {
210 let warning = format!(
211 "⚠️ Warning: Output extension '{}' does not match format '{}'",
212 ext, args.format
213 );
214 render_report.notes =
215 Some(render_report.notes.clone().unwrap_or_default() + &format!("\n{}", warning));
216 }
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
227fn fade_filter(args: &RenderConfig, duration: f32) -> String {
231 let alpha = if args.format == "mp4" { "" } else { ":alpha=1" };
232 let mut fades = Vec::new();
233 if args.fade_in > 0.0 {
234 fades.push(format!("fade=t=in:st=0:d={}{}", args.fade_in, alpha));
235 }
236 if args.fade_out > 0.0 {
237 let start = (duration - args.fade_out).max(0.0);
238 fades.push(format!(
239 "fade=t=out:st={}:d={}{}",
240 start, args.fade_out, alpha
241 ));
242 }
243 fades.join(",")
244}
245
246pub fn preview_frame(
248 input: &std::path::Path,
249 file_pattern: Option<String>,
250 frame_index: Option<usize>,
251 output: &std::path::Path,
252 verbose: bool,
253) -> Result<String, String> {
254 if !input.exists() {
255 return Err(format!(
256 "❌ Input path '{}' does not exist.",
257 input.display()
258 ));
259 }
260
261 let (folder, _temp_guard) = if input.extension().is_some_and(|ext| ext == "zip") {
263 let (path, guard) = utils::unzip_frames(input, verbose).map_err(|e| e.to_string())?;
264 (path, Some(guard))
265 } else {
266 (input.to_path_buf(), None)
267 };
268 let pattern = file_pattern.unwrap_or_else(|| "*.png".to_string());
269 let frames = input::collect_input_frames(&folder, Some(pattern.clone()))
270 .map_err(|e| format!("❌ Failed to read frames: {}", e))?;
271 if frames.is_empty() {
272 return Err(format!(
273 "❌ No input files found in '{}' matching pattern '{}'",
274 input.display(),
275 pattern
276 ));
277 }
278 let idx = frame_index.unwrap_or(frames.len() / 2);
279 if idx >= frames.len() {
280 return Err(format!(
281 "❌ Frame index {} out of range (0..{})",
282 idx,
283 frames.len() - 1
284 ));
285 }
286 std::fs::copy(&frames[idx], output).map_err(|e| format!("❌ Failed to copy frame: {}", e))?;
287
288 if verbose {
289 println!("🖼️ Preview saved to: {}", output.display());
290 }
291 Ok(output.to_string_lossy().into_owned())
292}