diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 29d29c4f3..c2b9f4ad4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 的透明图。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f36f40205..05a692290 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, Option) { + 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, ) -> (DownloadedOpenAiImage, Option, Option) { @@ -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");