From 5fd26a11eea023be07d6118c16c87d7420eb1863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 3 Aug 2026 20:07:06 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=BA=A7=E7=89=A9=E6=94=B9=E9=80=A0=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 generationInputs V2 稳定 action、field id 与 reference id 契约 恢复图片、视频、音频、角色动作等生成面板并保留 legacy 回退 非生成裁扩、去背景和图集拆分不再继承生成配方 匿名精选响应不再返回 owner generationInputs 同步前后端测试与架构文档 --- .../shared-memory/decision-log.md | 15 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...】生成类面板Lovart统一改造方案-2026-06-17.md | 4 +- ...编辑器】画板音乐生成入口设计-2026-06-18.md | 4 +- .../crates/api-server/src/editor_project.rs | 40 +- .../creation-home/creationShowcaseModel.ts | 8 +- ...CanvasEditorGenerationIntegration.test.tsx | 24 +- .../image-editor/ImageCanvasEditorTypes.ts | 25 +- .../ImageCanvasEditorView.test.tsx | 8 +- .../image-editor/ImageCanvasEditorView.tsx | 1 + .../image-editor/ImageCanvasExportModel.ts | 7 +- .../ImageCanvasGenerationDialogModel.test.ts | 185 ++++++++ .../ImageCanvasGenerationDialogModel.ts | 432 +++++++++++++++++- .../ImageCanvasGenerationModel.test.ts | 172 +++++-- .../ImageCanvasGenerationModel.ts | 367 +++++++++++++-- ...ageCanvasGenerationSubmissionModel.test.ts | 138 ++++-- .../ImageCanvasGenerationSubmissionModel.ts | 43 +- .../ImageCanvasMetadataModalView.tsx | 6 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 56 ++- .../ImageCanvasSelectedLayerToolbarView.tsx | 56 ++- ...anvasGenerationSubmissionWorkflow.test.tsx | 3 + ...ImageCanvasGenerationSubmissionWorkflow.ts | 74 ++- .../useImageCanvasGenerationWorkflow.test.tsx | 48 +- .../useImageCanvasGenerationWorkflow.ts | 114 ++++- .../image-editor/editorProjectClient.ts | 20 +- 26 files changed, 1606 insertions(+), 253 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0975b8153..14a50e7fe 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -29,10 +29,10 @@ ## 2026-07-31 修正抠图内部元数据的普通用户读取边界 - 背景:2026-07-30 的记录误把素材 owner 与后台审计并列为原始抠图执行信息的读取方。owner 是普通用户,前端不展示字段不能阻止其从项目资源、素材库、精选提交 / 点赞回包、画布布局或任务完成响应的网络 payload 读取 BgFilter、阿里云、本地键色、具体分割模型或背景色。 -- 决策:本条取代 2026-07-30 决策中“素材 owner、精选提交响应仍可读取原始值”的表述。普通用户(包括素材 owner)和匿名公开读取必须共同过滤素材顶层 `provider`、内部处理 `model`,以及 `generationInputs` 顶层 `screenColorHex`、`mattingProvider`、`mattingModel`;正常用户可见生成 `model` 和其他合法功能性顶层字段(例如 `characterAnimation`)保持不变。User/Owner mapper 先完成该清理,public mapper 在其基础上叠加公开字段规则;后台管理与服务端审计继续使用 raw mapper 和持久化原值。历史数据不迁移,统一在读取边界清理。 +- 决策:本条取代 2026-07-30 决策中“素材 owner、精选提交响应仍可读取原始值”的表述。普通用户(包括素材 owner)读取时必须过滤素材顶层 `provider`、内部处理 `model`,以及 `generationInputs` 顶层 `screenColorHex`、`mattingProvider`、`mattingModel`;正常用户可见生成 `model` 和其他合法功能性顶层字段(例如 `characterAnimation`)保持不变。匿名公开素材 payload 不包含整个 `generationInputs`。User/Owner mapper 完成普通用户清理,public mapper 在其基础上移除该 owner-only 配方字段;后台管理与服务端审计继续使用 raw mapper 和持久化原值。历史数据不迁移,统一在读取边界清理。 - 入站与持久化:客户端提交的 `generationInputs` 不得伪造上述内部键,服务端在实际处理完成后才写入可信值。手动去背景的正式素材 `model` 必须继承经服务端验证的正常源生图模型;若来源或祖先链不存在正常模型则为 `null`,不得写入 `BgFilter complex` 等内部处理模型。内部抠图 provider / model 可继续持久化供后台审计,普通用户完成响应和用户可见错误文本均不得暴露它们;手动去背景与角色动作透明化失败在 Owner HTTP / 任务状态边界统一替换为稳定业务文案,原始错误只留在任务记录、tracing 和后台审计。 - 影响范围:项目资源、素材库、精选提交 / 点赞、图片 / 图标 / 视频 / 音频 / 角色动画生成完成、Agent 紧凑结果和识别出的画布资源 / 图层快照的 User/Public mapper;手动去背景持久化和完成响应;External Editor API 创建素材 / 资源时的保留键入站清理;相应响应 / OpenAPI 契约、前端搜索 / 详情 / ZIP 过滤测试与后台 raw 审计测试。同源画布 BFF 的角色、图标和 UI 请求继续由前端自动提交默认 `segModel=birefnet`,后端继续校验并在缺失时回落默认值;该请求控制字段不进入 `generationInputs`、普通用户响应、搜索、详情、导出或错误详情。角色动作的 `seg_model` 继续由后端固定。不修改 SpacetimeDB schema、迁移或 bindings。 -- 验证方式:Owner 和匿名响应覆盖无 `provider`、无内部 `model`、无三个内部 `generationInputs` 键,且正常 `model` 与 `characterAnimation` 仍保留;Admin raw payload 保持完整。覆盖历史 `BgFilter complex`、三类入站伪造键、手动去背景源模型回溯和用户错误文本过滤;运行 api-server 定向测试、前端定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding` 与 `git diff --check`。 +- 验证方式:Owner 响应覆盖无 `provider`、无内部 `model`、无三个内部 `generationInputs` 键,同时保留正常 `model` 与 `characterAnimation`;匿名公开素材响应完全不含 `generationInputs`;Admin raw payload 保持完整。覆盖历史 `BgFilter complex`、三类入站伪造键、手动去背景源模型回溯和用户错误文本过滤;运行 api-server 定向测试、前端定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding` 与 `git diff --check`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 --- @@ -1024,7 +1024,7 @@ ## 2026-06-21 图片画布参考图元数据只保存项目内行引用 - 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。 -- 决策:`generationInputs.references` 只保存 `{ title, label, refType, refId }`,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。 +- 决策:`generationInputs.references` 只保存稳定行引用,不保存媒体本身。2026-08-03 起 V2 新写入结构为 `{ id, title, label?, refType, refId }`;其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`,媒体类型从对应行的查询结果获得。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。 - 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。 - 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 @@ -5908,3 +5908,12 @@ - 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。 - 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 图片画布生成产物统一“改造”契约 + +- 背景:画布生成结果统一显示“改造”,但部分动画 / 音频结果无法恢复面板;另一些非生成型派生结果继承最近生成输入,产生错误可执行动作。中文标题和素材类别被同时当成显示文案、参数键和路由键,改名后容易漂移。 +- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用的媒体类型从其资源行查回,不重复写入快照。提交前参数只归一一次,请求与持久快照共用同一归一值。 +- 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 +- 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 +- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ef0e0f8ff..65799de4c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 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、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用媒体类型通过 `refType/refId` 查回已 hydrate 的资源 / 图层获得,不重复写入 `generationInputs.references`。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -36,7 +36,7 @@ ### 静态图片风格与像素规整边界 -- 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 +- 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 归一后同时写入请求 / 队列 payload 和 V2 `generationInputs.fields[id="style"]`,确保后续改造恢复与当次实际提交一致;不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。 - `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 - `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 @@ -77,7 +77,7 @@ - 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 指向工程资源,`refType="asset"` 指向账号素材,媒体类型从对应行查询结果获得。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 @@ -139,7 +139,7 @@ - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 -- 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图再次改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 +- 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图进入改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 普通图片、角色和图标面板显示 `像素艺术` 勾选项并正确提交 / 恢复 `style: "none" | "pixelArt"`;其它生成或编辑面板不显示该选项。旧 payload、未知字符串、不支持 `kind` 和非字符串输入分别按本方案约定的兼容或错误语义处理。 - `pixelArt` 输出 Alpha 只包含 `0 / 255`;普通图片和角色先完成 Lanczos 交付尺寸归一,再由 snapper 使用 nearest 把逻辑网格恢复到同一输入尺寸,规整后不得再次执行尺寸插值。成功和后处理失败两条路径都不得比 `none` 增加 OSS PUT、项目资源、账号素材或画布 item,逻辑低分辨率图不得出现在 OSS 或响应资源快照中。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 61ed7e2aa..cd32e9bfe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -620,6 +620,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 +- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与资源;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。引用媒体类型从 `refType/refId` 对应的工程资源或账号素材查询结果获得,不在快照内重复保存。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 7daef9a08..fc1b766cb 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -112,8 +112,8 @@ - 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。 - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 -- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效和背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器。 +- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复;历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 ## 画布保存 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index 0572907cc..7108785f2 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -52,7 +52,7 @@ - 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。 - 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。 - 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。 -- 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。 +- 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。只有当前 owner 的可执行生成输入存在时才显示 `改造`;点击后按 `action` 恢复对应的音效或背景音乐生成面板,音效再按 `fields[id="model"]` 和 `fields[id="durationSeconds"]` 恢复模型与时长;面板不展示参考图组件,允许修改后生成新音频,新结果落在原音频旁边。 ## 前端提交契约 @@ -131,6 +131,6 @@ POST /api/editor/audios/background-music/generations - 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。 - 鼠标未悬停音频卡时底部播放器辅助控件不可见,悬停时从底部滑动显现;提示词位于卡片左上角。 - 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。 -- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。 +- 音频素材浮动工具栏在存在可执行生成输入时显示 `改造` 与 `下载按钮`,否则只显示下载;`改造` 恢复对应生成面板且没有参考图组件。 - 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。 - 刷新后 layout 能恢复音频生成器和音频图层。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9457a2004..efff1c7cb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -919,6 +919,8 @@ pub struct EditorProjectResourcePayload { asset_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] showcase_category: Option, + // TODO: generation_inputs V2 的 action 暂以字符串保存在 JSON 中;待 shared-contracts 提供 Rust DTO 后收口为共享 enum。 + #[serde(skip_serializing_if = "Option::is_none")] generation_inputs: Option, public_showcase_enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -5502,7 +5504,8 @@ pub async fn split_editor_icon_spritesheet( .provider .clone() .unwrap_or_else(|| "Genarrative".to_string()); - let generation_inputs = source_resource.generation_inputs.clone(); + // 手动拆图是确定性派生操作,切片不得继承源图集的生成输入,避免错误展示“改造”。 + let generation_inputs = None; let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, @@ -6764,6 +6767,7 @@ fn editor_project_resource_public_payload_from_record( resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str()); payload.author_display_name = display_name; payload.author_public_user_code = public_user_code; + payload.generation_inputs = None; payload } @@ -9952,14 +9956,16 @@ mod tests { } #[test] - fn editor_project_resource_public_payload_removes_internal_generation_metadata() { + fn editor_project_resource_public_payload_omits_generation_inputs() { let state = AppState::new(AppConfig::default()).expect("state should build"); let record = EditorShowcaseAssetRecord { model: Some("BgFilter complex".to_string()), provider: Some("BgFilter".to_string()), generation_inputs: Some(json!({ - "fields": [{"title": "处理阶段", "value": "角色抠图"}], - "references": [{"refType": "asset", "refId": "asset-source"}], + "version": 2, + "action": "image.generate", + "fields": [{"id": "prompt", "title": "生成提示词", "value": "角色立绘"}], + "references": [{"id": "reference", "label": "角色参考", "refType": "asset", "refId": "asset-source"}], "characterAnimation": {"action": "idle"}, "screenColorHex": "#CFEFFF", "mattingProvider": "BgFilter", @@ -9980,6 +9986,13 @@ mod tests { assert!(!owner_generation_inputs.contains_key("screenColorHex")); assert!(!owner_generation_inputs.contains_key("mattingProvider")); assert!(!owner_generation_inputs.contains_key("mattingModel")); + assert_eq!(owner_generation_inputs["version"], json!(2)); + assert_eq!(owner_generation_inputs["action"], json!("image.generate")); + assert_eq!(owner_generation_inputs["fields"][0]["id"], json!("prompt")); + assert_eq!( + owner_generation_inputs["references"][0]["id"], + json!("reference") + ); assert_eq!( owner_generation_inputs["characterAnimation"], json!({"action": "idle"}) @@ -9991,24 +10004,7 @@ mod tests { .expect("public payload should serialize"); assert!(public_payload.get("provider").is_none()); assert!(public_payload.get("model").is_none()); - let generation_inputs = public_payload["generationInputs"] - .as_object() - .expect("generation inputs should stay object"); - assert!(!generation_inputs.contains_key("screenColorHex")); - assert!(!generation_inputs.contains_key("mattingProvider")); - assert!(!generation_inputs.contains_key("mattingModel")); - assert_eq!( - generation_inputs["fields"], - json!([{"title": "处理阶段", "value": "角色抠图"}]) - ); - assert_eq!( - generation_inputs["references"], - json!([{"refType": "asset", "refId": "asset-source"}]) - ); - assert_eq!( - generation_inputs["characterAnimation"], - json!({"action": "idle"}) - ); + assert!(public_payload.get("generationInputs").is_none()); } #[test] diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 259350faf..1573caaff 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -646,7 +646,7 @@ function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) { function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) { const fields = asset.generationInputs?.fields ?? []; const promptField = fields.find((field) => { - const value = field.value.trim(); + const value = String(field.value).trim(); return ( value && USER_PROMPT_INPUT_TITLES.has( @@ -655,7 +655,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) { !isBuiltInPromptText(value) ); }); - const promptValue = promptField?.value.trim(); + const promptValue = promptField ? String(promptField.value).trim() : ''; if (promptValue) { return promptValue; } @@ -668,7 +668,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) { ) { return []; } - const value = field.value.trim(); + const value = String(field.value).trim(); return value && !isBuiltInPromptText(value) ? [`${field.title.trim()}:${value}`] : []; @@ -824,7 +824,7 @@ function inferVideoResolution( const fields = asset.generationInputs?.fields ?? []; const fieldValue = fields .flatMap((field) => [field.title, field.value]) - .map((value) => value.trim()) + .map((value) => String(value).trim()) .find((value) => /\b(?:480p|720p|1080p)\b/iu.test(value)); const matchedValue = fieldValue?.match(/\b(480p|720p|1080p)\b/iu)?.[1]; if (matchedValue && allowedValues.includes(matchedValue)) { diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index c307a36ee..9cc473c9a 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -874,8 +874,14 @@ describe('ImageCanvasEditorView generation integration', () => { }); const submittedGenerationInputs = generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs; - expect(JSON.stringify(submittedGenerationInputs)).not.toContain( - 'pixelArt', + expect(submittedGenerationInputs).toEqual( + expect.objectContaining({ + version: 2, + action: 'image.generate', + fields: expect.arrayContaining([ + expect.objectContaining({ id: 'style', value: 'pixelArt' }), + ]), + }), ); await waitFor(() => { @@ -2485,18 +2491,23 @@ describe('ImageCanvasEditorView generation integration', () => { const characterGenerationInput = generateEditorImageMock.mock.calls[0]?.[0]; expect(characterGenerationInput?.generationInputs).toEqual( expect.objectContaining({ + version: 2, + action: 'character.generate', fields: expect.arrayContaining([ expect.objectContaining({ + id: 'prompt', title: '角色设定', value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', }), ]), references: expect.arrayContaining([ expect.objectContaining({ + id: 'specReference', title: '角色规范', label: '角色规范', }), expect.objectContaining({ + id: 'reference', title: '常规参考图 1', label: '大鱼素材', }), @@ -2664,8 +2675,11 @@ describe('ImageCanvasEditorView generation integration', () => { projectId: 'editor-project-icons', assetFolderId: 'project', generationInputs: expect.objectContaining({ + version: 2, + action: 'icon.generate', fields: expect.arrayContaining([ expect.objectContaining({ + id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮', }), @@ -3255,7 +3269,6 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByText('角色')).toBeTruthy(); expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); - expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); expect( screen.queryByRole('dialog', { name: '角色动画生成面板' }), ).toBeNull(); @@ -3266,7 +3279,6 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); - expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '生成动画' })); expect( @@ -3536,8 +3548,11 @@ describe('ImageCanvasEditorView generation integration', () => { assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', generationInputs: expect.objectContaining({ + version: 2, + action: 'ui-design.extract-assets', fields: expect.arrayContaining([ expect.objectContaining({ + id: 'prompt', title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', @@ -3545,6 +3560,7 @@ describe('ImageCanvasEditorView generation integration', () => { ]), references: expect.arrayContaining([ expect.objectContaining({ + id: 'source', title: 'UI设计图', label: '战斗UI设计图', }), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 64b479ef9..dad90a50a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -64,19 +64,39 @@ export type EditorAsset = { uploadMessage?: string; }; +export type CanvasGenerationAction = + | 'image.generate' + | 'spec.generate' + | 'character.generate' + | 'icon.generate' + | 'ui-design.generate' + | 'publication.generate' + | 'video.generate' + | 'audio.sound-effect.generate' + | 'audio.background-music.generate' + | 'character-animation.generate' + | 'image.edit' + | 'ui-design.extract-assets'; + +export type CanvasGenerationInputValue = string | number | boolean; + export type CanvasGenerationInputField = { + id?: string; title: string; - value: string; + value: CanvasGenerationInputValue; }; export type CanvasGenerationInputReference = { + id?: string; title: string; - label: string; + label?: string; refType: 'project-resource' | 'asset'; refId: string; }; export type CanvasGenerationInputs = { + version?: 2; + action?: CanvasGenerationAction; fields: CanvasGenerationInputField[]; references: CanvasGenerationInputReference[]; }; @@ -201,6 +221,7 @@ export type GenerateDialogState = { assetLabel?: string; status: 'idle' | 'generating' | 'failed'; composerOpen?: boolean; + isRedrawAsNewArtifact?: boolean; sourceLayerId?: string; generatedLayerId?: string; specType?: SpecGenerationType; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 0182d2329..ec363fb02 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1278,7 +1278,9 @@ describe('ImageCanvasEditorView', () => { expect( within(infoPanel).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); - expect(screen.getByRole('button', { name: '改造' })).toBeTruthy(); + expect( + screen.queryByRole('button', { name: '改造' }), + ).toBeNull(); }); it('hydrates canvas images from Resolution instead of saved Size', async () => { @@ -1794,7 +1796,9 @@ describe('ImageCanvasEditorView', () => { expect( screen.getByRole('button', { name: '查看大鱼素材图片信息' }), ).toBeTruthy(); - expect(screen.getByRole('button', { name: '改造' })).toBeTruthy(); + expect( + screen.queryByRole('button', { name: '改造' }), + ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e6f8a7236..1ddb6355e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1737,6 +1737,7 @@ export function ImageCanvasEditorView({ layersRef.current, layer, ), + availableLayers: layersRef.current, }); if (!restoredDraft) { return false; diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 521ca5467..db5eb0cd4 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -8,6 +8,7 @@ import type { import { DEFAULT_ICON_DESCRIPTIONS, formatLayerImageType, + formatGenerationInputValue, getEditorLayerModelDisplayName, isEditorUserVisibleGenerationInputField, UI_DESIGN_ASSET_EXTRACTION_PROMPT, @@ -540,7 +541,7 @@ const BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE = new Map( function isBuiltInGenerationInputField(field: CanvasGenerationInputField) { const normalizedTitle = field.title.trim(); - const normalizedValue = field.value.trim(); + const normalizedValue = formatGenerationInputValue(field.value).trim(); const builtInValues = BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle); return ( @@ -557,12 +558,12 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) { .filter((field) => !isBuiltInGenerationInputField(field)) .map((field) => ({ title: field.title, - value: field.value, + value: formatGenerationInputValue(field.value), })) ?? []; const references = layer.generationInputs?.references.map((reference) => ({ title: reference.title, - label: reference.label, + label: reference.label ?? '参考素材', refType: reference.refType, refId: reference.refId, })) ?? []; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c568371b7..5523813fd 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -825,6 +825,191 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('restores V2 video and sound parameters by stable ids instead of display labels', () => { + const imageReference = createLayer({ + id: 'layer-image-reference', + resourceId: 'resource-image-reference', + title: '视觉参考', + }); + const audioReference = createLayer({ + id: 'layer-audio-reference', + resourceId: 'resource-audio-reference', + title: '音轨参考', + mediaType: 'audio', + }); + const context = { + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + availableLayers: [imageReference, audioReference], + }; + + expect( + createLayerGenerationDialogDraft({ + ...context, + sourceLayer: createLayer({ + sourceType: 'generated', + mediaType: 'video', + generationInputs: { + version: 2, + action: 'video.generate', + fields: [ + { id: 'prompt', title: '可随时改名', value: '角色追逐镜头' }, + { id: 'model', title: '不用作键', value: 'seedance2.0-fast' }, + { id: 'aspectRatio', title: '显示快照', value: '9:16' }, + { id: 'resolution', title: '显示快照', value: '1080p' }, + { id: 'durationSeconds', title: '显示快照', value: 8 }, + { id: 'sound', title: '显示快照', value: 'off' }, + ], + references: [ + { + id: 'imageReference', + title: '任意标题', + label: '旧显示名', + refType: 'project-resource', + refId: 'resource-image-reference', + }, + { + id: 'audioReference', + title: '任意标题', + label: '旧显示名', + refType: 'project-resource', + refId: 'resource-audio-reference', + }, + ], + }, + }), + }), + ).toMatchObject({ + mode: 'video', + prompt: '角色追逐镜头', + videoModel: 'seedance2.0-fast', + videoAspectRatio: '9:16', + videoResolution: '1080p', + videoDurationSeconds: 8, + videoSound: 'off', + generationReferences: [ + expect.objectContaining({ + id: 'canvas-layer-image-reference', + label: '视觉参考', + }), + expect.objectContaining({ + id: 'canvas-layer-audio-reference', + label: '音轨参考', + mediaType: 'audio', + }), + ], + }); + + expect( + createLayerGenerationDialogDraft({ + ...context, + sourceLayer: createLayer({ + sourceType: 'generated', + mediaType: 'audio', + generationInputs: { + version: 2, + action: 'audio.sound-effect.generate', + fields: [ + { id: 'prompt', title: '非 prompt 标题', value: '短促点击音' }, + { id: 'model', title: '非 model 标题', value: 'audio1.0' }, + { id: 'durationSeconds', title: '非 duration 标题', value: 7 }, + ], + references: [], + }, + }), + }), + ).toMatchObject({ + mode: 'audio-sound-effect', + prompt: '短促点击音', + soundModel: 'audio1.0', + soundDurationSeconds: 7, + }); + }); + + it('keeps valid V2 fields ahead of conflicting legacy dialog and resource fallbacks', () => { + expect( + createLayerGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + prompt: '资源旧提示词', + generationInputs: { + version: 2, + action: 'image.generate', + fields: [ + { id: 'model', title: '任意标题', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { id: 'aspectRatio', title: '任意标题', value: '2:3' }, + { id: 'imageSize', title: '任意标题', value: '2K' }, + ], + references: [], + }, + }), + sourceDialog: { + id: 'legacy-dialog', + mode: 'generate', + prompt: '历史对话框提示词', + status: 'idle', + imageModel: IMAGE_MODEL_NANOBANANA2, + aspectRatio: '1:1', + imageSize: '1K', + }, + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }), + ).toMatchObject({ + mode: 'generate', + prompt: '', + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '2:3', + imageSize: '2K', + }); + }); + + it('uses the V2 source reference ahead of a conflicting legacy animation source', () => { + const v2Source = createLayer({ + id: 'layer-v2-character', + resourceId: 'resource-v2-character', + assetKind: 'character', + }); + const legacySource = createLayer({ + id: 'layer-legacy-character', + resourceId: 'resource-legacy-character', + assetKind: 'character', + }); + + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + id: 'layer-animation-result', + sourceType: 'generated', + assetKind: 'character-animation', + sourceResourceId: 'resource-legacy-character', + generationInputs: { + version: 2, + action: 'character-animation.generate', + fields: [{ id: 'prompt', title: '任意标题', value: '循环奔跑' }], + references: [ + { + id: 'source', + title: '任意标题', + refType: 'project-resource', + refId: 'resource-v2-character', + }, + ], + }, + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + sourceAnimationLayer: legacySource, + availableLayers: [legacySource, v2Source], + }), + ).toMatchObject({ + mode: 'character-animation', + prompt: '循环奔跑', + sourceLayerId: 'layer-v2-character', + }); + }); + it('inherits and normalizes quick edit model parameters from the source', () => { expect( createQuickEditPanelDraft( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c8019b3b..983932734 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -30,12 +30,15 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, + formatGenerationInputValue, IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, + isNormalizedCanvasGenerationInputs, normalizeEditorImageModel, PUBLICATION_FRAME_ORIGINAL_SIZE, resizeGenerationPlaceholderToImageSelection, + resizeGenerationPlaceholderToVideoSelection, resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, @@ -50,6 +53,7 @@ type SourceGenerationDialogDraftContext = { sourceDialog?: CanvasGenerationDialogState | null; mode: 'quick-edit' | 'redraw'; sourceAnimationLayer?: CanvasLayer | null; + availableLayers?: CanvasLayer[]; }; const VIDEO_REFERENCE_LIMITS = { image: 9, @@ -112,7 +116,8 @@ export function isIconSpecReferenceLayer(layer: CanvasLayer) { layer.title.includes('UI素材规范') || layer.generationInputs?.fields.some( (field) => - field.value.includes('游戏UI规范') || field.value.includes('图标规范'), + formatGenerationInputValue(field.value).includes('游戏UI规范') || + formatGenerationInputValue(field.value).includes('图标规范'), ) === true ); } @@ -156,7 +161,7 @@ function getGenerationInputFieldValues(layer: CanvasLayer) { const values = new Map(); for (const field of layer.generationInputs?.fields ?? []) { const title = field.title.trim().toLowerCase(); - const value = field.value.trim(); + const value = formatGenerationInputValue(field.value).trim(); if (title && value && !values.has(title)) { values.set(title, value); } @@ -440,11 +445,10 @@ function findGenerationInputFieldValue( titles: readonly string[], ) { const normalizedTitles = new Set(titles.map((title) => title.toLowerCase())); - return ( - layer.generationInputs?.fields.find((field) => - normalizedTitles.has(field.title.trim().toLowerCase()), - )?.value ?? null + const field = layer.generationInputs?.fields.find((candidate) => + normalizedTitles.has(candidate.title.trim().toLowerCase()), ); + return field ? formatGenerationInputValue(field.value) : null; } const USER_PROMPT_INPUT_TITLES = new Set( @@ -484,7 +488,9 @@ function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) { const promptField = fields.find((field) => USER_PROMPT_INPUT_TITLES.has(field.title.trim().toLowerCase()), ); - const promptValue = promptField?.value.trim(); + const promptValue = promptField + ? formatGenerationInputValue(promptField.value).trim() + : undefined; if (promptValue) { return promptValue; } @@ -493,7 +499,7 @@ function resolveUserGenerationPromptSnapshot(sourceLayer: CanvasLayer) { if (!STRUCTURED_USER_INPUT_TITLES.has(field.title.trim().toLowerCase())) { return []; } - const value = field.value.trim(); + const value = formatGenerationInputValue(field.value).trim(); return value ? [`${field.title.trim()}:${value}`] : []; }); return structuredLines.join('\n'); @@ -682,6 +688,7 @@ export function createLayerGenerationDialogDraft({ viewport, sourceDialog, sourceAnimationLayer, + availableLayers, }: Omit): Omit< CanvasGenerationDialogState, 'id' @@ -692,15 +699,23 @@ export function createLayerGenerationDialogDraft({ viewport, sourceDialog, sourceAnimationLayer, + availableLayers, mode: 'redraw', }); if (!draft) { return null; } + const usesNormalizedInputs = isNormalizedCanvasGenerationInputs( + sourceLayer.generationInputs, + ); return { ...draft, - prompt: draft.prompt || sourceLayer.prompt?.trim() || '', - imageModel: sourceDialog?.imageModel ?? draft.imageModel, + prompt: usesNormalizedInputs + ? draft.prompt + : draft.prompt || sourceLayer.prompt?.trim() || '', + imageModel: usesNormalizedInputs + ? draft.imageModel + : sourceDialog?.imageModel ?? draft.imageModel, status: 'idle', composerOpen: true, generatedLayerId: sourceLayer.id, @@ -743,6 +758,390 @@ function placeDraftBesideSourceLayer< }; } +function getNormalizedFieldValue(sourceLayer: CanvasLayer, id: string) { + return sourceLayer.generationInputs?.fields.find((field) => field.id === id) + ?.value; +} + +function getNormalizedString( + sourceLayer: CanvasLayer, + id: string, + fallback = '', +) { + const value = getNormalizedFieldValue(sourceLayer, id); + return typeof value === 'string' ? value : fallback; +} + +function getNormalizedNumber( + sourceLayer: CanvasLayer, + id: string, + fallback: number, +) { + const value = getNormalizedFieldValue(sourceLayer, id); + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function resolveNormalizedReferences( + sourceLayer: CanvasLayer, + id: string, + availableLayers: CanvasLayer[] | undefined, +) { + const layers = availableLayers ?? []; + return (sourceLayer.generationInputs?.references ?? []) + .filter((reference) => reference.id === id) + .flatMap((reference) => { + const layer = layers.find((candidate) => + reference.refType === 'project-resource' + ? candidate.resourceId === reference.refId + : candidate.sourceAssetId === reference.refId, + ); + return layer ? [createCanvasLayerReference(layer)] : []; + }); +} + +function createNormalizedGenerationDialogDraft({ + sourceLayer, + canvasSize, + viewport, + availableLayers, +}: SourceGenerationDialogDraftContext): Omit< + CanvasGenerationDialogState, + 'id' +> | null { + const inputs = sourceLayer.generationInputs; + if (!isNormalizedCanvasGenerationInputs(inputs)) { + return null; + } + const prompt = getNormalizedString(sourceLayer, 'prompt'); + const references = resolveNormalizedReferences( + sourceLayer, + 'reference', + availableLayers, + ); + + if (inputs.action === 'image.generate') { + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createGenerateDialogDraft({ canvasSize, viewport }), + prompt, + sourceLayerId: sourceLayer.id, + imageModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_IMAGE_MODEL, + ), + style: + getNormalizedString(sourceLayer, 'style') === 'pixelArt' + ? 'pixelArt' + : 'none', + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + generationReferences: references, + }), + sourceLayer, + ); + } + + if (inputs.action === 'spec.generate') { + const specTypeValue = getNormalizedString(sourceLayer, 'specType', 'custom'); + const specType: SpecGenerationType = + specTypeValue === 'character' || + specTypeValue === 'ui' || + specTypeValue === 'icon' + ? specTypeValue + : 'custom'; + return placeDraftBesideSourceLayer( + { + ...createSpecDialogDraft({ canvasSize, viewport, specType }), + sourceLayerId: sourceLayer.id, + specValues: { + playSetting: getNormalizedString(sourceLayer, 'playSetting'), + artStyle: getNormalizedString(sourceLayer, 'artStyle'), + bodyRatio: getNormalizedString(sourceLayer, 'bodyRatio'), + characterView: getNormalizedString(sourceLayer, 'characterView'), + customPrompt: getNormalizedString(sourceLayer, 'customPrompt'), + }, + specReference: + resolveNormalizedReferences(sourceLayer, 'reference', availableLayers)[0] ?? + null, + }, + sourceLayer, + ); + } + + if (inputs.action === 'character.generate') { + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + }), + prompt, + sourceLayerId: sourceLayer.id, + style: + getNormalizedString(sourceLayer, 'style') === 'pixelArt' + ? 'pixelArt' + : 'none', + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + characterSpecReference: + resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? + null, + characterReferences: references, + }), + sourceLayer, + ); + } + + if (inputs.action === 'icon.generate') { + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + }), + prompt, + sourceLayerId: sourceLayer.id, + style: + getNormalizedString(sourceLayer, 'style') === 'pixelArt' + ? 'pixelArt' + : 'none', + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + iconDescriptions: prompt ? [prompt] : [], + iconSpecReference: + resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? + null, + generationReferences: references, + }), + sourceLayer, + ); + } + + if (inputs.action === 'ui-design.generate') { + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createUiDesignGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: getNormalizedString( + sourceLayer, + 'model', + IMAGE_MODEL_GPT_IMAGE_2, + ), + }), + prompt, + sourceLayerId: sourceLayer.id, + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + uiDesignSpecReference: + resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? + null, + generationReferences: references, + }), + sourceLayer, + ); + } + + if (inputs.action === 'publication.generate') { + const workflowValue = getNormalizedString( + sourceLayer, + 'workflowId', + 'publication-cover-image', + ); + const workflowId: PublicationMaterialsWorkflowId = + workflowValue === 'publication-detail-gallery' || + workflowValue === 'publication-promo-poster' + ? workflowValue + : 'publication-cover-image'; + return placeDraftBesideSourceLayer( + { + ...createPublicationGenerationDialogDraft({ + canvasSize, + viewport, + workflowId, + }), + sourceLayerId: sourceLayer.id, + publicationGameInfo: { + gameName: getNormalizedString(sourceLayer, 'gameName'), + gameCategories: getNormalizedString(sourceLayer, 'gameCategories'), + gameDescription: getNormalizedString(sourceLayer, 'gameDescription'), + }, + publicationReferences: references, + }, + sourceLayer, + ); + } + + if (inputs.action === 'video.generate') { + const videoDraft = resizeGenerationPlaceholderToVideoSelection({ + ...createVideoGenerationDialogDraft({ canvasSize, viewport }), + prompt, + sourceLayerId: sourceLayer.id, + videoModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_VIDEO_MODEL, + ) as GenerateDialogState['videoModel'], + videoAspectRatio: getNormalizedString( + sourceLayer, + 'aspectRatio', + DEFAULT_VIDEO_ASPECT_RATIO, + ) as GenerateDialogState['videoAspectRatio'], + videoResolution: getNormalizedString( + sourceLayer, + 'resolution', + '480p', + ) as GenerateDialogState['videoResolution'], + videoDurationSeconds: getNormalizedNumber( + sourceLayer, + 'durationSeconds', + DEFAULT_VIDEO_DURATION_SECONDS, + ), + videoSound: getNormalizedString( + sourceLayer, + 'sound', + DEFAULT_VIDEO_SOUND, + ) as GenerateDialogState['videoSound'], + generationReferences: [ + ...resolveNormalizedReferences( + sourceLayer, + 'imageReference', + availableLayers, + ), + ...resolveNormalizedReferences( + sourceLayer, + 'videoReference', + availableLayers, + ), + ...resolveNormalizedReferences( + sourceLayer, + 'audioReference', + availableLayers, + ), + ], + }) as Omit; + return placeDraftBesideSourceLayer( + videoDraft, + sourceLayer, + ); + } + + if ( + inputs.action === 'audio.sound-effect.generate' || + inputs.action === 'audio.background-music.generate' + ) { + return placeDraftBesideSourceLayer( + { + ...(inputs.action === 'audio.sound-effect.generate' + ? createSoundEffectGenerationDialogDraft({ canvasSize, viewport }) + : createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport })), + prompt, + sourceLayerId: sourceLayer.id, + soundModel: + inputs.action === 'audio.sound-effect.generate' + ? (getNormalizedString(sourceLayer, 'model', DEFAULT_SOUND_EFFECT_MODEL) as GenerateDialogState['soundModel']) + : undefined, + soundDurationSeconds: + inputs.action === 'audio.sound-effect.generate' + ? getNormalizedNumber( + sourceLayer, + 'durationSeconds', + DEFAULT_SOUND_EFFECT_DURATION_SECONDS, + ) + : undefined, + }, + sourceLayer, + ); + } + + if (inputs.action === 'character-animation.generate') { + const animationSource = resolveNormalizedReferences( + sourceLayer, + 'source', + availableLayers, + ) + .map((reference) => + (availableLayers ?? []).find( + (layer) => createCanvasLayerReference(layer).id === reference.id, + ), + ) + .find((layer): layer is CanvasLayer => Boolean(layer)); + if (!animationSource) { + return null; + } + const draft = createCharacterAnimationGenerationDialogDraft({ + canvasSize, + viewport, + layer: animationSource, + }); + return draft + ? placeDraftBesideSourceLayer( + { + ...draft, + prompt, + sourceLayerId: animationSource.id, + characterAnimationResolution: getNormalizedString( + sourceLayer, + 'resolution', + '480p', + ) as GenerateDialogState['characterAnimationResolution'], + characterAnimationRatio: getNormalizedString( + sourceLayer, + 'ratio', + 'same', + ) as GenerateDialogState['characterAnimationRatio'], + characterAnimationFrameCount: getNormalizedNumber( + sourceLayer, + 'frameCount', + 32, + ) as GenerateDialogState['characterAnimationFrameCount'], + characterAnimationDurationSeconds: getNormalizedNumber( + sourceLayer, + 'durationSeconds', + 4, + ) as GenerateDialogState['characterAnimationDurationSeconds'], + }, + sourceLayer, + ) + : null; + } + + if (inputs.action === 'image.edit') { + const source = + resolveNormalizedReferences(sourceLayer, 'source', availableLayers)[0]; + const sourceLayerForEdit = source + ? (availableLayers ?? []).find( + (layer) => createCanvasLayerReference(layer).id === source.id, + ) + : undefined; + if (!sourceLayerForEdit) { + return null; + } + const size = resolveEditorImageGenerationPixelSize({ + model: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + }); + return { + ...createQuickEditGenerationDialogDraft({ + sourceLayer: sourceLayerForEdit, + prompt, + model: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + frame: size, + }), + composerOpen: true, + isRedrawAsNewArtifact: true, + }; + } + + return null; +} + function restoreSharedImageOptions( draft: Omit, sourceLayer: CanvasLayer, @@ -961,10 +1360,23 @@ export function createSameSourceGenerationDialogDraft({ sourceDialog, mode, sourceAnimationLayer, + availableLayers, }: SourceGenerationDialogDraftContext): Omit< CanvasGenerationDialogState, 'id' > | null { + const normalizedDraft = createNormalizedGenerationDialogDraft({ + sourceLayer, + canvasSize, + viewport, + sourceDialog, + mode, + sourceAnimationLayer, + availableLayers, + }); + if (normalizedDraft) { + return normalizedDraft; + } const sourceMode = resolveGeneratedSourceDialogMode({ sourceLayer, sourceDialog, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c2e21ecbf..f12954d3b 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -18,6 +18,7 @@ import { buildSpecGenerationInputs, buildSpecPrompt, buildUiDesignAssetExtractionGenerationInputs, + canOpenRedrawPanel, calculateCharacterAnimationPrice, calculateEditorBackgroundMusicPrice, calculateEditorIconSpritesheetPrice, @@ -236,7 +237,19 @@ describe('ImageCanvasGenerationModel', () => { it('builds user-facing generation input snapshots instead of backend prompts', () => { expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({ - fields: [{ title: '生成提示词', value: '一张明亮主视觉' }], + version: 2, + action: 'image.generate', + fields: [ + { id: 'prompt', title: '生成提示词', value: '一张明亮主视觉' }, + { + id: 'model', + title: '模型', + value: 'gemini-3.1-flash-image-preview', + }, + { id: 'style', title: '风格', value: 'none' }, + { id: 'aspectRatio', title: '比例', value: '1:1' }, + { id: 'imageSize', title: '尺寸', value: '1K' }, + ], references: [], }); @@ -248,15 +261,18 @@ describe('ImageCanvasGenerationModel', () => { characterView: '右向三分之二侧身', customPrompt: '', }), - ).toEqual({ + ).toEqual(expect.objectContaining({ + version: 2, + action: 'spec.generate', fields: [ - { title: '玩法设定', value: '平台跳跃' }, - { title: '美术风格', value: '像素风' }, - { title: '头身比', value: '3' }, - { title: '角色视角', value: '右向三分之二侧身' }, + { id: 'specType', title: '规范类型', value: 'character' }, + { id: 'playSetting', title: '玩法设定', value: '平台跳跃' }, + { id: 'artStyle', title: '美术风格', value: '像素风' }, + { id: 'bodyRatio', title: '头身比', value: '3' }, + { id: 'characterView', title: '角色视角', value: '右向三分之二侧身' }, ], references: [], - }); + })); }); it('builds character, icon and edit reference snapshots', () => { @@ -280,23 +296,29 @@ describe('ImageCanvasGenerationModel', () => { }, ], ), - ).toEqual({ - fields: [{ title: '角色设定', value: '主角骑士' }], + ).toEqual(expect.objectContaining({ + version: 2, + action: 'character.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '角色设定', value: '主角骑士' }, + ]), references: [ { + id: 'specReference', title: '角色规范', label: '角色规范', refType: 'project-resource', refId: 'resource-spec', }, { + id: 'reference', title: '常规参考图 1', label: '盔甲参考', refType: 'project-resource', refId: 'resource-armor', }, ], - }); + })); expect( buildIconGenerationInputs(['返回按钮', '设置按钮'], { @@ -305,31 +327,41 @@ describe('ImageCanvasGenerationModel', () => { src: '/icon-spec.png', resourceId: 'resource-icon-spec', }), - ).toEqual({ - fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], + ).toEqual(expect.objectContaining({ + version: 2, + action: 'icon.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' }, + ]), references: [ { + id: 'specReference', title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, ], - }); + })); expect( buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer), - ).toEqual({ - fields: [{ title: '修改要求', value: '换成夜晚' }], + ).toEqual(expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { id: 'prompt', title: '修改要求', value: '换成夜晚' }, + ]), references: [ { + id: 'source', title: '参考图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, ], - }); + })); expect( buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [ { @@ -339,29 +371,32 @@ describe('ImageCanvasGenerationModel', () => { resourceId: 'resource-ui-style', }, ]), - ).toEqual({ - fields: [ - { + ).toEqual(expect.objectContaining({ + version: 2, + action: 'ui-design.extract-assets', + fields: expect.arrayContaining([ + expect.objectContaining({ + id: 'prompt', title: '提取提示词', - value: - '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', - }, - ], + }), + ]), references: [ { + id: 'source', title: 'UI设计图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { + id: 'reference', title: '参考图 1', label: 'UI风格参考', refType: 'project-resource', refId: 'resource-ui-style', }, ], - }); + })); expect( buildQuickEditGenerationInputs( '快速编辑提示词', @@ -376,23 +411,86 @@ describe('ImageCanvasGenerationModel', () => { }, ], ), - ).toEqual({ - fields: [{ title: '快速编辑提示词', value: '让图2下雨' }], + ).toEqual(expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' }, + ]), references: [ { + id: 'source', title: '原图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { + id: 'reference', title: '参考图 1', label: '天空', refType: 'project-resource', refId: 'resource-sky', }, ], - }); + })); + }); + + it('only exposes redraw for executable generation metadata', () => { + const generatedLayer = { + ...buildSourceLayer(), + sourceType: 'generated' as const, + }; + + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: buildImageGenerationInputs('生成城堡'), + }), + ).toBe(true); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: { + fields: [{ title: '生成提示词', value: '旧版城堡' }], + references: [], + }, + }), + ).toBe(true); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: null, + }), + ).toBe(false); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: { + version: 2, + fields: [{ title: '生成提示词', value: '公开展示文本' }], + references: [], + }, + }), + ).toBe(false); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: { + version: 2, + action: 'unknown.generate' as never, + fields: [{ id: 'prompt', title: '提示词', value: '未知动作' }], + references: [], + }, + }), + ).toBe(false); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + sourceType: 'uploaded', + generationInputs: buildImageGenerationInputs('上传文件'), + }), + ).toBe(false); }); it('keeps generated prompts and quick edit options stable', () => { @@ -472,9 +570,19 @@ describe('ImageCanvasGenerationModel', () => { }, ), ).toEqual({ - fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }], + version: 2, + action: 'spec.generate', + fields: [ + { id: 'specType', title: '规范类型', value: 'custom' }, + { + id: 'customPrompt', + title: '自定义规范提示词', + value: '生成一张怪兽规范图', + }, + ], references: [ { + id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', @@ -530,10 +638,12 @@ describe('ImageCanvasGenerationModel', () => { expect( buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7), ).toEqual({ + version: 2, + action: 'audio.sound-effect.generate', fields: [ - { title: 'prompt', value: '金币掉落叮当声' }, - { title: 'model', value: 'audio1.0' }, - { title: 'duration', value: '7秒' }, + { id: 'prompt', title: 'prompt', value: '金币掉落叮当声' }, + { id: 'model', title: 'model', value: 'audio1.0' }, + { id: 'durationSeconds', title: 'duration', value: 7 }, ], references: [], }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 396d10029..86e1b3051 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -10,8 +10,10 @@ import { } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, + CanvasGenerationAction, CanvasGenerationInputField, CanvasGenerationInputReference, + CanvasGenerationInputValue, CanvasGenerationInputs, CanvasLayer, CharacterReferenceImage, @@ -695,10 +697,40 @@ export function getLayerKindLabel(layer: CanvasLayer) { export function buildCharacterAnimationGenerationInputs( promptText: string, sourceLayer: CanvasLayer, + options: { + resolution?: string; + ratio?: string; + frameCount?: number; + durationSeconds?: number; + } = {}, ): CanvasGenerationInputs { return { - fields: createGenerationInputField('动作描述', promptText), - references: createLayerGenerationInputReference('角色图片', sourceLayer), + version: 2, + action: 'character-animation.generate', + fields: [ + ...createGenerationInputField('动作描述', promptText, 'prompt'), + ...createGenerationInputField( + '清晰度', + options.resolution ?? '480p', + 'resolution', + ), + ...createGenerationInputField('比例', options.ratio ?? 'same', 'ratio'), + ...createGenerationInputField( + '帧数', + options.frameCount ?? 32, + 'frameCount', + ), + ...createGenerationInputField( + '时长', + options.durationSeconds ?? 4, + 'durationSeconds', + ), + ], + references: createLayerGenerationInputReference( + '角色图片', + sourceLayer, + { id: 'source' }, + ), }; } @@ -951,6 +983,9 @@ export function createGenerationInputReference( CharacterReferenceImage, 'label' | 'resourceId' | 'sourceAssetId' >, + options: { + id?: string; + } = {}, ): CanvasGenerationInputReference[] { const resourceId = reference.resourceId?.trim(); if ( @@ -960,6 +995,7 @@ export function createGenerationInputReference( ) { return [ { + ...(options.id ? { id: options.id } : {}), title, label: reference.label, refType: 'project-resource', @@ -971,6 +1007,7 @@ export function createGenerationInputReference( return sourceAssetId && !sourceAssetId.startsWith('upload-') ? [ { + ...(options.id ? { id: options.id } : {}), title, label: reference.label, refType: 'asset', @@ -983,12 +1020,13 @@ export function createGenerationInputReference( export function createLayerGenerationInputReference( title: string, layer: CanvasLayer, + options: Parameters[2] = {}, ): CanvasGenerationInputReference[] { return createGenerationInputReference(title, { label: layer.title, resourceId: layer.resourceId, sourceAssetId: layer.sourceAssetId, - }); + }, options); } export function appendLimitedQuickEditReferences( @@ -1006,36 +1044,169 @@ export function appendLimitedQuickEditReferences( export function createGenerationInputField( title: string, - value: string | null | undefined, + value: CanvasGenerationInputValue | null | undefined, + id?: string, ): CanvasGenerationInputField[] { - const normalizedValue = value?.trim(); - return normalizedValue ? [{ title, value: normalizedValue }] : []; + const normalizedValue = + typeof value === 'string' ? value.trim() : value; + return normalizedValue === undefined || normalizedValue === null || normalizedValue === '' + ? [] + : [{ ...(id ? { id } : {}), title, value: normalizedValue }]; +} + +export function formatGenerationInputValue(value: CanvasGenerationInputValue) { + return typeof value === 'string' ? value : String(value); +} + +const CANVAS_GENERATION_ACTIONS = new Set([ + 'image.generate', + 'spec.generate', + 'character.generate', + 'icon.generate', + 'ui-design.generate', + 'publication.generate', + 'video.generate', + 'audio.sound-effect.generate', + 'audio.background-music.generate', + 'character-animation.generate', + 'image.edit', + 'ui-design.extract-assets', +]); + +export function isNormalizedCanvasGenerationInputs( + value: CanvasGenerationInputs | null | undefined, +): value is CanvasGenerationInputs & { + version: 2; + action: CanvasGenerationAction; +} { + return Boolean( + value?.version === 2 && + value.action && + CANVAS_GENERATION_ACTIONS.has(value.action) && + Array.isArray(value.fields) && + Array.isArray(value.references) && + value.fields.every((field) => Boolean(field && field.id?.trim())) && + value.references.every((reference) => + Boolean(reference && reference.id?.trim()), + ), + ); +} + +const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set( + [ + '生成提示词', + '视频描述', + 'prompt', + 'gpt_description_prompt', + '音效提示词', + '背景音乐提示词', + '角色设定', + '用户输入', + '素材描述', + '自定义规范提示词', + '玩法设定', + '美术风格', + '游戏名', + '修改要求', + '快速编辑提示词', + '重绘提示词', + '动作描述', + ].map((title) => title.toLowerCase()), +); + +export function canOpenRedrawPanel(layer: CanvasLayer) { + if (layer.sourceType === 'uploaded') { + return false; + } + if (isNormalizedCanvasGenerationInputs(layer.generationInputs)) { + return true; + } + if (layer.generationInputs?.version === 2) { + return false; + } + return Boolean( + layer.generationInputs?.fields.some((field) => + LEGACY_REUSABLE_GENERATION_INPUT_TITLES.has( + field.title.trim().toLowerCase(), + ), + ), + ); +} + +function buildNormalizedGenerationInputs( + action: CanvasGenerationAction, + fields: CanvasGenerationInputField[], + references: CanvasGenerationInputReference[], +): CanvasGenerationInputs { + return { version: 2, action, fields, references }; } export function buildImageGenerationInputs( prompt: string, references?: CharacterReferenceImage[], + options: { + model?: string; + style?: string; + aspectRatio?: string; + imageSize?: string; + } = {}, ): CanvasGenerationInputs { - return { - fields: createGenerationInputField('生成提示词', prompt), - references: (references ?? []).flatMap((reference, index) => - createGenerationInputReference(`参考图 ${index + 1}`, reference), + return buildNormalizedGenerationInputs( + 'image.generate', + [ + ...createGenerationInputField('生成提示词', prompt, 'prompt'), + ...createGenerationInputField( + '模型', + options.model ?? DEFAULT_IMAGE_MODEL, + 'model', + ), + ...createGenerationInputField('风格', options.style ?? 'none', 'style'), + ...createGenerationInputField( + '比例', + options.aspectRatio ?? '1:1', + 'aspectRatio', + ), + ...createGenerationInputField( + '尺寸', + options.imageSize ?? '1K', + 'imageSize', + ), + ], + (references ?? []).flatMap((reference, index) => + createGenerationInputReference(`参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), - }; + ); } export function buildVideoGenerationInputs( prompt: string, references?: CharacterReferenceImage[], + options: { + model?: string; + aspectRatio?: string; + resolution?: string; + durationSeconds?: number; + sound?: string; + } = {}, ): CanvasGenerationInputs { const mediaIndexes = { image: 0, video: 0, audio: 0, }; - return { - fields: createGenerationInputField('视频描述', prompt), - references: (references ?? []).flatMap((reference) => { + return buildNormalizedGenerationInputs( + 'video.generate', + [ + ...createGenerationInputField('视频描述', prompt, 'prompt'), + ...createGenerationInputField('模型', options.model ?? DEFAULT_VIDEO_MODEL, 'model'), + ...createGenerationInputField('比例', options.aspectRatio ?? DEFAULT_VIDEO_ASPECT_RATIO, 'aspectRatio'), + ...createGenerationInputField('清晰度', options.resolution ?? '480p', 'resolution'), + ...createGenerationInputField('时长', options.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS, 'durationSeconds'), + ...createGenerationInputField('声音', options.sound ?? DEFAULT_VIDEO_SOUND, 'sound'), + ], + (references ?? []).flatMap((reference) => { const mediaType = reference.mediaType === 'video' || reference.mediaType === 'audio' ? reference.mediaType @@ -1047,9 +1218,11 @@ export function buildVideoGenerationInputs( : mediaType === 'audio' ? `参考音频 ${mediaIndexes.audio}` : `参考图 ${mediaIndexes.image}`; - return createGenerationInputReference(title, reference); + return createGenerationInputReference(title, reference, { + id: `${mediaType}Reference`, + }); }), - }; + ); } export function buildSoundEffectGenerationInputs( @@ -1058,10 +1231,12 @@ export function buildSoundEffectGenerationInputs( duration: number, ): CanvasGenerationInputs { return { + version: 2, + action: 'audio.sound-effect.generate', fields: [ - ...createGenerationInputField('prompt', prompt), - ...createGenerationInputField('model', model), - ...createGenerationInputField('duration', `${duration}秒`), + ...createGenerationInputField('prompt', prompt, 'prompt'), + ...createGenerationInputField('model', model, 'model'), + ...createGenerationInputField('duration', duration, 'durationSeconds'), ], references: [], }; @@ -1071,9 +1246,12 @@ export function buildBackgroundMusicGenerationInputs( gptDescriptionPrompt: string, ): CanvasGenerationInputs { return { + version: 2, + action: 'audio.background-music.generate', fields: createGenerationInputField( 'gpt_description_prompt', gptDescriptionPrompt, + 'prompt', ), references: [], }; @@ -1085,29 +1263,40 @@ export function buildSpecGenerationInputs( reference?: CharacterReferenceImage | null, ): CanvasGenerationInputs { const references = reference - ? createGenerationInputReference('参考图', reference) + ? createGenerationInputReference('参考图', reference, { + id: 'reference', + }) : []; if (specType === 'custom') { return { - fields: createGenerationInputField( - '自定义规范提示词', - values.customPrompt, - ), + version: 2, + action: 'spec.generate', + fields: [ + ...createGenerationInputField('规范类型', specType, 'specType'), + ...createGenerationInputField( + '自定义规范提示词', + values.customPrompt, + 'customPrompt', + ), + ], references, }; } const baseFields = [ - ...createGenerationInputField('玩法设定', values.playSetting), - ...createGenerationInputField('美术风格', values.artStyle), + ...createGenerationInputField('规范类型', specType, 'specType'), + ...createGenerationInputField('玩法设定', values.playSetting, 'playSetting'), + ...createGenerationInputField('美术风格', values.artStyle, 'artStyle'), ]; if (specType === 'character') { baseFields.push( - ...createGenerationInputField('头身比', values.bodyRatio), - ...createGenerationInputField('角色视角', values.characterView), + ...createGenerationInputField('头身比', values.bodyRatio, 'bodyRatio'), + ...createGenerationInputField('角色视角', values.characterView, 'characterView'), ); } return { + version: 2, + action: 'spec.generate', fields: baseFields, references, }; @@ -1117,15 +1306,33 @@ export function buildCharacterGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references: CharacterReferenceImage[] | undefined, + options: { + model?: string; + style?: string; + aspectRatio?: string; + imageSize?: string; + } = {}, ): CanvasGenerationInputs { return { - fields: createGenerationInputField('角色设定', prompt), + version: 2, + action: 'character.generate', + fields: [ + ...createGenerationInputField('角色设定', prompt, 'prompt'), + ...createGenerationInputField('模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model'), + ...createGenerationInputField('风格', options.style ?? 'none', 'style'), + ...createGenerationInputField('比例', options.aspectRatio ?? '1:1', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), + ], references: [ ...(specReference - ? createGenerationInputReference('角色规范', specReference) + ? createGenerationInputReference('角色规范', specReference, { + id: 'specReference', + }) : []), ...(references ?? []).flatMap((reference, index) => - createGenerationInputReference(`常规参考图 ${index + 1}`, reference), + createGenerationInputReference(`常规参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), ], }; @@ -1135,15 +1342,27 @@ export function buildUiDesignGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references?: CharacterReferenceImage[], + options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { - fields: createGenerationInputField('用户输入', prompt), + version: 2, + action: 'ui-design.generate', + fields: [ + ...createGenerationInputField('用户输入', prompt, 'prompt'), + ...createGenerationInputField('模型', options.model ?? IMAGE_MODEL_GPT_IMAGE_2, 'model'), + ...createGenerationInputField('比例', options.aspectRatio ?? '16:9', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), + ], references: [ ...(specReference - ? createGenerationInputReference('图标规范', specReference) + ? createGenerationInputReference('图标规范', specReference, { + id: 'specReference', + }) : []), ...(references ?? []).flatMap((reference, index) => - createGenerationInputReference(`参考图 ${index + 1}`, reference), + createGenerationInputReference(`参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), ], }; @@ -1200,22 +1419,30 @@ export function buildPublicationMaterialsGenerationPrompt({ export function buildPublicationMaterialsGenerationInputs( gameInfo: PublicationMaterialsGameInfo | null | undefined, references: CharacterReferenceImage[] | undefined, + workflowId: PublicationMaterialsWorkflowId = 'publication-cover-image', ): CanvasGenerationInputs { const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); return { + version: 2, + action: 'publication.generate', fields: [ - ...createGenerationInputField('游戏名', normalizedGameInfo.gameName), + ...createGenerationInputField('宣发类型', workflowId, 'workflowId'), + ...createGenerationInputField('游戏名', normalizedGameInfo.gameName, 'gameName'), ...createGenerationInputField( '游戏分类', normalizedGameInfo.gameCategories, + 'gameCategories', ), ...createGenerationInputField( '一句话描述游戏', normalizedGameInfo.gameDescription, + 'gameDescription', ), ], references: (references ?? []).flatMap((reference, index) => - createGenerationInputReference(`宣发参考图 ${index + 1}`, reference), + createGenerationInputReference(`宣发参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), }; } @@ -1224,18 +1451,35 @@ export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, references?: CharacterReferenceImage[], + options: { + model?: string; + style?: string; + aspectRatio?: string; + imageSize?: string; + } = {}, ): CanvasGenerationInputs { return { + version: 2, + action: 'icon.generate', fields: [ { + id: 'prompt', title: '素材描述', value: iconDescriptions.join('\n'), }, + ...createGenerationInputField('模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model'), + ...createGenerationInputField('风格', options.style ?? 'none', 'style'), + ...createGenerationInputField('比例', options.aspectRatio ?? '1:1', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), ], references: [ - ...createGenerationInputReference('图标规范', specReference), + ...createGenerationInputReference('图标规范', specReference, { + id: 'specReference', + }), ...(references ?? []).flatMap((reference, index) => - createGenerationInputReference(`参考图 ${index + 1}`, reference), + createGenerationInputReference(`参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), ], }; @@ -1244,18 +1488,29 @@ export function buildIconGenerationInputs( export function buildUiDesignAssetExtractionGenerationInputs( sourceLayer: CanvasLayer, references?: CharacterReferenceImage[], + options: { model?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { + version: 2, + action: 'ui-design.extract-assets', fields: [ ...createGenerationInputField( '提取提示词', UI_DESIGN_ASSET_EXTRACTION_PROMPT, + 'prompt', ), + ...createGenerationInputField('模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model'), + ...createGenerationInputField('比例', '1:1', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), ], references: [ - ...createLayerGenerationInputReference('UI设计图', sourceLayer), + ...createLayerGenerationInputReference('UI设计图', sourceLayer, { + id: 'source', + }), ...(references ?? []).flatMap((reference, index) => - createGenerationInputReference(`参考图 ${index + 1}`, reference), + createGenerationInputReference(`参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), ], }; @@ -1265,10 +1520,20 @@ export function buildEditGenerationInputs( title: '修改要求' | '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, + options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { - fields: createGenerationInputField(title, prompt), - references: createLayerGenerationInputReference('参考图', sourceLayer), + version: 2, + action: 'image.edit', + fields: [ + ...createGenerationInputField(title, prompt, 'prompt'), + ...createGenerationInputField('模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model'), + ...createGenerationInputField('比例', options.aspectRatio ?? '1:1', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), + ], + references: createLayerGenerationInputReference('参考图', sourceLayer, { + id: 'source', + }), }; } @@ -1277,17 +1542,29 @@ export function buildQuickEditGenerationInputs( prompt: string, sourceLayer: CanvasLayer, references: CharacterReferenceImage[] | undefined, + options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { const limitedReferences = (references ?? []).slice( 0, QUICK_EDIT_REFERENCE_LIMIT, ); return { - fields: createGenerationInputField(title, prompt), + version: 2, + action: 'image.edit', + fields: [ + ...createGenerationInputField(title, prompt, 'prompt'), + ...createGenerationInputField('模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model'), + ...createGenerationInputField('比例', options.aspectRatio ?? '1:1', 'aspectRatio'), + ...createGenerationInputField('尺寸', options.imageSize ?? '1K', 'imageSize'), + ], references: [ - ...createLayerGenerationInputReference('原图', sourceLayer), + ...createLayerGenerationInputReference('原图', sourceLayer, { + id: 'source', + }), ...limitedReferences.flatMap((reference, index) => - createGenerationInputReference(`参考图 ${index + 1}`, reference), + createGenerationInputReference(`参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), ], }; diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 1784b7056..43286036c 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -53,10 +53,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, result: { title: '生成图片 3', - generationInputs: { - fields: [{ title: '生成提示词', value: '一张发光主视觉' }], + generationInputs: expect.objectContaining({ + version: 2, + action: 'image.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '生成提示词', value: '一张发光主视觉' }, + ]), references: [], - }, + }), }, rememberImageModel: 'gemini-3.1-flash-image-preview', }); @@ -124,17 +128,22 @@ describe('ImageCanvasGenerationSubmissionModel', () => { kind: 'edit', normalizedPrompt: '修改当前图片', sourceLayer, - generationInputs: { - fields: [{ title: '修改要求', value: '修改当前图片' }], + generationInputs: expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { id: 'prompt', title: '修改要求', value: '修改当前图片' }, + ]), references: [ { + id: 'source', title: '参考图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], - }, + }), }); }); @@ -178,17 +187,26 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { title: '源图 快速编辑', assetKind: 'character', - generationInputs: { - fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }], + generationInputs: expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { + id: 'prompt', + title: '快速编辑提示词', + value: '把当前图改成雨天', + }, + ]), references: [ { + id: 'source', title: '原图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], - }, + }), }, rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); @@ -255,20 +273,23 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( - { - fields: [ - { title: '玩法设定', value: '休闲消除' }, - { title: '美术风格', value: '清爽卡通' }, - ], + expect.objectContaining({ + version: 2, + action: 'spec.generate', + fields: expect.arrayContaining([ + { id: 'playSetting', title: '玩法设定', value: '休闲消除' }, + { id: 'artStyle', title: '美术风格', value: '清爽卡通' }, + ]), references: [ { + id: 'reference', title: '参考图', label: '参考.png', refType: 'project-resource', refId: 'resource-spec-ref', }, ], - }, + }), ); }); @@ -359,25 +380,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => { rememberImageModel: 'gpt-image-2', }); expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual( - { - fields: [ - { title: '角色设定', value: '白发骑士' }, - ], + expect.objectContaining({ + version: 2, + action: 'character.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '角色设定', value: '白发骑士' }, + ]), references: [ { + id: 'specReference', title: '角色规范', label: '角色规范', refType: 'project-resource', refId: 'resource-character-spec', }, { + id: 'reference', title: '常规参考图 1', label: '盔甲参考', refType: 'project-resource', refId: 'resource-armor', }, ], - }, + }), ); }); @@ -425,23 +450,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { assetKind: 'ui-design', title: 'UI设计图 6', - generationInputs: { - fields: [{ title: '用户输入', value: '设计移动端商城首页' }], + generationInputs: expect.objectContaining({ + version: 2, + action: 'ui-design.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '用户输入', value: '设计移动端商城首页' }, + ]), references: [ { + id: 'specReference', title: '图标规范', label: 'UI图标规范', refType: 'project-resource', refId: 'resource-ui-spec', }, { + id: 'reference', title: '参考图 1', label: '界面风格参考', refType: 'project-resource', refId: 'resource-ui-ref', }, ], - }, + }), }, rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, }); @@ -491,24 +522,32 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { assetKind: 'publication-material', title: '10 宣发素材', - generationInputs: { - fields: [ - { title: '游戏名', value: '重庆洪崖洞火锅' }, - { title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' }, + generationInputs: expect.objectContaining({ + version: 2, + action: 'publication.generate', + fields: expect.arrayContaining([ + { id: 'gameName', title: '游戏名', value: '重庆洪崖洞火锅' }, { + id: 'gameCategories', + title: '游戏分类', + value: '抓大鹅、休闲、治愈、手绘风', + }, + { + id: 'gameDescription', title: '一句话描述游戏', value: '在一堆物品里点击,找到三个一样的物品将其消除', }, - ], + ]), references: [ { + id: 'reference', title: '宣发参考图 1', label: '参考图', refType: 'project-resource', refId: 'resource-publication-ref', }, ], - }, + }), }, }); const prompt = plan.kind === 'image' ? plan.input.prompt : ''; @@ -722,23 +761,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => { aspectRatio: '3:2', imageSize: '2K', }, - generationInputs: { - fields: [{ title: '素材描述', value: '返回按钮\n\n设置按钮' }], + generationInputs: expect.objectContaining({ + version: 2, + action: 'icon.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '素材描述', value: '返回按钮\n\n设置按钮' }, + ]), references: [ { + id: 'specReference', title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, { + id: 'reference', title: '参考图 1', label: '按钮风格参考', refType: 'project-resource', refId: 'resource-icon-ref', }, ], - }, + }), rememberImageModel: 'gpt-image-2', resultTitle: '冒险游戏图标', }); @@ -850,28 +895,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ], }, result: { - generationInputs: { + generationInputs: expect.objectContaining({ + version: 2, + action: 'video.generate', references: [ { + id: 'imageReference', title: '参考图 1', label: '参考图.png', refType: 'project-resource', refId: 'resource-video-image-ref', }, { + id: 'videoReference', title: '参考视频 1', label: '参考视频.mp4', refType: 'project-resource', refId: 'resource-video-ref', }, { + id: 'audioReference', title: '参考音频 1', label: '参考音频.mp3', refType: 'project-resource', refId: 'resource-audio-ref', }, ], - }, + }), }, }); }); @@ -1028,10 +1078,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { title: '游戏音效 4', generationInputs: { + version: 2, + action: 'audio.sound-effect.generate', fields: [ - { title: 'prompt', value: '金币掉落叮当声' }, - { title: 'model', value: 'audio1.0' }, - { title: 'duration', value: '7秒' }, + { id: 'prompt', title: 'prompt', value: '金币掉落叮当声' }, + { id: 'model', title: 'model', value: 'audio1.0' }, + { id: 'durationSeconds', title: 'duration', value: 7 }, ], references: [], }, @@ -1060,11 +1112,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }, result: { generationInputs: { + version: 2, + action: 'audio.sound-effect.generate', fields: [ - { title: 'prompt', value: '按钮确认短促音' }, - { title: 'model', value: 'audio1.0' }, - { title: 'duration', value: '5秒' }, + { id: 'prompt', title: 'prompt', value: '按钮确认短促音' }, + { id: 'model', title: 'model', value: 'audio1.0' }, + { id: 'durationSeconds', title: 'duration', value: 5 }, ], + references: [], }, }, }); @@ -1092,8 +1147,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { title: '游戏背景音乐 5', generationInputs: { + version: 2, + action: 'audio.background-music.generate', fields: [ { + id: 'prompt', title: 'gpt_description_prompt', value: '温暖轻快的森林冒险背景音乐', }, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index f33d4f55f..bbd9c7d5d 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -251,6 +251,11 @@ export function buildImageGenerationSubmissionPlan({ '修改要求', normalizedPrompt, sourceLayer, + { + model: dialog.imageModel, + aspectRatio: dialog.aspectRatio, + imageSize: dialog.imageSize, + }, ), }; } @@ -260,7 +265,7 @@ export function buildImageGenerationSubmissionPlan({ (layer) => layer.id === dialog.sourceLayerId, ); if (!sourceLayer) { - throw new Error('未找到要改造的图片'); + throw new Error('未找到要改造的原图'); } const basePrompt = dialog.prompt.trim() || '快速编辑图片'; const normalizedQuickEditPrompt = basePrompt; @@ -284,6 +289,11 @@ export function buildImageGenerationSubmissionPlan({ normalizedQuickEditPrompt, sourceLayer, [], + { + model: imageModel, + aspectRatio: dialog.aspectRatio, + imageSize: dialog.imageSize, + }, ), }, rememberImageModel: imageModel, @@ -368,6 +378,12 @@ export function buildImageGenerationSubmissionPlan({ normalizedPrompt, dialog.characterSpecReference, dialog.characterReferences, + { + model: imageModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', + }, ), }, rememberImageModel: imageModel, @@ -405,6 +421,11 @@ export function buildImageGenerationSubmissionPlan({ normalizedPrompt, dialog.uiDesignSpecReference, dialog.generationReferences, + { + model: imageModel, + aspectRatio: dialog.aspectRatio ?? '16:9', + imageSize: normalizeGptImageSize(dialog.imageSize), + }, ), }, rememberImageModel: imageModel, @@ -450,6 +471,7 @@ export function buildImageGenerationSubmissionPlan({ generationInputs: buildPublicationMaterialsGenerationInputs( dialog.publicationGameInfo, dialog.publicationReferences, + workflow.id, ), }, }; @@ -503,6 +525,13 @@ export function buildImageGenerationSubmissionPlan({ generationInputs: buildVideoGenerationInputs( normalizedPrompt, dialog.generationReferences, + { + model, + aspectRatio, + resolution, + durationSeconds, + sound, + }, ), }, }; @@ -585,6 +614,12 @@ export function buildImageGenerationSubmissionPlan({ generationInputs: buildImageGenerationInputs( normalizedPrompt, dialog.generationReferences, + { + model: imageModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', + }, ), }, rememberImageModel: imageModel, @@ -646,6 +681,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan( iconDescriptions, dialog.iconSpecReference, dialog.generationReferences, + { + model: rememberImageModel, + style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', + aspectRatio: dialog.aspectRatio ?? '1:1', + imageSize: dialog.imageSize ?? '1K', + }, ), rememberImageModel, resultTitle: resolveGenerationAssetLabel( diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 3b5a1768c..f597f6b3a 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -76,14 +76,16 @@ export function ImageCanvasMetadataModalView({
{generationInputReferences.map((reference) => (
{reference.title} - {reference.label} + + {reference.label ?? '参考素材'} + {formatReferenceType(reference.refType)} ·{' '} {reference.refId} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index b06f0013e..671905984 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -25,7 +25,13 @@ function createLayer(overrides: Partial = {}): CanvasLayer { originalWidth: 320, originalHeight: 240, zIndex: 1, - sourceType: 'uploaded', + sourceType: 'generated', + generationInputs: { + version: 2, + action: 'image.generate', + fields: [{ id: 'prompt', title: '生成提示词', value: '测试图片' }], + references: [], + }, ...overrides, }; } @@ -85,7 +91,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { props.selectedLayer, ); expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer); - expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onOpenRedrawPanel).toHaveBeenCalledWith( + props.selectedLayer, + ); expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); expect( within(toolbar).queryByRole('button', { name: '删除图片' }), @@ -209,6 +217,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { src: 'data:audio/mpeg;base64,audio', mediaType: 'audio', assetKind: 'sound-effect', + generationInputs: { + version: 2, + action: 'audio.sound-effect.generate', + fields: [{ id: 'prompt', title: 'prompt', value: '金币声' }], + references: [], + }, }); const props = renderSelectedToolbar({ selectedLayer: layer }); const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' }); @@ -238,6 +252,32 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onDownloadLayer).toHaveBeenCalledWith(layer); }); + it('hides redraw for non-generative derived and public display-only layers', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + title: '手动裁扩结果', + generationInputs: null, + }), + }); + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + + cleanup(); + renderSelectedToolbar({ + selectedLayer: createLayer({ + title: '公开音效', + mediaType: 'audio', + assetKind: 'sound-effect', + generationInputs: { + version: 2, + fields: [{ title: 'prompt', value: '公开展示文本' }], + references: [], + }, + }), + }); + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy(); + }); + it('removes raster edit actions for video and character animation layers', () => { renderSelectedToolbar({ selectedLayer: createLayer({ @@ -245,6 +285,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { src: '/generated/video.mp4', mediaType: 'video', assetKind: 'video', + generationInputs: { + version: 2, + action: 'video.generate', + fields: [{ id: 'prompt', title: '视频描述', value: '测试视频' }], + references: [], + }, }), }); const videoToolbar = screen.getByRole('toolbar', { name: '图片工具栏' }); @@ -268,6 +314,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { src: '/generated/action/frame-01.png', mediaType: 'image-sequence', assetKind: 'character-animation', + generationInputs: { + version: 2, + action: 'character-animation.generate', + fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], + references: [], + }, }), }); const actionToolbar = screen.getByRole('toolbar', { name: '图片工具栏' }); diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index b1a423324..b61b0dc6e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -13,7 +13,10 @@ import type { CSSProperties } from 'react'; import { PlatformIconButton } from '../common/PlatformIconButton'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; -import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel'; +import { + canOpenRedrawPanel, + isQuickEditUnsupportedAssetKind, +} from './ImageCanvasGenerationModel'; type ImageCanvasSelectedLayerToolbarViewProps = { selectedLayer: CanvasLayer | null; @@ -47,6 +50,7 @@ export function ImageCanvasSelectedLayerToolbarView({ if (!selectedLayer || !selectedToolbarStyle) { return null; } + const canRedraw = canOpenRedrawPanel(selectedLayer); if (selectedLayer.mediaType === 'audio') { return ( @@ -57,15 +61,17 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} 生成动画 ) : null} -
))} {generationInputReferences.length ? ( diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index d01d498a6..11a574730 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -372,6 +372,26 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy(); }); + it.each([ + 'image.perfect-pixel', + 'spritesheet.split', + 'image.remove-background', + 'image.crop-expand', + ] as const)('never exposes redraw for deterministic action %s', (action) => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + generationInputs: { + version: 2, + action, + fields: [], + references: [], + }, + }), + }); + + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + }); + it('removes raster edit actions for video and character animation layers', () => { renderSelectedToolbar({ selectedLayer: createLayer({ diff --git a/src/components/image-editor/perfectPixelOperationStore.test.ts b/src/components/image-editor/perfectPixelOperationStore.test.ts index 8860263df..42d4a1f2c 100644 --- a/src/components/image-editor/perfectPixelOperationStore.test.ts +++ b/src/components/image-editor/perfectPixelOperationStore.test.ts @@ -62,7 +62,27 @@ describe('perfectPixelOperationStore', () => { }); it('round-trips an operation for the same owner and project', () => { - const operation = buildOperation('dialog-1'); + const operation = buildOperation('dialog-1', { + request: { + ...buildOperation('dialog-1').request, + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [ + { id: 'scale', title: '数字兼容', value: 2 }, + { id: 'enabled', title: '布尔兼容', value: false }, + ], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, + }, + }); savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation); const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 6a846bf38..978b4f4f1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -291,6 +291,19 @@ function createHydratedPerfectPixelDialog({ sourceImageSrc: 'generated-images/editor/source.png', projectId, sourceResourceId: 'resource-source', + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, assetLabel: '源图 · 完美像素', canvasCompletion: { dialogId: operationId, @@ -2401,6 +2414,18 @@ describe('useImageCanvasGenerationWorkflow', () => { height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', + generationInputs: { + version: 2, + action: 'image.crop-expand', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, }), ); await waitFor(() => { @@ -2572,7 +2597,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: undefined, targetLayerId: 'layer-source', assetKind: 'character', - generationInputs: null, + generationInputs: { + version: 2, + action: 'image.remove-background', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, assetFolderId: undefined, assetLabel: '源图 去背景', sourceResourceId: 'resource-source', @@ -2619,6 +2655,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: 'project-1', targetLayerId: 'layer-source', assetLabel: '源图 去背景', + generationInputs: { + version: 2, + action: 'image.remove-background', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, canvasCompletion: expect.objectContaining({ dialogId: 'generation-dialog-1', title: '源图 去背景', @@ -2751,7 +2799,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: 'project-1', sourceResourceId: 'resource-source', assetKind: 'character', - generationInputs: sourceLayer.generationInputs, + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, assetLabel: '源图 · 完美像素', canvasCompletion: expect.objectContaining({ dialogId: flushedOperation!.operationId, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 29dfcd3ca..2c042fe46 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -87,6 +87,7 @@ import { } from './ImageCanvasGenerationDialogModel'; import { appendLimitedImageReferences, + buildDeterministicGenerationInputs, calculateCharacterAnimationPrice, CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, CHARACTER_ANIMATION_DURATION_OPTIONS, @@ -2177,7 +2178,10 @@ export function useImageCanvasGenerationWorkflow({ taskId: cropExpandSourceLayer.taskId, sourceResourceId: cropExpandSourceLayer.resourceId, assetKind: cropExpandSourceLayer.assetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.crop-expand', + cropExpandSourceLayer, + ), }, ); cropExpandResourceId = cropExpandResource.resourceId; @@ -2212,7 +2216,10 @@ export function useImageCanvasGenerationWorkflow({ assetObjectId: cropExpandAssetObjectId, sourceAssetId: null, assetKind: cropExpandAssetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.crop-expand', + cropExpandSourceLayer, + ), }; captureCanvasHistory({ type: 'expand-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); @@ -2289,7 +2296,10 @@ export function useImageCanvasGenerationWorkflow({ projectId, targetLayerId: sourceLayer.id, assetKind: sourceLayer.assetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.remove-background', + sourceLayer, + ), assetFolderId, assetLabel, sourceResourceId: sourceLayer.resourceId, @@ -2570,18 +2580,10 @@ export function useImageCanvasGenerationWorkflow({ ...(sourceLayer.assetKind ? { assetKind: sourceLayer.assetKind } : {}), - ...(sourceLayer.generationInputs - ? { - generationInputs: { - fields: sourceLayer.generationInputs.fields.map((field) => ({ - ...field, - })), - references: sourceLayer.generationInputs.references.map( - (reference) => ({ ...reference }), - ), - }, - } - : {}), + generationInputs: buildDeterministicGenerationInputs( + 'image.perfect-pixel', + sourceLayer, + ), ...(assetFolderId ? { assetFolderId } : {}), assetLabel, canvasCompletion: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index f499089eb..6676526d3 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -138,7 +138,11 @@ export type EditorAssetGenerationInputs = { | 'audio.background-music.generate' | 'character-animation.generate' | 'image.edit' - | 'ui-design.extract-assets'; + | 'ui-design.extract-assets' + | 'image.perfect-pixel' + | 'spritesheet.split' + | 'image.remove-background' + | 'image.crop-expand'; fields: EditorAssetGenerationInputField[]; references: EditorAssetGenerationInputReference[]; [key: string]: unknown; From 21b05f9446f67167b881226fc4ea07d07217cc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 13:19:47 +0800 Subject: [PATCH 11/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=85=8D=E6=96=B9=E6=81=A2=E5=A4=8D=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容生成配方未来字段与历史完美像素快照 补齐参数回退、旧版恢复告警和本地参考图上传 收紧生成任务幂等比较与裁扩来源归属 同步画布编辑器设计文档和回归测试 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- .../api-server/src/editor_generation_queue.rs | 48 ++++++++++ .../crates/api-server/src/editor_project.rs | 70 ++++++++++++-- .../ImageCanvasEditorModel.test.ts | 69 +++++++++++++ .../ImageCanvasExportModel.test.ts | 23 +++++ .../image-editor/ImageCanvasExportModel.ts | 2 +- .../ImageCanvasGenerationInputsModel.ts | 96 +++++++++++++------ .../ImageCanvasGenerationModel.test.ts | 49 +++++++++- .../ImageCanvasGenerationModel.ts | 22 ++++- ...anvasGenerationSubmissionWorkflow.test.tsx | 40 ++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 18 ++++ .../useImageCanvasGenerationWorkflow.test.tsx | 25 ++++- .../useImageCanvasGenerationWorkflow.ts | 2 +- 13 files changed, 423 insertions(+), 45 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 075c2e2cf..7da61131c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,6 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。可重新选择的引用缺失时打开面板、留空对应槽位并提示重新选择,提交门禁继续校验必填槽位;角色动作、生成型图片编辑和 UI 素材提取等必须依赖原 `source` 图层才能构造面板的动作,在 source 缺失时拒绝改造并提示原因。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -34,7 +35,7 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 -- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 `title/label/refType/refId` 都不可信;站内已迁移生成队列只允许在入队 payload 中保留按位置对应的非 provenance 槽位 `id`,直接创建资源 / 素材及 External API 仍删除整段客户端 references。生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建 `id + title/label/refType/refId` 后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。滚动部署的队列幂等比较会忽略这些冗余展示槽位,实际 `sourceImageSrc/referenceImageSrcs` 或其它参数变化仍必须冲突。 +- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 `title/label/refType/refId` 都不可信;站内已迁移生成队列只允许在入队 payload 中保留按位置对应的非 provenance 槽位 `id`,直接创建资源 / 素材及 External API 仍删除整段客户端 references。生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建 `id + title/label/refType/refId` 后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。滚动部署的队列幂等比较只在新旧 payload 恰有一侧缺少 `references` 时忽略该字段;两侧都存在时槽位 ID / 数量 / 顺序必须严格一致,两侧都缺少时其它差异同样不得被归一,实际 `sourceImageSrc/referenceImageSrcs` 或其它参数变化始终必须冲突。 - 普通图片生成最多选择 5 张参考图;带主图 / 规范图的图片修改、图标素材和 UI 素材提取需要从打开面板起预留这 1 张主引用,再与 provider 容量取最小值(GPT-image-2 总计 5 张,nanobanana2 总计 14 张)。画布选择、上传和最终提交都必须阻止第 N+1 张进入请求;并发上传要把在途批次计入容量,并在创建项目资源 / 账号素材前按最新模型复验。reservation 必须绑定发起上传的 dialog / 快速编辑 / UI 提取上下文;任一参考图上传批次在途时,所有图片模型切换、从画布添加主图 / 规范图 / 参考图、提交生成、删除 / 剪切关联源图、删除其来源素材以及生成面板切换 / 关闭都要明确拒绝,批次持久化前后还要复验上下文未变化。批次部分失败时要保留并挂接已经成功持久化的引用,不能因其中一项失败而丢弃整批成功项;完成或失败并释放 reservation 后才允许继续操作。模型降容或后补主图若在操作当下已经超限,应保留原模型 / 原参考图并明确提示用户先删除,不得用 `slice` 静默丢弃;旧 dialog 或直接 API 请求由后端返回明确超限错误。 - 主站编辑器生成 POST 在浏览器端不自动重试;队列模式仍按同一 `x-request-id` 幂等重放,External v1 使用显式 `Idempotency-Key`。inline 模式没有结果级幂等时,不得因 408 / 429 / 5xx 或传输异常自动再次调用 provider。 @@ -53,6 +54,7 @@ - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 +- 裁扩创建资源时,未携带 `sourceResourceId` 仍允许保存无正式来源引用的确定性结果;一旦携带该 ID,api-server 必须确认它属于当前 owner 和当前项目,否则以 `400` 拒绝,禁止同时持久化悬空 / 越权 `sourceResourceId` 和空来源配方。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 - **完美像素的优先级判据(约束实现、评审与复查)**:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整,重做一次的代价接近于零。因此凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**;只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 - **明确禁止的补救方向**:不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。历史上引入过的同类封锁(未收口 operation 不可删除、随源图层清理豁免)已被逐条作废,不得以任何理由重新引入。既有的 `existingOperation` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 1dc135a83..eeac069ec 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -216,6 +216,10 @@ fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) - generation_inputs.remove("references").is_some() } +fn generation_input_references(value: &Value) -> Option<&Value> { + value.pointer("/generationInputs/references") +} + fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool { matches!( job_kind, @@ -248,6 +252,11 @@ fn editor_generation_request_payloads_match( if !job_kind_migrated_away_from_client_generation_references(job_kind) { return false; } + if generation_input_references(&existing).is_some() + == generation_input_references(&requested).is_some() + { + return false; + } strip_untrusted_generation_input_references_from_payload(&mut existing); strip_untrusted_generation_input_references_from_payload(&mut requested); existing == requested @@ -643,6 +652,45 @@ mod tests { ); } + #[test] + fn replayed_migrated_jobs_with_different_safe_slots_conflict() { + let mut job = queue_job_fixture("queued", None); + job.owner_user_id = "user-1".to_string(); + job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(); + job.request_payload_json = serde_json::to_string(&json!({ + "prompt": "same", + "referenceImageSrcs": ["asset-1"], + "generationInputs": { + "version": 2, + "action": "image.generate", + "fields": [], + "references": [{"id": "source"}] + } + })) + .expect("existing payload should serialize"); + let requested = serde_json::to_string(&json!({ + "prompt": "same", + "referenceImageSrcs": ["asset-1"], + "generationInputs": { + "version": 2, + "action": "image.generate", + "fields": [], + "references": [{"id": "specReference"}] + } + })) + .expect("requested payload should serialize"); + + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + requested.as_str(), + "幂等冲突", + ) + .expect_err("different migrated safe slots must conflict"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + #[test] fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() { let mut job = queue_job_fixture("queued", None); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5c5409849..5c20e8a2b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2598,6 +2598,27 @@ fn build_editor_crop_expand_generation_inputs( build_editor_deterministic_generation_inputs("image.crop-expand", reference) } +fn require_editor_crop_expand_source<'a>( + resources: &'a [EditorProjectResourceRecord], + owner_user_id: &str, + project_id: &str, + source_resource_id: &str, +) -> Result<&'a EditorProjectResourceRecord, AppError> { + resources + .iter() + .find(|resource| { + resource.resource_id == source_resource_id + && resource.project_id == project_id + && resource.owner_user_id == owner_user_id + }) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project-resource", + "message": "裁切扩图来源资源不存在或不属于当前项目", + })) + }) +} + async fn resolve_editor_project_resource_generation_inputs( state: &AppState, owner_user_id: &str, @@ -2626,12 +2647,13 @@ async fn resolve_editor_project_resource_generation_inputs( }) .await .map_err(map_editor_project_error)?; - let source = project.resources.iter().find(|resource| { - resource.resource_id == source_resource_id - && resource.project_id == project_id - && resource.owner_user_id == owner_user_id - }); - Ok(build_editor_crop_expand_generation_inputs(source)) + let source = require_editor_crop_expand_source( + project.resources.as_slice(), + owner_user_id, + project_id, + source_resource_id, + )?; + Ok(build_editor_crop_expand_generation_inputs(Some(source))) } fn editor_generated_image_storage_profile( @@ -13013,6 +13035,42 @@ mod tests { ); } + #[test] + fn crop_expand_source_must_match_owner_project_and_resource() { + let resource = test_editor_project_resource_record( + "resource-crop-source", + "project-1", + "generated-character-drafts/editor/crop-source.png", + None, + None, + ); + let resources = vec![resource]; + + assert!( + require_editor_crop_expand_source( + resources.as_slice(), + "user-1", + "project-1", + "resource-crop-source", + ) + .is_ok() + ); + for (owner_user_id, project_id, source_resource_id) in [ + ("user-2", "project-1", "resource-crop-source"), + ("user-1", "project-2", "resource-crop-source"), + ("user-1", "project-1", "resource-missing"), + ] { + let error = require_editor_crop_expand_source( + resources.as_slice(), + owner_user_id, + project_id, + source_resource_id, + ) + .expect_err("mismatched crop-expand source must fail closed"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + } + #[test] fn generation_reference_provenance_comes_from_matching_owner_records() { let resource = test_editor_project_resource_record( diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 9d344345f..4bbbba4b1 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -119,6 +119,47 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('ignores unknown nested V2 properties during non-strict resource hydration', () => { + expect( + generationInputsOrNull({ + version: 2, + action: 'image.generate', + futureTopLevelProperty: true, + fields: [ + { + id: 'prompt', + title: '生成提示词', + value: '未来配方', + futureFieldProperty: 'ignored', + }, + ], + references: [ + { + id: 'reference', + title: '参考图', + label: '未来素材', + refType: 'asset', + refId: 'asset-future', + futureReferenceProperty: 1, + }, + ], + }), + ).toEqual({ + version: 2, + action: 'image.generate', + fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }], + references: [ + { + id: 'reference', + title: '参考图', + label: '未来素材', + refType: 'asset', + refId: 'asset-future', + }, + ], + }); + }); + it('fails malformed V2 closed while preserving legacy hydration', () => { expect( generationInputsOrNull({ @@ -1491,6 +1532,34 @@ describe('ImageCanvasEditorModel', () => { ); }); + it('keeps legacy perfect-pixel snapshots with empty string metadata recoverable', () => { + const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata'; + const operation = buildPerfectPixelOperation(dialogId); + operation.request.generationInputs = { + fields: [{ title: '', value: '' }], + references: [ + { + title: '', + label: '', + refType: 'asset', + refId: '', + }, + ], + }; + + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperation: operation, + }); + + expect(hydrated?.perfectPixelOperation).toEqual(operation); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + }); + it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => { // 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在 // 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状, diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index e0702612f..70efee97e 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -171,6 +171,29 @@ describe('ImageCanvasExportModel', () => { }); }); + it('uses the reference fallback label when export metadata is blank', () => { + const metadata = buildLayerExportMetadata( + buildLayer({ + generationInputs: { + fields: [], + references: [ + { + title: '参考图', + label: ' ', + refType: 'asset', + refId: 'asset-reference', + }, + ], + }, + }), + 'images/001-layer.png', + ); + + expect(metadata.visible.generationInputs?.references[0]?.label).toBe( + '参考素材', + ); + }); + it('filters built-in prompts from exported visible generation inputs', () => { const metadata = buildLayerExportMetadata( buildLayer({ diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 6ec567bf1..d82d968d7 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -546,7 +546,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) { const references = layer.generationInputs?.references.map((reference) => ({ title: reference.title, - label: reference.label ?? '参考素材', + label: reference.label?.trim() || '参考素材', refType: reference.refType, refId: reference.refId, })) ?? []; diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index 0e729d260..a0c3f2ab6 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -24,21 +24,20 @@ export const CANVAS_GENERATION_ACTIONS = [ 'image.crop-expand', ] as const satisfies readonly CanvasGenerationAction[]; -export const REMIXABLE_CANVAS_GENERATION_ACTIONS = - new Set([ - 'image.generate', - 'spec.generate', - 'character.generate', - 'icon.generate', - 'ui-design.generate', - 'publication.generate', - 'video.generate', - 'audio.sound-effect.generate', - 'audio.background-music.generate', - 'character-animation.generate', - 'image.edit', - 'ui-design.extract-assets', - ]); +const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ + 'image.generate', + 'spec.generate', + 'character.generate', + 'icon.generate', + 'ui-design.generate', + 'publication.generate', + 'video.generate', + 'audio.sound-effect.generate', + 'audio.background-music.generate', + 'character-animation.generate', + 'image.edit', + 'ui-design.extract-assets', +]); const CANVAS_GENERATION_ACTION_SET = new Set(CANVAS_GENERATION_ACTIONS); const V2_GENERATION_INPUT_KEYS = new Set([ @@ -95,8 +94,9 @@ export function isCanvasGenerationAction( return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value); } -export function isNormalizedCanvasGenerationInputsStructure( +function hasNormalizedCanvasGenerationInputsStructure( value: unknown, + strictWhitelist: boolean, ): value is CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; @@ -114,7 +114,7 @@ export function isNormalizedCanvasGenerationInputsStructure( value.fields.every( (field) => isRecord(field) && - hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) && + (!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) && isPresentString(field.id) && typeof field.title === 'string' && isGenerationInputValue(field.value), @@ -122,7 +122,8 @@ export function isNormalizedCanvasGenerationInputsStructure( value.references.every( (reference) => isRecord(reference) && - hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) && + (!strictWhitelist || + hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) && isPresentString(reference.id) && typeof reference.title === 'string' && (reference.label === undefined || @@ -134,21 +135,46 @@ export function isNormalizedCanvasGenerationInputsStructure( ); } +export function isNormalizedCanvasGenerationInputsStructure( + value: unknown, +): value is CanvasGenerationInputs & { + version: 2; + action: CanvasGenerationAction; +} { + return hasNormalizedCanvasGenerationInputsStructure(value, true); +} + +export function isRemixableCanvasGenerationAction( + action: CanvasGenerationAction, +) { + return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action); +} + function cloneV2GenerationInputs( value: Record, strictWhitelist: boolean, ): CanvasGenerationInputs | null { if ( (strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) || - !isNormalizedCanvasGenerationInputsStructure(value) + !hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist) ) { return null; } return { version: 2, action: value.action, - fields: value.fields.map((field) => ({ ...field })), - references: value.references.map((reference) => ({ ...reference })), + fields: value.fields.map((field) => ({ + id: field.id, + title: field.title, + value: field.value, + })), + references: value.references.map((reference) => ({ + id: reference.id, + title: reference.title, + ...(reference.label === undefined ? {} : { label: reference.label }), + refType: reference.refType, + refId: reference.refId, + })), }; } @@ -170,32 +196,42 @@ function hydrateLegacyGenerationInputs( if ( !isRecord(field) || (strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) || - !isPresentString(field.title) || - !isPresentString(field.value) + (strictWhitelist + ? typeof field.title !== 'string' + : !isPresentString(field.title)) || + (strictWhitelist + ? typeof field.value !== 'string' + : !isPresentString(field.value)) ) { return []; } - return [{ title: field.title, value: field.value }]; + return [{ title: field.title as string, value: field.value as string }]; }); const references = rawReferences.flatMap((reference) => { if ( !isRecord(reference) || (strictWhitelist && !hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) || - !isPresentString(reference.title) || - !isPresentString(reference.label) || + (strictWhitelist + ? typeof reference.title !== 'string' + : !isPresentString(reference.title)) || + (strictWhitelist + ? typeof reference.label !== 'string' + : !isPresentString(reference.label)) || (reference.refType !== 'project-resource' && reference.refType !== 'asset') || - !isPresentString(reference.refId) + (strictWhitelist + ? typeof reference.refId !== 'string' + : !isPresentString(reference.refId)) ) { return []; } return [ { - title: reference.title, - label: reference.label, + title: reference.title as string, + label: reference.label as string, refType: reference.refType, - refId: reference.refId, + refId: reference.refId as string, } satisfies CanvasGenerationInputReference, ]; }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 86293356f..586b1de13 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -591,7 +591,10 @@ describe('ImageCanvasGenerationModel', () => { ], references: [], }); - expect(alias).toMatchObject({ ok: true, warnings: [] }); + expect(alias).toMatchObject({ + ok: true, + warnings: [expect.objectContaining({ fieldIds: ['style'] })], + }); expect( alias.ok ? alias.inputs.fields.find((field) => field.id === 'model')?.value @@ -706,6 +709,50 @@ describe('ImageCanvasGenerationModel', () => { ); }); + it('materializes and warns about missing action parameters', () => { + const decoded = decodeCanvasGenerationInputs({ + version: 2, + action: 'video.generate', + fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }], + references: [], + }); + + expect(decoded).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }), + expect.objectContaining({ id: 'durationSeconds', value: 4 }), + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ fieldIds: ['model'] }), + expect.objectContaining({ fieldIds: ['durationSeconds'] }), + ]), + }); + + const animation = decodeCanvasGenerationInputs({ + version: 2, + action: 'character-animation.generate', + fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], + references: [], + }); + expect(animation).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + expect.objectContaining({ id: 'frameCount', value: 32 }), + expect.objectContaining({ id: 'durationSeconds', value: 4 }), + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ + fieldIds: ['frameCount', 'durationSeconds'], + }), + ]), + }); + }); + it('falls invalid sound and character animation parameters back to complete defaults', () => { const sound = decodeCanvasGenerationInputs({ version: 2, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index ba2a17356..301a5a953 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -25,7 +25,7 @@ import type { } from './ImageCanvasEditorTypes'; import { isNormalizedCanvasGenerationInputsStructure, - REMIXABLE_CANVAS_GENERATION_ACTIONS, + isRemixableCanvasGenerationAction, } from './ImageCanvasGenerationInputsModel'; import { getPublicationMaterialsWorkflow, @@ -1259,9 +1259,14 @@ export function decodeCanvasGenerationInputs( title: string, fallback: T, resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined, + materializeMissing = true, ): T => { const index = findFieldIndex(id); if (index < 0) { + if (materializeMissing) { + setFieldValue(id, title, fallback); + addFallbackWarning([id]); + } return fallback; } const resolved = resolve(fields[index]!.value); @@ -1274,8 +1279,12 @@ export function decodeCanvasGenerationInputs( return resolved; }; const normalizeStringField = (id: string, title: string, fallback = '') => - normalizeExistingField(id, title, fallback, (fieldValue) => - typeof fieldValue === 'string' ? fieldValue : undefined, + normalizeExistingField( + id, + title, + fallback, + (fieldValue) => (typeof fieldValue === 'string' ? fieldValue : undefined), + false, ); const normalizeStringOption = ( id: string, @@ -1492,6 +1501,11 @@ export function decodeCanvasGenerationInputs( ); addFallbackWarning(['frameCount', 'durationSeconds']); } + } else { + const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0]; + setFieldValue('frameCount', '帧数', defaultDuration.frameCount); + setFieldValue('durationSeconds', '时长', defaultDuration.durationSeconds); + addFallbackWarning(['frameCount', 'durationSeconds']); } } @@ -1553,7 +1567,7 @@ export function canOpenRedrawPanel( } const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs); if (decodedInputs.ok) { - if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { + if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) { return false; } if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 282077556..66a867e31 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1065,6 +1065,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('quick-edit').textContent).toBe('-'); }); + it('uploads local quick-edit references before submitting the edit request', async () => { + editEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '参考局部素材修图' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(editEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + referenceImageSrcs: [ + 'generated-character-drafts/editor/generation-references/reference.png', + ], + }), + ); + }); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + }); + it('refreshes the wallet balance after inline image generation succeeds', async () => { const refreshWalletBalance = vi.fn(); editEditorImageMock.mockResolvedValueOnce( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index b28f24b38..3013f1ab0 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -2062,6 +2062,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, ); } + const normalizedReferenceImageSrcs = await Promise.all( + (submissionPlan.editInput.referenceImageSrcs ?? []).map( + (referenceImageSrc, index) => + resolveEditorGenerationMediaReference( + dialog.generationReferences?.[index] + ? { + ...dialog.generationReferences[index], + src: referenceImageSrc, + } + : { src: referenceImageSrc }, + 'image', + projectId, + ), + ), + ); const quickEditPlaceholderSize = getCanvasCompletionPlaceholderSizeFromPlan({ sourceLayer: submissionPlan.sourceLayer, @@ -2072,6 +2087,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ prompt: submissionPlan.normalizedPrompt, sourceImageSrc: referenceImageSrc, ...submissionPlan.editInput, + ...(normalizedReferenceImageSrcs.length + ? { referenceImageSrcs: normalizedReferenceImageSrcs } + : {}), projectId, assetKind: submissionPlan.result.assetKind, generationInputs: submissionPlan.result.generationInputs, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 978b4f4f1..b58b46848 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -4938,7 +4938,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ); expect(screen.getByTestId('generation-references').textContent).toBe(''); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。', + `${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`, ); }); @@ -5001,6 +5001,29 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('warns when a legacy recipe is restored successfully', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).not.toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '已按旧版数据恢复,部分参数可能使用当前默认值。', + ); + }); + it('does not legacy-fallback when a V2 required source is unavailable', () => { render( Date: Fri, 7 Aug 2026 13:58:26 +0800 Subject: [PATCH 12/25] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=98=BE=E9=9A=90=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按生成 action 能力决定改造按钮显隐 保留支持 action 的错误入口并在点击后提示原因 补充损坏配方与缺失来源回归测试 同步画布编辑器设计文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasGenerationModel.test.ts | 16 ++++++- .../ImageCanvasGenerationModel.ts | 47 ++++--------------- ...ageCanvasSelectedLayerToolbarView.test.tsx | 22 +++------ .../ImageCanvasSelectedLayerToolbarView.tsx | 7 +-- .../image-editor/ImageCanvasStageView.tsx | 1 - .../useImageCanvasGenerationWorkflow.test.tsx | 31 ++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 12 +++++ 8 files changed, 75 insertions(+), 63 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7da61131c..4cb66c3d9 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。可重新选择的引用缺失时打开面板、留空对应槽位并提示重新选择,提交门禁继续校验必填槽位;角色动作、生成型图片编辑和 UI 素材提取等必须依赖原 `source` 图层才能构造面板的动作,在 source 缺失时拒绝改造并提示原因。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。可重新选择的引用缺失时打开面板、留空对应槽位并提示重新选择,提交门禁继续校验必填槽位;角色动作、生成型图片编辑和 UI 素材提取等必须依赖原 `source` 图层才能构造面板的动作,在 source 缺失时拒绝改造并提示原因。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 586b1de13..90f640ce5 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -494,7 +494,7 @@ describe('ImageCanvasGenerationModel', () => { sourceType: 'uploaded', generationInputs: buildImageGenerationInputs('上传文件'), }), - ).toBe(false); + ).toBe(true); for (const action of [ 'image.perfect-pixel', 'spritesheet.split', @@ -556,6 +556,20 @@ describe('ImageCanvasGenerationModel', () => { }, }), ).not.toThrow(); + expect( + canOpenRedrawPanel({ + ...buildSourceLayer(), + sourceType: 'generated', + generationInputs: { + version: 2, + action: 'image.generate', + fields: [ + { id: 42, title: '生成提示词', value: '损坏数据' } as never, + ], + references: [], + }, + }), + ).toBe(true); }); it('stores the video web-search setting in reusable metadata', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 301a5a953..1cd4ebd25 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1544,48 +1544,17 @@ const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set( ].map((title) => title.toLowerCase()), ); -// 中文注释:只拒绝 source 丢失后无法由用户手动补齐的动作。image.edit 的快速编辑 -// 面板不能替换编辑目标,角色动作面板不能重新选择原角色图层,UI 素材 / spritesheet -// 提取也不能替换原 UI 设计图;缺少这些必需 source 时,当前 UI 根本无法构造并提交 -// 可执行请求,所以即使 prompt、模型、尺寸等字段仍可恢复也必须拒绝改造。其它拥有 -// 手动补选入口的 reference 缺失时不拒绝,继续按 best-effort 恢复其余参数。 -const REQUIRED_SOURCE_GENERATION_ACTIONS = new Set([ - 'image.edit', - 'character-animation.generate', - 'ui-design.extract-assets', -]); - -export function canOpenRedrawPanel( - layer: CanvasLayer, - availableLayers: CanvasLayer[] = [], -) { - if ( - layer.sourceType === 'uploaded' || - layer.taskId?.startsWith('pixel-art-snap-') - ) { +export function canOpenRedrawPanel(layer: CanvasLayer) { + // 中文注释:按钮显隐只表达 action capability。参数损坏或必需来源丢失属于 + // 点击后的恢复错误,不能在这里静默吞掉入口。 + if (layer.taskId?.startsWith('pixel-art-snap-')) { return false; } - const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs); - if (decodedInputs.ok) { - if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) { - return false; - } - if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { - return true; - } - const sourceReference = decodedInputs.inputs.references.find( - (reference) => reference.id === 'source', - ); - if (!sourceReference) { - return false; - } - return availableLayers.some((candidate) => - sourceReference.refType === 'project-resource' - ? candidate.resourceId === sourceReference.refId - : candidate.sourceAssetId === sourceReference.refId, - ); + const action = layer.generationInputs?.action; + if (action !== undefined) { + return isRemixableCanvasGenerationAction(action); } - if (layer.generationInputs?.version === 2) { + if (layer.sourceType === 'uploaded' || layer.generationInputs?.version === 2) { return false; } return Boolean( diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 11a574730..6ca7d5eca 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -44,7 +44,6 @@ function renderSelectedToolbar( const selectedLayer = createLayer(); const props: Parameters[0] = { selectedLayer, - availableLayers: [selectedLayer], selectedToolbarStyle: { left: 12, top: 24 }, onOpenQuickEditPanel: vi.fn(), onOpenRedrawPanel: vi.fn(), @@ -454,14 +453,10 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { within(actionToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['快速编辑', '下载按钮']); + ).toEqual(['快速编辑', '改造', '下载按钮']); }); - it('shows redraw only when a required V2 source is available', () => { - const sourceLayer = createLayer({ - id: 'source-layer', - resourceId: 'source-resource', - }); + it('keeps redraw visible when a supported action has lost its required source', () => { const generatedLayer = createLayer({ id: 'animation-layer', resourceId: 'animation-resource', @@ -477,18 +472,17 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { title: '原角色图', label: '角色', refType: 'project-resource', - refId: 'source-resource', + refId: 'missing-source-resource', }, ], }, }); - renderSelectedToolbar({ - selectedLayer: generatedLayer, - availableLayers: [generatedLayer, sourceLayer], - }); + const props = renderSelectedToolbar({ selectedLayer: generatedLayer }); - expect(screen.getByRole('button', { name: '改造' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '改造' })); + + expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(generatedLayer); }); it('renders character animation only for character layers', () => { @@ -542,7 +536,6 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { const { rerender } = render( { rerender( void; onOpenRedrawPanel: (layer: CanvasLayer) => void; @@ -40,7 +39,6 @@ type ImageCanvasSelectedLayerToolbarViewProps = { export function ImageCanvasSelectedLayerToolbarView({ selectedLayer, - availableLayers, selectedToolbarStyle, onOpenQuickEditPanel, onOpenRedrawPanel, @@ -59,10 +57,7 @@ export function ImageCanvasSelectedLayerToolbarView({ if (!selectedLayer || !selectedToolbarStyle) { return null; } - const canRedraw = canOpenRedrawPanel( - selectedLayer, - availableLayers, - ); + const canRedraw = canOpenRedrawPanel(selectedLayer); if (selectedLayer.mediaType === 'audio') { return ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index d4513fc3a..eac4df1a2 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -404,7 +404,6 @@ export function ImageCanvasStageView({ /> { }); }); + it('reports malformed metadata after clicking a supported redraw action', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '保存的生成参数版本不受支持或数据损坏,无法改造。', + ); + }); + it('warns when a legacy recipe is restored successfully', () => { render( Date: Fri, 7 Aug 2026 14:34:24 +0800 Subject: [PATCH 13/25] =?UTF-8?q?=E6=94=B6=E7=AA=84=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E6=9D=A5=E6=BA=90=E6=8B=92=E7=BB=9D=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可补选的必填与可选引用统一按最佳努力恢复 仅对三个无来源替换入口的 action 拒绝改造 细化快速编辑与来源绑定错误提示 补充权威设计说明和回归测试 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../useImageCanvasGenerationWorkflow.test.tsx | 45 +++++++++++++- .../useImageCanvasGenerationWorkflow.ts | 61 +++++++++++++++++-- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 4cb66c3d9..53da3ed80 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。可重新选择的引用缺失时打开面板、留空对应槽位并提示重新选择,提交门禁继续校验必填槽位;角色动作、生成型图片编辑和 UI 素材提取等必须依赖原 `source` 图层才能构造面板的动作,在 source 缺失时拒绝改造并提示原因。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有目标面板没有任何手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为三个例外:`image.edit` 的 source 是快速编辑 / 重绘的固定编辑目标,面板没有“换一张原图”控件;`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。三个例外缺少 source 时才允许显示“无法改造”,并分别说明不可替换原因;快速编辑使用“原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造”,该失败规则和文案不得用于任何可手动补选的槽位。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index b329483ca..690bc0063 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -4942,6 +4942,47 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('best-effort restores a required reference that the target panel can replace', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toContain( + 'character:idle:open', + ); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。', + ); + }); + it('warns and submits current defaults when V2 parameters are invalid', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '森林场景' }), @@ -5055,7 +5096,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('does not legacy-fallback when a V2 required source is unavailable', () => { + it('refuses redraw only when quick edit cannot replace its missing source', () => { render( { expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '原必需参考素材不在当前画布或来自面板上传,无法改造。', + '原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造。', ); }); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 7f2fe7f42..a78a69358 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -34,8 +34,10 @@ import { PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, } from './ImageCanvasEditorModel'; import type { + CanvasGenerationAction, CanvasGenerationDialogState, CanvasHistoryAction, + CanvasGenerationInputReference, CanvasLayer, CanvasTool, CanvasViewport, @@ -167,6 +169,44 @@ function getCanvasGenerationRedrawWarning({ return null; } +const NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION: Partial< + Record +> = { + 'image.edit': + '原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造。', + 'character-animation.generate': + '原角色图层不在当前画布,当前角色动作面板无法重新选择来源角色,无法改造。', + 'ui-design.extract-assets': + '原 UI 设计图不在当前画布,当前素材提取面板无法重新选择来源设计图,无法改造。', +}; + +function getMissingNonReplaceableSourceWarning({ + action, + references, + unavailableReferences, +}: { + action: CanvasGenerationAction; + references: CanvasGenerationInputReference[]; + unavailableReferences: CanvasGenerationInputReference[]; +}) { + const warning = NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION[action]; + if (!warning) { + return null; + } + const unavailableSourceKeys = new Set( + unavailableReferences + .filter((reference) => reference.id === 'source') + .map((reference) => `${reference.refType}:${reference.refId}`), + ); + const hasAvailableSource = references + .filter((reference) => reference.id === 'source') + .some( + (reference) => + !unavailableSourceKeys.has(`${reference.refType}:${reference.refId}`), + ); + return hasAvailableSource ? null : warning; +} + type CropExpandResizeDragState = { pointerId: number; handle: CropExpandResizeHandle; @@ -1819,6 +1859,17 @@ export function useImageCanvasGenerationWorkflow({ layers, ) : []; + const missingNonReplaceableSourceWarning = normalizedGenerationInputs + ? getMissingNonReplaceableSourceWarning({ + action: normalizedGenerationInputs.action, + references: normalizedGenerationInputs.references, + unavailableReferences, + }) + : null; + if (missingNonReplaceableSourceWarning) { + showGenerationWarning(missingNonReplaceableSourceWarning); + return; + } if ( isNormalizedGenerationInputs && normalizedGenerationInputs.action === 'ui-design.extract-assets' @@ -1835,7 +1886,9 @@ export function useImageCanvasGenerationWorkflow({ : null; if (!extractionSource) { showGenerationWarning( - '原 UI 设计图不在当前画布或来自面板上传,无法改造。', + NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION[ + 'ui-design.extract-assets' + ]!, ); return; } @@ -1908,11 +1961,7 @@ export function useImageCanvasGenerationWorkflow({ return; } if (isNormalizedGenerationInputs) { - showGenerationWarning( - unavailableReferences.length - ? '原必需参考素材不在当前画布或来自面板上传,无法改造。' - : '该生成配方当前无法恢复,无法改造。', - ); + showGenerationWarning('该生成配方当前无法恢复,无法改造。'); return; } if (sourceLayer.mediaType === 'audio') { From e088106541446ddc7219926243897ea172574cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 15:15:18 +0800 Subject: [PATCH 14/25] =?UTF-8?q?=E4=BB=8E=E6=94=B9=E9=80=A0=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=8E=92=E9=99=A4=E5=8E=9F=E4=BD=8D=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 image.edit 的可改造 action 能力 保留快速编辑入口并隐藏改造按钮 说明原位替换结果无法可靠恢复旧 source 同步权威设计文档和回归测试 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 ++- .../ImageCanvasGenerationInputsModel.ts | 5 ++- .../ImageCanvasGenerationModel.test.ts | 1 + ...ageCanvasSelectedLayerToolbarView.test.tsx | 25 ++++++++++++++ .../useImageCanvasGenerationWorkflow.test.tsx | 34 ------------------- .../useImageCanvasGenerationWorkflow.ts | 2 -- 6 files changed, 33 insertions(+), 38 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 53da3ed80..2b57c779c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,9 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有目标面板没有任何手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为三个例外:`image.edit` 的 source 是快速编辑 / 重绘的固定编辑目标,面板没有“换一张原图”控件;`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。三个例外缺少 source 时才允许显示“无法改造”,并分别说明不可替换原因;快速编辑使用“原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造”,该失败规则和文案不得用于任何可手动补选的槽位。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 +- V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外缺少 source 时才允许显示“无法改造”,并分别说明不可替换原因;该失败规则不得用于任何可手动补选的槽位。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index a0c3f2ab6..e0ade4e61 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -35,10 +35,13 @@ const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ 'audio.sound-effect.generate', 'audio.background-music.generate', 'character-animation.generate', - 'image.edit', 'ui-design.extract-assets', ]); +// 中文注释:image.edit 是图片快速编辑的原位替换记录,结果层就是被覆盖后的原层; +// 其 source 指向替换前的编辑目标,不能再作为“改造”新产物可靠恢复。继续修改应重新 +// 使用“快速编辑”,所以 image.edit 只属于已知 action,不属于可改造 action。 + const CANVAS_GENERATION_ACTION_SET = new Set(CANVAS_GENERATION_ACTIONS); const V2_GENERATION_INPUT_KEYS = new Set([ 'version', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 90f640ce5..5abda2360 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -496,6 +496,7 @@ describe('ImageCanvasGenerationModel', () => { }), ).toBe(true); for (const action of [ + 'image.edit', 'image.perfect-pixel', 'spritesheet.split', 'image.remove-background', diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 6ca7d5eca..1bb2e48d2 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -391,6 +391,31 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); }); + it('hides redraw for in-place quick-edit results', () => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + generationInputs: { + version: 2, + action: 'image.edit', + fields: [ + { id: 'prompt', title: '快速编辑提示词', value: '改成夜景' }, + ], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-before-in-place-edit', + }, + ], + }, + }), + }); + + expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + }); + it('removes raster edit actions for video and character animation layers', () => { renderSelectedToolbar({ selectedLayer: createLayer({ diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 690bc0063..3a5d761f2 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -5096,40 +5096,6 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('refuses redraw only when quick edit cannot replace its missing source', () => { - render( - , - ); - - fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); - - expect(screen.getByTestId('dialog').textContent).toBe('-'); - expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造。', - ); - }); - it('keeps character animation asset names synchronized with the dialog', () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index a78a69358..41f91af86 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -172,8 +172,6 @@ function getCanvasGenerationRedrawWarning({ const NON_REPLACEABLE_SOURCE_WARNING_BY_ACTION: Partial< Record > = { - 'image.edit': - '原编辑目标不在当前画布,当前快速编辑面板无法重新选择编辑目标,无法改造。', 'character-animation.generate': '原角色图层不在当前画布,当前角色动作面板无法重新选择来源角色,无法改造。', 'ui-design.extract-assets': From 2a19ee1e276ae0935c2208a8901f8592cf02256c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 17:30:14 +0800 Subject: [PATCH 15/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=94=9F=E6=88=90=E6=B5=81=E7=A8=8B=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整画布生成工作流类型导入顺序,通过 simple-import-sort 检查。 --- src/components/image-editor/useImageCanvasGenerationWorkflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 41f91af86..2bfe619d4 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -36,8 +36,8 @@ import { import type { CanvasGenerationAction, CanvasGenerationDialogState, - CanvasHistoryAction, CanvasGenerationInputReference, + CanvasHistoryAction, CanvasLayer, CanvasTool, CanvasViewport, From e99f3209f14c35e9729a936048b792204c37803b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 20:25:42 +0800 Subject: [PATCH 16/25] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E6=8D=9F=E5=9D=8F?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=85=8D=E6=96=B9=E9=99=8D=E7=BA=A7=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留生成配方 hydrate 来源状态 禁止损坏或未来版本配方回退 legacy 恢复 补齐资源快照与改造入口回归测试 记录已生成图层点击恢复浮层的产品待确认项 同步画布编辑器恢复契约文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasEditorModel.test.ts | 43 +++++++++++++++++++ .../image-editor/ImageCanvasEditorModel.ts | 27 +++++++++--- .../image-editor/ImageCanvasEditorTypes.ts | 9 ++++ .../image-editor/ImageCanvasEditorView.tsx | 17 ++++++++ .../ImageCanvasGenerationDialogModel.test.ts | 16 +++++++ .../ImageCanvasGenerationDialogModel.ts | 3 ++ .../ImageCanvasGenerationInputsModel.ts | 39 ++++++++++++++--- .../useImageCanvasGenerationWorkflow.test.tsx | 22 ++++++++++ .../useImageCanvasGenerationWorkflow.ts | 4 ++ 10 files changed, 169 insertions(+), 13 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 2b57c779c..b67df0e7f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -22,7 +22,7 @@ - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外缺少 source 时才允许显示“无法改造”,并分别说明不可替换原因;该失败规则不得用于任何可手动补选的槽位。 - 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 -- V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 +- V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 4bbbba4b1..dd2178822 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -187,6 +187,49 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('preserves an invalid versioned resource marker instead of falling back to layout legacy inputs', () => { + const hydrated = hydrateLayer( + { + layerId: 'layer-invalid-versioned-recipe', + resourceId: 'resource-invalid-versioned-recipe', + title: '损坏配方生成图', + src: '/layout/fallback.png', + x: 0, + y: 0, + width: 320, + height: 320, + originalWidth: 320, + originalHeight: 320, + zIndex: 1, + sourceType: 'generated', + generationInputs: { + fields: [{ title: '生成提示词', value: '不得当作旧版恢复' }], + references: [], + }, + }, + new Map([ + [ + 'resource-invalid-versioned-recipe', + { + imageSrc: '/resource/generated.png', + sourceType: 'generated', + generationInputs: { + version: 3, + action: 'image.generate', + fields: [], + references: [], + }, + }, + ], + ]), + ); + + expect(hydrated).toMatchObject({ + generationInputs: null, + generationInputsHydrationState: 'invalid-versioned', + }); + }); + it('keeps the resource default kind separate from a layer override', () => { const layer = { id: 'layer-shared', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 5165abaaa..7eaf500a9 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -21,7 +21,10 @@ import type { PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; -import { hydrateCanvasGenerationInputs } from './ImageCanvasGenerationInputsModel'; +import { + hydrateCanvasGenerationInputs, + hydrateCanvasGenerationInputsResult, +} from './ImageCanvasGenerationInputsModel'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { @@ -215,6 +218,7 @@ export function createLayerFromAsset( assetKindOverride: null, assetKind: asset.assetKind ?? assetKind, generationInputs: asset.generationInputs, + generationInputsHydrationState: asset.generationInputsHydrationState, imageSequenceFrames: asset.imageSequenceFrames, imageSequenceDurationMs: asset.imageSequenceDurationMs, } satisfies CanvasLayer; @@ -1431,6 +1435,19 @@ export function hydrateLayer( imageSequenceDurationMs = audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined; } + const resourceGenerationInputs = hydrateCanvasGenerationInputsResult( + resource?.generationInputs, + ); + const snapshotGenerationInputs = hydrateCanvasGenerationInputsResult( + snapshot.generationInputs, + ); + // 资源行是正式配方来源。它若明确是损坏/未来版本的 V2,不能因布局中的旧快照或 + // legacy fallback 被掩盖;只有资源值真正 absent 时才读取布局快照。 + const generationInputsHydration = isFormalCharacterAnimation + ? resourceGenerationInputs + : resourceGenerationInputs.state === 'absent' + ? snapshotGenerationInputs + : resourceGenerationInputs; return { id: layerId, resourceId, @@ -1501,10 +1518,8 @@ export function hydrateLayer( resourceAssetKind, assetKindOverride, assetKind, - generationInputs: isFormalCharacterAnimation - ? generationInputsOrNull(resource?.generationInputs) - : (generationInputsOrNull(resource?.generationInputs) ?? - generationInputsOrNull(snapshot.generationInputs)), + generationInputs: generationInputsHydration.inputs, + generationInputsHydrationState: generationInputsHydration.state, hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -1721,6 +1736,8 @@ export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { showcaseLikeCount: asset.showcaseLikeCount ?? null, assetKind, generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs).state, imageSequenceFrames: asset.imageSequenceFrames ?? undefined, imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, }; diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index a4b1b9276..c77f81abf 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -61,6 +61,7 @@ export type EditorAsset = { showcaseSubmitError?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; + generationInputsHydrationState?: CanvasGenerationInputsHydrationState; imageSequenceFrames?: EditorImageSequenceFrameResult[]; imageSequenceDurationMs?: number; uploadStatus?: 'uploading' | 'failed'; @@ -109,6 +110,13 @@ export type CanvasGenerationInputs = { references: CanvasGenerationInputReference[]; }; +// hydrate 后的客户端运行时来源状态;不属于 generationInputs 持久化契约。 +// 用于区分真正缺少配方的 legacy 数据与损坏/未来版本的版本化配方。 +export type CanvasGenerationInputsHydrationState = + | 'absent' + | 'valid' + | 'invalid-versioned'; + export type CanvasLayer = { id: string; resourceId: string; @@ -141,6 +149,7 @@ export type CanvasLayer = { assetKindOverride?: CanvasAssetKind | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; + generationInputsHydrationState?: CanvasGenerationInputsHydrationState; imageSequenceFrames?: EditorImageSequenceFrameResult[]; imageSequenceDurationMs?: number; previewVideoPath?: string | null; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f8c11f085..50ec6473d 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -43,6 +43,7 @@ import { resolveContextMenuPosition, resolveLayerResourceAssetKind, } from './ImageCanvasEditorModel'; +import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { AssetPointerDragState, @@ -306,6 +307,7 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { sourceAssetId: asset.id, assetKind: asset.assetKind ?? null, generationInputs: asset.generationInputs ?? null, + generationInputsHydrationState: asset.generationInputsHydrationState, imageSequenceFrames: asset.imageSequenceFrames, imageSequenceDurationMs: asset.imageSequenceDurationMs, }; @@ -1113,6 +1115,9 @@ export function ImageCanvasEditorView({ asset.imageSequenceDurationMs ?? undefined, assetKind: persistedAssetKind, generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs) + .state, }, ]); setAssetFolders((currentFolders) => @@ -1185,6 +1190,8 @@ export function ImageCanvasEditorView({ imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs).state, }, ]); setAssetFolders((currentFolders) => @@ -1854,6 +1861,12 @@ export function ImageCanvasEditorView({ }); const openLayerGenerationDialog = useCallback( (layer: CanvasLayer) => { + if (layer.generationInputsHydrationState === 'invalid-versioned') { + showGenerationWarning( + '保存的生成参数版本不受支持或数据损坏,无法改造。', + ); + return false; + } const existingDialog = [...canvasGenerationDialogsRef.current] .reverse() .find((dialog) => dialog.generatedLayerId === layer.id); @@ -1887,10 +1900,14 @@ export function ImageCanvasEditorView({ openCanvasGenerationDialog, setSelectedLayerId, setSelectedLayerIds, + showGenerationWarning, ], ); const openGeneratedLayerGenerationDialog = useCallback( (layer: CanvasLayer) => { + // TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层? + // 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式 + // 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。 if (layer.sourceType !== 'generated') { return false; } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 984653653..10d4bc238 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -60,6 +60,22 @@ function createLayer(overrides: Partial = {}): CanvasLayer { } describe('ImageCanvasGenerationDialogModel', () => { + it('does not legacy-fallback from a hydrated invalid versioned recipe', () => { + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + generationInputs: null, + generationInputsHydrationState: 'invalid-versioned', + prompt: '不能误当旧版配方', + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toBeNull(); + }); + it('creates centered drafts for image and spec generation dialogs', () => { const canvasSize = { width: 1000, height: 800 }; const viewport = { x: 100, y: 40, scale: 2 }; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 80db386ce..d10912c12 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -1416,6 +1416,9 @@ export function createSameSourceGenerationDialogDraft({ CanvasGenerationDialogState, 'id' > | null { + if (sourceLayer.generationInputsHydrationState === 'invalid-versioned') { + return null; + } const normalizedDraft = createNormalizedGenerationDialogDraft({ sourceLayer, canvasSize, diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index e0ade4e61..7b3940c44 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -3,6 +3,7 @@ import type { CanvasGenerationInputField, CanvasGenerationInputReference, CanvasGenerationInputs, + CanvasGenerationInputsHydrationState, } from './ImageCanvasEditorTypes'; export const CANVAS_GENERATION_ACTIONS = [ @@ -250,15 +251,39 @@ function hydrateLegacyGenerationInputs( : null; } +export type CanvasGenerationInputsHydrationResult = { + state: CanvasGenerationInputsHydrationState; + inputs: CanvasGenerationInputs | null; +}; + +export function hydrateCanvasGenerationInputsResult( + value: unknown, + options: { strictWhitelist?: boolean } = {}, +): CanvasGenerationInputsHydrationResult { + if (!isRecord(value)) { + return { state: 'absent', inputs: null }; + } + if ('version' in value || 'action' in value) { + const inputs = cloneV2GenerationInputs( + value, + options.strictWhitelist === true, + ); + return inputs + ? { state: 'valid', inputs } + : { state: 'invalid-versioned', inputs: null }; + } + const inputs = hydrateLegacyGenerationInputs( + value, + options.strictWhitelist === true, + ); + return inputs + ? { state: 'valid', inputs } + : { state: 'absent', inputs: null }; +} + export function hydrateCanvasGenerationInputs( value: unknown, options: { strictWhitelist?: boolean } = {}, ): CanvasGenerationInputs | null { - if (!isRecord(value)) { - return null; - } - if ('version' in value || 'action' in value) { - return cloneV2GenerationInputs(value, options.strictWhitelist === true); - } - return hydrateLegacyGenerationInputs(value, options.strictWhitelist === true); + return hydrateCanvasGenerationInputsResult(value, options).inputs; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 3a5d761f2..d61e17f98 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -5073,6 +5073,28 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not legacy-fallback when hydration retained an invalid versioned recipe', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '保存的生成参数版本不受支持或数据损坏,无法改造。', + ); + }); + it('warns when a legacy recipe is restored successfully', () => { render( reference.id === 'source', ); + // 同一 resourceId/sourceAssetId 的画布副本引用同一份媒体数据;UI 素材提取的 + // 框选覆盖层落在任一副本均可,恢复不依赖原 placement 的坐标、尺寸或层级,故按 + // 当前图层顺序取首个匹配项即可,无需为此持久化 canvas layer identity。 const extractionSource = sourceReference ? layers.find((layer) => sourceReference.refType === 'project-resource' From 9a8564bc1d9d45c68a4e049f290ec50847845e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 21:01:46 +0800 Subject: [PATCH 17/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E5=88=87=E7=89=87=E9=85=8D=E6=96=B9=E8=AE=B0=E5=BD=95=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从预处理记录读取实际素材记录 --- server-rs/crates/api-server/src/editor_project.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index bf69c1a68..f0b668f20 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8158,7 +8158,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( generation_inputs: build_editor_deterministic_asset_generation_inputs( "spritesheet.split", "输入图集", - &spritesheet_record, + &spritesheet_record.record, ), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, @@ -9444,7 +9444,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( generation_inputs: build_editor_deterministic_asset_generation_inputs( "spritesheet.split", "输入图集", - &spritesheet_record, + &spritesheet_record.record, ), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, From 48b852ab04276b1a9a1a45409d488196c2ae412b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 22:04:37 +0800 Subject: [PATCH 18/25] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BC=96=E8=BE=91=E5=99=A8=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整 ImageCanvasEditorView 的生成输入模型导入顺序以通过 ESLint。 --- src/components/image-editor/ImageCanvasEditorView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 50ec6473d..29ba31400 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -43,7 +43,6 @@ import { resolveContextMenuPosition, resolveLayerResourceAssetKind, } from './ImageCanvasEditorModel'; -import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { AssetPointerDragState, @@ -64,6 +63,7 @@ import type { } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; +import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel'; import { applyEditorGenerationPricingConfig, isCanvasGenerationDialog, From d5f0f6de93764f16a48c11bf1e1d4408a0c01894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 11:15:18 +0800 Subject: [PATCH 19/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=BE=93=E5=85=A5=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 场景请求提交前将画布参数转换为字符串契约 保留素材结果中的原始画布参数类型 --- .../ImageCanvasGenerationSubmissionModel.ts | 26 ++++++++++++++++++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index be9e4fe2d..aaef7ff65 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -2,6 +2,7 @@ import { CUSTOM_EDITOR_SCENE_STYLE_PRESET, DEFAULT_EDITOR_SCENE_STYLE_PRESET, } from '../../../packages/shared/src/contracts/editorScene'; +import type { EditorSceneGenerationInputs } from '../../../packages/shared/src/contracts/editorScene'; import type { EditorBackgroundMusicGenerationInput, EditorCharacterAnimationGenerationInput, @@ -75,6 +76,29 @@ export const EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR = '请填写自定义画 export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80; +/** + * 场景生成接口的历史契约只接受字符串参数;画布配方则保留原生 + * string | number | boolean 值,以便在素材元数据中无损回放。 + */ +export function toEditorSceneGenerationInputs( + generationInputs: CanvasGenerationInputs, +): EditorSceneGenerationInputs { + return { + fields: generationInputs.fields.map(({ title, value }) => ({ + title, + value: String(value), + })), + references: generationInputs.references.map( + ({ title, label, refType, refId }) => ({ + title, + label: label ?? '', + refType, + refId, + }), + ), + }; +} + export function resolveGenerationAssetLabel( assetLabel: string | null | undefined, fallback: string, @@ -628,7 +652,7 @@ export function buildImageGenerationSubmissionPlan({ ), } : {}), - generationInputs, + generationInputs: toEditorSceneGenerationInputs(generationInputs), }, result: { assetKind: 'scene', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 74d098a65..c041e80c7 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -2373,7 +2373,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generateEditorScene({ ...sceneGenerationInput, projectId, - generationInputs: submissionPlan.result.generationInputs, + generationInputs: submissionPlan.input.generationInputs, assetFolderId, assetLabel: submissionPlan.result.title, ...(projectId && canvasCompletionPlaceholder From 179c4c36c5a5e1e35a7dda444a35e71f6aa683b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 11:23:50 +0800 Subject: [PATCH 20/25] =?UTF-8?q?=E6=95=B4=E7=90=86=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=A8=A1=E5=9E=8B=E5=AF=BC=E5=85=A5=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 ESLint 规则排序场景生成契约导入 --- .../image-editor/ImageCanvasGenerationSubmissionModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index aaef7ff65..45a26c017 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -1,8 +1,8 @@ +import type { EditorSceneGenerationInputs } from '../../../packages/shared/src/contracts/editorScene'; import { CUSTOM_EDITOR_SCENE_STYLE_PRESET, DEFAULT_EDITOR_SCENE_STYLE_PRESET, } from '../../../packages/shared/src/contracts/editorScene'; -import type { EditorSceneGenerationInputs } from '../../../packages/shared/src/contracts/editorScene'; import type { EditorBackgroundMusicGenerationInput, EditorCharacterAnimationGenerationInput, From 5c0e84c78a3d781729fe863c194acd076f88024b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 12:26:43 +0800 Subject: [PATCH 21/25] =?UTF-8?q?=E5=9C=BA=E6=99=AF=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=94=B9=E9=80=A0=E9=85=8D=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 scene.generate V2 配方、解码与场景面板恢复 服务端按结构化请求重建权威场景字段和参考图槽位 兼容旧场景画面内容字段并限制非场景改造入口 补充前后端测试与 assetKind 改造接入文档 --- .../shared-memory/decision-log.md | 8 + ...方案】图片画布游戏场景生成链路-2026-08-04.md | 19 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- .../crates/api-server/src/editor_project.rs | 69 +++-- .../image-editor/ImageCanvasEditorTypes.ts | 1 + .../ImageCanvasGenerationDialogModel.test.ts | 103 ++++++- .../ImageCanvasGenerationDialogModel.ts | 96 ++++-- .../ImageCanvasGenerationInputsModel.ts | 2 + .../ImageCanvasGenerationModel.test.ts | 286 ++++++++++-------- .../ImageCanvasGenerationModel.ts | 72 ++++- ...ageCanvasGenerationSubmissionModel.test.ts | 19 +- .../ImageCanvasGenerationSubmissionModel.ts | 5 + .../image-editor/editorProjectClient.ts | 1 + 14 files changed, 497 insertions(+), 190 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8ade79462..7c72d6c03 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6811,3 +6811,11 @@ - V2 水合统一按 `version/action/fields[].id/references[].id` 严格识别,保留有限数字、布尔值与无标签引用;出现 V2 标记但结构无效时不得降级 legacy。完美像素账本复用相同 V2 白名单,外层仍为 version 1,并继续原形接受旧 legacy 请求以维持 exact retry fingerprint。 - 安全边界:站内已迁移队列只保留客户端 references 的安全槽位 `id`,真实 `title/label/refType/refId` 全部按 owner-scoped 记录重建;External API 和直接不可信写入仍删除整段 references。队列幂等比较忽略该冗余展示槽位,但继续严格比较实际媒体来源与其它参数。 - 不改 SpacetimeDB schema、路由或 External OpenAPI;不回填历史记录。 + +## 2026-08-08 游戏场景接入 V2 改造配方 + +- 背景:`72f268e0` 新增 `assetKind = scene` 和场景专用生成入口,但场景生成输入仍是依赖中文标题的 legacy 快照,无法通过现役 V2 action allowlist 稳定恢复“改造”。 +- 决策:新增稳定 action `scene.generate`,字段 ID 固定为 `prompt/stylePreset/customStyle/model/aspectRatio/imageSize`,参考槽 ID 固定为 `reference`。新产物由服务端按结构化场景请求重建权威 V2 fields,并从真实 `referenceImageSrcs` 生成安全引用槽位,后续继续沿用 owner-scoped provenance 重建;前端 action decoder 恢复场景 composer,并让请求与持久快照共用规范化参数。 +- capability 边界:`assetKind` 只表示素材类别,不直接授予“改造”。`scene.generate` 显式进入已知与可改造 action allowlist;V2 上线前的场景仅在 `assetKind === scene` 且 legacy 字段包含“画面内容”时兼容恢复并告警,不把“画面内容”加入全局 legacy 标题路由,其他类别同名字段继续拒绝改造。 +- 影响范围:图片画布场景提交、生成输入解码、改造入口、场景 composer 恢复、api-server 场景配方重建与对应前后端测试;不修改 SpacetimeDB schema、migration、bindings、External v1 路由或 OpenAPI。 +- 关联文档:`docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 diff --git a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md index 539daf15c..41ee5b104 100644 --- a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md +++ b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md @@ -1,6 +1,6 @@ # 图片画布游戏场景生成链路 -更新时间:`2026-08-04` +更新时间:`2026-08-08` ## 1. 目标 @@ -151,7 +151,20 @@ prompt = 后端完整 Prompt - `prompt`:后端最终提交给图片 Provider 的完整场景 Prompt。 - `actual_prompt`:Provider 返回的 actual/revised Prompt。 - `asset_kind`:`scene`。 -- `generation_inputs_json`:画面内容、视觉风格、自定义画风和用户参考图引用。 +- `generation_inputs_json`:可恢复场景生成器的 V2 配方;`assetKind = scene` 只表示素材类别,不直接授予“改造”能力。 + +场景新产物统一保存以下稳定执行契约: + +```text +version = 2 +action = scene.generate +fields[].id = prompt | stylePreset | customStyle | model | aspectRatio | imageSize +references[].id = reference +``` + +`title` / `label` 仅用于中文展示,不参与路由或恢复。前端提交快照和再次提交使用同一组规范化后的模型、比例与清晰度;后端忽略客户端提供的执行字段和引用 provenance,按请求顶层结构化场景字段重建 `fields`,并按本次真实 `referenceImageSrcs` 只保留安全的 `references[id="reference"]` 槽位,再由现有 owner-scoped 引用重建链补齐 `refType/refId`。 + +`scene.generate` 必须同时进入已知 action 与可改造 action allowlist,并由 action 级 decoder 和场景 composer 恢复路径读取。`assetKind = scene` 不能替代上述 capability 判断。`72f268e0` 之后、V2 上线之前产生的场景数据只在 `assetKind === "scene"` 且 legacy `fields` 含展示标题“画面内容”时走既有 legacy 恢复和告警;“画面内容”不得加入全局 legacy 可复用标题列表,其他素材即使带同名字段也不能获得改造入口。 ## 8. 验收 @@ -164,3 +177,5 @@ prompt = 后端完整 Prompt - 展示价、入队价、实际扣费和资源成本一致。 - 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。 - 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。 +- 新场景产物保存 `scene.generate` V2 配方,点击“改造”后恢复画面内容、风格、自定义风格、模型、比例、清晰度和当前画布中仍存在的参考图,再次提交仍保存规范 V2。 +- V2 场景、窄化匹配的历史场景显示“改造”;非场景素材即使 legacy 字段标题为“画面内容”也不得显示“改造”。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4a9dbc9fe..b023c0d92 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -447,7 +447,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `;默认全量 dry-run,`--apply` 时每批先 dry-run,再用返回的 batch SHA-256 写入,四个 scope 完成后从头执行零匹配 / 零 blocker 复核。迁移完成后 api-server、后台、前端和外部 helper 只读取正式字段,不再包含 legacy fallback。新建动作资源 / 素材若在 `generationInputs` 提交旧运行字段,或正式帧包含 `frameIndex`,api-server 与 SpacetimeDB storage 均失败关闭;其它素材的任意生成输入不受动作专属门禁影响。 - 普通画布 layer 的持久化和前端响应不包含顶层 `mediaType`;渲染类型只由资源 / 素材 `assetKind` 在前端派生。`generationInputs.references[*].mediaType` 是生成参考输入契约,不属于画布 layer legacy 字段,迁移和响应清洗不得递归删除。 -## 新增编辑器 `assetKind` 接入清单(2026-08-03) +## 新增编辑器 `assetKind` 接入清单(2026-08-03,2026-08-08 增补) 新增前先确定稳定字符串、主媒体含义、专属元数据、来源血缘、下载产物、复用规则和公开边界。只修改新类别实际经过的链路,不机械改动全部结构。 @@ -463,6 +463,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 | 后台契约 | `server-rs/crates/shared-contracts/src/admin.rs`、`api-server/src/admin.rs` | 后台素材查询或精选审核需要展示该类别时返回完整正式字段,不能只返回封面或首帧 | | TypeScript 客户端类型 | `src/services/image-editor/editorProjectClient.ts`、`apps/admin-web/src/api/adminApiTypes.ts` | 接收后端 camelCase 字段,不定义第二套业务真相 | | 画布 layer / layout 映射 | `src/components/image-editor/` | 新类别能进入画布时贯通资源加载、素材点击 / 拖放、保存恢复、复制、撤销、删除和导出;layout 只保留恢复副本 | +| “改造” capability 与 V2 配方 | `src/components/image-editor/ImageCanvasGenerationInputsModel.ts`、`ImageCanvasGenerationModel.ts`、`ImageCanvasGenerationDialogModel.ts` 及生成提交链 | 新类别接入时必须显式判断是否允许恢复原生成器。可改造的生成类别必须定义稳定 V2 `action`、`fields[].id`、`references[].id`、action 级 decoder、面板恢复路径、改造 allowlist 和往返测试;确定性或不可重放类别即使保存 action 也不得进入改造 allowlist。`assetKind` 本身不能授予 capability;legacy 兼容只能按类别和旧配方特征窄化,并覆盖正反例,禁止把显示标题扩成全局路由键 | | 素材库与 renderer | `src/components/image-editor/` | 由 `assetKind` 派生图片、视频、音频或序列 renderer,并实现正确缩略图、预览和下载行为 | | 后台媒体 renderer | `apps/admin-web/src/components/AdminEditorAssetMedia.tsx` | 后台需要预览时复用现有组件;列表只加载最小媒体,弹窗再按需加载完整媒体 | | 精选 read model 与 renderer | `api-server`、`src/components/creation-home/` | 新类别允许公开时贯通正式字段、卡片、弹窗和损坏数据行为 | @@ -472,6 +473,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 其它注意事项: - `assetKind` 是数据库、Rust DTO 和对外 JSON 的唯一媒体类别真相;不要新增或返回并列的 `mediaType`。前端 renderer 可以保留内部派生类型,但不得回写后端。 +- `assetKind` 只描述素材类别,不代表生成配方可执行或允许“改造”;新增类别必须单独完成上表的 capability 决策和验收。 - 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。 - legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 61ec6f413..cb901c516 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -114,7 +114,7 @@ - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index fc3a7768f..0ab88adef 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2478,34 +2478,24 @@ pub async fn toggle_editor_showcase_asset_like( )) } -fn editor_scene_style_label(style_preset: &str) -> &'static str { - match style_preset.trim() { - "anime" => "日系动画", - "watercolor" => "清透水彩", - "flat" => "平面几何", - "stop-motion" => "定格模型", - "custom" => "自定义", - _ => "未知", - } -} - fn build_editor_scene_generation_inputs( payload: &EditorSceneGenerateRequest, generation_options: &EditorGenerationOptions, ) -> Value { + // 场景结构字段由专用请求和服务端标准化结果重建;客户端 generationInputs 既不能 + // 覆盖这些执行字段,也不能声明引用 provenance。这里只按实际参考图数量保留安全槽位, + // 后续统一 owner-scoped reference rebuild 会用正式资源 / 素材记录补齐身份。 let references = payload - .generation_inputs - .as_ref() - .and_then(|value| value.get("references")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + .reference_image_srcs + .iter() + .map(|_| json!({ "id": "reference" })) + .collect::>(); let mut fields = vec![ - json!({ "title": "画面内容", "value": payload.scene_content.trim() }), - json!({ "title": "视觉风格", "value": editor_scene_style_label(payload.style_preset.as_str()) }), - json!({ "title": "图片比例", "value": generation_options.aspect_ratio }), - json!({ "title": "清晰度", "value": generation_options.image_size }), - json!({ "title": "生成模型", "value": generation_options.model }), + json!({ "id": "prompt", "title": "画面内容", "value": payload.scene_content.trim() }), + json!({ "id": "stylePreset", "title": "视觉风格", "value": payload.style_preset.trim() }), + json!({ "id": "model", "title": "模型", "value": generation_options.model }), + json!({ "id": "aspectRatio", "title": "比例", "value": generation_options.aspect_ratio }), + json!({ "id": "imageSize", "title": "尺寸", "value": generation_options.image_size }), ]; if payload.style_preset.trim() == "custom" { if let Some(custom_style) = payload @@ -2514,10 +2504,16 @@ fn build_editor_scene_generation_inputs( .map(str::trim) .filter(|value| !value.is_empty()) { - fields.push(json!({ "title": "自定义画风", "value": custom_style })); + fields + .push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style })); } } - json!({ "fields": fields, "references": references }) + json!({ + "version": 2, + "action": "scene.generate", + "fields": fields, + "references": references, + }) } fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str { @@ -22141,11 +22137,12 @@ mod tests { } #[test] - fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() { + fn scene_generation_rebuilds_v2_recipe_and_only_keeps_reference_slots() { let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({ "sceneContent": " 雨夜中的海边车站 ", "stylePreset": "custom", "customStyle": "复古像素风", + "referenceImageSrcs": ["resource-scene-reference"], "generationInputs": { "fields": [ { "title": "伪造字段", "value": "不得透传" } @@ -22174,14 +22171,24 @@ mod tests { assert!(!serialized.contains("不得透传")); assert!(serialized.contains("雨夜中的海边车站")); assert!(serialized.contains("复古像素风")); + assert!(!serialized.contains("editor-resource-scene-reference")); + assert_eq!(generation_inputs["version"], json!(2)); + assert_eq!(generation_inputs["action"], json!("scene.generate")); + assert_eq!(generation_inputs["fields"][0]["id"], json!("prompt")); + assert_eq!( + generation_inputs["fields"][1], + json!({ "id": "stylePreset", "title": "视觉风格", "value": "custom" }) + ); + assert_eq!(generation_inputs["fields"][2]["id"], json!("model")); + assert_eq!(generation_inputs["fields"][3]["id"], json!("aspectRatio")); + assert_eq!(generation_inputs["fields"][4]["id"], json!("imageSize")); + assert_eq!( + generation_inputs["fields"][5], + json!({ "id": "customStyle", "title": "自定义画风", "value": "复古像素风" }) + ); assert_eq!( generation_inputs["references"], - json!([{ - "title": "参考图", - "label": "车站参考", - "refType": "project-resource", - "refId": "editor-resource-scene-reference" - }]) + json!([{ "id": "reference" }]) ); } } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index e9f6c1e60..44ab167c8 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -73,6 +73,7 @@ export type EditorAsset = { export type CanvasGenerationAction = | 'image.generate' + | 'scene.generate' | 'spec.generate' | 'character.generate' | 'icon.generate' diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index f5224cd24..74a57c51d 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -1054,7 +1054,11 @@ describe('ImageCanvasGenerationDialogModel', () => { version: 2, action: 'image.generate', fields: [ - { id: 'model', title: '任意标题', value: IMAGE_MODEL_GPT_IMAGE_2 }, + { + id: 'model', + title: '任意标题', + value: IMAGE_MODEL_GPT_IMAGE_2, + }, { id: 'aspectRatio', title: '任意标题', value: '2:3' }, { id: 'imageSize', title: '任意标题', value: '2K' }, ], @@ -1082,6 +1086,98 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('restores scene.generate V2 inputs into the scene composer', () => { + const sceneReference = createLayer({ + id: 'layer-scene-reference', + resourceId: 'resource-scene-reference', + title: '车站参考', + }); + + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + id: 'layer-scene-result', + resourceId: 'resource-scene-result', + sourceType: 'generated', + assetKind: 'scene', + generationInputs: { + version: 2, + action: 'scene.generate', + fields: [ + { id: 'prompt', title: '任意标题', value: '雨夜海边车站' }, + { id: 'stylePreset', title: '任意标题', value: 'custom' }, + { id: 'customStyle', title: '任意标题', value: '复古像素风' }, + { + id: 'model', + title: '任意标题', + value: IMAGE_MODEL_GPT_IMAGE_2, + }, + { id: 'aspectRatio', title: '任意标题', value: '16:9' }, + { id: 'imageSize', title: '任意标题', value: '2K' }, + ], + references: [ + { + id: 'reference', + title: '任意标题', + refType: 'project-resource', + refId: 'resource-scene-reference', + }, + ], + }, + }), + availableLayers: [sceneReference], + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toMatchObject({ + mode: 'scene', + prompt: '雨夜海边车站', + sceneStylePreset: 'custom', + sceneCustomStyle: '复古像素风', + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '16:9', + imageSize: '2K', + generationReferences: [ + expect.objectContaining({ + id: 'canvas-layer-scene-reference', + label: '车站参考', + }), + ], + }); + }); + + it('restores the narrowly scoped legacy scene recipe into the scene composer', () => { + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + assetKind: 'scene', + model: IMAGE_MODEL_GPT_IMAGE_2, + originalWidth: 2048, + originalHeight: 1152, + generationInputs: { + fields: [ + { title: '画面内容', value: '雨夜海边车站' }, + { title: '视觉风格', value: '清透水彩' }, + ], + references: [], + }, + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toMatchObject({ + mode: 'scene', + prompt: '雨夜海边车站', + sceneStylePreset: 'watercolor', + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '16:9', + imageSize: '2K', + }); + }); + it('restores quick-edit references and the persisted image model', () => { const editSource = createLayer({ id: 'layer-edit-source', @@ -1442,7 +1538,10 @@ describe('ImageCanvasGenerationDialogModel', () => { generationReferences: [{ label: '参考图' }], }); expect( - appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })), + appendGenerationReference( + sceneWithReference, + createLayer({ id: 'other' }), + ), ).toMatchObject({ generationReferences: [{ label: '参考图' }, { label: '源图' }], }); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index f07323042..f9e34af9e 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -765,7 +765,7 @@ export function createLayerGenerationDialogDraft({ : draft.prompt || sourceLayer.prompt?.trim() || '', imageModel: usesNormalizedInputs ? draft.imageModel - : sourceDialog?.imageModel ?? draft.imageModel, + : (sourceDialog?.imageModel ?? draft.imageModel), status: 'idle', composerOpen: true, generatedLayerId: sourceLayer.id, @@ -933,8 +933,37 @@ function createNormalizedGenerationDialogDraft({ ); } + if (inputs.action === 'scene.generate') { + return placeDraftBesideSourceLayer( + resizeGenerationPlaceholderToImageSelection({ + ...createSceneGenerationDialogDraft({ canvasSize, viewport }), + prompt, + sourceLayerId: sourceLayer.id, + sceneStylePreset: getNormalizedString( + sourceLayer, + 'stylePreset', + DEFAULT_EDITOR_SCENE_STYLE_PRESET, + ) as GenerateDialogState['sceneStylePreset'], + sceneCustomStyle: getNormalizedString(sourceLayer, 'customStyle'), + imageModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_IMAGE_MODEL, + ), + aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'), + imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), + generationReferences: references, + }), + sourceLayer, + ); + } + if (inputs.action === 'spec.generate') { - const specTypeValue = getNormalizedString(sourceLayer, 'specType', 'custom'); + const specTypeValue = getNormalizedString( + sourceLayer, + 'specType', + 'custom', + ); const specType: SpecGenerationType = specTypeValue === 'character' || specTypeValue === 'ui' || @@ -953,8 +982,11 @@ function createNormalizedGenerationDialogDraft({ customPrompt: getNormalizedString(sourceLayer, 'customPrompt'), }, specReference: - resolveNormalizedReferences(sourceLayer, 'reference', availableLayers)[0] ?? - null, + resolveNormalizedReferences( + sourceLayer, + 'reference', + availableLayers, + )[0] ?? null, }, sourceLayer, ); @@ -966,7 +998,11 @@ function createNormalizedGenerationDialogDraft({ ...createCharacterGenerationDialogDraft({ canvasSize, viewport, - imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + imageModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_IMAGE_MODEL, + ), }), prompt, sourceLayerId: sourceLayer.id, @@ -977,8 +1013,11 @@ function createNormalizedGenerationDialogDraft({ aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), characterSpecReference: - resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? - null, + resolveNormalizedReferences( + sourceLayer, + 'specReference', + availableLayers, + )[0] ?? null, characterReferences: references, }), sourceLayer, @@ -991,7 +1030,11 @@ function createNormalizedGenerationDialogDraft({ ...createIconGenerationDialogDraft({ canvasSize, viewport, - imageModel: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), + imageModel: getNormalizedString( + sourceLayer, + 'model', + DEFAULT_IMAGE_MODEL, + ), }), prompt, sourceLayerId: sourceLayer.id, @@ -1003,8 +1046,11 @@ function createNormalizedGenerationDialogDraft({ imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), iconDescriptions: prompt ? [prompt] : [], iconSpecReference: - resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? - null, + resolveNormalizedReferences( + sourceLayer, + 'specReference', + availableLayers, + )[0] ?? null, generationReferences: references, }), sourceLayer, @@ -1028,8 +1074,11 @@ function createNormalizedGenerationDialogDraft({ aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '16:9'), imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), uiDesignSpecReference: - resolveNormalizedReferences(sourceLayer, 'specReference', availableLayers)[0] ?? - null, + resolveNormalizedReferences( + sourceLayer, + 'specReference', + availableLayers, + )[0] ?? null, generationReferences: references, }), sourceLayer, @@ -1119,10 +1168,7 @@ function createNormalizedGenerationDialogDraft({ ), ], }) as Omit; - return placeDraftBesideSourceLayer( - videoDraft, - sourceLayer, - ); + return placeDraftBesideSourceLayer(videoDraft, sourceLayer); } if ( @@ -1133,12 +1179,19 @@ function createNormalizedGenerationDialogDraft({ { ...(inputs.action === 'audio.sound-effect.generate' ? createSoundEffectGenerationDialogDraft({ canvasSize, viewport }) - : createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport })), + : createBackgroundMusicGenerationDialogDraft({ + canvasSize, + viewport, + })), prompt, sourceLayerId: sourceLayer.id, soundModel: inputs.action === 'audio.sound-effect.generate' - ? (getNormalizedString(sourceLayer, 'model', DEFAULT_SOUND_EFFECT_MODEL) as GenerateDialogState['soundModel']) + ? (getNormalizedString( + sourceLayer, + 'model', + DEFAULT_SOUND_EFFECT_MODEL, + ) as GenerateDialogState['soundModel']) : undefined, soundDurationSeconds: inputs.action === 'audio.sound-effect.generate' @@ -1206,8 +1259,11 @@ function createNormalizedGenerationDialogDraft({ } if (inputs.action === 'image.edit') { - const source = - resolveNormalizedReferences(sourceLayer, 'source', availableLayers)[0]; + const source = resolveNormalizedReferences( + sourceLayer, + 'source', + availableLayers, + )[0]; const sourceLayerForEdit = source ? (availableLayers ?? []).find( (layer) => createCanvasLayerReference(layer).id === source.id, diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index 7b3940c44..59fe4d20d 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -8,6 +8,7 @@ import type { export const CANVAS_GENERATION_ACTIONS = [ 'image.generate', + 'scene.generate', 'spec.generate', 'character.generate', 'icon.generate', @@ -27,6 +28,7 @@ export const CANVAS_GENERATION_ACTIONS = [ const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ 'image.generate', + 'scene.generate', 'spec.generate', 'character.generate', 'icon.generate', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5abda2360..a658322ee 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -14,6 +14,7 @@ import { buildImageGenerationInputs, buildQuickEditGenerationInputs, buildQuickEditModelOptions, + buildSceneGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, @@ -265,18 +266,20 @@ describe('ImageCanvasGenerationModel', () => { characterView: '右向三分之二侧身', customPrompt: '', }), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'spec.generate', - fields: [ - { id: 'specType', title: '规范类型', value: 'character' }, - { id: 'playSetting', title: '玩法设定', value: '平台跳跃' }, - { id: 'artStyle', title: '美术风格', value: '像素风' }, - { id: 'bodyRatio', title: '头身比', value: '3' }, - { id: 'characterView', title: '角色视角', value: '右向三分之二侧身' }, - ], - references: [], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'spec.generate', + fields: [ + { id: 'specType', title: '规范类型', value: 'character' }, + { id: 'playSetting', title: '玩法设定', value: '平台跳跃' }, + { id: 'artStyle', title: '美术风格', value: '像素风' }, + { id: 'bodyRatio', title: '头身比', value: '3' }, + { id: 'characterView', title: '角色视角', value: '右向三分之二侧身' }, + ], + references: [], + }), + ); }); it('builds character, icon and edit reference snapshots', () => { @@ -300,29 +303,31 @@ describe('ImageCanvasGenerationModel', () => { }, ], ), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'character.generate', - fields: expect.arrayContaining([ - { id: 'prompt', title: '角色设定', value: '主角骑士' }, - ]), - references: [ - { - id: 'specReference', - title: '角色规范', - label: '角色规范', - refType: 'project-resource', - refId: 'resource-spec', - }, - { - id: 'reference', - title: '常规参考图 1', - label: '盔甲参考', - refType: 'project-resource', - refId: 'resource-armor', - }, - ], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'character.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '角色设定', value: '主角骑士' }, + ]), + references: [ + { + id: 'specReference', + title: '角色规范', + label: '角色规范', + refType: 'project-resource', + refId: 'resource-spec', + }, + { + id: 'reference', + title: '常规参考图 1', + label: '盔甲参考', + refType: 'project-resource', + refId: 'resource-armor', + }, + ], + }), + ); expect( buildIconGenerationInputs(['返回按钮', '设置按钮'], { @@ -331,41 +336,45 @@ describe('ImageCanvasGenerationModel', () => { src: '/icon-spec.png', resourceId: 'resource-icon-spec', }), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'icon.generate', - fields: expect.arrayContaining([ - { id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' }, - ]), - references: [ - { - id: 'specReference', - title: '图标规范', - label: '图标规范', - refType: 'project-resource', - refId: 'resource-icon-spec', - }, - ], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'icon.generate', + fields: expect.arrayContaining([ + { id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' }, + ]), + references: [ + { + id: 'specReference', + title: '图标规范', + label: '图标规范', + refType: 'project-resource', + refId: 'resource-icon-spec', + }, + ], + }), + ); expect( buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'image.edit', - fields: expect.arrayContaining([ - { id: 'prompt', title: '修改要求', value: '换成夜晚' }, - ]), - references: [ - { - id: 'source', - title: '参考图', - label: '原图', - refType: 'project-resource', - refId: 'resource-source', - }, - ], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { id: 'prompt', title: '修改要求', value: '换成夜晚' }, + ]), + references: [ + { + id: 'source', + title: '参考图', + label: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }), + ); expect( buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [ { @@ -375,32 +384,34 @@ describe('ImageCanvasGenerationModel', () => { resourceId: 'resource-ui-style', }, ]), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'ui-design.extract-assets', - fields: expect.arrayContaining([ - expect.objectContaining({ - id: 'prompt', - title: '提取提示词', - }), - ]), - references: [ - { - id: 'source', - title: 'UI设计图', - label: '原图', - refType: 'project-resource', - refId: 'resource-source', - }, - { - id: 'reference', - title: '参考图 1', - label: 'UI风格参考', - refType: 'project-resource', - refId: 'resource-ui-style', - }, - ], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'ui-design.extract-assets', + fields: expect.arrayContaining([ + expect.objectContaining({ + id: 'prompt', + title: '提取提示词', + }), + ]), + references: [ + { + id: 'source', + title: 'UI设计图', + label: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + { + id: 'reference', + title: '参考图 1', + label: 'UI风格参考', + refType: 'project-resource', + refId: 'resource-ui-style', + }, + ], + }), + ); expect( buildQuickEditGenerationInputs( '快速编辑提示词', @@ -415,29 +426,31 @@ describe('ImageCanvasGenerationModel', () => { }, ], ), - ).toEqual(expect.objectContaining({ - version: 2, - action: 'image.edit', - fields: expect.arrayContaining([ - { id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' }, - ]), - references: [ - { - id: 'source', - title: '原图', - label: '原图', - refType: 'project-resource', - refId: 'resource-source', - }, - { - id: 'reference', - title: '参考图 1', - label: '天空', - refType: 'project-resource', - refId: 'resource-sky', - }, - ], - })); + ).toEqual( + expect.objectContaining({ + version: 2, + action: 'image.edit', + fields: expect.arrayContaining([ + { id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' }, + ]), + references: [ + { + id: 'source', + title: '原图', + label: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + { + id: 'reference', + title: '参考图 1', + label: '天空', + refType: 'project-resource', + refId: 'resource-sky', + }, + ], + }), + ); }); it('only exposes redraw for executable generation metadata', () => { @@ -452,6 +465,43 @@ describe('ImageCanvasGenerationModel', () => { generationInputs: buildImageGenerationInputs('生成城堡'), }), ).toBe(true); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + assetKind: 'scene', + generationInputs: buildSceneGenerationInputs( + '雨夜车站', + 'watercolor', + '', + [], + { + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '16:9', + imageSize: '2K', + }, + ), + }), + ).toBe(true); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + assetKind: 'scene', + generationInputs: { + fields: [{ title: '画面内容', value: '历史场景' }], + references: [], + }, + }), + ).toBe(true); + expect( + canOpenRedrawPanel({ + ...generatedLayer, + assetKind: 'image', + generationInputs: { + fields: [{ title: '画面内容', value: '非场景展示字段' }], + references: [], + }, + }), + ).toBe(false); expect( canOpenRedrawPanel({ ...generatedLayer, @@ -564,9 +614,7 @@ describe('ImageCanvasGenerationModel', () => { generationInputs: { version: 2, action: 'image.generate', - fields: [ - { id: 42, title: '生成提示词', value: '损坏数据' } as never, - ], + fields: [{ id: 42, title: '生成提示词', value: '损坏数据' } as never], references: [], }, }), diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 2ecdabdc1..fa0112602 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1,6 +1,7 @@ import { CUSTOM_EDITOR_SCENE_STYLE_PRESET, - getEditorSceneStylePresetLabel, + DEFAULT_EDITOR_SCENE_STYLE_PRESET, + EDITOR_SCENE_STYLE_PRESET_OPTIONS, } from '../../../packages/shared/src/contracts/editorScene'; import { ApiClientError } from '../../services/apiClient'; import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient'; @@ -1385,6 +1386,21 @@ export function decodeCanvasGenerationInputs( defaultModel: DEFAULT_IMAGE_MODEL, defaultAspectRatio: '1:1', }); + } else if (action === 'scene.generate') { + normalizeStringField('prompt', '画面内容'); + const stylePreset = normalizeStringOption( + 'stylePreset', + '视觉风格', + EDITOR_SCENE_STYLE_PRESET_OPTIONS.map((option) => option.value), + DEFAULT_EDITOR_SCENE_STYLE_PRESET, + ); + if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET) { + normalizeStringField('customStyle', '自定义画风'); + } + normalizeImageParameters({ + defaultModel: DEFAULT_IMAGE_MODEL, + defaultAspectRatio: '16:9', + }); } else if (action === 'ui-design.generate') { normalizeStringField('prompt', '用户输入'); normalizeImageParameters({ @@ -1564,9 +1580,23 @@ export function canOpenRedrawPanel(layer: CanvasLayer) { if (action !== undefined) { return isRemixableCanvasGenerationAction(action); } - if (layer.sourceType === 'uploaded' || layer.generationInputs?.version === 2) { + if ( + layer.sourceType === 'uploaded' || + layer.generationInputs?.version === 2 + ) { return false; } + // 72f268e0 至 scene.generate V2 落地前的场景产物只保存 legacy 中文字段。 + // 兼容范围限定为 scene assetKind,避免把同名展示字段重新提升为通用执行能力。 + if ( + layer.assetKind === 'scene' && + layer.generationInputs?.fields.some( + (field) => + typeof field?.title === 'string' && field.title.trim() === '画面内容', + ) + ) { + return true; + } return Boolean( layer.generationInputs?.fields.some( (field) => @@ -1630,20 +1660,38 @@ export function buildSceneGenerationInputs( stylePreset: NonNullable, customStyle: string | null | undefined, references?: CharacterReferenceImage[], + options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { - const styleLabel = getEditorSceneStylePresetLabel(stylePreset); - return { - fields: [ - ...createGenerationInputField('画面内容', sceneContent), - ...createGenerationInputField('视觉风格', styleLabel), + return buildNormalizedGenerationInputs( + 'scene.generate', + [ + ...createGenerationInputField('画面内容', sceneContent, 'prompt'), + ...createGenerationInputField('视觉风格', stylePreset, 'stylePreset'), ...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET - ? createGenerationInputField('自定义画风', customStyle) + ? createGenerationInputField('自定义画风', customStyle, 'customStyle') : []), + ...createGenerationInputField( + '模型', + options.model ?? DEFAULT_IMAGE_MODEL, + 'model', + ), + ...createGenerationInputField( + '比例', + options.aspectRatio ?? '16:9', + 'aspectRatio', + ), + ...createGenerationInputField( + '尺寸', + options.imageSize ?? '1K', + 'imageSize', + ), ], - references: (references ?? []).flatMap((reference, index) => - createGenerationInputReference(`场景参考图 ${index + 1}`, reference), + (references ?? []).flatMap((reference, index) => + createGenerationInputReference(`场景参考图 ${index + 1}`, reference, { + id: 'reference', + }), ), - }; + ); } export function buildVideoGenerationInputs( @@ -2154,7 +2202,7 @@ export function isCanvasGenerationDialog( dialog?.id && (dialog.mode === 'generate' || dialog.mode === 'scene' || - dialog.mode === 'spec' || + dialog.mode === 'spec' || dialog.mode === 'character' || dialog.mode === 'icon' || dialog.mode === 'publication' || diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 6f0a80e1a..58f636317 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -116,18 +116,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => { assetKind: 'scene', title: '游戏场景 2', generationInputs: { + version: 2, + action: 'scene.generate', fields: [ - { title: '画面内容', value: '雨夜中的欧洲小镇街道' }, - { title: '视觉风格', value: '清透水彩' }, + { + id: 'prompt', + title: '画面内容', + value: '雨夜中的欧洲小镇街道', + }, + { id: 'stylePreset', title: '视觉风格', value: 'watercolor' }, + { + id: 'model', + title: '模型', + value: IMAGE_MODEL_NANOBANANA2, + }, + { id: 'aspectRatio', title: '比例', value: '16:9' }, + { id: 'imageSize', title: '尺寸', value: '1K' }, ], references: [ { + id: 'reference', title: '场景参考图 1', label: '场景参考一', refType: 'project-resource', refId: 'scene-resource-1', }, { + id: 'reference', title: '场景参考图 2', label: '场景参考二', refType: 'project-resource', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 45a26c017..8dacd3881 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -632,6 +632,11 @@ export function buildImageGenerationSubmissionPlan({ stylePreset, customStyle, references, + { + model: imageModel, + aspectRatio: dialog.aspectRatio ?? '16:9', + imageSize: dialog.imageSize ?? '1K', + }, ); return { kind: 'scene', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 545f3f98b..58fe8c5bc 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -130,6 +130,7 @@ export type EditorAssetGenerationInputs = { version?: 2; action?: | 'image.generate' + | 'scene.generate' | 'spec.generate' | 'character.generate' | 'icon.generate' From 1303df1ee7f44da2a85624f45e6949b582930242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 12:53:53 +0800 Subject: [PATCH 22/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E7=AB=9E=E6=80=81=E4=B8=8E=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步关闭项目或 Run 切换时的运行详情弹窗,消除延迟 effect 覆盖用户点击的竞态 更新场景提交测试,按当前稳定枚举值和完整兼容字段断言 同步补充 AI 游戏 Runtime 与场景 V2 配方文档 --- .../features/project-workspace/SupervisorChatOnlyView.tsx | 4 ++-- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- .../【技术方案】图片画布游戏场景生成链路-2026-08-04.md | 2 +- .../useImageCanvasGenerationSubmissionWorkflow.test.tsx | 8 +++++++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 0a53d307b..a78af79e3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -9,7 +9,7 @@ import type { SetStateAction, UIEventHandler, } from 'react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants'; @@ -1142,7 +1142,7 @@ export function SupervisorChatOnlyView({ url.searchParams.set('genarrativeRevision', String(previewRevision)); return url.toString(); })(); - useEffect(() => { + useLayoutEffect(() => { setShowRuntimeDetails(false); }, [projectPath, runtime?.runId]); useEffect(() => { diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9a7b6a8e0..6202c8391 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -34,7 +34,7 @@ - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。game-chat 的自主链路中,Supervisor 持久化意图后只有 `code-prototype` 是主 Agent;它可能临时委派一个受限美术 child,后者只写 `assets/**`,回执返回同一主 Run 后由主 Agent 接入与验收。界面聚合当前 Supervisor 父 run、单主 Agent 及其直接美术 child 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - 公开消息硬门:模型仍负责 Supervisor / 专业 Agent 回复的业务语义,Runtime 不根据 tool 或 Provider 事件自行补写业务结论;但用户直接投递的 Project Supervisor 根后台任务必须先落为不可执行的 `preparing / public-status-pending`,再以 `runtime-public-status-*` 稳定 message ID 把“任务已接收,正在启动处理”写入项目 conversation,成功后才转为 `pending / queued`;恢复预检只读,只能在验证到同 run accepted 消息后把该任务临时分类为可恢复,真实 resume 持有 Agent 锁后才可持久提升为 `pending / queued`;写入失败则落为 `failed / public-status-write-failed`,不得继续执行。这些 Runtime 公开状态只供 UI 展示,prompt 构建器必须按稳定前缀排除。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,必须在 task、event、state 等其它终态投影之前先幂等写入一条脱敏、用户可理解的失败消息;专业 Agent 命中该全局硬期限时,也必须通过权威 Run Profile 和根 task 将同一根终态写入项目 conversation,同时保留 child 私有 Session 状态;状态文件本身写坏也不能导致零公开结果。当前 Runtime 自称根 agent/run 时,其 session 和两个 parent 字段必须与权威根 task 一致;任一身份冲突必须失败关闭,不得以另一 session 派生第二条项目终态。前端把该前缀识别为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,不二次持久化;仅根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 只保留在 Runtime 详情和进度投影中,不能再生成第二条聊天消息,专业 Agent 的公开启动事件仍可见。该硬门不改变 final-reply 的唯一性;非 Supervisor 专业 Agent 的失败消息继续留在对应 Agent Session,不把私有诊断写进项目 conversation。 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 -- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 +- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。运行详情弹窗在项目或 run 身份切换的同次提交中同步关闭,不能由延迟 effect 关闭用户在新 run 状态可见后刚打开的弹窗。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task manifest 漂移:父 Supervisor 必须分别判断“能否调度新节点”和“是否存在必须等待的工作”。派生视觉需要父规划修复时不再调度新 child,但当前最新且活跃的根 Run 下,只要存在确定性 runId、scheduler source、正确父绑定且 durable journal 为 queued/running 的 ready child,父 Run 就保持 `waiting-for-manifest-tasks`,不能因旧 hydration 快照把 manifest running 覆盖成 pending 而提前 fixed-graph-stalled。game-chat child 可在相同严格身份下容忍 pending 漂移;正式产物、Canvas、revision、`game.static_smoke` 与 `preview.validate` 门禁不放宽。GUI/CLI、旧父 Run、终态、确认/用户输入/reconciliation、伪造绑定或非确定性 runId 全部失败关闭;更新根 Run 后旧 child 不得继续维持新 DAG 或投影完成。 - Supervisor 持久决策与单主条件美术:game-chat 的关键词、用户是否报告“美术未接入”、占位状态和当前资产探测只形成 `advisoryOnly=true` 的补充上下文,不得直接重置 Graph、预完成美术节点、选择复用/生成分支或继承历史试玩类型。当前根 Run 没有持久化 Supervisor 决策时,scheduler 不启动任何 child;Supervisor Provider 只通过 auto-safe 的 `agent.route_manifest` 提交 `game-chat-workflow-decision.v2`:`intentSummary` 是 Supervisor 自行理解并持久化的用户意图,`strategy=audit-existing-first` 只是固定安全执行策略,两者不得混用。此动作不能审计、生成、委派或替代后续判断,也不能把整体视觉重做解释成整套美术的强制重生成;成功后 Runtime 只启动唯一 `code-prototype` 主 Agent。升级恢复时严格校验 v1 sidecar 的旧 fingerprint,并从完成合同绑定的有效任务恢复 `intentSummary`;旧 `code-director` coverage/route 只作为迁移输入,不作为当前完成证据,必须由同一根 Run 的 `code-prototype` 重新 `asset.list` 后原位替换为单主合同。确定性 `code-prototype` Run 仅兼容已知 canonical task 文本版本,其余 task/binding/root 身份继续失败关闭;升级前已运行的 fixed-graph 美术 child 不再具备任何 mutation 或生图权限。主 Agent 必须以当前正式资产、Canvas 登记、私有图集合同、四张语义切片和 art manifest 判断真实缺口;完整覆盖时直接接入,不得生成或扣费。只有可证实缺失 `art-spec` 或核心 spritesheet 时,主 Agent 才可对相应 `art-director` 或 `art-asset-plan` 建立一条 durable 委派;每次最多一个活跃美术 child,child 仅可写 `assets/**`,不得修改 `game/**` 或接入/验收游戏。若两个槽位都缺失,必须先完成 `art-director`,由同一主 Run 认领其 `EvidenceReady` delivery 后,才能委派依赖规范图的 `art-asset-plan`;失败或未就绪 delivery 不得消耗不可重试的图集委派槽位。主 Agent 认领必要回执后继续同一 Run 完成素材接入、原玩法语义校验、`game.static_smoke` 与桌面/移动 `preview.validate`。绝对硬截止对嵌套美术 child 继续核验 `root -> code-prototype -> agent-delegate` 完整身份并保留未知外部生成的 reconciliation 证据。Runtime 只负责校验根/父子身份、当前 revision、路径、Canvas 登记、缺口/路由 fingerprint、写入范围及完成证据;纯“继续”仍走既有正式 continuation 合同,普通美术措辞不得借用更老项目的具体试玩场景。不得以增加 loop 预算、伪造 revision、机械改写 manifest 或重放历史图片 action 代替 Supervisor 决策和程序侧审计。 diff --git a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md index 41ee5b104..56dae069e 100644 --- a/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md +++ b/docs/technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md @@ -162,7 +162,7 @@ fields[].id = prompt | stylePreset | customStyle | model | aspectRatio | imageSi references[].id = reference ``` -`title` / `label` 仅用于中文展示,不参与路由或恢复。前端提交快照和再次提交使用同一组规范化后的模型、比例与清晰度;后端忽略客户端提供的执行字段和引用 provenance,按请求顶层结构化场景字段重建 `fields`,并按本次真实 `referenceImageSrcs` 只保留安全的 `references[id="reference"]` 槽位,再由现有 owner-scoped 引用重建链补齐 `refType/refId`。 +`title` / `label` 仅用于中文展示,不参与路由或恢复。前端提交快照和再次提交使用同一组规范化后的模型、比例与清晰度。请求兼容字段 `generationInputs.fields[]` 保留中文展示 `title`,同时把规范值字符串化,例如 `{ title: "视觉风格", value: "anime" }`;它不携带 V2 的 `id / action / version`。后端忽略客户端提供的执行字段和引用 provenance,按请求顶层 `sceneContent / stylePreset / customStyle / model / aspectRatio / imageSize` 重建 V2 `fields`,并按本次真实 `referenceImageSrcs` 只保留安全的 `references[id="reference"]` 槽位,再由现有 owner-scoped 引用重建链补齐 `refType/refId`。 `scene.generate` 必须同时进入已知 action 与可改造 action allowlist,并由 action 级 decoder 和场景 composer 恢复路径读取。`assetKind = scene` 不能替代上述 capability 判断。`72f268e0` 之后、V2 上线之前产生的场景数据只在 `assetKind === "scene"` 且 legacy `fields` 含展示标题“画面内容”时走既有 legacy 恢复和告警;“画面内容”不得加入全局 legacy 可复用标题列表,其他素材即使带同名字段也不能获得改造入口。 diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 78075d7f1..b030dbc98 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1775,7 +1775,13 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { generationInputs: { fields: [ { title: '画面内容', value: '雨夜中的欧洲小镇街道' }, - { title: '视觉风格', value: '日系动画' }, + { title: '视觉风格', value: 'anime' }, + { + title: '模型', + value: 'gemini-3.1-flash-image-preview', + }, + { title: '比例', value: '16:9' }, + { title: '尺寸', value: '1K' }, ], references: [], }, From f30a55069ae9be97c8c52e4e0baf327a0aff33a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 17:52:54 +0800 Subject: [PATCH 23/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=85=8D=E6=96=B9=E6=97=B6=E9=95=BF=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 V2 durationSeconds 字段标识和数值类型 补充回归测试与改造契约说明 --- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- .../crates/api-server/src/editor_project.rs | 38 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index cb901c516..a32b2166d 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -114,7 +114,7 @@ - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 0ab88adef..9ebc68a17 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -12490,6 +12490,7 @@ pub(crate) fn with_editor_media_duration_generation_input( None => serde_json::Map::new(), Some(value) => return Some(value), }; + let is_v2 = object.contains_key("version") || object.contains_key("action"); let fields = object .entry("fields".to_string()) .or_insert_with(|| Value::Array(Vec::new())); @@ -12497,16 +12498,29 @@ pub(crate) fn with_editor_media_duration_generation_input( return Some(Value::Object(object)); }; fields.retain(|field| { - field + let is_duration_id = field + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.trim() == "durationSeconds"); + let is_legacy_duration_title = field .get("title") .and_then(Value::as_str) .map(str::trim) - .is_none_or(|title| title != "时长" && title != "duration") + .is_some_and(|title| title == "时长" || title == "duration"); + !is_duration_id && !is_legacy_duration_title + }); + fields.push(if is_v2 { + json!({ + "id": "durationSeconds", + "title": "时长", + "value": duration_seconds, + }) + } else { + json!({ + "title": "时长", + "value": format!("{duration_seconds}秒"), + }) }); - fields.push(json!({ - "title": "时长", - "value": format!("{duration_seconds}秒"), - })); object .entry("references".to_string()) .or_insert_with(|| Value::Array(Vec::new())); @@ -14521,9 +14535,11 @@ mod tests { fn generated_media_canvas_item_keeps_duration_only_in_generation_inputs() { let generation_inputs = with_editor_media_duration_generation_input( Some(json!({ + "version": 2, + "action": "video.generate", "fields": [ - { "title": "视频描述", "value": "角色挥手" }, - { "title": "duration", "value": "4秒" } + { "id": "prompt", "title": "视频描述", "value": "角色挥手" }, + { "id": "durationSeconds", "title": "duration", "value": 4 } ], "references": [] })), @@ -14576,7 +14592,11 @@ mod tests { item["generationInputs"]["fields"][1]["title"], json!("时长") ); - assert_eq!(item["generationInputs"]["fields"][1]["value"], json!("5秒")); + assert_eq!( + item["generationInputs"]["fields"][1]["id"], + json!("durationSeconds") + ); + assert_eq!(item["generationInputs"]["fields"][1]["value"], json!(5)); } #[test] From 18ae26006974e279e0c57c1b1079868f5d98fbdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 18:03:36 +0800 Subject: [PATCH 24/25] =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91=E6=9D=A5=E6=BA=90=E9=85=8D?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将源视频写入 V2 videoReference 引用列表 补充提交与改造水合回归测试及契约说明 --- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- .../ImageCanvasGenerationDialogModel.test.ts | 20 ++++++++++++++++++- ...anvasGenerationSubmissionWorkflow.test.tsx | 13 ++++++++++++ ...ImageCanvasGenerationSubmissionWorkflow.ts | 3 ++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index a32b2166d..332bf3800 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -114,7 +114,7 @@ - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 74a57c51d..23540461d 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -913,10 +913,16 @@ describe('ImageCanvasGenerationDialogModel', () => { title: '音轨参考', mediaType: 'audio', }); + const videoReference = createLayer({ + id: 'layer-video-reference', + resourceId: 'resource-video-reference', + title: '源视频', + mediaType: 'video', + }); const context = { canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, - availableLayers: [imageReference, audioReference], + availableLayers: [imageReference, videoReference, audioReference], }; expect( @@ -945,6 +951,13 @@ describe('ImageCanvasGenerationDialogModel', () => { refType: 'project-resource', refId: 'resource-image-reference', }, + { + id: 'videoReference', + title: '任意标题', + label: '旧显示名', + refType: 'project-resource', + refId: 'resource-video-reference', + }, { id: 'audioReference', title: '任意标题', @@ -970,6 +983,11 @@ describe('ImageCanvasGenerationDialogModel', () => { id: 'canvas-layer-image-reference', label: '视觉参考', }), + expect.objectContaining({ + id: 'canvas-layer-video-reference', + label: '源视频', + mediaType: 'video', + }), expect.objectContaining({ id: 'canvas-layer-audio-reference', label: '音轨参考', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b030dbc98..df1466f2c 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1513,6 +1513,19 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { referenceVideoSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], + generationInputs: expect.objectContaining({ + version: 2, + action: 'video.generate', + references: [ + { + id: 'videoReference', + title: '参考视频 1', + label: '源视频', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }), assetFolderId: 'project', assetLabel: '源视频 快速编辑', }), diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index c041e80c7..e1921af6d 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -65,6 +65,7 @@ import { buildQuickEditGenerationInputs, buildUiDesignAssetExtractionGenerationInputs, buildVideoGenerationInputs, + createCanvasLayerReference, DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_IMAGE_MODEL, @@ -1628,7 +1629,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); const generationInputs = buildVideoGenerationInputs( normalizedPrompt, - quickEditReferences, + [createCanvasLayerReference(quickEditSourceLayer)], { model: videoModel, aspectRatio: '16:9', From 9a326c940a15ffdaa0576e64fda12602868a2fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 19:01:03 +0800 Subject: [PATCH 25/25] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E7=9A=84=E6=94=B9=E9=80=A0=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确不可替换 source 缺失时保留改造按钮并在点击后报错 同步现役设计文档、后端契约与长期决策记录 补齐角色动作和 UI 素材提取的按钮与失败回归测试 --- .../shared-memory/decision-log.md | 4 +-- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 28 ++++++++++++++- .../useImageCanvasGenerationWorkflow.test.tsx | 36 +++++++++++++++++++ 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 506d0e81b..b93f723ed 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -168,7 +168,7 @@ - 背景:画布 `generationInputs.version=2` 从持久化 JSON 恢复时只校验通用结构,未知、非法、已下线或与当前模型能力不兼容的模型、比例、尺寸、清晰度、声音、时长及角色动作档位仍可通过 TypeScript 断言进入 UI 和再次提交。前端已隐藏但后端仍兼容的历史 Veo 也不再属于当前可选模型。 - 决策:不新增 V3,不迁移数据库,不保留旧值再次执行;V2 在读取时原地按 `action` 解码。所有已存在但未知、非法、已下线或与当前模型不兼容的参数统一回落到该 action 的当前默认值,历史 Veo 同样回落到当前默认视频模型。图片比例 / 尺寸按回落后的模型联动校验,视频参数按当前模型能力校验,角色动作 `frameCount / durationSeconds` 按完整档位成对校验。发生回落时必须向用户显示“部分原生成参数已使用当前默认值”告警;再次提交只使用规范结果并保存为仍是 `version: 2` 的新快照。缺失字段继续由当前 action 默认值补齐,不为此单独升级版本。 -- 实现边界:单一 action 级 runtime decoder 是持久化 V2 的读取真相,恢复 UI、改造入口和再次提交链不得各自解释原始 JSON;canonical 选项从当前编辑器模型 / 参数注册表派生,不新增平行旧模型清单。通用结构不合法时整份配方不支持改造;必需 `source` 缺失时仍拒绝改造,不用默认值伪造引用。该策略只改变画布配方的运行时恢复和后续重存,不修改 SpacetimeDB schema、BFF DTO 或已有资产原始 JSON。 +- 实现边界:单一 action 级 runtime decoder 是持久化 V2 的读取真相,恢复 UI、改造入口和再次提交链不得各自解释原始 JSON;canonical 选项从当前编辑器模型 / 参数注册表派生,不新增平行旧模型清单。通用结构不合法时整份配方不支持改造;必需 `source` 缺失时仍按 action capability 保留改造按钮,点击后在恢复路径拒绝改造,不用默认值伪造引用;运行期来源变化时同样必须复检并拒绝。该策略只改变画布配方的运行时恢复和后续重存,不修改 SpacetimeDB schema、BFF DTO 或已有资产原始 JSON。 - 影响范围:`ImageCanvasGenerationModel` 的 V2 decoder、`ImageCanvasGenerationDialogModel` 的恢复入口、`useImageCanvasGenerationWorkflow` 的回落告警、生成输入回归测试和编辑器 Lovart 统一方案。 - 验证方式:fixture 覆盖当前合法 V2、历史 Veo、未知模型、模型不兼容尺寸、非法视频参数、非法音效参数、角色动作错配档位和缺失必需来源;断言 UI、价格与提交使用规范值,回落显示告警,再次生成仍保存 V2。运行图片画布定向 Vitest、`npm run typecheck`、定向 ESLint、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`review.txt`。 @@ -6632,7 +6632,7 @@ ## 2026-08-03 图片画布生成产物统一“改造”契约 - 背景:画布生成结果统一显示“改造”,但部分动画 / 音频结果无法恢复面板;另一些非生成型派生结果继承最近生成输入,产生错误可执行动作。中文标题和素材类别被同时当成显示文案、参数键和路由键,改名后容易漂移。 -- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 +- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`,V2 以稳定 `action`、`fields[].id`、`references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。 - 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。 - 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。 - 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 081dc1b1f..035051db1 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,7 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 -- V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外缺少 source 时才允许显示“无法改造”,并分别说明不可替换原因;该失败规则不得用于任何可手动补选的槽位。 +- V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 - 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`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`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 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 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b023c0d92..b5b2c8d93 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -675,7 +675,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 生成链路的新 resource ID 必须按 owner + operation kind + operation ID + stable slot 派生,只能在统一结果 procedure 中创建或完整比较;普通“同媒体复用”不得把它替换成另一随机 ID。 - 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 -- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与稳定指针;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。前端改造只在当前画布图层中匹配引用并取得运行时媒体类型,不新增 owner-only 工程资源 / 素材库 resolver;面板直接上传引用和已移出画布的引用均不恢复。可重新选择的引用由前端留空槽位、提示并交给提交门禁校验;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 的引用缺失不得触发 legacy adapter。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。 +- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与稳定指针;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。前端改造只在当前画布图层中匹配引用并取得运行时媒体类型,不新增 owner-only 工程资源 / 素材库 resolver;面板直接上传引用和已移出画布的引用均不恢复。可重新选择的引用由前端留空槽位、提示并交给提交门禁校验;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时在点击恢复路径显示明确错误并拒绝,运行期来源变化时再次校验。有效 V2 的引用缺失不得触发 legacy adapter。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 332bf3800..4fad885e0 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -114,7 +114,7 @@ - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 - 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 1bb2e48d2..7561bcfd3 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -481,7 +481,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { ).toEqual(['快速编辑', '改造', '下载按钮']); }); - it('keeps redraw visible when a supported action has lost its required source', () => { + it('keeps redraw visible for supported actions with a missing non-replaceable source', () => { const generatedLayer = createLayer({ id: 'animation-layer', resourceId: 'animation-resource', @@ -508,6 +508,32 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { fireEvent.click(screen.getByRole('button', { name: '改造' })); expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(generatedLayer); + + cleanup(); + const uiDesignLayer = createLayer({ + id: 'ui-spritesheet-layer', + resourceId: 'ui-spritesheet-resource', + assetKind: 'icon-spritesheet', + generationInputs: { + version: 2, + action: 'ui-design.extract-assets', + fields: [{ id: 'prompt', title: '提取提示词', value: '提取按钮' }], + references: [ + { + id: 'source', + title: 'UI设计图', + refType: 'project-resource', + refId: 'missing-ui-design-resource', + }, + ], + }, + }); + + const uiProps = renderSelectedToolbar({ selectedLayer: uiDesignLayer }); + + fireEvent.click(screen.getByRole('button', { name: '改造' })); + + expect(uiProps.onOpenRedrawPanel).toHaveBeenCalledWith(uiDesignLayer); }); it('renders character animation only for character layers', () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index d61e17f98..7fc5b4ab3 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -4905,6 +4905,42 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('warns when UI asset extraction has lost its non-replaceable source layer', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('ui-extraction').textContent).toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '原 UI 设计图不在当前画布,当前素材提取面板无法重新选择来源设计图,无法改造。', + ); + }); + it('opens V2 panels without references that are not on the current canvas', () => { render(