diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc2d27490..667c04b70 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 角色带背景原图与透明图统一交付尺寸 + +- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。 +- 决策:角色分支必须在持久化带纯色背景原图和调用 BgFilter 之前,先按统一业务像素矩阵执行一次尺寸归一;该原图和透明派生图始终使用同一实际像素尺寸,1K 的长边为 `1024`。若 provider 回图任意一边小于业务目标或比例偏差过大,仍禁止放大或大幅裁切;此时两张图一同保留 provider 实际尺寸并返回通用 `warning`,不允许只改透明图。BgFilter 回图尺寸漂移时只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并回贴到该原图;蒙版比例超限、回贴失败或尺寸验证失败时必须改用原图单产物降级,不持久化尺寸或比例不一致的透明图。若尺寸降级和后处理降级同时发生,同一条 `warning.reason` 必须同时保留两个原因。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs` 的角色生成、原图持久化、BgFilter 输入、项目资源尺寸与画布图层 Resolution;不改变前端请求 DTO、扣费、素材类型或多产物布局。 +- 验证方式:后端定向测试覆盖角色全尺寸矩阵:`nanobanana2` 的 `0.5K / 1K / 2K` 和 `gpt-image-2` 的 `1K / 2K`,每档均覆盖 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`,30 个组合全部构造大于目标尺寸的真实 PNG provider 回图并执行像素恢复,不只校验字符串映射;另覆盖欠尺寸禁止放大、比例超限、BgFilter 错比例 alpha 蒙版拒绝和组合告警。同时从函数调用顺序上固定“尺寸归一 → 持久化带背景原图 → BgFilter”。运行 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-23 画布 Agent 工具生命周期统一经 object-safe trait 分派 - 背景:画布 Agent 八类工具的参数规范化、确认展示、计价与 worker payload、完成结果格式化和媒体投影分别在 `tool_args.rs`、`display_args.rs`、`api.rs`、`reconcile.rs` 重复按工具名分派;新增或调整工具时容易漏改其中一处。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a5536fdd7..a02142e26 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -93,7 +93,7 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index af5e67aeb..4cf462c55 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -123,6 +123,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-con const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; +const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -631,6 +632,19 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration } } +fn editor_postprocess_fallback_warning_with_dimension( + reason: &'static str, + dimension_warning: Option<&EditorGenerationWarningResponse>, +) -> EditorGenerationWarningResponse { + let reason = dimension_warning + .map(|warning| format!("{reason} {}", warning.reason)) + .unwrap_or_else(|| reason.to_string()); + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE, + reason, + } +} + fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { let reason = error .details() @@ -1774,18 +1788,16 @@ pub(crate) async fn generate_editor_image_for_owner( // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, // 保证不同模型的完成图与生成前占位标注一致。 - let (restored_image, dimension_restore_error) = if is_character_generation { - // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; - // 透明主结果会在后处理完成后再归一到画布业务规格。 - (image, None) - } else { + // 角色带背景原图和透明结果是同一个业务产物的两个版本,必须在 + // provider 原图持久化前就收口到同一交付尺寸。否则 1K 任务会把模型原生 + // 2752x1536 原图和长边 1024 的透明图同时放入画布。 + let (restored_image, dimension_restore_error) = restore_editor_generated_image_output_dimensions_or_original( image, generation_options.model, image_size.as_ref(), - ) - }; - let mut dimension_warning = dimension_restore_error + ); + let dimension_warning = dimension_restore_error .as_ref() .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { @@ -1907,8 +1919,9 @@ pub(crate) async fn generate_editor_image_for_owner( resource: source_record.resource, asset: source_record.asset, project: completed_project, - warning: Some(editor_postprocess_fallback_warning( + warning: Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", + dimension_warning.as_ref(), )), }, )); @@ -1933,8 +1946,39 @@ pub(crate) async fn generate_editor_image_for_owner( delivery_width, delivery_height, error = ?error, - "角色透明图尺寸恢复失败,保留去背景服务原始输出" + "角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布" ); + let completed_project = complete_editor_canvas_generation( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + source_record.resource.as_ref(), + ) + .await?; + return Ok(json_success_body( + Some(&request_context), + EditorImageGenerationResponse { + image_src: source_image_src, + object_key: Some(source_object_key), + asset_object_id: Some(source_asset_object_id), + width: delivery_width, + height: delivery_height, + source_type: "generated", + prompt: role_setting, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + resource: source_record.resource, + asset: source_record.asset, + project: completed_project, + warning: Some(editor_postprocess_fallback_warning_with_dimension( + "生成任务成功,后处理失败。", + dimension_warning.as_ref(), + )), + }, + )); } image = restored_removal_image; output_prompt = "去除纯色背景".to_string(); @@ -1948,27 +1992,6 @@ pub(crate) async fn generate_editor_image_for_owner( None }; - if is_character_generation { - let (restored_image, dimension_restore_error) = - restore_editor_generated_image_output_dimensions_or_original( - image, - generation_options.model, - image_size.as_ref(), - ); - if let Some(error) = dimension_restore_error { - dimension_warning = Some(editor_dimension_restore_warning(&error)); - tracing::warn!( - task_id = %generated.task_id, - provider_width, - provider_height, - target_size = %image_size, - error = ?error, - "画板角色透明主图尺寸恢复失败,保留后处理原图" - ); - } - image = restored_image; - } - let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2810,6 +2833,28 @@ fn apply_editor_postprocessed_alpha_to_provider_source( return Ok(None); } + let source_ratio = f64::from(provider_source.width()) / f64::from(provider_source.height()); + let postprocessed_ratio = f64::from(postprocessed.width()) / f64::from(postprocessed.height()); + let ratio_drift = if source_ratio >= postprocessed_ratio { + 1.0 - postprocessed_ratio / source_ratio + } else { + 1.0 - source_ratio / postprocessed_ratio + }; + if ratio_drift > EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "code": "postprocessed-alpha-aspect-ratio-mismatch", + "message": "透明后处理图比例与原图偏差过大,禁止拉伸 Alpha 蒙版", + "sourceWidth": provider_source.width(), + "sourceHeight": provider_source.height(), + "postprocessedWidth": postprocessed.width(), + "postprocessedHeight": postprocessed.height(), + "ratioDrift": ratio_drift, + })), + ); + } + // 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。 // 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。 let postprocessed = postprocessed.to_rgba8(); @@ -8627,6 +8672,39 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + fn static_image_postprocess_rejects_wrong_aspect_ratio_alpha_mask() { + let provider_source = image::DynamicImage::new_rgba8(1024, 576); + let mut provider_source_bytes = Cursor::new(Vec::new()); + provider_source + .write_to(&mut provider_source_bytes, image::ImageFormat::Png) + .expect("provider source should encode"); + let provider_source = DownloadedOpenAiImage { + bytes: provider_source_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let postprocessed = image::DynamicImage::new_rgba8(512, 512); + let mut postprocessed_bytes = Cursor::new(Vec::new()); + postprocessed + .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) + .expect("postprocessed image should encode"); + let postprocessed = DownloadedOpenAiImage { + bytes: postprocessed_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let error = + apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) + .expect_err("wrong-aspect alpha masks must use source-only fallback"); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!("postprocessed-alpha-aspect-ratio-mismatch")) + ); + } + #[test] fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { let image = image::DynamicImage::new_rgba8(944, 704); @@ -8932,6 +9010,85 @@ mod tests { assert_eq!(fallback.provider_image_size, "1K"); } + #[test] + fn character_generation_covers_full_model_ratio_and_size_matrix() { + for (image_size, aspect_ratio, expected_delivery_size) in [ + ("0.5K", "1:1", "512x512"), + ("0.5K", "4:3", "512x384"), + ("0.5K", "3:2", "512x341"), + ("0.5K", "2:3", "341x512"), + ("0.5K", "9:16", "288x512"), + ("0.5K", "16:9", "512x288"), + ("1K", "1:1", "1024x1024"), + ("1K", "4:3", "1024x768"), + ("1K", "3:2", "1024x683"), + ("1K", "2:3", "683x1024"), + ("1K", "9:16", "576x1024"), + ("1K", "16:9", "1024x576"), + ("2K", "1:1", "2048x2048"), + ("2K", "4:3", "2048x1536"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), + ("2K", "9:16", "1152x2048"), + ("2K", "16:9", "2048x1152"), + ] { + let mut models = vec![EDITOR_IMAGE_MODEL_NANOBANANA2]; + if image_size != "0.5K" { + models.push(GPT_IMAGE_2_MODEL); + } + for model in models { + let (expected_width, expected_height) = + parse_editor_image_edit_pixel_size(expected_delivery_size) + .expect("delivery matrix should contain valid pixel dimensions"); + let options = normalize_editor_generation_options( + Some(model), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!( + options.size, expected_delivery_size, + "{model} {image_size} {aspect_ratio} should use the shared delivery matrix" + ); + assert_eq!( + resolve_editor_image_request_size(Some("character"), None, true, &options,), + expected_delivery_size, + "character source and cutout should share {model} {image_size} {aspect_ratio} delivery dimensions" + ); + + let provider_width = (expected_width * 9).div_ceil(8); + let provider_height = (expected_height * 9).div_ceil(8); + let provider_image = + image::DynamicImage::new_luma8(provider_width, provider_height); + let mut provider_bytes = Cursor::new(Vec::new()); + provider_image + .write_to(&mut provider_bytes, image::ImageFormat::Png) + .expect("synthetic provider image should encode"); + let provider_output = DownloadedOpenAiImage { + bytes: provider_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let (restored, restore_error) = + restore_editor_generated_image_output_dimensions_or_original( + provider_output, + model, + expected_delivery_size, + ); + assert!( + restore_error.is_none(), + "{model} {image_size} {aspect_ratio} should restore a larger provider image" + ); + let restored = image::load_from_memory(restored.bytes.as_slice()) + .expect("restored character source should remain a valid image"); + assert_eq!( + (restored.width(), restored.height()), + (expected_width, expected_height), + "{model} {image_size} {aspect_ratio} should restore real pixels before background removal" + ); + } + } + } + #[test] fn publication_material_request_size_keeps_workflow_pixels() { let options = @@ -10122,7 +10279,11 @@ mod tests { } #[test] - fn editor_character_postprocess_fallback_response_keeps_source_and_warning() { + fn editor_character_postprocess_fallback_response_keeps_source_and_dimension_warning() { + let dimension_warning = EditorGenerationWarningResponse { + code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, + reason: "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位".to_string(), + }; let response = EditorImageGenerationResponse { image_src: "/api/assets/source-character.png".to_string(), object_key: Some("generated/editor/character-source.png".to_string()), @@ -10138,8 +10299,9 @@ mod tests { resource: None, asset: None, project: None, - warning: Some(editor_postprocess_fallback_warning( + warning: Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", + Some(&dimension_warning), )), }; @@ -10159,7 +10321,9 @@ mod tests { ); assert_eq!( payload["warning"]["reason"], - json!("生成任务成功,后处理失败。") + json!( + "生成任务成功,后处理失败。 provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位" + ) ); } @@ -11255,6 +11419,12 @@ mod tests { .expect("character generation function end marker should exist"); let body = &function_tail[..end]; + let dimension_restore = body + .find("restore_editor_generated_image_output_dimensions_or_original") + .expect("character provider image should be restored to delivery dimensions"); + let source_persist = body + .find("persist_editor_provider_source_image") + .expect("character provider source should be persisted"); let phase = body .find("caller.report_processing_phase(state).await?") .expect("phase report should remain fallible"); @@ -11274,6 +11444,10 @@ mod tests { phase < removal, "phase errors must fail before fallback is considered" ); + assert!( + dimension_restore < source_persist && source_persist < removal, + "character delivery dimensions must be restored before source persistence and background removal" + ); for snippet in [ "Err(error)", "complete_editor_canvas_generation", @@ -11302,6 +11476,30 @@ mod tests { assert!(success_tail.contains("persist_editor_generated_image(")); assert!(success_tail.contains("persist_editor_generated_asset(")); assert!(success_tail.contains(".await?;")); + + let alpha_restore = body + .find("apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original") + .expect("character postprocess should restore alpha to the source dimensions"); + let dimension_fallback = body[alpha_restore..] + .find("if let Some(error) = postprocess_dimension_error") + .map(|offset| alpha_restore + offset) + .expect("character postprocess dimension errors should be handled"); + let processed_image = body[dimension_fallback..] + .find("image = restored_removal_image;") + .map(|offset| dimension_fallback + offset) + .expect("processed image should only continue after dimension validation"); + let dimension_fallback_body = &body[dimension_fallback..processed_image]; + for snippet in [ + "complete_editor_canvas_generation", + "source_record.resource.as_ref()", + "return Ok(json_success_body", + "warning: Some(editor_postprocess_fallback_warning", + ] { + assert!( + dimension_fallback_body.contains(snippet), + "character dimension fallback should contain {snippet}" + ); + } } #[test]