2460 lines
77 KiB
Rust
2460 lines
77 KiB
Rust
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<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 = 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<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 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));
|
||
}
|
||
}
|