From feed34cb0b17562c8f8c70b0cf4779b8a8166eb5 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sun, 26 Jul 2026 00:13:35 +0800 Subject: [PATCH] perf: improve perceptual image compression --- docs/api.md | 3 +- src/services/compress.rs | 797 ++++++++++++++++++++++++++++++--------- 2 files changed, 631 insertions(+), 169 deletions(-) diff --git a/docs/api.md b/docs/api.md index 58bd49b..24c1325 100644 --- a/docs/api.md +++ b/docs/api.md @@ -280,7 +280,8 @@ Idempotency-Key: # 建议 处理约束: - 动画 GIF/APNG/WebP/AVIF 不会静默截取首帧,而是返回 `400 UNSUPPORTED_FORMAT`。 - EXIF 方向会先应用到像素,再移除或归一化方向标记;透明图片转 JPEG 时以白色合成背景。 -- 目标体积搜索优先保持原分辨率,必要时在清晰度保护范围内缩放;仍无法达到时返回 `400 INVALID_REQUEST`,不会把超出目标的文件作为成功结果。 +- 目标体积搜索先尝试原尺寸最高质量;无法满足时固定感知质量下限并搜索可用的最大分辨率,避免以极低质量强行保留像素尺寸。显式传入 `max_width` 或 `max_height` 时不会再次自动缩放,只在指定尺寸内调节质量。 +- WebP 优先使用 libwebp 原生目标码率控制并预留安全余量,仍超过硬上限时自动回退到通用搜索;无法在清晰度保护范围内达到目标时返回 `400 INVALID_REQUEST`。 - `compression_rate=100` 只有在同格式且未指定缩放时属于免计量原样请求;格式转换或缩放后若体积变小,正常计 1 次。 响应: diff --git a/src/services/compress.rs b/src/services/compress.rs index 630c982..c43d42a 100644 --- a/src/services/compress.rs +++ b/src/services/compress.rs @@ -4,7 +4,7 @@ use crate::state::AppState; use image::codecs::bmp::BmpEncoder; use image::codecs::gif::{GifDecoder, GifEncoder}; use image::codecs::ico::IcoEncoder; -use image::codecs::png::{PngDecoder, PngEncoder}; +use image::codecs::png::PngDecoder; use image::codecs::tiff::TiffEncoder; use image::codecs::webp::WebPDecoder; use image::metadata::Orientation; @@ -30,6 +30,14 @@ const COMPRESSION_TIMEOUT_GRACE: Duration = Duration::from_secs(1); const JPEG_TARGET_MIN_QUALITY: u8 = 25; const WEBP_TARGET_MIN_QUALITY: u8 = 30; const AVIF_TARGET_MIN_QUALITY: u8 = 38; +const JPEG_PERCEPTUAL_QUALITY: u8 = 72; +const WEBP_PERCEPTUAL_QUALITY: u8 = 70; +const AVIF_PERCEPTUAL_QUALITY: u8 = 55; +const JPEG_TARGET_MAX_QUALITY: u8 = 90; +const WEBP_TARGET_MAX_QUALITY: u8 = 92; +const AVIF_TARGET_MAX_QUALITY: u8 = 90; +const AVIF_ENCODER_SPEED: u8 = 5; +const WEBP_TARGET_SAFETY_PERCENT: u64 = 97; const METADATA_TARGET_OVERHEAD: u64 = 1024; #[derive(Clone)] @@ -515,6 +523,7 @@ fn compress_image_bytes_sync( .transpose()?; let strength_rate = strength_from_rate(retention_rate); + let allow_target_resize = max_width.is_none() && max_height.is_none(); let mut transformed = orientation != Orientation::NoTransforms; let mut output = if format_in == ImageFmt::Png @@ -544,15 +553,15 @@ fn compress_image_bytes_sync( match format_out { ImageFmt::Png => encode_png(image, strength_rate, preserve_metadata)?, ImageFmt::Jpeg => match encoding_target_size { - Some(target) => encode_jpeg_target(image, target, deadline)?, + Some(target) => encode_jpeg_target(image, target, allow_target_resize, deadline)?, None => encode_jpeg(image, strength_rate)?, }, ImageFmt::Webp => match encoding_target_size { - Some(target) => encode_webp_target(image, target, deadline)?, + Some(target) => encode_webp_target(image, target, allow_target_resize, deadline)?, None => encode_webp(image, strength_rate)?, }, ImageFmt::Avif => match encoding_target_size { - Some(target) => encode_avif_target(image, target, deadline)?, + Some(target) => encode_avif_target(image, target, allow_target_resize, deadline)?, None => encode_avif(image, strength_rate)?, }, ImageFmt::Gif => encode_gif(image, strength_rate)?, @@ -750,23 +759,35 @@ fn fit_within(w: u32, h: u32, max_width: Option, max_height: Option) - } fn encode_png(image: DynamicImage, rate: u8, preserve_metadata: bool) -> Result, AppError> { - let rgba = image.to_rgba8(); - let (w, h) = rgba.dimensions(); - let mut out = Vec::new(); - - let encoder = PngEncoder::new(&mut out); - encoder - .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) - .map_err(|err| { - AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err) - })?; + let (width, height) = image.dimensions(); + let (data, color_type) = match image { + DynamicImage::ImageLuma8(image) => ( + image.into_raw(), + oxipng::ColorType::Grayscale { + transparent_shade: None, + }, + ), + DynamicImage::ImageLumaA8(image) => (image.into_raw(), oxipng::ColorType::GrayscaleAlpha), + DynamicImage::ImageRgb8(image) => ( + image.into_raw(), + oxipng::ColorType::RGB { + transparent_color: None, + }, + ), + DynamicImage::ImageRgba8(image) => (image.into_raw(), oxipng::ColorType::RGBA), + image => (image.to_rgba8().into_raw(), oxipng::ColorType::RGBA), + }; let preset = png_preset_from_rate(rate); let mut opts = oxipng::Options::from_preset(preset); if !preserve_metadata { opts.strip = StripChunks::Safe; } - oxipng::optimize_from_memory(&out, &opts) + let raw = oxipng::RawImage::new(width, height, color_type, oxipng::BitDepth::Eight, data) + .map_err(|err| { + AppError::new(ErrorCode::CompressionFailed, "PNG 原始像素无效").with_source(err) + })?; + raw.create_optimized_png(&opts) .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 优化失败").with_source(err)) } @@ -808,6 +829,7 @@ fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, A let mut encoder = jpeg_encoder::Encoder::new(&mut out, quality); encoder.set_optimized_huffman_tables(true); encoder.set_progressive(true); + encoder.set_sampling_factor(jpeg_encoder::SamplingFactor::F_2_2); encoder .encode(raw, width, height, jpeg_encoder::ColorType::Rgb) .map_err(|err| { @@ -817,9 +839,8 @@ fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, A } fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { - let rgba = image.to_rgba8(); - let (w, h) = rgba.dimensions(); - let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h); + let pixels = prepare_target_pixels(&image); + let encoder = webp_encoder(&pixels); let bytes = if rate <= 10 { encoder.encode_lossless() @@ -830,49 +851,59 @@ fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { Ok(bytes.to_vec()) } -fn encode_webp_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { - let encoder = webp::Encoder::from_rgba(raw, w, h); +fn encode_webp_pixels(pixels: &TargetPixels, quality: u8) -> Result, AppError> { + let encoder = webp_encoder(pixels); Ok(encoder.encode(quality as f32).to_vec()) } fn encode_avif(image: DynamicImage, rate: u8) -> Result, AppError> { - let rgba = image.to_rgba8(); - let (w, h) = rgba.dimensions(); - + let pixels = prepare_target_pixels(&image); let quality = avif_quality_from_rate(rate); - - let raw = rgba.into_raw(); - let pixels = raw.as_rgba(); - let img = ravif::Img::new(pixels, w as usize, h as usize); - - let encoder = ravif::Encoder::new().with_quality(quality); - let encoded = encoder.encode_rgba(img).map_err(|err| { - AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err) - })?; - - Ok(encoded.avif_file) + encode_avif_pixels(&pixels, quality as u8) } -fn encode_avif_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { - let pixels = raw.as_rgba(); - let img = ravif::Img::new(pixels, w as usize, h as usize); - let encoder = ravif::Encoder::new().with_quality(quality as f32); - let encoded = encoder.encode_rgba(img).map_err(|err| { - AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err) - })?; +fn encode_avif_pixels(pixels: &TargetPixels, quality: u8) -> Result, AppError> { + let encoder = ravif::Encoder::new() + .with_quality(quality as f32) + .with_speed(AVIF_ENCODER_SPEED) + .with_num_threads(Some(1)); + let encoded = match pixels.layout { + TargetPixelLayout::Rgb => { + let image = ravif::Img::new( + pixels.bytes.as_rgb(), + pixels.width as usize, + pixels.height as usize, + ); + encoder.encode_rgb(image) + } + TargetPixelLayout::Rgba => { + let image = ravif::Img::new( + pixels.bytes.as_rgba(), + pixels.width as usize, + pixels.height as usize, + ); + encoder.encode_rgba(image) + } + } + .map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?; Ok(encoded.avif_file) } fn encode_jpeg_target( image: DynamicImage, target_size: u64, + allow_resize: bool, deadline: &CompressionDeadline, ) -> Result, AppError> { encode_with_auto_resize( image, - target_size, - JPEG_TARGET_MIN_QUALITY, - 95, + TargetSearchConfig { + target_size, + absolute_min_quality: JPEG_TARGET_MIN_QUALITY, + perceptual_quality: JPEG_PERCEPTUAL_QUALITY, + max_quality: JPEG_TARGET_MAX_QUALITY, + allow_resize, + }, deadline, |img| { let rgb = jpeg_rgb(img); @@ -881,130 +912,274 @@ fn encode_jpeg_target( bytes: rgb.into_raw(), width: w, height: h, + layout: TargetPixelLayout::Rgb, } }, - encode_jpeg_raw, + |pixels, quality| encode_jpeg_raw(&pixels.bytes, pixels.width, pixels.height, quality), ) } fn encode_webp_target( image: DynamicImage, target_size: u64, + allow_resize: bool, deadline: &CompressionDeadline, ) -> Result, AppError> { + deadline.check()?; + let pixels = prepare_target_pixels(&image); + let native_min_quality = if allow_resize { + WEBP_PERCEPTUAL_QUALITY + } else { + WEBP_TARGET_MIN_QUALITY + }; + let native_result = encode_webp_native_target(&pixels, target_size, native_min_quality); + deadline.check()?; + match native_result { + Ok(bytes) if bytes.len() as u64 <= target_size => return Ok(bytes), + Ok(_) => {} + Err(err) => tracing::debug!(error = %err, "WebP 原生目标体积编码失败,回退到外层搜索"), + } + encode_with_auto_resize( image, - target_size, - WEBP_TARGET_MIN_QUALITY, - 95, - deadline, - |img| { - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - TargetPixels { - bytes: rgba.into_raw(), - width: w, - height: h, - } + TargetSearchConfig { + target_size, + absolute_min_quality: WEBP_TARGET_MIN_QUALITY, + perceptual_quality: WEBP_PERCEPTUAL_QUALITY, + max_quality: WEBP_TARGET_MAX_QUALITY, + allow_resize, }, - encode_webp_raw, + deadline, + prepare_target_pixels, + encode_webp_pixels, ) } +fn encode_webp_native_target( + pixels: &TargetPixels, + target_size: u64, + min_quality: u8, +) -> Result, AppError> { + let mut config = webp::WebPConfig::new() + .map_err(|_| AppError::new(ErrorCode::CompressionFailed, "初始化 WebP 目标体积配置失败"))?; + let biased_target = target_size + .saturating_mul(WEBP_TARGET_SAFETY_PERCENT) + .div_ceil(100) + .clamp(1, i32::MAX as u64); + config.lossless = 0; + config.quality = WEBP_TARGET_MAX_QUALITY as f32; + config.method = 5; + config.target_size = biased_target as i32; + config.pass = 6; + config.thread_level = 0; + config.alpha_compression = 1; + config.qmin = i32::from(min_quality); + config.qmax = i32::from(WEBP_TARGET_MAX_QUALITY); + + webp_encoder(pixels) + .encode_advanced(&config) + .map(|bytes| bytes.to_vec()) + .map_err(|err| { + AppError::new( + ErrorCode::CompressionFailed, + format!("WebP 目标体积编码失败: {err:?}"), + ) + }) +} + fn encode_avif_target( image: DynamicImage, target_size: u64, + allow_resize: bool, deadline: &CompressionDeadline, ) -> Result, AppError> { encode_with_auto_resize( image, - target_size, - AVIF_TARGET_MIN_QUALITY, - 95, - deadline, - |img| { - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - TargetPixels { - bytes: rgba.into_raw(), - width: w, - height: h, - } + TargetSearchConfig { + target_size, + absolute_min_quality: AVIF_TARGET_MIN_QUALITY, + perceptual_quality: AVIF_PERCEPTUAL_QUALITY, + max_quality: AVIF_TARGET_MAX_QUALITY, + allow_resize, }, - encode_avif_raw, + deadline, + prepare_target_pixels, + encode_avif_pixels, ) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TargetPixelLayout { + Rgb, + Rgba, +} + struct TargetPixels { bytes: Vec, width: u32, height: u32, + layout: TargetPixelLayout, } -/// 目标体积压缩(质量优先 + 有边界的降尺寸) +#[derive(Clone, Copy)] +struct TargetSearchConfig { + target_size: u64, + absolute_min_quality: u8, + perceptual_quality: u8, + max_quality: u8, + allow_resize: bool, +} + +fn prepare_target_pixels(image: &DynamicImage) -> TargetPixels { + if !image.color().has_alpha() { + let rgb = image.to_rgb8(); + let (width, height) = rgb.dimensions(); + return TargetPixels { + bytes: rgb.into_raw(), + width, + height, + layout: TargetPixelLayout::Rgb, + }; + } + + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); + let rgba = rgba.into_raw(); + if rgba.chunks_exact(4).any(|pixel| pixel[3] != 255) { + return TargetPixels { + bytes: rgba, + width, + height, + layout: TargetPixelLayout::Rgba, + }; + } + + let mut rgb = Vec::with_capacity(width as usize * height as usize * 3); + for pixel in rgba.chunks_exact(4) { + rgb.extend_from_slice(&pixel[..3]); + } + TargetPixels { + bytes: rgb, + width, + height, + layout: TargetPixelLayout::Rgb, + } +} + +fn webp_encoder(pixels: &TargetPixels) -> webp::Encoder<'_> { + match pixels.layout { + TargetPixelLayout::Rgb => { + webp::Encoder::from_rgb(&pixels.bytes, pixels.width, pixels.height) + } + TargetPixelLayout::Rgba => { + webp::Encoder::from_rgba(&pixels.bytes, pixels.width, pixels.height) + } + } +} + +/// 目标体积压缩(感知质量优先 + 有边界的降尺寸)。 /// -/// 策略: -/// 1) 先在原图尺寸内二分质量,尽量保持清晰度; -/// 2) 原尺寸失败时先验证最低允许尺寸,快速识别不可达目标; -/// 3) 最低尺寸可达时,二分查找满足目标的最高分辨率; -/// 4) 严格限制最小缩放比例,避免“过度糊图”。 +/// 原尺寸最高质量不满足目标时,先固定感知质量下限并搜索最大分辨率。 +/// 只有最小允许尺寸仍超标时,才继续降低质量。显式尺寸约束会关闭 +/// 自动降采样,以保持调用方要求的输出尺寸。 fn encode_with_auto_resize( image: DynamicImage, - target_size: u64, - min_q: u8, - max_q: u8, + config: TargetSearchConfig, deadline: &CompressionDeadline, mut prepare_fn: P, mut encode_fn: E, ) -> Result, AppError> where P: FnMut(&DynamicImage) -> TargetPixels, - E: FnMut(&[u8], u32, u32, u8) -> Result, AppError>, + E: FnMut(&TargetPixels, u8) -> Result, AppError>, { deadline.check()?; let (orig_w, orig_h) = image.dimensions(); let long_edge = orig_w.max(orig_h); let long_edge_floor = TARGET_MIN_LONG_EDGE.min(long_edge) as f64 / long_edge as f64; let min_scale = TARGET_MIN_SCALE.max(long_edge_floor).min(1.0); + let absolute_min = config.absolute_min_quality.min(config.max_quality); + let perceptual = config + .perceptual_quality + .clamp(absolute_min, config.max_quality); - let (full_result, full_dimensions) = encode_target_at_scale( - &image, - 1.0, - (min_q, max_q), - target_size, - deadline, - &mut prepare_fn, - &mut encode_fn, - )?; - if full_result.len() as u64 <= target_size { - return Ok(full_result); + let (full_pixels, full_dimensions) = + prepare_target_at_scale(&image, 1.0, deadline, &mut prepare_fn)?; + let full_max = encode_fn(&full_pixels, config.max_quality)?; + if full_max.len() as u64 <= config.target_size { + return Ok(full_max); } - if min_scale >= 1.0 { - return Err(target_unreachable_error( - target_size, - full_result.len() as u64, - )); + if !config.allow_resize { + return search_below_upper_bound( + &full_pixels, + absolute_min, + QualityCandidate { + quality: config.max_quality, + bytes: full_max, + }, + config.target_size, + deadline, + &mut encode_fn, + ); } deadline.check()?; - let (min_result, min_dimensions) = encode_target_at_scale( - &image, - min_scale, - (min_q, max_q), - target_size, - deadline, - &mut prepare_fn, - &mut encode_fn, - )?; - if min_result.len() as u64 > target_size { - return Err(target_unreachable_error( - target_size, - min_result.len() as u64, - )); + let full_floor = if perceptual == config.max_quality { + full_max + } else { + let floor = encode_fn(&full_pixels, perceptual)?; + if floor.len() as u64 <= config.target_size { + return refine_target_quality( + &full_pixels, + QualityCandidate { + quality: perceptual, + bytes: floor, + }, + QualityCandidate { + quality: config.max_quality, + bytes: full_max, + }, + config.target_size, + deadline, + &mut encode_fn, + ); + } + floor + }; + + if min_scale >= 1.0 { + return search_below_upper_bound( + &full_pixels, + absolute_min, + QualityCandidate { + quality: perceptual, + bytes: full_floor, + }, + config.target_size, + deadline, + &mut encode_fn, + ); } - let mut best_under = min_result; + let (min_pixels, min_dimensions) = + prepare_target_at_scale(&image, min_scale, deadline, &mut prepare_fn)?; + let min_floor = encode_fn(&min_pixels, perceptual)?; + if min_floor.len() as u64 > config.target_size { + return search_below_upper_bound( + &min_pixels, + absolute_min, + QualityCandidate { + quality: perceptual, + bytes: min_floor, + }, + config.target_size, + deadline, + &mut encode_fn, + ); + } + + let mut best_under = min_floor; let mut under_scale = min_scale; let mut under_dimensions = min_dimensions; let mut over_scale = 1.0; @@ -1026,16 +1201,10 @@ where continue; } - let (candidate_result, _) = encode_target_at_scale( - &image, - candidate_scale, - (min_q, max_q), - target_size, - deadline, - &mut prepare_fn, - &mut encode_fn, - )?; - if candidate_result.len() as u64 <= target_size { + let (candidate_pixels, _) = + prepare_target_at_scale(&image, candidate_scale, deadline, &mut prepare_fn)?; + let candidate_result = encode_fn(&candidate_pixels, perceptual)?; + if candidate_result.len() as u64 <= config.target_size { best_under = candidate_result; under_scale = candidate_scale; under_dimensions = candidate_dimensions; @@ -1048,6 +1217,51 @@ where Ok(best_under) } +struct QualityCandidate { + quality: u8, + bytes: Vec, +} + +fn search_below_upper_bound( + pixels: &TargetPixels, + min_quality: u8, + upper: QualityCandidate, + target_size: u64, + deadline: &CompressionDeadline, + encode_fn: &mut E, +) -> Result, AppError> +where + E: FnMut(&TargetPixels, u8) -> Result, AppError>, +{ + if min_quality >= upper.quality { + return Err(target_unreachable_error( + target_size, + upper.bytes.len() as u64, + )); + } + + deadline.check()?; + let min_bytes = encode_fn(pixels, min_quality)?; + if min_bytes.len() as u64 > target_size { + return Err(target_unreachable_error( + target_size, + min_bytes.len() as u64, + )); + } + + refine_target_quality( + pixels, + QualityCandidate { + quality: min_quality, + bytes: min_bytes, + }, + upper, + target_size, + deadline, + encode_fn, + ) +} + fn scale_search_attempts(width: u32, height: u32) -> usize { match u64::from(width).saturating_mul(u64::from(height)) { 12_000_000.. => 3, @@ -1063,18 +1277,14 @@ fn dimensions_at_scale(orig_w: u32, orig_h: u32, scale: f64) -> (u32, u32) { ) } -fn encode_target_at_scale( +fn prepare_target_at_scale

( image: &DynamicImage, scale: f64, - quality_range: (u8, u8), - target_size: u64, deadline: &CompressionDeadline, prepare_fn: &mut P, - encode_fn: &mut E, -) -> Result<(Vec, (u32, u32)), AppError> +) -> Result<(TargetPixels, (u32, u32)), AppError> where P: FnMut(&DynamicImage) -> TargetPixels, - E: FnMut(&[u8], u32, u32, u8) -> Result, AppError>, { deadline.check()?; let (orig_w, orig_h) = image.dimensions(); @@ -1090,12 +1300,11 @@ where let candidate = resized.as_ref().unwrap_or(image); let pixels = prepare_fn(candidate); deadline.check()?; - let (min_q, max_q) = quality_range; - let result = encode_target_quality(&pixels, min_q, max_q, target_size, deadline, encode_fn)?; - Ok((result, dimensions)) + Ok((pixels, dimensions)) } /// 对给定图片进行二分质量搜索 +#[cfg(test)] fn encode_target_quality( pixels: &TargetPixels, min_q: u8, @@ -1105,29 +1314,59 @@ fn encode_target_quality( encode_fn: &mut E, ) -> Result, AppError> where - E: FnMut(&[u8], u32, u32, u8) -> Result, AppError>, + E: FnMut(&TargetPixels, u8) -> Result, AppError>, { deadline.check()?; // Start with the highest quality. If it already fits, no lower-quality // encodes can improve the result. - let max_quality = encode_fn(&pixels.bytes, pixels.width, pixels.height, max_q)?; + let max_quality = encode_fn(pixels, max_q)?; let max_size = max_quality.len() as u64; if max_size <= target_size || min_q == max_q { return Ok(max_quality); } deadline.check()?; - let min_quality = encode_fn(&pixels.bytes, pixels.width, pixels.height, min_q)?; + let min_quality = encode_fn(pixels, min_q)?; let min_size = min_quality.len() as u64; if min_size > target_size { return Ok(min_quality); } - let mut best_under = min_quality; - let mut under_q = min_q; - let mut under_size = min_size; - let mut over_q = max_q; - let mut over_size = max_size; + refine_target_quality( + pixels, + QualityCandidate { + quality: min_q, + bytes: min_quality, + }, + QualityCandidate { + quality: max_q, + bytes: max_quality, + }, + target_size, + deadline, + encode_fn, + ) +} + +fn refine_target_quality( + pixels: &TargetPixels, + under: QualityCandidate, + over: QualityCandidate, + target_size: u64, + deadline: &CompressionDeadline, + encode_fn: &mut E, +) -> Result, AppError> +where + E: FnMut(&TargetPixels, u8) -> Result, AppError>, +{ + debug_assert!(under.bytes.len() as u64 <= target_size); + debug_assert!(over.bytes.len() as u64 > target_size); + + let mut best_under = under.bytes; + let mut under_q = under.quality; + let mut under_size = best_under.len() as u64; + let mut over_q = over.quality; + let mut over_size = over.bytes.len() as u64; // Encoded size is usually close to monotonic in quality. Interpolation // jumps near the target first; the binary phase still proves the exact @@ -1152,7 +1391,7 @@ where }; let candidate_q = under_q.saturating_add(estimated_offset.clamp(1, quality_span.saturating_sub(1)) as u8); - let bytes = encode_fn(&pixels.bytes, pixels.width, pixels.height, candidate_q)?; + let bytes = encode_fn(pixels, candidate_q)?; let size = bytes.len() as u64; if size > target_size { over_q = candidate_q; @@ -1169,7 +1408,7 @@ where return Ok(best_under); } let mid = under_q + (over_q - under_q) / 2; - let bytes = encode_fn(&pixels.bytes, pixels.width, pixels.height, mid)?; + let bytes = encode_fn(pixels, mid)?; let size = bytes.len() as u64; if size > target_size { over_q = mid; @@ -1397,9 +1636,31 @@ mod tests { bytes: Vec::new(), width, height, + layout: TargetPixelLayout::Rgb, } } + fn target_search_config( + target_size: u64, + absolute_min_quality: u8, + perceptual_quality: u8, + max_quality: u8, + ) -> TargetSearchConfig { + TargetSearchConfig { + target_size, + absolute_min_quality, + perceptual_quality, + max_quality, + allow_resize: true, + } + } + + fn jpeg_luma_sampling_factor(bytes: &[u8]) -> Option { + bytes.windows(12).find_map(|window| { + (window[0] == 0xff && matches!(window[1], 0xc0..=0xc2)).then_some(window[11]) + }) + } + fn crc32(bytes: &[u8]) -> u32 { let mut crc = u32::MAX; for byte in bytes { @@ -1444,7 +1705,7 @@ mod tests { let height = 10_u32; let rgb = [32_u8, 128, 224].repeat((width * height) as usize); let mut png = Vec::new(); - PngEncoder::new(&mut png) + image::codecs::png::PngEncoder::new(&mut png) .write_image(&rgb, width, height, ExtendedColorType::Rgb8) .unwrap(); @@ -1599,6 +1860,151 @@ mod tests { ); } + #[test] + fn png_raw_encoder_preserves_grayscale_and_transparency() { + let grayscale = + image::GrayImage::from_fn(32, 16, |x, y| image::Luma([((x * 7 + y * 11) % 256) as u8])); + let grayscale_png = encode_png(DynamicImage::ImageLuma8(grayscale), 55, false).unwrap(); + assert_eq!(detect_format(&grayscale_png).unwrap(), ImageFmt::Png); + assert_eq!(grayscale_png[25], 0); + + let transparent = image::RgbaImage::from_fn(16, 16, |x, y| { + image::Rgba([x as u8 * 12, y as u8 * 12, 90, ((x + y) * 8) as u8]) + }); + let transparent_png = encode_png(DynamicImage::ImageRgba8(transparent), 55, false).unwrap(); + let decoded = image::load_from_memory(&transparent_png) + .unwrap() + .to_rgba8(); + assert_eq!(decoded.dimensions(), (16, 16)); + assert!(decoded.pixels().any(|pixel| pixel[3] < 255)); + } + + #[test] + fn jpeg_uses_stable_420_sampling_at_high_quality() { + let image = DynamicImage::ImageRgb8(RgbImage::from_fn(64, 64, |x, y| { + Rgb([(x * 3) as u8, (y * 3) as u8, ((x + y) * 2) as u8]) + })); + let output = encode_jpeg_with_quality(image, 95).unwrap(); + assert_eq!(jpeg_luma_sampling_factor(&output), Some(0x22)); + } + + #[test] + fn opaque_alpha_images_use_rgb_target_pixels() { + let opaque = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 8, + 4, + image::Rgba([10, 20, 30, 255]), + )); + let transparent = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 8, + 4, + image::Rgba([10, 20, 30, 128]), + )); + + let opaque = prepare_target_pixels(&opaque); + let transparent = prepare_target_pixels(&transparent); + assert_eq!(opaque.layout, TargetPixelLayout::Rgb); + assert_eq!(opaque.bytes.len(), 8 * 4 * 3); + assert_eq!(transparent.layout, TargetPixelLayout::Rgba); + assert_eq!(transparent.bytes.len(), 8 * 4 * 4); + } + + #[test] + fn avif_rgb_path_produces_an_avif_file() { + let image = DynamicImage::ImageRgb8(RgbImage::from_fn(16, 16, |x, y| { + Rgb([(x * 11) as u8, (y * 13) as u8, ((x + y) * 7) as u8]) + })); + let pixels = prepare_target_pixels(&image); + let output = encode_avif_pixels(&pixels, 60).unwrap(); + assert_eq!(detect_format(&output).unwrap(), ImageFmt::Avif); + } + + #[test] + fn webp_target_encoder_keeps_the_hard_size_limit() { + let image = DynamicImage::ImageRgb8(RgbImage::from_fn(320, 240, |x, y| { + Rgb([ + ((x * 17 + y * 3) % 256) as u8, + ((x * 5 + y * 19) % 256) as u8, + ((x ^ y) % 256) as u8, + ]) + })); + let pixels = prepare_target_pixels(&image); + let smallest = encode_webp_pixels(&pixels, WEBP_TARGET_MIN_QUALITY) + .unwrap() + .len() as u64; + let largest = encode_webp_pixels(&pixels, WEBP_TARGET_MAX_QUALITY) + .unwrap() + .len() as u64; + let target = smallest + (largest.saturating_sub(smallest) / 2); + let output = + encode_webp_target(image, target, true, &CompressionDeadline::unlimited()).unwrap(); + assert!(output.len() as u64 <= target); + assert_eq!(detect_format(&output).unwrap(), ImageFmt::Webp); + } + + #[test] + fn jpeg_target_encoder_prefers_perceptual_downscaling() { + let image = DynamicImage::ImageRgb8(RgbImage::from_fn(800, 600, |x, y| { + Rgb([ + ((x * 13 + y * 7) % 256) as u8, + ((x * 3 + y * 17) % 256) as u8, + ((x ^ (y * 5)) % 256) as u8, + ]) + })); + let full = jpeg_rgb(&image); + let full_floor = encode_jpeg_raw( + full.as_raw(), + full.width(), + full.height(), + JPEG_PERCEPTUAL_QUALITY, + ) + .unwrap(); + let reduced = image.resize_exact(640, 480, image::imageops::FilterType::Lanczos3); + let reduced = jpeg_rgb(&reduced); + let reduced_floor = encode_jpeg_raw( + reduced.as_raw(), + reduced.width(), + reduced.height(), + JPEG_PERCEPTUAL_QUALITY, + ) + .unwrap(); + assert!(reduced_floor.len() < full_floor.len()); + let target = (reduced_floor.len() + full_floor.len()) as u64 / 2; + + let output = + encode_jpeg_target(image, target, true, &CompressionDeadline::unlimited()).unwrap(); + let decoded = image::load_from_memory(&output).unwrap(); + assert!(output.len() as u64 <= target); + assert!(decoded.width() < 800); + let aspect_ratio = decoded.width() as f64 / decoded.height() as f64; + assert!((aspect_ratio - (4.0 / 3.0)).abs() < 0.01); + } + + #[test] + fn avif_target_encoder_keeps_the_hard_size_limit() { + let image = DynamicImage::ImageRgb8(RgbImage::from_fn(64, 64, |x, y| { + Rgb([ + ((x * 29 + y * 7) % 256) as u8, + ((x * 11 + y * 31) % 256) as u8, + ((x ^ (y * 13)) % 256) as u8, + ]) + })); + let pixels = prepare_target_pixels(&image); + let smallest = encode_avif_pixels(&pixels, AVIF_TARGET_MIN_QUALITY) + .unwrap() + .len() as u64; + let largest = encode_avif_pixels(&pixels, AVIF_TARGET_MAX_QUALITY) + .unwrap() + .len() as u64; + assert!(smallest < largest); + let target = smallest + (largest - smallest) / 2; + + let output = + encode_avif_target(image, target, false, &CompressionDeadline::unlimited()).unwrap(); + assert!(output.len() as u64 <= target); + assert_eq!(detect_format(&output).unwrap(), ImageFmt::Avif); + } + #[test] fn target_encoder_stops_when_full_resolution_meets_target() { use std::cell::Cell; @@ -1607,12 +2013,10 @@ mod tests { let image = DynamicImage::new_rgb8(800, 600); let result = encode_with_auto_resize( image, - 100, - 40, - 95, + target_search_config(100, 40, 70, 95), &CompressionDeadline::unlimited(), empty_target_pixels, - |_raw, _width, _height, quality| { + |_pixels, quality| { calls.set(calls.get() + 1); Ok(vec![0; quality as usize]) }, @@ -1632,6 +2036,7 @@ mod tests { bytes: Vec::new(), width: 32, height: 32, + layout: TargetPixelLayout::Rgb, }; let result = encode_target_quality( &pixels, @@ -1639,7 +2044,7 @@ mod tests { 95, 50_000, &CompressionDeadline::unlimited(), - &mut |_raw, _width, _height, quality| { + &mut |_pixels, quality| { calls.borrow_mut().push(quality); Ok(vec![0; usize::from(quality) * 1_000]) }, @@ -1655,12 +2060,15 @@ mod tests { let image = DynamicImage::new_rgb8(960, 640); let result = encode_with_auto_resize( image, - 40_000, - 40, - 40, + target_search_config(40_000, 40, 40, 40), &CompressionDeadline::unlimited(), empty_target_pixels, - |_raw, width, height, _quality| Ok(vec![0; (width as usize * height as usize) / 10]), + |pixels, _quality| { + Ok(vec![ + 0; + (pixels.width as usize * pixels.height as usize) / 10 + ]) + }, ) .unwrap(); @@ -1674,12 +2082,10 @@ mod tests { let calls = Cell::new(0); let error = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), - 1_000, - 40, - 40, + target_search_config(1_000, 40, 40, 40), &CompressionDeadline::unlimited(), empty_target_pixels, - |_raw, _width, _height, _quality| { + |_pixels, _quality| { calls.set(calls.get() + 1); Ok(vec![0; 50_000]) }, @@ -1700,12 +2106,10 @@ mod tests { deadline.cancel(); let error = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), - 40_000, - 40, - 40, + target_search_config(40_000, 40, 40, 40), &deadline, empty_target_pixels, - |_raw, _width, _height, _quality| { + |_pixels, _quality| { calls.set(calls.get() + 1); Ok(vec![0; 1]) }, @@ -1734,17 +2138,18 @@ mod tests { let controller = deadline.clone(); let result = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), - 40_000, - 40, - 40, + target_search_config(40_000, 40, 40, 40), &deadline, empty_target_pixels, - |_raw, width, height, _quality| { + |pixels, _quality| { calls.set(calls.get() + 1); if calls.get() == 2 { controller.cancel(); } - Ok(vec![0; (width as usize * height as usize) / 10]) + Ok(vec![ + 0; + (pixels.width as usize * pixels.height as usize) / 10 + ]) }, ) .unwrap(); @@ -1761,15 +2166,13 @@ mod tests { let encodes = Cell::new(0); let result = encode_with_auto_resize( DynamicImage::new_rgb8(800, 600), - 50_000, - 25, - 95, + target_search_config(50_000, 25, 25, 95), &CompressionDeadline::unlimited(), |image| { preparations.set(preparations.get() + 1); empty_target_pixels(image) }, - |_raw, _width, _height, quality| { + |_pixels, quality| { encodes.set(encodes.get() + 1); Ok(vec![0; usize::from(quality) * 1_000]) }, @@ -1781,6 +2184,64 @@ mod tests { assert!(encodes.get() > 1); } + #[test] + fn perceptual_search_reduces_resolution_before_quality() { + use std::cell::RefCell; + + let calls = RefCell::new(Vec::new()); + let result = encode_with_auto_resize( + DynamicImage::new_rgb8(1_000, 1_000), + target_search_config(50_000, 25, 70, 95), + &CompressionDeadline::unlimited(), + empty_target_pixels, + |pixels, quality| { + calls + .borrow_mut() + .push((pixels.width, pixels.height, quality)); + let size = + pixels.width as usize * pixels.height as usize * quality as usize / 1_000; + Ok(vec![0; size]) + }, + ) + .unwrap(); + + let calls = calls.into_inner(); + assert!(result.len() <= 50_000); + assert!(calls.iter().all(|(_, _, quality)| *quality >= 70)); + assert!(calls.iter().any(|(width, _, _)| *width < 1_000)); + assert!(calls.len() <= 9); + } + + #[test] + fn explicit_dimensions_disable_target_driven_resizing() { + use std::cell::RefCell; + + let calls = RefCell::new(Vec::new()); + let mut config = target_search_config(50_000, 25, 70, 95); + config.allow_resize = false; + let result = encode_with_auto_resize( + DynamicImage::new_rgb8(1_000, 1_000), + config, + &CompressionDeadline::unlimited(), + empty_target_pixels, + |pixels, quality| { + calls + .borrow_mut() + .push((pixels.width, pixels.height, quality)); + let size = + pixels.width as usize * pixels.height as usize * quality as usize / 1_000; + Ok(vec![0; size]) + }, + ) + .unwrap(); + + assert!(result.len() <= 50_000); + assert!(calls + .into_inner() + .iter() + .all(|(width, height, _)| (*width, *height) == (1_000, 1_000))); + } + #[test] fn large_images_use_a_smaller_scale_search_budget() { assert_eq!(scale_search_attempts(4_000, 3_000), 3);