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
210pub fn run_ffmpeg_with_output(args: &[String]) -> Result<(ExitStatus, String), String> {
211    let output = Command::new("ffmpeg").args(args).output().map_err(|e| {
212        if e.kind() == std::io::ErrorKind::NotFound {
213            "❌ ffmpeg not found in PATH.".to_string()
214        } else {
215            format!("❌ ffmpeg failed to run: {}", e)
216        }
217    })?;
218
219    if !output.status.success() {
220        return Err(format!(
221            "❌ ffmpeg exited with code {}",
222            output.status.code().unwrap_or(-1)
223        ));
224    }
225
226    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
227    Ok((output.status, stderr))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::unzip_frames;
233    use std::fs::File;
234    use std::io::Write;
235    use std::path::Path;
236    use tempfile::tempdir;
237    use zip::write::{FileOptions, ZipWriter};
238    use zip::CompressionMethod;
239
240    // Helper to create a small zip containing two fake PNG files
241    fn create_test_zip(path: &Path) -> zip::result::ZipResult<()> {
242        let file = File::create(path)?;
243        let mut zip = ZipWriter::new(file);
244        let options = FileOptions::default().compression_method(CompressionMethod::Stored);
245
246        zip.start_file("frame_0000.png", options)?;
247        zip.write_all(b"png0")?;
248        zip.start_file("frame_0001.png", options)?;
249        zip.write_all(b"png1")?;
250        zip.finish()?;
251        Ok(())
252    }
253
254    #[test]
255    fn unzip_frames_extracts_pngs() -> Result<(), Box<dyn std::error::Error>> {
256        let dir = tempdir()?;
257        let zip_path = dir.path().join("frames.zip");
258        create_test_zip(&zip_path)?;
259
260        let (out_dir, _guard) = unzip_frames(&zip_path, false)?;
261
262        let count = std::fs::read_dir(&out_dir)?.count();
263        assert_eq!(count, 2);
264        assert!(out_dir.join("frame_0000.png").exists());
265        assert!(out_dir.join("frame_0001.png").exists());
266
267        Ok(())
268    }
269}