1use crate::decoder::{Pixels, RawImage};
18use crate::error::Error;
19use crate::logging::{img_debug, img_info};
20use std::sync::Arc;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum OutputResolution {
55 Original,
57 Width2560,
60 Width1080,
63 Custom(u32),
73}
74
75impl OutputResolution {
76 #[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
91pub(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 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 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 #[test]
241 fn no_op_resize_shares_arc_allocation() {
242 let raw = solid_rgba8(640, 480);
243
244 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 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 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); let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
278 assert_eq!(out.width, 2560);
279 assert_eq!(out.height, 1440); }
281
282 #[test]
283 fn downscales_to_1080() {
284 let raw = solid_rgba8(1920, 1080); let out = resize_raw_image(&raw, OutputResolution::Width1080).unwrap();
286 assert_eq!(out.width, 1080);
287 assert_eq!(out.height, 608);
289 }
290
291 #[test]
292 fn aspect_ratio_preserved_portrait() {
293 let raw = solid_rgba8(4320, 6480);
295 let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
296 assert_eq!(out.width, 2560);
297 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 #[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 #[test]
373 fn mismatched_rgba8_buffer_returns_internal_error() {
374 let raw = RawImage {
379 width: 2000,
380 height: 100,
381 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 #[test]
408 fn very_wide_single_row() {
409 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); }
415
416 #[test]
417 fn single_pixel_image() {
418 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 let raw = solid_rgba8(4096, 16384);
433 let out = resize_raw_image(&raw, OutputResolution::Width2560).unwrap();
434 assert_eq!(out.width, 2560);
435 assert_eq!(out.height, 10240);
437 }
438}