spatial_maker/
image_loader.rs1use crate::error::{SpatialError, SpatialResult};
2use image::DynamicImage;
3use std::path::Path;
4use std::process::Command;
5
6pub async fn load_image(path: impl AsRef<Path>) -> SpatialResult<DynamicImage> {
7 let path = path.as_ref();
8
9 if !path.exists() {
10 return Err(SpatialError::ImageError(format!(
11 "Image file not found: {:?}",
12 path
13 )));
14 }
15
16 let extension = path
17 .extension()
18 .and_then(|ext| ext.to_str())
19 .map(|s| s.to_lowercase())
20 .ok_or_else(|| SpatialError::ImageError(format!("File has no extension: {:?}", path)))?;
21
22 match extension.as_str() {
23 "avif" => load_avif(path).await,
24 "jxl" => load_jxl(path).await,
25 "heic" | "heif" => load_heic(path).await,
26 "jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "tif" | "webp" => load_standard(path),
27 _ => Err(SpatialError::ImageError(format!(
28 "Unsupported image format: .{}",
29 extension
30 ))),
31 }
32}
33
34fn load_standard(path: impl AsRef<Path>) -> SpatialResult<DynamicImage> {
35 let path = path.as_ref();
36 let img = image::open(path)
37 .map_err(|e| SpatialError::ImageError(format!("Failed to load image {:?}: {}", path, e)))?;
38 Ok(img)
39}
40
41async fn load_avif(path: &Path) -> SpatialResult<DynamicImage> {
42 #[cfg(feature = "avif")]
43 {
44 match image::open(path) {
45 Ok(img) => return Ok(img),
46 Err(e) => {
47 tracing::warn!("Native AVIF decoder failed: {}, falling back to ffmpeg", e);
48 }
49 }
50 }
51 load_with_ffmpeg(path, "avif").await
52}
53
54async fn load_jxl(path: &Path) -> SpatialResult<DynamicImage> {
55 #[cfg(feature = "jxl")]
56 {
57 match load_jxl_native(path) {
58 Ok(img) => return Ok(img),
59 Err(e) => {
60 tracing::warn!("Native JXL decoder failed: {}, falling back to ffmpeg", e);
61 }
62 }
63 }
64 load_with_ffmpeg(path, "jxl").await
65}
66
67async fn load_heic(path: &Path) -> SpatialResult<DynamicImage> {
68 #[cfg(feature = "heic")]
69 {
70 match load_heic_native(path) {
71 Ok(img) => return Ok(img),
72 Err(e) => {
73 tracing::warn!("Native HEIC decoder failed: {}, falling back to ffmpeg", e);
74 }
75 }
76 }
77 load_with_ffmpeg(path, "heic").await
78}
79
80#[cfg(feature = "jxl")]
81fn load_jxl_native(path: &Path) -> SpatialResult<DynamicImage> {
82 use jxl_oxide::JxlImage;
83
84 let data = std::fs::read(path)
85 .map_err(|e| SpatialError::IoError(format!("Failed to read JXL file: {}", e)))?;
86
87 let jxl_image = JxlImage::builder()
88 .read(&data[..])
89 .map_err(|e| SpatialError::ImageError(format!("JXL decode failed: {:?}", e)))?;
90
91 let width = jxl_image.width();
92 let height = jxl_image.height();
93
94 let render = jxl_image
95 .render_frame(0)
96 .map_err(|e| SpatialError::ImageError(format!("JXL render failed: {:?}", e)))?;
97
98 let planar = render.image_planar();
99 if planar.is_empty() {
100 return Err(SpatialError::ImageError(
101 "JXL image has no color channels".to_string(),
102 ));
103 }
104
105 let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
106 for y in 0..height {
107 for x in 0..width {
108 let idx = (y * width + x) as usize;
109 let r = (planar[0].buf()[idx] * 255.0).clamp(0.0, 255.0) as u8;
110 let g = if planar.len() > 1 {
111 (planar[1].buf()[idx] * 255.0).clamp(0.0, 255.0) as u8
112 } else {
113 r
114 };
115 let b = if planar.len() > 2 {
116 (planar[2].buf()[idx] * 255.0).clamp(0.0, 255.0) as u8
117 } else {
118 r
119 };
120 rgb_data.push(r);
121 rgb_data.push(g);
122 rgb_data.push(b);
123 }
124 }
125
126 let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
127 SpatialError::ImageError("Failed to create image buffer from JXL data".to_string())
128 })?;
129
130 Ok(DynamicImage::ImageRgb8(img_buffer))
131}
132
133#[cfg(feature = "heic")]
134fn load_heic_native(path: &Path) -> SpatialResult<DynamicImage> {
135 use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
136
137 let lib_heif = LibHeif::new();
138 let ctx = HeifContext::read_from_file(
139 path.to_str()
140 .ok_or_else(|| SpatialError::IoError("Invalid path encoding".to_string()))?,
141 )
142 .map_err(|e| SpatialError::ImageError(format!("Failed to load HEIC file: {:?}", e)))?;
143
144 let handle = ctx.primary_image_handle().map_err(|e| {
145 SpatialError::ImageError(format!("Failed to get HEIC image handle: {:?}", e))
146 })?;
147
148 let width = handle.width();
149 let height = handle.height();
150
151 let image = lib_heif
152 .decode(&handle, ColorSpace::Rgb(RgbChroma::Rgb), None)
153 .map_err(|e| SpatialError::ImageError(format!("HEIC decode failed: {:?}", e)))?;
154
155 let planes = image.planes();
156 let interleaved = planes.interleaved.ok_or_else(|| {
157 SpatialError::ImageError("No interleaved plane in HEIC image".to_string())
158 })?;
159
160 let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
161 for y in 0..height {
162 let row_start = (y * interleaved.stride as u32) as usize;
163 let row_end = row_start + (width * 3) as usize;
164 rgb_data.extend_from_slice(&interleaved.data[row_start..row_end]);
165 }
166
167 let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
168 SpatialError::ImageError("Failed to create image buffer from HEIC data".to_string())
169 })?;
170
171 Ok(DynamicImage::ImageRgb8(img_buffer))
172}
173
174async fn load_with_ffmpeg(path: &Path, format: &str) -> SpatialResult<DynamicImage> {
175 if !is_ffmpeg_available() {
176 return Err(SpatialError::ImageError(format!(
177 "{} format requires ffmpeg for conversion (not installed or not in PATH)",
178 format.to_uppercase()
179 )));
180 }
181
182 let temp_dir = std::env::temp_dir();
183 let temp_filename = format!(
184 "spatial_maker_convert_{}_{}.jpg",
185 format,
186 std::time::SystemTime::now()
187 .duration_since(std::time::UNIX_EPOCH)
188 .unwrap_or_default()
189 .as_millis()
190 );
191 let temp_path = temp_dir.join(temp_filename);
192
193 let input_str = path
194 .to_str()
195 .ok_or_else(|| SpatialError::IoError("Invalid input path".to_string()))?;
196 let output_str = temp_path
197 .to_str()
198 .ok_or_else(|| SpatialError::IoError("Invalid output path".to_string()))?;
199
200 let output = Command::new("ffmpeg")
201 .args(&["-i", input_str, "-c:v", "libjpeg", "-q:v", "2", "-y", output_str])
202 .output()
203 .map_err(|e| SpatialError::IoError(format!("Failed to run ffmpeg: {}", e)))?;
204
205 if !output.status.success() {
206 let stderr = String::from_utf8_lossy(&output.stderr);
207 return Err(SpatialError::ImageError(format!(
208 "ffmpeg conversion failed for {} format:\n{}",
209 format.to_uppercase(),
210 stderr
211 )));
212 }
213
214 let img = image::open(&temp_path).map_err(|e| {
215 let _ = std::fs::remove_file(&temp_path);
216 SpatialError::ImageError(format!("Failed to load converted image: {}", e))
217 })?;
218
219 let _ = std::fs::remove_file(&temp_path);
220
221 Ok(img)
222}
223
224fn is_ffmpeg_available() -> bool {
225 Command::new("ffmpeg")
226 .arg("-version")
227 .output()
228 .map(|output| output.status.success())
229 .unwrap_or(false)
230}