From f4ee01eccaa0887b8ca327eb1f52abc1b442862e Mon Sep 17 00:00:00 2001 From: Hocuri Date: Sun, 25 Jan 2026 18:58:09 +0100 Subject: [PATCH] fix: Don't upscale images and test that image resolution isn't changed unnecessarily (#7769) This adds a test for https://github.com/chatmail/core/pull/7760. Also, it fixes another bug which I uncovered with the test: If the resolution was already lower than the max resolution, then the image was upscaled to match the max resolution. --------- Co-authored-by: 72374 <250991390+72374@users.noreply.github.com> --- src/blob.rs | 38 +++++++++++------- src/blob/blob_tests.rs | 53 ++++++++++++++++++++++++++ test-data/image/screenshot120x120.jpg | Bin 0 -> 28911 bytes 3 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 test-data/image/screenshot120x120.jpg diff --git a/src/blob.rs b/src/blob.rs index 8d6cad744..bd426a35c 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -1,5 +1,6 @@ //! # Blob directory management. +use std::cmp::max; use std::io::{Cursor, Seek}; use std::iter::FusedIterator; use std::mem; @@ -255,7 +256,7 @@ impl<'a> BlobObject<'a> { /// Recode image to avatar size. pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> { - let (img_wh, max_bytes) = + let (max_wh, max_bytes) = match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?) .unwrap_or_default() { @@ -272,7 +273,7 @@ impl<'a> BlobObject<'a> { let is_avatar = true; self.check_or_recode_to_size( context, None, // The name of an avatar doesn't matter - viewtype, img_wh, max_bytes, is_avatar, + viewtype, max_wh, max_bytes, is_avatar, )?; Ok(()) @@ -293,7 +294,7 @@ impl<'a> BlobObject<'a> { name: Option, viewtype: &mut Viewtype, ) -> Result { - let (img_wh, max_bytes) = + let (max_wh, max_bytes) = match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?) .unwrap_or_default() { @@ -304,13 +305,15 @@ impl<'a> BlobObject<'a> { MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES), }; let is_avatar = false; - self.check_or_recode_to_size(context, name, viewtype, img_wh, max_bytes, is_avatar) + self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar) } - /// Checks or recodes the image so that it fits into limits on width/height and byte size. + /// Checks or recodes the image so that it fits into limits on width/height and/or byte size. /// - /// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `img_wh` and proceeds - /// with the result without rechecking. + /// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds + /// with the result (even if `max_bytes` is still exceeded). + /// + /// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`. /// /// This modifies the blob object in-place. /// @@ -323,7 +326,7 @@ impl<'a> BlobObject<'a> { context: &Context, name: Option, viewtype: &mut Viewtype, - mut img_wh: u32, + max_wh: u32, max_bytes: usize, is_avatar: bool, ) -> Result { @@ -385,7 +388,14 @@ impl<'a> BlobObject<'a> { _ => img, }; - let exceeds_wh = img.width() > img_wh || img.height() > img_wh; + // max_wh is the maximum image width and height, i.e. the resolution-limit. + // target_wh target-resolution for resizing the image. + let exceeds_wh = img.width() > max_wh || img.height() > max_wh; + let mut target_wh = if exceeds_wh { + max_wh + } else { + max(img.width(), img.height()) + }; let exceeds_max_bytes = nr_bytes > max_bytes as u64; let jpeg_quality = 75; @@ -438,9 +448,9 @@ impl<'a> BlobObject<'a> { // usually has less pixels by cropping, UI that needs to wait anyways, // and also benefits from slightly better (5%) encoding of Triangle-filtered images. let new_img = if is_avatar { - img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle) + img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle) } else { - img.thumbnail(img_wh, img_wh) + img.thumbnail(target_wh, target_wh) }; if encoded_img_exceeds_bytes( @@ -451,19 +461,19 @@ impl<'a> BlobObject<'a> { &mut encoded, )? && is_avatar { - if img_wh < 20 { + if target_wh < 20 { return Err(format_err!( "Failed to scale image to below {max_bytes}B.", )); } - img_wh = img_wh * 2 / 3; + target_wh = target_wh * 2 / 3; } else { info!( context, "Final scaled-down image size: {}B ({}px).", encoded.len(), - img_wh + target_wh ); break; } diff --git a/src/blob/blob_tests.rs b/src/blob/blob_tests.rs index e93d8f7f5..ed5a2b001 100644 --- a/src/blob/blob_tests.rs +++ b/src/blob/blob_tests.rs @@ -798,3 +798,56 @@ async fn test_create_and_deduplicate_from_bytes() -> Result<()> { Ok(()) } + +/// Tests that an image that already fits into the width limit, +/// but not the bytes limit, +/// is compressed without changing the resolution. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_without_downscaling() -> Result<()> { + let t = &TestContext::new().await; + + let image = include_bytes!("../../test-data/image/screenshot120x120.jpg"); + const { assert!(120 < constants::WORSE_AVATAR_SIZE) }; + + for is_avatar in [true, false] { + let mut blob = + BlobObject::create_and_deduplicate_from_bytes(t, image, "image.jpg").unwrap(); + let image_path = blob.to_abs_path(); + check_image_size(&image_path, 120, 120); + + assert!( + fs::metadata(&image_path).await.unwrap().len() > constants::WORSE_AVATAR_BYTES as u64 + ); + + // Repeat the check, because a second call to `check_or_recode_to_size()` + // is not supposed to change anything: + let mut imgs = vec![]; + for _ in 0..2 { + let mut viewtype = Viewtype::Image; + let new_name = blob.check_or_recode_to_size( + t, + Some("image.jpg".to_string()), + &mut viewtype, + constants::WORSE_AVATAR_SIZE, + constants::WORSE_AVATAR_BYTES, + is_avatar, + )?; + let image_path = blob.to_abs_path(); + assert_eq!(new_name, "image.jpg"); // The name shall not have changed + assert_eq!(viewtype, Viewtype::Image); // The viewtype shall not have changed + let img = check_image_size(&image_path, 120, 120); // The resolution shall not have changed + imgs.push(img); + + let new_image_bytes = fs::metadata(&image_path).await.unwrap().len(); + assert!( + new_image_bytes < constants::WORSE_AVATAR_BYTES as u64, + "The new image size, {new_image_bytes}, should be lower than {}, is_avatar={is_avatar}", + constants::WORSE_AVATAR_BYTES + ); + } + + assert_eq!(imgs[0], imgs[1]); + } + + Ok(()) +} diff --git a/test-data/image/screenshot120x120.jpg b/test-data/image/screenshot120x120.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dc3ea22929fbeeff8592e6a2f55872baf095d690 GIT binary patch literal 28911 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