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
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
222fn 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
251pub 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
266pub fn run_ffmpeg_for_output(args: &[String], output: &str) -> Result<String, String> {
268 run_ffmpeg_with_progress(args, output, false, &mut |_| {})
269}
270
271pub 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
285pub 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 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 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
370pub 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 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}