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 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
222pub fn run_ffmpeg_with_output(args: &[String]) -> Result<(ExitStatus, String), String> {
225 let output = Command::new("ffmpeg").args(args).output().map_err(|e| {
226 if e.kind() == std::io::ErrorKind::NotFound {
227 "❌ ffmpeg not found in PATH.".to_string()
228 } else {
229 format!("❌ ffmpeg failed to run: {}", e)
230 }
231 })?;
232
233 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
234 if !output.status.success() {
235 let mut msg = format!(
236 "❌ ffmpeg exited with code {}",
237 output.status.code().unwrap_or(-1)
238 );
239 let tail: Vec<&str> = stderr
240 .lines()
241 .map(str::trim)
242 .filter(|l| !l.is_empty())
243 .collect();
244 if !tail.is_empty() {
245 msg.push_str(":\n");
246 for line in &tail[tail.len().saturating_sub(8)..] {
247 msg.push_str(" ");
248 msg.push_str(line);
249 msg.push('\n');
250 }
251 msg.push_str(" (run with --verbose-ffmpeg for the full log)");
252 }
253 return Err(msg);
254 }
255 Ok((output.status, stderr))
256}
257
258pub fn run_ffmpeg_for_output(args: &[String], output: &str) -> Result<String, String> {
260 match run_ffmpeg_with_output(args) {
261 Ok((_, stderr)) => Ok(stderr),
262 Err(e) => {
263 let _ = std::fs::remove_file(output);
264 Err(e)
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::unzip_frames;
272 use std::fs::File;
273 use std::io::Write;
274 use std::path::Path;
275 use tempfile::tempdir;
276 use zip::write::{SimpleFileOptions, ZipWriter};
277 use zip::CompressionMethod;
278
279 fn create_test_zip(path: &Path) -> zip::result::ZipResult<()> {
281 let file = File::create(path)?;
282 let mut zip = ZipWriter::new(file);
283 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
284
285 zip.start_file("frame_0000.png", options)?;
286 zip.write_all(b"png0")?;
287 zip.start_file("frame_0001.png", options)?;
288 zip.write_all(b"png1")?;
289 zip.finish()?;
290 Ok(())
291 }
292
293 #[test]
294 fn unzip_frames_extracts_pngs() -> Result<(), Box<dyn std::error::Error>> {
295 let dir = tempdir()?;
296 let zip_path = dir.path().join("frames.zip");
297 create_test_zip(&zip_path)?;
298
299 let (out_dir, _guard) = unzip_frames(&zip_path, false)?;
300
301 let count = std::fs::read_dir(&out_dir)?.count();
302 assert_eq!(count, 2);
303 assert!(out_dir.join("frame_0000.png").exists());
304 assert!(out_dir.join("frame_0001.png").exists());
305
306 Ok(())
307 }
308}