Skip to main content

img4avif/
resize.rs

1//! Output resolution control and image resizing.
2//!
3//! This module defines the [`OutputResolution`] enum that controls how a decoded
4//! image is scaled before AVIF encoding.  The actual downscaling is performed
5//! by `resize_raw_image`, which uses a Lanczos-3 filter for high quality.
6//!
7//! ## Behaviour
8//!
9//! - **Only downscales** — if the image is already at or below the target width
10//!   it is returned unchanged.  This prevents quality loss from unnecessary
11//!   upscaling.
12//! - **Preserves aspect ratio** — the height is computed proportionally so the
13//!   image is never cropped or stretched.
14//! - **Both bit-depths** — 8-bit (`Rgba8`) and 16-bit (`Rgba16`) images are
15//!   handled; 16-bit precision is preserved through the resize step.
16
17use crate::decoder::{Pixels, RawImage};
18use crate::error::Error;
19use crate::logging::{img_debug, img_info};
20use std::sync::Arc;
21
22/// Controls the output resolution applied before AVIF encoding.
23///
24/// Set this on [`Config`](crate::Config) via
25/// [`Config::output_resolutions`](crate::Config::output_resolutions) to
26/// produce one or more outputs at different sizes from a single decode pass.
27///
28/// # Downscale-only
29///
30/// When the decoded image is already **at or below** the target width the
31/// pixels are passed through unchanged — `img4avif` never upscales.
32///
33/// # Aspect ratio
34///
35/// The height is always scaled proportionally so the image is never cropped
36/// or distorted.
37///
38/// # Example
39///
40/// ```rust,no_run
41/// use img4avif::{Config, Converter, OutputResolution};
42///
43/// # fn main() -> Result<(), img4avif::Error> {
44/// // Produce only the 1080-wide variant.
45/// let config = Config::default()
46///     .output_resolutions(vec![OutputResolution::Width1080]);
47/// let converter = Converter::new(config)?;
48/// let avif_1080 = converter.convert(&std::fs::read("photo.jpg")?)?;
49/// std::fs::write("photo_1080.avif", avif_1080)?;
50/// # Ok(())
51/// # }
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum OutputResolution {
55    /// Preserve the original image dimensions (no resize).
56    Original,
57    /// Shrink so the width is at most **2560 pixels**, preserving the aspect
58    /// ratio.  Images already ≤ 2560 px wide are passed through unchanged.
59    Width2560,
60    /// Shrink so the width is at most **1080 pixels**, preserving the aspect
61    /// ratio.  Images already ≤ 1080 px wide are passed through unchanged.
62    Width1080,
63    /// Shrink so the width is at most the given number of pixels, preserving
64    /// the aspect ratio.  Images already at or below this width are passed
65    /// through unchanged.
66    ///
67    /// A width of `0` is treated the same as [`Original`](Self::Original) —
68    /// the image is returned at its full size without any resizing.  This is
69    /// a deliberate design choice that makes it safe to derive a target width
70    /// from arithmetic that could produce zero; callers that want a hard error
71    /// on zero should validate the value before constructing this variant.
72    Custom(u32),
73}
74
75impl OutputResolution {
76    /// Returns the maximum permitted width for this variant, or `None` for
77    /// [`OutputResolution::Original`] (no limit) and for
78    /// [`OutputResolution::Custom(0)`](OutputResolution::Custom) (treated as
79    /// no limit).
80    #[must_use]
81    pub(crate) fn max_width(self) -> Option<u32> {
82        match self {
83            Self::Original | Self::Custom(0) => None,
84            Self::Width2560 => Some(2560),
85            Self::Width1080 => Some(1080),
86            Self::Custom(w) => Some(w),
87        }
88    }
89}
90
91/// Resize `raw` to fit within `resolution`, preserving the aspect ratio.
92///
93/// **Only downscales** — if the image width is already ≤ the target width the
94/// input is returned unchanged without copying the pixel data.
95///
96/// Uses the Lanczos-3 filter for high-quality downsampling.  Both
97/// [`Pixels::Rgba8`] and [`Pixels::Rgba16`] inputs are supported; 16-bit
98/// precision is preserved throughout the resize step.
99///
100/// Accepts the image by shared reference so that callers can invoke this
101/// function multiple times on the same [`RawImage`] (e.g. `convert_multi`)
102/// without cloning the pixel buffer upfront.
103///
104/// # Errors
105///
106/// Returns [`Error::Internal`] if the pixel buffer does not match the
107/// declared image dimensions.  This should never happen with images produced
108/// by the built-in decoders; if it does, please report a bug.
109pub(crate) fn resize_raw_image(
110    raw: &RawImage,
111    resolution: OutputResolution,
112) -> Result<RawImage, Error> {
113    let Some(target_width) = resolution.max_width() else {
114        // OutputResolution::Original — return the image unchanged.
115        return Ok(raw.clone());
116    };
117
118    let &RawImage {
119        width,
120        height,
121        ref pixels,
122    } = raw;
123
124    if width <= target_width {
125        img_debug!(
126            "resize: {}×{} is already within {}px target — skipping",
127            width,
128            height,
129            target_width
130        );
131        return Ok(RawImage {
132            width,
133            height,
134            pixels: pixels.clone(),
135        });
136    }
137
138    let new_width = target_width;
139    // Proportional height, rounded to nearest pixel.  Use saturating u64
140    // arithmetic so that extreme aspect ratios cannot silently produce a
141    // 1-pixel-tall output via integer overflow.
142    let height_u64 = u64::from(height)
143        .saturating_mul(u64::from(new_width))
144        .saturating_add(u64::from(width) / 2)
145        / u64::from(width);
146
147    let new_height = u32::try_from(height_u64)
148        .map_err(|_| {
149            Error::Internal(format!(
150                "resize calculation overflow: {width}×{height} → width {target_width}"
151            ))
152        })?
153        .max(1);
154
155    img_info!(
156        "resize: {}×{} → {}×{} ({} target width, Lanczos3)",
157        width,
158        height,
159        new_width,
160        new_height,
161        target_width
162    );
163
164    match pixels {
165        Pixels::Rgba8(data) => {
166            let buf =
167                image::RgbaImage::from_raw(width, height, data.to_vec()).ok_or_else(|| {
168                    Error::Internal(format!(
169                    "RGBA8 pixel buffer size does not match declared dimensions {width}×{height}; \
170                     this is a bug — please report it"
171                ))
172                })?;
173            let resized = image::imageops::resize(
174                &buf,
175                new_width,
176                new_height,
177                image::imageops::FilterType::Lanczos3,
178            );
179            Ok(RawImage {
180                width: new_width,
181                height: new_height,
182                pixels: Pixels::Rgba8(Arc::from(resized.into_raw())),
183            })
184        }
185        Pixels::Rgba16(data) => {
186            use image::{ImageBuffer, Rgba};
187            let buf: ImageBuffer<Rgba<u16>, Vec<u16>> =
188                ImageBuffer::from_raw(width, height, data.to_vec())
189                    .ok_or_else(|| Error::Internal(format!(
190                        "RGBA16 pixel buffer size does not match declared dimensions {width}×{height}; \
191                         this is a bug — please report it"
192                    )))?;
193            let resized = image::imageops::resize(
194                &buf,
195                new_width,
196                new_height,
197                image::imageops::FilterType::Lanczos3,
198            );
199            Ok(RawImage {
200                width: new_width,
201                height: new_height,
202                pixels: Pixels::Rgba16(Arc::from(resized.into_raw())),
203            })
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::decoder::Pixels;
212
213    fn solid_rgba8(width: u32, height: u32) -> RawImage {
214        let pixel = [255u8, 128, 64, 255];
215        RawImage {
216            width,
217            height,
218            pixels: Pixels::Rgba8(Arc::from(pixel.repeat(width as usize * height as usize))),
219        }
220    }
221
222    fn solid_rgba16(width: u32, height: u32) -> RawImage {
223        let pixel = [32768u16, 16384, 8192, 65535];
224        RawImage {
225            width,
226            height,
227            pixels: Pixels::Rgba16(Arc::from(pixel.repeat(width as usize * height as usize))),
228        }
229    }
230
231    #[test]
232    fn original_is_unchanged() {
233        let raw = solid_rgba8(4000, 3000);
234        let out = resize_raw_image(&raw, OutputResolution::Original).unwrap();
235        assert_eq!(out.width, 4000);
236        assert_eq!(out.height, 3000);
237    }
238
239    /// No-op resize paths share the pixel buffer via Arc (no deep copy).
240    #[test]
241    fn no_op_resize_shares_arc_allocation() {
242        let raw = solid_rgba8(640, 480);
243
244        // OutputResolution::Original must share the same Arc allocation.
245        let out_original = resize_raw_image(&raw, OutputResolution::Original).unwrap();
246        if let (Pixels::Rgba8(src), Pixels::Rgba8(dst)) = (&raw.pixels, &out_original.pixels) {
247            assert!(Arc::ptr_eq(src, dst), "Original path must share the Arc");
248        } else {
249            panic!("expected Rgba8 pixels");
250        }
251
252        // Already-small path (width <= target) must also share the Arc.
253        let out_2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
254        if let (Pixels::Rgba8(src), Pixels::Rgba8(dst)) = (&raw.pixels, &out_2560.pixels) {
255            assert!(Arc::ptr_eq(src, dst), "No-op resize must share the Arc");
256        } else {
257            panic!("expected Rgba8 pixels");
258        }
259    }
260
261    #[test]
262    fn no_upscale_when_already_small() {
263        // A 640-wide image should not be upscaled to 2560 or 1080.
264        let raw = solid_rgba8(640, 480);
265        let out2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
266        assert_eq!(out2560.width, 640);
267        assert_eq!(out2560.height, 480);
268
269        let out1080 = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
270        assert_eq!(out1080.width, 640);
271        assert_eq!(out1080.height, 480);
272    }
273
274    #[test]
275    fn downscales_to_2560() {
276        let raw = solid_rgba8(5120, 2880); // 16:9 at 5K
277        let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
278        assert_eq!(out.width, 2560);
279        assert_eq!(out.height, 1440); // 16:9 preserved
280    }
281
282    #[test]
283    fn downscales_to_1080() {
284        let raw = solid_rgba8(1920, 1080); // Full HD
285        let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
286        assert_eq!(out.width, 1080);
287        // Height: (1080 * 1080 + 960) / 1920 = 1167360 / 1920 = 608 (exactly)
288        assert_eq!(out.height, 608);
289    }
290
291    #[test]
292    fn aspect_ratio_preserved_portrait() {
293        // Portrait 2:3 at 4320×6480 (higher-res)
294        let raw = solid_rgba8(4320, 6480);
295        let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
296        assert_eq!(out.width, 2560);
297        // height = 6480 * 2560 / 4320 = 3840
298        assert_eq!(out.height, 3840);
299    }
300
301    #[test]
302    fn exact_target_width_is_not_resized() {
303        let raw = solid_rgba8(2560, 1440);
304        let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
305        assert_eq!(out.width, 2560);
306        assert_eq!(out.height, 1440);
307    }
308
309    #[test]
310    fn custom_resolution_downscales() {
311        let raw = solid_rgba8(1920, 1080);
312        let out = resize_raw_image(&raw, OutputResolution::Custom(720)).unwrap();
313        assert_eq!(out.width, 720);
314    }
315
316    #[test]
317    fn custom_resolution_zero_is_original() {
318        let raw = solid_rgba8(1920, 1080);
319        let out = resize_raw_image(&raw, OutputResolution::Custom(0)).unwrap();
320        assert_eq!(out.width, 1920);
321        assert_eq!(out.height, 1080);
322    }
323
324    #[test]
325    fn custom_resolution_no_upscale() {
326        let raw = solid_rgba8(640, 480);
327        let out = resize_raw_image(&raw, OutputResolution::Custom(1280)).unwrap();
328        assert_eq!(out.width, 640);
329        assert_eq!(out.height, 480);
330    }
331
332    #[test]
333    fn output_resolution_max_width() {
334        assert_eq!(OutputResolution::Original.max_width(), None);
335        assert_eq!(OutputResolution::Width2560.max_width(), Some(2560));
336        assert_eq!(OutputResolution::Width1080.max_width(), Some(1080));
337        assert_eq!(OutputResolution::Custom(720).max_width(), Some(720));
338        assert_eq!(OutputResolution::Custom(3840).max_width(), Some(3840));
339        assert_eq!(OutputResolution::Custom(0).max_width(), None);
340    }
341
342    // --- 16-bit resize tests ---
343
344    #[test]
345    fn rgba16_original_is_unchanged() {
346        let raw = solid_rgba16(4000, 3000);
347        let out = resize_raw_image(&raw, OutputResolution::Original).unwrap();
348        assert_eq!(out.width, 4000);
349        assert_eq!(out.height, 3000);
350        assert!(matches!(out.pixels, Pixels::Rgba16(_)));
351    }
352
353    #[test]
354    fn rgba16_downscales_to_2560() {
355        let raw = solid_rgba16(5120, 2880);
356        let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
357        assert_eq!(out.width, 2560);
358        assert_eq!(out.height, 1440);
359        assert!(matches!(out.pixels, Pixels::Rgba16(_)));
360    }
361
362    #[test]
363    fn rgba16_no_upscale() {
364        let raw = solid_rgba16(640, 480);
365        let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
366        assert_eq!(out.width, 640);
367        assert_eq!(out.height, 480);
368    }
369
370    // --- buffer mismatch returns Error::Internal ---
371
372    #[test]
373    fn mismatched_rgba8_buffer_returns_internal_error() {
374        // Declare a wide image (width > 1080) so the resize path is actually
375        // triggered and can detect the buffer/dimension mismatch.
376        // A width of 100 would be skipped (no downscale needed), so the error
377        // would never occur — that was the pre-existing bug in this test.
378        let raw = RawImage {
379            width: 2000,
380            height: 100,
381            // Only 1 pixel worth of data instead of 2000 * 100 pixels
382            pixels: Pixels::Rgba8(Arc::from([255u8, 0, 0, 255].as_slice())),
383        };
384        let err = resize_raw_image(&raw, OutputResolution::Width1080).unwrap_err();
385        assert!(
386            matches!(err, Error::Internal(_)),
387            "expected Error::Internal, got {err:?}"
388        );
389    }
390
391    #[test]
392    fn mismatched_rgba16_buffer_returns_internal_error() {
393        let raw = RawImage {
394            width: 2000,
395            height: 100,
396            pixels: Pixels::Rgba16(Arc::from([65535u16, 0, 0, 65535].as_slice())),
397        };
398        let err = resize_raw_image(&raw, OutputResolution::Width1080).unwrap_err();
399        assert!(
400            matches!(err, Error::Internal(_)),
401            "expected Error::Internal, got {err:?}"
402        );
403    }
404
405    // --- degenerate dimensions ---
406
407    #[test]
408    fn very_wide_single_row() {
409        // 2000×1 image, resize to Width1080 → 1080×1 (height stays 1)
410        let raw = solid_rgba8(2000, 1);
411        let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
412        assert_eq!(out.width, 1080);
413        assert_eq!(out.height, 1); // floor((1 * 1080 + 1000) / 2000) = 1
414    }
415
416    #[test]
417    fn single_pixel_image() {
418        // 1×1 image is below every target width — returned unchanged
419        let raw = solid_rgba8(1, 1);
420        let out2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
421        assert_eq!(out2560.width, 1);
422        let out1080 = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
423        assert_eq!(out1080.width, 1);
424    }
425
426    #[test]
427    fn saturating_arithmetic_does_not_truncate_tall_image() {
428        // A very tall portrait image: 4096×16384 resized to Width2560.
429        // height_u64 = (16384 * 2560 + 2048) / 4096 = 10,240 — fits in u32.
430        // This test verifies the saturating-arithmetic path produces the
431        // correct proportional height without any silent truncation.
432        let raw = solid_rgba8(4096, 16384);
433        let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
434        assert_eq!(out.width, 2560);
435        // height = (16384 * 2560 + 2048) / 4096 = 10240
436        assert_eq!(out.height, 10240);
437    }
438}