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/// Read an AVIF file and parse its ISOBMFF container.
92///
93/// Works around an `avif-parse` limitation: the `mdat` box in many real-world
94/// AVIF files uses `size == 0` ("extends to end of file"), which the library
95/// rejects. We patch the box header in memory before parsing.
96///
97/// UPSTREAM: <https://github.com/kornelski/avif-parse/blob/v2.0.0/src/lib.rs#L663>
98/// Remove `fix_unsized_isobmff_boxes` once avif-parse handles size-0 boxes.
99fn 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
107/// Patch ISOBMFF boxes that have `size == 0` (meaning "extends to end of file").
108///
109/// Per ISO 14496-12 § 4.2, a box with `size == 0` is valid and means "this box
110/// extends to EOF". `avif-parse` ≤ 2.0.0 returns `Unsupported("unknown sized box")`
111/// instead. We rewrite the 4-byte size field in place with `(file_len - box_offset)`.
112fn 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            // Size 0 means "rest of file". Patch it if it fits in u32.
124            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; // size-0 box is always the last box
129        }
130        let box_size = if size == 1 {
131            // Extended 64-bit size
132            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
143/// Extract dimensions from an AVIF file's container metadata (no full decode needed).
144fn 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
158/// Decode an AVIF file using avif-parse (container) + rav1d (AV1 decode).
159///
160/// The `image` crate's `"avif"` feature only provides the encoder (rav1e).
161/// Decoding requires `"avif-native"` which depends on the C library dav1d.
162/// Instead, we use `rav1d` (pure Rust port of dav1d) directly.
163fn 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    // Initialize rav1d decoder
177    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    // Create data buffer and copy AV1 bytes
196    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    // Feed data to decoder
208    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    // Get decoded picture
221    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    // Extract dimensions and pixel layout
232    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    // Convert YUV planes to interleaved RGB8
241    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
301/// Decoded YUV plane data from rav1d, ready for RGB conversion.
302struct 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    /// Chroma subsampling: horizontal, vertical (e.g. I420 = true, true)
312    ss_x: bool,
313    ss_y: bool,
314    monochrome: bool,
315}
316
317impl YuvPlanes {
318    /// Convert YUV planes to interleaved RGB8 using BT.601 coefficients.
319    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                    // BT.601 YCbCr -> RGB, then scale to 8-bit
340                    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/// Read a single pixel value from a YUV plane, handling both 8-bit and 16-bit storage.
362#[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        // 10-bit and 12-bit are stored as u16
368        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
373/// Save a DynamicImage to the given path, inferring format from extension.
374fn 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
390/// Encode and save as AVIF using ravif/rav1e (speed=6 for reasonable throughput).
391fn 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(&params.source)?;
422        let resized = img.resize(params.width, params.height, FilterType::Lanczos3);
423        save_image(&resized, &params.output, params.quality.value())
424    }
425
426    fn thumbnail(&self, params: &ThumbnailParams) -> Result<(), BackendError> {
427        let img = load_image(&params.source)?;
428
429        // Fill-resize then center-crop to exact dimensions
430        let filled =
431            img.resize_to_fill(params.crop_width, params.crop_height, FilterType::Lanczos3);
432
433        // Apply sharpening if requested
434        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, &params.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    /// Create a small valid JPEG file with the given dimensions.
466    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    /// Create a small valid AVIF file by encoding a JPEG through our AVIF encoder.
626    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}