diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 622240e86..e520ad764 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -296,7 +296,7 @@ npm run check:server-rs-ddd - 用途:外部生成 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` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 素材写回:worker 成功后仍经 `api-server` facade 写入 `editor_project_resource` / `editor_asset`;结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 分流按上一条执行,前端不直接发明正式完成态。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 -- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 +- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥,风格归一化或像素规整产生的通用 `warning` 可与 `sliceWarning` 并存。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`,两条并存时按“通用在前、拆分在后”拼接且 `code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),不允许任何一条被丢弃;`sliceWarning.reason` 无论是否并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 ### `external_generation_job_summary` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 038dc8ad6..d98f6279d 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -42,7 +42,7 @@ 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` 为准。 +角色图生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`,当前稳定 `code` 为 `postprocess-failed-source-preserved`。provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 warning,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);2026-07-29 起风格归一化或像素规整会产生新的通用 `warning`,它可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index a3dfcd624..7daef9a08 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -123,7 +123,7 @@ - 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 - 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 -- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 +- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,该情况不会进入拆分,因此与 `sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 - 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9d6dd2861..ca45bb9ba 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -124,7 +124,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persisten const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; -const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; +pub(crate) const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); 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 4280c81ae..15e9ad98d 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -40,7 +40,8 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest, + EditorGenerationCaller, EditorGenerationPhaseReporter, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, @@ -1270,12 +1271,11 @@ fn compact_editor_generation_result(mut result: Value) -> Value { result } -fn extract_editor_generation_warning(response: &Value) -> Option { - let data = response.get("data").unwrap_or(response); - let (warning, is_slice_warning) = match data.get("warning") { - Some(warning) => (warning, false), - None => (data.get("sliceWarning")?, true), - }; +fn extract_editor_generation_warning_fields( + warning: Option<&Value>, + is_slice_warning: bool, +) -> Option<(String, String)> { + let warning = warning?; let code = warning.get("code")?.as_str()?.trim(); let reason = warning.get("reason")?.as_str()?.trim(); if code.is_empty() || reason.is_empty() { @@ -1286,6 +1286,29 @@ fn extract_editor_generation_warning(response: &Value) -> Option { } else { reason.to_string() }; + Some((code.to_string(), reason)) +} + +fn extract_editor_generation_warning(response: &Value) -> Option { + let data = response.get("data").unwrap_or(response); + // 中文注释:风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存。 + // 队列结果只有一个有界 warning 字段,因此按与 inline 响应相同的策略归一: + // code 不同时收敛为 multiple-generation-warnings,reason 按“通用在前、拆分在后” + // 顺序拼接,再交给既有上界收敛,不允许其中任何一条被静默丢弃。 + let common = extract_editor_generation_warning_fields(data.get("warning"), false); + let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true); + let (code, reason) = match (common, slice) { + (None, None) => return None, + (Some(warning), None) | (None, Some(warning)) => warning, + (Some((common_code, common_reason)), Some((slice_code, slice_reason))) => { + let code = if common_code == slice_code { + common_code + } else { + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string() + }; + (code, format!("{common_reason} {slice_reason}")) + } + }; let reason = normalize_editor_generation_warning_reason(reason.as_str()); Some(json!({ "code": code, @@ -1846,18 +1869,18 @@ mod tests { } #[test] - fn editor_generation_result_payload_prefers_common_postprocess_warning() { + fn editor_generation_result_payload_merges_common_and_slice_warnings() { 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": "生成任务成功,后处理失败。" + "code": "unsupported-image-style", + "reason": "不支持的图片风格,已按无风格继续生成。" }, "sliceWarning": { "code": "insufficient-connected-components", - "reason": "不应覆盖通用后处理告警" + "reason": "有效连通域不足" } } }); @@ -1866,6 +1889,35 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("worker 结果应是合法 JSON"); + // 中文注释:风格归一化告警与拆分告警可以并存,队列只有一个 warning 字段, + // 必须拼接后收敛 code,不能让其中任何一条消失。 + assert_eq!( + payload["warning"], + json!({ + "code": "multiple-generation-warnings", + "reason": "不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:有效连通域不足" + }) + ); + assert!(payload.get("imageSrc").is_none()); + } + + #[test] + fn editor_generation_result_payload_keeps_single_warning_untouched() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let response = json!({ + "data": { + "warning": { + "code": "postprocess-failed-source-preserved", + "reason": "生成任务成功,后处理失败。" + } + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker 结果应是合法 JSON"); + + // 中文注释:透明背景最终失败不会进入拆分,此时仍是单条告警,原样保留。 assert_eq!( payload["warning"], json!({ @@ -1873,7 +1925,6 @@ mod tests { "reason": "生成任务成功,后处理失败。" }) ); - assert!(payload.get("imageSrc").is_none()); } #[test] diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 02de6372f..038ecf126 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2413,6 +2413,65 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('keeps both the common warning and the slice warning when they coexist', async () => { + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 512, + spritesheetHeight: 512, + prompt: '图标素材', + actualPrompt: '图标素材', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-icons', + iconImageSrcs: [], + warning: { + code: 'unsupported-image-style', + reason: '不支持的图片风格,已按无风格继续生成。', + }, + sliceWarning: { + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }, + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交图标' })); + + // 风格归一化告警与拆分告警可以并存,两者都必须出现在同一条提示里。 + await waitFor(() => { + expect(screen.getByTestId('generation-warning').textContent).toBe( + '不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:连通域数量不足', + ); + }); + }); + it('submits uploaded objectKey icon references before submitting spritesheets', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index af859f2d3..ff4df781d 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -457,14 +457,17 @@ function resolveEditorGenerationWarningMessage( warning: string | null | undefined, sliceWarning: string | null | undefined, ) { + // 风格归一化和像素规整产生的通用 warning 可以与 sliceWarning 并存, + // 这里按“通用在前、拆分在后”拼接成单条提示,不允许其中任何一条被丢弃。 const commonReason = warning?.trim(); - if (commonReason) { - return commonReason; - } const sliceReason = sliceWarning?.trim(); - return sliceReason + const prefixedSliceReason = sliceReason ? `${EDITOR_SPRITESHEET_SLICE_WARNING_PREFIX}${sliceReason}` : undefined; + if (commonReason && prefixedSliceReason) { + return `${commonReason} ${prefixedSliceReason}`; + } + return commonReason || prefixedSliceReason; } async function runEditorGenerationWithWalletRefresh(