aether_renderer_core/
utils.rs1use 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
12pub 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
76pub 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
93pub 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
135pub 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(); 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 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}