feat: 支持按目标大小压缩图片

- 新增 target_size_bytes 参数,支持直接指定压缩后的目标大小(字节)
- 实现自动缩放算法:当仅调整质量无法达到目标时,自动缩小图片尺寸
- 前端新增压缩模式切换:百分比模式 / 目标大小模式
- 支持 KB/MB 单位选择
- 优化二分搜索算法,提高目标大小的精准度

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 17:51:15 +08:00
parent df9c40e456
commit 5f432998a3
5 changed files with 288 additions and 27 deletions

View File

@@ -188,6 +188,7 @@ pub async fn compress_image_bytes(
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,
@@ -203,7 +204,11 @@ pub async fn compress_image_bytes(
}
let retention_rate = effective_rate(compression_rate, level);
let target_size = compression_rate.map(|value| target_size_from_rate(original_size, value));
// 优先使用直接指定的目标大小,其次根据百分比计算
let target_size = match target_size_bytes {
Some(bytes) => Some(bytes),
None => compression_rate.map(|value| target_size_from_rate(original_size, value)),
};
if compression_rate == Some(100)
&& format_in == format_out
@@ -439,24 +444,157 @@ 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> {
let rgb = image.to_rgb8();
let (w, h) = rgb.dimensions();
let raw = rgb.into_raw();
encode_target_quality(35, 95, target_size, |q| encode_jpeg_raw(&raw, w, h, q))
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgb = img.to_rgb8();
let (w, h) = rgb.dimensions();
encode_jpeg_raw(rgb.as_raw(), w, h, q)
})
}
fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let raw = rgba.into_raw();
encode_target_quality(30, 95, target_size, |q| encode_webp_raw(&raw, w, h, q))
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
encode_webp_raw(rgba.as_raw(), w, h, q)
})
}
fn encode_avif_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let raw = rgba.into_raw();
encode_target_quality(35, 90, target_size, |q| encode_avif_raw(&raw, w, h, q))
encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
encode_avif_raw(rgba.as_raw(), w, h, q)
})
}
/// 支持自动缩放尺寸的目标大小压缩
/// 当仅调整质量无法达到目标大小时,自动缩小图片尺寸
fn encode_with_auto_resize<F>(
image: DynamicImage,
target_size: u64,
min_q: u8,
max_q: u8,
mut encode_fn: F,
) -> Result<Vec<u8>, AppError>
where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
let (orig_w, orig_h) = image.dimensions();
let min_dimension = 16u32; // 最小尺寸限制
// 首先尝试用最低质量压缩原始尺寸
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 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_result = min_q_result;
let mut best_is_under = false;
// 尝试多个缩放级别
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; // 达到最小尺寸
}
let resized = 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_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;
}
break; // 已找到满足条件的最大尺寸
} else if !best_is_under && result_size < best_result.len() as u64 {
// 还没找到满足条件的,保存最接近的
best_result = result;
}
}
Ok(best_result)
}
/// 对给定图片进行二分质量搜索
fn encode_target_quality_with_image<F>(
image: &DynamicImage,
min_q: u8,
max_q: u8,
target_size: u64,
encode_fn: &mut F,
) -> Result<Vec<u8>, AppError>
where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
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;
let is_under = size <= target_size;
let diff = if size > target_size {
size - target_size
} else {
target_size - size
};
let should_update = match (best_is_under, is_under) {
(false, true) => true,
(true, false) => false,
_ => diff < best_diff,
};
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 {
if low > high {
break;
}
let mid = (low + high) / 2;
let bytes = encode_fn(image, mid)?;
let size = bytes.len() as u64;
consider(bytes);
if size > target_size {
high = mid.saturating_sub(1);
} else {
low = mid.saturating_add(1);
}
}
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
fn encode_target_quality<F>(
@@ -470,35 +608,53 @@ 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 mut 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 {
size - target_size
} else {
target_size - size
};
if diff < best_diff {
best_diff = diff;
best = Some(bytes);
// 优先选择不超过目标大小的结果
let should_update = match (*best_is_under, is_under) {
(false, true) => true, // 当前小于目标,之前大于目标 -> 更新
(true, false) => false, // 当前大于目标,之前小于目标 -> 不更新
_ => diff < *best_diff, // 同类情况,选择更接近的
};
if should_update {
*best_diff = diff;
*best_is_under = is_under;
*best_size = size;
*best = Some(bytes);
}
};
consider(encode(min_q)?);
// 先尝试两端
let bytes = encode(min_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if min_q != max_q {
consider(encode(max_q)?);
let bytes = encode(max_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
}
// 二分查找,增加迭代次数到 12 次以提高精度
let mut low = min_q;
let mut high = max_q;
for _ in 0..7 {
for _ in 0..12 {
if low > high {
break;
}
let mid = (low + high) / 2;
let bytes = encode(mid)?;
let size = bytes.len() as u64;
consider(bytes);
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if size > target_size {
high = mid.saturating_sub(1);
} else {
@@ -506,6 +662,19 @@ where
}
}
// 精细调整:如果当前结果超出目标太多,尝试更低质量
if best_size > target_size {
let mut q = min_q;
while q <= min_q.saturating_add(5) && q <= max_q {
let bytes = encode(q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if best_size <= target_size {
break; // 已找到满足条件的结果
}
q = q.saturating_add(1);
}
}
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}