feat(compress): improve quality guardrails and format conversion
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user