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 read_avif_file(path: &Path) -> Result<avif_parse::AvifData, BackendError> {
100 let mut file_data = std::fs::read(path).map_err(BackendError::Io)?;
101 fix_unsized_isobmff_boxes(&mut file_data);
102 avif_parse::read_avif(&mut std::io::Cursor::new(&file_data)).map_err(|e| {
103 BackendError::ProcessingFailed(format!("Failed to parse AVIF {}: {e:?}", path.display()))
104 })
105}
106
107fn fix_unsized_isobmff_boxes(data: &mut [u8]) {
113 let file_len = data.len() as u64;
114 let mut offset: usize = 0;
115 while offset + 8 <= data.len() {
116 let size = u32::from_be_bytes([
117 data[offset],
118 data[offset + 1],
119 data[offset + 2],
120 data[offset + 3],
121 ]);
122 if size == 0 {
123 let remaining = file_len - offset as u64;
125 if remaining <= u32::MAX as u64 {
126 data[offset..offset + 4].copy_from_slice(&(remaining as u32).to_be_bytes());
127 }
128 break; }
130 let box_size = if size == 1 {
131 if offset + 16 > data.len() {
133 break;
134 }
135 u64::from_be_bytes(data[offset + 8..offset + 16].try_into().unwrap())
136 } else {
137 size as u64
138 };
139 offset += box_size as usize;
140 }
141}
142
143fn identify_avif(path: &Path) -> Result<Dimensions, BackendError> {
145 let avif = read_avif_file(path)?;
146 let meta = avif.primary_item_metadata().map_err(|e| {
147 BackendError::ProcessingFailed(format!(
148 "Failed to read AVIF metadata {}: {e:?}",
149 path.display()
150 ))
151 })?;
152 Ok(Dimensions {
153 width: meta.max_frame_width.get(),
154 height: meta.max_frame_height.get(),
155 })
156}
157
158fn decode_avif(path: &Path) -> Result<DynamicImage, BackendError> {
164 use rav1d::include::dav1d::data::Dav1dData;
165 use rav1d::include::dav1d::dav1d::Dav1dSettings;
166 use rav1d::include::dav1d::headers::{
167 DAV1D_PIXEL_LAYOUT_I400, DAV1D_PIXEL_LAYOUT_I420, DAV1D_PIXEL_LAYOUT_I422,
168 DAV1D_PIXEL_LAYOUT_I444,
169 };
170 use rav1d::include::dav1d::picture::Dav1dPicture;
171 use std::ptr::NonNull;
172
173 let avif = read_avif_file(path)?;
174 let av1_bytes: &[u8] = &avif.primary_item;
175
176 let mut settings = std::mem::MaybeUninit::<Dav1dSettings>::uninit();
178 unsafe {
179 rav1d::src::lib::dav1d_default_settings(NonNull::new(settings.as_mut_ptr()).unwrap())
180 };
181 let mut settings = unsafe { settings.assume_init() };
182 settings.n_threads = 1;
183 settings.max_frame_delay = 1;
184
185 let mut ctx = None;
186 let rc =
187 unsafe { rav1d::src::lib::dav1d_open(NonNull::new(&mut ctx), NonNull::new(&mut settings)) };
188 if rc.0 != 0 {
189 return Err(BackendError::ProcessingFailed(format!(
190 "rav1d open failed ({})",
191 rc.0
192 )));
193 }
194
195 let mut data = Dav1dData::default();
197 let buf_ptr =
198 unsafe { rav1d::src::lib::dav1d_data_create(NonNull::new(&mut data), av1_bytes.len()) };
199 if buf_ptr.is_null() {
200 unsafe { rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx)) };
201 return Err(BackendError::ProcessingFailed(
202 "rav1d data_create failed".into(),
203 ));
204 }
205 unsafe { std::ptr::copy_nonoverlapping(av1_bytes.as_ptr(), buf_ptr, av1_bytes.len()) };
206
207 let rc = unsafe { rav1d::src::lib::dav1d_send_data(ctx, NonNull::new(&mut data)) };
209 if rc.0 != 0 {
210 unsafe {
211 rav1d::src::lib::dav1d_data_unref(NonNull::new(&mut data));
212 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
213 }
214 return Err(BackendError::ProcessingFailed(format!(
215 "rav1d send_data failed ({})",
216 rc.0
217 )));
218 }
219
220 let mut pic: Dav1dPicture = unsafe { std::mem::zeroed() };
222 let rc = unsafe { rav1d::src::lib::dav1d_get_picture(ctx, NonNull::new(&mut pic)) };
223 if rc.0 != 0 {
224 unsafe { rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx)) };
225 return Err(BackendError::ProcessingFailed(format!(
226 "rav1d get_picture failed ({})",
227 rc.0
228 )));
229 }
230
231 let w = pic.p.w as u32;
233 let h = pic.p.h as u32;
234 let bpc = pic.p.bpc as u32;
235 let layout = pic.p.layout;
236 let y_stride = pic.stride[0];
237 let uv_stride = pic.stride[1];
238 let y_ptr = pic.data[0].unwrap().as_ptr() as *const u8;
239
240 let rgb = if layout == DAV1D_PIXEL_LAYOUT_I400 {
242 YuvPlanes {
243 y_ptr,
244 u_ptr: y_ptr,
245 v_ptr: y_ptr,
246 y_stride,
247 uv_stride: 0,
248 width: w,
249 height: h,
250 bpc,
251 ss_x: false,
252 ss_y: false,
253 monochrome: true,
254 }
255 .to_rgb()
256 } else {
257 let u_ptr = pic.data[1].unwrap().as_ptr() as *const u8;
258 let v_ptr = pic.data[2].unwrap().as_ptr() as *const u8;
259 let (ss_x, ss_y) = match layout {
260 DAV1D_PIXEL_LAYOUT_I420 => (true, true),
261 DAV1D_PIXEL_LAYOUT_I422 => (true, false),
262 DAV1D_PIXEL_LAYOUT_I444 => (false, false),
263 _ => {
264 unsafe {
265 rav1d::src::lib::dav1d_picture_unref(NonNull::new(&mut pic));
266 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
267 }
268 return Err(BackendError::ProcessingFailed(format!(
269 "Unsupported AVIF pixel layout: {layout}"
270 )));
271 }
272 };
273 YuvPlanes {
274 y_ptr,
275 u_ptr,
276 v_ptr,
277 y_stride,
278 uv_stride,
279 width: w,
280 height: h,
281 bpc,
282 ss_x,
283 ss_y,
284 monochrome: false,
285 }
286 .to_rgb()
287 };
288
289 unsafe {
290 rav1d::src::lib::dav1d_picture_unref(NonNull::new(&mut pic));
291 rav1d::src::lib::dav1d_close(NonNull::new(&mut ctx));
292 }
293
294 image::RgbImage::from_raw(w, h, rgb)
295 .map(DynamicImage::ImageRgb8)
296 .ok_or_else(|| {
297 BackendError::ProcessingFailed("Failed to create image from decoded AVIF data".into())
298 })
299}
300
301struct YuvPlanes {
303 y_ptr: *const u8,
304 u_ptr: *const u8,
305 v_ptr: *const u8,
306 y_stride: isize,
307 uv_stride: isize,
308 width: u32,
309 height: u32,
310 bpc: u32,
311 ss_x: bool,
313 ss_y: bool,
314 monochrome: bool,
315}
316
317impl YuvPlanes {
318 fn to_rgb(&self) -> Vec<u8> {
320 let max_val = ((1u32 << self.bpc) - 1) as f32;
321 let center = (1u32 << (self.bpc - 1)) as f32;
322 let scale = 255.0 / max_val;
323
324 let mut rgb = vec![0u8; (self.width * self.height * 3) as usize];
325
326 for row in 0..self.height {
327 for col in 0..self.width {
328 let y_val = read_pixel(self.y_ptr, self.y_stride, col, row, self.bpc);
329
330 let (r, g, b) = if self.monochrome {
331 let v = (y_val * scale).clamp(0.0, 255.0);
332 (v, v, v)
333 } else {
334 let u_col = if self.ss_x { col / 2 } else { col };
335 let u_row = if self.ss_y { row / 2 } else { row };
336 let cb = read_pixel(self.u_ptr, self.uv_stride, u_col, u_row, self.bpc);
337 let cr = read_pixel(self.v_ptr, self.uv_stride, u_col, u_row, self.bpc);
338
339 let cb_f = cb - center;
341 let cr_f = cr - center;
342
343 (
344 ((y_val + 1.402 * cr_f) * scale).clamp(0.0, 255.0),
345 ((y_val - 0.344136 * cb_f - 0.714136 * cr_f) * scale).clamp(0.0, 255.0),
346 ((y_val + 1.772 * cb_f) * scale).clamp(0.0, 255.0),
347 )
348 };
349
350 let idx = ((row * self.width + col) * 3) as usize;
351 rgb[idx] = r as u8;
352 rgb[idx + 1] = g as u8;
353 rgb[idx + 2] = b as u8;
354 }
355 }
356
357 rgb
358 }
359}
360
361#[inline]
363fn read_pixel(ptr: *const u8, stride: isize, x: u32, y: u32, bpc: u32) -> f32 {
364 if bpc <= 8 {
365 (unsafe { *ptr.offset(y as isize * stride + x as isize) }) as f32
366 } else {
367 let byte_offset = y as isize * stride + x as isize * 2;
369 (unsafe { *(ptr.offset(byte_offset) as *const u16) }) as f32
370 }
371}
372
373fn save_image(img: &DynamicImage, path: &Path, quality: u32) -> Result<(), BackendError> {
375 let ext = path
376 .extension()
377 .and_then(|e| e.to_str())
378 .unwrap_or("")
379 .to_lowercase();
380
381 match ext.as_str() {
382 "avif" => save_avif(img, path, quality),
383 other => Err(BackendError::ProcessingFailed(format!(
384 "Unsupported output format: {}",
385 other
386 ))),
387 }
388}
389
390fn save_avif(img: &DynamicImage, path: &Path, quality: u32) -> Result<(), BackendError> {
392 let file = std::fs::File::create(path).map_err(BackendError::Io)?;
393 let writer = std::io::BufWriter::new(file);
394 let encoder =
395 image::codecs::avif::AvifEncoder::new_with_speed_quality(writer, 6, quality as u8);
396 img.write_with_encoder(encoder)
397 .map_err(|e| BackendError::ProcessingFailed(format!("AVIF encode failed: {}", e)))
398}
399
400impl ImageBackend for RustBackend {
401 fn identify(&self, path: &Path) -> Result<Dimensions, BackendError> {
402 if is_avif(path) {
403 return identify_avif(path);
404 }
405 let (width, height) = image::image_dimensions(path).map_err(|e| {
406 BackendError::ProcessingFailed(format!("Failed to read dimensions: {}", e))
407 })?;
408 Ok(Dimensions { width, height })
409 }
410
411 fn read_metadata(&self, path: &Path) -> Result<ImageMetadata, BackendError> {
412 let iptc = super::iptc_parser::read_iptc(path);
413 Ok(ImageMetadata {
414 title: iptc.object_name,
415 description: iptc.caption,
416 keywords: iptc.keywords,
417 })
418 }
419
420 fn resize(&self, params: &ResizeParams) -> Result<(), BackendError> {
421 let img = load_image(¶ms.source)?;
422 let resized = img.resize(params.width, params.height, FilterType::Lanczos3);
423 save_image(&resized, ¶ms.output, params.quality.value())
424 }
425
426 fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError> {
427 let img = load_image(¶ms.source)?;
428
429 let filled =
431 img.resize_to_fill(params.crop_width, params.crop_height, FilterType::Lanczos3);
432
433 let final_img = if let Some(sharpening) = params.sharpening {
435 DynamicImage::from(image::imageops::unsharpen(
436 &filled,
437 sharpening.sigma,
438 sharpening.threshold,
439 ))
440 } else {
441 filled
442 };
443
444 save_image(&final_img, ¶ms.output, params.quality.value())
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::imaging::params::{Quality, Sharpening};
452 use image::{ImageEncoder, RgbImage};
453
454 #[test]
455 fn supported_extensions_match_decodable_formats() {
456 let exts = super::supported_input_extensions();
457 for expected in &["jpg", "jpeg", "png", "tif", "tiff", "webp", "avif"] {
458 assert!(
459 exts.contains(expected),
460 "expected {expected} in supported extensions"
461 );
462 }
463 }
464
465 fn create_test_jpeg(path: &Path, width: u32, height: u32) {
467 let img = RgbImage::from_fn(width, height, |x, y| {
468 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
469 });
470 let file = std::fs::File::create(path).unwrap();
471 let writer = std::io::BufWriter::new(file);
472 image::codecs::jpeg::JpegEncoder::new(writer)
473 .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
474 .unwrap();
475 }
476
477 #[test]
478 fn identify_synthetic_jpeg() {
479 let tmp = tempfile::TempDir::new().unwrap();
480 let path = tmp.path().join("test.jpg");
481 create_test_jpeg(&path, 200, 150);
482
483 let backend = RustBackend::new();
484 let dims = backend.identify(&path).unwrap();
485 assert_eq!(dims.width, 200);
486 assert_eq!(dims.height, 150);
487 }
488
489 #[test]
490 fn identify_nonexistent_file_errors() {
491 let backend = RustBackend::new();
492 let result = backend.identify(Path::new("/nonexistent/image.jpg"));
493 assert!(result.is_err());
494 }
495
496 #[test]
497 fn read_metadata_synthetic_returns_default() {
498 let tmp = tempfile::TempDir::new().unwrap();
499 let path = tmp.path().join("test.jpg");
500 create_test_jpeg(&path, 100, 100);
501
502 let backend = RustBackend::new();
503 let meta = backend.read_metadata(&path).unwrap();
504 assert_eq!(meta, ImageMetadata::default());
505 }
506
507 #[test]
508 fn read_metadata_nonexistent_returns_default() {
509 let backend = RustBackend::new();
510 let meta = backend
511 .read_metadata(Path::new("/nonexistent/image.jpg"))
512 .unwrap();
513 assert_eq!(meta, ImageMetadata::default());
514 }
515
516 #[test]
517 fn resize_synthetic_to_avif() {
518 let tmp = tempfile::TempDir::new().unwrap();
519 let source = tmp.path().join("source.jpg");
520 create_test_jpeg(&source, 400, 300);
521
522 let output = tmp.path().join("resized.avif");
523 let backend = RustBackend::new();
524 backend
525 .resize(&ResizeParams {
526 source,
527 output: output.clone(),
528 width: 200,
529 height: 150,
530 quality: Quality::new(85),
531 })
532 .unwrap();
533
534 assert!(output.exists());
535 assert!(std::fs::metadata(&output).unwrap().len() > 0);
536 }
537
538 #[test]
539 fn resize_unsupported_format_errors() {
540 let tmp = tempfile::TempDir::new().unwrap();
541 let source = tmp.path().join("source.jpg");
542 create_test_jpeg(&source, 100, 100);
543
544 let output = tmp.path().join("output.webp");
545 let backend = RustBackend::new();
546 let result = backend.resize(&ResizeParams {
547 source,
548 output,
549 width: 50,
550 height: 50,
551 quality: Quality::new(85),
552 });
553 assert!(result.is_err());
554 }
555
556 #[test]
557 fn thumbnail_synthetic_exact_dimensions() {
558 let tmp = tempfile::TempDir::new().unwrap();
559 let source = tmp.path().join("source.jpg");
560 create_test_jpeg(&source, 800, 600);
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: 400,
569 crop_height: 500,
570 quality: Quality::new(85),
571 sharpening: Some(Sharpening::light()),
572 })
573 .unwrap();
574
575 assert!(output.exists());
576 assert!(std::fs::metadata(&output).unwrap().len() > 0);
577 }
578
579 #[test]
580 fn thumbnail_synthetic_portrait_source() {
581 let tmp = tempfile::TempDir::new().unwrap();
582 let source = tmp.path().join("source.jpg");
583 create_test_jpeg(&source, 600, 800);
584
585 let output = tmp.path().join("thumb.avif");
586 let backend = RustBackend::new();
587 backend
588 .thumbnail(&ThumbnailParams {
589 source,
590 output: output.clone(),
591 crop_width: 400,
592 crop_height: 500,
593 quality: Quality::new(85),
594 sharpening: Some(Sharpening::light()),
595 })
596 .unwrap();
597
598 assert!(output.exists());
599 assert!(std::fs::metadata(&output).unwrap().len() > 0);
600 }
601
602 #[test]
603 fn thumbnail_synthetic_without_sharpening() {
604 let tmp = tempfile::TempDir::new().unwrap();
605 let source = tmp.path().join("source.jpg");
606 create_test_jpeg(&source, 400, 300);
607
608 let output = tmp.path().join("thumb.avif");
609 let backend = RustBackend::new();
610 backend
611 .thumbnail(&ThumbnailParams {
612 source,
613 output: output.clone(),
614 crop_width: 200,
615 crop_height: 200,
616 quality: Quality::new(85),
617 sharpening: None,
618 })
619 .unwrap();
620
621 assert!(output.exists());
622 assert!(std::fs::metadata(&output).unwrap().len() > 0);
623 }
624
625 fn create_test_avif(path: &Path, width: u32, height: u32) {
627 let img = RgbImage::from_fn(width, height, |x, y| {
628 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
629 });
630 let dynamic = DynamicImage::ImageRgb8(img);
631 super::save_avif(&dynamic, path, 85).unwrap();
632 }
633
634 #[test]
635 fn decode_avif_roundtrip() {
636 let tmp = tempfile::TempDir::new().unwrap();
637 let avif_path = tmp.path().join("test.avif");
638 create_test_avif(&avif_path, 64, 48);
639
640 let decoded = super::decode_avif(&avif_path).unwrap();
641 assert_eq!(decoded.width(), 64);
642 assert_eq!(decoded.height(), 48);
643 }
644
645 #[test]
646 fn identify_avif_dimensions() {
647 let tmp = tempfile::TempDir::new().unwrap();
648 let avif_path = tmp.path().join("test.avif");
649 create_test_avif(&avif_path, 120, 80);
650
651 let dims = super::identify_avif(&avif_path).unwrap();
652 assert_eq!(dims.width, 120);
653 assert_eq!(dims.height, 80);
654 }
655
656 #[test]
657 fn resize_avif_input_to_avif_output() {
658 let tmp = tempfile::TempDir::new().unwrap();
659 let source = tmp.path().join("source.avif");
660 create_test_avif(&source, 200, 150);
661
662 let output = tmp.path().join("resized.avif");
663 let backend = RustBackend::new();
664 backend
665 .resize(&ResizeParams {
666 source,
667 output: output.clone(),
668 width: 100,
669 height: 75,
670 quality: Quality::new(85),
671 })
672 .unwrap();
673
674 assert!(output.exists());
675 assert!(std::fs::metadata(&output).unwrap().len() > 0);
676 }
677}