Skip to main content

aether_renderer_core/
utils.rs

1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus};
4use tempfile::tempdir;
5use zip::ZipArchive;
6
7fn is_valid_image(file_name: &str) -> bool {
8    let name = file_name.to_lowercase();
9    name.ends_with(".png") && !name.starts_with("._")
10}
11
12/// Extracts `frame_*.png` from a ZIP into a temporary folder and returns the
13/// folder path along with the temp directory guard.
14pub fn unzip_frames(
15    zip_path: &Path,
16    verbose: bool,
17) -> Result<(PathBuf, tempfile::TempDir), Box<dyn std::error::Error>> {
18    let file = File::open(zip_path)
19        .map_err(|e| format!("❌ Failed to open zip file '{}': {}", zip_path.display(), e))?;
20
21    let mut archive =
22        ZipArchive::new(file).map_err(|e| format!("❌ Failed to read zip archive: {}", e))?;
23
24    let temp_dir = tempdir().map_err(|e| format!("❌ Failed to create temp dir: {}", e))?;
25    let temp_path = temp_dir.path().to_path_buf();
26
27    let mut extracted = 0u32;
28    for i in 0..archive.len() {
29        let mut file = archive
30            .by_index(i)
31            .map_err(|e| format!("❌ Failed to access file in zip at index {}: {}", i, e))?;
32
33        let filename = file.name().rsplit(['/', '\\']).next().unwrap_or("");
34        if !is_valid_image(filename) {
35            continue;
36        }
37
38        let full_out_path = temp_path.join(filename);
39        let mut out_file = File::create(&full_out_path).map_err(|e| {
40            format!(
41                "❌ Failed to create output file '{}': {}",
42                full_out_path.display(),
43                e
44            )
45        })?;
46
47        std::io::copy(&mut file, &mut out_file).map_err(|e| {
48            format!(
49                "❌ Failed to copy content to '{}': {}",
50                full_out_path.display(),
51                e
52            )
53        })?;
54
55        if verbose {
56            println!("✅ Extracting: {}", full_out_path.display());
57        }
58        extracted += 1;
59    }
60
61    if extracted == 0 {
62        return Err("❌ No PNG files found in zip archive".into());
63    }
64
65    if verbose {
66        if extracted > 1 {
67            println!("⚠️  Extracted {} frames from zip", extracted);
68        } else {
69            println!("✅ Extracted 1 frame from zip");
70        }
71        println!("🗂️  Extracted frames to: {}", temp_path.display());
72    }
73    Ok((temp_path.clone(), temp_dir))
74}
75
76/// Count PNG files inside a ZIP archive
77pub fn count_pngs_in_zip(zip_path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
78    let file = File::open(zip_path)
79        .map_err(|e| format!("❌ Failed to open zip file '{}': {}", zip_path.display(), e))?;
80    let mut archive =
81        ZipArchive::new(file).map_err(|e| format!("❌ Failed to read zip archive: {}", e))?;
82    let mut count = 0usize;
83    for i in 0..archive.len() {
84        let file = archive.by_index(i)?;
85        let filename = file.name().rsplit(['/', '\\']).next().unwrap_or("");
86        if is_valid_image(filename) {
87            count += 1;
88        }
89    }
90    Ok(count)
91}
92
93/// Extract a specific PNG frame from a ZIP archive
94pub fn extract_frame_from_zip(
95    zip_path: &Path,
96    frame_index: usize,
97    output: &Path,
98) -> Result<(), Box<dyn std::error::Error>> {
99    let file = File::open(zip_path)
100        .map_err(|e| format!("❌ Failed to open zip file '{}': {}", zip_path.display(), e))?;
101    let mut archive =
102        ZipArchive::new(file).map_err(|e| format!("❌ Failed to read zip archive: {}", e))?;
103    let mut png_indices = Vec::new();
104    for i in 0..archive.len() {
105        let f = archive.by_index(i)?;
106        let name = f.name().rsplit(['/', '\\']).next().unwrap_or("");
107        if is_valid_image(name) {
108            png_indices.push(i);
109        }
110    }
111    if png_indices.is_empty() {
112        return Err("❌ No PNG files found in zip archive".into());
113    }
114    if frame_index >= png_indices.len() {
115        return Err(format!(
116            "❌ Frame index {} out of range (0..{})",
117            frame_index,
118            png_indices.len() - 1
119        )
120        .into());
121    }
122    let mut file = archive.by_index(png_indices[frame_index])?;
123    let mut out = File::create(output).map_err(|e| {
124        format!(
125            "❌ Failed to create output file '{}': {}",
126            output.display(),
127            e
128        )
129    })?;
130    std::io::copy(&mut file, &mut out)
131        .map_err(|e| format!("❌ Failed to copy content to '{}': {}", output.display(), e))?;
132    Ok(())
133}
134
135/// Open the rendered output in the default system viewer
136pub fn open_output(path: &str) -> std::io::Result<()> {
137    #[cfg(target_os = "macos")]
138    {
139        Command::new("open").arg(path).status().map(|_| ())
140    }
141    #[cfg(target_os = "linux")]
142    {
143        Command::new("xdg-open").arg(path).status().map(|_| ())
144    }
145    #[cfg(target_os = "windows")]
146    {
147        Command::new("cmd")
148            .args(["/C", "start", path])
149            .status()
150            .map(|_| ())
151    }
152}
153
154pub fn scan_ffmpeg_stderr(stderr: &str) -> Vec<String> {
155    let mut warnings = Vec::new();
156    let stderr_lc = stderr.to_lowercase(); // 👈 normalize
157
158    if stderr_lc.contains("drop") {
159        warnings.push("⚠️ FFmpeg reported frame drops.".to_string());
160    }
161
162    if stderr_lc.contains("missing") {
163        warnings.push("⚠️ Possible missing or unreadable frame(s).".to_string());
164    }
165
166    if stderr_lc.contains("buffer") || stderr_lc.contains("underrun") {
167        warnings.push("⚠️ Buffer underrun or encoding delay detected.".to_string());
168    }
169
170    if stderr_lc.contains("deprecated") {
171        warnings.push("⚠️ Deprecated options used in FFmpeg command.".to_string());
172    }
173
174    if stderr_lc.contains("high frame rate") {
175        warnings.push("⚠️ High frame rate detected, may cause performance issues.".to_string());
176    }
177
178    if stderr_lc.contains("invalid frame") {
179        warnings.push("⚠️ Invalid frame detected in input.".to_string());
180    }
181
182    if stderr_lc.contains("no such file or directory") {
183        warnings.push("⚠️ Input file not found or inaccessible.".to_string());
184    }
185
186    if stderr_lc.contains("unrecognized option") {
187        warnings.push("⚠️ Unrecognized FFmpeg option used.".to_string());
188    }
189
190    if stderr_lc.contains("error") {
191        warnings.push("❌ FFmpeg encountered an error.".to_string());
192    }
193
194    if stderr_lc.contains("warning") {
195        warnings.push("⚠️ FFmpeg issued a warning.".to_string());
196    }
197
198    if stderr_lc.contains("frame rate very high") {
199        warnings
200            .push("⚠️ Frame rate very high for a muxer not efficiently supporting it.".to_string());
201    }
202
203    if stderr.contains("duration") && stderr.contains("Past") {
204        warnings.push("⚠️ Past frame duration too large.".to_string());
205    }
206
207    warnings
208}
209
210/// Arguments every ffmpeg call starts with: no banner, and only warnings unless
211/// `verbose_ffmpeg` is set.
212pub fn ffmpeg_base_args(verbose_ffmpeg: bool) -> Vec<String> {
213    let level = if verbose_ffmpeg { "info" } else { "warning" };
214    vec![
215        "-hide_banner".into(),
216        "-nostdin".into(),
217        "-loglevel".into(),
218        level.into(),
219    ]
220}
221
222/// The error for a failed ffmpeg run, ending with the last lines ffmpeg printed,
223/// which usually say what went wrong.
224fn ffmpeg_failure(status: ExitStatus, stderr: &str) -> String {
225    let mut msg = format!("❌ ffmpeg exited with code {}", status.code().unwrap_or(-1));
226    let tail: Vec<&str> = stderr
227        .lines()
228        .map(str::trim)
229        .filter(|l| !l.is_empty())
230        .collect();
231    if !tail.is_empty() {
232        msg.push_str(":\n");
233        for line in &tail[tail.len().saturating_sub(8)..] {
234            msg.push_str("   ");
235            msg.push_str(line);
236            msg.push('\n');
237        }
238        msg.push_str("   (run with --verbose-ffmpeg for the full log)");
239    }
240    msg
241}
242
243fn spawn_error(e: std::io::Error) -> String {
244    if e.kind() == std::io::ErrorKind::NotFound {
245        "❌ ffmpeg not found in PATH.".to_string()
246    } else {
247        format!("❌ ffmpeg failed to run: {}", e)
248    }
249}
250
251/// Run ffmpeg and return its stderr. On failure the error includes the last lines
252/// ffmpeg printed, which usually say what went wrong.
253pub fn run_ffmpeg_with_output(args: &[String]) -> Result<(ExitStatus, String), String> {
254    let output = Command::new("ffmpeg")
255        .args(args)
256        .output()
257        .map_err(spawn_error)?;
258
259    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
260    if !output.status.success() {
261        return Err(ffmpeg_failure(output.status, &stderr));
262    }
263    Ok((output.status, stderr))
264}
265
266/// Like [`run_ffmpeg_with_output`], but removes a half-written `output` file if ffmpeg fails.
267pub fn run_ffmpeg_for_output(args: &[String], output: &str) -> Result<String, String> {
268    run_ffmpeg_with_progress(args, output, false, &mut |_| {})
269}
270
271/// Run ffmpeg, calling `on_frame` with the number of frames written so far
272/// (from ffmpeg's `-progress` output, about twice a second, and once at the end).
273///
274/// Returns ffmpeg's stderr. With `echo_stderr`, ffmpeg's log is also printed as it
275/// arrives. On failure a half-written `output` file is removed.
276pub fn run_ffmpeg_with_progress(
277    args: &[String],
278    output: &str,
279    echo_stderr: bool,
280    on_frame: &mut dyn FnMut(u32),
281) -> Result<String, String> {
282    run_ffmpeg_with_input(args, output, echo_stderr, on_frame, None)
283}
284
285/// Like [`run_ffmpeg_with_progress`], but feeds `input` to ffmpeg's stdin (for an
286/// `-i pipe:0` input, e.g. a PNG stream). The copy runs on its own thread, so a
287/// slow producer and ffmpeg can't block each other. If reading `input` fails,
288/// the render fails too and the half-written `output` is removed.
289pub fn run_ffmpeg_with_input(
290    args: &[String],
291    output: &str,
292    echo_stderr: bool,
293    on_frame: &mut dyn FnMut(u32),
294    input: Option<Box<dyn std::io::Read + Send>>,
295) -> Result<String, String> {
296    use std::io::{BufRead, BufReader, Read};
297    use std::process::Stdio;
298
299    let mut child = Command::new("ffmpeg")
300        .args(["-progress", "pipe:1", "-nostats"])
301        .args(args)
302        .stdin(if input.is_some() {
303            Stdio::piped()
304        } else {
305            Stdio::null()
306        })
307        .stdout(Stdio::piped())
308        .stderr(Stdio::piped())
309        .spawn()
310        .map_err(spawn_error)?;
311
312    // Copy the input into ffmpeg; dropping its stdin afterwards signals the end.
313    // A broken pipe means ffmpeg stopped reading (it failed or has enough); its
314    // own exit status reports that, so only real read errors are kept.
315    let writer_thread = input.map(|mut input| {
316        let mut stdin = child.stdin.take().expect("stdin is piped");
317        std::thread::spawn(move || match std::io::copy(&mut input, &mut stdin) {
318            Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => Err(e),
319            _ => Ok(()),
320        })
321    });
322
323    // Read stderr on its own thread so neither pipe can fill up and block ffmpeg.
324    let mut stderr_pipe = child.stderr.take().expect("stderr is piped");
325    let stderr_thread = std::thread::spawn(move || {
326        let mut text = String::new();
327        if echo_stderr {
328            for line in BufReader::new(stderr_pipe).lines().map_while(Result::ok) {
329                eprintln!("{line}");
330                text.push_str(&line);
331                text.push('\n');
332            }
333        } else {
334            let mut bytes = Vec::new();
335            let _ = stderr_pipe.read_to_end(&mut bytes);
336            text = String::from_utf8_lossy(&bytes).into_owned();
337        }
338        text
339    });
340
341    let stdout = child.stdout.take().expect("stdout is piped");
342    for line in BufReader::new(stdout).lines().map_while(Result::ok) {
343        if let Some(n) = line.trim().strip_prefix("frame=") {
344            if let Ok(n) = n.trim().parse::<u32>() {
345                on_frame(n);
346            }
347        }
348    }
349
350    let status = child.wait().map_err(spawn_error)?;
351    let stderr = stderr_thread.join().unwrap_or_default();
352    let input_result = writer_thread.map(|t| t.join());
353    if !status.success() {
354        let _ = std::fs::remove_file(output);
355        return Err(ffmpeg_failure(status, &stderr));
356    }
357    match input_result {
358        Some(Ok(Err(e))) => {
359            let _ = std::fs::remove_file(output);
360            Err(format!("❌ Failed to read the input stream: {e}"))
361        }
362        Some(Err(_)) => {
363            let _ = std::fs::remove_file(output);
364            Err("❌ The input stream reader crashed.".into())
365        }
366        _ => Ok(stderr),
367    }
368}
369
370/// The ffmpeg filters that paint `color` behind the (transparent) frames, so the
371/// result is fully opaque. Labels are local to this snippet, so it can be placed
372/// anywhere in a `-vf` chain or `-filter_complex` graph.
373pub fn background_filter(color: &str) -> String {
374    format!(
375        "format=rgba,split[ar_fg][ar_canvas];\
376         [ar_canvas]drawbox=x=0:y=0:w=iw:h=ih:color={color}:t=fill:replace=1[ar_bg];\
377         [ar_bg][ar_fg]overlay=format=auto"
378    )
379}
380
381#[cfg(test)]
382mod tests {
383    use super::unzip_frames;
384    use std::fs::File;
385    use std::io::Write;
386    use std::path::Path;
387    use tempfile::tempdir;
388    use zip::write::{SimpleFileOptions, ZipWriter};
389    use zip::CompressionMethod;
390
391    // Helper to create a small zip containing two fake PNG files
392    fn create_test_zip(path: &Path) -> zip::result::ZipResult<()> {
393        let file = File::create(path)?;
394        let mut zip = ZipWriter::new(file);
395        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
396
397        zip.start_file("frame_0000.png", options)?;
398        zip.write_all(b"png0")?;
399        zip.start_file("frame_0001.png", options)?;
400        zip.write_all(b"png1")?;
401        zip.finish()?;
402        Ok(())
403    }
404
405    #[test]
406    fn unzip_frames_extracts_pngs() -> Result<(), Box<dyn std::error::Error>> {
407        let dir = tempdir()?;
408        let zip_path = dir.path().join("frames.zip");
409        create_test_zip(&zip_path)?;
410
411        let (out_dir, _guard) = unzip_frames(&zip_path, false)?;
412
413        let count = std::fs::read_dir(&out_dir)?.count();
414        assert_eq!(count, 2);
415        assert!(out_dir.join("frame_0000.png").exists());
416        assert!(out_dir.join("frame_0001.png").exists());
417
418        Ok(())
419    }
420}