Skip to main content

webp_anim/
resize.rs

1use std::{error::Error, fmt};
2
3use fast_image_resize::{
4    FilterType, PixelType, ResizeAlg, ResizeOptions as FirResizeOptions, Resizer,
5    images::Image as FirImage,
6};
7
8use crate::model::{AnimationFrame, CanvasSize};
9
10/// Resampling filter used by a [`ResizePlan`].
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub enum ResizeFilter {
13    /// Blend nearby source pixels. Fast and smooth.
14    #[default]
15    Bilinear,
16    /// A sharper bicubic filter with a small risk of haloing around hard edges.
17    CatmullRom,
18    /// A high-detail sinc filter. Best for visual comparison, but slowest and
19    /// can ring around very high-contrast edges.
20    Lanczos3,
21}
22
23/// Constraints used to derive a full-canvas resize operation.
24///
25/// The resulting canvas always fits within `maximum` and retains the source
26/// aspect ratio. A plan never adds padding or crops pixels.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub struct ResizeOptions {
29    /// Maximum destination canvas dimensions.
30    pub maximum: CanvasSize,
31    /// Whether a source smaller than `maximum` may be enlarged.
32    pub allow_upscale: bool,
33    /// Resampling filter used by the resize operation.
34    pub filter: ResizeFilter,
35    /// Upper bound for the transformed RGBA buffer.
36    pub max_output_rgba_bytes: usize,
37}
38
39impl ResizeOptions {
40    /// Creates a contain-style resize with no upscaling and bilinear filtering.
41    pub const fn contain(maximum: CanvasSize) -> Self {
42        Self {
43            maximum,
44            allow_upscale: false,
45            filter: ResizeFilter::Bilinear,
46            max_output_rgba_bytes: usize::MAX,
47        }
48    }
49}
50
51/// A validated, reusable full-canvas RGBA resize operation.
52///
53/// Build one plan per source canvas, then apply it to each composited frame of
54/// an animation. [`Self::transform_frame`] preserves the frame duration.
55#[derive(Clone, Copy, Debug, Eq, PartialEq)]
56pub struct ResizePlan {
57    source: CanvasSize,
58    destination: CanvasSize,
59    filter: ResizeFilter,
60}
61
62impl ResizePlan {
63    /// Validates resize options and derives the destination canvas.
64    ///
65    /// The destination preserves the source aspect ratio, fits within the
66    /// configured maximum, and never crops or pads pixels.
67    pub fn new(source: CanvasSize, options: ResizeOptions) -> Result<Self, ResizeError> {
68        validate_size("source", source)?;
69        validate_size("maximum", options.maximum)?;
70
71        let destination = destination_size(source, options.maximum, options.allow_upscale);
72        let output_bytes = destination
73            .rgba_bytes()
74            .ok_or(ResizeError::OutputSizeOverflow)?;
75        if output_bytes > options.max_output_rgba_bytes {
76            return Err(ResizeError::OutputTooLarge {
77                actual: output_bytes,
78                maximum: options.max_output_rgba_bytes,
79            });
80        }
81
82        Ok(Self {
83            source,
84            destination,
85            filter: options.filter,
86        })
87    }
88
89    /// Returns the source canvas expected by this plan.
90    pub const fn source(&self) -> CanvasSize {
91        self.source
92    }
93
94    /// Returns the destination canvas produced by this plan.
95    pub const fn destination(&self) -> CanvasSize {
96        self.destination
97    }
98
99    /// Returns whether the plan preserves the source dimensions.
100    pub const fn is_noop(&self) -> bool {
101        self.source.width == self.destination.width && self.source.height == self.destination.height
102    }
103
104    /// One-shot convenience API for one complete, tightly packed RGBA canvas.
105    ///
106    /// It allocates a workspace and output buffer for this call. For sequential
107    /// animation frames, create one [`ResizeWorkspace`] with [`Self::workspace`]
108    /// and reuse it instead.
109    pub fn transform_rgba(&self, source_rgba: &[u8]) -> Result<Vec<u8>, ResizeError> {
110        let mut source = source_rgba.to_vec();
111        let mut workspace = self.workspace()?;
112        workspace.transform_rgba(&mut source)?;
113        Ok(workspace.pixels().to_vec())
114    }
115
116    /// Creates reusable resize state for sequential animation frames.
117    ///
118    /// Reuse this workspace for the full animation to retain its resizer and
119    /// destination buffer between frames.
120    pub fn workspace(&self) -> Result<ResizeWorkspace, ResizeError> {
121        let destination_bytes = self
122            .destination
123            .rgba_bytes()
124            .ok_or(ResizeError::OutputSizeOverflow)?;
125        Ok(ResizeWorkspace {
126            plan: *self,
127            resizer: Resizer::new(),
128            destination: vec![0; destination_bytes],
129        })
130    }
131
132    /// One-shot convenience API that transforms a decoded frame and preserves
133    /// its duration.
134    ///
135    /// It allocates for every call. For animation processing, prefer a reused
136    /// [`ResizeWorkspace`] and construct the output frame at the call site.
137    pub fn transform_frame(&self, frame: AnimationFrame) -> Result<AnimationFrame, ResizeError> {
138        if frame.canvas != self.source {
139            return Err(ResizeError::UnexpectedFrameCanvas {
140                actual: frame.canvas,
141                expected: self.source,
142            });
143        }
144        Ok(AnimationFrame {
145            rgba: self.transform_rgba(&frame.rgba)?,
146            canvas: self.destination,
147            duration: frame.duration,
148        })
149    }
150}
151
152/// Reusable RGBA resize buffers and processor for sequential animation frames.
153///
154/// The workspace owns its destination buffer. [`Self::pixels`] remains valid
155/// until the next successful resize or until the workspace is dropped.
156pub struct ResizeWorkspace {
157    plan: ResizePlan,
158    resizer: Resizer,
159    destination: Vec<u8>,
160}
161
162impl ResizeWorkspace {
163    /// Resizes a full-canvas RGBA source into this workspace's reusable
164    /// destination buffer.
165    ///
166    /// [`fast_image_resize::images::Image::from_slice_u8`] requires a mutable
167    /// buffer for every image view, including the source view. This method uses
168    /// that contract, but directs its output to the workspace destination rather
169    /// than modifying `source_rgba`. The destination allocation and resizer are
170    /// retained for the next frame.
171    pub fn transform_rgba(&mut self, source_rgba: &mut [u8]) -> Result<(), ResizeError> {
172        let expected = self
173            .plan
174            .source
175            .rgba_bytes()
176            .ok_or(ResizeError::SourceSizeOverflow)?;
177        if source_rgba.len() != expected {
178            return Err(ResizeError::InvalidSourceBufferLength {
179                actual: source_rgba.len(),
180                expected,
181            });
182        }
183        if self.plan.is_noop() {
184            self.destination.copy_from_slice(source_rgba);
185            return Ok(());
186        }
187        let has_transparency = source_rgba.chunks_exact(4).any(|pixel| pixel[3] != 255);
188        let source = FirImage::from_slice_u8(
189            self.plan.source.width,
190            self.plan.source.height,
191            source_rgba,
192            PixelType::U8x4,
193        )
194        .map_err(|error| ResizeError::ImageResize(error.to_string()))?;
195        let mut destination = FirImage::from_slice_u8(
196            self.plan.destination.width,
197            self.plan.destination.height,
198            &mut self.destination,
199            PixelType::U8x4,
200        )
201        .map_err(|error| ResizeError::ImageResize(error.to_string()))?;
202        let filter = match self.plan.filter {
203            ResizeFilter::Bilinear => FilterType::Bilinear,
204            ResizeFilter::CatmullRom => FilterType::CatmullRom,
205            ResizeFilter::Lanczos3 => FilterType::Lanczos3,
206        };
207        let options = FirResizeOptions::new()
208            .resize_alg(ResizeAlg::Convolution(filter))
209            .use_alpha(has_transparency);
210        self.resizer
211            .resize(&source, &mut destination, &options)
212            .map_err(|error| ResizeError::ImageResize(error.to_string()))?;
213        Ok(())
214    }
215
216    /// Returns the destination pixels from the most recent successful resize.
217    ///
218    /// The next successful [`Self::transform_rgba`] call overwrites this buffer.
219    pub fn pixels(&self) -> &[u8] {
220        &self.destination
221    }
222}
223
224/// Failure to create or apply a [`ResizePlan`].
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub enum ResizeError {
227    /// A source or maximum canvas has a zero dimension.
228    InvalidCanvasSize {
229        /// Which input was invalid.
230        which: &'static str,
231        /// Invalid dimensions.
232        size: CanvasSize,
233    },
234    /// The source RGBA byte count overflowed the host address space.
235    SourceSizeOverflow,
236    /// The destination RGBA byte count overflowed the host address space.
237    OutputSizeOverflow,
238    /// The destination exceeds `ResizeOptions::max_output_rgba_bytes`.
239    OutputTooLarge {
240        /// Actual destination buffer size in bytes.
241        actual: usize,
242        /// Configured maximum destination buffer size in bytes.
243        maximum: usize,
244    },
245    /// The source buffer length does not match the plan's source canvas.
246    InvalidSourceBufferLength {
247        /// Actual source buffer size in bytes.
248        actual: usize,
249        /// Required source buffer size in bytes.
250        expected: usize,
251    },
252    /// A frame's canvas does not match the plan's source canvas.
253    UnexpectedFrameCanvas {
254        /// Canvas received from the caller.
255        actual: CanvasSize,
256        /// Canvas required by the plan.
257        expected: CanvasSize,
258    },
259    /// The underlying RGBA resizer rejected the operation.
260    ImageResize(String),
261}
262
263impl fmt::Display for ResizeError {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::InvalidCanvasSize { which, size } => write!(
267                f,
268                "{which} canvas has invalid dimensions {}x{}",
269                size.width, size.height
270            ),
271            Self::SourceSizeOverflow => {
272                f.write_str("source RGBA size overflows the host address space")
273            }
274            Self::OutputSizeOverflow => {
275                f.write_str("output RGBA size overflows the host address space")
276            }
277            Self::OutputTooLarge { actual, maximum } => write!(
278                f,
279                "output is {actual} bytes, exceeding the {maximum}-byte limit"
280            ),
281            Self::InvalidSourceBufferLength { actual, expected } => {
282                write!(f, "source buffer is {actual} bytes; expected {expected}")
283            }
284            Self::UnexpectedFrameCanvas { actual, expected } => write!(
285                f,
286                "frame canvas {}x{} does not match plan source {}x{}",
287                actual.width, actual.height, expected.width, expected.height
288            ),
289            Self::ImageResize(message) => write!(f, "RGBA resize failed: {message}"),
290        }
291    }
292}
293
294impl Error for ResizeError {}
295
296fn validate_size(which: &'static str, size: CanvasSize) -> Result<(), ResizeError> {
297    if size.width == 0 || size.height == 0 {
298        return Err(ResizeError::InvalidCanvasSize { which, size });
299    }
300    Ok(())
301}
302
303fn destination_size(source: CanvasSize, maximum: CanvasSize, allow_upscale: bool) -> CanvasSize {
304    let width_limited = u64::from(maximum.width) * u64::from(source.height)
305        <= u64::from(maximum.height) * u64::from(source.width);
306    let (numerator, denominator) = if width_limited {
307        (maximum.width, source.width)
308    } else {
309        (maximum.height, source.height)
310    };
311    if !allow_upscale && numerator >= denominator {
312        return source;
313    }
314    CanvasSize {
315        width: u32::try_from(
316            (u64::from(source.width) * u64::from(numerator) / u64::from(denominator)).max(1),
317        )
318        .expect("scaled width fits u32"),
319        height: u32::try_from(
320            (u64::from(source.height) * u64::from(numerator) / u64::from(denominator)).max(1),
321        )
322        .expect("scaled height fits u32"),
323    }
324}