feat(compress): improve quality guardrails and format conversion
This commit is contained in:
@@ -6,9 +6,9 @@
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态)
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态,支持格式转换)
|
||||
- **批量处理**:支持多图片同时上传和处理
|
||||
- **压缩率**:1-100(数值越大压缩越强)
|
||||
- **压缩率**:1-100(数值越小压缩越强,100 为不压缩)
|
||||
- **用户系统**:注册、登录、API Key 管理
|
||||
- **计费与用量**:套餐/订阅/配额/发票
|
||||
- **管理员后台**:用户管理、系统监控、配置管理
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
响应:
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
核心组件:
|
||||
- 顶栏:Logo + `/pricing` `/docs` + 登录/注册(登录后显示头像菜单)
|
||||
- 上传区:拖拽 + 点击选择 + 限制提示(格式/大小/数量)
|
||||
- 参数区:压缩率(压缩后体积占比)、尺寸限制、元数据开关(输出格式保持原图)
|
||||
- 参数区:压缩率(压缩后体积占比)/目标体积、输出格式、尺寸限制、元数据开关
|
||||
- 结果区:文件列表(缩略图、大小、节省%、状态、下载/重试)
|
||||
- 汇总区:总节省、下载 ZIP、清空
|
||||
- 信任区:隐私说明(默认去 EXIF)、保留期说明、状态页/联系入口
|
||||
|
||||
@@ -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 <token></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_format、target_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/<task_id></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/<file_id> -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/<task_id> -o batch.zip</code></pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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