diff --git a/docs/api.md b/docs/api.md index ccea2ac..953260a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -276,7 +276,7 @@ Idempotency-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` | 处理约束: diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 625c64f..fb274a3 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.vue @@ -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) }}% +
+ {{ targetResultHint(item) }} +
{{ item.error }}
@@ -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')" > - 按目标大小 + 按体积上限 @@ -573,7 +600,7 @@ async function resendVerification() {
-
目标大小
+
最大输出大小
- 仅支持 JPEG/WebP/AVIF;保持原格式时会自动输出 WebP,过小且无法保证清晰度的目标会被拒绝。 + 这是体积上限,不是固定输出大小。系统会优先使用原格式和最高可用画质;最高画质结果更小时不会填充无效数据。
@@ -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" > - + @@ -611,7 +638,7 @@ async function resendVerification() { -
支持按需转码。目标大小模式建议配合 JPEG/WebP/AVIF。
+
支持按需转码。体积上限模式仅使用 JPEG/WebP/AVIF;其他输入会自动转为 WebP。
diff --git a/migrations/027_task_target_size.sql b/migrations/027_task_target_size.sql new file mode 100644 index 0000000..73428e2 --- /dev/null +++ b/migrations/027_task_target_size.sql @@ -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); diff --git a/src/api/compress.rs b/src/api/compress.rs index c542976..622b99a 100644 --- a/src/api/compress.rs +++ b/src/api/compress.rs @@ -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 最小为 1024(1KB)", )); } + 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, level: CompressionLevel, compression_rate: Option, + target_size_bytes: Option, 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) diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 99bbd38..66e745f 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -56,6 +56,7 @@ struct BatchFileInput { struct BatchOptions { level: CompressionLevel, compression_rate: Option, + target_size_bytes: Option, output_format: Option, max_width: Option, max_height: Option, @@ -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::() { + 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 最小为 1024(1KB)", + )); + } + 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()); diff --git a/src/services/compress.rs b/src/services/compress.rs index c43d42a..12d8b29 100644 --- a/src/services/compress.rs +++ b/src/services/compress.rs @@ -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, 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, 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| { diff --git a/src/worker/mod.rs b/src/worker/mod.rs index 1169d79..75414d5 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -659,6 +659,7 @@ async fn ack_message( struct TaskProcRow { compression_level: String, compression_rate: Option, + target_size_bytes: Option, max_width: Option, max_height: Option, 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, + target_size_bytes: Option, max_width: Option, max_height: Option, 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, 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)