fix(thumbnail): prevent upsampling
This commit is contained in:
@@ -719,6 +719,14 @@ impl ThumbnailService {
|
|||||||
/// Aspect-ratio-preserving target dimensions so the longest side equals
|
/// Aspect-ratio-preserving target dimensions so the longest side equals
|
||||||
/// `max_dim` (clamped to ≥1 to keep the SIMD resizer happy on extreme ratios).
|
/// `max_dim` (clamped to ≥1 to keep the SIMD resizer happy on extreme ratios).
|
||||||
fn fit_dims(src_w: u32, src_h: u32, max_dim: u32) -> (u32, u32) {
|
fn fit_dims(src_w: u32, src_h: u32, max_dim: u32) -> (u32, u32) {
|
||||||
|
// Never upsample. A sub-`max_dim` source (a favicon, a 1×1
|
||||||
|
// pixel, an avatar under the Icon size) is encoded at its
|
||||||
|
// original resolution — upscaling wastes bytes AND, on sources
|
||||||
|
// smaller than the convolution kernel's support radius,
|
||||||
|
// `fast_image_resize`'s Lanczos3/CatmullRom paths reject the
|
||||||
|
// resize outright, surfacing as `ImageError("...")` from
|
||||||
|
// `encode_thumbnail`.
|
||||||
|
let max_dim = max_dim.min(src_w.max(src_h));
|
||||||
if src_w > src_h {
|
if src_w > src_h {
|
||||||
let ratio = max_dim as f32 / src_w as f32;
|
let ratio = max_dim as f32 / src_w as f32;
|
||||||
(max_dim, ((src_h as f32 * ratio) as u32).max(1))
|
(max_dim, ((src_h as f32 * ratio) as u32).max(1))
|
||||||
@@ -761,16 +769,23 @@ impl ThumbnailService {
|
|||||||
filter
|
filter
|
||||||
};
|
};
|
||||||
|
|
||||||
let src = ImageRef::new(src_w, src_h, src_rgb, PixelType::U8x3)
|
// Skip Resizer when the target already matches the source. Two
|
||||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
// reasons: (1) `fit_dims` caps at source size so any src<=max_dim
|
||||||
let mut dst = Image::new(dst_w, dst_h, PixelType::U8x3);
|
// path lands here; (2) fast_image_resize's convolution kernels
|
||||||
let opts = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter));
|
// reject sources smaller than their support radius, so a 1×1
|
||||||
Resizer::new()
|
// pass-through would still fail even at src==dst.
|
||||||
.resize(&src, &mut dst, &opts)
|
let resized = if src_w == dst_w && src_h == dst_h {
|
||||||
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
src_rgb.to_vec()
|
||||||
|
} else {
|
||||||
// The resized RGB8 plane feeds either codec from one buffer.
|
let src = ImageRef::new(src_w, src_h, src_rgb, PixelType::U8x3)
|
||||||
let resized = dst.into_vec();
|
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
let mut dst = Image::new(dst_w, dst_h, PixelType::U8x3);
|
||||||
|
let opts = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(filter));
|
||||||
|
Resizer::new()
|
||||||
|
.resize(&src, &mut dst, &opts)
|
||||||
|
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
|
||||||
|
dst.into_vec()
|
||||||
|
};
|
||||||
match format {
|
match format {
|
||||||
ThumbnailFormat::Jpeg => {
|
ThumbnailFormat::Jpeg => {
|
||||||
let rgb = image::RgbImage::from_raw(dst_w, dst_h, resized).ok_or_else(|| {
|
let rgb = image::RgbImage::from_raw(dst_w, dst_h, resized).ok_or_else(|| {
|
||||||
@@ -1676,3 +1691,33 @@ pub struct ThumbnailStats {
|
|||||||
pub cache_size_bytes: usize,
|
pub cache_size_bytes: usize,
|
||||||
pub max_cache_bytes: usize,
|
pub max_cache_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||||
|
|
||||||
|
// A 1×1 transparent RGBA PNG — same fixture the E2E seed uploads.
|
||||||
|
// Verifies that `fit_dims` never upsamples and `encode_thumbnail`
|
||||||
|
// skips the convolution kernel for src==dst, both of which would
|
||||||
|
// otherwise surface as `ImageError` from every downstream size/
|
||||||
|
// format pair. Note: this base64 must be a valid PNG (correct chunk
|
||||||
|
// CRCs) — the `image` crate rejects malformed CRCs even where
|
||||||
|
// ImageMagick's `identify` is lenient. Regenerate with:
|
||||||
|
// python3 -c "…" | pbcopy # see tests/e2e/spa/helpers.ts for the snippet
|
||||||
|
const PIXEL_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_thumbnail_handles_1x1_source_for_every_size_and_format() {
|
||||||
|
let png = B64.decode(PIXEL_PNG_B64).unwrap();
|
||||||
|
for &size in ThumbnailSize::all() {
|
||||||
|
for &format in &[ThumbnailFormat::Jpeg, ThumbnailFormat::Webp] {
|
||||||
|
let out = ThumbnailService::render_thumbnail_from_data(&png, size, format)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!("render_thumbnail_from_data({size:?}, {format:?}) failed: {e}")
|
||||||
|
});
|
||||||
|
assert!(!out.is_empty(), "empty output for {size:?} {format:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user