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

@@ -6,9 +6,9 @@
### 核心功能
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态)
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态,支持格式转换
- **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100数值越压缩越强)
- **压缩率**1-100数值越压缩越强100 为不压缩
- **用户系统**注册、登录、API Key 管理
- **计费与用量**:套餐/订阅/配额/发票
- **管理员后台**:用户管理、系统监控、配置管理

View File

@@ -274,9 +274,10 @@ Idempotency-Key: <key> # 建议
| `file` | File | 是 | 图片文件 |
| `compression_rate` | Integer | 否 | 压缩率 1-100压缩后体积占原图比例数值越小压缩越强100 表示不压缩),优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` |
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式 |
| `max_width` | Integer | 否 | 最大宽度(等比缩放) |
| `max_height` | Integer | 否 | 最大高度(等比缩放) |
| `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |
响应:
@@ -309,6 +310,8 @@ X-API-Key: <your-api-key> # 或 Bearer token不建议匿名
Idempotency-Key: <key> #
```
表单字段与 `/compress` 一致(包括 `output_format``target_size_bytes`)。
成功响应:
- HTTP `200`
- Body压缩后的图片二进制
@@ -339,7 +342,7 @@ Idempotency-Key: <key> # 建议
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
| `compression_rate` | Integer | 否 | 压缩率 1-100压缩后体积占原图比例数值越小压缩越强100 表示不压缩),优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
| `output_format` | String | 否 | 已停用,仅支持保持原格式 |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式 |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |
响应:

View File

@@ -44,7 +44,7 @@
核心组件:
- 顶栏Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单)
- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量)
- 参数区:压缩率(压缩后体积占比)、尺寸限制、元数据开关(输出格式保持原图)
- 参数区:压缩率(压缩后体积占比)/目标体积、输出格式、尺寸限制、元数据开关
- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试)
- 汇总区:总节省、下载 ZIP、清空
- 信任区:隐私说明(默认去 EXIF、保留期说明、状态页/联系入口

View File

@@ -19,7 +19,7 @@
<ul class="mt-2 list-disc space-y-1 pl-5">
<li>Base URL<code>https://ys.workyai.cn/api/v1</code></li>
<li>认证方式<code>X-API-Key</code>推荐 <code>Authorization: Bearer &lt;token&gt;</code></li>
<li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF静态/ BMP / TIFF / ICO</li>
<li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF静态/ BMP / TIFF / ICO支持 output_format 转码</li>
<li>压缩率<code>compression_rate</code> 1-100表示压缩后体积占原图比例100 为不压缩</li>
<li>计量成功压缩 1 个文件计 1 若体积未变小或压缩率为 100则不扣额度</li>
</ul>
@@ -28,14 +28,15 @@
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
<div class="font-medium text-slate-900">同步压缩返回 JSON + 下载链接</div>
<div class="mt-2 text-xs text-slate-500">
<code>POST /compress</code>表单字段<code>file</code><code>compression_rate</code>
<code>max_width</code><code>max_height</code><code>preserve_metadata</code>
<code>POST /compress</code>表单字段<code>file</code><code>compression_rate</code>/<code>target_size_bytes</code>
<code>output_format</code><code>max_width</code><code>max_height</code><code>preserve_metadata</code>
</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \\
-H \"X-API-Key: if_live_xxx\" \\
-F \"file=@./demo.jpg\" \\
-F \"compression_rate=20\" \\
-F \"max_width=2000\" \\
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \
-H "X-API-Key: if_live_xxx" \
-F "file=@./demo.jpg" \
-F "compression_rate=20" \
-F "output_format=webp" \
-F "max_width=2000" \
https://ys.workyai.cn/api/v1/compress</code></pre>
<div class="mt-3 text-xs text-slate-500">
返回字段包含 <code>download_url</code><code>saved_percent</code> <code>billing.units_charged</code>
@@ -45,12 +46,13 @@
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
<div class="font-medium text-slate-900">同步压缩直接返回二进制</div>
<div class="mt-2 text-xs text-slate-500">
<code>POST /compress/direct</code>响应头包含原/压缩大小与扣费信息
<code>POST /compress/direct</code>支持同样参数 output_formattarget_size_bytes响应头包含原/压缩大小与扣费信息
</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \\
-H \"X-API-Key: if_live_xxx\" \\
-F \"file=@./demo.jpg\" \\
-F \"compression_rate=20\" \\
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \
-H "X-API-Key: if_live_xxx" \
-F "file=@./demo.jpg" \
-F "target_size_bytes=120000" \
-F "output_format=jpeg" \
https://ys.workyai.cn/api/v1/compress/direct -o out.jpg -D headers.txt</code></pre>
<div class="mt-3 text-xs text-slate-500">
重点响应头<code>ImageForge-Original-Size</code>
@@ -64,16 +66,16 @@
<div class="mt-2 text-xs text-slate-500">
<code>POST /compress/batch</code>上传多文件后返回任务 ID
</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \\
-H \"X-API-Key: if_live_xxx\" \\
-F \"files[]=@./a.jpg\" \\
-F \"files[]=@./b.jpg\" \\
-F \"compression_rate=30\" \\
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \
-H "X-API-Key: if_live_xxx" \
-F "files[]=@./a.jpg" \
-F "files[]=@./b.jpg" \
-F "compression_rate=30" \
https://ys.workyai.cn/api/v1/compress/batch</code></pre>
<div class="mt-3 text-xs text-slate-500">
轮询任务<code>GET /compress/tasks/&lt;task_id&gt;</code>响应包含每个文件的 <code>download_url</code> <code>download_all_url</code>ZIP
</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -H \"X-API-Key: if_live_xxx\" \\
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -H "X-API-Key: if_live_xxx" \
https://ys.workyai.cn/api/v1/compress/tasks/550e8400-e29b-41d4-a716-446655440200</code></pre>
</div>
@@ -82,10 +84,10 @@
<div class="mt-2 text-xs text-slate-500">
下载地址不在 <code>/api/v1</code> 需带同样的认证头
</div>
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -H \"X-API-Key: if_live_xxx\" \\
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -H "X-API-Key: if_live_xxx" \
-L https://ys.workyai.cn/downloads/&lt;file_id&gt; -o result.jpg
curl -H \"X-API-Key: if_live_xxx\" \\
curl -H "X-API-Key: if_live_xxx" \
-L https://ys.workyai.cn/downloads/tasks/&lt;task_id&gt; -o batch.zip</code></pre>
</div>

View File

@@ -21,12 +21,14 @@ interface UploadItem {
const auth = useAuthStore()
type CompressionMode = 'percent' | 'size'
type OutputFormatOption = 'auto' | 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico'
const options = reactive({
mode: 'percent' as CompressionMode, // 压缩模式:百分比 / 目标大小
compressionRate: 60,
targetSize: '' as string, // 目标大小数值
targetUnit: 'KB' as 'KB' | 'MB', // 目标大小单位
outputFormat: 'auto' as OutputFormatOption,
maxWidth: '' as string,
maxHeight: '' as string,
})
@@ -114,7 +116,9 @@ 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
const bytes = options.targetUnit === 'MB' ? size * 1024 * 1024 : size * 1024
if (!Number.isFinite(bytes) || bytes < 1024) return undefined
return Math.round(bytes)
}
async function runOne(item: UploadItem) {
@@ -122,14 +126,25 @@ async function runOne(item: UploadItem) {
item.error = undefined
try {
const targetSizeBytes = getTargetSizeBytes()
if (options.mode === 'size' && !targetSizeBytes) {
item.status = 'error'
item.error = '请填写有效目标大小(至少 1KB'
return
}
const outputFormat = options.outputFormat === 'auto' ? undefined : options.outputFormat
const compressOptions = options.mode === 'percent'
? {
compression_rate: options.compressionRate,
output_format: outputFormat,
max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight),
}
: {
target_size_bytes: getTargetSizeBytes(),
target_size_bytes: targetSizeBytes,
output_format: outputFormat,
max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight),
}
@@ -542,6 +557,25 @@ async function resendVerification() {
<div class="text-xs text-slate-500">直接指定压缩后的目标大小系统会自动调整质量以逼近目标 JPEG/WebP/AVIF 支持</div>
</div>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">输出格式</div>
<select
v-model="options.outputFormat"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
>
<option value="auto">保持原格式推荐</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
<option value="gif">GIF仅静态</option>
<option value="bmp">BMP</option>
<option value="tiff">TIFF</option>
<option value="ico">ICO</option>
</select>
<div class="text-xs text-slate-500">支持按需转码目标大小模式建议配合 JPEG/WebP/AVIF</div>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大宽度px</div>

View File

@@ -77,6 +77,22 @@ fn default_units_charged() -> i32 {
1
}
fn ensure_target_size_supported(
target_size_bytes: Option<u64>,
format_out: ImageFmt,
) -> Result<(), AppError> {
if target_size_bytes.is_some() && !compress::supports_target_size_format(format_out) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!(
"target_size_bytes 仅支持输出 jpeg/webp/avif当前为 {}",
format_out.as_str()
),
));
}
Ok(())
}
async fn compress_json(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
@@ -90,15 +106,8 @@ async fn compress_json(
let req = parse_single_file_request(&mut multipart).await?;
let format_in = compress::detect_format(&req.file_bytes)?;
if let Some(format_out) = req.output_format {
if format_out != format_in {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"当前仅支持保持原图片格式",
));
}
}
let format_out = format_in;
let format_out = req.output_format.unwrap_or(format_in);
ensure_target_size_supported(req.target_size_bytes, format_out)?;
let effective_level = req
.compression_rate
.map(compress::rate_to_level)
@@ -407,15 +416,8 @@ async fn compress_direct(
}
let format_in = compress::detect_format(&req.file_bytes)?;
if let Some(format_out) = req.output_format {
if format_out != format_in {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"当前仅支持保持原图片格式",
));
}
}
let format_out = format_in;
let format_out = req.output_format.unwrap_or(format_in);
ensure_target_size_supported(req.target_size_bytes, format_out)?;
let effective_level = req
.compression_rate
.map(compress::rate_to_level)

View File

@@ -540,12 +540,10 @@ async fn parse_batch_request(
opts.level = compress::rate_to_level(rate);
}
if opts.output_format.is_some() {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"当前仅支持保持原图片格式",
));
if let Some(format_out) = opts.output_format {
for file in &mut files {
file.output_format = format_out;
}
}
let mw = opts.max_width.map(|v| v.to_string()).unwrap_or_default();

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 {