fix: honor target image size across compression paths
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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` |
|
||||
|
||||
处理约束:
|
||||
|
||||
@@ -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">
|
||||
|
||||
6
migrations/027_task_target_size.sql
Normal file
6
migrations/027_task_target_size.sql
Normal 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);
|
||||
@@ -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<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)
|
||||
|
||||
@@ -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 最小为 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());
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user