From 6d4033f0443b8381315ab51db84e9c8c45e271ac Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 2 Jul 2026 20:28:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E8=AE=A9=E9=9F=B3=E9=A2=91=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=BF=9B=E5=85=A5=E7=B4=A0=E6=9D=90=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 音效和背景音乐生成复用媒体素材持久化路径 前端提交音频生成时携带素材库上下文并消费返回素材快照 更新音频生成契约、OpenAPI 和相关文档 补充音频素材库链路回归测试 --- .../genarrative-external-v1.openapi.json | 50 +++++++++ ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 3 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 10 +- .../generation.rs | 96 ++++++++++++++-- .../vector_engine_audio_generation/tests.rs | 12 ++ .../crates/shared-contracts/src/assets.rs | 35 ++++++ .../ImageCanvasGenerationLayerModel.test.ts | 27 ++++- .../ImageCanvasGenerationLayerModel.ts | 15 ++- ...anvasGenerationSubmissionWorkflow.test.tsx | 106 ++++++++++++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 7 ++ .../image-editor/editorProjectClient.test.ts | 19 ++++ .../image-editor/editorProjectClient.ts | 10 ++ 12 files changed, 371 insertions(+), 19 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index f0bd895b4..3fab957cf 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3643,6 +3643,20 @@ }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ], + "description": "传 project 时写入默认项目素材文件夹;传具体 folderId 时写入该文件夹。" + }, + "assetLabel": { + "type": [ + "string", + "null" + ], + "description": "写入素材库时使用的素材名称。" } }, "additionalProperties": false @@ -3681,6 +3695,20 @@ }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": [ + "string", + "null" + ], + "description": "传 project 时写入默认项目素材文件夹;传具体 folderId 时写入该文件夹。" + }, + "assetLabel": { + "type": [ + "string", + "null" + ], + "description": "写入素材库时使用的素材名称。" } }, "additionalProperties": false @@ -3771,6 +3799,28 @@ ], "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ], + "description": "请求携带 projectId 时返回写入的画布资源快照。" + }, + "asset": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorAsset" + }, + { + "type": "null" + } + ], + "description": "请求携带 assetFolderId 时返回写入的账号素材快照。" + }, "queueState": { "anyOf": [ { diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 5f55669f2..83a8e5e54 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -52,7 +52,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 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `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` 时完成。`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` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -80,6 +80,7 @@ - `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和 `editor_green_screen` 组装的标准绿幕素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `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/images/edits`:按提示词、当前图片 Data URL 和最多 8 张额外参考图调用 VectorEngine edits,返回新的生成图片元数据;请求携带 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`。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index f0a8aa06a..b7159d938 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -99,13 +99,13 @@ SpacetimeDB procedure: - 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。 - 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。 -- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;音频类外部调用使用 API Key 所属账号作为 asset owner。 +- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。 - API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。 -图片类外部生成成功后,后端拿到素材后: +素材外部生成成功后,后端拿到素材后: -1. 通过 OSS / asset object adapter 持久化图片。 -2. 写入 `editor_asset`,让生成图进入账号级素材库。 +1. 通过 OSS / asset object adapter 持久化媒体文件。 +2. 写入 `editor_asset`,让生成素材进入账号级素材库。 3. 如果请求带 `projectId`,写入 `editor_project_resource`。 4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。 @@ -136,7 +136,7 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 -- 外部图片生成、重绘、图标拆分和 UI 素材拆分成功后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 外部图片生成、重绘、图标拆分、UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 - OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 2141c3cfe..717cec731 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -20,8 +20,9 @@ use crate::{ editor_generation_source_entity_id, enqueue_editor_generation_job, }, editor_project::{ - EditorCanvasGeneratedLayerInput, build_editor_canvas_generated_layer_item, - complete_editor_canvas_generation_with_items, + EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, + build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, + persist_editor_generated_media_asset, }, http_error::AppError, request_context::RequestContext, @@ -191,6 +192,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); + let asset_folder_id = payload.asset_folder_id.clone(); + let asset_label = payload.asset_label.clone(); let pricing = state .editor_generation_pricing() .map_err(|error| { @@ -239,13 +242,47 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( .audio_src .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址")) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let (resource, asset) = persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or_else(|| "生成音效".to_string()), + image_src: audio_src.clone(), + object_key: generated.object_key.clone(), + asset_object_id: generated.asset_object_id.clone(), + width: EDITOR_AUDIO_WIDTH, + height: EDITOR_AUDIO_HEIGHT, + prompt: normalized.prompt.clone(), + actual_prompt: Some(normalized.prompt.clone()), + model: normalized.model.clone(), + provider: generated.provider.clone(), + task_id: generated.task_id.clone(), + source_resource_id: None, + asset_kind: Some("sound-effect".to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: None, + }, + ) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let completed_project = if let Some(completion) = canvas_completion.as_ref() { let layer_id = format!("layer-editor-sound-effect-{}", generated.task_id); + let resource_id = resource + .as_ref() + .map(|resource| resource.resource_id.clone()) + .unwrap_or_else(|| format!("local-resource-editor-sound-effect-{}", generated.task_id)); let item = build_editor_canvas_generated_layer_item( completion, EditorCanvasGeneratedLayerInput { layer_id: layer_id.clone(), - resource_id: format!("local-resource-editor-sound-effect-{}", generated.task_id), + resource_id, title: completion.title.trim().to_string(), src: audio_src.clone(), media_type: Some("audio".to_string()), @@ -285,6 +322,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( }; let completed_project = completed_project.and_then(|project| serde_json::to_value(project).ok()); + let resource = resource.and_then(|resource| serde_json::to_value(resource).ok()); + let asset = asset.and_then(|asset| serde_json::to_value(asset).ok()); Ok(json_success_body( Some(&request_context), @@ -304,6 +343,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner( price_mud_points: normalized.price_mud_points, audio_kind: "sound-effect".to_string(), project: completed_project, + resource, + asset, queue_state: None, }, )) @@ -371,6 +412,8 @@ pub(crate) async fn generate_editor_background_music_for_owner( let project_id = payload.project_id.clone(); let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); + let asset_folder_id = payload.asset_folder_id.clone(); + let asset_label = payload.asset_label.clone(); let pricing = state .editor_generation_pricing() .map_err(|error| { @@ -417,16 +460,51 @@ pub(crate) async fn generate_editor_background_music_for_owner( .audio_src .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")) .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let (resource, asset) = persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or_else(|| "生成背景音乐".to_string()), + image_src: audio_src.clone(), + object_key: generated.object_key.clone(), + asset_object_id: generated.asset_object_id.clone(), + width: EDITOR_AUDIO_WIDTH, + height: EDITOR_AUDIO_HEIGHT, + prompt: normalized.gpt_description_prompt.clone(), + actual_prompt: Some(normalized.gpt_description_prompt.clone()), + model: platform_audio::SUNO_DEFAULT_MODEL.to_string(), + provider: generated.provider.clone(), + task_id: generated.task_id.clone(), + source_resource_id: None, + asset_kind: Some("background-music".to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: None, + }, + ) + .await + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; let completed_project = if let Some(completion) = canvas_completion.as_ref() { let layer_id = format!("layer-editor-background-music-{}", generated.task_id); + let resource_id = resource + .as_ref() + .map(|resource| resource.resource_id.clone()); let item = build_editor_canvas_generated_layer_item( completion, EditorCanvasGeneratedLayerInput { layer_id: layer_id.clone(), - resource_id: format!( - "local-resource-editor-background-music-{}", - generated.task_id - ), + resource_id: resource_id.unwrap_or_else(|| { + format!( + "local-resource-editor-background-music-{}", + generated.task_id + ) + }), title: completion.title.trim().to_string(), src: audio_src.clone(), media_type: Some("audio".to_string()), @@ -466,6 +544,8 @@ pub(crate) async fn generate_editor_background_music_for_owner( }; let completed_project = completed_project.and_then(|project| serde_json::to_value(project).ok()); + let resource = resource.and_then(|resource| serde_json::to_value(resource).ok()); + let asset = asset.and_then(|asset| serde_json::to_value(asset).ok()); Ok(json_success_body( Some(&request_context), @@ -485,6 +565,8 @@ pub(crate) async fn generate_editor_background_music_for_owner( price_mud_points: normalized.price_mud_points, audio_kind: "background-music".to_string(), project: completed_project, + resource, + asset, queue_state: None, }, )) diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs index 53913ee9d..711b638a4 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs @@ -112,6 +112,8 @@ fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect("editor sound effect request should normalize"); @@ -131,6 +133,8 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect("Vidu audio model should be accepted"); @@ -143,6 +147,8 @@ fn editor_sound_effect_request_accepts_only_vidu_audio_model() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("Suno text-to-sound should be disabled for editor sound effects"); @@ -160,6 +166,8 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("duration outside 2-10 seconds should fail"); @@ -198,6 +206,8 @@ fn editor_background_music_request_forces_instrumental_and_resolves_price() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect("editor background music request should normalize"); @@ -215,6 +225,8 @@ fn editor_background_music_request_rejects_prompt_over_documented_limit() { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit"); diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 2ab13de63..90a03a889 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -503,6 +503,10 @@ pub struct EditorSoundEffectGenerateRequest { pub canvas_completion: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub generation_inputs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_folder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_label: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -516,6 +520,10 @@ pub struct EditorBackgroundMusicGenerateRequest { pub canvas_completion: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub generation_inputs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_folder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_label: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -541,6 +549,10 @@ pub struct EditorAudioGenerateResponse { #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub queue_state: Option, } @@ -1299,11 +1311,15 @@ mod tests { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: Some("project".to_string()), + asset_label: Some("游戏音效 1".to_string()), }) .expect("sound request should serialize"); assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声")); assert_eq!(sound_payload["model"], json!("audio1.0")); assert_eq!(sound_payload["duration"], json!(7)); + assert_eq!(sound_payload["assetFolderId"], json!("project")); + assert_eq!(sound_payload["assetLabel"], json!("游戏音效 1")); assert!(sound_payload.get("priceMudPoints").is_none()); assert!(sound_payload.get("sound").is_none()); assert!(sound_payload.get("type").is_none()); @@ -1322,6 +1338,8 @@ mod tests { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect("sound request with unset model should serialize"); assert!(unset_model_payload.get("model").is_none()); @@ -1333,6 +1351,8 @@ mod tests { project_id: None, canvas_completion: None, generation_inputs: None, + asset_folder_id: None, + asset_label: None, }) .expect("background music request should serialize"); assert_eq!( @@ -1357,6 +1377,17 @@ mod tests { price_mud_points: 10, audio_kind: "sound-effect".to_string(), project: None, + resource: None, + asset: Some(json!({ + "assetId": "asset-audio-1", + "folderId": "user-1:asset-folder:project", + "label": "游戏音效 1", + "imageSrc": "/generated-character-drafts/editor-audios/sfx.mp3", + "width": 420, + "height": 120, + "sourceType": "generated", + "assetKind": "sound-effect" + })), queue_state: None, }) .expect("audio response should serialize"); @@ -1370,6 +1401,10 @@ mod tests { ); assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1")); assert_eq!(response_payload["audioKind"], json!("sound-effect")); + assert_eq!( + response_payload["asset"]["assetKind"], + json!("sound-effect") + ); } #[test] diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 611a0cb2d..dfa3993aa 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -461,6 +461,27 @@ describe('ImageCanvasGenerationLayerModel', () => { priceMudPoints: 10, audioKind: 'sound-effect', durationSeconds: 75, + resource: { + resourceId: 'resource-audio-bff', + projectId: 'editor-project-1', + imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', + objectKey: 'generated-character-drafts/editor-audios/sfx.mp3', + assetObjectId: 'assetobj-audio-1', + width: 420, + height: 120, + sourceType: 'generated', + assetKind: 'sound-effect', + }, + asset: { + assetId: 'asset-audio-bff', + folderId: 'user-1:asset-folder:project', + label: '游戏音效 14', + imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', + width: 420, + height: 120, + sourceType: 'generated', + assetKind: 'sound-effect', + }, }, generatedIndex: 14, title: '游戏音效 14', @@ -482,7 +503,7 @@ describe('ImageCanvasGenerationLayerModel', () => { expect(layer).toMatchObject({ id: 'layer-audio-14', - resourceId: 'local-resource-audio-14', + resourceId: 'resource-audio-bff', title: '游戏音效 14', src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', @@ -500,7 +521,11 @@ describe('ImageCanvasGenerationLayerModel', () => { taskId: 'sound-task-1', objectKey: 'generated-character-drafts/editor-audios/sfx.mp3', assetObjectId: 'assetobj-audio-1', + sourceAssetId: 'asset-audio-bff', durationSeconds: 75, + generatedAssetSnapshot: expect.objectContaining({ + assetId: 'asset-audio-bff', + }), }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 225090dbf..d13b56521 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -459,6 +459,8 @@ export function createAudioResultLayer({ generationInputs, frame, }: AudioResultLayerOptions): CanvasLayer { + const resource = generated.resource; + const asset = generated.asset; const originalWidth = generated.width || 420; const originalHeight = generated.height || 120; const { width, height } = resolveLayerResolutionSize( @@ -478,9 +480,10 @@ export function createAudioResultLayer({ return { id: `layer-audio-${generatedIndex}`, - resourceId: `local-resource-audio-${generatedIndex}`, + resourceId: + resource?.resourceId ?? `local-resource-audio-${generatedIndex}`, title, - src: generated.audioSrc, + src: resource?.imageSrc ?? generated.audioSrc, mediaType: 'audio', assetKind: generated.audioKind, x: frameX ?? worldCenter.x - width / 2, @@ -496,10 +499,12 @@ export function createAudioResultLayer({ model: generated.model, provider: generated.provider, taskId: generated.taskId, - objectKey: generated.objectKey, - assetObjectId: generated.assetObjectId, + objectKey: resource?.objectKey ?? generated.objectKey, + assetObjectId: resource?.assetObjectId ?? generated.assetObjectId, + sourceAssetId: asset?.assetId, durationSeconds: generated.durationSeconds ?? undefined, - generationInputs, + generationInputs: resource?.generationInputs ?? generationInputs, + generatedAssetSnapshot: asset ?? undefined, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 5149fc0d1..ff5203247 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -122,6 +122,24 @@ function createVideoGenerated(overrides = {}) { }; } +function createAudioGenerated(overrides = {}) { + return { + audioSrc: 'https://assets.example.test/generated-audio.mp3', + width: 420, + height: 120, + sourceType: 'generated' as const, + prompt: '金币掉落叮当声', + actualPrompt: '金币掉落叮当声', + model: 'audio1.0', + provider: 'VectorEngine', + taskId: 'task-audio-generated', + priceMudPoints: 5, + audioKind: 'sound-effect' as const, + durationSeconds: 7, + ...overrides, + }; +} + function createIconResult(name: string, imageSrc: string) { return { name, @@ -1110,6 +1128,94 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('submits audio generations with asset library context and upserts the returned asset', async () => { + const applyProjectSnapshot = vi.fn(); + const upsertGeneratedAsset = vi.fn(); + generateEditorSoundEffectMock.mockResolvedValueOnce( + createAudioGenerated({ + project: { + projectId: 'editor-project-audio', + title: '音频项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-27T00:00:00.000Z', + }, + asset: { + assetId: 'asset-sound-1', + folderId: 'user-1:asset-folder:project', + label: '游戏音效 1', + imageSrc: 'https://assets.example.test/generated-audio.mp3', + width: 420, + height: 120, + sourceType: 'generated', + assetKind: 'sound-effect', + }, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '金币掉落叮当声', + duration: 7, + projectId: 'editor-project-audio', + assetFolderId: 'project', + assetLabel: '游戏音效 1', + canvasCompletion: { + dialogId: 'dialog-sound', + title: '游戏音效 1', + placeholder: { + x: 200, + y: 160, + width: 420, + height: 120, + originalWidth: 420, + originalHeight: 120, + }, + }, + }), + ); + }); + await waitFor(() => { + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'editor-project-audio' }), + ); + expect(upsertGeneratedAsset).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: 'asset-sound-1', + assetKind: 'sound-effect', + }), + ); + }); + }); + it('resolves uploaded objectKey references before submitting image generation', async () => { resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce( 'data:image/jpeg;base64,normalized-ref', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index b07c543aa..cf4797680 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1547,6 +1547,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, + assetFolderId, + assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { @@ -1561,6 +1563,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, + assetFolderId, + assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder ? { canvasCompletion: { @@ -1586,6 +1590,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); + if (generated.asset) { + upsertGeneratedAsset?.(generated.asset); + } return; } addAudioResultLayer( diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 8a2598cd5..14b0fdb73 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1255,15 +1255,28 @@ describe('editorProjectClient', () => { provider: 'VectorEngine', taskId: 'sound-task-1', audioKind: 'sound-effect', + asset: { + assetId: 'asset-sound-1', + folderId: 'user-1:asset-folder:project', + label: '游戏音效 1', + imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', + width: 420, + height: 120, + sourceType: 'generated', + assetKind: 'sound-effect', + }, }); const result = await generateEditorSoundEffect({ prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, + assetFolderId: 'project', + assetLabel: '游戏音效 1', }); expect(result.taskId).toBe('sound-task-1'); + expect(result.asset?.assetId).toBe('asset-sound-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/generations', expect.objectContaining({ @@ -1273,6 +1286,8 @@ describe('editorProjectClient', () => { prompt: '金币掉落叮当声', model: 'audio1.0', duration: 7, + assetFolderId: 'project', + assetLabel: '游戏音效 1', }), }), '生成游戏音效失败', @@ -1334,6 +1349,8 @@ describe('editorProjectClient', () => { const result = await generateEditorBackgroundMusic({ gptDescriptionPrompt: '森林冒险背景音乐', makeInstrumental: true, + assetFolderId: 'project', + assetLabel: '游戏背景音乐 1', }); expect(result.taskId).toBe('music-task-1'); @@ -1345,6 +1362,8 @@ describe('editorProjectClient', () => { body: JSON.stringify({ gptDescriptionPrompt: '森林冒险背景音乐', makeInstrumental: true, + assetFolderId: 'project', + assetLabel: '游戏背景音乐 1', }), }), '生成游戏背景音乐失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 77c5eee7c..072b75804 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -393,6 +393,8 @@ export type EditorSoundEffectGenerationInput = { projectId?: string | null; canvasCompletion?: EditorCanvasGenerationCompletionInput | null; generationInputs?: EditorAssetGenerationInputs | null; + assetFolderId?: string | null; + assetLabel?: string | null; }; export type EditorBackgroundMusicGenerationInput = { @@ -401,6 +403,8 @@ export type EditorBackgroundMusicGenerationInput = { projectId?: string | null; canvasCompletion?: EditorCanvasGenerationCompletionInput | null; generationInputs?: EditorAssetGenerationInputs | null; + assetFolderId?: string | null; + assetLabel?: string | null; }; export type EditorAudioGenerationResult = { @@ -418,6 +422,8 @@ export type EditorAudioGenerationResult = { priceMudPoints: number; audioKind: 'sound-effect' | 'background-music'; durationSeconds?: number | null; + resource?: EditorProjectResourceSnapshot | null; + asset?: EditorAssetSnapshot | null; project?: EditorProjectSnapshot | null; queueState?: ExternalGenerationJobStatusRecord | null; }; @@ -1032,6 +1038,8 @@ export async function generateEditorSoundEffect( ...(input.generationInputs ? { generationInputs: input.generationInputs } : {}), + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), }), '生成游戏音效失败', { @@ -1056,6 +1064,8 @@ export async function generateEditorBackgroundMusic( ...(input.generationInputs ? { generationInputs: input.generationInputs } : {}), + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), }), '生成游戏背景音乐失败', { From 7d954e0985d2ffac8f38b810cbd1f6ba4bc73cfb Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 2 Jul 2026 21:14:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=A4=8D=E5=88=B6=E5=88=B0=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=89=AA=E5=88=87=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将图片剪切板写入提前到用户点击调用栈内发起 固定系统剪切板输出为 image/png 补充异步图片读取前已启动系统剪切板写入的回归测试 --- .../useImageCanvasLayerCommands.test.tsx | 45 ++++++++++++++++--- .../useImageCanvasLayerCommands.ts | 31 ++++++++----- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 717210232..8c3ee2d97 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -43,17 +43,18 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { const originalClipboard = navigator.clipboard; const originalClipboardItem = globalThis.ClipboardItem; const originalCreateImageBitmap = globalThis.createImageBitmap; +type ClipboardImageItems = Record>; function installImageClipboardMock() { class ClipboardItemMock { - readonly items: Record; + readonly items: ClipboardImageItems; - constructor(items: Record) { + constructor(items: ClipboardImageItems) { this.items = items; } } const write = vi.fn( - async (_items: Array<{ items: Record }>) => undefined, + async (_items: Array<{ items: ClipboardImageItems }>) => undefined, ); Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, @@ -311,15 +312,41 @@ describe('useImageCanvasLayerCommands', () => { await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); const clipboardItems = write.mock.calls[0]?.[0] as - | Array<{ items: Record }> + | Array<{ items: ClipboardImageItems }> | undefined; - expect(clipboardItems?.[0]?.items['image/png']).toBeInstanceOf(Blob); + await expect( + clipboardItems?.[0]?.items['image/png'], + ).resolves.toBeInstanceOf(Blob); expect(readLayerImageBlob).toHaveBeenCalledWith( expect.objectContaining({ id: 'second' }), ); expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); }); + it('starts system clipboard write before image bytes finish loading', async () => { + const write = installImageClipboardMock(); + let resolveBlob: (blob: Blob) => void = () => undefined; + vi.mocked(readLayerImageBlob).mockReturnValueOnce( + new Promise((resolve) => { + resolveBlob = resolve; + }), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + + await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); + const clipboardItems = write.mock.calls[0]?.[0] as + | Array<{ items: ClipboardImageItems }> + | undefined; + const pngBlobPromise = clipboardItems?.[0]?.items['image/png']; + expect(pngBlobPromise).toBeInstanceOf(Promise); + + resolveBlob(new Blob(['late-png'], { type: 'image/png' })); + + await expect(pngBlobPromise).resolves.toBeInstanceOf(Blob); + }); + it('normalizes copied images to png for the system clipboard', async () => { const write = installImageClipboardMock(); const drawImage = vi.fn(); @@ -351,9 +378,13 @@ describe('useImageCanvasLayerCommands', () => { await waitFor(() => expect(write).toHaveBeenCalledTimes(1)); const clipboardItems = write.mock.calls[0]?.[0] as - | Array<{ items: Record }> + | Array<{ items: ClipboardImageItems }> | undefined; - expect(clipboardItems?.[0]?.items['image/png']?.type).toBe('image/png'); + await expect( + Promise.resolve(clipboardItems?.[0]?.items['image/png']).then( + (blob) => blob?.type, + ), + ).resolves.toBe('image/png'); expect(drawImage).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index bc0b4d414..2550fb774 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -99,18 +99,27 @@ async function normalizeClipboardImageBlob(blob: Blob) { return blob; } +async function readLayerClipboardPngBlob(layer: CanvasLayer) { + const blob = await normalizeClipboardImageBlob( + await readLayerImageBlob(layer), + ); + return blob.type === 'image/png' + ? blob + : blob.slice(0, blob.size, 'image/png'); +} + function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) { - void (async () => { - const layer = layers.slice().reverse().find(isClipboardImageLayer); - const clipboard = globalThis.navigator?.clipboard; - if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') { - return; - } - const blob = await normalizeClipboardImageBlob( - await readLayerImageBlob(layer), - ); - await clipboard.write([new ClipboardItem({ [blob.type]: blob })]); - })().catch(() => { + const layer = layers.slice().reverse().find(isClipboardImageLayer); + const clipboard = globalThis.navigator?.clipboard; + const ClipboardItemCtor = globalThis.ClipboardItem; + if (!layer || !clipboard?.write || typeof ClipboardItemCtor !== 'function') { + return; + } + void clipboard.write([ + new ClipboardItemCtor({ + 'image/png': readLayerClipboardPngBlob(layer), + }), + ]).catch(() => { // 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。 }); } From d707613a8b2b2fee72ee61630cff9a5023b7d21a Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 2 Jul 2026 23:05:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91=E5=9B=9E=E5=86=99=E4=B8=8E?= =?UTF-8?q?=E5=8E=9F=E5=9B=BE=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快速编辑回到原图面板流程,避免新建生成器占位 提交快速编辑时传入目标图层并在后端成功后回写画布 快速编辑生成输入记录原图引用,图片信息可展示原图来源 同步前后端测试与图片画布文档记忆 --- docs/project-memory/shared-memory/pitfalls.md | 30 ++- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 12 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 14 +- .../crates/api-server/src/editor_project.rs | 47 ++++- server-rs/crates/api-server/src/state.rs | 1 - ...CanvasBasicGenerationComposerView.test.tsx | 42 ++++ ...ImageCanvasBasicGenerationComposerView.tsx | 24 ++- ...CanvasEditorGenerationIntegration.test.tsx | 15 +- .../image-editor/ImageCanvasEditorView.tsx | 3 +- ...ImageCanvasGenerationComposerView.test.tsx | 16 +- .../ImageCanvasGenerationComposerView.tsx | 6 +- .../ImageCanvasGenerationDialogModel.test.ts | 8 +- .../ImageCanvasGenerationDialogModel.ts | 8 +- .../ImageCanvasGenerationImageOptionsView.tsx | 18 +- .../ImageCanvasGenerationModel.test.ts | 8 +- .../ImageCanvasGenerationModel.ts | 18 +- ...ageCanvasGenerationSubmissionModel.test.ts | 9 +- .../ImageCanvasGenerationSubmissionModel.ts | 5 +- .../ImageCanvasQuickEditPanelView.test.tsx | 64 +++--- .../ImageCanvasQuickEditPanelView.tsx | 29 ++- ...anvasGenerationSubmissionWorkflow.test.tsx | 192 ++++++++++++++---- ...ImageCanvasGenerationSubmissionWorkflow.ts | 110 ++++------ .../useImageCanvasGenerationWorkflow.test.tsx | 92 ++++++--- .../useImageCanvasGenerationWorkflow.ts | 74 +++---- .../image-editor/editorProjectClient.test.ts | 2 + .../image-editor/editorProjectClient.ts | 2 + 27 files changed, 528 insertions(+), 323 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e403046b1..0cce8ddea 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -66,8 +66,8 @@ - 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。 - 原因:Data URL 参考图可以直接解析,但 objectKey 是服务端私有对象引用;只校验 generated 前缀、mime 和大小不能证明它属于当前账号。 -- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。快速编辑和图标素材额外参考图也必须真实传到 provider,不只写 metadata。 -- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入快速编辑 / 图标生成请求。 +- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。 +- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。 ## 编辑器生成按钮显示泥点后仍要查真实钱包预扣 @@ -311,14 +311,30 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。 - 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 -## 图片画布图片改造也必须创建独立生成器占位 +## 图片画布重绘创建独立占位,快速编辑不要新建生成器 -- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。 -- 原因:图片改造复用了旧 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路。 -- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求。 -- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`。 +- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。 +- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。 +- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`。 - 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。 +## 图片画布快速编辑完成必须按目标图层回写 + +- 现象:图片快速编辑任务成功后,刷新页面素材库能看到新图,但画布上的源图没有替换。 +- 原因:`/api/editor/images/edits` 只保存生成图、项目资源和素材;没有 `canvasCompletion` 时不会写 `editor_canvas.layers_json`。`sourceResourceId` 只能表示溯源,同一资源可出现在多个图层,不能用它来决定替换哪一层。 +- 处理:图片快速编辑请求必须传 `targetLayerId`;后端在没有 `canvasCompletion` 的快速编辑完成分支里,用目标 layer id 和生成资源写回项目 layout。 +- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points` 和 `editor_image_edit_can_complete_by_replacing_target_layer`。 +- 关联:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/editor_project.rs`。 + +## 图片画布快速编辑元数据必须记录原图引用 + +- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。 +- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然把原图作为 `sourceImageSrc` 传给 provider,但如果 `buildQuickEditGenerationInputs(...)` 不把源图写成引用,后端资源和画布层都没有可展示的原图引用。 +- 处理:快速编辑的 `generationInputs.references` 必须始终包含 `原图`,再追加用户额外参考图;关闭额外参考图入口时也不能删除这条源图引用。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx -- --runInBand`。 +- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/components/image-editor/ImageCanvasMetadataModalView.tsx`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。 + ## 图片画布生成完成应用项目快照后也要刷新素材库 - 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 83a8e5e54..fc8fffb8f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,12 +20,12 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准绿幕资产统一走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的绿幕提示词契约和本地确定性绿幕透明化,字节级解码、透明化和 PNG 编码下沉复用 `platform-image::generated_asset_sheets`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作抽帧后的序列帧都属于标准绿幕资产,不再依赖 BiRefNet;后端在执行绿幕透明化前必须先把带绿幕源图写入 OSS。BiRefNet 服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;可选访问令牌只来自服务端环境变量 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 -- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs` 的最后一张隐式参考,也不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交时保留用户提示词里对 `原图`、`当前图片`、`当前图` 或 `图1` 的原始表述,不再改写为 `图N`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;生成成功后直接用结果覆盖原图图层,该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 +- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 -- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图`、`生成角色动作` 和快速编辑提交后创建的 `Quick Edit Generator`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;快速编辑提交时同样必须把避让后的 placeholder 写入独立生成占位,生成结果落在该占位位置。 +- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 ## 交互规则 @@ -78,7 +78,7 @@ - `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并由后端切分为独立透明图标。请求支持 `model`、`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 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和 `editor_green_screen` 组装的标准绿幕素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `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/images/edits`:按提示词、当前图片 Data URL 和最多 8 张额外参考图调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 +- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;接口能力仍可接收明确参考图,但图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。请求携带 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`。 @@ -106,13 +106,13 @@ - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 -- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户额外选择的参考图。 +- 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 -- 快速编辑的额外参考图既进入输入快照,也作为 `/api/editor/images/edits` 的 `referenceImageSrcs` 提交给后端;原图仍只作为 `sourceImageSrc`,不占用额外参考图额度。 +- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。 - 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `