修复像素规整降级绕过交付尺寸守卫
像素路径的 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:
@@ -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` 的透明图。
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user