feat: 支持按目标大小压缩图片
- 新增 target_size_bytes 参数,支持直接指定压缩后的目标大小(字节) - 实现自动缩放算法:当仅调整质量无法达到目标时,自动缩小图片尺寸 - 前端新增压缩模式切换:百分比模式 / 目标大小模式 - 支持 KB/MB 单位选择 - 优化二分搜索算法,提高目标大小的精准度 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,13 @@ interface UploadItem {
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
type CompressionMode = 'percent' | 'size'
|
||||
|
||||
const options = reactive({
|
||||
mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小
|
||||
compressionRate: 60,
|
||||
targetSize: '' as string, // 目标大小数值
|
||||
targetUnit: 'KB' as 'KB' | 'MB', // 目标大小单位
|
||||
maxWidth: '' as string,
|
||||
maxHeight: '' as string,
|
||||
})
|
||||
@@ -105,18 +110,33 @@ function toInt(v: string): number | undefined {
|
||||
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) {
|
||||
item.status = 'compressing'
|
||||
item.error = undefined
|
||||
|
||||
try {
|
||||
const compressOptions = options.mode === 'percent'
|
||||
? {
|
||||
compression_rate: options.compressionRate,
|
||||
max_width: toInt(options.maxWidth),
|
||||
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,
|
||||
{
|
||||
compression_rate: options.compressionRate,
|
||||
max_width: toInt(options.maxWidth),
|
||||
max_height: toInt(options.maxHeight),
|
||||
},
|
||||
compressOptions,
|
||||
auth.token,
|
||||
)
|
||||
|
||||
@@ -460,7 +480,31 @@ async function resendVerification() {
|
||||
<div class="text-sm font-medium text-slate-900">压缩参数</div>
|
||||
|
||||
<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">
|
||||
<span>压缩率</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>
|
||||
</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">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">最大宽度(px)</div>
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface CompressResponse {
|
||||
export interface CompressOptions {
|
||||
level?: CompressionLevel
|
||||
compression_rate?: number
|
||||
target_size_bytes?: number // 新增:直接指定目标大小(字节)
|
||||
output_format?: OutputFormat
|
||||
max_width?: 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.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.max_width) form.append('max_width', String(options.max_width))
|
||||
if (options.max_height) form.append('max_height', String(options.max_height))
|
||||
|
||||
@@ -54,6 +54,7 @@ struct CompressRequest {
|
||||
file_bytes: Vec<u8>,
|
||||
level: CompressionLevel,
|
||||
compression_rate: Option<u8>,
|
||||
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
|
||||
output_format: Option<ImageFmt>,
|
||||
max_width: Option<u32>,
|
||||
max_height: Option<u32>,
|
||||
@@ -246,6 +247,7 @@ async fn compress_json(
|
||||
format_out,
|
||||
effective_level,
|
||||
req.compression_rate,
|
||||
req.target_size_bytes, // 新增:目标大小
|
||||
req.max_width,
|
||||
req.max_height,
|
||||
req.preserve_metadata,
|
||||
@@ -603,6 +605,7 @@ async fn compress_direct(
|
||||
format_out,
|
||||
effective_level,
|
||||
req.compression_rate,
|
||||
req.target_size_bytes, // 新增:目标大小
|
||||
req.max_width,
|
||||
req.max_height,
|
||||
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 output_format: Option<ImageFmt> = 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_height: Option<u32> = None;
|
||||
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"
|
||||
);
|
||||
}
|
||||
"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 最小为 1024(1KB)",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -917,6 +939,7 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
|
||||
file_bytes,
|
||||
level,
|
||||
compression_rate,
|
||||
target_size_bytes, // 新增
|
||||
output_format,
|
||||
max_width,
|
||||
max_height,
|
||||
|
||||
@@ -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, "压缩失败"))
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ async fn process_task_file(
|
||||
format_out,
|
||||
level,
|
||||
compression_rate,
|
||||
None, // target_size_bytes: worker 批量任务不支持精确大小
|
||||
max_width,
|
||||
max_height,
|
||||
ctx.preserve_metadata,
|
||||
|
||||
Reference in New Issue
Block a user