Skip to main content

spatial_maker/
image_loader.rs

1use 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}