1use super::backend::{BackendError, Dimensions, ImageBackend, ImageMetadata};
18use super::params::{ResizeParams, ThumbnailParams};
19use image::imageops::FilterType;
20use image::{DynamicImage, ImageFormat, ImageReader};
21use std::path::Path;
22use std::sync::LazyLock;
23
24const PHOTO_CANDIDATES: &[(&str, ImageFormat)] = &[
31 ("jpg", ImageFormat::Jpeg),
32 ("jpeg", ImageFormat::Jpeg),
33 ("png", ImageFormat::Png),
34 ("tif", ImageFormat::Tiff),
35 ("tiff", ImageFormat::Tiff),
36 ("webp", ImageFormat::WebP),
37];
38
39static SUPPORTED_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
40 let mut exts: Vec<&'static str> = PHOTO_CANDIDATES
41 .iter()
42 .filter(|(_, fmt)| fmt.reading_enabled())
43 .map(|(ext, _)| *ext)
44 .collect();
45 exts.push("avif");
47 exts
48});
49
50pub fn supported_input_extensions() -> &'static [&'static str] {
52 &SUPPORTED_EXTENSIONS
53}
54
55pub struct RustBackend;
59
60impl RustBackend {
61 pub fn new() -> Self {
62 Self
63 }
64}
65
66impl Default for RustBackend {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72fn is_avif(path: &Path) -> bool {
73 path.extension()
74 .and_then(|e| e.to_str())
75 .is_some_and(|e| e.eq_ignore_ascii_case("avif"))
76}
77
78fn load_image(path: &Path) -> Result<DynamicImage, BackendError> {
80 if is_avif(path) {
81 return decode_avif(path);
82 }
83 ImageReader::open(path)
84 .map_err(BackendError::Io)?
85 .decode()
86 .map_err(|e| {
87 BackendError::ProcessingFailed(format!("Failed to decode {}: {}", path.display(), e))
88 })
89}
90
91fn identify_avif(path: &Path) -> Result<Dimensions, BackendError> {
93 let file_data = std::fs::read(path).map_err(BackendError::Io)?;
94 let avif = avif_parse::read_avif(&mut std::io::Cursor::new(&file_data)).map_err(|e| {
95 BackendError::ProcessingFailed(format!("Failed to parse AVIF {}: {e:?}", path.display()))
96 })?;
97 let meta = avif.primary_item_metadata().map_err(|e| {
98 BackendError::ProcessingFailed(format!(
99 "Failed to read AVIF metadata {}: {e:?}",
100 path.display()
101 ))
102 })?;
103 Ok(Dimensions {
104 width: meta.max_frame_width.get(),
105 height: meta.max_frame_height.get(),
106 })
107}
108
109fn decode_avif(path: &Path) -> Result<DynamicImage, BackendError> {
115 use rav1d::include::dav1d::data::Dav1dData;
116 use rav1d::include::dav1d::dav1d::Dav1dSettings;
117 use rav1d::include::dav1d::headers::{
118 DAV1D_PIXEL_LAYOUT_I400, DAV1D_PIXEL_LAYOUT_I420, DAV1D_PIXEL_LAYOUT_I422,
119 DAV1D_PIXEL_LAYOUT_I444,
120 };
121 use rav1d::include::dav1d::picture::Dav1dPicture;
122 use std::ptr::NonNull;
123
124 let file_data = std::fs::read(path).map_err(BackendError::Io)?;
125 let avif = avif_parse::read_avif(&mut std::io::Cursor::new(&file_data)).map_err(|e| {
126 BackendError::ProcessingFailed(format!("Failed to parse AVIF {}: {e:?}", path.display()))
127 })?;
128 let av1_bytes: &[u8] = &avif.primary_item;
129
130 let mut settings = std::mem::MaybeUninit::<Dav1dSettings>::uninit();
132 unsafe {
133 rav1d::src::lib::dav1d_default_settings(NonNull::new(settings.as_mut_ptr()).unwrap())
134 };
135 let mut settings = unsafe { settings.assume_init() };
136 settings.n_threads = 1;
137 settings.max_frame_delay = 1;
138
139 let mut ctx = None;
140 let rc =
141 unsafe { rav1d::src::lib::dav1d_open(NonNull::new(&mut ctx), NonNull::new(&mut settings)) };
142 if rc.0 != 0 {
143 return Err(BackendError::ProcessingFailed(format!(
144 "rav1d open failed ({})",
145 rc.0
146 )));
147 }
148
149 let mut data = Dav1dData::default();
151 let buf_ptr =
152 unsafe { rav1d::src::lib::dav1d_data_create(NonNull::new(&mut data), av1_bytes.len()) };
153 if buf_ptr.is_null() {
154 unsafe { rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx)) };
155 return Err(BackendError::ProcessingFailed(
156 "rav1d data_create failed".into(),
157 ));
158 }
159 unsafe { std::ptr::copy_nonoverlapping(av1_bytes.as_ptr(), buf_ptr, av1_bytes.len()) };
160
161 let rc = unsafe { rav1d::src::lib::dav1d_send_data(ctx, NonNull::new(&mut data)) };
163 if rc.0 != 0 {
164 unsafe {
165 rav1d::src::lib::dav1d_data_unref(NonNull::new(&mut data));
166 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
167 }
168 return Err(BackendError::ProcessingFailed(format!(
169 "rav1d send_data failed ({})",
170 rc.0
171 )));
172 }
173
174 let mut pic: Dav1dPicture = unsafe { std::mem::zeroed() };
176 let rc = unsafe { rav1d::src::lib::dav1d_get_picture(ctx, NonNull::new(&mut pic)) };
177 if rc.0 != 0 {
178 unsafe { rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx)) };
179 return Err(BackendError::ProcessingFailed(format!(
180 "rav1d get_picture failed ({})",
181 rc.0
182 )));
183 }
184
185 let w = pic.p.w as u32;
187 let h = pic.p.h as u32;
188 let bpc = pic.p.bpc as u32;
189 let layout = pic.p.layout;
190 let y_stride = pic.stride[0];
191 let uv_stride = pic.stride[1];
192 let y_ptr = pic.data[0].unwrap().as_ptr() as *const u8;
193
194 let rgb = if layout == DAV1D_PIXEL_LAYOUT_I400 {
196 YuvPlanes {
197 y_ptr,
198 u_ptr: y_ptr,
199 v_ptr: y_ptr,
200 y_stride,
201 uv_stride: 0,
202 width: w,
203 height: h,
204 bpc,
205 ss_x: false,
206 ss_y: false,
207 monochrome: true,
208 }
209 .to_rgb()
210 } else {
211 let u_ptr = pic.data[1].unwrap().as_ptr() as *const u8;
212 let v_ptr = pic.data[2].unwrap().as_ptr() as *const u8;
213 let (ss_x, ss_y) = match layout {
214 DAV1D_PIXEL_LAYOUT_I420 => (true, true),
215 DAV1D_PIXEL_LAYOUT_I422 => (true, false),
216 DAV1D_PIXEL_LAYOUT_I444 => (false, false),
217 _ => {
218 unsafe {
219 rav1d::src::lib::dav1d_picture_unref(NonNull::new(&mut pic));
220 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
221 }
222 return Err(BackendError::ProcessingFailed(format!(
223 "Unsupported AVIF pixel layout: {layout}"
224 )));
225 }
226 };
227 YuvPlanes {
228 y_ptr,
229 u_ptr,
230 v_ptr,
231 y_stride,
232 uv_stride,
233 width: w,
234 height: h,
235 bpc,
236 ss_x,
237 ss_y,
238 monochrome: false,
239 }
240 .to_rgb()
241 };
242
243 unsafe {
244 rav1d::src::lib::dav1d_picture_unref(NonNull::new(&mut pic));
245 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
246 }
247
248 image::RgbImage::from_raw(w, h, rgb)
249 .map(DynamicImage::ImageRgb8)
250 .ok_or_else(|| {
251 BackendError::ProcessingFailed("Failed to create image from decoded AVIF data".into())
252 })
253}
254
255struct YuvPlanes {
257 y_ptr: *const u8,
258 u_ptr: *const u8,
259 v_ptr: *const u8,
260 y_stride: isize,
261 uv_stride: isize,
262 width: u32,
263 height: u32,
264 bpc: u32,
265 ss_x: bool,
267 ss_y: bool,
268 monochrome: bool,
269}
270
271impl YuvPlanes {
272 fn to_rgb(&self) -> Vec<u8> {
274 let max_val = ((1u32 << self.bpc) - 1) as f32;
275 let center = (1u32 << (self.bpc - 1)) as f32;
276 let scale = 255.0 / max_val;
277
278 let mut rgb = vec![0u8; (self.width * self.height * 3) as usize];
279
280 for row in 0..self.height {
281 for col in 0..self.width {
282 let y_val = read_pixel(self.y_ptr, self.y_stride, col, row, self.bpc);
283
284 let (r, g, b) = if self.monochrome {
285 let v = (y_val * scale).clamp(0.0, 255.0);
286 (v, v, v)
287 } else {
288 let u_col = if self.ss_x { col / 2 } else { col };
289 let u_row = if self.ss_y { row / 2 } else { row };
290 let cb = read_pixel(self.u_ptr, self.uv_stride, u_col, u_row, self.bpc);
291 let cr = read_pixel(self.v_ptr, self.uv_stride, u_col, u_row, self.bpc);
292
293 let cb_f = cb - center;
295 let cr_f = cr - center;
296
297 (
298 ((y_val + 1.402 * cr_f) * scale).clamp(0.0, 255.0),
299 ((y_val - 0.344136 * cb_f - 0.714136 * cr_f) * scale).clamp(0.0, 255.0),
300 ((y_val + 1.772 * cb_f) * scale).clamp(0.0, 255.0),
301 )
302 };
303
304 let idx = ((row * self.width + col) * 3) as usize;
305 rgb[idx] = r as u8;
306 rgb[idx + 1] = g as u8;
307 rgb[idx + 2] = b as u8;
308 }
309 }
310
311 rgb
312 }
313}
314
315#[inline]
317fn read_pixel(ptr: *const u8, stride: isize, x: u32, y: u32, bpc: u32) -> f32 {
318 if bpc <= 8 {
319 (unsafe { *ptr.offset(y as isize * stride + x as isize) }) as f32
320 } else {
321 let byte_offset = y as isize * stride + x as isize * 2;
323 (unsafe { *(ptr.offset(byte_offset) as *const u16) }) as f32
324 }
325}
326
327fn save_image(img: &DynamicImage, path: &Path, quality: u32) -> Result<(), BackendError> {
329 let ext = path
330 .extension()
331 .and_then(|e| e.to_str())
332 .unwrap_or("")
333 .to_lowercase();
334
335 match ext.as_str() {
336 "avif" => save_avif(img, path, quality),
337 other => Err(BackendError::ProcessingFailed(format!(
338 "Unsupported output format: {}",
339 other
340 ))),
341 }
342}
343
344fn save_avif(img: &DynamicImage, path: &Path, quality: u32) -> Result<(), BackendError> {
346 let file = std::fs::File::create(path).map_err(BackendError::Io)?;
347 let writer = std::io::BufWriter::new(file);
348 let encoder =
349 image::codecs::avif::AvifEncoder::new_with_speed_quality(writer, 6, quality as u8);
350 img.write_with_encoder(encoder)
351 .map_err(|e| BackendError::ProcessingFailed(format!("AVIF encode failed: {}", e)))
352}
353
354impl ImageBackend for RustBackend {
355 fn identify(&self, path: &Path) -> Result<Dimensions, BackendError> {
356 if is_avif(path) {
357 return identify_avif(path);
358 }
359 let (width, height) = image::image_dimensions(path).map_err(|e| {
360 BackendError::ProcessingFailed(format!("Failed to read dimensions: {}", e))
361 })?;
362 Ok(Dimensions { width, height })
363 }
364
365 fn read_metadata(&self, path: &Path) -> Result<ImageMetadata, BackendError> {
366 let iptc = super::iptc_parser::read_iptc(path);
367 Ok(ImageMetadata {
368 title: iptc.object_name,
369 description: iptc.caption,
370 keywords: iptc.keywords,
371 })
372 }
373
374 fn resize(&self, params: &ResizeParams) -> Result<(), BackendError> {
375 let img = load_image(¶ms.source)?;
376 let resized = img.resize(params.width, params.height, FilterType::Lanczos3);
377 save_image(&resized, ¶ms.output, params.quality.value())
378 }
379
380 fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError> {
381 let img = load_image(¶ms.source)?;
382
383 let filled =
385 img.resize_to_fill(params.crop_width, params.crop_height, FilterType::Lanczos3);
386
387 let final_img = if let Some(sharpening) = params.sharpening {
389 DynamicImage::from(image::imageops::unsharpen(
390 &filled,
391 sharpening.sigma,
392 sharpening.threshold,
393 ))
394 } else {
395 filled
396 };
397
398 save_image(&final_img, ¶ms.output, params.quality.value())
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::imaging::params::{Quality, Sharpening};
406 use image::{ImageEncoder, RgbImage};
407
408 #[test]
409 fn supported_extensions_match_decodable_formats() {
410 let exts = super::supported_input_extensions();
411 for expected in &["jpg", "jpeg", "png", "tif", "tiff", "webp", "avif"] {
412 assert!(
413 exts.contains(expected),
414 "expected {expected} in supported extensions"
415 );
416 }
417 }
418
419 fn create_test_jpeg(path: &Path, width: u32, height: u32) {
421 let img = RgbImage::from_fn(width, height, |x, y| {
422 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
423 });
424 let file = std::fs::File::create(path).unwrap();
425 let writer = std::io::BufWriter::new(file);
426 image::codecs::jpeg::JpegEncoder::new(writer)
427 .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
428 .unwrap();
429 }
430
431 #[test]
432 fn identify_synthetic_jpeg() {
433 let tmp = tempfile::TempDir::new().unwrap();
434 let path = tmp.path().join("test.jpg");
435 create_test_jpeg(&path, 200, 150);
436
437 let backend = RustBackend::new();
438 let dims = backend.identify(&path).unwrap();
439 assert_eq!(dims.width, 200);
440 assert_eq!(dims.height, 150);
441 }
442
443 #[test]
444 fn identify_nonexistent_file_errors() {
445 let backend = RustBackend::new();
446 let result = backend.identify(Path::new("/nonexistent/image.jpg"));
447 assert!(result.is_err());
448 }
449
450 #[test]
451 fn read_metadata_synthetic_returns_default() {
452 let tmp = tempfile::TempDir::new().unwrap();
453 let path = tmp.path().join("test.jpg");
454 create_test_jpeg(&path, 100, 100);
455
456 let backend = RustBackend::new();
457 let meta = backend.read_metadata(&path).unwrap();
458 assert_eq!(meta, ImageMetadata::default());
459 }
460
461 #[test]
462 fn read_metadata_nonexistent_returns_default() {
463 let backend = RustBackend::new();
464 let meta = backend
465 .read_metadata(Path::new("/nonexistent/image.jpg"))
466 .unwrap();
467 assert_eq!(meta, ImageMetadata::default());
468 }
469
470 #[test]
471 fn resize_synthetic_to_avif() {
472 let tmp = tempfile::TempDir::new().unwrap();
473 let source = tmp.path().join("source.jpg");
474 create_test_jpeg(&source, 400, 300);
475
476 let output = tmp.path().join("resized.avif");
477 let backend = RustBackend::new();
478 backend
479 .resize(&ResizeParams {
480 source,
481 output: output.clone(),
482 width: 200,
483 height: 150,
484 quality: Quality::new(85),
485 })
486 .unwrap();
487
488 assert!(output.exists());
489 assert!(std::fs::metadata(&output).unwrap().len() > 0);
490 }
491
492 #[test]
493 fn resize_unsupported_format_errors() {
494 let tmp = tempfile::TempDir::new().unwrap();
495 let source = tmp.path().join("source.jpg");
496 create_test_jpeg(&source, 100, 100);
497
498 let output = tmp.path().join("output.webp");
499 let backend = RustBackend::new();
500 let result = backend.resize(&ResizeParams {
501 source,
502 output,
503 width: 50,
504 height: 50,
505 quality: Quality::new(85),
506 });
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn thumbnail_synthetic_exact_dimensions() {
512 let tmp = tempfile::TempDir::new().unwrap();
513 let source = tmp.path().join("source.jpg");
514 create_test_jpeg(&source, 800, 600);
515
516 let output = tmp.path().join("thumb.avif");
517 let backend = RustBackend::new();
518 backend
519 .thumbnail(&ThumbnailParams {
520 source,
521 output: output.clone(),
522 crop_width: 400,
523 crop_height: 500,
524 quality: Quality::new(85),
525 sharpening: Some(Sharpening::light()),
526 })
527 .unwrap();
528
529 assert!(output.exists());
530 assert!(std::fs::metadata(&output).unwrap().len() > 0);
531 }
532
533 #[test]
534 fn thumbnail_synthetic_portrait_source() {
535 let tmp = tempfile::TempDir::new().unwrap();
536 let source = tmp.path().join("source.jpg");
537 create_test_jpeg(&source, 600, 800);
538
539 let output = tmp.path().join("thumb.avif");
540 let backend = RustBackend::new();
541 backend
542 .thumbnail(&ThumbnailParams {
543 source,
544 output: output.clone(),
545 crop_width: 400,
546 crop_height: 500,
547 quality: Quality::new(85),
548 sharpening: Some(Sharpening::light()),
549 })
550 .unwrap();
551
552 assert!(output.exists());
553 assert!(std::fs::metadata(&output).unwrap().len() > 0);
554 }
555
556 #[test]
557 fn thumbnail_synthetic_without_sharpening() {
558 let tmp = tempfile::TempDir::new().unwrap();
559 let source = tmp.path().join("source.jpg");
560 create_test_jpeg(&source, 400, 300);
561
562 let output = tmp.path().join("thumb.avif");
563 let backend = RustBackend::new();
564 backend
565 .thumbnail(&ThumbnailParams {
566 source,
567 output: output.clone(),
568 crop_width: 200,
569 crop_height: 200,
570 quality: Quality::new(85),
571 sharpening: None,
572 })
573 .unwrap();
574
575 assert!(output.exists());
576 assert!(std::fs::metadata(&output).unwrap().len() > 0);
577 }
578
579 fn create_test_avif(path: &Path, width: u32, height: u32) {
581 let img = RgbImage::from_fn(width, height, |x, y| {
582 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
583 });
584 let dynamic = DynamicImage::ImageRgb8(img);
585 super::save_avif(&dynamic, path, 85).unwrap();
586 }
587
588 #[test]
589 fn decode_avif_roundtrip() {
590 let tmp = tempfile::TempDir::new().unwrap();
591 let avif_path = tmp.path().join("test.avif");
592 create_test_avif(&avif_path, 64, 48);
593
594 let decoded = super::decode_avif(&avif_path).unwrap();
595 assert_eq!(decoded.width(), 64);
596 assert_eq!(decoded.height(), 48);
597 }
598
599 #[test]
600 fn identify_avif_dimensions() {
601 let tmp = tempfile::TempDir::new().unwrap();
602 let avif_path = tmp.path().join("test.avif");
603 create_test_avif(&avif_path, 120, 80);
604
605 let dims = super::identify_avif(&avif_path).unwrap();
606 assert_eq!(dims.width, 120);
607 assert_eq!(dims.height, 80);
608 }
609
610 #[test]
611 fn resize_avif_input_to_avif_output() {
612 let tmp = tempfile::TempDir::new().unwrap();
613 let source = tmp.path().join("source.avif");
614 create_test_avif(&source, 200, 150);
615
616 let output = tmp.path().join("resized.avif");
617 let backend = RustBackend::new();
618 backend
619 .resize(&ResizeParams {
620 source,
621 output: output.clone(),
622 width: 100,
623 height: 75,
624 quality: Quality::new(85),
625 })
626 .unwrap();
627
628 assert!(output.exists());
629 assert!(std::fs::metadata(&output).unwrap().len() > 0);
630 }
631}