use crate::decoder::{Pixels, RawImage};
use crate::error::Error;
use crate::logging::{img_debug, img_info};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OutputResolution {
Original,
Width2560,
Width1080,
Custom(u32),
}
impl OutputResolution {
#[must_use]
pub(crate) fn max_width(self) -> Option<u32> {
match self {
Self::Original | Self::Custom(0) => None,
Self::Width2560 => Some(2560),
Self::Width1080 => Some(1080),
Self::Custom(w) => Some(w),
}
}
}
pub(crate) fn resize_raw_image(
raw: &RawImage,
resolution: OutputResolution,
) -> Result<RawImage, Error> {
let Some(target_width) = resolution.max_width() else {
return Ok(raw.clone());
};
let &RawImage {
width,
height,
ref pixels,
} = raw;
if width <= target_width {
img_debug!(
"resize: {}×{} is already within {}px target — skipping",
width,
height,
target_width
);
return Ok(RawImage {
width,
height,
pixels: pixels.clone(),
});
}
let new_width = target_width;
let height_u64 = u64::from(height)
.saturating_mul(u64::from(new_width))
.saturating_add(u64::from(width) / 2)
/ u64::from(width);
let new_height = u32::try_from(height_u64)
.map_err(|_| {
Error::Internal(format!(
"resize calculation overflow: {width}×{height} → width {target_width}"
))
})?
.max(1);
img_info!(
"resize: {}×{} → {}×{} ({} target width, Lanczos3)",
width,
height,
new_width,
new_height,
target_width
);
match pixels {
Pixels::Rgba8(data) => {
let buf =
image::RgbaImage::from_raw(width, height, data.to_vec()).ok_or_else(|| {
Error::Internal(format!(
"RGBA8 pixel buffer size does not match declared dimensions {width}×{height}; \
this is a bug — please report it"
))
})?;
let resized = image::imageops::resize(
&buf,
new_width,
new_height,
image::imageops::FilterType::Lanczos3,
);
Ok(RawImage {
width: new_width,
height: new_height,
pixels: Pixels::Rgba8(Arc::from(resized.into_raw())),
})
}
Pixels::Rgba16(data) => {
use image::{ImageBuffer, Rgba};
let buf: ImageBuffer<Rgba<u16>, Vec<u16>> =
ImageBuffer::from_raw(width, height, data.to_vec())
.ok_or_else(|| Error::Internal(format!(
"RGBA16 pixel buffer size does not match declared dimensions {width}×{height}; \
this is a bug — please report it"
)))?;
let resized = image::imageops::resize(
&buf,
new_width,
new_height,
image::imageops::FilterType::Lanczos3,
);
Ok(RawImage {
width: new_width,
height: new_height,
pixels: Pixels::Rgba16(Arc::from(resized.into_raw())),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decoder::Pixels;
fn solid_rgba8(width: u32, height: u32) -> RawImage {
let pixel = [255u8, 128, 64, 255];
RawImage {
width,
height,
pixels: Pixels::Rgba8(Arc::from(pixel.repeat(width as usize * height as usize))),
}
}
fn solid_rgba16(width: u32, height: u32) -> RawImage {
let pixel = [32768u16, 16384, 8192, 65535];
RawImage {
width,
height,
pixels: Pixels::Rgba16(Arc::from(pixel.repeat(width as usize * height as usize))),
}
}
#[test]
fn original_is_unchanged() {
let raw = solid_rgba8(4000, 3000);
let out = resize_raw_image(&raw, OutputResolution::Original).unwrap();
assert_eq!(out.width, 4000);
assert_eq!(out.height, 3000);
}
#[test]
fn no_op_resize_shares_arc_allocation() {
let raw = solid_rgba8(640, 480);
let out_original = resize_raw_image(&raw, OutputResolution::Original).unwrap();
if let (Pixels::Rgba8(src), Pixels::Rgba8(dst)) = (&raw.pixels, &out_original.pixels) {
assert!(Arc::ptr_eq(src, dst), "Original path must share the Arc");
} else {
panic!("expected Rgba8 pixels");
}
let out_2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
if let (Pixels::Rgba8(src), Pixels::Rgba8(dst)) = (&raw.pixels, &out_2560.pixels) {
assert!(Arc::ptr_eq(src, dst), "No-op resize must share the Arc");
} else {
panic!("expected Rgba8 pixels");
}
}
#[test]
fn no_upscale_when_already_small() {
let raw = solid_rgba8(640, 480);
let out2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out2560.width, 640);
assert_eq!(out2560.height, 480);
let out1080 = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
assert_eq!(out1080.width, 640);
assert_eq!(out1080.height, 480);
}
#[test]
fn downscales_to_2560() {
let raw = solid_rgba8(5120, 2880); let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out.width, 2560);
assert_eq!(out.height, 1440); }
#[test]
fn downscales_to_1080() {
let raw = solid_rgba8(1920, 1080); let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
assert_eq!(out.width, 1080);
assert_eq!(out.height, 608);
}
#[test]
fn aspect_ratio_preserved_portrait() {
let raw = solid_rgba8(4320, 6480);
let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out.width, 2560);
assert_eq!(out.height, 3840);
}
#[test]
fn exact_target_width_is_not_resized() {
let raw = solid_rgba8(2560, 1440);
let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out.width, 2560);
assert_eq!(out.height, 1440);
}
#[test]
fn custom_resolution_downscales() {
let raw = solid_rgba8(1920, 1080);
let out = resize_raw_image(&raw, OutputResolution::Custom(720)).unwrap();
assert_eq!(out.width, 720);
}
#[test]
fn custom_resolution_zero_is_original() {
let raw = solid_rgba8(1920, 1080);
let out = resize_raw_image(&raw, OutputResolution::Custom(0)).unwrap();
assert_eq!(out.width, 1920);
assert_eq!(out.height, 1080);
}
#[test]
fn custom_resolution_no_upscale() {
let raw = solid_rgba8(640, 480);
let out = resize_raw_image(&raw, OutputResolution::Custom(1280)).unwrap();
assert_eq!(out.width, 640);
assert_eq!(out.height, 480);
}
#[test]
fn output_resolution_max_width() {
assert_eq!(OutputResolution::Original.max_width(), None);
assert_eq!(OutputResolution::Width2560.max_width(), Some(2560));
assert_eq!(OutputResolution::Width1080.max_width(), Some(1080));
assert_eq!(OutputResolution::Custom(720).max_width(), Some(720));
assert_eq!(OutputResolution::Custom(3840).max_width(), Some(3840));
assert_eq!(OutputResolution::Custom(0).max_width(), None);
}
#[test]
fn rgba16_original_is_unchanged() {
let raw = solid_rgba16(4000, 3000);
let out = resize_raw_image(&raw, OutputResolution::Original).unwrap();
assert_eq!(out.width, 4000);
assert_eq!(out.height, 3000);
assert!(matches!(out.pixels, Pixels::Rgba16(_)));
}
#[test]
fn rgba16_downscales_to_2560() {
let raw = solid_rgba16(5120, 2880);
let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out.width, 2560);
assert_eq!(out.height, 1440);
assert!(matches!(out.pixels, Pixels::Rgba16(_)));
}
#[test]
fn rgba16_no_upscale() {
let raw = solid_rgba16(640, 480);
let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
assert_eq!(out.width, 640);
assert_eq!(out.height, 480);
}
#[test]
fn mismatched_rgba8_buffer_returns_internal_error() {
let raw = RawImage {
width: 2000,
height: 100,
pixels: Pixels::Rgba8(Arc::from([255u8, 0, 0, 255].as_slice())),
};
let err = resize_raw_image(&raw, OutputResolution::Width1080).unwrap_err();
assert!(
matches!(err, Error::Internal(_)),
"expected Error::Internal, got {err:?}"
);
}
#[test]
fn mismatched_rgba16_buffer_returns_internal_error() {
let raw = RawImage {
width: 2000,
height: 100,
pixels: Pixels::Rgba16(Arc::from([65535u16, 0, 0, 65535].as_slice())),
};
let err = resize_raw_image(&raw, OutputResolution::Width1080).unwrap_err();
assert!(
matches!(err, Error::Internal(_)),
"expected Error::Internal, got {err:?}"
);
}
#[test]
fn very_wide_single_row() {
let raw = solid_rgba8(2000, 1);
let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
assert_eq!(out.width, 1080);
assert_eq!(out.height, 1); }
#[test]
fn single_pixel_image() {
let raw = solid_rgba8(1, 1);
let out2560 = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out2560.width, 1);
let out1080 = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
assert_eq!(out1080.width, 1);
}
#[test]
fn saturating_arithmetic_does_not_truncate_tall_image() {
let raw = solid_rgba8(4096, 16384);
let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
assert_eq!(out.width, 2560);
assert_eq!(out.height, 10240);
}
}