Skip to main content

simple_gal/imaging/
rust_backend.rs

1//! Pure Rust image processing backend — zero external dependencies.
2//!
3//! Everything is statically linked into the binary.
4//!
5//! ## Crate mapping
6//!
7//! | Operation | Crate / function |
8//! |---|---|
9//! | Decode (JPEG, PNG, TIFF, WebP) | `image` crate (pure Rust decoders) |
10//! | Decode (AVIF) | `avif-parse` (container) + `rav1d` (AV1 decode) + custom YUV→RGB |
11//! | Resize | `image::imageops::resize` with `Lanczos3` filter |
12//! | Encode → AVIF | `image::codecs::avif::AvifEncoder` (rav1e, speed 6) |
13//! | Thumbnail crop | `image::DynamicImage::resize_to_fill` |
14//! | Sharpening | `image::imageops::unsharpen` |
15//! | IPTC metadata | custom `iptc_parser` (JPEG APP13 + TIFF IFD) |
16
17use 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
24/// Extensions whose decoders are compiled in and known to work.
25///
26/// AVIF is deliberately excluded: the `image` crate's `"avif"` feature only enables the
27/// **encoder** (rav1e). The decoder requires `"avif-native"` (a C library we don't use).
28/// `ImageFormat::reading_enabled()` incorrectly returns `true` for AVIF when `"avif"` is
29/// enabled, so we cannot rely on that API alone.
30const 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    // AVIF is decoded via our custom rav1d-based decoder (not the image crate)
46    exts.push("avif");
47    exts
48});
49
50/// Returns the set of image file extensions that have working decoders compiled in.
51pub fn supported_input_extensions() -> &'static [&'static str] {
52    &SUPPORTED_EXTENSIONS
53}
54
55/// Pure Rust backend using the `image` crate ecosystem.
56///
57/// See the [module docs](self) for the crate-to-operation mapping.
58pub 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
78/// Load and decode an image from disk.
79fn 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
91/// Extract dimensions from an AVIF file's container metadata (no full decode needed).
92fn 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
109/// Decode an AVIF file using avif-parse (container) + rav1d (AV1 decode).
110///
111/// The `image` crate's `"avif"` feature only provides the encoder (rav1e).
112/// Decoding requires `"avif-native"` which depends on the C library dav1d.
113/// Instead, we use `rav1d` (pure Rust port of dav1d) directly.
114fn 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    // Initialize rav1d decoder
131    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    // Create data buffer and copy AV1 bytes
150    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    // Feed data to decoder
162    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    // Get decoded picture
175    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    // Extract dimensions and pixel layout
186    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    // Convert YUV planes to interleaved RGB8
195    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
255/// Decoded YUV plane data from rav1d, ready for RGB conversion.
256struct 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    /// Chroma subsampling: horizontal, vertical (e.g. I420 = true, true)
266    ss_x: bool,
267    ss_y: bool,
268    monochrome: bool,
269}
270
271impl YuvPlanes {
272    /// Convert YUV planes to interleaved RGB8 using BT.601 coefficients.
273    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                    // BT.601 YCbCr -> RGB, then scale to 8-bit
294                    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/// Read a single pixel value from a YUV plane, handling both 8-bit and 16-bit storage.
316#[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        // 10-bit and 12-bit are stored as u16
322        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
327/// Save a DynamicImage to the given path, inferring format from extension.
328fn 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
344/// Encode and save as AVIF using ravif/rav1e (speed=6 for reasonable throughput).
345fn 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(&params.source)?;
376        let resized = img.resize(params.width, params.height, FilterType::Lanczos3);
377        save_image(&resized, &params.output, params.quality.value())
378    }
379
380    fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError> {
381        let img = load_image(&params.source)?;
382
383        // Fill-resize then center-crop to exact dimensions
384        let filled =
385            img.resize_to_fill(params.crop_width, params.crop_height, FilterType::Lanczos3);
386
387        // Apply sharpening if requested
388        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, &params.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    /// Create a small valid JPEG file with the given dimensions.
420    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    /// Create a small valid AVIF file by encoding a JPEG through our AVIF encoder.
580    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}