diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index abf8f6f3a..ac684ddf7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -318,6 +318,14 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation. +## Successful Responses with Warnings + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists. + +- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation. +- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. + ## Guardrails - Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 747d7ea28..77f7269e9 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -78,6 +78,14 @@ Ask a follow-up only when two routes could both be correct and produce different All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`. +## HTTP 2xx Warning Handling + +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist. + +- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch. +- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning. +- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively. + ## Reference Image Upload If the user provides a local file as a reference image, run upload before the generation request: diff --git a/.env.local b/.env.local index 8e970016c..49f8cc1e8 100644 --- a/.env.local +++ b/.env.local @@ -29,7 +29,7 @@ GENARRATIVE_LLM_PROVIDER="ark" GENARRATIVE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" GENARRATIVE_LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" GENARRATIVE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" -GENARRATIVE_EDITOR_BGFILTER_BASE_URL=https://u1082648-97d5-01f90c83.westx.seetacloud.com:8443 +GENARRATIVE_EDITOR_BGFILTER_BASE_URL="https://u1082648-b442-cd409e05.westx.seetacloud.com:8443" APIMART_BASE_URL="https://api.apimart.ai/v1" APIMART_API_KEY="" APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index acc09d755..694bf03f4 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -969,7 +969,7 @@ "Editor Images" ], "operationId": "generateExternalEditorIconSpritesheet", - "summary": "按规范图生成并拆分图标素材", + "summary": "按规范图生成图标 spritesheet 并尝试拆分", "security": [ { "ExternalApiKey": [] @@ -987,7 +987,7 @@ }, "responses": { "200": { - "description": "图标 spritesheet、切片结果与落库资源", + "description": "图标 spritesheet、实际切片结果、可选非阻断告警与落库资源", "content": { "application/json": { "schema": { @@ -1017,7 +1017,7 @@ "Editor Images" ], "operationId": "extractExternalEditorUiDesignAssets", - "summary": "从 UI 设计图拆分素材", + "summary": "从 UI 设计图生成素材 spritesheet 并尝试拆分", "security": [ { "ExternalApiKey": [] @@ -1035,7 +1035,7 @@ }, "responses": { "200": { - "description": "UI 设计图素材 spritesheet、切片结果与落库资源", + "description": "UI 设计图素材 spritesheet、实际切片结果、可选非阻断告警与落库资源", "content": { "application/json": { "schema": { @@ -2849,6 +2849,17 @@ } ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" + }, + "warning": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorGenerationWarning" + }, + { + "type": "null" + } + ], + "description": "生成成功但后处理降级时返回的非阻断告警。" } } }, @@ -3090,6 +3101,25 @@ }, "additionalProperties": false }, + "EditorGenerationWarning": { + "type": "object", + "required": [ + "code", + "reason" + ], + "properties": { + "code": { + "type": "string", + "const": "postprocess-failed-source-preserved", + "description": "透明背景处理最终失败并保留 provider 原图时的稳定原因码。" + }, + "reason": { + "type": "string", + "description": "可直接展示给调用方的非阻断告警原因。" + } + }, + "additionalProperties": false + }, "EditorIconSpritesheetGenerationResponse": { "type": "object", "required": [ @@ -3130,7 +3160,7 @@ "type": "null" } ], - "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。" + "description": "图集已成功持久化,但自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。与通用 warning 互斥。" }, "prompt": { "type": "string" @@ -3184,7 +3214,24 @@ } ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" + }, + "warning": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorGenerationWarning" + }, + { + "type": "null" + } + ], + "description": "透明背景处理最终失败、provider 原图作为主结果时返回的非阻断告警。与 sliceWarning 互斥。" } + }, + "not": { + "required": [ + "warning", + "sliceWarning" + ] } }, "EditorCharacterAnimationGenerationRequest": { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f9b8370fd..7a1f70afc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -43,7 +43,7 @@ ## 2026-07-13 外部生成任务持久化真实执行阶段 - 背景:图片画布任务列表此前把所有 `running` 任务固定映射为“正在生成”,角色生图、图标/UI spritesheet、角色动作和手动去背景进入抠图后仍无法展示“正在处理”;前端按耗时推断阶段会产生新的非正式业务真相。 -- 决策:不新增 DB 表,在既有 `external_generation_job` 与 `external_generation_job_summary` 末尾追加带默认值的可选 `phase`。worker claim 时写 `generating`;角色生图、图标 spritesheet、UI 素材提取在调用 BgFilter 前,角色动作在视频生成返回并开始抽帧/逐帧抠图前,手动去背景在执行开始时,通过 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。BFF 将 `running + processing` 映射为“正在处理”,其它 `running`(含旧数据 `phase=None`)映射为“正在生成”;前端只展示后端投影。 +- 决策:不新增 DB 表,在既有 `external_generation_job` 与 `external_generation_job_summary` 末尾追加带默认值的可选 `phase`。worker claim 时写 `generating`;角色生图、图标 spritesheet、UI 素材提取在调用 BgFilter 前,角色动作在视频生成返回并开始抽帧/逐帧抠图前,手动去背景在执行开始时,通过 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;api-server 对 `LeaseFencingRejected` 立即终止,对 `OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,仅对 `Build` / `ConnectDropped` / `Timeout` 在同一 job attempt 内重试 `1` 次。编辑器 job 固定 `max_attempts=1`,第二次传输失败后进入 `failed`,不回 `pending`、不重新调用 provider,也不按错误文案猜测拒绝类型。BFF 将 `running + processing` 映射为“正在处理”,其它 `running`(含旧数据 `phase=None`)映射为“正在生成”;前端只展示后端投影。 - 影响范围:`external_generation_job`、`external_generation_job_summary`、SpacetimeDB procedure / typed client / bindings、图片画布生成 worker、任务列表 BFF 与相关文档。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、外部生成 module/client/api-server 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -83,8 +83,8 @@ ## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物 - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 -- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。 - 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -400,7 +400,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。透明背景处理正常成功时,UI 提取把透明 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分成功的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成在透明背景处理正常成功时把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。2026-07-16 起,透明背景处理最终失败时只把已经持久化的 provider 原图作为唯一主图放入画布,以 `completed + warning` 收口,不创建透明图集,也不继续拆分。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 @@ -3196,8 +3196,9 @@ ## 2026-06-19 编辑器角色形象回填改用通用抠图 - 背景:画板编辑器里的 `生成角色形象` 属于编辑器图片生成链路,用户要求把“人物抠图”改成通用抠图方法,不再与 RPG / 资产工坊角色主图专用后处理绑定。 -- 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;输出仍归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 +- 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;透明背景处理正常成功时输出归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 - 2026-06-22 补充:所有明确设置绿幕用于后续抠图的 prompt 都必须固定写明 `#00FF00 / RGB(0,255,0)`,不能只写“纯绿色绿幕”或“接近 #00FF00”;编辑器角色图通用抠图额外开启暗绿 / 灰绿绿幕背景识别,只作为生成模型偏离标准亮绿时的兜底。该宽松识别只参与从画布边缘连通扩散出的背景清理,不参与全图断开绿色区域删除,避免误伤角色衣物或纹理。 +- 2026-07-16 补充:透明背景处理最终失败、但 provider 原图已持久化时,角色任务以 `completed + warning` 收口,provider 原图作为唯一主图放入画布,不创建透明处理图。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet_muted_green_alpha_requires_explicit_option`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6fdd9ab55..826761a08 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## phase 上报的业务拒绝与传输失败不能共用字符串错误 + +- 现象:provider 已经返回并保存原图,worker 上报 `processing` 时一次断连或超时就直接把任务判为失败;或者为了规避误杀而重试所有错误,导致 stale lease 的旧 worker 继续执行后处理。 +- 原因:phase procedure 的 lease / fencing 业务拒绝与 SDK 建连、断连、超时错误被压成同一种字符串错误,调用方无法可靠决定是否重试;按中文或 SDK 文案匹配会在错误文本变化后失效。 +- 处理:procedure 返回结构化 `LeaseFencingRejected` / `OtherRejected`,typed client 再把模块拒绝与 RPC 错误分开。`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试;只有 `Build` / `ConnectDropped` / `Timeout` 在同一 job attempt 内重试一次。编辑器 job 固定 `max_attempts=1`,第二次传输失败后进入 `failed`,不回 `pending`、不重新调用 provider。不得让 phase 上报错误落入“后处理失败保留原图”的降级分支。 +- 验证:分别覆盖 lease / fencing 拒绝、其它拒绝、建连、断连、超时和第二次失败,确认最多调用两次;同时断言角色、图标和 UI 的原图降级只包住透明背景处理,不包住 phase 上报。 +- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 + ## 禁止 Data URL 持久化时不要漏掉异步任务 JSON - 现象:工程、素材、图层和元数据都已禁止 Data URL 后,服务器仍在生成高峰出现 SpacetimeDB / api-server 内存急剧膨胀甚至 OOM;读取少量正式生成任务也会造成远大于响应体的瞬时内存增长。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 0bedb60d0..952390cad 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -24,7 +24,7 @@ - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置。无项目上下文时不创建项目资源或画布图层。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 @@ -57,7 +57,7 @@ - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 -- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 +- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 @@ -87,10 +87,10 @@ - `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 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 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`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 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`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 +- `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`;前端按后端快照落画布,不补造缺失产物。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 调用 VectorEngine edits,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。画布快速编辑必须把源图精确 `originalWidth x originalHeight` 作为业务目标 `size` 提交,不能重新映射为近似比例或 1K / 2K 预设;api-server 在 VectorEngine provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸,成功时只上传恢复结果,失败时只上传 provider 原图。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 - `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。 @@ -130,7 +130,7 @@ - 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 - Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;只有尚未登记的浏览器本地图片才先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 8e54cbba9..9ad4c15e4 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -24,7 +24,7 @@ - `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。 - `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。 - `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。 -- `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。 +- `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`,调用方不解析错误文案;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试 `1` 次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。 - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 - `list_external_generation_job_summaries_and_return`:按当前账号从轻量摘要投影读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格、执行阶段和完成提示确认状态。 @@ -41,10 +41,10 @@ 队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: - `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。 -- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 +- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、可选 `warning`、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。 - 任务被 claim 后默认处于 `generating`,BFF 显示“正在生成”;真实进入 BgFilter、逐帧抠图或手动去背景时切换为 `processing`,BFF 显示“正在处理”。旧任务 `phase=None` 按 `generating` 兼容,前端不得按耗时或 job kind 推断阶段。 - `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。 -- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选的 `warning`。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。 +- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `operationId`(即任务 ID)、`status`、`phaseLabel`、`phaseDetail`、`progress`、`error`、`updatedAtMicros`,以及可选的 `warning` 完整原因字符串。生成页轮询只依赖状态、阶段、进度、错误和警告;`jobKind`、source 和完整时间信息继续由任务列表接口或业务快照提供。`attempt` / `maxAttempts` 属于 worker 调度事实,不向该前端契约暴露;若未来需要面向用户展示,必须单独完成产品、契约和摘要投影设计。 BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、lease、执行和计费事实仍以 `external_generation_job` 为准,用户可见任务列表、单任务状态、执行阶段和通知确认的正式读取事实源为 `external_generation_job_summary`,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见任务列表放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。终态提示的弹出与否以后端 `notification_acknowledged_at` 为准;前端在提示展示后后台调用 acknowledge 接口,关闭按钮只负责收起本地弹窗,不能只靠本地 dismiss 永久吞掉任务。 @@ -194,6 +194,10 @@ controller 配置: 画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset` 和 `editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。 +角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 + +inline 成功响应使用结构化 `warning.code/reason`;queue worker 只把同一结构写入有界的 `result_payload_json.warning`,任务摘要提取其中的 `reason` 到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回完整原因。图标 / UI 的透明图已经成功、只有自动拆分失败时,继续保留现有 `sliceWarning` 兼容契约;通用 `warning` 优先,只有不存在通用 `warning` 时,worker 和前端才把 `sliceWarning` 归一为自动拆分告警。 + ## 验收 基础检查: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 551c20a96..0dd0f6945 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -280,16 +280,16 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。 -- 非阻断告警:图标图集生成和 UI 素材提取成功但自动拆分降级时,worker 的 `result_payload_json` 只额外保存有界的 `warning.code/reason`,不保存 spritesheet、切片列表或媒体 URL。 +- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集和既有 `sliceWarning` 兼容契约;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline 响应继续使用既有 `sliceWarning.code/reason`;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先,不保存图片、切片列表或媒体 URL。 ### `external_generation_job_summary` - Rust 结构体:`ExternalGenerationJobSummary` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 用途:外部生成正式任务列表的轻量投影,按 `job_id` 保存 owner、来源、状态、可选 `phase`、价格、有界错误摘要、通知确认时间、各阶段时间和入队时提取的 `request_prompt`,不包含 request/result payload、worker lease 或 dedupe 内部字段。错误摘要统一拒绝内联媒体并限制为 2048 字符;列表在单次 owner 扫描中同时计数并只保留请求 limit 的固定大小 top-N,不得先收集全量历史再截断。enqueue、claim、renew、phase update、complete、fail 事务同步投影;acknowledge 只更新该轻量表并写审计事件,后续主任务同步必须保留已有确认时间,禁止为了写确认时间加载 / 重写大 payload 行。BFF 的列表、状态和确认只调用 summary procedure;`running + processing` 映射为“正在处理”,其它 running(含旧行 `phase=None`)映射为“正在生成”。历史终态任务由迁移操作员的游标分批 maintenance procedure 在压缩 payload 时同步回填摘要,正式列表不得为兼容旧数据回扫完整主表。 -- 非阻断告警:摘要字段 `warning_message` 由完成任务的轻量 `result_payload_json.warning.reason` 提取,complete 和历史 backfill 共用同一构建路径;错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase` 与 `warning_message` 分别表示当前执行阶段和成功降级提示,不得混用。 +- 非阻断告警:摘要字段 `warning_message` 由完成任务的轻量 `result_payload_json.warning.reason` 提取,complete 和历史 backfill 共用同一构建路径;单 job 状态和任务列表 BFF 以 `warning: string` 返回该完整原因,不再返回结构化 code。错误与告警摘要都不复制内联媒体并限制为 2048 字符。`phase` 与 `warning_message` 分别表示当前执行阶段和成功降级提示,不得混用。 - 正式读取 procedure 为 `get_external_generation_job_summary_and_return`、`list_external_generation_job_summaries_and_return` 和 `acknowledge_external_generation_job_summaries_and_return`。历史维护 procedure 为 `compact_external_generation_job_payloads_and_return` 与 `backfill_external_generation_job_summaries_and_return`,仅 migration operator 可调用;运维入口统一使用 `npm run spacetime:external-generation:maintain -- ...`,默认 dry-run、单批最多 25 条。B-tree cursor 选择阶段最多反序列化 `limit + 1` 行,apply 再按主键逐条读取选中行;怀疑存在单行异常巨型 JSON 时必须先使用 `--limit 1`。payload 压缩额外固定使用 `source_module = editor-canvas` 的复合 cursor 索引,不得静默改写其它玩法历史任务。 ### `external_generation_job_event` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index b7159d938..038dc8ad6 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -42,6 +42,8 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 +角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`;服务端保证通用 `warning` 与 `sliceWarning` 互斥,防御性客户端若收到异常双字段响应仍以通用 `warning` 为准。 + 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: ```text @@ -137,6 +139,7 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 - 外部图片生成、重绘、图标拆分、UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 角色图、图标 spritesheet 和 UI 素材提取的 2xx 成功响应允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 - OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index e8d8a3677..1f8eeebb0 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -116,9 +116,9 @@ - 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 - 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 -- 一次生成任务产生多个可复用产物时,全部产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能只保留最终产物或由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取至少同时回填纯色背景原图与透明后处理结果;UI 素材提取继续一并回填拆分素材。`generatedLayerId` 仍锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。 +- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 -- 图标和 UI 图集自动拆分属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 warning toast 提示用户可手动重试。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成图集标记为失败。 +- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 @@ -190,7 +190,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化。正常透明化成功时复用图标素材拆分流程,把透明 spritesheet 图集和拆分成功的素材放到画布;透明背景处理最终失败时只把 provider 原图放到画布,不继续拆分,并显示通用 warning。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index a05a10efb..416fdd5fb 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -61,9 +61,9 @@ 仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。 ``` -- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter,固定传 `background_mode=flat`、`cross_check=off` 并按默认 `segModel=birefnet` 透明化;透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力。调用方未指定素材文件夹时落默认“项目”文件夹。 -- UI 素材自动拆分与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。 -- 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。图集图层同样提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS、项目资源和账号素材库,再调用 BgFilter,固定传 `background_mode=flat`、`cross_check=off` 并按默认 `segModel=birefnet` 透明化。透明背景处理正常成功时,透明 spritesheet 同样先进入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- UI 素材自动拆分只在透明图集成功后执行,与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。 +- 正常透明化成功时,前端先把透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分成功的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布;透明背景处理最终失败时只消费后端快照中的 provider 原图。透明图集图层提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。 ## 验收点 @@ -72,5 +72,5 @@ - 从画布选择时只能绑定图标规范图片。 - 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。 - 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入`、`图标规范` 与普通参考图。 -- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`,画布同时出现透明 spritesheet 图集和拆分后的独立素材。 +- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`。正常透明化和拆分成功时画布同时出现透明 spritesheet 图集和拆分后的独立素材;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图并显示通用 warning。 - UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index b4bf1e2f2..e0f18415b 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -4,7 +4,7 @@ ## 背景 -图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet,在后端去背景后自动拆分为可独立编辑的素材。 +图片画布编辑器已有普通图片生成、生成规范、生成角色形象和角色动画入口。本次新增 `生成图标素材`,用于一次输入多条图标素材描述,生成一张纯色背景 spritesheet;后端去背景正常成功后,再尝试自动拆分为可独立编辑的素材。 ## 入口与画布表现 @@ -12,7 +12,7 @@ - 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 - 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。 - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 -- 生成完成后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域拆出的 `assetKind: "icon"` 素材铺到图集右侧。 +- 透明背景处理正常成功后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材铺到图集右侧;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 @@ -60,8 +60,8 @@ ## 去背与保存 - 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`、`cross_check=off`,请求字段同时包含 `screenColor` 和 `segModel`。前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。 -- 带背景原图和去背后的透明 spritesheet 都先同时写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。 -- 自动拆分是生成后的 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;前端在 inline、worker 队列完成和刷新恢复三条路径统一显示 warning toast,用户可在图集工具栏手动重试。 +- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 - 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 @@ -79,6 +79,6 @@ - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,上传参考图优先提交 `objectKey`,并写入 `generationInputs.references`。 -- 生成成功后画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层。 +- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 图集和按描述命名的独立图标图层;透明图集成功但拆分失败时只出现透明图集,透明背景处理最终失败时只出现 provider 原图。 - 选中图集图层时显示 `拆分图集`,点击后不新增第二张图集,只在原图集右侧追加自动识别的独立素材,并同步写入素材库。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 60fe57826..5e4a3465b 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,8 +65,8 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 -- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用共享 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=`、`seg_model=`、`background_mode=flat` 和 `cross_check=on`,用户路径默认并只提交 `seg_model=birefnet`;`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。首次请求失败后立即重试 `1` 次,第二次仍失败进入“阿里云通用抠图 → 本地键色”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回透明 PNG Data URL 及 `objectKey` / `assetObjectId`。三段透明背景处理最终仍失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。 +- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -114,7 +114,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d68a78f3c..5e5397d21 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -48,7 +48,8 @@ use spacetime_client::{ EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError, + ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, + SpacetimeClientError, }; use crate::{ @@ -120,6 +121,8 @@ const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; 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_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"; const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; @@ -376,25 +379,66 @@ impl EditorGenerationPhaseReporter { } pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> { - state - .spacetime_client() - .update_external_generation_job_phase(ExternalGenerationJobPhaseUpdateRecordInput { - job_id: self.job_id.clone(), - worker_id: self.worker_id.clone(), - lease_token: self.lease_token.clone(), - phase: "processing".to_string(), - }) - .await - .map(|_| ()) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "external-generation-phase", - "message": format!("更新外部生成任务处理阶段失败:{error}"), - })) - }) + let input = ExternalGenerationJobPhaseUpdateRecordInput { + job_id: self.job_id.clone(), + worker_id: self.worker_id.clone(), + lease_token: self.lease_token.clone(), + phase: "processing".to_string(), + }; + let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1; + for attempt in 1..=max_attempts { + match state + .spacetime_client() + .update_external_generation_job_phase(input.clone()) + .await + { + Ok(_) => return Ok(()), + Err(error) => { + let will_retry = + should_retry_editor_generation_phase_update(&error, attempt, max_attempts); + tracing::warn!( + provider = "external-generation-phase", + job_id = %self.job_id, + attempt, + max_attempts, + will_retry, + lease_fencing_rejected = error.is_lease_fencing_rejected(), + error = %error, + "external_generation_processing_phase_report_failed" + ); + if will_retry { + continue; + } + return Err(map_editor_generation_phase_update_error(error)); + } + } + } + unreachable!("phase report retry loop should return on success or final error") } } +fn should_retry_editor_generation_phase_update( + error: &ExternalGenerationJobPhaseUpdateError, + attempt: usize, + max_attempts: usize, +) -> bool { + error.is_retryable_transport() && attempt < max_attempts +} + +fn map_editor_generation_phase_update_error( + error: ExternalGenerationJobPhaseUpdateError, +) -> AppError { + let status = if error.is_lease_fencing_rejected() { + StatusCode::CONFLICT + } else { + StatusCode::BAD_GATEWAY + }; + AppError::from_status(status).with_details(json!({ + "provider": "external-generation-phase", + "message": format!("更新外部生成任务处理阶段失败:{error}"), + })) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResponse { @@ -574,6 +618,20 @@ pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) preview_video_path: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorGenerationWarningResponse { + code: &'static str, + reason: String, +} + +fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGenerationWarningResponse { + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE, + reason: reason.to_string(), + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { @@ -591,6 +649,8 @@ pub struct EditorImageGenerationResponse { resource: Option, asset: Option, project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, } #[derive(Debug, Serialize)] @@ -646,6 +706,8 @@ pub struct EditorIconSpritesheetGenerationResponse { spritesheet_resource: Option, spritesheet_asset: Option, project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, } #[derive(Debug, Serialize)] @@ -1680,6 +1742,10 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + let source_image_src = + editor_media_src_from_object_key(source_persisted.object_key.as_str()); + let source_object_key = source_persisted.object_key.clone(); + let source_asset_object_id = source_persisted.asset_object_id.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -1724,7 +1790,50 @@ pub(crate) async fn generate_editor_image_for_owner( EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &matting_audit, ) - .await?; + .await; + let removal = match removal { + Ok(removal) => removal, + Err(error) => { + let failure_message = error.body_text(); + tracing::warn!( + provider = "editor-character-image", + operation = "background_postprocess", + task_id = %generated.task_id, + error = %failure_message, + "角色原图已保存,但透明背景处理失败,使用原图完成画布" + ); + 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: provider_width, + height: provider_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( + "图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。", + )), + }, + )); + } + }; image = removal.image; output_prompt = "去除纯色背景".to_string(); output_actual_prompt = None; @@ -1843,6 +1952,7 @@ pub(crate) async fn generate_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, + warning: None, }, )) } @@ -2660,6 +2770,7 @@ pub(crate) async fn edit_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, + warning: None, }, )) } @@ -3818,6 +3929,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -3856,7 +3968,50 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) - .await?; + .await; + let removal = match removal { + Ok(removal) => removal, + Err(error) => { + let failure_message = error.body_text(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "background_postprocess", + task_id = %generated.task_id, + error = %failure_message, + "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" + ); + 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), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src: source_image_src, + spritesheet_width: source_width, + spritesheet_height: source_height, + icon_image_srcs: Vec::new(), + slice_warning: None, + prompt, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + price_mud_points: expected_price_mud_points, + spritesheet_resource: source_record.resource, + spritesheet_asset: source_record.asset, + project: completed_project, + warning: Some(editor_postprocess_fallback_warning( + "图标图集已生成,但透明背景处理失败,已将原图放入画布;未生成透明图集和拆分素材。", + )), + }, + )); + } + }; let image = removal.image; let matting_generation_inputs = build_editor_derived_asset_generation_inputs( "图标图集抠图", @@ -4026,6 +4181,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, project: completed_project, + warning: None, }, )) } @@ -4429,6 +4585,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_record = persist_editor_provider_source_resource( state, source_persisted, @@ -4467,7 +4624,50 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) - .await?; + .await; + let removal = match removal { + Ok(removal) => removal, + Err(error) => { + let failure_message = error.body_text(); + tracing::warn!( + provider = "editor-ui-design-asset-extraction", + operation = "background_postprocess", + task_id = %generated.task_id, + error = %failure_message, + "UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布" + ); + 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), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src: source_image_src, + spritesheet_width: source_width, + spritesheet_height: source_height, + icon_image_srcs: Vec::new(), + slice_warning: None, + prompt, + actual_prompt: generated.actual_prompt, + model: generation_options.model.to_string(), + provider: "VectorEngine", + task_id: generated.task_id, + price_mud_points: expected_price_mud_points, + spritesheet_resource: source_record.resource, + spritesheet_asset: source_record.asset, + project: completed_project, + warning: Some(editor_postprocess_fallback_warning( + "UI 素材图集已生成,但透明背景处理失败,已将原图放入画布;未生成透明图集和拆分素材。", + )), + }, + )); + } + }; let image = removal.image; let matting_generation_inputs = build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record); @@ -4631,6 +4831,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, project: completed_project, + warning: None, }, )) } @@ -8715,6 +8916,77 @@ mod tests { ); } + #[test] + fn editor_canvas_postprocess_fallback_only_anchors_provider_source() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-character-fallback".to_string()), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 200.0, + height: 300.0, + original_width: 400.0, + original_height: 600.0, + }, + }; + let source = editor_project_resource_for_canvas_test( + "resource-character-source", + "character", + 400, + 600, + ); + let source_layer_id = generated_canvas_layer_id(source.resource_id.as_str()); + let source_item = build_generated_canvas_layer_item( + &completion, + &completion.placeholder, + &source, + source_layer_id.as_str(), + 0, + ); + let layers = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character-fallback", + "resourceId": "generation-dialog:dialog-character-fallback", + "dialog": { + "id": "dialog-character-fallback", + "status": "generating", + "placeholder": completion.placeholder.clone() + } + }]); + + let completed = apply_editor_canvas_generation_multi_items( + layers, + &completion, + vec![source_item], + Some(source_layer_id.clone()), + ) + .expect("source-only fallback should complete the generation dialog"); + let completed_items = completed + .layers + .as_array() + .expect("layers should stay array"); + let generated_resources = completed_items + .iter() + .filter(|item| { + item.get("resourceId") + .and_then(Value::as_str) + .is_some_and(|resource_id| resource_id.starts_with("resource-character-")) + }) + .collect::>(); + assert_eq!(generated_resources.len(), 1); + assert_eq!( + generated_resources[0]["resourceId"], + json!("resource-character-source") + ); + let dialog = completed_items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("generation dialog should remain"); + assert_eq!(dialog["generatedLayerId"], json!(source_layer_id)); + } + #[test] fn editor_canvas_spritesheet_items_include_source_sheet_and_slices() { let completion = EditorCanvasGenerationCompletionRequest { @@ -9146,6 +9418,48 @@ mod tests { assert_eq!(infer_editor_reference_image_mime_type(b"not image"), None); } + #[test] + fn editor_character_postprocess_fallback_response_keeps_source_and_warning() { + let response = EditorImageGenerationResponse { + image_src: "/api/assets/source-character.png".to_string(), + object_key: Some("generated/editor/character-source.png".to_string()), + asset_object_id: Some("asset-object-character-source".to_string()), + width: 1024, + height: 1536, + source_type: "generated", + prompt: "角色形象".to_string(), + actual_prompt: Some("角色形象 actual".to_string()), + model: GPT_IMAGE_2_MODEL.to_string(), + provider: "VectorEngine", + task_id: "task-character-source".to_string(), + resource: None, + asset: None, + project: None, + warning: Some(editor_postprocess_fallback_warning( + "图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。", + )), + }; + + let payload = serde_json::to_value(response).expect("response should serialize"); + + assert_eq!( + payload["imageSrc"], + json!("/api/assets/source-character.png") + ); + assert_eq!( + payload["objectKey"], + json!("generated/editor/character-source.png") + ); + assert_eq!( + payload["warning"]["code"], + json!(EDITOR_GENERATION_POSTPROCESS_WARNING_CODE) + ); + assert_eq!( + payload["warning"]["reason"], + json!("图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。") + ); + } + #[tokio::test] async fn editor_icon_spritesheet_price_is_resolved_from_generation_config() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -9187,6 +9501,7 @@ mod tests { spritesheet_resource: None, spritesheet_asset: None, project: None, + warning: None, }; let payload = serde_json::to_value(response).expect("response should serialize"); @@ -9196,10 +9511,9 @@ mod tests { payload["sliceWarning"]["code"], json!("insufficient-connected-components") ); - assert!( - payload["sliceWarning"]["reason"] - .as_str() - .is_some_and(|reason| reason.contains("连通域数量不足")) + assert_eq!( + payload["sliceWarning"]["reason"], + json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。") ); } @@ -9887,6 +10201,226 @@ mod tests { ); } + #[test] + fn editor_processing_phase_retries_transport_once_but_not_fencing() { + let source = include_str!("editor_project.rs"); + assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;")); + assert_function_contains_in_order( + source, + "pub(crate) async fn report_processing(&self, state: &AppState)", + "fn map_editor_generation_phase_update_error", + &[ + "let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1", + "for attempt in 1..=max_attempts", + ".update_external_generation_job_phase(input.clone())", + "should_retry_editor_generation_phase_update", + "if will_retry", + "continue", + "map_editor_generation_phase_update_error(error)", + ], + ); + assert_function_contains( + source, + "fn map_editor_generation_phase_update_error", + "#[derive(Debug, Serialize)]", + &["error.is_lease_fencing_rejected()", "StatusCode::CONFLICT"], + ); + } + + #[test] + fn editor_processing_phase_retry_policy_stops_after_one_transport_retry() { + let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1; + let transport_errors = [ + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build( + "initial websocket connect failed".to_string(), + )), + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped), + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout( + spacetime_client::SpacetimeClientStage::ProcedureResult, + )), + ]; + for error in &transport_errors { + assert!(should_retry_editor_generation_phase_update( + error, + 1, + max_attempts + )); + assert!(!should_retry_editor_generation_phase_update( + error, + max_attempts, + max_attempts + )); + } + + let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected( + "lease token 不匹配".to_string(), + ); + assert!(!should_retry_editor_generation_phase_update( + &lease, + 1, + max_attempts + )); + + let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string()); + assert!(!should_retry_editor_generation_phase_update( + &rejected, + 1, + max_attempts + )); + + for error in [ + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Procedure( + "procedure rejected".to_string(), + )), + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime( + "runtime invariant failed".to_string(), + )), + ] { + assert!(!should_retry_editor_generation_phase_update( + &error, + 1, + max_attempts + )); + } + } + + #[test] + fn editor_static_postprocess_failure_completes_canvas_with_source_only() { + let source = include_str!("editor_project.rs"); + for (start, end) in [ + ( + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + ), + ( + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + ), + ] { + assert_function_contains_in_order( + source, + start, + end, + &[ + "persist_editor_provider_source_resource", + "caller.report_processing_phase(state).await?", + "remove_editor_generated_screen_background_with_bgfilter", + "Err(error)", + "complete_editor_canvas_generation", + "source_record.resource.as_ref()", + "warning: Some(editor_postprocess_fallback_warning", + ], + ); + + let start_index = source + .find(start) + .unwrap_or_else(|| panic!("missing function start marker: {start}")); + let function_tail = &source[start_index..]; + let end_index = function_tail + .find(end) + .unwrap_or_else(|| panic!("missing function end marker: {end}")); + let body = &function_tail[..end_index]; + let fallback_start = body + .find("let removal = match removal {") + .unwrap_or_else(|| panic!("{start} should match only the postprocess result")); + let success_marker = if start.contains("generate_editor_image_for_owner") { + "image = removal.image;" + } else { + "let image = removal.image;" + }; + let fallback_end = body[fallback_start..] + .find(success_marker) + .map(|offset| fallback_start + offset) + .unwrap_or_else(|| { + panic!("{start} should resume its processed-image success path") + }); + let fallback = &body[fallback_start..fallback_end]; + assert!(fallback.contains("return Ok(json_success_body")); + for forbidden in [ + "persist_editor_generated_image(", + "persist_editor_generated_asset(", + "slice_generated_icon_spritesheet", + "persist_editor_spritesheet_slices", + ] { + assert!( + !fallback.contains(forbidden), + "{start} fallback must not absorb later failure scope: {forbidden}" + ); + } + if start.contains("spritesheet") || start.contains("ui_design") { + let success_tail = &body[fallback_end..]; + assert!(success_tail.contains("persist_editor_generated_image(")); + assert!(success_tail.contains("persist_editor_generated_asset(")); + assert!(success_tail.contains("persist_editor_spritesheet_slices")); + } + } + } + + #[test] + fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() { + let source = include_str!("editor_project.rs"); + let start = source + .find("pub(crate) async fn generate_editor_image_for_owner") + .expect("character generation function should exist"); + let function_tail = &source[start..]; + let end = function_tail + .find("fn normalize_editor_image_generation_size") + .expect("character generation function end marker should exist"); + let body = &function_tail[..end]; + + let phase = body + .find("caller.report_processing_phase(state).await?") + .expect("phase report should remain fallible"); + let removal = body + .find("let removal = remove_editor_generated_screen_background_with_bgfilter") + .expect("transparent background postprocess should exist"); + let fallback_start = body + .find("let removal = match removal {") + .expect("only the postprocess result should be matched"); + let fallback_end = body[fallback_start..] + .find("image = removal.image;") + .map(|offset| fallback_start + offset) + .expect("processed-image success path should follow the fallback match"); + let fallback = &body[fallback_start..fallback_end]; + + assert!( + phase < removal, + "phase errors must fail before fallback is considered" + ); + for snippet in [ + "Err(error)", + "complete_editor_canvas_generation", + "source_record.resource.as_ref()", + "return Ok(json_success_body", + "warning: Some(editor_postprocess_fallback_warning", + ] { + assert!( + fallback.contains(snippet), + "character postprocess fallback should contain {snippet}" + ); + } + for forbidden in [ + "persist_editor_generated_image(", + "persist_editor_generated_asset(", + "slice_generated_icon_spritesheet", + "build_multi_asset_canvas_layer_items", + ] { + assert!( + !fallback.contains(forbidden), + "character postprocess fallback must not absorb later failure scope: {forbidden}" + ); + } + + let success_tail = &body[fallback_end..]; + assert!(success_tail.contains("persist_editor_generated_image(")); + assert!(success_tail.contains("persist_editor_generated_asset(")); + assert!(success_tail.contains(".await?;")); + } + #[test] fn editor_manual_background_removal_retries_once() { let source = include_str!("editor_project.rs"); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 627bc5545..5264e1445 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -882,6 +882,20 @@ mod tests { ["sliceWarning"]["anyOf"][0]["$ref"], "#/components/schemas/EditorIconSpritesheetSliceWarning" ); + assert_eq!( + parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"] + ["warning"]["anyOf"][0]["$ref"], + "#/components/schemas/EditorGenerationWarning" + ); + assert_eq!( + parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] + ["warning"]["anyOf"][0]["$ref"], + "#/components/schemas/EditorGenerationWarning" + ); + assert_eq!( + parsed["components"]["schemas"]["EditorGenerationWarning"]["required"], + json!(["code", "reason"]) + ); assert!( parsed["paths"] .get("/api/external/v1/editor/ui-designs/assets/extractions") diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 2d959f887..02f4a3110 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -15,6 +15,7 @@ use tokio::{ use tracing::{error, info, warn}; const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048; +const EDITOR_GENERATION_SLICE_WARNING_PREFIX: &str = "图集已生成,但自动拆分未完成:"; const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str = "自动拆分未完成(告警详情含内联媒体引用,已省略)"; @@ -675,7 +676,15 @@ async fn process_external_generation_job_once( ) .await { - Ok(_) => complete_editor_generation_job(&state, &worker_id, &job).await, + Ok(response) => { + complete_editor_generation_job_with_response( + &state, + &worker_id, + &job, + &response.0, + ) + .await + } Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1080,7 +1089,7 @@ fn editor_generation_result_payload_json( "sourceModule": job.source_module.clone(), "sourceEntityId": job.source_entity_id.clone(), }); - if let Some(warning) = extract_editor_generation_slice_warning(response) + if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { object.insert("warning".to_string(), warning); @@ -1088,15 +1097,23 @@ fn editor_generation_result_payload_json( payload.to_string() } -fn extract_editor_generation_slice_warning(response: &Value) -> Option { +fn extract_editor_generation_warning(response: &Value) -> Option { let data = response.get("data").unwrap_or(response); - let warning = data.get("sliceWarning")?; + let (warning, is_slice_warning) = match data.get("warning") { + Some(warning) => (warning, false), + None => (data.get("sliceWarning")?, true), + }; let code = warning.get("code")?.as_str()?.trim(); let reason = warning.get("reason")?.as_str()?.trim(); if code.is_empty() || reason.is_empty() { return None; } - let reason = normalize_editor_generation_warning_reason(reason); + let reason = if is_slice_warning { + format!("{EDITOR_GENERATION_SLICE_WARNING_PREFIX}{reason}") + } else { + reason.to_string() + }; + let reason = normalize_editor_generation_warning_reason(reason.as_str()); Some(json!({ "code": code, "reason": reason, @@ -1378,13 +1395,72 @@ mod tests { payload["warning"], json!({ "code": "insufficient-connected-components", - "reason": "连通域数量不足" + "reason": "图集已生成,但自动拆分未完成:连通域数量不足" }) ); assert!(payload.get("spritesheetImageSrc").is_none()); assert!(payload.get("iconImageSrcs").is_none()); } + #[test] + fn editor_generation_result_payload_prefers_common_postprocess_warning() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let response = json!({ + "data": { + "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", + "warning": { + "code": "postprocess-failed-source-preserved", + "reason": "图片已生成,但透明背景处理失败,已将原图放入画布。" + }, + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "不应覆盖通用后处理告警" + } + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker 结果应是合法 JSON"); + + assert_eq!( + payload["warning"], + json!({ + "code": "postprocess-failed-source-preserved", + "reason": "图片已生成,但透明背景处理失败,已将原图放入画布。" + }) + ); + assert!(payload.get("imageSrc").is_none()); + } + + #[test] + fn editor_image_job_completion_keeps_inline_response_warning() { + let source = include_str!("external_generation_worker.rs"); + let start = source + .find("EDITOR_IMAGE_GENERATION_JOB_KIND => {") + .expect("editor image worker branch should exist"); + let branch_tail = &source[start..]; + let end = branch_tail + .find("EDITOR_IMAGE_EDIT_JOB_KIND => {") + .expect("editor image worker branch end marker should exist"); + let branch = &branch_tail[..end]; + + for snippet in [ + "Ok(response)", + "complete_editor_generation_job_with_response", + "&response.0", + ] { + assert!( + branch.contains(snippet), + "editor image completion should preserve response warning via {snippet}" + ); + } + assert!( + !branch.contains("Ok(_) => complete_editor_generation_job"), + "editor image completion must not discard the inline fallback warning" + ); + } + #[test] fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() { let job = external_generation_job_record_fixture(Some("lease-1")); diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index 5ee02ade6..0d00c249a 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -8,6 +8,43 @@ const EXTERNAL_GENERATION_QUEUE_WAKE_SUBSCRIPTION_QUERIES: [&str; 2] = [ "SELECT * FROM external_generation_job WHERE status = 'running'", ]; +#[derive(Debug)] +pub enum ExternalGenerationJobPhaseUpdateError { + LeaseFencingRejected(String), + Rejected(String), + Rpc(SpacetimeClientError), +} + +impl ExternalGenerationJobPhaseUpdateError { + pub fn is_lease_fencing_rejected(&self) -> bool { + matches!(self, Self::LeaseFencingRejected(_)) + } + + pub fn is_retryable_transport(&self) -> bool { + matches!( + self, + Self::Rpc( + SpacetimeClientError::Build(_) + | SpacetimeClientError::ConnectDropped + | SpacetimeClientError::Timeout(_) + ) + ) + } +} + +impl std::fmt::Display for ExternalGenerationJobPhaseUpdateError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LeaseFencingRejected(message) | Self::Rejected(message) => { + formatter.write_str(message) + } + Self::Rpc(error) => std::fmt::Display::fmt(error, formatter), + } + } +} + +impl std::error::Error for ExternalGenerationJobPhaseUpdateError {} + pub struct ExternalGenerationQueueWakeSubscription { connection: DbConnection, _subscriptions: Vec, @@ -260,26 +297,42 @@ impl SpacetimeClient { pub async fn update_external_generation_job_phase( &self, input: ExternalGenerationJobPhaseUpdateRecordInput, - ) -> Result { + ) -> Result { let procedure_input = input.into(); - self.call_after_connect( - "update_external_generation_job_phase_and_return", - move |connection, sender| { - connection - .procedures() - .update_external_generation_job_phase_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_external_generation_job_procedure_result); - send_once(&sender, mapped); - }, - ); - }, - ) - .await + let outcome = self + .call_after_connect( + "update_external_generation_job_phase_and_return", + move |connection, sender| { + connection + .procedures() + .update_external_generation_job_phase_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .map(map_external_generation_job_phase_update_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + .map_err(ExternalGenerationJobPhaseUpdateError::Rpc)?; + + match outcome { + ExternalGenerationJobPhaseUpdateProcedureOutcome::Updated(job) => Ok(job), + ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { kind, message } => { + match kind { + ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected => Err( + ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected(message), + ), + ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected => { + Err(ExternalGenerationJobPhaseUpdateError::Rejected(message)) + } + } + } + } } pub async fn fail_external_generation_job( @@ -485,3 +538,46 @@ fn send_external_generation_queue_wake(sender: &watch::Sender, counter: &At let next = counter.fetch_add(1, Ordering::Relaxed).saturating_add(1); let _ = sender.send(next); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_update_only_retries_transport_errors() { + let build = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build( + "initial websocket connect failed".to_string(), + )); + assert!(build.is_retryable_transport()); + + let timeout = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout( + SpacetimeClientStage::ProcedureResult, + )); + assert!(timeout.is_retryable_transport()); + assert!(!timeout.is_lease_fencing_rejected()); + + let disconnected = + ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped); + assert!(disconnected.is_retryable_transport()); + + let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected( + "lease token 不匹配".to_string(), + ); + assert!(!lease.is_retryable_transport()); + assert!(lease.is_lease_fencing_rejected()); + + let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string()); + assert!(!rejected.is_retryable_transport()); + assert!(!rejected.is_lease_fencing_rejected()); + + let procedure = ExternalGenerationJobPhaseUpdateError::Rpc( + SpacetimeClientError::Procedure("procedure rejected".to_string()), + ); + assert!(!procedure.is_retryable_transport()); + + let runtime = ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime( + "runtime invariant failed".to_string(), + )); + assert!(!runtime.is_retryable_transport()); + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index e096b4ff4..1cf665929 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -151,7 +151,9 @@ pub mod editor_agent; pub mod editor_project; pub mod external_api_key; pub mod external_generation; -pub use external_generation::ExternalGenerationQueueWakeSubscription; +pub use external_generation::{ + ExternalGenerationJobPhaseUpdateError, ExternalGenerationQueueWakeSubscription, +}; pub mod profile_recharge_expiration; pub use profile_recharge_expiration::ProfileRechargeExpirationSubscription; @@ -930,14 +932,11 @@ impl SpacetimeClient { .on_connect(move |_, _, _| { send_connect_once(&connect_sender, Ok(())); }) - .on_disconnect(move |_, error| { + .on_disconnect(move |_, _error| { broken_flag.store(true, Ordering::SeqCst); - let message = error - .map(|error| error.to_string()) - .unwrap_or_else(|| "SpacetimeDB 连接已断开".to_string()); send_connect_once( &disconnect_sender, - Err(SpacetimeClientError::Procedure(message)), + Err(SpacetimeClientError::ConnectDropped), ); }) .build() diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index e8946daa9..3ac07f4cc 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -120,6 +120,10 @@ pub use self::external_generation::{ ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobSummaryListRecord, ExternalGenerationJobSummaryRecord, ExternalGenerationQueueStatsRecord, }; +pub(crate) use self::external_generation::{ + ExternalGenerationJobPhaseUpdateProcedureOutcome, + map_external_generation_job_phase_update_procedure_result, +}; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index 259239a67..25cd092e2 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -123,6 +123,39 @@ pub(crate) fn map_external_generation_job_procedure_result( Ok(map_external_generation_job_snapshot(job)) } +pub(crate) enum ExternalGenerationJobPhaseUpdateProcedureOutcome { + Updated(ExternalGenerationJobRecord), + Rejected { + kind: ExternalGenerationJobPhaseUpdateFailureKind, + message: String, + }, +} + +pub(crate) fn map_external_generation_job_phase_update_procedure_result( + result: ExternalGenerationJobPhaseUpdateProcedureResult, +) -> ExternalGenerationJobPhaseUpdateProcedureOutcome { + if !result.ok { + return ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { + kind: result + .failure_kind + .unwrap_or(ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected), + message: result + .error_message + .unwrap_or_else(|| "SpacetimeDB phase update procedure 返回未知拒绝".to_string()), + }; + } + + match result.job { + Some(job) => ExternalGenerationJobPhaseUpdateProcedureOutcome::Updated( + map_external_generation_job_snapshot(job), + ), + None => ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { + kind: ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected, + message: "SpacetimeDB phase update procedure 未返回任务快照".to_string(), + }, + } +} + pub(crate) fn map_external_generation_job_claim_result( result: ExternalGenerationJobProcedureResult, ) -> Result, SpacetimeClientError> { @@ -448,3 +481,45 @@ pub struct ExternalGenerationQueueStatsRecord { pub oldest_claimable_age_micros: Option, pub now_micros: i64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_update_mapper_keeps_structured_rejection_kind() { + let lease_rejection = map_external_generation_job_phase_update_procedure_result( + ExternalGenerationJobPhaseUpdateProcedureResult { + ok: false, + job: None, + failure_kind: Some( + ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected, + ), + error_message: Some("lease 已过期".to_string()), + }, + ); + assert!(matches!( + lease_rejection, + ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { + kind: ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected, + .. + } + )); + + let other_rejection = map_external_generation_job_phase_update_procedure_result( + ExternalGenerationJobPhaseUpdateProcedureResult { + ok: false, + job: None, + failure_kind: Some(ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected), + error_message: Some("phase 非法".to_string()), + }, + ); + assert!(matches!( + other_rejection, + ExternalGenerationJobPhaseUpdateProcedureOutcome::Rejected { + kind: ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected, + .. + } + )); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 4a4818974..f3e51f22a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -508,7 +508,9 @@ pub mod external_generation_job_get_input_type; pub mod external_generation_job_list_input_type; pub mod external_generation_job_payload_compaction_input_type; pub mod external_generation_job_payload_compaction_procedure_result_type; +pub mod external_generation_job_phase_update_failure_kind_type; pub mod external_generation_job_phase_update_input_type; +pub mod external_generation_job_phase_update_procedure_result_type; pub mod external_generation_job_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_snapshot_type; @@ -1933,7 +1935,9 @@ pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; pub use external_generation_job_list_input_type::ExternalGenerationJobListInput; pub use external_generation_job_payload_compaction_input_type::ExternalGenerationJobPayloadCompactionInput; pub use external_generation_job_payload_compaction_procedure_result_type::ExternalGenerationJobPayloadCompactionProcedureResult; +pub use external_generation_job_phase_update_failure_kind_type::ExternalGenerationJobPhaseUpdateFailureKind; pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; +pub use external_generation_job_phase_update_procedure_result_type::ExternalGenerationJobPhaseUpdateProcedureResult; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_failure_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_failure_kind_type.rs new file mode 100644 index 000000000..9c876a3d1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_failure_kind_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum ExternalGenerationJobPhaseUpdateFailureKind { + LeaseFencingRejected, + + OtherRejected, +} + +impl __sdk::InModule for ExternalGenerationJobPhaseUpdateFailureKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_procedure_result_type.rs new file mode 100644 index 000000000..48fa66a08 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_phase_update_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_phase_update_failure_kind_type::ExternalGenerationJobPhaseUpdateFailureKind; +use super::external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobPhaseUpdateProcedureResult { + pub ok: bool, + pub job: Option, + pub failure_kind: Option, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalGenerationJobPhaseUpdateProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs index 76a8bce83..a6cc1b841 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_external_generation_job_phase_and_return_procedure.rs @@ -5,7 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; -use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +use super::external_generation_job_phase_update_procedure_result_type::ExternalGenerationJobPhaseUpdateProcedureResult; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -35,7 +35,7 @@ pub trait update_external_generation_job_phase_and_return { __callback: impl FnOnce( &super::ProcedureEventContext, - Result, + Result, ) + Send + 'static, ); @@ -48,12 +48,12 @@ impl update_external_generation_job_phase_and_return for super::RemoteProcedures __callback: impl FnOnce( &super::ProcedureEventContext, - Result, + Result, ) + Send + 'static, ) { self.imp - .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + .invoke_procedure_with_callback::<_, ExternalGenerationJobPhaseUpdateProcedureResult>( "update_external_generation_job_phase_and_return", UpdateExternalGenerationJobPhaseAndReturnArgs { input }, __callback, diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index b3e68c586..9df6e960c 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -188,6 +188,12 @@ pub struct ExternalGenerationJobPhaseUpdateInput { pub phase: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum ExternalGenerationJobPhaseUpdateFailureKind { + LeaseFencingRejected, + OtherRejected, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobCompleteInput { pub job_id: String, @@ -286,6 +292,14 @@ pub struct ExternalGenerationJobProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobPhaseUpdateProcedureResult { + pub ok: bool, + pub job: Option, + pub failure_kind: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobSummarySnapshot { pub job_id: String, @@ -450,16 +464,27 @@ pub fn renew_external_generation_job_lease_and_return( pub fn update_external_generation_job_phase_and_return( ctx: &mut ProcedureContext, input: ExternalGenerationJobPhaseUpdateInput, -) -> ExternalGenerationJobProcedureResult { +) -> ExternalGenerationJobPhaseUpdateProcedureResult { let caller = ctx.sender(); match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, - )?; + ) + .map_err(ExternalGenerationJobPhaseUpdateError::other)?; update_external_generation_job_phase_tx(tx, input.clone()) }) { - Ok(job) => single_external_generation_job_result(job), - Err(message) => failed_external_generation_job_result(message), + Ok(job) => ExternalGenerationJobPhaseUpdateProcedureResult { + ok: true, + job: Some(job), + failure_kind: None, + error_message: None, + }, + Err(error) => ExternalGenerationJobPhaseUpdateProcedureResult { + ok: false, + job: None, + failure_kind: Some(error.kind), + error_message: Some(error.message), + }, } } @@ -1313,9 +1338,10 @@ fn renew_external_generation_job_lease_tx( fn update_external_generation_job_phase_tx( ctx: &ReducerContext, input: ExternalGenerationJobPhaseUpdateInput, -) -> Result { - let phase = normalize_external_generation_job_phase(&input.phase)?; - let mut row = get_worker_owned_external_generation_job( +) -> Result { + let phase = normalize_external_generation_job_phase(&input.phase) + .map_err(ExternalGenerationJobPhaseUpdateError::other)?; + let mut row = get_worker_owned_external_generation_job_for_phase_update( ctx, &input.job_id, &input.worker_id, @@ -1466,28 +1492,96 @@ fn get_worker_owned_external_generation_job( worker_id: &str, lease_token: &str, ) -> Result { - validate_required("external_generation_job.job_id", job_id)?; - validate_required("external_generation_job.worker_id", worker_id)?; - validate_required("external_generation_job.lease_token", lease_token)?; + get_worker_owned_external_generation_job_for_phase_update(ctx, job_id, worker_id, lease_token) + .map_err(|error| error.message) +} + +#[derive(Debug)] +struct ExternalGenerationJobPhaseUpdateError { + kind: ExternalGenerationJobPhaseUpdateFailureKind, + message: String, +} + +impl ExternalGenerationJobPhaseUpdateError { + fn lease_fencing(message: impl Into) -> Self { + Self { + kind: ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected, + message: message.into(), + } + } + + fn other(message: impl Into) -> Self { + Self { + kind: ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected, + message: message.into(), + } + } +} + +impl std::fmt::Display for ExternalGenerationJobPhaseUpdateError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message.as_str()) + } +} + +impl std::error::Error for ExternalGenerationJobPhaseUpdateError {} + +fn get_worker_owned_external_generation_job_for_phase_update( + ctx: &ReducerContext, + job_id: &str, + worker_id: &str, + lease_token: &str, +) -> Result { + validate_required("external_generation_job.job_id", job_id) + .map_err(ExternalGenerationJobPhaseUpdateError::other)?; + validate_required("external_generation_job.worker_id", worker_id) + .map_err(ExternalGenerationJobPhaseUpdateError::other)?; + validate_required("external_generation_job.lease_token", lease_token) + .map_err(ExternalGenerationJobPhaseUpdateError::other)?; let row = ctx .db .external_generation_job() .job_id() .find(&job_id.trim().to_string()) - .ok_or_else(|| "external_generation_job 不存在".to_string())?; + .ok_or_else(|| { + ExternalGenerationJobPhaseUpdateError::lease_fencing("external_generation_job 不存在") + })?; + validate_external_generation_job_phase_update_lease( + &row, + worker_id, + lease_token, + ctx.timestamp, + )?; + Ok(row) +} + +fn validate_external_generation_job_phase_update_lease( + row: &ExternalGenerationJob, + worker_id: &str, + lease_token: &str, + now: Timestamp, +) -> Result<(), ExternalGenerationJobPhaseUpdateError> { if row.status != EXTERNAL_GENERATION_STATUS_RUNNING { - return Err("external_generation_job 当前不是 running 状态".to_string()); + return Err(ExternalGenerationJobPhaseUpdateError::lease_fencing( + "external_generation_job 当前不是 running 状态", + )); } if !is_external_generation_job_owned_by_worker(&row, worker_id) { - return Err("external_generation_job worker lease 不匹配".to_string()); + return Err(ExternalGenerationJobPhaseUpdateError::lease_fencing( + "external_generation_job worker lease 不匹配", + )); } if !is_external_generation_job_owned_by_lease_token(&row, lease_token) { - return Err("external_generation_job lease token 不匹配".to_string()); + return Err(ExternalGenerationJobPhaseUpdateError::lease_fencing( + "external_generation_job lease token 不匹配", + )); } - if !is_external_generation_job_lease_active(&row, ctx.timestamp) { - return Err("external_generation_job lease 已过期".to_string()); + if !is_external_generation_job_lease_active(row, now) { + return Err(ExternalGenerationJobPhaseUpdateError::lease_fencing( + "external_generation_job lease 已过期", + )); } - Ok(row) + Ok(()) } fn is_external_generation_job_owned_by_worker( @@ -2486,6 +2580,78 @@ mod tests { assert!(normalize_external_generation_job_phase("uploading").is_err()); } + #[test] + fn external_generation_phase_rejection_kind_is_machine_readable() { + let lease = ExternalGenerationJobPhaseUpdateError::lease_fencing("lease 已过期"); + assert_eq!( + lease.kind, + ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected + ); + assert_eq!(lease.message, "lease 已过期"); + + let other = ExternalGenerationJobPhaseUpdateError::other("phase 非法"); + assert_eq!( + other.kind, + ExternalGenerationJobPhaseUpdateFailureKind::OtherRejected + ); + assert_eq!(other.message, "phase 非法"); + } + + #[test] + fn external_generation_phase_lease_guard_classifies_every_fencing_rejection() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.worker_id = Some("worker-a".to_string()); + row.lease_token = Some("lease-1".to_string()); + row.lease_expires_at = Some(micros(2_000)); + + assert!( + validate_external_generation_job_phase_update_lease( + &row, + "worker-a", + "lease-1", + micros(1_999), + ) + .is_ok() + ); + + let mut terminal = row.clone(); + terminal.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string(); + let cases = [ + validate_external_generation_job_phase_update_lease( + &terminal, + "worker-a", + "lease-1", + micros(1_999), + ), + validate_external_generation_job_phase_update_lease( + &row, + "worker-b", + "lease-1", + micros(1_999), + ), + validate_external_generation_job_phase_update_lease( + &row, + "worker-a", + "lease-2", + micros(1_999), + ), + validate_external_generation_job_phase_update_lease( + &row, + "worker-a", + "lease-1", + micros(2_000), + ), + ]; + + for result in cases { + let error = result.expect_err("stale worker 必须被 fencing 拒绝"); + assert_eq!( + error.kind, + ExternalGenerationJobPhaseUpdateFailureKind::LeaseFencingRejected + ); + } + } + #[test] fn external_generation_job_result_failure_is_structured() { let result = failed_external_generation_job_result("失败".to_string()); diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 7a75928b8..da6398880 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2938,7 +2938,7 @@ describe('ImageCanvasEditorView generation integration', () => { status: 'completed', progress: 100, phaseDetail: '生成已完成。', - warning: '连通域数量不足', + warning: '图集已生成,但自动拆分未完成:连通域数量不足', completedAt: '2026-06-21T00:01:00.000Z', updatedAt: '2026-06-21T00:01:00.000Z', updatedAtMicros: 2, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 551bc50d5..3ec81ab63 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1251,9 +1251,7 @@ export function ImageCanvasEditorView({ } const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim(); if (warning) { - showGenerationWarning( - `图集已生成,但自动拆分未完成:${warning}`, - ); + showGenerationWarning(warning); } refreshEditorWalletBalance(); void loadEditorProject(projectId) diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 119962679..2188cdd40 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1029,7 +1029,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { it('submits character redraws through the character image prompt contract', async () => { generateEditorImageMock.mockResolvedValueOnce( - createGenerated({ prompt: '角色换成蓝色披风' }), + createGenerated({ + prompt: '角色换成蓝色披风', + warning: { + code: 'postprocess-failed-source-preserved', + reason: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + }, + }), ); render( { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:角色换成蓝色披风:-', ); + expect(screen.getByTestId('generation-warning').textContent).toBe( + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + ); }); it('submits video quick edits through the video API with the source video', async () => { @@ -1653,7 +1663,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); }); - it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => { + it('refreshes the wallet and shows the warning after a queued character generation completes', async () => { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const refreshWalletBalance = vi.fn(); @@ -1671,9 +1681,12 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { imageSrc: '/generated-editor-images/project/image.png', width: 1024, height: 1024, - prompt: '队列生成', + prompt: '队列角色生成', + }), + queueState: createQueueState({ + warning: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', }), - queueState: createQueueState(), }); render( { onQueuedGenerationTask={refreshTaskList} onWalletBalanceMayHaveChanged={refreshWalletBalance} initialDialog={{ - id: 'dialog-generate', - mode: 'generate', - prompt: '队列生成', + id: 'dialog-character', + mode: 'character', + prompt: '队列角色生成', status: 'idle', composerOpen: true, imageModel: 'gpt-image-2', @@ -1712,6 +1725,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('generation-warning').textContent).toBe( + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + ); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); @@ -1805,7 +1821,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await applyQueuedEditorGenerationProject( { queueState: createQueueState({ - warning: '连通域数量不足', + warning: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', }), }, 'editor-project-1', @@ -1816,7 +1833,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); expect(onGenerationWarning).toHaveBeenCalledWith( - '图集已生成,但自动拆分未完成:连通域数量不足', + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', ); }); @@ -2388,6 +2405,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', + warning: { + code: 'postprocess-failed-source-preserved', + reason: + 'UI 素材图集已生成,但透明背景处理失败,已将原图放入画布;未生成透明图集和拆分素材。', + }, }); render( { 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('fit-count').textContent).toBe('1'); + expect(screen.getByTestId('generation-warning').textContent).toBe( + 'UI 素材图集已生成,但透明背景处理失败,已将原图放入画布;未生成透明图集和拆分素材。', + ); }); it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => { @@ -2545,7 +2570,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { it('keeps character generation dialog data after the result is created', async () => { generateEditorImageMock.mockResolvedValueOnce( - createGenerated({ width: 768, height: 768 }), + createGenerated({ + width: 768, + height: 768, + warning: { + code: 'postprocess-failed-source-preserved', + reason: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + }, + }), ); render( { expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:layer-generated-1:placeholder:-', ); + expect(screen.getByTestId('generation-warning').textContent).toBe( + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + ); }); it('moves character animation panels from generating to completed', async () => { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 0a12470a8..fb8b16ec2 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -88,6 +88,9 @@ import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetEx type CanvasSize = { width: number; height: number }; +const EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX = + '图集已生成,但自动拆分未完成:'; + type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; @@ -438,7 +441,21 @@ function notifyEditorGenerationWarning( if (!reason) { return; } - callback?.(`图集已生成,但自动拆分未完成:${reason}`); + callback?.(reason); +} + +function resolveEditorGenerationWarningMessage( + warning: string | null | undefined, + sliceWarning: string | null | undefined, +) { + const commonReason = warning?.trim(); + if (commonReason) { + return commonReason; + } + const sliceReason = sliceWarning?.trim(); + return sliceReason + ? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}` + : undefined; } async function runEditorGenerationWithWalletRefresh( @@ -1097,7 +1114,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ onWalletBalanceMayHaveChanged, ); notifyEditorGenerationWarning( - generated.sliceWarning?.reason, + resolveEditorGenerationWarningMessage( + generated.warning?.reason, + generated.sliceWarning?.reason, + ), onGenerationWarning, ); if ( @@ -1234,7 +1254,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); rememberImageModel(submissionPlan.rememberImageModel); notifyEditorGenerationWarning( - generated.sliceWarning?.reason, + resolveEditorGenerationWarningMessage( + generated.warning?.reason, + generated.sliceWarning?.reason, + ), onGenerationWarning, ); if ( @@ -1590,6 +1613,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }), onWalletBalanceMayHaveChanged, ); + const characterWarningCallback = isCharacterRedraw + ? onGenerationWarning + : undefined; + if (isCharacterRedraw) { + notifyEditorGenerationWarning( + generated.warning?.reason, + characterWarningCallback, + ); + } if ( await applyQueuedEditorGenerationProject( generated, @@ -1597,6 +1629,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, + characterWarningCallback, ) ) { if (panelMode === 'quick-edit') { @@ -1681,6 +1714,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setQuickEditPanel, updateCanvasGenerationDialogById, viewport, + onGenerationWarning, onWalletBalanceMayHaveChanged, ]); @@ -1999,6 +2033,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }), onWalletBalanceMayHaveChanged, ); + const characterWarningCallback = + imageGenerationInput.kind === 'character' + ? onGenerationWarning + : undefined; + if (imageGenerationInput.kind === 'character') { + notifyEditorGenerationWarning( + generated.warning?.reason, + characterWarningCallback, + ); + } if ( await applyQueuedEditorGenerationProject( generated, @@ -2006,6 +2050,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, + characterWarningCallback, ) ) { return; @@ -2078,6 +2123,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setGenerateDialog, updateCanvasGenerationDialogById, upsertGeneratedAsset, + onGenerationWarning, onWalletBalanceMayHaveChanged, ], ); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 2e8da9ddb..20cf04882 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -743,6 +743,11 @@ describe('editorProjectClient', () => { model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-task-1', + warning: { + code: 'postprocess-failed-source-preserved', + reason: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + }, }); const result = await generateEditorImage({ @@ -750,6 +755,11 @@ describe('editorProjectClient', () => { }); expect(result.taskId).toBe('vector-task-1'); + expect(result.warning).toEqual({ + code: 'postprocess-failed-source-preserved', + reason: + '图片已生成,但透明背景处理失败,已将原图放入画布;未生成透明处理图。', + }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 15c72b3ad..90b1abf28 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -273,6 +273,7 @@ export type EditorImageGenerationResult = { resource?: EditorProjectResourceSnapshot | null; asset?: EditorAssetSnapshot | null; project?: EditorProjectSnapshot | null; + warning?: EditorGenerationWarning | null; queueState?: ExternalGenerationJobStatusRecord | null; }; @@ -289,11 +290,13 @@ export type EditorIconSpritesheetIconResult = { asset?: EditorAssetSnapshot | null; }; -export type EditorIconSpritesheetSliceWarning = { +export type EditorGenerationWarning = { code: string; reason: string; }; +export type EditorIconSpritesheetSliceWarning = EditorGenerationWarning; + export type EditorIconSpritesheetGenerationResult = { spritesheetImageSrc: string; spritesheetWidth: number; @@ -309,6 +312,7 @@ export type EditorIconSpritesheetGenerationResult = { spritesheetResource?: EditorProjectResourceSnapshot | null; spritesheetAsset?: EditorAssetSnapshot | null; project?: EditorProjectSnapshot | null; + warning?: EditorGenerationWarning | null; queueState?: ExternalGenerationJobStatusRecord | null; };