feat(compress): improve quality guardrails and format conversion

This commit is contained in:
2026-02-08 00:12:27 +08:00
parent f8955d8a6c
commit 65387ca846
8 changed files with 233 additions and 102 deletions

View File

@@ -14,6 +14,14 @@ use oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
const TARGET_MIN_DIMENSION: u32 = 640;
const TARGET_MIN_SCALE: f64 = 0.55;
const TARGET_RESIZE_ATTEMPTS: usize = 5;
const JPEG_TARGET_MIN_QUALITY: u8 = 40;
const WEBP_TARGET_MIN_QUALITY: u8 = 42;
const AVIF_TARGET_MIN_QUALITY: u8 = 38;
#[derive(Debug, Clone, Copy)]
pub enum CompressionLevel {
High,
@@ -140,6 +148,40 @@ pub fn parse_output_format(value: &str) -> Result<ImageFmt, AppError> {
}
}
pub fn supports_target_size_format(format: ImageFmt) -> bool {
matches!(format, ImageFmt::Jpeg | ImageFmt::Webp | ImageFmt::Avif)
}
fn parse_ftyp_brands(bytes: &[u8]) -> Option<Vec<[u8; 4]>> {
if bytes.len() < 16 || &bytes[4..8] != b"ftyp" {
return None;
}
let mut brands = Vec::new();
let mut major = [0_u8; 4];
major.copy_from_slice(&bytes[8..12]);
brands.push(major);
let mut offset = 16;
while offset + 4 <= bytes.len() && 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);
@@ -150,12 +192,22 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
return Ok(ImageFmt::Webp);
}
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
if bytes[8..12].eq_ignore_ascii_case(b"avif")
|| bytes[8..12].eq_ignore_ascii_case(b"avis")
{
if let Some(brands) = parse_ftyp_brands(bytes) {
if has_brand(&brands, &[*b"avif", *b"avis"]) {
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);
@@ -177,10 +229,11 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
}
Err(AppError::new(
ErrorCode::UnsupportedFormat,
"不支持的图片格式",
"不支持的图片格式,请使用 PNG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO",
))
}
pub async fn compress_image_bytes(
state: &AppState,
input: &[u8],
@@ -444,7 +497,7 @@ fn encode_avif_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, A
}
fn encode_jpeg_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
encode_with_auto_resize(image, target_size, JPEG_TARGET_MIN_QUALITY, 95, |img, q| {
let rgb = img.to_rgb8();
let (w, h) = rgb.dimensions();
encode_jpeg_raw(rgb.as_raw(), w, h, q)
@@ -452,7 +505,7 @@ fn encode_jpeg_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>,
}
fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
encode_with_auto_resize(image, target_size, WEBP_TARGET_MIN_QUALITY, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
encode_webp_raw(rgba.as_raw(), w, h, q)
@@ -460,15 +513,19 @@ fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>,
}
fn encode_avif_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
encode_with_auto_resize(image, target_size, AVIF_TARGET_MIN_QUALITY, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
encode_avif_raw(rgba.as_raw(), w, h, q)
})
}
/// 支持自动缩放尺寸的目标大小压缩
/// 当仅调整质量无法达到目标大小时,自动缩小图片尺寸
/// 目标体积压缩(质量优先 + 有边界的降尺寸)
///
/// 策略:
/// 1) 先在原图尺寸内二分质量,尽量保持清晰度;
/// 2) 仅在无法接近目标时,才逐步缩小尺寸;
/// 3) 严格限制最小缩放比例,避免“过度糊图”。
fn encode_with_auto_resize<F>(
image: DynamicImage,
target_size: u64,
@@ -480,55 +537,94 @@ where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
let (orig_w, orig_h) = image.dimensions();
let min_dimension = 16u32; // 最小尺寸限制
let min_w = ((orig_w as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_w))
.max(1);
let min_h = ((orig_h as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_h))
.max(1);
// 首先尝试用最低质量压缩原始尺寸
let min_q_result = encode_fn(&image, min_q)?;
if min_q_result.len() as u64 <= target_size {
// 最低质量已满足,用二分法找最佳质量
return encode_target_quality_with_image(&image, min_q, max_q, target_size, &mut encode_fn);
let mut scales = Vec::with_capacity(TARGET_RESIZE_ATTEMPTS + 1);
scales.push(1.0);
for step in 1..=TARGET_RESIZE_ATTEMPTS {
let ratio = step as f64 / TARGET_RESIZE_ATTEMPTS as f64;
let scale = 1.0 - (1.0 - TARGET_MIN_SCALE) * ratio;
scales.push(scale.max(TARGET_MIN_SCALE));
}
// 需要缩放:根据当前大小和目标大小计算缩放比例
let current_size = min_q_result.len() as u64;
// 文件大小大致与像素数成正比,所以尺寸缩放系数 = sqrt(目标大小/当前大小)
let scale = ((target_size as f64 / current_size as f64).sqrt() * 0.9).min(1.0); // 0.9 为安全系数
let mut best_under: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_over: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_result = min_q_result;
let mut best_is_under = false;
for scale in scales {
let new_w = ((orig_w as f64 * scale).round() as u32).clamp(1, orig_w);
let new_h = ((orig_h as f64 * scale).round() as u32).clamp(1, orig_h);
// 尝试多个缩放级别
let scales = [scale, scale * 0.8, scale * 0.6, scale * 0.4, 0.3, 0.2, 0.1];
for &s in &scales {
let new_w = ((orig_w as f64 * s).round() as u32).max(min_dimension);
let new_h = ((orig_h as f64 * s).round() as u32).max(min_dimension);
if new_w < min_dimension && new_h < min_dimension {
break; // 达到最小尺寸
if new_w < min_w || new_h < min_h {
continue;
}
let resized = image.resize(new_w, new_h, image::imageops::FilterType::Lanczos3);
let resized = if new_w == orig_w && new_h == orig_h {
image.clone()
} else {
image.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
};
// 对缩放后的图片进行二分质量搜索
let result = encode_target_quality_with_image(&resized, min_q, max_q, target_size, &mut encode_fn)?;
let result =
encode_target_quality_with_image(&resized, min_q, max_q, target_size, &mut encode_fn)?;
let result_size = result.len() as u64;
if result_size <= target_size {
// 找到满足条件的结果
if !best_is_under || result_size > best_result.len() as u64 {
// 优先选择更大的(更接近目标且不超过)
best_result = result;
best_is_under = true;
let should_update = match &best_under {
None => true,
Some((_bytes, best_w, best_h, best_size)) => {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels || (new_pixels == best_pixels && result_size > *best_size)
}
};
if should_update {
best_under = Some((result, new_w, new_h, result_size));
}
if new_w == orig_w
&& new_h == orig_h
&& target_size.saturating_sub(result_size) <= 1024
{
break;
}
} else {
let should_update = match &best_over {
None => true,
Some((_bytes, best_w, best_h, best_size)) => {
let over = result_size.saturating_sub(target_size);
let best_over_by = best_size.saturating_sub(target_size);
if over < best_over_by {
true
} else if over == best_over_by {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels
} else {
false
}
}
};
if should_update {
best_over = Some((result, new_w, new_h, result_size));
}
break; // 已找到满足条件的最大尺寸
} else if !best_is_under && result_size < best_result.len() as u64 {
// 还没找到满足条件的,保存最接近的
best_result = result;
}
}
Ok(best_result)
if let Some((bytes, _, _, _)) = best_under {
return Ok(bytes);
}
if let Some((bytes, _, _, _)) = best_over {
return Ok(bytes);
}
Err(AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
/// 对给定图片进行二分质量搜索
@@ -545,7 +641,6 @@ where
let mut best: Option<Vec<u8>> = None;
let mut best_diff = u64::MAX;
let mut best_is_under = false;
let mut best_size = 0u64;
let mut consider = |bytes: Vec<u8>| {
let size = bytes.len() as u64;
@@ -565,21 +660,18 @@ where
if should_update {
best_diff = diff;
best_is_under = is_under;
best_size = size;
best = Some(bytes);
}
};
// 先尝试两端
consider(encode_fn(image, min_q)?);
if min_q != max_q {
consider(encode_fn(image, max_q)?);
}
// 二分查找
let mut low = min_q;
let mut high = max_q;
for _ in 0..10 {
for _ in 0..12 {
if low > high {
break;
}
@@ -612,7 +704,7 @@ where
let mut best_size = 0u64;
// 考虑一个候选结果
let mut consider = |bytes: Vec<u8>, best: &mut Option<Vec<u8>>, best_diff: &mut u64, best_is_under: &mut bool, best_size: &mut u64| {
let consider = |bytes: Vec<u8>, best: &mut Option<Vec<u8>>, best_diff: &mut u64, best_is_under: &mut bool, best_size: &mut u64| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size {