修复像素规整降级绕过交付尺寸守卫
Project CI / Repository checks (pull_request) Successful in 1m4s
Project CI / Frontend tests (pull_request) Successful in 2m1s
Project CI / Native shell tests (pull_request) Successful in 2m26s
Project CI / Backend tests (pull_request) Successful in 3m28s

像素路径的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、
CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)此前都直接
返回 BgFilter 原始输出并把尺寸错误置为 None,跳过非像素路径已有的尺寸比对
与 alpha 回贴。BgFilter 回图尺寸漂移叠加并发上限 2 的 permit 超时后,角色会
绕过原图安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。

新增 degrade_editor_pixel_art_to_postprocessed_with_dimension_guard 收口所有
降级分支,复用非像素路径的守卫:尺寸一致原样返回且不产生额外 OSS GET,漂移
才回读原图重贴 alpha,修复失败返回尺寸错误交调用方降级。由 provider 原图合成
的 rgba_source fallback 尺寸天然正确,不经守卫。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:19:01 +00:00
parent 9655324dea
commit a7d8bccec9
2 changed files with 150 additions and 23 deletions
@@ -16,6 +16,16 @@
---
## 2026-07-29 像素规整降级必须复用交付尺寸守卫
- 背景:像素模式接入「角色带背景原图与透明图统一交付尺寸」后,删除了原先像素路径末尾的后置尺寸恢复。但像素规整的 best-effort 降级分支(预算耗尽、回读 provider 原图失败或超时、CPU permit 获取失败、worker 内 deadline、join 异常、worker 超时)都直接返回 BgFilter 原始输出并把尺寸错误置为 `None`,跳过了非像素路径已有的尺寸比对与 alpha 回贴。BgFilter 回图尺寸漂移是已知现象,叠加并发上限 2 导致的 permit 超时后,角色会绕过「改用已保存的同尺寸原图完成画布」的安全降级,角色和图标都可能持久化尺寸漂移的低分辨率透明图。
- 决策:像素路径的每一条降级都必须经 `degrade_editor_pixel_art_to_postprocessed_with_dimension_guard` 收口,该守卫复用非像素路径的 `apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original`:先做纯内存尺寸比对,与交付尺寸一致就原样返回且不产生额外 OSS GET;漂移才回读原图重贴 alpha;修复失败返回尺寸错误交由调用方降级。由 provider 原图逐像素合成的 `rgba_source` fallback 尺寸天然正确,不再经守卫。像素路径函数因此需要显式接收交付宽高。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色与图标像素规整降级路径;不改变成功路径、OSS PUT 次数、资源类型、画布项或前端契约,OSS GET 仍只在尺寸漂移时发生。
- 验证方式:`pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions` 结构断言固定"降级分支不得返回 `(postprocessed, None, …)`"与守卫的委托实现;运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml``npm run check:rustfmt``npm run check:encoding``git diff --check`
- 关联文档:本文件「2026-07-29 角色带背景原图与透明图统一交付尺寸」与「2026-07-28 图片生成风格使用可扩展字段并以纯内存像素规整首发」。
---
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
+140 -23
View File
@@ -2028,6 +2028,8 @@ pub(crate) async fn generate_editor_image_for_owner(
apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
delivery_width,
delivery_height,
removal.image,
request_context.external_call_deadline(),
)
@@ -3204,9 +3206,35 @@ fn resolve_editor_pixel_art_processing_deadline(
.unwrap_or(local_deadline)
}
// 中文注释:像素规整的每一条 best-effort 降级都不能直接把 BgFilter 原始输出当作
// 最终结果——它的尺寸可能相对交付尺寸漂移。这里复用非像素路径的同一套守卫:先做
// 纯内存尺寸比对,一致就原样返回且不产生 OSS GET;只有真的漂移才回读原图重贴
// alpha,修不好则返回尺寸错误,由调用方走原图安全降级。
async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state: &AppState,
provider_source_object_key: &str,
delivery_width: u32,
delivery_height: u32,
postprocessed: DownloadedOpenAiImage,
reason: String,
) -> (DownloadedOpenAiImage, Option<AppError>, Option<String>) {
let (image, dimension_error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state,
provider_source_object_key,
delivery_width,
delivery_height,
postprocessed,
)
.await;
(image, dimension_error, Some(reason))
}
async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(
state: &AppState,
provider_source_object_key: &str,
delivery_width: u32,
delivery_height: u32,
postprocessed: DownloadedOpenAiImage,
request_deadline: Option<Instant>,
) -> (DownloadedOpenAiImage, Option<AppError>, Option<String>) {
@@ -3216,11 +3244,15 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
// BgFilter 前持久化并释放,因此这里只回读既有对象一次;这次回读同时承担
// 尺寸漂移时的 alpha 回贴,禁止为两个步骤分别发起 OSS GET。
if Instant::now() >= processing_deadline {
return (
return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
postprocessed,
None,
Some("像素规整处理预算已耗尽,已保留透明后处理图。".to_string()),
);
"像素规整处理预算已耗尽,已保留透明后处理图。".to_string(),
)
.await;
}
let provider_source = match tokio::time::timeout_at(
tokio::time::Instant::from_std(processing_deadline),
@@ -3231,19 +3263,41 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
Ok(Ok(provider_source)) => provider_source,
Ok(Err(error)) => {
let reason = error.body_text();
return (postprocessed, None, Some(reason));
return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
postprocessed,
reason,
)
.await;
}
Err(_) => {
return (
return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
postprocessed,
None,
Some("像素规整读取 provider 原图超时,已保留透明后处理图。".to_string()),
);
"像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(),
)
.await;
}
};
let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await {
Ok(permit) => permit,
Err(error) => return (postprocessed, None, Some(error)),
Err(error) => {
return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
postprocessed,
error,
)
.await;
}
};
let provider_source = Arc::new(provider_source);
@@ -3287,21 +3341,49 @@ async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_
tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await;
match result {
Ok(Ok(Ok(image))) => (image, None, None),
Ok(Ok(Err((fallback, preparation_error, error)))) => (
fallback.unwrap_or_else(|| take_arc_downloaded_image(postprocessed)),
preparation_error,
// 中文注释:fallback 由 provider 原图逐像素合成,尺寸天然等于交付尺寸,
// 不需要再走守卫;准备阶段已产出尺寸错误时同样直接交回调用方降级。
Ok(Ok(Err((Some(rgba_source), preparation_error, error)))) => {
(rgba_source, preparation_error, Some(error))
}
Ok(Ok(Err((None, Some(preparation_error), error)))) => (
take_arc_downloaded_image(postprocessed),
Some(preparation_error),
Some(error),
),
Ok(Err(error)) => (
take_arc_downloaded_image(postprocessed),
None,
Some(format!("像素规整工作线程异常:{error}")),
),
Err(_) => (
take_arc_downloaded_image(postprocessed),
None,
Some("像素规整处理超时,已保留透明后处理图。".to_string()),
),
Ok(Ok(Err((None, None, error)))) => {
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
take_arc_downloaded_image(postprocessed),
error,
)
.await
}
Ok(Err(error)) => {
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
take_arc_downloaded_image(postprocessed),
format!("像素规整工作线程异常:{error}"),
)
.await
}
Err(_) => {
degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(
state,
provider_source_object_key,
delivery_width,
delivery_height,
take_arc_downloaded_image(postprocessed),
"像素规整处理超时,已保留透明后处理图。".to_string(),
)
.await
}
}
}
@@ -4630,6 +4712,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
source_width,
source_height,
removal.image,
request_context.external_call_deadline(),
)
@@ -9201,6 +9285,39 @@ mod tests {
);
}
#[test]
fn pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions() {
let source = include_str!("editor_project.rs");
// 中文注释:像素规整的每一条 best-effort 降级(预算耗尽、回读原图失败或超时、
// permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter
// 原始输出连同 None 尺寸错误直接交回调用方,否则角色会绕过原图安全降级、
// 角色和图标都可能持久化尺寸漂移的低分辨率透明图。
assert_function_not_contains(
source,
"async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original",
"fn restore_editor_generated_image_output_dimensions",
&[
"return (postprocessed, None,",
"(postprocessed, None, Some(reason))",
"take_arc_downloaded_image(postprocessed),\n None,",
"fallback.unwrap_or_else(",
],
);
// 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时
// 不产生额外 OSS GET,漂移修不好时返回尺寸错误。
assert_function_contains_in_order(
source,
"async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard",
"async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original",
&[
"apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
"delivery_width",
"delivery_height",
"(image, dimension_error, Some(reason))",
],
);
}
#[test]
fn pixel_art_provider_input_prep_shares_deadline_and_cpu_permit() {
let source = include_str!("editor_project.rs");