Compare commits
3 Commits
cdec19977c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b3a08185 | ||
|
|
66454b6325 | ||
|
|
72f36c631e |
@@ -276,7 +276,7 @@ Idempotency-Key: <key> # 建议
|
|||||||
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式;ICO 自动等比缩至 256x256 边界) |
|
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式;ICO 自动等比缩至 256x256 边界) |
|
||||||
| `max_width` | Integer | 否 | 大于 0 的最大宽度(等比缩放) |
|
| `max_width` | Integer | 否 | 大于 0 的最大宽度(等比缩放) |
|
||||||
| `max_height` | 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` |
|
| `preserve_metadata` | Boolean | 否 | 是否保留 EXIF/ICC(默认 `false`);元数据输出仅支持 `jpeg/png/webp` |
|
||||||
|
|
||||||
处理约束:
|
处理约束:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface UploadItem {
|
|||||||
status: ItemStatus
|
status: ItemStatus
|
||||||
result?: CompressResponse
|
result?: CompressResponse
|
||||||
error?: string
|
error?: string
|
||||||
|
targetSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -135,6 +136,28 @@ function getTargetSizeBytes(): number | undefined {
|
|||||||
return Math.round(bytes)
|
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) {
|
function setCompressionMode(mode: CompressionMode) {
|
||||||
options.mode = mode
|
options.mode = mode
|
||||||
if (
|
if (
|
||||||
@@ -159,7 +182,7 @@ async function runOne(item: UploadItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outputFormat: OutputFormat | undefined = options.outputFormat === 'auto'
|
const outputFormat: OutputFormat | undefined = options.outputFormat === 'auto'
|
||||||
? (options.mode === 'size' ? 'webp' : undefined)
|
? (options.mode === 'size' ? targetOutputFormat(item.file) : undefined)
|
||||||
: options.outputFormat
|
: options.outputFormat
|
||||||
|
|
||||||
if (options.mode === 'size' && outputFormat && !targetSizeFormats.has(outputFormat)) {
|
if (options.mode === 'size' && outputFormat && !targetSizeFormats.has(outputFormat)) {
|
||||||
@@ -188,6 +211,7 @@ async function runOne(item: UploadItem) {
|
|||||||
auth.token,
|
auth.token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
item.targetSizeBytes = targetSizeBytes
|
||||||
item.result = result
|
item.result = result
|
||||||
item.status = 'done'
|
item.status = 'done'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -452,6 +476,9 @@ async function resendVerification() {
|
|||||||
{{ item.result.saved_percent.toFixed(2) }}%
|
{{ item.result.saved_percent.toFixed(2) }}%
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 v-if="item.error" class="mt-1 text-xs text-rose-700">{{ item.error }}</div>
|
||||||
</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'"
|
:class="options.mode === 'size' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'"
|
||||||
@click="setCompressionMode('size')"
|
@click="setCompressionMode('size')"
|
||||||
>
|
>
|
||||||
按目标大小
|
按体积上限
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -573,7 +600,7 @@ async function resendVerification() {
|
|||||||
|
|
||||||
<!-- 目标大小模式 -->
|
<!-- 目标大小模式 -->
|
||||||
<div v-else class="space-y-1">
|
<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">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="options.targetSize"
|
v-model="options.targetSize"
|
||||||
@@ -591,7 +618,7 @@ async function resendVerification() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-500">
|
<div class="text-xs text-slate-500">
|
||||||
仅支持 JPEG/WebP/AVIF;保持原格式时会自动输出 WebP,过小且无法保证清晰度的目标会被拒绝。
|
这是体积上限,不是固定输出大小。系统会优先使用原格式和最高可用画质;最高画质结果更小时不会填充无效数据。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -601,7 +628,7 @@ async function resendVerification() {
|
|||||||
v-model="options.outputFormat"
|
v-model="options.outputFormat"
|
||||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
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="jpeg">JPEG</option>
|
||||||
<option value="png" :disabled="options.mode === 'size'">PNG</option>
|
<option value="png" :disabled="options.mode === 'size'">PNG</option>
|
||||||
<option value="webp">WebP</option>
|
<option value="webp">WebP</option>
|
||||||
@@ -611,7 +638,7 @@ async function resendVerification() {
|
|||||||
<option value="tiff" :disabled="options.mode === 'size'">TIFF</option>
|
<option value="tiff" :disabled="options.mode === 'size'">TIFF</option>
|
||||||
<option value="ico" :disabled="options.mode === 'size'">ICO</option>
|
<option value="ico" :disabled="options.mode === 'size'">ICO</option>
|
||||||
</select>
|
</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>
|
</label>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<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,
|
req.max_height,
|
||||||
effective_level,
|
effective_level,
|
||||||
req.compression_rate,
|
req.compression_rate,
|
||||||
|
req.target_size_bytes,
|
||||||
format_in,
|
format_in,
|
||||||
format_out,
|
format_out,
|
||||||
original_size,
|
original_size,
|
||||||
@@ -728,6 +729,7 @@ async fn compress_direct(
|
|||||||
req.max_height,
|
req.max_height,
|
||||||
effective_level,
|
effective_level,
|
||||||
req.compression_rate,
|
req.compression_rate,
|
||||||
|
req.target_size_bytes,
|
||||||
format_in,
|
format_in,
|
||||||
format_out,
|
format_out,
|
||||||
original_size,
|
original_size,
|
||||||
@@ -1022,7 +1024,6 @@ async fn parse_single_file_request(
|
|||||||
"target_size_bytes 格式错误,需为正整数(字节)",
|
"target_size_bytes 格式错误,需为正整数(字节)",
|
||||||
)
|
)
|
||||||
})?);
|
})?);
|
||||||
// 最小目标大小限制:1KB
|
|
||||||
if let Some(size) = target_size_bytes {
|
if let Some(size) = target_size_bytes {
|
||||||
if size < 1024 {
|
if size < 1024 {
|
||||||
return Err(AppError::new(
|
return Err(AppError::new(
|
||||||
@@ -1030,6 +1031,12 @@ async fn parse_single_file_request(
|
|||||||
"target_size_bytes 最小为 1024(1KB)",
|
"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>,
|
max_height: Option<u32>,
|
||||||
level: CompressionLevel,
|
level: CompressionLevel,
|
||||||
compression_rate: Option<u8>,
|
compression_rate: Option<u8>,
|
||||||
|
target_size_bytes: Option<u64>,
|
||||||
format_in: ImageFmt,
|
format_in: ImageFmt,
|
||||||
format_out: ImageFmt,
|
format_out: ImageFmt,
|
||||||
original_size: u64,
|
original_size: u64,
|
||||||
@@ -1264,16 +1272,16 @@ async fn record_task_and_metering(
|
|||||||
INSERT INTO tasks (
|
INSERT INTO tasks (
|
||||||
id, user_id, session_id, api_key_id, client_ip, source, status,
|
id, user_id, session_id, api_key_id, client_ip, source, status,
|
||||||
compression_level, output_format, max_width, max_height, preserve_metadata,
|
compression_level, output_format, max_width, max_height, preserve_metadata,
|
||||||
compression_rate,
|
compression_rate, target_size_bytes,
|
||||||
total_files, completed_files, failed_files,
|
total_files, completed_files, failed_files,
|
||||||
total_original_size, total_compressed_size,
|
total_original_size, total_compressed_size,
|
||||||
started_at, completed_at, expires_at, retention_hours
|
started_at, completed_at, expires_at, retention_hours
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::inet, $6::task_source, 'completed',
|
$1, $2, $3, $4, $5::inet, $6::task_source, 'completed',
|
||||||
$7::compression_level, $8, $9, $10, $11, $12,
|
$7::compression_level, $8, $9, $10, $11, $12,
|
||||||
1, 1, 0,
|
$13, 1, 1, 0,
|
||||||
$13, $14,
|
$14, $15,
|
||||||
NOW(), NOW(), $15, $16
|
NOW(), NOW(), $16, $17
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -1289,6 +1297,7 @@ async fn record_task_and_metering(
|
|||||||
.bind(max_height.map(|v| v as i32))
|
.bind(max_height.map(|v| v as i32))
|
||||||
.bind(false)
|
.bind(false)
|
||||||
.bind(compression_rate.map(|v| v as i16))
|
.bind(compression_rate.map(|v| v as i16))
|
||||||
|
.bind(target_size_bytes.map(|v| v as i64))
|
||||||
.bind(original_size as i64)
|
.bind(original_size as i64)
|
||||||
.bind(compressed_size as i64)
|
.bind(compressed_size as i64)
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct BatchFileInput {
|
|||||||
struct BatchOptions {
|
struct BatchOptions {
|
||||||
level: CompressionLevel,
|
level: CompressionLevel,
|
||||||
compression_rate: Option<u8>,
|
compression_rate: Option<u8>,
|
||||||
|
target_size_bytes: Option<u64>,
|
||||||
output_format: Option<ImageFmt>,
|
output_format: Option<ImageFmt>,
|
||||||
max_width: Option<u32>,
|
max_width: Option<u32>,
|
||||||
max_height: Option<u32>,
|
max_height: Option<u32>,
|
||||||
@@ -235,16 +236,16 @@ async fn create_batch_task(
|
|||||||
INSERT INTO tasks (
|
INSERT INTO tasks (
|
||||||
id, user_id, session_id, api_key_id, client_ip, source, status,
|
id, user_id, session_id, api_key_id, client_ip, source, status,
|
||||||
compression_level, output_format, max_width, max_height, preserve_metadata,
|
compression_level, output_format, max_width, max_height, preserve_metadata,
|
||||||
compression_rate,
|
compression_rate, target_size_bytes,
|
||||||
total_files, completed_files, failed_files,
|
total_files, completed_files, failed_files,
|
||||||
total_original_size, total_compressed_size,
|
total_original_size, total_compressed_size,
|
||||||
expires_at, retention_hours, anonymous_units_reserved, anonymous_quota_date
|
expires_at, retention_hours, anonymous_units_reserved, anonymous_quota_date
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
|
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
|
||||||
$7::compression_level, $8, $9, $10, $11, $12,
|
$7::compression_level, $8, $9, $10, $11, $12,
|
||||||
$13, 0, 0,
|
$13, $14, 0, 0,
|
||||||
$14, 0,
|
$15, 0,
|
||||||
$15, $16, $17, $18
|
$16, $17, $18, $19
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -258,8 +259,9 @@ async fn create_batch_task(
|
|||||||
.bind(opts.output_format.map(|f| f.as_str()))
|
.bind(opts.output_format.map(|f| f.as_str()))
|
||||||
.bind(opts.max_width.map(|v| v as i32))
|
.bind(opts.max_width.map(|v| v as i32))
|
||||||
.bind(opts.max_height.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.compression_rate.map(|v| v as i16))
|
||||||
|
.bind(opts.target_size_bytes.map(|v| v as i64))
|
||||||
.bind(files.len() as i32)
|
.bind(files.len() as i32)
|
||||||
.bind(total_original_size)
|
.bind(total_original_size)
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
@@ -491,6 +493,7 @@ async fn parse_batch_request(
|
|||||||
let mut opts = BatchOptions {
|
let mut opts = BatchOptions {
|
||||||
level: CompressionLevel::Medium,
|
level: CompressionLevel::Medium,
|
||||||
compression_rate: None,
|
compression_rate: None,
|
||||||
|
target_size_bytes: None,
|
||||||
output_format: None,
|
output_format: None,
|
||||||
max_width: None,
|
max_width: None,
|
||||||
max_height: 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" => {
|
"max_width" => {
|
||||||
let v = text.trim();
|
let v = text.trim();
|
||||||
if !v.is_empty() {
|
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 {
|
if let Some(rate) = opts.compression_rate {
|
||||||
opts.level = compress::rate_to_level(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 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 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("");
|
let out_fmt = opts.output_format.map(|f| f.as_str()).unwrap_or("");
|
||||||
@@ -738,6 +795,10 @@ async fn parse_batch_request(
|
|||||||
.compression_rate
|
.compression_rate
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default();
|
.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 preserve = if opts.preserve_metadata { "1" } else { "0" };
|
||||||
|
|
||||||
let mut h = Sha256::new();
|
let mut h = Sha256::new();
|
||||||
@@ -745,6 +806,7 @@ async fn parse_batch_request(
|
|||||||
h.update(opts.level.as_str().as_bytes());
|
h.update(opts.level.as_str().as_bytes());
|
||||||
h.update(out_fmt.as_bytes());
|
h.update(out_fmt.as_bytes());
|
||||||
h.update(rate_key.as_bytes());
|
h.update(rate_key.as_bytes());
|
||||||
|
h.update(target_key.as_bytes());
|
||||||
h.update(mw.as_bytes());
|
h.update(mw.as_bytes());
|
||||||
h.update(mh.as_bytes());
|
h.update(mh.as_bytes());
|
||||||
h.update(preserve.as_bytes());
|
h.update(preserve.as_bytes());
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ const AVIF_TARGET_MIN_QUALITY: u8 = 38;
|
|||||||
const JPEG_PERCEPTUAL_QUALITY: u8 = 72;
|
const JPEG_PERCEPTUAL_QUALITY: u8 = 72;
|
||||||
const WEBP_PERCEPTUAL_QUALITY: u8 = 70;
|
const WEBP_PERCEPTUAL_QUALITY: u8 = 70;
|
||||||
const AVIF_PERCEPTUAL_QUALITY: u8 = 55;
|
const AVIF_PERCEPTUAL_QUALITY: u8 = 55;
|
||||||
const JPEG_TARGET_MAX_QUALITY: u8 = 90;
|
const JPEG_TARGET_MAX_QUALITY: u8 = 100;
|
||||||
const WEBP_TARGET_MAX_QUALITY: u8 = 92;
|
const WEBP_TARGET_MAX_QUALITY: u8 = 100;
|
||||||
const AVIF_TARGET_MAX_QUALITY: u8 = 90;
|
const AVIF_TARGET_MAX_QUALITY: u8 = 100;
|
||||||
const AVIF_ENCODER_SPEED: u8 = 5;
|
const AVIF_ENCODER_SPEED: u8 = 5;
|
||||||
const WEBP_TARGET_SAFETY_PERCENT: u64 = 97;
|
const WEBP_TARGET_SAFETY_PERCENT: u64 = 97;
|
||||||
|
const WEBP_HIGH_EFFORT_LOSSLESS_MAX_PIXELS: u64 = 2_100_000;
|
||||||
const METADATA_TARGET_OVERHEAD: u64 = 1024;
|
const METADATA_TARGET_OVERHEAD: u64 = 1024;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -927,6 +928,23 @@ fn encode_webp_target(
|
|||||||
) -> Result<Vec<u8>, AppError> {
|
) -> Result<Vec<u8>, AppError> {
|
||||||
deadline.check()?;
|
deadline.check()?;
|
||||||
let pixels = prepare_target_pixels(&image);
|
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 {
|
let native_min_quality = if allow_resize {
|
||||||
WEBP_PERCEPTUAL_QUALITY
|
WEBP_PERCEPTUAL_QUALITY
|
||||||
} else {
|
} else {
|
||||||
@@ -987,6 +1005,37 @@ 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 = webp_lossless_method(pixels);
|
||||||
|
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 webp_lossless_method(pixels: &TargetPixels) -> i32 {
|
||||||
|
let pixel_count = u64::from(pixels.width).saturating_mul(u64::from(pixels.height));
|
||||||
|
if pixel_count <= WEBP_HIGH_EFFORT_LOSSLESS_MAX_PIXELS {
|
||||||
|
6
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn encode_avif_target(
|
fn encode_avif_target(
|
||||||
image: DynamicImage,
|
image: DynamicImage,
|
||||||
target_size: u64,
|
target_size: u64,
|
||||||
@@ -1942,6 +1991,79 @@ mod tests {
|
|||||||
assert_eq!(detect_format(&output).unwrap(), ImageFmt::Webp);
|
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 large_webp_targets_use_the_fast_lossless_probe() {
|
||||||
|
let small = TargetPixels {
|
||||||
|
bytes: Vec::new(),
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
layout: TargetPixelLayout::Rgb,
|
||||||
|
};
|
||||||
|
let large = TargetPixels {
|
||||||
|
bytes: Vec::new(),
|
||||||
|
width: 4096,
|
||||||
|
height: 3072,
|
||||||
|
layout: TargetPixelLayout::Rgb,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(webp_lossless_method(&small), 6);
|
||||||
|
assert_eq!(webp_lossless_method(&large), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jpeg_target_encoder_prefers_perceptual_downscaling() {
|
fn jpeg_target_encoder_prefers_perceptual_downscaling() {
|
||||||
let image = DynamicImage::ImageRgb8(RgbImage::from_fn(800, 600, |x, y| {
|
let image = DynamicImage::ImageRgb8(RgbImage::from_fn(800, 600, |x, y| {
|
||||||
|
|||||||
@@ -659,6 +659,7 @@ async fn ack_message(
|
|||||||
struct TaskProcRow {
|
struct TaskProcRow {
|
||||||
compression_level: String,
|
compression_level: String,
|
||||||
compression_rate: Option<i16>,
|
compression_rate: Option<i16>,
|
||||||
|
target_size_bytes: Option<i64>,
|
||||||
max_width: Option<i32>,
|
max_width: Option<i32>,
|
||||||
max_height: Option<i32>,
|
max_height: Option<i32>,
|
||||||
preserve_metadata: bool,
|
preserve_metadata: bool,
|
||||||
@@ -735,6 +736,7 @@ pub(crate) async fn process_task(
|
|||||||
RETURNING
|
RETURNING
|
||||||
compression_level::text AS compression_level,
|
compression_level::text AS compression_level,
|
||||||
compression_rate,
|
compression_rate,
|
||||||
|
target_size_bytes,
|
||||||
max_width,
|
max_width,
|
||||||
max_height,
|
max_height,
|
||||||
preserve_metadata,
|
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 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
|
let level = compression_rate
|
||||||
.map(compress::rate_to_level)
|
.map(compress::rate_to_level)
|
||||||
.unwrap_or(compress::parse_level(&task.compression_level)?);
|
.unwrap_or(compress::parse_level(&task.compression_level)?);
|
||||||
@@ -849,6 +852,7 @@ pub(crate) async fn process_task(
|
|||||||
file,
|
file,
|
||||||
level,
|
level,
|
||||||
compression_rate,
|
compression_rate,
|
||||||
|
target_size_bytes,
|
||||||
max_width,
|
max_width,
|
||||||
max_height,
|
max_height,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -953,25 +957,18 @@ async fn file_attempt_is_current(state: &AppState, fence: &FileFence) -> Result<
|
|||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "检查文件处理租约失败").with_source(err))
|
.map_err(|err| AppError::new(ErrorCode::Internal, "检查文件处理租约失败").with_source(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
async fn claim_task_file_attempt(
|
||||||
async fn process_task_file(
|
state: &AppState,
|
||||||
state: AppState,
|
|
||||||
task_id: Uuid,
|
task_id: Uuid,
|
||||||
task_attempt: i64,
|
task_attempt: i64,
|
||||||
|
file_id: Uuid,
|
||||||
worker_id: Uuid,
|
worker_id: Uuid,
|
||||||
file: TaskFileProcRow,
|
) -> Result<Option<i64>, AppError> {
|
||||||
level: compress::CompressionLevel,
|
sqlx::query_scalar(
|
||||||
compression_rate: Option<u8>,
|
|
||||||
max_width: Option<u32>,
|
|
||||||
max_height: Option<u32>,
|
|
||||||
ctx: TaskContext,
|
|
||||||
billing_ctx: Option<billing::BillingContext>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let file_attempt: Option<i64> = sqlx::query_scalar(
|
|
||||||
r#"
|
r#"
|
||||||
UPDATE task_files AS f
|
UPDATE task_files AS f
|
||||||
SET status = 'processing',
|
SET status = 'processing',
|
||||||
processing_attempt = processing_attempt + 1,
|
processing_attempt = f.processing_attempt + 1,
|
||||||
lease_owner = $4,
|
lease_owner = $4,
|
||||||
lease_until = NOW() + $5 * INTERVAL '1 second',
|
lease_until = NOW() + $5 * INTERVAL '1 second',
|
||||||
error_message = NULL
|
error_message = NULL
|
||||||
@@ -998,14 +995,33 @@ async fn process_task_file(
|
|||||||
RETURNING f.processing_attempt
|
RETURNING f.processing_attempt
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(file.id)
|
.bind(file_id)
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
.bind(task_attempt)
|
.bind(task_attempt)
|
||||||
.bind(worker_id)
|
.bind(worker_id)
|
||||||
.bind(PROCESSING_LEASE_SECONDS)
|
.bind(PROCESSING_LEASE_SECONDS)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新文件处理状态失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "更新文件处理状态失败").with_source(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn process_task_file(
|
||||||
|
state: AppState,
|
||||||
|
task_id: Uuid,
|
||||||
|
task_attempt: i64,
|
||||||
|
worker_id: Uuid,
|
||||||
|
file: TaskFileProcRow,
|
||||||
|
level: compress::CompressionLevel,
|
||||||
|
compression_rate: Option<u8>,
|
||||||
|
target_size_bytes: Option<u64>,
|
||||||
|
max_width: Option<u32>,
|
||||||
|
max_height: Option<u32>,
|
||||||
|
ctx: TaskContext,
|
||||||
|
billing_ctx: Option<billing::BillingContext>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let file_attempt =
|
||||||
|
claim_task_file_attempt(&state, task_id, task_attempt, file.id, worker_id).await?;
|
||||||
let Some(file_attempt) = file_attempt else {
|
let Some(file_attempt) = file_attempt else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -1053,7 +1069,7 @@ async fn process_task_file(
|
|||||||
format_out,
|
format_out,
|
||||||
level,
|
level,
|
||||||
compression_rate,
|
compression_rate,
|
||||||
None, // target_size_bytes: worker 批量任务不支持精确大小
|
target_size_bytes,
|
||||||
max_width,
|
max_width,
|
||||||
max_height,
|
max_height,
|
||||||
ctx.preserve_metadata,
|
ctx.preserve_metadata,
|
||||||
@@ -1082,7 +1098,7 @@ async fn process_task_file(
|
|||||||
compression_rate,
|
compression_rate,
|
||||||
format_in == format_out,
|
format_in == format_out,
|
||||||
max_width.is_some() || max_height.is_some(),
|
max_width.is_some() || max_height.is_some(),
|
||||||
false,
|
target_size_bytes.is_some(),
|
||||||
original_size,
|
original_size,
|
||||||
compressed_size,
|
compressed_size,
|
||||||
);
|
);
|
||||||
@@ -1990,7 +2006,10 @@ mod tests {
|
|||||||
use crate::services::mail::Mailer;
|
use crate::services::mail::Mailer;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2140,16 +2159,35 @@ mod tests {
|
|||||||
original_size, status, processing_attempt, lease_owner, lease_until
|
original_size, status, processing_attempt, lease_owner, lease_until
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, 'fence.png', 'png', 'png',
|
$1, $2, 'fence.png', 'png', 'png',
|
||||||
100, 'processing', 2, $3, NOW() + INTERVAL '5 minutes'
|
100, 'pending', 0, NULL, NULL
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
.bind(winning_owner)
|
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.expect("insert test task file");
|
.expect("insert test task file");
|
||||||
|
assert_eq!(
|
||||||
|
claim_task_file_attempt(&state, task_id, 2, file_id, winning_owner)
|
||||||
|
.await
|
||||||
|
.expect("claim pending task file"),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE task_files
|
||||||
|
SET processing_attempt = 2,
|
||||||
|
lease_owner = $2,
|
||||||
|
lease_until = NOW() + INTERVAL '5 minutes'
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(file_id)
|
||||||
|
.bind(winning_owner)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("prepare winning file fence");
|
||||||
|
|
||||||
let stale_key = storage::result_attempt_key(24, task_id, file_id, 1, 1, "png");
|
let stale_key = storage::result_attempt_key(24, task_id, file_id, 1, 1, "png");
|
||||||
let winning_key = storage::result_attempt_key(24, task_id, file_id, 2, 2, "png");
|
let winning_key = storage::result_attempt_key(24, task_id, file_id, 2, 2, "png");
|
||||||
@@ -2327,6 +2365,123 @@ mod tests {
|
|||||||
.expect("query used units");
|
.expect("query used units");
|
||||||
assert_eq!(used_units, 1);
|
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;
|
discard_tracked_result(&state, &winning_object, None).await;
|
||||||
sqlx::query("DELETE FROM usage_events WHERE task_id = $1")
|
sqlx::query("DELETE FROM usage_events WHERE task_id = $1")
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user