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

@@ -20,8 +20,13 @@ interface UploadItem {
const auth = useAuthStore() const auth = useAuthStore()
type CompressionMode = 'percent' | 'size'
const options = reactive({ const options = reactive({
mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小
compressionRate: 60, compressionRate: 60,
targetSize: '' as string, // 目标大小数值
targetUnit: 'KB' as 'KB' | 'MB', // 目标大小单位
maxWidth: '' as string, maxWidth: '' as string,
maxHeight: '' as string, maxHeight: '' as string,
}) })
@@ -105,18 +110,33 @@ function toInt(v: string): number | undefined {
return Math.floor(n) return Math.floor(n)
} }
function getTargetSizeBytes(): number | undefined {
if (options.mode !== 'size') return undefined
const size = Number(options.targetSize)
if (!Number.isFinite(size) || size <= 0) return undefined
return options.targetUnit === 'MB' ? size * 1024 * 1024 : size * 1024
}
async function runOne(item: UploadItem) { async function runOne(item: UploadItem) {
item.status = 'compressing' item.status = 'compressing'
item.error = undefined item.error = undefined
try { try {
const result = await compressFile( const compressOptions = options.mode === 'percent'
item.file, ? {
{
compression_rate: options.compressionRate, compression_rate: options.compressionRate,
max_width: toInt(options.maxWidth), max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight), max_height: toInt(options.maxHeight),
}, }
: {
target_size_bytes: getTargetSizeBytes(),
max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight),
}
const result = await compressFile(
item.file,
compressOptions,
auth.token, auth.token,
) )
@@ -460,7 +480,31 @@ async function resendVerification() {
<div class="text-sm font-medium text-slate-900">压缩参数</div> <div class="text-sm font-medium text-slate-900">压缩参数</div>
<div class="mt-4 grid grid-cols-1 gap-4"> <div class="mt-4 grid grid-cols-1 gap-4">
<label class="space-y-1"> <!-- 压缩模式切换 -->
<div class="space-y-2">
<div class="text-xs font-medium text-slate-600">压缩模式</div>
<div class="flex rounded-lg border border-slate-200 p-1">
<button
type="button"
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition"
:class="options.mode === 'percent' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
@click="options.mode = 'percent'"
>
按百分比
</button>
<button
type="button"
class="flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition"
:class="options.mode === 'size' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
@click="options.mode = 'size'"
>
按目标大小
</button>
</div>
</div>
<!-- 百分比模式 -->
<label v-if="options.mode === 'percent'" class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium text-slate-600"> <div class="flex items-center justify-between text-xs font-medium text-slate-600">
<span>压缩率</span> <span>压缩率</span>
<span class="text-slate-500">{{ options.compressionRate }}%</span> <span class="text-slate-500">{{ options.compressionRate }}%</span>
@@ -476,6 +520,28 @@ async function resendVerification() {
<div class="text-xs text-slate-500">数值越小压缩越强目标为压缩后体积占原图比例100% 为不压缩</div> <div class="text-xs text-slate-500">数值越小压缩越强目标为压缩后体积占原图比例100% 为不压缩</div>
</label> </label>
<!-- 目标大小模式 -->
<div v-else class="space-y-1">
<div class="text-xs font-medium text-slate-600">目标大小</div>
<div class="flex gap-2">
<input
v-model="options.targetSize"
type="number"
min="1"
placeholder="例如 50"
class="flex-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
<select
v-model="options.targetUnit"
class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
>
<option value="KB">KB</option>
<option value="MB">MB</option>
</select>
</div>
<div class="text-xs text-slate-500">直接指定压缩后的目标大小系统会自动调整质量以逼近目标 JPEG/WebP/AVIF 支持</div>
</div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="space-y-1"> <label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大宽度px</div> <div class="text-xs font-medium text-slate-600">最大宽度px</div>

View File

@@ -84,6 +84,7 @@ export interface CompressResponse {
export interface CompressOptions { export interface CompressOptions {
level?: CompressionLevel level?: CompressionLevel
compression_rate?: number compression_rate?: number
target_size_bytes?: number // 新增:直接指定目标大小(字节)
output_format?: OutputFormat output_format?: OutputFormat
max_width?: number max_width?: number
max_height?: number max_height?: number
@@ -96,6 +97,7 @@ export async function compressFile(file: File, options: CompressOptions, token?:
if (options.level) form.append('level', options.level) if (options.level) form.append('level', options.level)
if (options.compression_rate) form.append('compression_rate', String(options.compression_rate)) if (options.compression_rate) form.append('compression_rate', String(options.compression_rate))
if (options.target_size_bytes) form.append('target_size_bytes', String(options.target_size_bytes))
if (options.output_format) form.append('output_format', options.output_format) if (options.output_format) form.append('output_format', options.output_format)
if (options.max_width) form.append('max_width', String(options.max_width)) if (options.max_width) form.append('max_width', String(options.max_width))
if (options.max_height) form.append('max_height', String(options.max_height)) if (options.max_height) form.append('max_height', String(options.max_height))

View File

@@ -54,6 +54,7 @@ struct CompressRequest {
file_bytes: Vec<u8>, file_bytes: Vec<u8>,
level: CompressionLevel, level: CompressionLevel,
compression_rate: Option<u8>, compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
output_format: Option<ImageFmt>, output_format: Option<ImageFmt>,
max_width: Option<u32>, max_width: Option<u32>,
max_height: Option<u32>, max_height: Option<u32>,
@@ -246,6 +247,7 @@ async fn compress_json(
format_out, format_out,
effective_level, effective_level,
req.compression_rate, req.compression_rate,
req.target_size_bytes, // 新增:目标大小
req.max_width, req.max_width,
req.max_height, req.max_height,
req.preserve_metadata, req.preserve_metadata,
@@ -603,6 +605,7 @@ async fn compress_direct(
format_out, format_out,
effective_level, effective_level,
req.compression_rate, req.compression_rate,
req.target_size_bytes, // 新增:目标大小
req.max_width, req.max_width,
req.max_height, req.max_height,
req.preserve_metadata, req.preserve_metadata,
@@ -835,6 +838,7 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
let mut level = CompressionLevel::Medium; let mut level = CompressionLevel::Medium;
let mut output_format: Option<ImageFmt> = None; let mut output_format: Option<ImageFmt> = None;
let mut compression_rate: Option<u8> = None; let mut compression_rate: Option<u8> = None;
let mut target_size_bytes: Option<u64> = None; // 新增
let mut max_width: Option<u32> = None; let mut max_width: Option<u32> = None;
let mut max_height: Option<u32> = None; let mut max_height: Option<u32> = None;
let mut preserve_metadata = false; let mut preserve_metadata = false;
@@ -905,6 +909,24 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
"1" | "true" | "yes" | "y" | "on" "1" | "true" | "yes" | "y" | "on"
); );
} }
"target_size_bytes" | "target_size" => {
let v = text.trim();
if !v.is_empty() {
target_size_bytes = Some(
v.parse::<u64>()
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "target_size_bytes 格式错误,需为正整数(字节)"))?,
);
// 最小目标大小限制1KB
if let Some(size) = target_size_bytes {
if size < 1024 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 最小为 10241KB",
));
}
}
}
}
_ => {} _ => {}
} }
} }
@@ -917,6 +939,7 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
file_bytes, file_bytes,
level, level,
compression_rate, compression_rate,
target_size_bytes, // 新增
output_format, output_format,
max_width, max_width,
max_height, max_height,

View File

@@ -188,6 +188,7 @@ pub async fn compress_image_bytes(
format_out: ImageFmt, format_out: ImageFmt,
level: CompressionLevel, level: CompressionLevel,
compression_rate: Option<u8>, compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
max_width: Option<u32>, max_width: Option<u32>,
max_height: Option<u32>, max_height: Option<u32>,
preserve_metadata: bool, preserve_metadata: bool,
@@ -203,7 +204,11 @@ pub async fn compress_image_bytes(
} }
let retention_rate = effective_rate(compression_rate, level); 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) if compression_rate == Some(100)
&& format_in == format_out && 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> { fn encode_jpeg_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
let rgb = image.to_rgb8(); encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgb = img.to_rgb8();
let (w, h) = rgb.dimensions(); let (w, h) = rgb.dimensions();
let raw = rgb.into_raw(); encode_jpeg_raw(rgb.as_raw(), w, h, q)
encode_target_quality(35, 95, target_size, |q| encode_jpeg_raw(&raw, w, h, q)) })
} }
fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> { fn encode_webp_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8(); encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions(); let (w, h) = rgba.dimensions();
let raw = rgba.into_raw(); encode_webp_raw(rgba.as_raw(), w, h, q)
encode_target_quality(30, 95, target_size, |q| encode_webp_raw(&raw, w, h, q)) })
} }
fn encode_avif_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> { fn encode_avif_target(image: DynamicImage, target_size: u64) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8(); encode_with_auto_resize(image, target_size, 1, 95, |img, q| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions(); let (w, h) = rgba.dimensions();
let raw = rgba.into_raw(); encode_avif_raw(rgba.as_raw(), w, h, q)
encode_target_quality(35, 90, target_size, |q| encode_avif_raw(&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>( fn encode_target_quality<F>(
@@ -470,35 +608,53 @@ where
{ {
let mut best: Option<Vec<u8>> = None; let mut best: Option<Vec<u8>> = None;
let mut best_diff = u64::MAX; 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 size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size { let diff = if size > target_size {
size - target_size size - target_size
} else { } else {
target_size - size 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 { 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 low = min_q;
let mut high = max_q; let mut high = max_q;
for _ in 0..7 { for _ in 0..12 {
if low > high { if low > high {
break; break;
} }
let mid = (low + high) / 2; let mid = (low + high) / 2;
let bytes = encode(mid)?; let bytes = encode(mid)?;
let size = bytes.len() as u64; 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 { if size > target_size {
high = mid.saturating_sub(1); high = mid.saturating_sub(1);
} else { } 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, "压缩失败")) best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
} }

View File

@@ -384,6 +384,7 @@ async fn process_task_file(
format_out, format_out,
level, level,
compression_rate, compression_rate,
None, // target_size_bytes: worker 批量任务不支持精确大小
max_width, max_width,
max_height, max_height,
ctx.preserve_metadata, ctx.preserve_metadata,