fix: honor target image size across compression paths
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
237899745
2026-07-26 12:14:05 +08:00
parent 72f36c631e
commit 66454b6325
7 changed files with 344 additions and 22 deletions

View File

@@ -276,7 +276,7 @@ Idempotency-Key: <key> # 建议
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`默认保持原格式ICO 自动等比缩至 256x256 边界) |
| `max_width` | Integer | 否 | 大于 0 的最大宽度(等比缩放) |
| `max_height` | Integer | 否 | 大于 0 的最大高度(等比缩放) |
| `target_size_bytes` | Integer | 否 | 不小于 1024 的目标体积(字节),仅 `jpeg/webp/avif` 输出支持;不能与 `compression_rate` 同时指定 |
| `target_size_bytes` | Integer | 否 | 不小于 1024 的最大输出体积(字节),仅 `jpeg/webp/avif` 输出支持;系统选择上限内的最高保真结果,不用填充凑到固定大小;不能与 `compression_rate` 同时指定 |
| `preserve_metadata` | Boolean | 否 | 是否保留 EXIF/ICC默认 `false`);元数据输出仅支持 `jpeg/png/webp` |
处理约束:

View File

@@ -24,6 +24,7 @@ interface UploadItem {
status: ItemStatus
result?: CompressResponse
error?: string
targetSizeBytes?: number
}
const auth = useAuthStore()
@@ -135,6 +136,28 @@ function getTargetSizeBytes(): number | undefined {
return Math.round(bytes)
}
function targetOutputFormat(file: File): OutputFormat {
const mime = file.type.trim().toLowerCase()
if (mime === 'image/jpeg' || mime === 'image/jpg') return 'jpeg'
if (mime === 'image/webp') return 'webp'
if (mime === 'image/avif') return 'avif'
const extension = file.name.split('.').pop()?.toLowerCase()
if (extension === 'jpg' || extension === 'jpeg') return 'jpeg'
if (extension === 'webp') return 'webp'
if (extension === 'avif') return 'avif'
return 'webp'
}
function targetResultHint(item: UploadItem): string | null {
if (!item.result || !item.targetSizeBytes) return null
const target = formatBytes(item.targetSizeBytes)
if (item.result.compressed_size * 4 < item.targetSizeBytes * 3) {
return `体积上限 ${target};当前格式的最高保真结果本身更小,不会添加无效填充。`
}
return `体积上限 ${target};结果已控制在上限内。`
}
function setCompressionMode(mode: CompressionMode) {
options.mode = mode
if (
@@ -159,7 +182,7 @@ async function runOne(item: UploadItem) {
}
const outputFormat: OutputFormat | undefined = options.outputFormat === 'auto'
? (options.mode === 'size' ? 'webp' : undefined)
? (options.mode === 'size' ? targetOutputFormat(item.file) : undefined)
: options.outputFormat
if (options.mode === 'size' && outputFormat && !targetSizeFormats.has(outputFormat)) {
@@ -188,6 +211,7 @@ async function runOne(item: UploadItem) {
auth.token,
)
item.targetSizeBytes = targetSizeBytes
item.result = result
item.status = 'done'
} catch (err) {
@@ -452,6 +476,9 @@ async function resendVerification() {
{{ item.result.saved_percent.toFixed(2) }}%
</template>
</div>
<div v-if="targetResultHint(item)" class="mt-1 text-xs text-slate-500">
{{ targetResultHint(item) }}
</div>
<div v-if="item.error" class="mt-1 text-xs text-rose-700">{{ item.error }}</div>
</div>
@@ -547,7 +574,7 @@ async function resendVerification() {
:class="options.mode === 'size' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
@click="setCompressionMode('size')"
>
目标大小
体积上限
</button>
</div>
</div>
@@ -573,7 +600,7 @@ async function resendVerification() {
<!-- 目标大小模式 -->
<div v-else class="space-y-1">
<div class="text-xs font-medium text-slate-600">目标大小</div>
<div class="text-xs font-medium text-slate-600">最大输出大小</div>
<div class="flex gap-2">
<input
v-model="options.targetSize"
@@ -591,7 +618,7 @@ async function resendVerification() {
</select>
</div>
<div class="text-xs text-slate-500">
仅支持 JPEG/WebP/AVIF保持原格式时会自动输出 WebP过小且无法保证清晰度的目标会被拒绝
这是体积上限不是固定输出大小系统会优先使用原格式和最高可用画质最高画质结果更小时不会填充无效数据
</div>
</div>
@@ -601,7 +628,7 @@ async function resendVerification() {
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">{{ options.mode === 'size' ? '自动选择 WebP推荐' : '保持原格式(推荐)' }}</option>
<option value="auto">{{ options.mode === 'size' ? '智能选择(优先原格式' : '保持原格式(推荐)' }}</option>
<option value="jpeg">JPEG</option>
<option value="png" :disabled="options.mode === 'size'">PNG</option>
<option value="webp">WebP</option>
@@ -611,7 +638,7 @@ async function resendVerification() {
<option value="tiff" :disabled="options.mode === 'size'">TIFF</option>
<option value="ico" :disabled="options.mode === 'size'">ICO</option>
</select>
<div class="text-xs text-slate-500">支持按需转码目标大小模式建议配合 JPEG/WebP/AVIF</div>
<div class="text-xs text-slate-500">支持按需转码体积上限模式仅使用 JPEG/WebP/AVIF其他输入会自动转为 WebP</div>
</label>
<div class="grid grid-cols-2 gap-3">

View File

@@ -0,0 +1,6 @@
ALTER TABLE tasks
ADD COLUMN target_size_bytes BIGINT;
ALTER TABLE tasks
ADD CONSTRAINT tasks_target_size_bytes_check
CHECK (target_size_bytes IS NULL OR target_size_bytes >= 1024);

View File

@@ -397,6 +397,7 @@ async fn compress_json(
req.max_height,
effective_level,
req.compression_rate,
req.target_size_bytes,
format_in,
format_out,
original_size,
@@ -728,6 +729,7 @@ async fn compress_direct(
req.max_height,
effective_level,
req.compression_rate,
req.target_size_bytes,
format_in,
format_out,
original_size,
@@ -1022,7 +1024,6 @@ async fn parse_single_file_request(
"target_size_bytes 格式错误,需为正整数(字节)",
)
})?);
// 最小目标大小限制1KB
if let Some(size) = target_size_bytes {
if size < 1024 {
return Err(AppError::new(
@@ -1030,6 +1031,12 @@ async fn parse_single_file_request(
"target_size_bytes 最小为 10241KB",
));
}
if i64::try_from(size).is_err() {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 超出支持范围",
));
}
}
}
}
@@ -1229,6 +1236,7 @@ async fn record_task_and_metering(
max_height: Option<u32>,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
format_in: ImageFmt,
format_out: ImageFmt,
original_size: u64,
@@ -1264,16 +1272,16 @@ async fn record_task_and_metering(
INSERT INTO tasks (
id, user_id, session_id, api_key_id, client_ip, source, status,
compression_level, output_format, max_width, max_height, preserve_metadata,
compression_rate,
compression_rate, target_size_bytes,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
started_at, completed_at, expires_at, retention_hours
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'completed',
$7::compression_level, $8, $9, $10, $11, $12,
1, 1, 0,
$13, $14,
NOW(), NOW(), $15, $16
$13, 1, 1, 0,
$14, $15,
NOW(), NOW(), $16, $17
)
"#,
)
@@ -1289,6 +1297,7 @@ async fn record_task_and_metering(
.bind(max_height.map(|v| v as i32))
.bind(false)
.bind(compression_rate.map(|v| v as i16))
.bind(target_size_bytes.map(|v| v as i64))
.bind(original_size as i64)
.bind(compressed_size as i64)
.bind(expires_at)

View File

@@ -56,6 +56,7 @@ struct BatchFileInput {
struct BatchOptions {
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
output_format: Option<ImageFmt>,
max_width: Option<u32>,
max_height: Option<u32>,
@@ -235,16 +236,16 @@ async fn create_batch_task(
INSERT INTO tasks (
id, user_id, session_id, api_key_id, client_ip, source, status,
compression_level, output_format, max_width, max_height, preserve_metadata,
compression_rate,
compression_rate, target_size_bytes,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
expires_at, retention_hours, anonymous_units_reserved, anonymous_quota_date
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
$7::compression_level, $8, $9, $10, $11, $12,
$13, 0, 0,
$14, 0,
$15, $16, $17, $18
$13, $14, 0, 0,
$15, 0,
$16, $17, $18, $19
)
"#,
)
@@ -258,8 +259,9 @@ async fn create_batch_task(
.bind(opts.output_format.map(|f| f.as_str()))
.bind(opts.max_width.map(|v| v as i32))
.bind(opts.max_height.map(|v| v as i32))
.bind(false)
.bind(opts.preserve_metadata)
.bind(opts.compression_rate.map(|v| v as i16))
.bind(opts.target_size_bytes.map(|v| v as i64))
.bind(files.len() as i32)
.bind(total_original_size)
.bind(expires_at)
@@ -491,6 +493,7 @@ async fn parse_batch_request(
let mut opts = BatchOptions {
level: CompressionLevel::Medium,
compression_rate: None,
target_size_bytes: None,
output_format: None,
max_width: None,
max_height: None,
@@ -681,6 +684,36 @@ async fn parse_batch_request(
});
}
}
"target_size_bytes" | "target_size" => {
let v = text.trim();
if !v.is_empty() {
let parsed = match v.parse::<u64>() {
Ok(parsed) if parsed >= 1024 && i64::try_from(parsed).is_ok() => parsed,
Ok(parsed) if parsed >= 1024 => {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 超出支持范围",
));
}
Ok(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 最小为 10241KB",
));
}
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 格式错误,需为正整数(字节)",
));
}
};
opts.target_size_bytes = Some(parsed);
}
}
"max_width" => {
let v = text.trim();
if !v.is_empty() {
@@ -721,6 +754,14 @@ async fn parse_batch_request(
}
}
if opts.compression_rate.is_some() && opts.target_size_bytes.is_some() {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 与 target_size_bytes 不能同时指定",
));
}
if let Some(rate) = opts.compression_rate {
opts.level = compress::rate_to_level(rate);
}
@@ -731,6 +772,22 @@ async fn parse_batch_request(
}
}
if opts.target_size_bytes.is_some() {
if let Some(file) = files
.iter()
.find(|file| !compress::supports_target_size_format(file.output_format))
{
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!(
"target_size_bytes 仅支持输出 jpeg/webp/avif当前为 {}",
file.output_format.as_str()
),
));
}
}
let mw = opts.max_width.map(|v| v.to_string()).unwrap_or_default();
let mh = opts.max_height.map(|v| v.to_string()).unwrap_or_default();
let out_fmt = opts.output_format.map(|f| f.as_str()).unwrap_or("");
@@ -738,6 +795,10 @@ async fn parse_batch_request(
.compression_rate
.map(|v| v.to_string())
.unwrap_or_default();
let target_key = opts
.target_size_bytes
.map(|v| v.to_string())
.unwrap_or_default();
let preserve = if opts.preserve_metadata { "1" } else { "0" };
let mut h = Sha256::new();
@@ -745,6 +806,7 @@ async fn parse_batch_request(
h.update(opts.level.as_str().as_bytes());
h.update(out_fmt.as_bytes());
h.update(rate_key.as_bytes());
h.update(target_key.as_bytes());
h.update(mw.as_bytes());
h.update(mh.as_bytes());
h.update(preserve.as_bytes());

View File

@@ -33,9 +33,9 @@ const AVIF_TARGET_MIN_QUALITY: u8 = 38;
const JPEG_PERCEPTUAL_QUALITY: u8 = 72;
const WEBP_PERCEPTUAL_QUALITY: u8 = 70;
const AVIF_PERCEPTUAL_QUALITY: u8 = 55;
const JPEG_TARGET_MAX_QUALITY: u8 = 90;
const WEBP_TARGET_MAX_QUALITY: u8 = 92;
const AVIF_TARGET_MAX_QUALITY: u8 = 90;
const JPEG_TARGET_MAX_QUALITY: u8 = 100;
const WEBP_TARGET_MAX_QUALITY: u8 = 100;
const AVIF_TARGET_MAX_QUALITY: u8 = 100;
const AVIF_ENCODER_SPEED: u8 = 5;
const WEBP_TARGET_SAFETY_PERCENT: u64 = 97;
const METADATA_TARGET_OVERHEAD: u64 = 1024;
@@ -927,6 +927,23 @@ fn encode_webp_target(
) -> Result<Vec<u8>, AppError> {
deadline.check()?;
let pixels = prepare_target_pixels(&image);
let lossless_candidate = encode_webp_lossless_pixels(&pixels);
deadline.check()?;
match lossless_candidate {
Ok(lossless) if lossless.len() as u64 <= target_size => return Ok(lossless),
Ok(_) => {}
Err(error) => {
tracing::debug!(error = %error, "WebP 无损候选编码失败,继续尝试有损编码");
}
}
deadline.check()?;
let max_lossy = encode_webp_pixels(&pixels, WEBP_TARGET_MAX_QUALITY)?;
deadline.check()?;
if max_lossy.len() as u64 <= target_size {
return Ok(max_lossy);
}
let native_min_quality = if allow_resize {
WEBP_PERCEPTUAL_QUALITY
} else {
@@ -987,6 +1004,28 @@ fn encode_webp_native_target(
})
}
fn encode_webp_lossless_pixels(pixels: &TargetPixels) -> Result<Vec<u8>, AppError> {
let mut config = webp::WebPConfig::new()
.map_err(|_| AppError::new(ErrorCode::CompressionFailed, "初始化 WebP 无损配置失败"))?;
config.lossless = 1;
config.quality = 100.0;
config.method = 6;
config.alpha_compression = 1;
config.near_lossless = 100;
config.exact = 1;
config.thread_level = 0;
webp_encoder(pixels)
.encode_advanced(&config)
.map(|bytes| bytes.to_vec())
.map_err(|err| {
AppError::new(
ErrorCode::CompressionFailed,
format!("WebP 无损编码失败: {err:?}"),
)
})
}
fn encode_avif_target(
image: DynamicImage,
target_size: u64,
@@ -1942,6 +1981,60 @@ mod tests {
assert_eq!(detect_format(&output).unwrap(), ImageFmt::Webp);
}
#[test]
fn webp_target_prefers_lossless_when_it_fits() {
let image = DynamicImage::ImageRgb8(RgbImage::from_fn(160, 120, |x, y| {
let block = ((x / 20) + (y / 20) * 3) as u8;
Rgb([
block.wrapping_mul(31),
block.wrapping_mul(17),
block.wrapping_mul(11),
])
}));
let pixels = prepare_target_pixels(&image);
let lossless = encode_webp_lossless_pixels(&pixels).unwrap();
let output = encode_webp_target(
image.clone(),
lossless.len() as u64,
true,
&CompressionDeadline::unlimited(),
)
.unwrap();
assert_eq!(output, lossless);
assert_eq!(
image::load_from_memory(&output).unwrap().to_rgb8(),
image.to_rgb8()
);
}
#[test]
fn webp_target_uses_quality_100_when_lossless_exceeds_the_cap() {
let mut state = 0x7f4a_7c15_u32;
let image = DynamicImage::ImageRgb8(RgbImage::from_fn(160, 120, |_x, _y| {
let mut channel = || {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
state as u8
};
Rgb([channel(), channel(), channel()])
}));
let pixels = prepare_target_pixels(&image);
let max_lossy = encode_webp_pixels(&pixels, WEBP_TARGET_MAX_QUALITY).unwrap();
let lossless = encode_webp_lossless_pixels(&pixels).unwrap();
assert!(max_lossy.len() < lossless.len());
let output = encode_webp_target(
image,
max_lossy.len() as u64,
true,
&CompressionDeadline::unlimited(),
)
.unwrap();
assert_eq!(output, max_lossy);
}
#[test]
fn jpeg_target_encoder_prefers_perceptual_downscaling() {
let image = DynamicImage::ImageRgb8(RgbImage::from_fn(800, 600, |x, y| {

View File

@@ -659,6 +659,7 @@ async fn ack_message(
struct TaskProcRow {
compression_level: String,
compression_rate: Option<i16>,
target_size_bytes: Option<i64>,
max_width: Option<i32>,
max_height: Option<i32>,
preserve_metadata: bool,
@@ -735,6 +736,7 @@ pub(crate) async fn process_task(
RETURNING
compression_level::text AS compression_level,
compression_rate,
target_size_bytes,
max_width,
max_height,
preserve_metadata,
@@ -782,6 +784,7 @@ pub(crate) async fn process_task(
};
let compression_rate = task.compression_rate.and_then(|v| u8::try_from(v).ok());
let target_size_bytes = task.target_size_bytes.and_then(|v| u64::try_from(v).ok());
let level = compression_rate
.map(compress::rate_to_level)
.unwrap_or(compress::parse_level(&task.compression_level)?);
@@ -849,6 +852,7 @@ pub(crate) async fn process_task(
file,
level,
compression_rate,
target_size_bytes,
max_width,
max_height,
ctx,
@@ -1010,6 +1014,7 @@ async fn process_task_file(
file: TaskFileProcRow,
level: compress::CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
max_width: Option<u32>,
max_height: Option<u32>,
ctx: TaskContext,
@@ -1064,7 +1069,7 @@ async fn process_task_file(
format_out,
level,
compression_rate,
None, // target_size_bytes: worker 批量任务不支持精确大小
target_size_bytes,
max_width,
max_height,
ctx.preserve_metadata,
@@ -1093,7 +1098,7 @@ async fn process_task_file(
compression_rate,
format_in == format_out,
max_width.is_some() || max_height.is_some(),
false,
target_size_bytes.is_some(),
original_size,
compressed_size,
);
@@ -2001,7 +2006,10 @@ mod tests {
use crate::services::mail::Mailer;
use bytes::Bytes;
use chrono::Utc;
use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
use sqlx::postgres::PgPoolOptions;
use std::io::Cursor;
use std::path::PathBuf;
use tokio::sync::Barrier;
#[test]
@@ -2357,6 +2365,123 @@ mod tests {
.expect("query used units");
assert_eq!(used_units, 1);
let target_task_id = Uuid::new_v4();
let target_file_id = Uuid::new_v4();
let target_worker = Uuid::new_v4();
let target_image = DynamicImage::ImageRgb8(RgbImage::from_fn(160, 120, |x, y| {
let block = ((x / 20) + (y / 20) * 3) as u8;
Rgb([
block.wrapping_mul(31),
block.wrapping_mul(17),
block.wrapping_mul(11),
])
}));
let mut input_cursor = Cursor::new(Vec::new());
target_image
.write_to(&mut input_cursor, ImageFormat::Png)
.expect("encode target-size input PNG");
let target_input = input_cursor.into_inner();
let target_input_dir = PathBuf::from(&state.config.storage_path)
.join("orig")
.join(target_task_id.to_string());
tokio::fs::create_dir_all(&target_input_dir)
.await
.expect("create target-size input directory");
let target_input_path = target_input_dir.join("source.png");
tokio::fs::write(&target_input_path, &target_input)
.await
.expect("write target-size input");
sqlx::query(
r#"
INSERT INTO tasks (
id, user_id, status, compression_level, output_format,
target_size_bytes, total_files, total_original_size,
expires_at, retention_hours
) VALUES (
$1, $2, 'pending', 'medium', 'webp',
$3, 1, $4,
NOW() + INTERVAL '1 day', 24
)
"#,
)
.bind(target_task_id)
.bind(user_id)
.bind(1_048_576_i64)
.bind(target_input.len() as i64)
.execute(&pool)
.await
.expect("insert target-size task");
sqlx::query(
r#"
INSERT INTO task_files (
id, task_id, original_name, original_format, output_format,
original_size, input_path, status
) VALUES ($1, $2, 'source.png', 'png', 'webp', $3, $4, 'pending')
"#,
)
.bind(target_file_id)
.bind(target_task_id)
.bind(target_input.len() as i64)
.bind(target_input_path.to_string_lossy().to_string())
.execute(&pool)
.await
.expect("insert target-size task file");
assert_eq!(
process_task(&state, target_task_id, target_worker)
.await
.expect("process target-size task"),
TaskProcessOutcome::Done
);
let target_task_status: String =
sqlx::query_scalar("SELECT status::text FROM tasks WHERE id = $1")
.bind(target_task_id)
.fetch_one(&pool)
.await
.expect("query target-size task status");
assert_eq!(target_task_status, "completed");
let target_result: (String, Option<Uuid>, String, i64) = sqlx::query_as(
r#"
SELECT storage_backend, storage_endpoint_id, storage_key, compressed_size
FROM task_files
WHERE id = $1
"#,
)
.bind(target_file_id)
.fetch_one(&pool)
.await
.expect("query target-size result");
assert!(target_result.3 <= 1_048_576);
let target_locator = storage::ObjectLocator {
backend: target_result.0,
endpoint_id: target_result.1,
key: target_result.2,
};
let target_output = storage::read_bytes(&state, &target_locator)
.await
.expect("read target-size result");
assert_eq!(
image::load_from_memory(&target_output)
.expect("decode target-size result")
.to_rgb8(),
target_image.to_rgb8(),
"worker must forward target_size_bytes and select the lossless candidate"
);
storage::delete_object(&state, &target_locator)
.await
.expect("delete target-size result object");
sqlx::query("DELETE FROM usage_events WHERE task_id = $1")
.bind(target_task_id)
.execute(&pool)
.await
.expect("delete target-size usage event");
sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(target_task_id)
.execute(&pool)
.await
.expect("delete target-size task");
discard_tracked_result(&state, &winning_object, None).await;
sqlx::query("DELETE FROM usage_events WHERE task_id = $1")
.bind(task_id)