aether_renderer_core/
utils.rs

1use std::fs::{self, File};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use tempfile::tempdir;
5use zip::ZipArchive;
6
7/// Extracts `frame_*.png` from a ZIP into a temporary folder and returns the
8/// folder path along with the temp directory guard.
9pub fn unzip_frames(
10    zip_path: &Path,
11) -> Result<(PathBuf, tempfile::TempDir), Box<dyn std::error::Error>> {
12    let file = File::open(zip_path)
13        .map_err(|e| format!("❌ Failed to open zip file '{}': {}", zip_path.display(), e))?;
14
15    let mut archive =
16        ZipArchive::new(file).map_err(|e| format!("❌ Failed to read zip archive: {}", e))?;
17
18    let temp_dir = tempdir().map_err(|e| format!("❌ Failed to create temp dir: {}", e))?;
19    let temp_path = temp_dir.path().to_path_buf();
20
21    let mut extracted = 0u32;
22    for i in 0..archive.len() {
23        let mut file = archive
24            .by_index(i)
25            .map_err(|e| format!("❌ Failed to access file in zip at index {}: {}", i, e))?;
26
27        let filename = file.name().rsplit('/').next().unwrap_or("");
28        if !filename.ends_with(".png") {
29            continue;
30        }
31
32        let full_out_path = temp_path.join(filename);
33        let mut out_file = File::create(&full_out_path).map_err(|e| {
34            format!(
35                "❌ Failed to create output file '{}': {}",
36                full_out_path.display(),
37                e
38            )
39        })?;
40
41        std::io::copy(&mut file, &mut out_file).map_err(|e| {
42            format!(
43                "❌ Failed to copy content to '{}': {}",
44                full_out_path.display(),
45                e
46            )
47        })?;
48
49        println!("✅ Extracting: {}", full_out_path.display());
50        extracted += 1;
51    }
52
53    if extracted == 0 {
54        return Err("❌ No PNG files found in zip archive".into());
55    }
56
57    println!("🗂️  Extracted frames to: {}", temp_path.display());
58    Ok((temp_path.clone(), temp_dir))
59}
60
61/// Open the rendered output in the default system viewer
62pub fn open_output(path: &str) -> std::io::Result<()> {
63    #[cfg(target_os = "macos")]
64    {
65        Command::new("open").arg(path).status().map(|_| ())
66    }
67    #[cfg(target_os = "linux")]
68    {
69        Command::new("xdg-open").arg(path).status().map(|_| ())
70    }
71    #[cfg(target_os = "windows")]
72    {
73        Command::new("cmd")
74            .args(["/C", "start", path])
75            .status()
76            .map(|_| ())
77    }
78}
79
80/// Resolve final output path based on `--app-output` CLI option.
81/// Returns the path to the output file and ensures the directory exists.
82pub fn apply_app_output(output: &Path, app_output: Option<PathBuf>) -> std::io::Result<PathBuf> {
83    if let Some(dir) = app_output {
84        fs::create_dir_all(&dir)?;
85        let file_name = output.file_name().unwrap_or_else(|| output.as_os_str());
86        Ok(dir.join(file_name))
87    } else {
88        Ok(output.to_path_buf())
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::{apply_app_output, unzip_frames};
95    use std::fs::File;
96    use std::io::Write;
97    use std::path::{Path, PathBuf};
98    use tempfile::tempdir;
99    use zip::write::{FileOptions, ZipWriter};
100    use zip::CompressionMethod;
101
102    // Helper to create a small zip containing two fake PNG files
103    fn create_test_zip(path: &Path) -> zip::result::ZipResult<()> {
104        let file = File::create(path)?;
105        let mut zip = ZipWriter::new(file);
106        let options = FileOptions::default().compression_method(CompressionMethod::Stored);
107
108        zip.start_file("frame_0000.png", options)?;
109        zip.write_all(b"png0")?;
110        zip.start_file("frame_0001.png", options)?;
111        zip.write_all(b"png1")?;
112        zip.finish()?;
113        Ok(())
114    }
115
116    #[test]
117    fn unzip_frames_extracts_pngs() -> Result<(), Box<dyn std::error::Error>> {
118        let dir = tempdir()?;
119        let zip_path = dir.path().join("frames.zip");
120        create_test_zip(&zip_path)?;
121
122        let (out_dir, _guard) = unzip_frames(&zip_path)?;
123
124        let count = std::fs::read_dir(&out_dir)?.count();
125        assert_eq!(count, 2);
126        assert!(out_dir.join("frame_0000.png").exists());
127        assert!(out_dir.join("frame_0001.png").exists());
128
129        Ok(())
130    }
131
132    #[test]
133    fn output_without_app_output() -> Result<(), Box<dyn std::error::Error>> {
134        let out = PathBuf::from("video.webm");
135        let result = apply_app_output(&out, None)?;
136        assert_eq!(result, PathBuf::from("video.webm"));
137        Ok(())
138    }
139
140    #[test]
141    fn output_with_relative_app_output() -> Result<(), Box<dyn std::error::Error>> {
142        let out = PathBuf::from("my.mp4");
143        let base_rel = PathBuf::from("test_previews_rel");
144        if base_rel.exists() {
145            std::fs::remove_dir_all(&base_rel)?;
146        }
147        let result = apply_app_output(&out, Some(base_rel.clone()))?;
148        assert_eq!(result, base_rel.join("my.mp4"));
149        assert!(base_rel.exists());
150        std::fs::remove_dir_all(&base_rel)?;
151        Ok(())
152    }
153
154    #[test]
155    fn output_with_absolute_app_output() -> Result<(), Box<dyn std::error::Error>> {
156        let out = PathBuf::from("my.gif");
157        let tmp = tempdir()?;
158        let abs = tmp.path().join("abs_previews");
159        let result = apply_app_output(&out, Some(abs.clone()))?;
160        assert_eq!(result, abs.join("my.gif"));
161        assert!(abs.exists());
162        Ok(())
163    }
164}