Files
ystp/src/services/compress.rs
237899745 03b3a08185
Some checks failed
CI / verify (push) Has been cancelled
fix: bound large WebP lossless probe cost
2026-07-26 12:22:53 +08:00

2489 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 WEBP_HIGH_EFFORT_LOSSLESS_MAX_PIXELS: u64 = 2_100_000;
const METADATA_TARGET_OVERHEAD: u64 = 1024;
#[derive(Clone)]
struct CompressionDeadline {
deadline: Option<Instant>,
cancelled: Arc<AtomicBool>,
}
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<CompressionLevel, AppError> {
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<u8, AppError> {
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<u32, AppError> {
let dimension = value
.trim()
.parse::<u32>()
.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<ImageFmt, AppError> {
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<Vec<[u8; 4]>> {
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<ImageFmt, AppError> {
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<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
) -> Result<Vec<u8>, 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<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
max_image_pixels: u64,
deadline: &CompressionDeadline,
) -> Result<Vec<u8>, 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<Orientation, AppError> {
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<DynamicImage, AppError> {
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<DynamicImage> {
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<u64, AppError> {
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<u32>,
max_height: Option<u32>,
) -> (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<u32>, max_height: Option<u32>) -> (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<Vec<u8>, 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<Vec<u8>, AppError> {
let quality = jpeg_quality_from_rate(rate);
encode_jpeg_with_quality(image, quality)
}
fn encode_jpeg_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, AppError> {
let encoder = webp_encoder(pixels);
Ok(encoder.encode(quality as f32).to_vec())
}
fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, AppError> {
let mut config = webp::WebPConfig::new()
.map_err(|_| AppError::new(ErrorCode::CompressionFailed, "初始化 WebP 无损配置失败"))?;
config.lossless = 1;
config.quality = 100.0;
config.method = webp_lossless_method(pixels);
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 webp_lossless_method(pixels: &TargetPixels) -> i32 {
let pixel_count = u64::from(pixels.width).saturating_mul(u64::from(pixels.height));
if pixel_count <= WEBP_HIGH_EFFORT_LOSSLESS_MAX_PIXELS {
6
} else {
0
}
}
fn encode_avif_target(
image: DynamicImage,
target_size: u64,
allow_resize: bool,
deadline: &CompressionDeadline,
) -> Result<Vec<u8>, 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<u8>,
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<P, E>(
image: DynamicImage,
config: TargetSearchConfig,
deadline: &CompressionDeadline,
mut prepare_fn: P,
mut encode_fn: E,
) -> Result<Vec<u8>, AppError>
where
P: FnMut(&DynamicImage) -> TargetPixels,
E: FnMut(&TargetPixels, u8) -> Result<Vec<u8>, 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<u8>,
}
fn search_below_upper_bound<E>(
pixels: &TargetPixels,
min_quality: u8,
upper: QualityCandidate,
target_size: u64,
deadline: &CompressionDeadline,
encode_fn: &mut E,
) -> Result<Vec<u8>, AppError>
where
E: FnMut(&TargetPixels, u8) -> Result<Vec<u8>, 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<P>(
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<E>(
pixels: &TargetPixels,
min_q: u8,
max_q: u8,
target_size: u64,
deadline: &CompressionDeadline,
encode_fn: &mut E,
) -> Result<Vec<u8>, AppError>
where
E: FnMut(&TargetPixels, u8) -> Result<Vec<u8>, 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<E>(
pixels: &TargetPixels,
under: QualityCandidate,
over: QualityCandidate,
target_size: u64,
deadline: &CompressionDeadline,
encode_fn: &mut E,
) -> Result<Vec<u8>, AppError>
where
E: FnMut(&TargetPixels, u8) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<ImgBytes>, Option<ImgBytes>) {
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<u8>,
icc_profile: Option<ImgBytes>,
exif: Option<ImgBytes>,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>, 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<bool, AppError> {
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<bool, AppError> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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 large_webp_targets_use_the_fast_lossless_probe() {
let small = TargetPixels {
bytes: Vec::new(),
width: 1920,
height: 1080,
layout: TargetPixelLayout::Rgb,
};
let large = TargetPixels {
bytes: Vec::new(),
width: 4096,
height: 3072,
layout: TargetPixelLayout::Rgb,
};
assert_eq!(webp_lossless_method(&small), 6);
assert_eq!(webp_lossless_method(&large), 0);
}
#[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));
}
}