use crate::error::{AppError, ErrorCode}; 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; use image::codecs::tiff::TiffEncoder; use image::codecs::webp::WebPDecoder; use image::metadata::Orientation; use image::{ AnimationDecoder, DynamicImage, ExtendedColorType, GenericImageView, ImageDecoder, ImageEncoder, ImageReader, Rgb, RgbImage, }; use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC}; use oxipng::StripChunks; use rgb::FromSlice; use std::io::Cursor; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; const TARGET_MIN_LONG_EDGE: u32 = 640; const TARGET_MIN_SCALE: f64 = 0.55; const TARGET_SCALE_SEARCH_ATTEMPTS: usize = 6; const TARGET_QUALITY_INTERPOLATION_ATTEMPTS: usize = 2; const COMPRESSION_TIME_BUDGET: Duration = Duration::from_secs(30); 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 = 100; const WEBP_TARGET_MAX_QUALITY: u8 = 100; const AVIF_TARGET_MAX_QUALITY: u8 = 100; const AVIF_ENCODER_SPEED: u8 = 5; const WEBP_TARGET_SAFETY_PERCENT: u64 = 97; const METADATA_TARGET_OVERHEAD: u64 = 1024; #[derive(Clone)] struct CompressionDeadline { deadline: Option, cancelled: Arc, } impl CompressionDeadline { fn new(budget: Duration) -> Self { Self { deadline: Instant::now().checked_add(budget), cancelled: Arc::new(AtomicBool::new(false)), } } #[cfg(test)] fn unlimited() -> Self { Self { deadline: None, cancelled: Arc::new(AtomicBool::new(false)), } } fn cancel(&self) { self.cancelled.store(true, Ordering::Relaxed); } fn expired(&self) -> bool { self.cancelled.load(Ordering::Relaxed) || self .deadline .is_some_and(|deadline| Instant::now() >= deadline) } fn remaining(&self) -> Duration { if self.cancelled.load(Ordering::Relaxed) { return Duration::ZERO; } self.deadline .map(|deadline| deadline.saturating_duration_since(Instant::now())) .unwrap_or(Duration::MAX) } fn check(&self) -> Result<(), AppError> { if self.expired() { return Err(compression_timeout_error()); } Ok(()) } } fn compression_timeout_error() -> AppError { AppError::new( ErrorCode::CompressionFailed, "图片处理超过 30 秒,请降低分辨率或改用 JPEG/WebP 格式", ) } #[derive(Debug, Clone, Copy)] pub enum CompressionLevel { High, Medium, Low, } impl CompressionLevel { pub fn as_str(self) -> &'static str { match self { Self::High => "high", Self::Medium => "medium", Self::Low => "low", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImageFmt { Png, Jpeg, Webp, Avif, Gif, Bmp, Tiff, Ico, } impl ImageFmt { pub fn as_str(self) -> &'static str { match self { Self::Png => "png", Self::Jpeg => "jpeg", Self::Webp => "webp", Self::Avif => "avif", Self::Gif => "gif", Self::Bmp => "bmp", Self::Tiff => "tiff", Self::Ico => "ico", } } pub fn extension(self) -> &'static str { match self { Self::Png => "png", Self::Jpeg => "jpg", Self::Webp => "webp", Self::Avif => "avif", Self::Gif => "gif", Self::Bmp => "bmp", Self::Tiff => "tiff", Self::Ico => "ico", } } pub fn content_type(self) -> &'static str { match self { Self::Png => "image/png", Self::Jpeg => "image/jpeg", Self::Webp => "image/webp", Self::Avif => "image/avif", Self::Gif => "image/gif", Self::Bmp => "image/bmp", Self::Tiff => "image/tiff", Self::Ico => "image/x-icon", } } } pub fn parse_level(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "" | "medium" => Ok(CompressionLevel::Medium), "high" => Ok(CompressionLevel::High), "low" => Ok(CompressionLevel::Low), _ => Err(AppError::new( ErrorCode::InvalidRequest, "level 仅支持 high/medium/low", )), } } pub fn parse_compression_rate(value: &str) -> Result { let rate: u8 = value.trim().parse().map_err(|_| { AppError::new( ErrorCode::InvalidRequest, "compression_rate 需为 1-100 的整数(压缩后体积占比)", ) })?; if !(1..=100).contains(&rate) { return Err(AppError::new( ErrorCode::InvalidRequest, "compression_rate 需在 1-100 之间(压缩后体积占比)", )); } Ok(rate) } pub fn parse_dimension(value: &str, field: &str) -> Result { let dimension = value .trim() .parse::() .map_err(|_| AppError::new(ErrorCode::InvalidRequest, format!("{field} 格式错误")))?; if dimension == 0 { return Err(AppError::new( ErrorCode::InvalidRequest, format!("{field} 必须大于 0"), )); } Ok(dimension) } pub fn rate_to_level(rate: u8) -> CompressionLevel { match rate { 1..=33 => CompressionLevel::High, 34..=66 => CompressionLevel::Medium, _ => CompressionLevel::Low, } } pub fn parse_output_format(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "png" => Ok(ImageFmt::Png), "jpeg" | "jpg" => Ok(ImageFmt::Jpeg), "webp" => Ok(ImageFmt::Webp), "avif" => Ok(ImageFmt::Avif), "gif" => Ok(ImageFmt::Gif), "bmp" => Ok(ImageFmt::Bmp), "tif" | "tiff" => Ok(ImageFmt::Tiff), "ico" => Ok(ImageFmt::Ico), _ => Err(AppError::new( ErrorCode::InvalidRequest, "output_format 仅支持 png/jpeg/webp/avif/gif/bmp/tiff/ico", )), } } pub fn supports_target_size_format(format: ImageFmt) -> bool { matches!(format, ImageFmt::Jpeg | ImageFmt::Webp | ImageFmt::Avif) } fn supports_metadata_format(format: ImageFmt) -> bool { matches!(format, ImageFmt::Jpeg | ImageFmt::Png | ImageFmt::Webp) } fn parse_ftyp_brands(bytes: &[u8]) -> Option> { if bytes.len() < 16 || &bytes[4..8] != b"ftyp" { return None; } let declared_size = u32::from_be_bytes(bytes[0..4].try_into().ok()?); let (major_offset, compatible_offset, box_end) = match declared_size { 0 => (8, 16, bytes.len()), 1 => { if bytes.len() < 24 { return None; } let extended_size = u64::from_be_bytes(bytes[8..16].try_into().ok()?); let end = usize::try_from(extended_size).ok()?; (16, 24, end) } size => (8, 16, size as usize), }; if box_end > bytes.len() || box_end < compatible_offset { return None; } let mut brands = Vec::new(); let mut major = [0_u8; 4]; major.copy_from_slice(bytes.get(major_offset..major_offset + 4)?); brands.push(major); let mut offset = compatible_offset; while offset + 4 <= box_end && brands.len() < 20 { let mut brand = [0_u8; 4]; brand.copy_from_slice(&bytes[offset..offset + 4]); brands.push(brand); offset += 4; } Some(brands) } fn has_brand(brands: &[[u8; 4]], targets: &[[u8; 4]]) -> bool { brands.iter().any(|brand| { targets .iter() .any(|target| brand.as_slice().eq_ignore_ascii_case(target.as_slice())) }) } pub fn detect_format(bytes: &[u8]) -> Result { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Ok(ImageFmt::Png); } if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 { return Ok(ImageFmt::Jpeg); } if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { return Ok(ImageFmt::Webp); } if let Some(brands) = parse_ftyp_brands(bytes) { if has_brand(&brands, &[*b"avis"]) { return Err(AppError::new( ErrorCode::UnsupportedFormat, "暂不支持动画 AVIF", )); } if has_brand(&brands, &[*b"avif"]) { return Ok(ImageFmt::Avif); } if has_brand( &brands, &[ *b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1", *b"msf1", ], ) { return Err(AppError::new( ErrorCode::UnsupportedFormat, "暂不支持 HEIC/HEIF,请先转换为 JPG/PNG/WebP 后再压缩", )); } } if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { return Ok(ImageFmt::Gif); } if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D { return Ok(ImageFmt::Bmp); } if bytes.len() >= 4 { if &bytes[0..4] == b"II*\x00" || &bytes[0..4] == b"MM\x00*" { return Ok(ImageFmt::Tiff); } if bytes[0] == 0x00 && bytes[1] == 0x00 && (bytes[2] == 0x01 || bytes[2] == 0x02) && bytes[3] == 0x00 { return Ok(ImageFmt::Ico); } } Err(AppError::new( ErrorCode::UnsupportedFormat, "不支持的图片格式,请使用 PNG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO", )) } #[allow(clippy::too_many_arguments)] pub async fn compress_image_bytes( state: &AppState, input: Vec, format_in: ImageFmt, format_out: ImageFmt, level: CompressionLevel, compression_rate: Option, target_size_bytes: Option, // 新增:直接指定目标大小(字节) max_width: Option, max_height: Option, preserve_metadata: bool, ) -> Result, AppError> { let started = Instant::now(); let bytes_in = input.len() as u64; let max_image_pixels = crate::services::settings::runtime_policy(state) .await? .file_limits .max_image_pixels; let deadline = CompressionDeadline::new(COMPRESSION_TIME_BUDGET); let permit = match tokio::time::timeout( deadline.remaining(), state.image_processing_semaphore.clone().acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(err)) => { return Err( AppError::new(ErrorCode::Internal, "图片处理并发控制器已关闭").with_source(err), ) } Err(_) => { crate::services::metrics::record_compression(state, started.elapsed(), bytes_in, None); return Err(compression_timeout_error()); } }; let blocking_deadline = deadline.clone(); let handle = tokio::task::spawn_blocking(move || { let _permit = permit; compress_image_bytes_sync( input, format_in, format_out, level, compression_rate, target_size_bytes, max_width, max_height, preserve_metadata, max_image_pixels, &blocking_deadline, ) }); let result = match tokio::time::timeout( deadline .remaining() .saturating_add(COMPRESSION_TIMEOUT_GRACE), handle, ) .await { Ok(Ok(result)) => result, Ok(Err(err)) => Err( AppError::new(ErrorCode::CompressionFailed, "图片处理任务异常退出").with_source(err), ), Err(_) => { deadline.cancel(); Err(compression_timeout_error()) } }; crate::services::metrics::record_compression( state, started.elapsed(), bytes_in, result.as_ref().ok().map(|bytes| bytes.len() as u64), ); result } #[allow(clippy::too_many_arguments)] fn compress_image_bytes_sync( input: Vec, format_in: ImageFmt, format_out: ImageFmt, level: CompressionLevel, compression_rate: Option, target_size_bytes: Option, max_width: Option, max_height: Option, preserve_metadata: bool, max_image_pixels: u64, deadline: &CompressionDeadline, ) -> Result, AppError> { deadline.check()?; let original_size = input.len() as u64; #[cfg(not(target_os = "linux"))] if format_in == ImageFmt::Avif { return Err(AppError::new( ErrorCode::UnsupportedFormat, "当前平台构建不支持 AVIF 解码,请转换为 PNG/JPEG/WebP 后重试", )); } let orientation = inspect_image(&input, max_image_pixels)?; deadline.check()?; if is_animated_image(&input, format_in)? { return Err(AppError::new( ErrorCode::UnsupportedFormat, format!("暂不支持动画 {}", format_in.as_str().to_ascii_uppercase()), )); } deadline.check()?; let retention_rate = effective_rate(compression_rate, level); // 优先使用直接指定的目标大小,其次根据百分比计算 let target_size = match target_size_bytes { Some(bytes) => Some(bytes), None => compression_rate.map(|value| target_size_from_rate(original_size, value)), }; // If the original already satisfies a same-format target, keeping it is // both the highest-quality and the smallest amount of work. if target_size.is_some_and(|target| original_size <= target) && format_in == format_out && max_width.is_none() && max_height.is_none() && (preserve_metadata || orientation == Orientation::NoTransforms) { if preserve_metadata { return Ok(input); } let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone()); return Ok(stripped); } let (icc_profile, mut exif) = if preserve_metadata { extract_metadata(&input) } else { (None, None) }; if preserve_metadata && (icc_profile.is_some() || exif.is_some()) && !supports_metadata_format(format_out) { return Err(AppError::new( ErrorCode::InvalidRequest, format!( "输出 {} 暂不支持保留 EXIF/ICC 元数据,请关闭保留元数据或改用 jpeg/png/webp", format_out.as_str() ), )); } if orientation != Orientation::NoTransforms { if let Some(exif_bytes) = exif.take() { let mut normalized = exif_bytes.to_vec(); let _ = Orientation::remove_from_exif_chunk(&mut normalized); exif = Some(ImgBytes::from(normalized)); } } let encoding_target_size = target_size .map(|target| { target_size_without_metadata( target, format_out, preserve_metadata, icc_profile.as_ref(), exif.as_ref(), ) }) .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 && format_out == ImageFmt::Png && max_width.is_none() && max_height.is_none() && orientation == Orientation::NoTransforms { let preset = png_preset_from_rate(strength_rate); let mut opts = oxipng::Options::from_preset(preset); if !preserve_metadata { opts.strip = StripChunks::Safe; } oxipng::optimize_from_memory(&input, &opts).map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err) })? } else { let mut image = decode_image(&input, format_in)?; deadline.check()?; image.apply_orientation(orientation); let (image, did_resize) = resize_if_needed(image, max_width, max_height); transformed |= did_resize; deadline.check()?; 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, allow_target_resize, deadline)?, None => encode_jpeg(image, strength_rate)?, }, ImageFmt::Webp => match encoding_target_size { 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, allow_target_resize, deadline)?, None => encode_avif(image, strength_rate)?, }, ImageFmt::Gif => encode_gif(image, strength_rate)?, ImageFmt::Bmp => encode_bmp(image)?, ImageFmt::Tiff => encode_tiff(image)?, ImageFmt::Ico => encode_ico(image)?, } }; if preserve_metadata { output = apply_metadata(output, icc_profile, exif)?; } if let Some(target) = target_size { if supports_target_size_format(format_out) && output.len() as u64 > target { return Err(target_unreachable_error(target, output.len() as u64)); } } if format_in == format_out && !transformed && output.len() >= input.len() { if preserve_metadata { return Ok(input); } let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone()); return Ok(if stripped.len() <= input.len() { stripped } else { input }); } Ok(output) } fn inspect_image(input: &[u8], max_image_pixels: u64) -> Result { let reader = ImageReader::new(Cursor::new(input)) .with_guessed_format() .map_err(|err| AppError::new(ErrorCode::InvalidImage, "读取图片头失败").with_source(err))?; let mut decoder = reader.into_decoder().map_err(|err| { AppError::new(ErrorCode::InvalidImage, "读取图片尺寸失败").with_source(err) })?; let (w, h) = decoder.dimensions(); let pixels = (w as u64).saturating_mul(h as u64); if pixels > max_image_pixels { return Err(AppError::new( ErrorCode::TooManyPixels, format!("图片像素过大({}x{})", w, h), )); } decoder .orientation() .map_err(|err| AppError::new(ErrorCode::InvalidImage, "读取图片方向失败").with_source(err)) } fn decode_image(input: &[u8], format: ImageFmt) -> Result { match image::load_from_memory(input) { Ok(image) => Ok(image), Err(primary_error) => { // image-rs deliberately rejects ICO files whose embedded PNG is not // RGBA, although RGB PNG icons are produced by common tooling. if format == ImageFmt::Ico { if let Some(image) = decode_ico_png_entry(input) { return Ok(image); } } Err(AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(primary_error)) } } } fn decode_ico_png_entry(input: &[u8]) -> Option { if input.len() < 6 || u16::from_le_bytes(input[0..2].try_into().ok()?) != 0 || u16::from_le_bytes(input[2..4].try_into().ok()?) != 1 { return None; } let count = usize::from(u16::from_le_bytes(input[4..6].try_into().ok()?)); let table_end = 6_usize.checked_add(count.checked_mul(16)?)?; if count == 0 || table_end > input.len() { return None; } let mut selected: Option<(u16, u32, u32, u32, u32, u32)> = None; for index in 0..count { let entry = 6 + index * 16; let width = if input[entry] == 0 { 256 } else { u32::from(input[entry]) }; let height = if input[entry + 1] == 0 { 256 } else { u32::from(input[entry + 1]) }; let bits = u16::from_le_bytes(input[entry + 6..entry + 8].try_into().ok()?); let length = u32::from_le_bytes(input[entry + 8..entry + 12].try_into().ok()?); let offset = u32::from_le_bytes(input[entry + 12..entry + 16].try_into().ok()?); let pixels = width.saturating_mul(height); if selected.is_none_or(|(best_bits, best_pixels, _, _, _, _)| { (bits, pixels) >= (best_bits, best_pixels) }) { selected = Some((bits, pixels, width, height, length, offset)); } } let (_, _, expected_width, expected_height, length, offset) = selected?; let start = usize::try_from(offset).ok()?; let end = start.checked_add(usize::try_from(length).ok()?)?; let embedded = input.get(start..end)?; if !embedded.starts_with(b"\x89PNG\r\n\x1a\n") { return None; } let image = image::load_from_memory_with_format(embedded, image::ImageFormat::Png).ok()?; (image.dimensions() == (expected_width, expected_height)).then_some(image) } fn target_size_without_metadata( target_size: u64, format_out: ImageFmt, preserve_metadata: bool, icc_profile: Option<&ImgBytes>, exif: Option<&ImgBytes>, ) -> Result { if !preserve_metadata || !supports_metadata_format(format_out) { return Ok(target_size); } let payload_size = icc_profile .map(|bytes| bytes.len() as u64) .unwrap_or_default() .saturating_add(exif.map(|bytes| bytes.len() as u64).unwrap_or_default()); if payload_size == 0 { return Ok(target_size); } let reservation = payload_size.saturating_add(METADATA_TARGET_OVERHEAD); if reservation >= target_size { return Err(AppError::new( ErrorCode::InvalidRequest, format!( "目标体积 {target_size} 字节不足以保留约 {payload_size} 字节的元数据,请提高目标大小或关闭保留元数据" ), )); } Ok(target_size - reservation) } fn target_unreachable_error(target_size: u64, smallest_size: u64) -> AppError { AppError::new( ErrorCode::InvalidRequest, format!( "目标体积 {target_size} 字节在清晰度保护范围内无法达到,当前最小约 {smallest_size} 字节;请提高目标大小/比例或改用 AVIF/WebP" ), ) } fn resize_if_needed( image: DynamicImage, max_width: Option, max_height: Option, ) -> (DynamicImage, bool) { if max_width.is_none() && max_height.is_none() { return (image, false); } let (w, h) = image.dimensions(); let (target_w, target_h) = fit_within(w, h, max_width, max_height); if target_w == w && target_h == h { return (image, false); } ( image.resize(target_w, target_h, image::imageops::FilterType::Lanczos3), true, ) } fn fit_within(w: u32, h: u32, max_width: Option, max_height: Option) -> (u32, u32) { let mut scale = 1.0_f64; if let Some(mw) = max_width.filter(|v| *v > 0) { scale = scale.min(mw as f64 / w as f64); } if let Some(mh) = max_height.filter(|v| *v > 0) { scale = scale.min(mh as f64 / h as f64); } if scale >= 1.0 { return (w, h); } let nw = (w as f64 * scale).round().max(1.0) as u32; let nh = (h as f64 * scale).round().max(1.0) as u32; (nw, nh) } fn encode_png(image: DynamicImage, rate: u8, preserve_metadata: bool) -> Result, AppError> { 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; } 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)) } fn encode_jpeg(image: DynamicImage, rate: u8) -> Result, AppError> { let quality = jpeg_quality_from_rate(rate); encode_jpeg_with_quality(image, quality) } fn encode_jpeg_with_quality(image: DynamicImage, quality: u8) -> Result, AppError> { let rgb = jpeg_rgb(&image); let (w, h) = rgb.dimensions(); encode_jpeg_raw(rgb.as_raw(), w, h, quality) } fn jpeg_rgb(image: &DynamicImage) -> RgbImage { if !image.color().has_alpha() { return image.to_rgb8(); } let rgba = image.to_rgba8(); RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| { let pixel = rgba.get_pixel(x, y).0; let alpha = u16::from(pixel[3]); let inverse_alpha = 255 - alpha; Rgb([ ((u16::from(pixel[0]) * alpha + 255 * inverse_alpha + 127) / 255) as u8, ((u16::from(pixel[1]) * alpha + 255 * inverse_alpha + 127) / 255) as u8, ((u16::from(pixel[2]) * alpha + 255 * inverse_alpha + 127) / 255) as u8, ]) }) } fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result, AppError> { let width = u16::try_from(w) .map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 宽度不能超过 65535 像素"))?; let height = u16::try_from(h) .map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 高度不能超过 65535 像素"))?; let mut out = Vec::new(); 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| { AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err) })?; Ok(out) } fn encode_webp(image: DynamicImage, rate: u8) -> Result, AppError> { let pixels = prepare_target_pixels(&image); let encoder = webp_encoder(&pixels); let bytes = if rate <= 10 { encoder.encode_lossless() } else { encoder.encode(webp_quality_from_rate(rate)) }; Ok(bytes.to_vec()) } 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 pixels = prepare_target_pixels(&image); let quality = avif_quality_from_rate(rate); encode_avif_pixels(&pixels, quality as u8) } 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, 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); let (w, h) = rgb.dimensions(); TargetPixels { bytes: rgb.into_raw(), width: w, height: h, layout: TargetPixelLayout::Rgb, } }, |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 lossless_candidate = encode_webp_lossless_pixels(&pixels); deadline.check()?; match lossless_candidate { Ok(lossless) if lossless.len() as u64 <= target_size => return Ok(lossless), Ok(_) => {} Err(error) => { tracing::debug!(error = %error, "WebP 无损候选编码失败,继续尝试有损编码"); } } deadline.check()?; let max_lossy = encode_webp_pixels(&pixels, WEBP_TARGET_MAX_QUALITY)?; deadline.check()?; if max_lossy.len() as u64 <= target_size { return Ok(max_lossy); } 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, TargetSearchConfig { target_size, absolute_min_quality: WEBP_TARGET_MIN_QUALITY, perceptual_quality: WEBP_PERCEPTUAL_QUALITY, max_quality: WEBP_TARGET_MAX_QUALITY, allow_resize, }, 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_webp_lossless_pixels(pixels: &TargetPixels) -> Result, AppError> { let mut config = webp::WebPConfig::new() .map_err(|_| AppError::new(ErrorCode::CompressionFailed, "初始化 WebP 无损配置失败"))?; config.lossless = 1; config.quality = 100.0; config.method = 6; config.alpha_compression = 1; config.near_lossless = 100; config.exact = 1; config.thread_level = 0; 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, TargetSearchConfig { target_size, absolute_min_quality: AVIF_TARGET_MIN_QUALITY, perceptual_quality: AVIF_PERCEPTUAL_QUALITY, max_quality: AVIF_TARGET_MAX_QUALITY, allow_resize, }, 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) } } } /// 目标体积压缩(感知质量优先 + 有边界的降尺寸)。 /// /// 原尺寸最高质量不满足目标时,先固定感知质量下限并搜索最大分辨率。 /// 只有最小允许尺寸仍超标时,才继续降低质量。显式尺寸约束会关闭 /// 自动降采样,以保持调用方要求的输出尺寸。 fn encode_with_auto_resize( image: DynamicImage, config: TargetSearchConfig, deadline: &CompressionDeadline, mut prepare_fn: P, mut encode_fn: E, ) -> Result, AppError> where P: FnMut(&DynamicImage) -> TargetPixels, 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_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 !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 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 (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; let mut over_dimensions = full_dimensions; for _ in 0..scale_search_attempts(orig_w, orig_h) { if deadline.expired() { return Ok(best_under); } let candidate_scale = (under_scale + over_scale) / 2.0; let candidate_dimensions = dimensions_at_scale(orig_w, orig_h, candidate_scale); if candidate_dimensions == under_dimensions { under_scale = candidate_scale; continue; } if candidate_dimensions == over_dimensions { over_scale = candidate_scale; continue; } 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; } else { over_scale = candidate_scale; over_dimensions = candidate_dimensions; } } 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, 4_000_000.. => 4, _ => TARGET_SCALE_SEARCH_ATTEMPTS, } } fn dimensions_at_scale(orig_w: u32, orig_h: u32, scale: f64) -> (u32, u32) { ( ((orig_w as f64 * scale).round() as u32).clamp(1, orig_w), ((orig_h as f64 * scale).round() as u32).clamp(1, orig_h), ) } fn prepare_target_at_scale

( image: &DynamicImage, scale: f64, deadline: &CompressionDeadline, prepare_fn: &mut P, ) -> Result<(TargetPixels, (u32, u32)), AppError> where P: FnMut(&DynamicImage) -> TargetPixels, { deadline.check()?; let (orig_w, orig_h) = image.dimensions(); let dimensions = dimensions_at_scale(orig_w, orig_h, scale); let resized = (dimensions != (orig_w, orig_h)).then(|| { image.resize_exact( dimensions.0, dimensions.1, image::imageops::FilterType::Lanczos3, ) }); deadline.check()?; let candidate = resized.as_ref().unwrap_or(image); let pixels = prepare_fn(candidate); deadline.check()?; Ok((pixels, dimensions)) } /// 对给定图片进行二分质量搜索 #[cfg(test)] fn encode_target_quality( pixels: &TargetPixels, min_q: u8, max_q: u8, target_size: u64, deadline: &CompressionDeadline, encode_fn: &mut E, ) -> Result, AppError> where 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, 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, min_q)?; let min_size = min_quality.len() as u64; if min_size > target_size { return Ok(min_quality); } 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 // highest fitting integer quality, so output quality is not approximated. for _ in 0..TARGET_QUALITY_INTERPOLATION_ATTEMPTS { if deadline.expired() { return Ok(best_under); } if under_q.saturating_add(1) >= over_q { break; } let quality_span = u64::from(over_q - under_q); let size_span = over_size.saturating_sub(under_size); let estimated_offset = if size_span == 0 { quality_span / 2 } else { target_size .saturating_sub(under_size) .saturating_mul(quality_span) / size_span }; let candidate_q = under_q.saturating_add(estimated_offset.clamp(1, quality_span.saturating_sub(1)) as u8); let bytes = encode_fn(pixels, candidate_q)?; let size = bytes.len() as u64; if size > target_size { over_q = candidate_q; over_size = size; } else { best_under = bytes; under_q = candidate_q; under_size = size; } } while under_q.saturating_add(1) < over_q { if deadline.expired() { return Ok(best_under); } let mid = under_q + (over_q - under_q) / 2; let bytes = encode_fn(pixels, mid)?; let size = bytes.len() as u64; if size > target_size { over_q = mid; } else { best_under = bytes; under_q = mid; } } Ok(best_under) } fn encode_gif(image: DynamicImage, rate: u8) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); let mut out = Vec::new(); let speed = gif_speed_from_rate(rate); { let mut encoder = GifEncoder::new_with_speed(&mut out, speed); encoder .encode(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) .map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err) })?; } Ok(out) } fn encode_bmp(image: DynamicImage) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); let mut out = Vec::new(); let encoder = BmpEncoder::new(&mut out); encoder .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) .map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err) })?; Ok(out) } fn encode_tiff(image: DynamicImage) -> Result, AppError> { let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); let mut out = Cursor::new(Vec::new()); let encoder = TiffEncoder::new(&mut out); encoder .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) .map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err) })?; Ok(out.into_inner()) } fn encode_ico(image: DynamicImage) -> Result, AppError> { // A single ICO directory entry can represent at most 256x256 pixels. let (image, _) = resize_if_needed(image, Some(256), Some(256)); let rgba = image.to_rgba8(); let (w, h) = rgba.dimensions(); let mut out = Vec::new(); let encoder = IcoEncoder::new(&mut out); encoder .write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8) .map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err) })?; Ok(out) } fn extract_metadata(input: &[u8]) -> (Option, Option) { let bytes = ImgBytes::copy_from_slice(input); match DynImage::from_bytes(bytes) { Ok(Some(img)) => (img.icc_profile(), img.exif()), _ => (None, None), } } fn apply_metadata( output: Vec, icc_profile: Option, exif: Option, ) -> Result, AppError> { if icc_profile.is_none() && exif.is_none() { return Ok(output); } let out_bytes = ImgBytes::from(output); let dyn_img = DynImage::from_bytes(out_bytes.clone()).map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err) })?; let Some(mut img) = dyn_img else { return Ok(out_bytes.to_vec()); }; img.set_icc_profile(icc_profile); img.set_exif(exif); let mut buf = Vec::new(); img.encoder().write_to(&mut buf).map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err) })?; Ok(buf) } fn strip_metadata(input: &[u8]) -> Result, AppError> { let bytes = ImgBytes::copy_from_slice(input); let dyn_img = DynImage::from_bytes(bytes.clone()).map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err) })?; let Some(mut img) = dyn_img else { return Ok(bytes.to_vec()); }; img.set_icc_profile(None); img.set_exif(None); let mut buf = Vec::new(); img.encoder().write_to(&mut buf).map_err(|err| { AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err) })?; Ok(buf) } fn effective_rate(rate: Option, level: CompressionLevel) -> u8 { match rate { Some(value) => value.clamp(1, 100), None => match level { CompressionLevel::Low => 80, CompressionLevel::Medium => 55, CompressionLevel::High => 30, }, } } fn target_size_from_rate(original_size: u64, rate: u8) -> u64 { let rate = rate.clamp(1, 100) as u64; let target = original_size.saturating_mul(rate) / 100; target.max(1) } fn png_preset_from_rate(rate: u8) -> u8 { (((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8 } fn jpeg_quality_from_rate(rate: u8) -> u8 { quality_from_rate(rate, 35, 95) } fn webp_quality_from_rate(rate: u8) -> f32 { quality_from_rate(rate, 40, 92) as f32 } fn avif_quality_from_rate(rate: u8) -> f32 { quality_from_rate(rate, 35, 90) as f32 } fn quality_from_rate(rate: u8, min_quality: u8, max_quality: u8) -> u8 { let min_q = min_quality as i32; let max_q = max_quality as i32; let rate = rate.clamp(1, 100) as i32; let span = max_q - min_q; let q = max_q - ((rate - 1) * span / 99); q.clamp(min_q, max_q) as u8 } fn gif_speed_from_rate(rate: u8) -> i32 { let rate = rate.clamp(1, 100) as i32; 1 + ((rate - 1) * 29 / 99) } fn strength_from_rate(rate: u8) -> u8 { let rate = rate.clamp(1, 100); 101_u8.saturating_sub(rate) } fn is_animated_gif(input: &[u8]) -> Result { let decoder = GifDecoder::new(Cursor::new(input)) .map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?; let mut frames = decoder.into_frames(); if let Some(frame) = frames.next() { frame.map_err(|err| { AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err) })?; } if let Some(frame) = frames.next() { frame.map_err(|err| { AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err) })?; return Ok(true); } Ok(false) } fn is_animated_image(input: &[u8], format: ImageFmt) -> Result { match format { ImageFmt::Gif => is_animated_gif(input), ImageFmt::Png => { let decoder = PngDecoder::new(Cursor::new(input)).map_err(|err| { AppError::new(ErrorCode::InvalidImage, "PNG 解码失败").with_source(err) })?; decoder.is_apng().map_err(|err| { AppError::new(ErrorCode::InvalidImage, "PNG 动画检测失败").with_source(err) }) } ImageFmt::Webp => { let decoder = WebPDecoder::new(Cursor::new(input)).map_err(|err| { AppError::new(ErrorCode::InvalidImage, "WebP 解码失败").with_source(err) })?; Ok(decoder.has_animation()) } _ => Ok(false), } } #[cfg(test)] mod tests { use super::*; fn empty_target_pixels(image: &DynamicImage) -> TargetPixels { let (width, height) = image.dimensions(); TargetPixels { 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 { crc ^= u32::from(*byte); for _ in 0..8 { crc = (crc >> 1) ^ (0xedb8_8320 & (0_u32.wrapping_sub(crc & 1))); } } !crc } fn apng_header() -> Vec { let png = encode_png(DynamicImage::new_rgba8(2, 2), 100, false).unwrap(); let ihdr_end = 8 + 4 + 4 + 13 + 4; let mut chunk = Vec::new(); chunk.extend_from_slice(&8_u32.to_be_bytes()); chunk.extend_from_slice(b"acTL"); chunk.extend_from_slice(&2_u32.to_be_bytes()); chunk.extend_from_slice(&0_u32.to_be_bytes()); chunk.extend_from_slice(&crc32(&chunk[4..]).to_be_bytes()); let mut output = Vec::with_capacity(png.len() + chunk.len()); output.extend_from_slice(&png[..ihdr_end]); output.extend_from_slice(&chunk); output.extend_from_slice(&png[ihdr_end..]); output } fn animated_webp() -> Vec { let mut config = webp::WebPConfig::new().unwrap(); config.lossless = 1; let red = [255_u8, 0, 0, 255].repeat(16); let blue = [0_u8, 0, 255, 255].repeat(16); let mut encoder = webp::AnimEncoder::new(4, 4, &config); encoder.add_frame(webp::AnimFrame::from_rgba(&red, 4, 4, 0)); encoder.add_frame(webp::AnimFrame::from_rgba(&blue, 4, 4, 100)); encoder.encode().to_vec() } fn rgb_png_ico() -> Vec { let width = 16_u32; let height = 10_u32; let rgb = [32_u8, 128, 224].repeat((width * height) as usize); let mut png = Vec::new(); image::codecs::png::PngEncoder::new(&mut png) .write_image(&rgb, width, height, ExtendedColorType::Rgb8) .unwrap(); let mut ico = Vec::new(); ico.extend_from_slice(&0_u16.to_le_bytes()); ico.extend_from_slice(&1_u16.to_le_bytes()); ico.extend_from_slice(&1_u16.to_le_bytes()); ico.extend_from_slice(&[width as u8, height as u8, 0, 0]); ico.extend_from_slice(&1_u16.to_le_bytes()); ico.extend_from_slice(&24_u16.to_le_bytes()); ico.extend_from_slice(&(png.len() as u32).to_le_bytes()); ico.extend_from_slice(&22_u32.to_le_bytes()); ico.extend_from_slice(&png); ico } fn jpeg_with_orientation(width: u16, height: u16, orientation: u8) -> Vec { let mut raw = Vec::with_capacity(width as usize * height as usize * 3); for y in 0..height { for x in 0..width { raw.extend_from_slice(&[ (x % 256) as u8, (y % 256) as u8, ((u32::from(x) + u32::from(y)) % 256) as u8, ]); } } let exif = [ b'I', b'I', 42, 0, 8, 0, 0, 0, // Little-endian TIFF header. 1, 0, // One IFD entry. 0x12, 0x01, 3, 0, 1, 0, 0, 0, orientation, 0, 0, 0, 0, 0, 0, 0, ]; let mut output = Vec::new(); let mut encoder = jpeg_encoder::Encoder::new(&mut output, 95); encoder.add_exif_metadata(&exif).unwrap(); encoder .encode(&raw, width, height, jpeg_encoder::ColorType::Rgb) .unwrap(); output } #[test] fn detects_supported_formats_from_signatures() { assert_eq!(detect_format(b"\x89PNG\r\n\x1a\n").unwrap(), ImageFmt::Png); assert_eq!(detect_format(b"\xff\xd8").unwrap(), ImageFmt::Jpeg); assert_eq!( detect_format(b"RIFF\x00\x00\x00\x00WEBP").unwrap(), ImageFmt::Webp ); assert_eq!(detect_format(b"GIF89a").unwrap(), ImageFmt::Gif); assert_eq!(detect_format(b"BM").unwrap(), ImageFmt::Bmp); } #[test] fn detects_avif_and_rejects_heic() { let avif = b"\x00\x00\x00\x14ftypavif\x00\x00\x00\x00avif"; assert_eq!(detect_format(avif).unwrap(), ImageFmt::Avif); let heic = b"\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1"; let error = detect_format(heic).unwrap_err(); assert_eq!(error.code, ErrorCode::UnsupportedFormat); let mut bounded_ftyp = b"\x00\x00\x00\x10ftypavif\x00\x00\x00\x00".to_vec(); bounded_ftyp.extend_from_slice(b"avis"); assert_eq!(detect_format(&bounded_ftyp).unwrap(), ImageFmt::Avif); } #[test] fn rejects_animated_png_webp_and_avif_sequence() { assert!(is_animated_image(&apng_header(), ImageFmt::Png).unwrap()); assert!(is_animated_image(&animated_webp(), ImageFmt::Webp).unwrap()); let mut avif_sequence = Vec::new(); avif_sequence.extend_from_slice(&24_u32.to_be_bytes()); avif_sequence.extend_from_slice(b"ftypavis"); avif_sequence.extend_from_slice(&0_u32.to_be_bytes()); avif_sequence.extend_from_slice(b"avisavif"); let error = detect_format(&avif_sequence).unwrap_err(); assert_eq!(error.code, ErrorCode::UnsupportedFormat); assert!(error.message.contains("动画 AVIF")); } #[test] fn decodes_ico_with_an_embedded_rgb_png() { let input = rgb_png_ico(); assert!(image::load_from_memory(&input).is_err()); let decoded = decode_image(&input, ImageFmt::Ico).unwrap(); assert_eq!(decoded.dimensions(), (16, 10)); let output = compress_image_bytes_sync( input, ImageFmt::Ico, ImageFmt::Webp, CompressionLevel::Low, None, None, None, None, false, 1_000_000, &CompressionDeadline::unlimited(), ) .unwrap(); assert_eq!(detect_format(&output).unwrap(), ImageFmt::Webp); } #[test] fn fit_within_preserves_aspect_ratio_and_never_upscales() { assert_eq!(fit_within(4000, 2000, Some(1000), None), (1000, 500)); assert_eq!(fit_within(4000, 2000, None, Some(250)), (500, 250)); assert_eq!(fit_within(400, 200, Some(800), Some(800)), (400, 200)); } #[test] fn compression_rate_maps_to_expected_target_size() { assert_eq!(target_size_from_rate(10_000, 1), 100); assert_eq!(target_size_from_rate(10_000, 55), 5_500); assert_eq!(target_size_from_rate(10_000, 100), 10_000); } #[test] fn dimensions_must_be_positive() { assert_eq!(parse_dimension("128", "max_width").unwrap(), 128); assert_eq!( parse_dimension("0", "max_width").unwrap_err().code, ErrorCode::InvalidRequest ); } #[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 webp_target_prefers_lossless_when_it_fits() { let image = DynamicImage::ImageRgb8(RgbImage::from_fn(160, 120, |x, y| { let block = ((x / 20) + (y / 20) * 3) as u8; Rgb([ block.wrapping_mul(31), block.wrapping_mul(17), block.wrapping_mul(11), ]) })); let pixels = prepare_target_pixels(&image); let lossless = encode_webp_lossless_pixels(&pixels).unwrap(); let output = encode_webp_target( image.clone(), lossless.len() as u64, true, &CompressionDeadline::unlimited(), ) .unwrap(); assert_eq!(output, lossless); assert_eq!( image::load_from_memory(&output).unwrap().to_rgb8(), image.to_rgb8() ); } #[test] fn webp_target_uses_quality_100_when_lossless_exceeds_the_cap() { let mut state = 0x7f4a_7c15_u32; let image = DynamicImage::ImageRgb8(RgbImage::from_fn(160, 120, |_x, _y| { let mut channel = || { state ^= state << 13; state ^= state >> 17; state ^= state << 5; state as u8 }; Rgb([channel(), channel(), channel()]) })); let pixels = prepare_target_pixels(&image); let max_lossy = encode_webp_pixels(&pixels, WEBP_TARGET_MAX_QUALITY).unwrap(); let lossless = encode_webp_lossless_pixels(&pixels).unwrap(); assert!(max_lossy.len() < lossless.len()); let output = encode_webp_target( image, max_lossy.len() as u64, true, &CompressionDeadline::unlimited(), ) .unwrap(); assert_eq!(output, max_lossy); } #[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; let calls = Cell::new(0); let image = DynamicImage::new_rgb8(800, 600); let result = encode_with_auto_resize( image, target_search_config(100, 40, 70, 95), &CompressionDeadline::unlimited(), empty_target_pixels, |_pixels, quality| { calls.set(calls.get() + 1); Ok(vec![0; quality as usize]) }, ) .unwrap(); assert_eq!(result.len(), 95); assert_eq!(calls.get(), 1); } #[test] fn target_quality_interpolation_finds_the_exact_boundary() { use std::cell::RefCell; let calls = RefCell::new(Vec::new()); let pixels = TargetPixels { bytes: Vec::new(), width: 32, height: 32, layout: TargetPixelLayout::Rgb, }; let result = encode_target_quality( &pixels, 25, 95, 50_000, &CompressionDeadline::unlimited(), &mut |_pixels, quality| { calls.borrow_mut().push(quality); Ok(vec![0; usize::from(quality) * 1_000]) }, ) .unwrap(); assert_eq!(result.len(), 50_000); assert_eq!(*calls.borrow(), vec![95, 25, 50, 51]); } #[test] fn target_encoder_can_reduce_a_landscape_at_the_long_edge_floor() { let image = DynamicImage::new_rgb8(960, 640); let result = encode_with_auto_resize( image, target_search_config(40_000, 40, 40, 40), &CompressionDeadline::unlimited(), empty_target_pixels, |pixels, _quality| { Ok(vec![ 0; (pixels.width as usize * pixels.height as usize) / 10 ]) }, ) .unwrap(); assert!(result.len() <= 40_000); } #[test] fn target_encoder_rejects_an_unreachable_target() { use std::cell::Cell; let calls = Cell::new(0); let error = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), target_search_config(1_000, 40, 40, 40), &CompressionDeadline::unlimited(), empty_target_pixels, |_pixels, _quality| { calls.set(calls.get() + 1); Ok(vec![0; 50_000]) }, ) .unwrap_err(); assert_eq!(error.code, ErrorCode::InvalidRequest); assert!(error.message.contains("无法达到")); assert_eq!(calls.get(), 2); } #[test] fn target_encoder_rejects_work_after_the_deadline() { use std::cell::Cell; let calls = Cell::new(0); let deadline = CompressionDeadline::unlimited(); deadline.cancel(); let error = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), target_search_config(40_000, 40, 40, 40), &deadline, empty_target_pixels, |_pixels, _quality| { calls.set(calls.get() + 1); Ok(vec![0; 1]) }, ) .unwrap_err(); assert_eq!(error.code, ErrorCode::CompressionFailed); assert_eq!(calls.get(), 0); } #[test] fn compression_deadline_reports_no_remaining_time_after_cancellation() { let deadline = CompressionDeadline::unlimited(); assert_eq!(deadline.remaining(), Duration::MAX); deadline.cancel(); assert_eq!(deadline.remaining(), Duration::ZERO); } #[test] fn target_encoder_returns_the_best_candidate_when_search_is_cancelled() { use std::cell::Cell; let calls = Cell::new(0); let deadline = CompressionDeadline::unlimited(); let controller = deadline.clone(); let result = encode_with_auto_resize( DynamicImage::new_rgb8(960, 640), target_search_config(40_000, 40, 40, 40), &deadline, empty_target_pixels, |pixels, _quality| { calls.set(calls.get() + 1); if calls.get() == 2 { controller.cancel(); } Ok(vec![ 0; (pixels.width as usize * pixels.height as usize) / 10 ]) }, ) .unwrap(); assert!(result.len() <= 40_000); assert_eq!(calls.get(), 2); } #[test] fn target_encoder_prepares_pixels_once_for_each_scale() { use std::cell::Cell; let preparations = Cell::new(0); let encodes = Cell::new(0); let result = encode_with_auto_resize( DynamicImage::new_rgb8(800, 600), target_search_config(50_000, 25, 25, 95), &CompressionDeadline::unlimited(), |image| { preparations.set(preparations.get() + 1); empty_target_pixels(image) }, |_pixels, quality| { encodes.set(encodes.get() + 1); Ok(vec![0; usize::from(quality) * 1_000]) }, ) .unwrap(); assert_eq!(result.len(), 50_000); assert_eq!(preparations.get(), 1); 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); assert_eq!(scale_search_attempts(2_000, 2_000), 4); assert_eq!(scale_search_attempts(1_999, 2_000), 6); } #[test] fn png_fast_path_enforces_the_pixel_limit() { let input = encode_png(DynamicImage::new_rgb8(20, 20), 100, false).unwrap(); let error = compress_image_bytes_sync( input, ImageFmt::Png, ImageFmt::Png, CompressionLevel::Low, Some(100), None, None, None, true, 399, &CompressionDeadline::unlimited(), ) .unwrap_err(); assert_eq!(error.code, ErrorCode::TooManyPixels); } #[test] fn exif_orientation_is_applied_before_metadata_is_removed() { let input = jpeg_with_orientation(120, 80, 6); assert_eq!( inspect_image(&input, 1_000_000).unwrap(), Orientation::Rotate90 ); let output = compress_image_bytes_sync( input, ImageFmt::Jpeg, ImageFmt::Png, CompressionLevel::Medium, None, None, None, None, false, 1_000_000, &CompressionDeadline::unlimited(), ) .unwrap(); let decoded = image::load_from_memory(&output).unwrap(); assert_eq!(decoded.dimensions(), (80, 120)); assert_eq!( inspect_image(&output, 1_000_000).unwrap(), Orientation::NoTransforms ); } #[test] fn jpeg_alpha_is_composited_onto_white() { let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 2, 1, image::Rgba([0, 0, 0, 0]), )); let rgb = jpeg_rgb(&image); assert_eq!(rgb.get_pixel(0, 0).0, [255, 255, 255]); let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 1, 1, image::Rgba([255, 0, 0, 128]), )); assert_eq!(jpeg_rgb(&image).get_pixel(0, 0).0, [255, 127, 127]); } #[test] fn metadata_bytes_are_reserved_from_the_encoding_target() { let exif = ImgBytes::from(vec![0; 2_000]); assert_eq!( target_size_without_metadata(10_000, ImageFmt::Jpeg, true, None, Some(&exif)).unwrap(), 6_976 ); assert_eq!( target_size_without_metadata(3_000, ImageFmt::Jpeg, true, None, Some(&exif)) .unwrap_err() .code, ErrorCode::InvalidRequest ); } #[test] fn format_conversion_never_returns_the_original_encoding() { let input = encode_png(DynamicImage::new_rgba8(10, 10), 100, false).unwrap(); let output = compress_image_bytes_sync( input, ImageFmt::Png, ImageFmt::Bmp, CompressionLevel::Medium, None, None, None, None, false, 1_000_000, &CompressionDeadline::unlimited(), ) .unwrap(); assert!(output.starts_with(b"BM")); } #[test] fn ico_encoder_fits_large_images_within_the_format_limit() { let output = encode_ico(DynamicImage::new_rgba8(960, 640)).unwrap(); let decoded = image::load_from_memory(&output).unwrap(); assert!(output.starts_with(b"\x00\x00\x01\x00")); assert_eq!(decoded.dimensions(), (256, 171)); } }