From fc49ba2a612cc95086862920b516044d44808f51 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 21 Jun 2026 22:30:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=86=99=E5=85=A5=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=94=9F=E6=88=90=E5=AE=8C=E6=88=90=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端在图片生成完成后直接写入画布布局和生成器状态 前端提交canvasCompletion并只应用后端项目快照刷新显示 移除项目加载时根据资源行推断生成完成态的路径 更新OpenAPI、图片画布文档和共享排障记忆 --- .../genarrative-external-v1.openapi.json | 79 +++ .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- .../crates/api-server/src/editor_project.rs | 559 +++++++++++++++++- .../image-editor/ImageCanvasEditorModel.ts | 11 + .../image-editor/ImageCanvasEditorView.tsx | 3 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 149 ++++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 39 +- .../useImageCanvasGenerationSurface.tsx | 8 +- .../useImageCanvasGenerationWorkflow.ts | 8 +- .../useImageCanvasProjectPersistence.test.tsx | 109 ++++ .../useImageCanvasProjectPersistence.ts | 114 ++-- .../image-editor/editorProjectClient.test.ts | 63 ++ .../image-editor/editorProjectClient.ts | 19 + 15 files changed, 1131 insertions(+), 50 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 60bf3100e..bf7087e02 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2555,6 +2555,74 @@ "string", "null" ] + }, + "canvasCompletion": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorCanvasGenerationCompletion" + }, + { + "type": "null" + } + ], + "description": "带项目上下文生成时,服务端据此直接写入画布完成态并返回最新项目快照。" + } + }, + "additionalProperties": false + }, + "EditorCanvasGenerationCompletion": { + "type": "object", + "required": [ + "dialogId", + "title", + "placeholder" + ], + "properties": { + "dialogId": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "placeholder": { + "$ref": "#/components/schemas/EditorCanvasGenerationPlaceholder" + } + }, + "additionalProperties": false + }, + "EditorCanvasGenerationPlaceholder": { + "type": "object", + "required": [ + "x", + "y", + "width", + "height", + "originalWidth", + "originalHeight" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "minimum": 1 + }, + "height": { + "type": "number", + "minimum": 1 + }, + "originalWidth": { + "type": "number", + "minimum": 1 + }, + "originalHeight": { + "type": "number", + "minimum": 1 } }, "additionalProperties": false @@ -2687,6 +2755,17 @@ "type": "null" } ] + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProject" + }, + { + "type": "null" + } + ], + "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" } } }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f307a9e37..416adf545 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-21 图片画布生成完成态由后端写入画布布局 + +- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。 +- 决策:图片生成请求在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、结果标题和占位框);`api-server` 在生成成功并创建 `editor_project_resource` / `editor_asset` 后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层,把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存 layout 后返回最新项目快照。前端只应用后端快照刷新显示,不再把生成完成态作为正式业务真相,也不在项目加载时根据资源行推断完成态。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布生成提交工作流、项目快照 hydrate / persistence、图片画布技术方案和排障记录。 +- 验证方式:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-06-21 图片画布参考图元数据只保存项目内行引用 - 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 428d096bc..f2950215e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -87,6 +87,14 @@ - 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,其中 `editor_character_animation_extracts_final_sample_from_short_video` 应覆盖本机 FFmpeg 8 的 0 帧回归。 - 关联:`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +## 图片编辑器生成长请求完成态必须由后端写入画布 + +- 现象:画板角色形象等生成请求已经在服务端返回 `200`,OSS 中也已有 `generated-character-drafts/.../image.png`,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。 +- 原因:生成是一次长 HTTP 请求,浏览器在请求完成前刷新或重新挂载时会丢失原页面的成功回调;如果完成态只靠前端回调把结果图层写回 `editor_canvas.layers_json`,服务端虽然已经创建 `editor_project_resource` / `editor_asset`,但布局里的 `generation-dialog` 仍可能停在 `status="generating"` 且没有 `generatedLayerId`。如果之后从素材库把同一私有素材加回画布,前端再次创建项目资源时若提交 signed URL / Data URL,还会触发 `413`,进一步阻断资源行绑定。 +- 处理:图片生成提交必须在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框);`api-server` 生成成功并创建资源后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层、把生成器改回 `idle` 并写入 `generatedLayerId`,再沿用后端当前 viewport 保存 layout 并返回最新项目快照。前端只应用该快照刷新显示,不在加载时根据资源行推断完成态;有项目上下文但后端没有返回快照时也不得本地补结果图层。为已有 `objectKey` 的图层创建项目资源时,`imageSrc` 只提交 `/`,不要提交 signed URL / Data URL。 +- 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL - 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e156e9877..a10ccaab3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -40,11 +40,11 @@ - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 -- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。 +- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框),BFF 在生成成功后必须直接读取当前项目布局,只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `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`;宣发素材生成器还必须保存并恢复 `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` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 +- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。带 `canvasCompletion` 的图片生成以 BFF 返回的 `project` 快照刷新画布;无项目上下文或没有返回快照时,前端才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3867d791f..d205c1f0b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -177,6 +177,26 @@ pub struct EditorImageGenerationRequest { pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) source_resource_id: Option, + pub(crate) canvas_completion: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCanvasGenerationCompletionRequest { + pub(crate) dialog_id: String, + pub(crate) title: String, + pub(crate) placeholder: EditorCanvasGenerationPlaceholderPayload, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCanvasGenerationPlaceholderPayload { + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) original_width: f64, + pub(crate) original_height: f64, } #[derive(Debug, Deserialize)] @@ -293,6 +313,11 @@ struct EditorGeneratedAssetRecord { asset: Option, } +struct EditorCanvasGenerationCompletionResult { + layers: Value, + changed: bool, +} + #[derive(Debug)] struct PersistEditorGeneratedAssetInput { project_id: Option, @@ -330,6 +355,7 @@ pub struct EditorImageGenerationResponse { task_id: String, resource: Option, asset: Option, + project: Option, } #[derive(Debug, Serialize)] @@ -1062,8 +1088,8 @@ pub(crate) async fn generate_editor_image_for_owner( let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { - project_id: payload.project_id, - owner_user_id: caller.owner_user_id, + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| role_setting.clone()), @@ -1085,6 +1111,14 @@ pub(crate) async fn generate_editor_image_for_owner( }, ) .await?; + let completed_project = complete_editor_canvas_generation( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + generated_asset.resource.as_ref(), + ) + .await?; Ok(json_success_body( Some(&request_context), @@ -1104,6 +1138,7 @@ pub(crate) async fn generate_editor_image_for_owner( task_id: generated.task_id, resource: generated_asset.resource, asset: generated_asset.asset, + project: completed_project, }, )) } @@ -1356,6 +1391,7 @@ pub(crate) async fn edit_editor_image_for_owner( task_id: generated.task_id, resource: generated_asset.resource, asset: generated_asset.asset, + project: None, }, )) } @@ -1885,6 +1921,210 @@ async fn persist_editor_generated_asset( Ok(EditorGeneratedAssetRecord { resource, asset }) } +async fn complete_editor_canvas_generation( + state: &AppState, + owner_user_id: &str, + project_id: Option<&str>, + completion: Option<&EditorCanvasGenerationCompletionRequest>, + resource: Option<&EditorProjectResourcePayload>, +) -> Result, AppError> { + let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource) + else { + return Ok(None); + }; + let project_id = project_id.trim(); + if project_id.is_empty() || completion.dialog_id.trim().is_empty() { + return Ok(None); + } + + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(map_editor_project_error)?; + let completion_result = + apply_editor_canvas_generation_completion(project.layers, completion, resource)?; + if !completion_result.changed { + return Ok(None); + } + let saved = state + .spacetime_client() + .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + viewport: project.viewport.clone(), + layers_json: serialize_editor_layers(completion_result.layers)?, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(Some(editor_project_payload_from_record(saved))) +} + +fn apply_editor_canvas_generation_completion( + layers: Value, + completion: &EditorCanvasGenerationCompletionRequest, + resource: &EditorProjectResourcePayload, +) -> Result { + let mut items = match layers { + Value::Array(items) => items, + _ => Vec::new(), + }; + let Some(placeholder) = items + .iter() + .find(|item| generation_dialog_item_matches(item, completion.dialog_id.as_str())) + .map(|item| { + generation_dialog_item_placeholder(item, completion.dialog_id.as_str()) + .unwrap_or_else(|| completion.placeholder.clone()) + }) + else { + return Ok(EditorCanvasGenerationCompletionResult { + layers: Value::Array(items), + changed: false, + }); + }; + let generated_layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); + let completed_layer_id = if let Some(existing_layer_id) = items.iter().find_map(|item| { + let resource_id = item.get("resourceId").and_then(Value::as_str)?; + if resource_id == resource.resource_id { + item.get("layerId") + .and_then(Value::as_str) + .map(str::to_string) + } else { + None + } + }) { + existing_layer_id + } else { + let z_index = next_canvas_layer_z_index(&items); + items.push(build_generated_canvas_layer_item( + completion, + &placeholder, + resource, + generated_layer_id.as_str(), + z_index, + )); + generated_layer_id + }; + + for item in &mut items { + if generation_dialog_item_matches(item, completion.dialog_id.as_str()) { + if let Some(dialog) = item.get_mut("dialog").and_then(Value::as_object_mut) { + dialog.insert("status".to_string(), json!("idle")); + dialog.insert("composerOpen".to_string(), json!(true)); + dialog.insert("generatedLayerId".to_string(), json!(completed_layer_id)); + dialog.remove("errorMessage"); + } + } + } + + Ok(EditorCanvasGenerationCompletionResult { + layers: Value::Array(items), + changed: true, + }) +} + +fn build_generated_canvas_layer_item( + completion: &EditorCanvasGenerationCompletionRequest, + placeholder: &EditorCanvasGenerationPlaceholderPayload, + resource: &EditorProjectResourcePayload, + generated_layer_id: &str, + z_index: i64, +) -> Value { + let original_width = positive_f64(resource.width as f64, placeholder.original_width); + let original_height = positive_f64(resource.height as f64, placeholder.original_height); + let width = positive_f64(original_width, placeholder.width); + let height = positive_f64(original_height, placeholder.height); + let placeholder_width = positive_f64(placeholder.width, width); + let placeholder_height = positive_f64(placeholder.height, height); + let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; + let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; + let asset_kind = resource.asset_kind.as_deref(); + + json!({ + "layerId": generated_layer_id, + "resourceId": resource.resource_id, + "title": completion.title.trim(), + "x": x, + "y": y, + "width": width, + "height": height, + "originalWidth": original_width, + "originalHeight": original_height, + "zIndex": z_index, + "sourceType": "generated", + "mediaType": generated_canvas_media_type(asset_kind), + "prompt": resource.prompt, + "actualPrompt": resource.actual_prompt, + "model": resource.model, + "provider": resource.provider, + "taskId": resource.task_id, + "objectKey": resource.object_key, + "assetObjectId": resource.asset_object_id, + "sourceResourceId": resource.source_resource_id, + }) +} + +fn positive_f64(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value.round().max(1.0) + } else if fallback.is_finite() && fallback > 0.0 { + fallback.round().max(1.0) + } else { + 1.0 + } +} + +fn generated_canvas_layer_id(resource_id: &str) -> String { + format!("layer-editor-resource-{resource_id}") +} + +fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { + match asset_kind { + Some("video") => "video", + Some("sound-effect") | Some("background-music") => "audio", + _ => "image", + } +} + +fn next_canvas_layer_z_index(items: &[Value]) -> i64 { + items + .iter() + .filter_map(|item| item.get("zIndex").and_then(Value::as_i64)) + .max() + .unwrap_or(0) + + 1 +} + +fn generation_dialog_item_matches(item: &Value, dialog_id: &str) -> bool { + item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") + && item + .get("dialog") + .and_then(|dialog| dialog.get("id")) + .and_then(Value::as_str) + .is_some_and(|candidate| candidate == dialog_id) +} + +fn generation_dialog_item_placeholder( + item: &Value, + dialog_id: &str, +) -> Option { + if !generation_dialog_item_matches(item, dialog_id) { + return None; + } + serde_json::from_value( + item.get("dialog")? + .get("placeholder") + .cloned() + .unwrap_or(Value::Null), + ) + .ok() +} + fn normalize_generated_asset_folder_id( folder_id: Option, owner_user_id: &str, @@ -2563,6 +2803,321 @@ mod tests { assert_eq!(decoded.get_pixel(4, 4).0[3], 255); } + #[test] + fn editor_canvas_generation_completion_inserts_result_layer_and_marks_dialog_idle() { + let layers = json!([ + { + "layerId": "layer-existing", + "resourceId": "resource-existing", + "zIndex": 4 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "dialog": { + "id": "dialog-character", + "mode": "character", + "prompt": "银发游侠", + "status": "generating", + "composerOpen": false, + "errorMessage": "旧错误", + "placeholder": { + "x": 200, + "y": 160, + "width": 420, + "height": 560, + "originalWidth": 1024, + "originalHeight": 1536 + } + } + } + ]); + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: "dialog-character".to_string(), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 200.0, + y: 160.0, + width: 420.0, + height: 560.0, + original_width: 1024.0, + original_height: 1536.0, + }, + }; + let resource = EditorProjectResourcePayload { + resource_id: "editor-resource-character".to_string(), + project_id: "editor-project-1".to_string(), + image_src: "data:image/png;base64,character".to_string(), + object_key: Some("generated-character-drafts/editor/character.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + width: 1024, + height: 1536, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: Some("实际提示词".to_string()), + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: Some("resource-spec".to_string()), + asset_kind: Some("character".to_string()), + generation_inputs: Some(json!({ + "fields": [{"title": "角色设定", "value": "银发游侠"}], + "references": [] + })), + created_at: "2026-06-21T12:16:22.000Z".to_string(), + updated_at: "2026-06-21T12:16:22.000Z".to_string(), + }; + + let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) + .expect("completion should apply"); + assert!(completed.changed); + let items = completed + .layers + .as_array() + .expect("layers should stay array"); + let result_layer = items + .iter() + .find(|item| item["resourceId"] == "editor-resource-character") + .expect("result layer should be inserted"); + + assert_eq!( + result_layer["layerId"], + json!("layer-editor-resource-editor-resource-character") + ); + assert_eq!(result_layer["title"], json!("角色形象 1")); + assert_eq!(result_layer["x"], json!(-102.0)); + assert_eq!(result_layer["y"], json!(-328.0)); + assert_eq!(result_layer["originalWidth"], json!(1024.0)); + assert_eq!(result_layer["originalHeight"], json!(1536.0)); + assert_eq!(result_layer["zIndex"], json!(5)); + assert_eq!(result_layer["mediaType"], json!("image")); + assert_eq!( + result_layer.get("src"), + None, + "layout must not embed heavy image payloads" + ); + + let dialog = items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("dialog should remain"); + assert_eq!(dialog["status"], json!("idle")); + assert_eq!(dialog["composerOpen"], json!(true)); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-editor-resource-character") + ); + assert!(dialog.get("errorMessage").is_none()); + } + + #[test] + fn editor_canvas_generation_completion_is_idempotent_for_existing_resource_layer() { + let layers = json!([ + { + "layerId": "layer-existing-result", + "resourceId": "editor-resource-character", + "zIndex": 7 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "dialog": { + "id": "dialog-character", + "mode": "character", + "prompt": "银发游侠", + "status": "generating", + "composerOpen": false + } + } + ]); + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: "dialog-character".to_string(), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + original_width: 1.0, + original_height: 1.0, + }, + }; + let resource = EditorProjectResourcePayload { + resource_id: "editor-resource-character".to_string(), + project_id: "editor-project-1".to_string(), + image_src: "data:image/png;base64,character".to_string(), + object_key: None, + asset_object_id: None, + width: 1024, + height: 1024, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs: None, + created_at: "2026-06-21T12:16:22.000Z".to_string(), + updated_at: "2026-06-21T12:16:22.000Z".to_string(), + }; + + let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) + .expect("completion should apply"); + assert!(completed.changed); + let items = completed + .layers + .as_array() + .expect("layers should stay array"); + + assert_eq!( + items + .iter() + .filter(|item| item["resourceId"] == "editor-resource-character") + .count(), + 1 + ); + let dialog = items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("dialog should remain"); + assert_eq!(dialog["generatedLayerId"], json!("layer-existing-result")); + } + + #[test] + fn editor_canvas_generation_completion_uses_latest_layout_placeholder() { + let layers = json!([ + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "dialog": { + "id": "dialog-character", + "mode": "character", + "prompt": "银发游侠", + "status": "generating", + "composerOpen": false, + "placeholder": { + "x": 500, + "y": 400, + "width": 420, + "height": 560, + "originalWidth": 1024, + "originalHeight": 1536 + } + } + } + ]); + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: "dialog-character".to_string(), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + original_width: 1.0, + original_height: 1.0, + }, + }; + let resource = EditorProjectResourcePayload { + resource_id: "editor-resource-character".to_string(), + project_id: "editor-project-1".to_string(), + image_src: "data:image/png;base64,character".to_string(), + object_key: None, + asset_object_id: None, + width: 1024, + height: 1536, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs: None, + created_at: "2026-06-21T12:16:22.000Z".to_string(), + updated_at: "2026-06-21T12:16:22.000Z".to_string(), + }; + + let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) + .expect("completion should apply"); + let result_layer = completed + .layers + .as_array() + .expect("layers should stay array") + .iter() + .find(|item| item["resourceId"] == "editor-resource-character") + .expect("result layer should be inserted"); + + assert_eq!(result_layer["x"], json!(198.0)); + assert_eq!(result_layer["y"], json!(-88.0)); + } + + #[test] + fn editor_canvas_generation_completion_skips_when_dialog_was_removed() { + let layers = json!([ + { + "layerId": "layer-existing", + "resourceId": "resource-existing", + "zIndex": 4 + } + ]); + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: "dialog-character".to_string(), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 200.0, + y: 160.0, + width: 420.0, + height: 560.0, + original_width: 1024.0, + original_height: 1536.0, + }, + }; + let resource = EditorProjectResourcePayload { + resource_id: "editor-resource-character".to_string(), + project_id: "editor-project-1".to_string(), + image_src: "data:image/png;base64,character".to_string(), + object_key: None, + asset_object_id: None, + width: 1024, + height: 1536, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs: None, + created_at: "2026-06-21T12:16:22.000Z".to_string(), + updated_at: "2026-06-21T12:16:22.000Z".to_string(), + }; + + let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) + .expect("completion should be ignored"); + + assert!(!completed.changed); + assert_eq!( + completed.layers, + json!([ + { + "layerId": "layer-existing", + "resourceId": "resource-existing", + "zIndex": 4 + } + ]) + ); + } + #[test] fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() { let descriptions = vec![ diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index b99c0a4fa..a021d94b6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -624,13 +624,24 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { } export type CanvasLayerResourceMetadata = { + resourceId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; + width?: number | null; + height?: number | null; + sourceType?: string | null; durationSeconds?: number | null; sourceResourceId?: string | null; assetKind?: string | null; + prompt?: string | null; + actualPrompt?: string | null; + model?: string | null; + provider?: string | null; + taskId?: string | null; generationInputs?: unknown; + createdAt?: string | null; + updatedAt?: string | null; }; export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f6641bd49..48b2d330a 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -493,7 +493,7 @@ export function ImageCanvasEditorView() { setViewport, ], ); - const { projectId, appendCanvasLayersWithResources } = + const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, @@ -615,6 +615,7 @@ export function ImageCanvasEditorView() { projectId, assetFolderId: activeUploadFolderId, upsertGeneratedAsset, + applyProjectSnapshot, }); const { setQuickEditPanel, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 308441501..a5cbc894d 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -100,11 +100,17 @@ function SubmissionWorkflowHarness({ initialQuickEditPanel = null, initialCharacterAnimationPanel = null, initialLayers = [createLayer()], + projectId = undefined, + applyProjectSnapshot = undefined, }: { initialDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; initialCharacterAnimationPanel?: CharacterAnimationPanelState | null; initialLayers?: CanvasLayer[]; + projectId?: string | null; + applyProjectSnapshot?: Parameters< + typeof useImageCanvasGenerationSubmissionWorkflow + >[0]['applyProjectSnapshot']; }) { const [layers, setLayers] = useState(initialLayers); const [activeTool, setActiveTool] = useState('generate'); @@ -164,6 +170,9 @@ function SubmissionWorkflowHarness({ rememberImageModel: (imageModel) => { rememberedImageModelRef.current = imageModel; }, + projectId, + assetFolderId: projectId ? 'project' : undefined, + applyProjectSnapshot, }); return ( @@ -456,7 +465,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); - it('uses the latest moved canvas placeholder when a generation completes', async () => { + it('uses the latest moved canvas placeholder for local-only generation fallback', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 512, height: 512 }), ); @@ -494,6 +503,144 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('passes canvas completion context and refreshes from the backend project snapshot', async () => { + const applyProjectSnapshot = vi.fn(); + generateEditorImageMock.mockResolvedValueOnce({ + ...createGenerated({ + width: 1024, + height: 1024, + prompt: '银发游侠', + }), + project: { + projectId: 'editor-project-1', + title: '角色项目', + viewport: { x: 12, y: 24, scale: 1.5 }, + layers: [ + { + layerId: 'layer-editor-resource-character', + resourceId: 'editor-resource-character', + }, + ], + resources: [], + updatedAt: '2026-06-21T12:16:22.000Z', + }, + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '银发游侠', + kind: 'character', + projectId: 'editor-project-1', + assetKind: 'character', + assetFolderId: 'project', + canvasCompletion: { + dialogId: 'dialog-character', + title: '角色形象 1', + placeholder: { + x: 200, + y: 160, + width: 420, + height: 560, + originalWidth: 1024, + originalHeight: 1536, + }, + }, + }), + ); + }); + await waitFor(() => { + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'editor-project-1', + title: '角色项目', + }), + ); + }); + expect(screen.getByTestId('layers').textContent).not.toContain( + 'layer-generated-1', + ); + }); + + it('does not add a local layer when project generation returns without a backend snapshot', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ + width: 1024, + height: 1024, + prompt: '银发游侠', + project: null, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'editor-project-1', + canvasCompletion: expect.objectContaining({ + dialogId: 'dialog-character', + }), + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toBe( + 'character:idle:open:-:placeholder:-', + ); + }); + expect(screen.getByTestId('layers').textContent).not.toContain( + 'layer-generated-1', + ); + }); + it('submits publication detail materials as one billable editor image and appends the result', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index b8d1881c9..3137047bc 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -20,7 +20,10 @@ import { generateEditorSoundEffect, generateEditorVideo, } from '../../services/image-editor/editorProjectClient'; -import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient'; +import type { + EditorAssetSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationDialogState, CanvasGenerationInputs, @@ -100,6 +103,7 @@ type GenerationSubmissionWorkflowOptions = { projectId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; + applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; }; async function normalizePublicationReferenceImages( @@ -148,6 +152,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, assetFolderId, upsertGeneratedAsset, + applyProjectSnapshot, }: GenerationSubmissionWorkflowOptions) { const addGeneratedLayersToCanvas = useCallback( (nextLayers: CanvasLayer[]) => { @@ -849,10 +854,39 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generationInputs: submissionPlan.result.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, + ...(canvasDialog && projectId + ? { + canvasCompletion: { + dialogId: canvasDialog.id, + title: submissionPlan.result.title, + placeholder: getGeneratingDialogPlaceholder(dialog), + }, + } + : {}), }); if (submissionPlan.rememberImageModel) { rememberImageModel(submissionPlan.rememberImageModel); } + if (generated.project && applyProjectSnapshot) { + applyProjectSnapshot(generated.project); + if (generated.asset) { + upsertGeneratedAsset?.(generated.asset); + } + return; + } + if (canvasDialog && projectId) { + updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => + currentDialog.generatedLayerId + ? currentDialog + : { + ...currentDialog, + status: 'idle', + composerOpen: true, + errorMessage: undefined, + }, + ); + return; + } addGeneratedResultLayer(generated, { frame: getGeneratingDialogPlaceholder(dialog), assetKind: submissionPlan.result.assetKind, @@ -891,8 +925,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({ rememberImageModel, projectId, assetFolderId, + applyProjectSnapshot, setGenerateDialog, updateCanvasGenerationDialogById, + upsertGeneratedAsset, + viewport, ], ); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 38d7dc666..06329c9ee 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -15,7 +15,10 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient'; +import type { + EditorAssetSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -91,6 +94,7 @@ type ImageCanvasGenerationSurfaceOptions = { projectId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; + applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; }; function renderEditorPortal(node: ReactNode) { @@ -167,6 +171,7 @@ export function useImageCanvasGenerationSurface({ projectId, assetFolderId, upsertGeneratedAsset, + applyProjectSnapshot, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef void; + applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; }; export function useImageCanvasGenerationWorkflow({ @@ -218,6 +222,7 @@ export function useImageCanvasGenerationWorkflow({ projectId, assetFolderId, upsertGeneratedAsset, + applyProjectSnapshot, }: GenerationWorkflowOptions) { const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = @@ -976,6 +981,7 @@ export function useImageCanvasGenerationWorkflow({ projectId, assetFolderId, upsertGeneratedAsset, + applyProjectSnapshot, }); const setEffectiveCharacterAnimationPanel: Dispatch< diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 8de81221f..de86a8a85 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -178,6 +178,21 @@ function ProjectPersistenceHarness({ > append character +