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/66] =?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} -
); })} diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 8a1933af3..7dd353335 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -83,7 +83,6 @@ import { buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, resolveGenerationAssetLabel, - resolveRegisteredEditorReferenceId, } from './ImageCanvasGenerationSubmissionModel'; import type { UiAssetExtractionMark, @@ -1341,11 +1340,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ if (!canvasDialog) { return; } - const iconSpecReference = dialog.iconSpecReference; - if (!iconSpecReference) { - return; - } - setSubmittingIconDialog({ ...canvasDialog, iconDescriptions: submissionPlan.iconDescriptions, @@ -1357,7 +1351,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ try { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference); const referenceImageSrcs = await Promise.all( (dialog.generationReferences ?? []).map((reference) => resolveEditorGenerationMediaReference( @@ -1370,7 +1363,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const generated = await runEditorGenerationWithWalletRefresh( generateEditorIconSpritesheet({ ...submissionPlan.input, - referenceId, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs: submissionPlan.generationInputs, diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 4627413df..49807c463 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -11,11 +11,11 @@ import { deleteEditorAssetFolder, deleteEditorProject, editEditorImage, + EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, EDITOR_ICON_DESCRIPTION_MAX_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH, - EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, @@ -1596,6 +1596,31 @@ describe('editorProjectClient', () => { ); }); + it('normalizes icon spec fields and rejects blank values before requesting', async () => { + await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow( + '玩法设定不能为空', + ); + await expect( + generateEditorIconSpec({ + playSetting: '回合制', + artStyle: ' ', + }), + ).rejects.toThrow('美术风格不能为空'); + expect(requestJsonMock).not.toHaveBeenCalled(); + + requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' }); + await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe( + '优化玩法', + ); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/llm/icon-specs/refine-game-play', + expect.objectContaining({ + body: JSON.stringify({ playSetting: '原玩法' }), + }), + '优化玩法设定失败', + ); + }); + it('rejects overlong icon spec generation fields before dispatch', async () => { await expect( generateEditorIconSpec({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 18dc90033..82c4ba2d2 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -76,12 +76,16 @@ function assertStableEditorMediaReferences( } function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) { - if (Array.from(value).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`${fieldLabel}不能为空`); + } + if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) { throw new Error( `${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`, ); } - return value; + return normalized; } function resolveEditorProviderReferenceLimit(model: string | null | undefined) { From b2b517d5b9d3931e77efbca2ff16f9da7dac172f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:15:40 +0800 Subject: [PATCH 28/66] =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E6=BA=90=E7=B4=A0=E6=9D=90=E7=9A=84=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确仅在必需 source 无法手动补齐且请求无法执行时拒绝改造 说明可手动补选的 reference 继续按 best-effort 恢复参数 --- src/components/image-editor/ImageCanvasGenerationModel.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index e203dd097..547c75f2f 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1551,6 +1551,11 @@ 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', From baa1f32f8e761da0571a5a7760df8f2e3d82b563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:23:47 +0800 Subject: [PATCH 29/66] =?UTF-8?q?=E6=A0=87=E6=B3=A8=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E4=B8=8E=E6=B5=81=E6=B0=B4=E7=BA=BF=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=80=BA=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确认计费提交早于持久化的边界继承自 master,并补充可对账状态 TODO 确认编码与上传共用并发槽继承自 master,并补充有界流水线 TODO 保留既有 OSS 清理历史 TODO,本提交不改变运行行为 --- server-rs/crates/api-server/src/editor_project_icon.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index fe55350c1..47c47db42 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -850,6 +850,9 @@ pub(crate) async fn persist_editor_spritesheet_slices( // TODO(legacy-spritesheet-cleanup): 历史并发上传链路在部分 PUT/HEAD 成功后失败时没有 // 记录成功对象并执行可重试补偿;需先定义 staged/committed/cleanup-pending 生命周期, // 再处理批量持久化失败、补偿失败和任务重放,不能只做一次 best-effort delete。 + // TODO(legacy-spritesheet-pipeline): 该并发结构继承自 master;同一个 stream slot 串联编码、 + // PUT 与 HEAD,慢上传会阻塞尚未开始的编码并继续消耗 processing_deadline。应拆成带有界 + // channel 的独立编码池和上传池,并分别定义 CPU/内存准入、处理期限与网络超时。 let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) .map(|index| { let prepared = prepared.clone(); @@ -1358,6 +1361,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; + // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; + // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 + // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, + // 不能只把后续副作用机械包入 billable future。 let (generated, prompt, screen_background_decision) = execute_billable_asset_operation_with_cost( state, From 706fa3ca8cfafae483116cef57bd8e5811b4ac5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:36:03 +0800 Subject: [PATCH 30/66] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持音频背景音乐生成链路的 `version: 2` 和 `action: audio.background-music.generate` 参数 --- .../useImageCanvasGenerationSubmissionWorkflow.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index a5554b8fb..282077556 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2002,8 +2002,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { makeInstrumental: true, projectId: 'editor-project-bgm', generationInputs: { + version: 2, + action: 'audio.background-music.generate', fields: [ { + id: 'prompt', title: 'gpt_description_prompt', value: canonicalPrompt, }, @@ -2058,8 +2061,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(generatedLayer?.prompt).toBe(canonicalPrompt); expect(generatedLayer?.actualPrompt).toBe(canonicalPrompt); expect(generatedLayer?.generationInputs).toEqual({ + version: 2, + action: 'audio.background-music.generate', fields: [ { + id: 'prompt', title: 'gpt_description_prompt', value: canonicalPrompt, }, From 3bbde84f872cc543603d20d37148c937b57e4e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:37:32 +0800 Subject: [PATCH 31/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新图标规范生成逻辑:调整资源引用为 `resourceId` 统一后端 LLM 客户端接口 --- .../src/editor_background_music_prompt_assist.rs | 2 +- .../useImageCanvasGenerationWorkflow.test.tsx | 13 ++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs index 121498760..24cf26db0 100644 --- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs @@ -135,7 +135,7 @@ fn background_music_prompt_bad_request(message: impl Into) -> AppError { } fn require_background_music_prompt_llm_client(state: &AppState) -> Result<&LlmClient, AppError> { - state.editor_agent_llm_client().ok_or_else(|| { + state.vector_engine_llm_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("服务端尚未配置可用的编辑器 LLM") .with_details(json!({ diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 9dc7de182..5dd866d96 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1131,6 +1131,7 @@ function GenerationWorkflowHarness({ id: 'spec-ref', label: '参考.png', src: 'data:image/png;base64,ref', + resourceId: 'resource-spec-ref', }, } : currentDialog, @@ -2153,14 +2154,6 @@ describe('useImageCanvasGenerationWorkflow', () => { }); it('submits icon spec generation with business fields and a stable reference', async () => { - uploadEditorMediaAssetFileMock.mockResolvedValueOnce({ - src: 'https://signed.example.test/generation-reference.png', - objectKey: - 'generated-character-drafts/editor/generation-references/reference.png', - assetObjectId: 'asset-object-generation-reference', - legacyPublicPath: - '/generated-character-drafts/editor/generation-references/reference.png', - }); generateEditorIconSpecMock.mockResolvedValueOnce( createGenerated({ prompt: 'UI规范图' }), ); @@ -2176,9 +2169,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect.objectContaining({ playSetting: '消除玩法', artStyle: '清爽卡通', - referenceImageSrcs: [ - 'generated-character-drafts/editor/generation-references/reference.png', - ], + referenceId: 'resource-spec-ref', }), ); }); From 29498a727d45051950f6de04a4623321c5ec0642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:54:31 +0800 Subject: [PATCH 32/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=B8=8E=E8=83=8C=E6=99=AF=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新图标图集用户提示词隔离门禁以匹配规范化输入 调整背景音乐同步回退用例以避开项目权威快照路径 --- .../crates/api-server/src/editor_project.rs | 2 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 43 +++++++------------ 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 004b30629..5eaa06bd9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -14094,7 +14094,7 @@ mod tests { "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ - "let user_prompt = icon_descriptions.join(\"\\n\");", + "let user_prompt = spritesheet_prompt.as_str().to_string();", "image,\n user_prompt.as_str(),\n generated.actual_prompt.as_deref(),", "prompt: user_prompt.clone(),", "&image,\n user_prompt.as_str(),\n None,", diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 064685f71..7b782eec5 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1972,7 +1972,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { render( { fireEvent.click(submitButton); expect(generateEditorBackgroundMusicMock).toHaveBeenCalledTimes(1); - expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith({ - gptDescriptionPrompt: canonicalPrompt, - makeInstrumental: true, - projectId: 'editor-project-bgm', - generationInputs: { - fields: [ - { - title: 'gpt_description_prompt', - value: canonicalPrompt, - }, - ], - references: [], - }, - assetFolderId: 'project', - assetLabel: '游戏背景音乐 1', - canvasCompletion: { - dialogId: 'dialog-bgm', - title: '游戏背景音乐 1', - placeholder: { - x: 200, - y: 160, - width: 420, - height: 120, - originalWidth: 420, - originalHeight: 120, + expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith( + expect.objectContaining({ + gptDescriptionPrompt: canonicalPrompt, + makeInstrumental: true, + generationInputs: { + fields: [ + { + title: 'gpt_description_prompt', + value: canonicalPrompt, + }, + ], + references: [], }, - }, - }); + assetLabel: '游戏背景音乐 1', + }), + ); expect(screen.getByTestId('tracked-dialog').textContent).toContain( `audio-background-music:${canonicalPrompt}:idle:open`, ); From 5eee62cf395f34013e9fa3fd369fa903c24071c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 20:12:21 +0800 Subject: [PATCH 33/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8B=92=E7=BB=9D=E6=B5=8B=E8=AF=95=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为自动预览权限检查断言增加 2500 毫秒等待预算 保持权限拒绝文案与禁止启动预览断言不变 --- .../tests/appSurface/project-development.suite.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 7919a671d..d9bc07c95 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7302,11 +7302,14 @@ export function registerProjectSupervisorSurfaceTests() { await Promise.resolve(); }); - await waitFor(() => { - expect(within(surface).getByLabelText('最新状态').textContent).toContain( - '项目权限策略拒绝执行:preview.start', - ); - }); + await waitFor( + () => { + expect(within(surface).getByLabelText('最新状态').textContent).toContain( + '项目权限策略拒绝执行:preview.start', + ); + }, + { timeout: 2500 }, + ); expect( invoke.mock.calls.filter( ([command]) => command === 'start_local_game_preview', From f8bcf7cf54b42cd93186516c20562ffe70ba5062 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 11:15:11 +0800 Subject: [PATCH 34/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E4=B8=8A=E4=BC=A0=E7=B1=BB=E5=9E=8B=E5=90=88?= =?UTF-8?q?=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标规范上传在项目资源与账号素材分支统一登记为 icon-spec。 补充上传后提交图集生成及两条持久化分支回归测试。 同步编辑器设计文档、外部 API Skill 与 Python helper 的 referenceId 合同。 --- .../genarrative-external-editor-api/SKILL.md | 2 +- .../references/api-operations.md | 8 +- .../references/capability-routing.md | 2 +- .../references/requests-and-outputs.md | 2 + .../scripts/genarrative_external_api.py | 9 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 +- ...CanvasEditorGenerationIntegration.test.tsx | 84 +++++++++++++++++++ .../useImageCanvasUploadWorkflow.test.tsx | 70 ++++++++++++++++ .../useImageCanvasUploadWorkflow.ts | 8 +- 9 files changed, 177 insertions(+), 10 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index d8aa83ba8..350dca833 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -17,7 +17,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on 2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec. 3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints. 4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts. -5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to generation; never substitute a temporary signed URL. +5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`. 6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder. 7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`. 8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 1597a2a12..155022c7f 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -44,7 +44,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | --- | --- | --- | --- | | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | -| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -70,19 +70,21 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM ## Reference Field Mapping -After confirming a local upload, pass its stable `objectKey` into: +After confirming a local upload, pass its stable `objectKey` into operations that accept object references: | Target capability | Field | | --- | --- | | Image generation | `referenceImageSrcs` | | Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` | -| Icon spritesheet | `referenceImageSrc`; additional style references in `referenceImageSrcs` | +| Icon spritesheet | Register the primary spec as an `assetKind="icon-spec"` project resource or asset, then pass its returned ID as `referenceId`; additional style references remain in `referenceImageSrcs` | | UI design extraction | `sourceImageSrc`; additional references in `referenceImageSrcs` | | Character animation | `sourceImageSrc` | | Video with image references | `referenceImageSrcs` | Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference. +The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. + ## Common Values Use OpenAPI as the final authority; these common values are a routing aid: diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 82e70f48d..855cc0f16 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -78,7 +78,7 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 1f3d04e3c..08f493498 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -167,6 +167,8 @@ client.generate_image( ) ``` +Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`. + For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID: ```json diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index bce78e20e..e2f3a99e9 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -560,10 +560,13 @@ class GenarrativeExternalClient: def generate_icon_spritesheet( self, - reference_image_src: str, + reference_id: str, icon_descriptions: list[str], **fields: Any, ) -> Any: + reference_id = normalize_optional_text(reference_id) + if not reference_id: + raise GenarrativeApiError("reference_id must be a registered icon-spec resource or asset ID") descriptions = [item.strip() for item in icon_descriptions if item.strip()] if not descriptions: raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item") @@ -574,7 +577,7 @@ class GenarrativeExternalClient: return self.submit_and_wait_generation( "/api/external/v1/editor/icon-spritesheets/generations", { - "referenceImageSrc": reference_image_src, + "referenceId": reference_id, "iconDescriptions": descriptions, **fields, }, @@ -768,7 +771,7 @@ def _self_test() -> None: assetLabel="贪吃蛇透明图集", ) assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" - assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec" + assert calls[0]["body"]["referenceId"] == "editor-resource-spec" assert calls[0]["body"]["screenColor"] == "auto" assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index d01147a24..126ebfa2f 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -25,7 +25,7 @@ - 点击后弹出菜单:`从画布中选择`、`新建图标规范`、`上传图片`。 - `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标规范图片;其它图片点击无效。 - `新建图标规范` 复用生成规范表单,规格类型为 `图标规范`,生成成功后图层标记为 `icon-spec`。 - - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。 + - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布;存在当前项目时创建 `assetKind: "icon-spec"` 的项目资源,不存在当前项目时创建同类型账号素材,提交图集生成时只使用持久化返回的 `resourceId` 或 `assetId`,不得按通用 `image` 类型登记。 2. 第二模块为素材描述文本框。 - UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。 - 默认按换行填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 6a22b4dbb..e39d77fdc 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3187,6 +3187,90 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull(); }); + it('uploads an authoritative icon spec before submitting icon spritesheet generation', async () => { + uploadEditorMediaAssetFileMock.mockResolvedValueOnce({ + objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png', + assetObjectId: 'asset-object-uploaded-icon-spec', + legacyPublicPath: + '/generated-character-drafts/editor/icon-specs/uploaded.png', + src: 'https://oss.example.com/icon-specs/uploaded.png', + }); + createEditorProjectResourceMock.mockImplementationOnce( + async (projectId, input) => ({ + resourceId: 'resource-uploaded-icon-spec', + projectId, + imageSrc: input.imageSrc, + objectKey: input.objectKey, + assetObjectId: input.assetObjectId, + width: input.width, + height: input.height, + sourceType: input.sourceType, + assetKind: input.assetKind, + generationInputs: input.generationInputs, + }), + ); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,dXBsb2FkZWQtc2hlZXQ=', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gemini-3.1-flash-image-preview', + provider: 'VectorEngine', + taskId: 'icon-task-uploaded-spec-1', + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); + await userEvent.upload( + screen.getByLabelText('上传媒体文件'), + new File(['uploaded-icon-spec'], '上传图标规范.png', { + type: 'image/png', + }), + ); + + await waitFor(() => { + expect( + within(iconPanel).getByRole('button', { name: '上传图标规范.png' }), + ).toBeTruthy(); + }); + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png', + assetKind: 'icon-spec', + sourceType: 'uploaded', + }), + ); + + fireEvent.change( + within(iconPanel).getByRole('textbox', { name: '素材描述' }), + { target: { value: '上传后的返回按钮' } }, + ); + fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( + expect.objectContaining({ + referenceId: 'resource-uploaded-icon-spec', + iconDescriptions: ['上传后的返回按钮'], + projectId: 'editor-project-default', + }), + ); + }); + expect( + createEditorProjectResourceMock.mock.invocationCallOrder[0], + ).toBeLessThan( + generateEditorIconSpritesheetMock.mock.invocationCallOrder[0]!, + ); + }); + it('exits character generation canvas picking with Escape', () => { render(); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index d9d398dcd..a28c9e0df 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -333,6 +333,18 @@ function UploadWorkflowHarness({ > 准备规范生成 + +
))} {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 21209910ddb60e2f4dc82b085992bb8c59690d81 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 12:18:01 +0800 Subject: [PATCH 37/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=BC=95=E7=94=A8=E4=B8=8E=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复外部 API 参数覆盖与图标描述数组边界 提前校验 Agent 正式引用并收紧 SpacetimeDB 对象一致性 修正自然语言提示词转义与纯文本格式校验 移除图标规范占位提示词并避免重复下载参考图 补充定向测试与对应架构文档 --- .../scripts/genarrative_external_api.py | 4 +- .../shared-memory/decision-log.md | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 6 +- .../api-server/src/editor_agent/tool.rs | 45 ++++++++ .../crates/api-server/src/editor_project.rs | 48 ++++++-- .../api-server/src/editor_project_icon.rs | 50 +++++--- .../crates/api-server/src/llm/icon_specs.rs | 37 +++++- .../crates/api-server/src/prompt/icon_spec.rs | 60 +++++++--- .../src/editor_project_storage.rs | 109 ++++++++++++++++-- ...ImageCanvasGenerationSubmissionWorkflow.ts | 1 - 11 files changed, 296 insertions(+), 67 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index e2f3a99e9..8ba84cc0a 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -577,9 +577,9 @@ class GenarrativeExternalClient: return self.submit_and_wait_generation( "/api/external/v1/editor/icon-spritesheets/generations", { + **fields, "referenceId": reference_id, "iconDescriptions": descriptions, - **fields, }, idempotency_key=idempotency_key, ) @@ -769,6 +769,8 @@ def _self_test() -> None: ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], canvasSession=session, assetLabel="贪吃蛇透明图集", + referenceId="must-not-override-explicit-reference", + iconDescriptions=["不得覆盖显式图标描述"], ) assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" assert calls[0]["body"]["referenceId"] == "editor-resource-spec" diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6d18ccf7e..b3657ad0b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6733,6 +6733,7 @@ - `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。 - 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。 - External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。 +- `resolve_editor_reference_and_return` 的歧义只在当前 owner 范围内判断:两张表先按 `owner_user_id` 过滤,同名但属于其它账号的行不阻断合法引用。记录存在 `asset_object_id` 时按该 ID 定位并同时核对 bucket、object key 与 owner,只有缺少 ID 的兼容旧行才按位置查询。图标规范入队 / inline 预检只验证这组行与对象元数据,最终共享图片执行器再下载一次参考图正文,避免同一引用在入队、worker 预检和生成阶段重复下载。 - 关联:`server-rs/crates/api-server/src/editor_project_icon.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。 ## 2026-08-06 音效与背景音乐恢复共享音频 Composer diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 40cf16c6f..8453a1e52 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -243,7 +243,7 @@ npm run check:server-rs-ddd 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:去空后 `1..100` 条、单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 则必须保留已经过边界校验的原文,不得把 `R&B`、引号或尖括号改写成 XML entity。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:原始数组先满足 OpenAPI `1..100`,再去空且至少保留 1 条;单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 5ef063c47..23f313048 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,8 +40,8 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 -- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:去除空白项后必须保留 `1..100` 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 -- worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。同一 ID 若同时命中项目资源和素材必须按协议歧义拒绝,不得静默偏向任一表。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。解析成功后才使用返回行内已验证的 `objectKey` 读取对象,`objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 +- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 +- worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: - `referenceId`:图标主规范的正式引用,必填且只允许当前 owner 的项目资源 ID 或素材 ID。本地临时图必须先按 `assetKind="icon-spec"` 上传并登记为项目资源或账号素材,再提交返回的 `resourceId` 或 `assetId`;禁止提交 `objectKey`、URL、Data URL、Blob URL 或临时 key。 - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。 @@ -63,6 +63,8 @@ <完整用户需求> ``` +上述最终 spritesheet prompt、玩法润色 prompt、美术风格润色 prompt 和规范图生图 prompt 都是自然语言文本,必须保留已经过长度与空白校验的用户原文;不得把 `R&B`、引号或尖括号改写成 XML entity。只有 `build_extra_param_prompt` 中真正位于 `` / `` XML 元素内的数据执行 XML 转义。 + ## 像素风格后处理 - 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 7dda774fa..320a7655c 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -725,6 +725,15 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; let args = GenerateIconSpritesheetTool::normalize_args(self, args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; + self.context + .image_metadata(&args.reference_image_id) + .and_then(|metadata| metadata.reference_id.as_deref()) + .filter(|reference_id| !reference_id.trim().is_empty()) + .ok_or_else(|| { + EditorAgentToolError::invalid_args( + "reference image is not a registered resource or asset", + ) + })?; serialize_normalized_args(Self::NAME, &args) } @@ -796,11 +805,13 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { .context .image_metadata(&args.reference_image_id) .and_then(|metadata| metadata.reference_id.as_deref()) + .filter(|reference_id| !reference_id.trim().is_empty()) .ok_or_else(|| { EditorAgentToolError::invalid_args( "reference image is not a registered resource or asset", ) })? + .trim() .to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorIconSpritesheetGenerationRequest { @@ -1375,6 +1386,40 @@ mod tests { let missing = editor_agent_tool(EditImageTool::NAME, &EditorToolContext::default()) .expect("edit tool should resolve"); assert!(missing.validate_args(&normalized).is_err()); + + let registered_icon = editor_agent_tool( + GenerateIconSpritesheetTool::NAME, + &context_with_image("image-1"), + ) + .expect("icon spritesheet tool should resolve"); + assert!( + registered_icon + .validate_args(&json!({ + "reference_image_id": "image-1", + "icon_descriptions": ["背包"] + })) + .is_ok() + ); + + let mut unregistered_context = context_with_image("image-1"); + unregistered_context + .images + .get_mut(&ImageId { + id: "image-1".to_string(), + }) + .expect("test image should exist") + .reference_id = None; + let unregistered_icon = + editor_agent_tool(GenerateIconSpritesheetTool::NAME, &unregistered_context) + .expect("icon spritesheet tool should resolve"); + assert!( + unregistered_icon + .validate_args(&json!({ + "reference_image_id": "image-1", + "icon_descriptions": ["背包"] + })) + .is_err() + ); } #[test] diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5eaa06bd9..d99d2ca18 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1691,6 +1691,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( mut payload: EditorImageGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { + validate_editor_image_generation_prompt(payload.prompt.as_str())?; payload.generation_inputs = sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( @@ -1840,6 +1841,7 @@ pub(crate) async fn generate_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageGenerationRequest, ) -> Result, AppError> { + validate_editor_image_generation_prompt(payload.prompt.as_str())?; let prompt = payload.prompt.clone(); generate_editor_image_for_owner_with_prompt_builder( state, @@ -1856,6 +1858,18 @@ pub(crate) async fn generate_editor_image_for_owner( .await } +fn validate_editor_image_generation_prompt(prompt: &str) -> Result<(), AppError> { + if prompt.trim().is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-generation", + "message": "生成提示词不能为空", + })), + ); + } + Ok(()) +} + pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder( state: &AppState, request_context: &RequestContext, @@ -1875,16 +1889,6 @@ where "referenceImageSrcs", "生成参考图", )?; - let initial_prompt = payload.prompt.trim().to_string(); - if initial_prompt.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-image-generation", - "message": "生成提示词不能为空", - })), - ); - } - let normalized_kind = payload.kind.as_deref().map(str::trim); let is_character_generation = matches!(normalized_kind, Some("character")); let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); @@ -9438,7 +9442,7 @@ pub(crate) async fn validate_editor_reference_id_for_owner( ) -> Result<(), AppError> { let resolved = resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?; - let object_key = match resolved { + match resolved { EditorReferenceRecord::ProjectResource(resource) => resource.object_key, EditorReferenceRecord::Asset(asset) => asset.object_key, } @@ -9448,7 +9452,6 @@ pub(crate) async fn validate_editor_reference_id_for_owner( "message": "已验证的编辑器引用缺少 object_key。", })) })?; - read_editor_reference_image_object(state, object_key.as_str()).await?; Ok(()) } @@ -10293,6 +10296,12 @@ mod tests { thread, }; + #[test] + fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() { + assert!(validate_editor_image_generation_prompt("生成森林场景").is_ok()); + assert!(validate_editor_image_generation_prompt(" \n\t ").is_err()); + } + #[test] fn editor_asset_payload_uses_formal_sequence_fields_only() { let stored_formal_frames = json!([ @@ -13927,6 +13936,21 @@ mod tests { "parse_editor_reference_image", ], ); + assert_function_contains( + source, + "pub(crate) async fn validate_editor_reference_id_for_owner", + "pub(crate) async fn resolve_editor_icon_spec_reference", + &["resolve_editor_reference_record_by_id_for_owner("], + ); + assert_function_not_contains( + source, + "pub(crate) async fn validate_editor_reference_id_for_owner", + "pub(crate) async fn resolve_editor_icon_spec_reference", + &[ + "read_editor_reference_image_object(", + "parse_editor_reference_image(", + ], + ); } #[test] diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 47c47db42..b191634e5 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -137,7 +137,17 @@ impl ValidatedEditorIconSpritesheetPrompt { pub(crate) fn normalize_icon_descriptions( descriptions: Vec, ) -> Result { - let mut normalized = Vec::with_capacity(descriptions.len().min(EDITOR_ICON_DESCRIPTION_LIMIT)); + if descriptions.len() > EDITOR_ICON_DESCRIPTION_LIMIT { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "maxItems": EDITOR_ICON_DESCRIPTION_LIMIT, + "message": "图标素材描述数量必须在 1 到 100 个之间。", + })), + ); + } + let mut normalized = Vec::with_capacity(descriptions.len()); let mut total_chars = 0usize; let mut total_utf8_bytes = 0usize; for (item_index, description) in descriptions.into_iter().enumerate() { @@ -145,16 +155,6 @@ pub(crate) fn normalize_icon_descriptions( if description.is_empty() { continue; } - if normalized.len() >= EDITOR_ICON_DESCRIPTION_LIMIT { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-icon-spritesheet", - "field": "iconDescriptions", - "maxItems": EDITOR_ICON_DESCRIPTION_LIMIT, - "message": "图标素材描述数量必须在 1 到 100 个之间。", - })), - ); - } let actual_length = description.chars().count(); if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS { return Err( @@ -528,8 +528,7 @@ pub(crate) async fn generate_icon_spec_for_owner( let request_param = payload.request_param.clone(); let has_reference_image = payload.reference_id.is_some(); let generation_inputs = payload.generation_inputs.clone(); - let image_request = - build_icon_spec_image_request(payload, "图标规范生成中".to_string(), generation_inputs); + let image_request = build_icon_spec_image_request(payload, generation_inputs); generate_editor_image_for_owner_with_prompt_builder( state, request_context, @@ -592,11 +591,12 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result { fn build_icon_spec_image_request( payload: EditorIconSpecGenerationRequest, - prompt: String, generation_inputs: Option, ) -> EditorImageGenerationRequest { EditorImageGenerationRequest { - prompt, + // 中文注释:最终 prompt 由共享执行器的 prompt builder 在预扣后构建, + // 这里不再放置会被误校验或误持久化的占位文本。 + prompt: String::new(), size: Some(ICON_SPEC_SIZE.to_string()), kind: Some("spec".to_string()), style: None, @@ -2479,6 +2479,23 @@ mod tests { ); } + #[test] + fn icon_descriptions_reject_raw_array_over_openapi_max_items() { + let mut descriptions = vec![String::new(); EDITOR_ICON_DESCRIPTION_LIMIT]; + descriptions.push("唯一非空项".to_string()); + + let error = normalize_icon_descriptions(descriptions) + .expect_err("raw array above maxItems must fail before blank entries are discarded"); + + assert_eq!( + error + .details() + .and_then(|details| details.get("maxItems")) + .and_then(Value::as_u64), + Some(EDITOR_ICON_DESCRIPTION_LIMIT as u64) + ); + } + #[test] fn spritesheet_genre_requires_exact_game_type_title() { assert_eq!( @@ -2523,11 +2540,10 @@ mod tests { source_resource_id: Some("resource-source-1".to_string()), canvas_completion: None, }, - "最终图标规范 prompt".to_string(), Some(json!({ "fields": [], "references": [] })), ); - assert_eq!(image_request.prompt, "最终图标规范 prompt"); + assert!(image_request.prompt.is_empty()); assert_eq!( image_request.generation_inputs, Some(json!({ "fields": [], "references": [] })) diff --git a/server-rs/crates/api-server/src/llm/icon_specs.rs b/server-rs/crates/api-server/src/llm/icon_specs.rs index abcb8213d..e1ba1b9bd 100644 --- a/server-rs/crates/api-server/src/llm/icon_specs.rs +++ b/server-rs/crates/api-server/src/llm/icon_specs.rs @@ -158,23 +158,42 @@ fn normalize_refined_text(value: &str) -> Result { "> ", "- ", "* ", + "+ ", "说明:", "说明:", "解释:", "解释:", ]; - let has_disallowed_line = value.lines().any(|line| { + let has_disallowed_format = value.lines().any(|line| { let line = line.trim_start(); disallowed_prefixes .iter() .any(|prefix| line.starts_with(prefix)) + || line.starts_with(['{', '[']) + || is_markdown_ordered_list_item(line) + || ["**", "__", "~~"] + .iter() + .any(|marker| line.contains(marker)) + || line.contains('`') }); - if value.contains("```") || value.starts_with(['{', '[']) || has_disallowed_line { + if has_disallowed_format { return Err(RefinedTextError::InvalidFormat); } Ok(value.to_string()) } +fn is_markdown_ordered_list_item(line: &str) -> bool { + let digit_count = line + .chars() + .take_while(|character| character.is_ascii_digit()) + .count(); + if digit_count == 0 { + return false; + } + let suffix = &line[digit_count..]; + suffix.starts_with(". ") || suffix.starts_with(") ") +} + async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result { let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -295,6 +314,20 @@ mod tests { normalize_refined_text(r#"{"playSetting":"回合制"}"#).unwrap_err(), RefinedTextError::InvalidFormat ); + for invalid in [ + "1. 回合制占点", + "2) 资源调度", + "##优化后的玩法", + "**回合制占点**", + "正文说明\n{\"playSetting\":\"回合制\"}", + "正文说明\n[\"回合制\"]", + ] { + assert_eq!( + normalize_refined_text(invalid).unwrap_err(), + RefinedTextError::InvalidFormat, + "structured or Markdown output should fail: {invalid}", + ); + } assert_eq!( normalize_refined_text("回合制占点,围绕资源调度形成攻防循环") .expect("plain text should pass"), diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index f947b7c2b..af9eb9b4a 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -63,7 +63,7 @@ pub(crate) fn build_refine_play_setting_prompt(play_setting: &str) -> String { 只返回可直接使用的中文纯文本,可以分段或换行。 "#, - play_setting = escape_xml_prompt_data(play_setting), + play_setting = play_setting, ) } @@ -110,7 +110,7 @@ pub(crate) fn build_refine_art_style_prompt(art_style: &str) -> String { 只返回可直接使用的中文纯文本,可以分段或换行。 "#, - art_style = escape_xml_prompt_data(art_style), + art_style = art_style, ) } @@ -168,12 +168,12 @@ pub(crate) fn get_gen_icon_spec_prompt( {reference_image_prompt} 使用场景为【{use_case}】。图标设计需适配该平台的展示规范和缩略图尺寸。适合目标用户【{target_user}】玩家。 "#, - play_setting = escape_xml_prompt_data(¶m.play_setting), - art_style = escape_xml_prompt_data(¶m.art_style), - theme = escape_xml_prompt_data(&extra_param.theme), - use_case = escape_xml_prompt_data(&extra_param.use_case), + play_setting = param.play_setting.as_str(), + art_style = param.art_style.as_str(), + theme = extra_param.theme.as_str(), + use_case = extra_param.use_case.as_str(), genre_name = genre_name, - target_user = escape_xml_prompt_data(&extra_param.target_user), + target_user = extra_param.target_user.as_str(), ) } @@ -182,7 +182,7 @@ pub(crate) fn build_spritesheet_prompt( screen_color: EditorScreenBackgroundColor, genre: Option, ) -> String { - let normalized_prompt = escape_xml_prompt_data(user_prompt.as_str()); + let normalized_prompt = user_prompt.as_str(); let background_color = format!( "{label} {hex}", label = screen_color.label, @@ -313,17 +313,10 @@ mod tests { } #[test] - fn user_controlled_prompt_data_escapes_xml_reserved_characters() { + fn xml_prompt_fields_escape_reserved_characters() { let malicious = r#"'&"#; let escaped = "</playSetting></artStyle><instruction value="override">'&"; - let play_setting_prompt = build_refine_play_setting_prompt(malicious); - let art_style_prompt = build_refine_art_style_prompt(malicious); - assert!(play_setting_prompt.contains(escaped)); - assert!(art_style_prompt.contains(escaped)); - assert!(!play_setting_prompt.contains(malicious)); - assert!(!art_style_prompt.contains(malicious)); - let extra_prompt = build_extra_param_prompt(&RequestParam { play_setting: malicious.to_string(), art_style: malicious.to_string(), @@ -332,17 +325,46 @@ mod tests { assert_eq!(extra_prompt.matches("").count(), 1); assert!(extra_prompt.contains(format!("{escaped}").as_str())); assert!(extra_prompt.contains(format!("{escaped}").as_str())); + } + + #[test] + fn plain_text_prompt_fields_preserve_user_text() { + let user_text = r#"R&B <像素> "复古""#; + let escaped = "R&B <像素> "复古""; + + let play_setting_prompt = build_refine_play_setting_prompt(user_text); + let art_style_prompt = build_refine_art_style_prompt(user_text); + assert!(play_setting_prompt.contains(user_text)); + assert!(art_style_prompt.contains(user_text)); + assert!(!play_setting_prompt.contains(escaped)); + assert!(!art_style_prompt.contains(escaped)); + + let icon_spec_prompt = get_gen_icon_spec_prompt( + &RequestParam { + play_setting: user_text.to_string(), + art_style: user_text.to_string(), + }, + &ExtraParam { + genre: GameGenre::Strategy, + theme: user_text.to_string(), + use_case: user_text.to_string(), + target_user: user_text.to_string(), + }, + false, + ); + assert!(icon_spec_prompt.contains(user_text)); + assert!(!icon_spec_prompt.contains(escaped)); let spritesheet_input = - crate::editor_project_icon::normalize_icon_descriptions(vec![malicious.to_string()]) + crate::editor_project_icon::normalize_icon_descriptions(vec![user_text.to_string()]) .expect("bounded spritesheet prompt should pass"); let spritesheet_prompt = build_spritesheet_prompt( &spritesheet_input, crate::editor_green_screen::default_editor_screen_background_color(), Some(GameGenre::Strategy), ); - assert!(spritesheet_prompt.contains(escaped)); - assert!(!spritesheet_prompt.contains(malicious)); + assert!(spritesheet_prompt.contains(user_text)); + assert!(!spritesheet_prompt.contains(escaped)); } #[test] diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index f931c3303..f8df5b2e9 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -6332,12 +6332,14 @@ fn resolve_editor_reference( .db .editor_project_resource() .resource_id() - .find(&reference_id.to_string()); + .find(&reference_id.to_string()) + .filter(|resource| editor_reference_owner_matches(&resource.owner_user_id, owner_user_id)); let asset = ctx .db .editor_asset() .asset_id() - .find(&reference_id.to_string()); + .find(&reference_id.to_string()) + .filter(|asset| editor_reference_owner_matches(&asset.owner_user_id, owner_user_id)); match (resource, asset) { (Some(_), Some(_)) => Err("编辑器引用 ID 同时命中项目资源与素材,无法唯一解析".to_string()), (Some(resource), None) => { @@ -6348,6 +6350,10 @@ fn resolve_editor_reference( } } +fn editor_reference_owner_matches(actual_owner_user_id: &str, owner_user_id: &str) -> bool { + actual_owner_user_id.trim() == owner_user_id +} + fn resolve_editor_reference_resource( ctx: &ReducerContext, bucket: &str, @@ -6360,7 +6366,13 @@ fn resolve_editor_reference_resource( resource.owner_user_id.as_str(), resource.object_key.as_deref(), )?; - verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?; + verify_editor_reference_asset_object( + ctx, + bucket, + owner_user_id, + resource.asset_object_id.as_deref(), + object_key.as_str(), + )?; let mut snapshot = resource_snapshot_from_row(resource.clone()); snapshot.object_key = Some(object_key); Ok(ResolvedEditorReference::ProjectResource(snapshot)) @@ -6378,7 +6390,13 @@ fn resolve_editor_reference_asset( asset.owner_user_id.as_str(), asset.object_key.as_deref(), )?; - verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?; + verify_editor_reference_asset_object( + ctx, + bucket, + owner_user_id, + asset.asset_object_id.as_deref(), + object_key.as_str(), + )?; let mut snapshot = asset_snapshot_from_row(ctx, asset); snapshot.object_key = Some(object_key); Ok(ResolvedEditorReference::Asset(snapshot)) @@ -6406,16 +6424,42 @@ fn verify_editor_reference_asset_object( ctx: &ReducerContext, bucket: &str, owner_user_id: &str, + asset_object_id: Option<&str>, object_key: &str, ) -> Result<(), String> { - let asset_object = crate::find_asset_object_by_location( - ctx, - &AssetObjectLocationInput { - bucket: bucket.to_string(), - object_key: object_key.to_string(), - }, - )? - .ok_or_else(|| "编辑器引用对应的 asset_object 不存在".to_string())?; + let asset_object = match asset_object_id { + Some(asset_object_id) => { + let asset_object_id = asset_object_id.trim(); + if asset_object_id.is_empty() { + return Err("编辑器引用记录的 asset_object_id 不能为空".to_string()); + } + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + .ok_or_else(|| "编辑器引用记录的 asset_object_id 不存在".to_string())? + } + None => crate::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: bucket.to_string(), + object_key: object_key.to_string(), + }, + )? + .ok_or_else(|| "编辑器引用对应的 asset_object 不存在".to_string())?, + }; + validate_editor_reference_asset_object(&asset_object, bucket, owner_user_id, object_key) +} + +fn validate_editor_reference_asset_object( + asset_object: &AssetObjectUpsertSnapshot, + bucket: &str, + owner_user_id: &str, + object_key: &str, +) -> Result<(), String> { + if asset_object.bucket.trim() != bucket + || asset_object.object_key.trim().trim_start_matches('/') + != object_key.trim().trim_start_matches('/') + { + return Err("编辑器引用记录的 asset_object 与对象位置不一致".to_string()); + } if asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) { return Err("编辑器引用对应的 asset_object 不属于当前用户".to_string()); } @@ -12253,6 +12297,47 @@ mod tests { } } + #[test] + fn editor_reference_ambiguity_only_considers_current_owner_candidates() { + assert!(editor_reference_owner_matches(" user-1 ", "user-1")); + assert!(!editor_reference_owner_matches("user-2", "user-1")); + } + + #[test] + fn editor_reference_asset_object_must_match_id_bound_location_and_owner() { + let object = migration_frame_object("generated/reference.png", "asset-object-reference"); + + assert!( + validate_editor_reference_asset_object( + &object, + "editor-private", + "user-1", + "generated/reference.png", + ) + .is_ok() + ); + assert!( + validate_editor_reference_asset_object( + &object, + "editor-private", + "user-1", + "generated/other.png", + ) + .expect_err("object key mismatch should fail") + .contains("对象位置不一致") + ); + assert!( + validate_editor_reference_asset_object( + &object, + "editor-private", + "user-2", + "generated/reference.png", + ) + .expect_err("owner mismatch should fail") + .contains("不属于当前用户") + ); + } + #[test] fn character_animation_normalization_ignores_unproven_top_level_frames() { let inputs = json!({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 7dd353335..1e773ee84 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1928,7 +1928,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ nextGeneratedIndex: layerCounterRef.current + 1, canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt, }); - // TODO legacy code if (submissionPlan.kind === 'edit') { const referenceImageSrc = await resolveEditorGenerationMediaReference( submissionPlan.sourceLayer, 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 38/66] =?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 39/66] =?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 40/66] =?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 41/66] =?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 42/66] =?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 43/66] =?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 1d23f5143334b10b98cc8383b70228480bd56372 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:45:36 +0800 Subject: [PATCH 44/66] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将图标规范字段的一键优化按钮字号从 9px 调整为 12px 将对应撤销优化按钮字号同步调整为 12px --- .../image-editor/ImageCanvasSpecGenerationPanelView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 014b23742..a23d0004b 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -513,7 +513,7 @@ export function ImageCanvasSpecGenerationPanelView({ tone="ghost" size="xxs" shape="pill" - className="!gap-1 !px-2 !py-0.5 !text-[9px] !leading-3" + className="!gap-1 !px-2 !py-0.5 !text-[12px] !leading-3" aria-label={`一键优化${item.title}`} disabled={ isGenerating || @@ -547,7 +547,7 @@ export function ImageCanvasSpecGenerationPanelView({ tone="ghost" size="xxs" shape="pill" - className="!gap-1 !px-2 !py-0.5 !text-[9px] !leading-3" + className="!gap-1 !px-2 !py-0.5 !text-[12px] !leading-3" aria-label={`撤销${item.title}优化`} disabled={isGenerating} onClick={() => undoIconSpecOptimization(item.field)} 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 45/66] =?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 46/66] =?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 47/66] =?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 48/66] =?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 49/66] =?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 50/66] =?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 3451c17142bf84cc7f0fe4e8c57291f095dd723e 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 16:02:47 +0800 Subject: [PATCH 51/66] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E4=B8=BB=E5=8F=82=E8=80=83=E6=9D=83=E5=A8=81?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规划与确认时从 SpacetimeDB 重建工具上下文 仅允许 icon-spec 作为精灵图主规范并完善提示 补充普通图片拒绝与图标规范通过测试 同步后端数据契约文档 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + .../crates/api-server/src/editor_agent/api.rs | 7 +- .../api-server/src/editor_agent/context.rs | 200 +++++++++++++++++- .../api-server/src/editor_agent/tool.rs | 51 +++-- .../platform-editor-agent/src/agent/asset.rs | 2 + .../platform-editor-agent/src/agent/prompt.rs | 2 +- .../agent/tools/generate_icon_spritesheet.rs | 3 +- 7 files changed, 236 insertions(+), 30 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 167272dd1..862535314 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -83,6 +83,7 @@ npm run check:server-rs-ddd - 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 生成 token 预算;VectorEngine 专用 client 显式发送当前字段 `max_completion_tokens`,其预算包含可见输出和隐藏 reasoning token。通用 OpenAI-compatible client 默认保留旧 `max_tokens`,只有确认 endpoint 能力后才 opt-in,禁止按模型名猜测或在 `400` 后自动重放。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 +- 工具上下文不得把 OSS 消息附件当作 `assetKind` 真相;每次规划和每次确认都按附件 `source + referenceId` 经 `spacetime-client` 重新读取当前工程资源或账号素材库,只把权威 `asset_kind` 放入服务端内存 `ImageMetadata`。图标 spritesheet 的主参考必须精确为 `icon-spec`,普通图片或风格参考图只能作为额外参考;主参考类型缺失、已删除或不是 `icon-spec` 时必须在生成任务入队和用户确认生效前失败关闭,并提示重新选择图标规范。 - `edit-image` 只接受当前图片上下文中的 `object_image_id`;`source_image_id` 不是现役 schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 - api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。 diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index 83c3df142..7b72bfda2 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -197,8 +197,9 @@ pub async fn editor_agent_message( // Tool calls and attachment bookkeeping are separate system messages. let previous_messages = build_prompt_memory(&document, history_end); - // Build tool context from document - let tool_context = context::build_tool_context(&document); + // Re-read authoritative resource and asset metadata for every planning turn. The message + // document only identifies attachments; it is not the source of truth for asset kind. + let tool_context = context::build_tool_context(&state, &conversation, &document).await?; // Build and run agent let Some(llm_client) = state.vector_engine_llm_client() else { @@ -1046,7 +1047,7 @@ pub async fn confirm_editor_agent_tool_call( })) })?; let project = load_editor_agent_project(&state, &conversation).await?; - let context = context::build_tool_context(&document); + let context = context::build_tool_context(&state, &conversation, &document).await?; let tool = editor_agent_tool(tool_name.as_str(), &context) .ok_or_else(|| editor_agent_bad_request(format!("unsupported tool: {tool_name}")))?; let normalized_args = tool diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs index 4a0a84047..8de569578 100644 --- a/server-rs/crates/api-server/src/editor_agent/context.rs +++ b/server-rs/crates/api-server/src/editor_agent/context.rs @@ -1,10 +1,82 @@ use crate::editor_agent::utils::IntoDataKey; +use crate::editor_project::{current_utc_micros, map_editor_project_error}; +use crate::http_error::AppError; +use crate::state::AppState; use platform_editor_agent::agent::asset::{ImageId, ImageMetadata}; use platform_editor_agent::agent::tools::context::EditorToolContext; -use shared_contracts::editor_agent::EditorAgentConversationMessagesDocument; +use shared_contracts::editor_agent::{ + EditorAgentAttachmentSource, EditorAgentConversationMessagesDocument, +}; +use spacetime_client::{ + EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorProjectGetRecordInput, + EditorProjectRecord, +}; use std::collections::HashMap; -pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> EditorToolContext { +pub async fn build_tool_context( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, +) -> Result { + let needs_project = document.messages.iter().any(|message| { + message + .attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource) + || message.tool_call.as_ref().is_some_and(|tool_call| { + tool_call.images.iter().any(|image| { + image + .resource_id + .as_deref() + .is_some_and(|id| !id.trim().is_empty()) + }) + }) + }); + let needs_library = document.messages.iter().any(|message| { + message + .attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset) + }); + + let project = if needs_project { + Some( + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + let library = if needs_library { + Some( + state + .spacetime_client() + .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + + Ok(build_tool_context_from_authoritative_records( + document, + project.as_ref(), + library.as_ref(), + )) +} + +fn build_tool_context_from_authoritative_records( + document: &EditorAgentConversationMessagesDocument, + project: Option<&EditorProjectRecord>, + library: Option<&EditorAssetLibraryRecord>, +) -> EditorToolContext { let mut images: HashMap = HashMap::new(); for msg in document.messages.iter().rev() { @@ -14,6 +86,24 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let metadata = ImageMetadata { data_key, reference_id: Some(a.reference_id.clone()), + asset_kind: match a.source { + EditorAgentAttachmentSource::CanvasResource => project + .and_then(|project| { + project + .resources + .iter() + .find(|resource| resource.resource_id == a.reference_id) + }) + .and_then(|resource| resource.asset_kind.clone()), + EditorAgentAttachmentSource::LibraryAsset => library + .and_then(|library| { + library + .assets + .iter() + .find(|asset| asset.asset_id == a.reference_id) + }) + .and_then(|asset| asset.asset_kind.clone()), + }, image_src: a.image_src.clone(), object_key: a.object_key.clone(), thumbnail_src: a.thumbnail_src.clone(), @@ -31,6 +121,16 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let metadata = ImageMetadata { data_key, reference_id: img.resource_id.clone(), + asset_kind: img.resource_id.as_deref().and_then(|resource_id| { + project + .and_then(|project| { + project + .resources + .iter() + .find(|resource| resource.resource_id == resource_id) + }) + .and_then(|resource| resource.asset_kind.clone()) + }), image_src: img.image_src.clone(), object_key: img.object_key.clone(), thumbnail_src: img.thumbnail_src.clone(), @@ -49,10 +149,17 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> #[cfg(test)] mod tests { use super::*; + use crate::editor_agent::tool::editor_agent_tool; + use platform_editor_agent::agent::tools::generate_icon_spritesheet::GenerateIconSpritesheetTool; + use platform_editor_agent::framework::tool::Tool; + use serde_json::json; use shared_contracts::editor_agent::{ EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentMessage, EditorAgentMessageRole, }; + use spacetime_client::{ + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord, + }; fn message(id: usize, label: &str, image_src: &str) -> EditorAgentMessage { EditorAgentMessage { @@ -75,6 +182,59 @@ mod tests { } } + fn project_with_resource_asset_kind(asset_kind: &str) -> EditorProjectRecord { + let viewport = EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }; + EditorProjectRecord { + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "测试工程".to_string(), + canvas: EditorCanvasRecord { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + title: "测试画布".to_string(), + viewport: viewport.clone(), + layers: json!([]), + revision: 0, + layout_storage_version: 2, + background_color: None, + created_at: "2026-08-08T00:00:00Z".to_string(), + updated_at: "2026-08-08T00:00:00Z".to_string(), + }, + viewport, + layers: json!([]), + resources: vec![EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("object-1".to_string()), + image_src: "/api/assets/read/current.png".to_string(), + object_key: Some("generated/reference.png".to_string()), + width: 640, + height: 480, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some(asset_kind.to_string()), + generation_inputs: None, + public_showcase_enabled: false, + created_at: "2026-08-08T00:00:00Z".to_string(), + updated_at: "2026-08-08T00:00:00Z".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + }], + created_at: "2026-08-08T00:00:00Z".to_string(), + updated_at: "2026-08-08T00:00:00Z".to_string(), + } + } + #[test] fn tool_context_keeps_complete_metadata_from_the_latest_image_reference() { let document = EditorAgentConversationMessagesDocument { @@ -86,7 +246,7 @@ mod tests { ], }; - let context = build_tool_context(&document); + let context = build_tool_context_from_authoritative_records(&document, None, None); let image_id = ImageId::from_data_key("generated/reference.png"); let metadata = context .image_metadata(&image_id) @@ -98,4 +258,38 @@ mod tests { assert_eq!(metadata.width, Some(640)); assert_eq!(metadata.height, Some(480)); } + + #[test] + fn icon_spritesheet_validation_uses_authoritative_project_asset_kind() { + let document = EditorAgentConversationMessagesDocument { + version: 2, + conversation_id: "conversation-1".to_string(), + messages: vec![message(0, "主规范", "/api/assets/read/current.png")], + }; + let args = json!({ + "reference_image_id": ImageId::from_data_key("generated/reference.png").id, + "icon_descriptions": ["背包"] + }); + + let ordinary_project = project_with_resource_asset_kind("image"); + let ordinary_context = + build_tool_context_from_authoritative_records(&document, Some(&ordinary_project), None); + let ordinary_tool = editor_agent_tool(GenerateIconSpritesheetTool::NAME, &ordinary_context) + .expect("icon spritesheet tool should resolve"); + let error = ordinary_tool + .validate_args(&args) + .expect_err("ordinary registered image must be rejected before confirmation"); + assert!(error.to_string().contains("icon-spec")); + + let icon_spec_project = project_with_resource_asset_kind("icon-spec"); + let icon_spec_context = build_tool_context_from_authoritative_records( + &document, + Some(&icon_spec_project), + None, + ); + let icon_spec_tool = + editor_agent_tool(GenerateIconSpritesheetTool::NAME, &icon_spec_context) + .expect("icon spritesheet tool should resolve"); + assert!(icon_spec_tool.validate_args(&args).is_ok()); + } } diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 320a7655c..3ee20e0b3 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -719,21 +719,37 @@ impl EditorAgentTool for EditImageTool { } } -// TODO(editor-agent, 延后开发): 将主规范图的权威 assetKind 带入工具上下文,并在规划与确认阶段仅接受 icon-spec。 +fn resolve_icon_spec_reference_id<'a>( + context: &'a EditorToolContext, + image_id: &ImageId, +) -> Result<&'a str, EditorAgentToolError> { + let metadata = context.image_metadata(image_id).ok_or_else(|| { + EditorAgentToolError::invalid_args("reference image is not available in tool context") + })?; + let reference_id = metadata + .reference_id + .as_deref() + .map(str::trim) + .filter(|reference_id| !reference_id.is_empty()) + .ok_or_else(|| { + EditorAgentToolError::invalid_args( + "reference image is not a registered resource or asset", + ) + })?; + if metadata.asset_kind.as_deref().map(str::trim) != Some("icon-spec") { + return Err(EditorAgentToolError::invalid_args( + "reference image must be a registered icon-spec; select an icon specification image and try again", + )); + } + Ok(reference_id) +} + impl EditorAgentTool for GenerateIconSpritesheetTool { fn validate_args(&self, args: &Value) -> Result { let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; let args = GenerateIconSpritesheetTool::normalize_args(self, args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; - self.context - .image_metadata(&args.reference_image_id) - .and_then(|metadata| metadata.reference_id.as_deref()) - .filter(|reference_id| !reference_id.trim().is_empty()) - .ok_or_else(|| { - EditorAgentToolError::invalid_args( - "reference image is not a registered resource or asset", - ) - })?; + resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?; serialize_normalized_args(Self::NAME, &args) } @@ -801,18 +817,8 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; - let reference_id = self - .context - .image_metadata(&args.reference_image_id) - .and_then(|metadata| metadata.reference_id.as_deref()) - .filter(|reference_id| !reference_id.trim().is_empty()) - .ok_or_else(|| { - EditorAgentToolError::invalid_args( - "reference image is not a registered resource or asset", - ) - })? - .trim() - .to_string(); + let reference_id = + resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorIconSpritesheetGenerationRequest { reference_id, @@ -1201,6 +1207,7 @@ mod tests { ImageMetadata { data_key: format!("generated/{image_id}.png"), reference_id: Some(format!("resource-{image_id}")), + asset_kind: Some("icon-spec".to_string()), image_src: format!("/api/assets/read/generated/{image_id}.png"), object_key: Some(format!("generated/{image_id}.png")), thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")), diff --git a/server-rs/crates/platform-editor-agent/src/agent/asset.rs b/server-rs/crates/platform-editor-agent/src/agent/asset.rs index a83aa4c17..1ce7eba00 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/asset.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/asset.rs @@ -40,6 +40,8 @@ impl Display for ImageId { pub struct ImageMetadata { pub data_key: String, pub reference_id: Option, + #[serde(default)] + pub asset_kind: Option, pub image_src: String, pub object_key: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs index fc1bc4b41..8d56a0349 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs @@ -72,7 +72,7 @@ pub fn generate_character_tool_description() -> String { pub fn generate_icon_spritesheet_tool_description() -> String { format!( - "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。可以一次调用生成多张图" + "仅用于生成多个图标成品、图标素材图集或 spritesheet。主参考图必须是系统中已登记为 icon-spec 的图标规范图,并填写多个 icon_descriptions;普通风格参考图只能放入额外参考图,不能充当主规范。没有图标规范图时不要调用,应提示用户先选择或创建图标规范。图标规范图或图标视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。可以一次调用生成多张图" ) } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 9d867fbbd..6db172ab9 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -149,7 +149,7 @@ impl Tool for GenerateIconSpritesheetTool { json!({ "type": "object", "properties": { - "reference_image_id": { "type": "string", "description": "必填的图标规范或风格参考图 ID。" }, + "reference_image_id": { "type": "string", "description": "必填、且系统已登记为 icon-spec 的图标规范图 ID。普通风格参考图不能作为主规范。" }, "model": image_model_parameter_schema(), "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc" }, "icon_descriptions": { @@ -344,6 +344,7 @@ mod tests { ImageMetadata { data_key: "asset://reference-image".to_string(), reference_id: Some("resource-reference-image".to_string()), + asset_kind: Some("icon-spec".to_string()), image_src: "asset://reference-image".to_string(), object_key: None, thumbnail_src: None, From 57c825089b563a4b386e3c4525dc5354c6ede0ba 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 16:58:41 +0800 Subject: [PATCH 52/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=86=85=E8=81=94=E7=94=9F=E6=88=90=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 inline 图标规范生成初始化稳定 operation。 使用 durable billing 边界包裹共享执行器。 补充调用顺序回归测试与图标生成契约说明。 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../crates/api-server/src/editor_project.rs | 17 +++++++++++++++++ .../api-server/src/editor_project_icon.rs | 13 ++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 23f313048..fe07058c6 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,6 +40,7 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 8ce67fb3e..be5ecf2ac 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -15993,6 +15993,23 @@ mod tests { ); } + #[test] + fn inline_icon_spec_initializes_operation_inside_durable_billing_boundary() { + let source = include_str!("editor_project_icon.rs"); + + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_icon_spec(", + "pub(crate) async fn enqueue_icon_spec_generation_for_owner", + &[ + ".with_inline_operation(", + "EDITOR_ICON_SPEC_GENERATION_JOB_KIND", + "with_editor_generation_durable_billing_boundary(", + "generate_icon_spec_for_owner(", + ], + ); + } + #[test] fn image_generation_preflight_checks_references_provider_and_pricing() { let source = concat!( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 6286d3144..38b089377 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -492,7 +492,18 @@ pub(crate) async fn generate_icon_spec( }, )); } - generate_icon_spec_for_owner(&state, &request_context, caller, payload).await + let caller = caller.with_inline_operation( + &request_context, + EDITOR_ICON_SPEC_GENERATION_JOB_KIND, + &payload, + )?; + with_editor_generation_durable_billing_boundary(generate_icon_spec_for_owner( + &state, + &request_context, + caller, + payload, + )) + .await } pub(crate) async fn enqueue_icon_spec_generation_for_owner( From 5379e038f62b65bdb9cdad6a165b9bce0d4c3f69 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:02:13 +0800 Subject: [PATCH 53/66] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E7=9B=AE=E6=A0=87=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入队前预检并回写规范化项目与素材目录。 worker 执行前再次预检最终写入目标。 补充默认目录及副作用顺序的回归测试和契约说明。 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../crates/api-server/src/editor_project.rs | 15 ++++++ .../api-server/src/editor_project_icon.rs | 50 ++++++++++++++----- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index fe07058c6..7c90a80d3 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -41,6 +41,7 @@ - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 +- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index be5ecf2ac..e9e6a110f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -17368,11 +17368,26 @@ mod tests { &[ "payload.icon_descriptions =", "normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();", + "preflight_editor_billable_generation_target(", "resolve_editor_icon_spec_reference(", "resolve_editor_icon_spritesheet_price(", "enqueue_editor_generation_job_for_caller(", ], ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "fn resolve_editor_icon_spritesheet_source_layer_asset_kind", + &[ + "ensure_editor_reference_image_source_limit(", + "preflight_editor_billable_generation_target(", + "payload.project_id = target.project_id;", + "payload.asset_folder_id = target.asset_folder_id;", + "rebuild_editor_generation_input_references(", + "resolve_editor_icon_spec_reference(", + "execute_billable_asset_operation_with_cost(", + ], + ); } #[test] diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 38b089377..b1d851e68 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -1340,19 +1340,6 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions(); - resolve_editor_icon_spec_reference( - state, - caller.owner_user_id.as_str(), - payload.reference_id.as_str(), - ) - .await - .map_err(|error| { - error.with_details(json!({ - "provider": "editor-icon-spritesheet", - "field": "referenceId", - "message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。", - })) - })?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-icon-spritesheet", @@ -1373,6 +1360,31 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( "referenceImageSrcs", "图标素材参考图", )?; + let target = preflight_editor_billable_generation_target( + state, + caller.owner_user_id.as_str(), + payload.project_id.clone(), + payload + .asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + ) + .await?; + payload.project_id = target.project_id; + payload.asset_folder_id = target.asset_folder_id; + resolve_editor_icon_spec_reference( + state, + caller.owner_user_id.as_str(), + payload.reference_id.as_str(), + ) + .await + .map_err(|error| { + error.with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "referenceId", + "message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。", + })) + })?; let price_mud_points = u64::from( resolve_editor_icon_spritesheet_price( state, @@ -1427,6 +1439,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "referenceImageSrcs", "图标素材参考图", )?; + let target = preflight_editor_billable_generation_target( + state, + caller.owner_user_id.as_str(), + payload.project_id.clone(), + payload + .asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + ) + .await?; + payload.project_id = target.project_id; + payload.asset_folder_id = target.asset_folder_id; payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), From b91350f3f24939d16f36f9bc7d0ff67872c10fda 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:05:10 +0800 Subject: [PATCH 54/66] =?UTF-8?q?=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=9B=BE=E6=A0=87=E8=A7=84=E8=8C=83=E9=98=9F?= =?UTF-8?q?=E5=88=97=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共享原子持久化成功后 worker 直接返回成功。 移除仅用于二次 completion 的响应包装 helper。 补充 lease 完成职责回归测试与契约说明。 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../src/external_generation_worker.rs | 44 +++++++++---------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 7c90a80d3..ba5a5875f 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -42,6 +42,7 @@ - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 +- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 0aa8cd356..7b69804e3 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -893,15 +893,7 @@ async fn process_external_generation_job_once( ) .await { - Ok(response) => { - complete_editor_generation_job_with_response( - &state, - &worker_id, - &job, - &response.0, - ) - .await - } + Ok(_) => Ok(()), Err(error) => { let message = error.body_text(); fail_job(&state, &worker_id, &job, message.clone()).await?; @@ -1193,21 +1185,6 @@ async fn complete_editor_generation_job( .await } -async fn complete_editor_generation_job_with_response( - state: &AppState, - worker_id: &str, - job: &ExternalGenerationJobRecord, - response: &Value, -) -> Result<(), String> { - complete_job( - state, - worker_id, - job, - Some(editor_generation_result_payload_json(job, response)), - ) - .await -} - fn editor_generation_result_payload_json( job: &ExternalGenerationJobRecord, response: &Value, @@ -2237,6 +2214,25 @@ mod tests { ); } + #[test] + fn icon_spec_job_completion_is_committed_by_atomic_persistence() { + let source = include_str!("external_generation_worker.rs"); + let start = source + .find("EDITOR_ICON_SPEC_GENERATION_JOB_KIND => {") + .expect("icon spec worker branch should exist"); + let branch_tail = &source[start..]; + let end = branch_tail + .find("EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND => {") + .expect("icon spec worker branch end marker should exist"); + let branch = &branch_tail[..end]; + + assert!(branch.contains("Ok(_) => Ok(())")); + assert!( + !branch.contains("complete_editor_generation_job"), + "icon spec completion must be part of the atomic result persistence" + ); + } + #[test] fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() { let job = external_generation_job_record_fixture(Some("lease-1")); From 78675326c73f80028a50d7b24971a6628dd3b795 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:11:00 +0800 Subject: [PATCH 55/66] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=85=A5=E9=98=9F=E7=9B=AE=E6=A0=87=E9=A2=84?= =?UTF-8?q?=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在参考资源解析和定价前校验项目与素材目录。 将预检返回的规范化目标写入队列任务。 拆分本地字段与远端引用校验并补充顺序回归测试。 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../crates/api-server/src/editor_project.rs | 20 ++++++++++++++ .../api-server/src/editor_project_icon.rs | 26 +++++++++++++++---- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index ba5a5875f..493a573b7 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -43,6 +43,7 @@ - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 +- queued 图标规范生成在本地文本门禁后、参考资源 owner 解析和定价之前执行目标预检,并把 canonical `projectId + assetFolderId` 写入任务 payload;不存在、越权或目录不匹配必须由提交请求同步失败,不得入队后再变成 worker 失败。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e9e6a110f..27e2b47b2 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -16010,6 +16010,26 @@ mod tests { ); } + #[test] + fn queued_icon_spec_preflights_and_persists_canonical_target_before_remote_work() { + let source = include_str!("editor_project_icon.rs"); + + assert_function_contains_in_order( + source, + "pub(crate) async fn enqueue_icon_spec_generation_for_owner", + "pub(crate) async fn generate_icon_spec_for_owner", + &[ + "validate_icon_spec_request_fields(", + "preflight_editor_billable_generation_target(", + "payload.project_id = target.project_id;", + "payload.asset_folder_id = target.asset_folder_id;", + "validate_icon_spec_reference_for_owner(", + "resolve_icon_spec_price(", + "enqueue_editor_generation_job_for_caller(", + ], + ); + } + #[test] fn image_generation_preflight_checks_references_provider_and_pricing() { let source = concat!( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index b1d851e68..ee746e8a2 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -513,7 +513,17 @@ pub(crate) async fn enqueue_icon_spec_generation_for_owner( mut payload: EditorIconSpecGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { - validate_icon_spec_request(state, caller, &mut payload).await?; + validate_icon_spec_request_fields(&mut payload)?; + let target = preflight_editor_billable_generation_target( + state, + caller.owner_user_id.as_str(), + payload.project_id.clone(), + payload.asset_folder_id.clone(), + ) + .await?; + payload.project_id = target.project_id; + payload.asset_folder_id = target.asset_folder_id; + validate_icon_spec_reference_for_owner(state, caller, &payload).await?; let price_mud_points = resolve_icon_spec_price(state).await?; enqueue_editor_generation_job_for_caller( state, @@ -538,7 +548,7 @@ pub(crate) async fn generate_icon_spec_for_owner( caller: EditorGenerationCaller, mut payload: EditorIconSpecGenerationRequest, ) -> Result, AppError> { - validate_icon_spec_request(state, &caller, &mut payload).await?; + validate_icon_spec_request_fields(&mut payload)?; let request_param = payload.request_param.clone(); let has_reference_image = payload.reference_id.is_some(); let generation_inputs = payload.generation_inputs.clone(); @@ -565,9 +575,7 @@ pub(crate) async fn generate_icon_spec_for_owner( .await } -async fn validate_icon_spec_request( - state: &AppState, - caller: &EditorGenerationCaller, +fn validate_icon_spec_request_fields( payload: &mut EditorIconSpecGenerationRequest, ) -> Result<(), AppError> { payload.request_param.play_setting = require_icon_spec_generation_text( @@ -580,6 +588,14 @@ async fn validate_icon_spec_request( "artStyle", "美术风格", )?; + Ok(()) +} + +async fn validate_icon_spec_reference_for_owner( + state: &AppState, + caller: &EditorGenerationCaller, + payload: &EditorIconSpecGenerationRequest, +) -> Result<(), AppError> { if let Some(reference_id) = payload.reference_id.as_deref() { validate_editor_reference_id_for_owner(state, caller.owner_user_id.as_str(), reference_id) .await?; From 982020359734b545586f2a442b3de94fad4c2a5a 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:14:15 +0800 Subject: [PATCH 56/66] =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E5=85=A5=E9=98=9F=E9=87=8D=E5=A4=8D=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E8=A7=84=E8=8C=83=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取图标规范 metadata-only 解析器。 队列提交只校验 owner、类型、对象元数据与游戏类型。 worker 执行阶段复用元数据校验后单次读取 OSS 正文。 补充无正文读取回归测试与契约说明。 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../crates/api-server/src/editor_project.rs | 46 ++++++++++++++++--- .../api-server/src/editor_project_icon.rs | 2 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 493a573b7..2b9545e5a 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -44,6 +44,7 @@ - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 - queued 图标规范生成在本地文本门禁后、参考资源 owner 解析和定价之前执行目标预检,并把 canonical `projectId + assetFolderId` 写入任务 payload;不存在、越权或目录不匹配必须由提交请求同步失败,不得入队后再变成 worker 失败。 +- spritesheet 队列提交对主图标规范只运行 metadata-only resolver,校验 owner、`assetKind="icon-spec"`、对象元数据和保存的游戏类型,不读取 OSS object body;worker 执行时复用同一 metadata resolver 重新确认当前事实,并在通过后只下载一次图片正文。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 27e2b47b2..ecdd3145f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -11214,6 +11214,11 @@ pub(crate) struct ResolvedEditorIconSpecReference { pub(crate) genre: Option, } +pub(crate) struct EditorIconSpecReferenceMetadata { + object_key: String, + genre: Option, +} + async fn resolve_editor_reference_record_by_id_for_owner( state: &AppState, owner_user_id: &str, @@ -11271,6 +11276,20 @@ pub(crate) async fn resolve_editor_icon_spec_reference( owner_user_id: &str, reference_id: &str, ) -> Result { + let metadata = + preflight_editor_icon_spec_reference_metadata(state, owner_user_id, reference_id).await?; + let image = read_editor_reference_image_object(state, metadata.object_key.as_str()).await?; + Ok(ResolvedEditorIconSpecReference { + image, + genre: metadata.genre, + }) +} + +pub(crate) async fn preflight_editor_icon_spec_reference_metadata( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result { let reference_id = reference_id.trim(); if reference_id.is_empty() { return Err( @@ -11316,8 +11335,7 @@ pub(crate) async fn resolve_editor_icon_spec_reference( "message": "图标规范引用保存了无效的游戏类型。", })) })?; - let image = read_editor_reference_image_object(state, object_key.as_str()).await?; - Ok(ResolvedEditorIconSpecReference { image, genre }) + Ok(EditorIconSpecReferenceMetadata { object_key, genre }) } pub(crate) async fn resolve_editor_reference_object_key_for_owner( @@ -15959,21 +15977,31 @@ mod tests { assert_function_contains( source, - "async fn resolve_editor_icon_spec_reference", + "pub(crate) async fn resolve_editor_icon_spec_reference(", + "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", + &[ + "preflight_editor_icon_spec_reference_metadata(", + "read_editor_reference_image_object(", + ], + ); + assert_function_contains( + source, + "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", "pub(crate) async fn resolve_editor_reference_object_key_for_owner", &[ "resolve_editor_reference_record_by_id_for_owner(", - "read_editor_reference_image_object(", + "EditorIconSpecReferenceMetadata", ], ); assert_function_not_contains( source, - "async fn resolve_editor_icon_spec_reference", + "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", "pub(crate) async fn resolve_editor_reference_object_key_for_owner", &[ "list_editor_projects", "get_editor_asset_library", "parse_editor_reference_image", + "read_editor_reference_image_object(", ], ); assert_function_contains( @@ -17389,11 +17417,17 @@ mod tests { "payload.icon_descriptions =", "normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();", "preflight_editor_billable_generation_target(", - "resolve_editor_icon_spec_reference(", + "preflight_editor_icon_spec_reference_metadata(", "resolve_editor_icon_spritesheet_price(", "enqueue_editor_generation_job_for_caller(", ], ); + assert_function_not_contains( + source, + "pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner", + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + &["read_editor_reference_image_object("], + ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index ee746e8a2..67989ef1f 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -1388,7 +1388,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; - resolve_editor_icon_spec_reference( + preflight_editor_icon_spec_reference_metadata( state, caller.owner_user_id.as_str(), payload.reference_id.as_str(), 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 57/66] =?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 58/66] =?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 92832e56c2006b0621e265e004a536e8fb261d87 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:06:04 +0800 Subject: [PATCH 59/66] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=94=9F=E6=88=90=E5=99=A8=E7=9A=84=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=B4=A0=E6=9D=90=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色图片生成在入队与同步执行预检前恢复 project 默认目录 UI 设计素材提取在入队与 worker 执行预检前恢复 project 默认目录 新增默认目录策略及四条调用路径回归测试 --- .../crates/api-server/src/editor_project.rs | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ecdd3145f..ab2b7ef82 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2645,7 +2645,10 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( state, caller.owner_user_id.as_str(), payload.project_id.clone(), - payload.asset_folder_id.clone(), + resolve_editor_image_generation_target_folder_id( + payload.asset_folder_id.clone(), + normalized_kind, + ), ) .await?; payload.project_id = target.project_id; @@ -2865,7 +2868,10 @@ where state, caller.owner_user_id.as_str(), payload.project_id.clone(), - payload.asset_folder_id.clone(), + resolve_editor_image_generation_target_folder_id( + payload.asset_folder_id.clone(), + normalized_kind, + ), ) .await?; payload.project_id = target.project_id; @@ -7240,7 +7246,7 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( state, caller.owner_user_id.as_str(), payload.project_id.clone(), - payload.asset_folder_id.clone(), + resolve_editor_ui_design_asset_extraction_target_folder_id(payload.asset_folder_id.clone()), ) .await?; payload.project_id = target.project_id; @@ -7309,7 +7315,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( state, caller.owner_user_id.as_str(), payload.project_id.clone(), - payload.asset_folder_id.clone(), + resolve_editor_ui_design_asset_extraction_target_folder_id(payload.asset_folder_id.clone()), ) .await?; payload.project_id = target.project_id; @@ -10224,6 +10230,22 @@ pub(crate) fn normalize_generated_asset_folder_id( } } +fn resolve_editor_image_generation_target_folder_id( + asset_folder_id: Option, + normalized_kind: Option<&str>, +) -> Option { + asset_folder_id.or_else(|| { + matches!(normalized_kind, Some("character")) + .then(|| EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()) + }) +} + +fn resolve_editor_ui_design_asset_extraction_target_folder_id( + asset_folder_id: Option, +) -> Option { + asset_folder_id.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct EditorBillableGenerationTarget { pub(crate) project_id: Option, @@ -20037,6 +20059,56 @@ mod tests { assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None); } + #[test] + fn optional_folder_generators_restore_their_project_folder_defaults() { + assert_eq!( + resolve_editor_image_generation_target_folder_id(None, Some("character")).as_deref(), + Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) + ); + assert_eq!( + resolve_editor_image_generation_target_folder_id(None, Some("scene")), + None + ); + assert_eq!( + resolve_editor_image_generation_target_folder_id( + Some("owner:asset-folder:custom".to_string()), + Some("character"), + ) + .as_deref(), + Some("owner:asset-folder:custom") + ); + assert_eq!( + resolve_editor_ui_design_asset_extraction_target_folder_id(None).as_deref(), + Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) + ); + + let source = include_str!("editor_project.rs"); + for (start, end, resolver) in [ + ( + "pub(crate) async fn enqueue_editor_image_generation_for_owner", + "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", + "resolve_editor_image_generation_target_folder_id(", + ), + ( + "pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder", + "fn editor_image_generation_billing_asset_kind", + "resolve_editor_image_generation_target_folder_id(", + ), + ( + "pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner", + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "resolve_editor_ui_design_asset_extraction_target_folder_id(", + ), + ( + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + "resolve_editor_ui_design_asset_extraction_target_folder_id(", + ), + ] { + assert_function_contains(source, start, end, &[resolver]); + } + } + fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) { let start_index = source .find(start) From 84ec79a8bbf96fc41aa188214ffbe80a2fceb310 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:10:17 +0800 Subject: [PATCH 60/66] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=98=9F=E5=88=97?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E5=86=BB=E7=BB=93=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E4=B8=8E=E8=B5=84=E4=BA=A7=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker 优先读取任务 billing context 中的入队价格 扣费与 generationCostMudPoints 共用同一有效价格 新增冻结价格显式数据流回归测试 --- .../crates/api-server/src/editor_project.rs | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ab2b7ef82..a2d1ce0b4 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -65,7 +65,9 @@ use spacetime_client::{ use crate::{ api_response::json_success_body, asset_billing::{ - begin_editor_generation_commit_attempt, editor_generation_commit_attempt_was_dispatched, + begin_editor_generation_commit_attempt, + current_external_generation_billing_price_mud_points, + editor_generation_commit_attempt_was_dispatched, execute_billable_asset_operation_with_cost, mark_editor_generation_commit_dispatched, mark_editor_generation_commit_outcome_confirmed, with_editor_generation_durable_billing_boundary, @@ -2952,20 +2954,25 @@ where } else { provider_request_size }; - let configured_price_mud_points = state - .editor_generation_pricing() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - })? - .image_generation_mud_points( - normalized_kind, - Some(generation_options.model), - Some(generation_options.image_size), - ); + let effective_price_mud_points = + if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { + price_mud_points + } else { + state + .editor_generation_pricing() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points( + normalized_kind, + Some(generation_options.model), + Some(generation_options.image_size), + ) + }; let prompt_generation_inputs = payload.generation_inputs.take(); let generate_operation = async { let prompt_build = prompt_builder(prompt_generation_inputs).await?; @@ -3062,7 +3069,7 @@ where caller.owner_user_id.as_str(), editor_image_generation_billing_asset_kind(normalized_kind), request_context.request_id(), - u64::from(configured_price_mud_points), + u64::from(effective_price_mud_points), generate_operation, ) .await?; @@ -3168,7 +3175,7 @@ where .clone() .or_else(|| Some("character".to_string())), generation_inputs: generation_inputs.clone(), - generation_cost_mud_points: u64::from(configured_price_mud_points), + generation_cost_mud_points: u64::from(effective_price_mud_points), thumbnail_src: None, group_task_id: None, group_task_expected_asset_count: None, @@ -3435,7 +3442,7 @@ where generation_cost_mud_points: if is_character_generation { 0 } else { - u64::from(configured_price_mud_points) + u64::from(effective_price_mud_points) }, image_sequence_frames: None, image_sequence_duration_ms: None, @@ -15970,7 +15977,7 @@ mod tests { assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", - "let configured_price_mud_points", + "let effective_price_mud_points", &[ "is_publication_material_generation", "Some(GPT_IMAGE_2_MODEL)", @@ -18951,7 +18958,7 @@ mod tests { "fn normalize_editor_image_generation_size", &[ "execute_billable_asset_operation_with_cost", - "configured_price_mud_points", + "effective_price_mud_points", ], ); assert_function_contains( @@ -18983,6 +18990,23 @@ mod tests { ); } + #[test] + fn queued_image_generation_persists_the_frozen_billing_price() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder", + "fn editor_image_generation_billing_asset_kind", + &[ + "current_external_generation_billing_price_mud_points()", + ".editor_generation_pricing()", + "execute_billable_asset_operation_with_cost(", + "u64::from(effective_price_mud_points)", + "generation_cost_mud_points: u64::from(effective_price_mud_points)", + ], + ); + } + #[test] fn editor_ui_design_asset_extraction_commits_prepared_bundle_atomically() { let source = include_str!("editor_project.rs"); From ae78e3bd5b409c19a667a1b822034a2fe0fce90c 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:13:06 +0800 Subject: [PATCH 61/66] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=9C=AA=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=BB=93=E6=9D=9F=E7=9A=84=E5=9B=BE=E6=A0=87=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标规范元数据解析统一检查 OpenAI Chat finish_reason length 与 content_filter 结果进入既有重试循环 新增完整 JSON 仍被不完整原因拒绝的回归测试 --- .../api-server/src/editor_project_icon.rs | 88 +++++++++++++------ 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 67989ef1f..80a19e7e5 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -664,33 +664,22 @@ async fn run_icon_spec_extra_param_llm( for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS { match llm_client.run(request.clone()).await { - Ok(response) => { - let text = response.text.trim(); - if text.is_empty() { - if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 未返回可用文本", - }), - )); - } - } else { - match parse_extra_param(text) { - Ok(extra_param) => return Ok(extra_param), - Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} - Err(_) => { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 返回内容不符合图标规范参数契约", - }))); - } - } + Ok(response) => match parse_extra_param_response( + response.text.as_str(), + response.finish_reason.as_deref(), + ) { + Ok(extra_param) => return Ok(extra_param), + Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} + Err(error) => { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": "editor-icon-spec-llm", + "operation": "complete-extra-param", + "message": error.message(), + }), + )); } - } + }, Err(error) if is_retryable_llm_error(&error) => { if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { return Err(map_llm_error(error)); @@ -703,6 +692,39 @@ async fn run_icon_spec_extra_param_llm( unreachable!("icon spec extra param retry loop always returns") } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExtraParamResponseError { + Empty, + Incomplete, + Invalid, +} + +impl ExtraParamResponseError { + fn message(self) -> &'static str { + match self { + Self::Empty => "LLM 未返回可用文本", + Self::Incomplete => "LLM 返回内容未完整结束", + Self::Invalid => "LLM 返回内容不符合图标规范参数契约", + } + } +} + +fn parse_extra_param_response( + text: &str, + finish_reason: Option<&str>, +) -> Result { + if finish_reason.is_some_and(|reason| { + platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason) + }) { + return Err(ExtraParamResponseError::Incomplete); + } + let text = text.trim(); + if text.is_empty() { + return Err(ExtraParamResponseError::Empty); + } + parse_extra_param(text).map_err(|_| ExtraParamResponseError::Invalid) +} + fn parse_extra_param(text: &str) -> Result { let mut extra_param = serde_json::from_str::(text).map_err(|_| ())?; for value in [ @@ -2752,6 +2774,20 @@ mod tests { assert!(parse_extra_param(oversized.to_string().as_str()).is_err()); } + #[test] + fn extra_param_rejects_explicitly_incomplete_llm_responses() { + let complete_json = + r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家"}"#; + + for finish_reason in ["length", "content_filter"] { + assert_eq!( + parse_extra_param_response(complete_json, Some(finish_reason)), + Err(ExtraParamResponseError::Incomplete) + ); + } + assert!(parse_extra_param_response(complete_json, Some("stop")).is_ok()); + } + #[test] fn icon_spec_generation_text_enforces_prompt_max_length() { assert!( From c1342910c5a0c0db501b330366c5fdb836a5cf76 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:16:31 +0800 Subject: [PATCH 62/66] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=9A=84=E6=8C=81=E4=B9=85=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 场景 inline 路由建立稳定 operation 身份并延迟退款决策至持久化完成 更新图标图集与场景路由的生产调用计数 静态计数排除测试模块并校验场景调用顺序 --- .../crates/api-server/src/asset_billing.rs | 30 +++++++++++++++++-- .../crates/api-server/src/editor_project.rs | 13 +++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 975a45b91..11e7369eb 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -928,21 +928,45 @@ mod tests { #[test] fn every_provider_inline_route_defers_billing_until_durable_completion() { for (source, expected_calls) in [ - (include_str!("editor_project.rs"), 3), - (include_str!("editor_project_icon.rs"), 1), + (include_str!("editor_project.rs"), 4), + (include_str!("editor_project_icon.rs"), 2), (include_str!("character_animation_assets.rs"), 2), ( include_str!("vector_engine_audio_generation/generation.rs"), 2, ), ] { + let production_source = source + .rsplit_once("\n#[cfg(test)]\nmod tests") + .map(|(production_source, _)| production_source) + .unwrap_or(source); assert_eq!( - source + production_source .matches("with_editor_generation_durable_billing_boundary(") .count(), expected_calls, ); } + + let editor_source = include_str!("editor_project.rs"); + let scene_route = editor_source + .split_once("pub async fn generate_editor_scene(") + .and_then(|(_, tail)| { + tail.split_once("pub async fn generate_editor_image(") + .map(|(body, _)| body) + }) + .expect("scene route"); + let inline_operation = scene_route + .find(".with_inline_operation(") + .expect("scene inline operation"); + let billing_boundary = scene_route + .find("with_editor_generation_durable_billing_boundary(") + .expect("scene durable billing boundary"); + let provider_execution = scene_route + .rfind("generate_editor_image_for_owner(") + .expect("scene provider execution"); + assert!(inline_operation < billing_boundary); + assert!(billing_boundary < provider_execution); } #[test] diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a2d1ce0b4..406c87e9f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2559,7 +2559,18 @@ pub async fn generate_editor_scene( }, )); } - generate_editor_image_for_owner(&state, &request_context, caller, image_payload).await + let caller = caller.with_inline_operation( + &request_context, + EDITOR_IMAGE_GENERATION_JOB_KIND, + &image_payload, + )?; + with_editor_generation_durable_billing_boundary(generate_editor_image_for_owner( + &state, + &request_context, + caller, + image_payload, + )) + .await } pub async fn generate_editor_image( 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 63/66] =?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( Date: Sat, 8 Aug 2026 19:08:29 +0800 Subject: [PATCH 64/66] =?UTF-8?q?=E4=BF=AE=E6=AD=A3AGC=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=BC=95=E7=94=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI游戏创作图集请求改用External v1必填referenceId 持久生成账本兼容回读旧本地sidecar并补充请求断言 同步Agent提示合同与项目文档 --- .../src/agent/generation/canvas_generation.rs | 4 +-- .../generation/external_generation_state.rs | 36 ++++++++++++++++--- .../src-tauri/src/agent/prompt.rs | 4 +-- .../tests/collaboration/static_deliveries.rs | 2 +- .../src-tauri/src/tests/project.rs | 6 +++- .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 7 files changed, 44 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 5c9613bdd..ffa9d901e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1240,13 +1240,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) .transpose()?; let (endpoint, request_body) = if is_canonical_art_spritesheet { - let reference_image_src = canonical_reference + let reference_id = canonical_reference .as_deref() .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; ( "/api/external/v1/editor/icon-spritesheets/generations", serde_json::json!({ - "referenceImageSrc": reference_image_src, + "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), "screenColor": "auto", "aspectRatio": options.aspect_ratio, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 0de156f4f..b1e1759c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -344,11 +344,10 @@ pub(super) fn platform_art_generation_runtime_request_snapshot( (generation_kind, reference_resource_ids) } "/api/external/v1/editor/icon-spritesheets/generations" => { - let reference_resource_id = json_string_field(&request_body, "referenceImageSrc") + let reference_resource_id = json_string_field(&request_body, "referenceId") + .or_else(|| json_string_field(&request_body, "referenceImageSrc")) .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - "External Editor 图集生成账本请求缺少 referenceImageSrc".to_string() - })?; + .ok_or_else(|| "External Editor 图集生成账本请求缺少 referenceId".to_string())?; ("icon-spritesheet".to_string(), vec![reference_resource_id]) } _ => return Err("External Editor 生成账本 endpoint 不受支持".to_string()), @@ -868,6 +867,35 @@ mod external_generation_state_tests { ) .expect("prepare generation ledger"); assert!(created); + let mut spritesheet_state = prepared.clone(); + spritesheet_state.endpoint = + "/api/external/v1/editor/icon-spritesheets/generations".to_string(); + spritesheet_state.request_body_json = serde_json::json!({ + "referenceId": "resource-icon-spec", + "iconDescriptions": ["玩家主体"], + "projectId": "canvas-project", + "assetFolderId": "asset-folder" + }) + .to_string(); + assert_eq!( + platform_art_generation_runtime_request_snapshot(&spritesheet_state) + .expect("parse current icon spritesheet ledger") + .reference_resource_ids, + vec!["resource-icon-spec"] + ); + spritesheet_state.request_body_json = serde_json::json!({ + "referenceImageSrc": "legacy-resource-icon-spec", + "iconDescriptions": ["玩家主体"], + "projectId": "canvas-project", + "assetFolderId": "asset-folder" + }) + .to_string(); + assert_eq!( + platform_art_generation_runtime_request_snapshot(&spritesheet_state) + .expect("parse already persisted legacy icon spritesheet ledger") + .reference_resource_ids, + vec!["legacy-resource-icon-spec"] + ); let mut invalid_key = prepared.clone(); invalid_key.idempotency_key = "invalid key".to_string(); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7a5dc9cc4..1f3c3d5cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" ) } @@ -1224,7 +1224,7 @@ mod tests { assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions")); assert!(with_canvas.contains("玩家主体及朝向/状态")); assert!(with_canvas.contains("不得假设为塔防")); - assert!(with_canvas.contains("权威 resourceId 作为 referenceImageSrc")); + assert!(with_canvas.contains("权威 resourceId 作为 referenceId")); assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations")); assert!(with_canvas.contains("不得回退普通生图或 UI extraction")); assert!(with_canvas.contains("screenColor=auto")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 64e1785db..86e58bedc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -103,7 +103,7 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "assetKind=art-spritesheet", "用 asset.list 确认 assets/art-spec.png 已登记", "由 Runtime 形成 iconDescriptions", - "权威 resourceId 作为 referenceImageSrc", + "权威 resourceId 作为 referenceId", "POST /api/external/v1/editor/icon-spritesheets/generations", "screenColor=auto", "不得回退普通生图", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5595b49cb..71e092fb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -845,7 +845,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "fixture should exercise queued, running, and completed states" ); for expected in [ - r#""referenceImageSrc":"resource-icon-spec""#, + r#""referenceId":"resource-icon-spec""#, r#""iconDescriptions":"#, r#""screenColor":"auto""#, r#""projectId":"canvas-project-1""#, @@ -859,6 +859,10 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "generation request missing {expected}: {generation_request}" ); } + assert!( + !generation_request.contains(r#""referenceImageSrc":"#), + "generation request must not send the retired primary reference field: {generation_request}" + ); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b14b67d88..e4ff4202d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5837,7 +5837,7 @@ ## 2026-07-28 AI 游戏创作正式视觉规范与透明 spritesheet DAG - 16-task 边界:继续复用现有 seed manifest 的 `art-director / design-foundation / art-asset-plan` 三个任务,不新增平行任务、会话或素材系统。`art-director` 是正式视觉规范前置:先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`。`generationInputs.artSpec` 只是辅助结构化上下文,不能替代这张真实规范图。 -- 路由与依赖:`design-foundation` 以已登记 `assets/art-spec.png` 的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,通过 `/api/external/v1/editor/images/generations` 的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 art-spec 资源 ID 作为必填 `referenceImageSrc`,调用 `/api/external/v1/editor/icon-spritesheets/generations`,提交具体 `iconDescriptions` 与 `screenColor=auto` 生成透明 `assets/art-spritesheet.png`。严禁把 `assets/ui-prototype.png` 当作规范图引用;`art-spec.png` 缺失、不是当前画布的 `icon-spec` 或缺少稳定 `resourceId` 时,两个下游任务都必须等待 `art-director`,不得把本地路径、Data URL / Blob URL 当成稳定引用,也不得退回普通生图。单波最多 `3` 个静态职责的资源上限保持不变,调度只调整现有任务的依赖边和就绪顺序。 +- 路由与依赖:`design-foundation` 以已登记 `assets/art-spec.png` 的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,通过 `/api/external/v1/editor/images/generations` 的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 art-spec 资源 ID 作为必填 `referenceId`,调用 `/api/external/v1/editor/icon-spritesheets/generations`,提交具体 `iconDescriptions` 与 `screenColor=auto` 生成透明 `assets/art-spritesheet.png`。严禁把 `assets/ui-prototype.png` 当作规范图引用;`art-spec.png` 缺失、不是当前画布的 `icon-spec` 或缺少稳定 `resourceId` 时,两个下游任务都必须等待 `art-director`,不得把本地路径、Data URL / Blob URL 当成稳定引用,也不得退回普通生图。单波最多 `3` 个静态职责的资源上限保持不变,调度只调整现有任务的依赖边和就绪顺序。 - UI extraction 边界:`/api/external/v1/editor/ui-designs/assets/extractions` 只适用于已有且带红框标注的 UI 设计图,不是 UI 设计图生成接口,也不进入本次 canonical DAG。后续若要生成独立 UI spritesheet,必须先补红框源图生成与正式产物合同,不得直接对无标注 `ui-prototype.png` 调用 extraction。 - 完成门禁:External Editor 2xx 只表示生成请求完成。通用 `warning` 优先于 `sliceWarning`;`postprocess-failed-source-preserved` 表示 provider 源图是唯一权威结果,但不满足透明图集合同,客户端保留服务端事实并失败关闭,不登记本地正式 spritesheet、不伪造切片、不自动重跑。仅 `sliceWarning` 时完整透明图集有效,Runtime 把原始 reason 写入私有审计与 Agent observation,但不得声称独立切片存在。下载结果还必须解码并至少包含一个真实透明像素,纯 RGB 或全不透明 RGBA 一律拒绝落盘和 manifest 登记。 - OpenAPI 同步事实:2026-07-28 从 `https://www.genarrative.world/api/external/v1/openapi.json` 获取的线上合同与 `docs/openapi/genarrative-external-v1.openapi.json` 原始 SHA-256 均为 `00fa39ea8781605b895b331a579fbf097eea7892962350cb2a82bed7e1024135`,逐字节一致,因此不制造无意义 JSON diff;实现按现有公开 `screenColor`、`assetLabel`、`warning` 与 `sliceWarning` 契约更新。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 50d111c2c..201117451 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -857,7 +857,7 @@ game-project/ - 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。 - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 -- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceImageSrc` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。 +- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceId` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。 From 02b4b6955d7d06b1c2493da246ed02efe13807e8 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:38:12 +0800 Subject: [PATCH 65/66] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E7=94=9F=E6=88=90=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpacetimeDB生成结果白名单加入独立图标规范任务 新增icon-spec durable result shape回归测试 同步后端数据契约与长期决策记录 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../src/editor_project_storage.rs | 33 ++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e4ff4202d..2ed0f3e4b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6771,7 +6771,7 @@ ## 2026-08-06 图标规范与图标图集使用独立任务及 ID-only 主规范引用 -- 本条取代上一条“不新增图标规范专用 worker job”和“图标图集主规范允许 objectKey”的结论。图标规范生成使用独立 `editor_icon_spec_generation` job kind;队列保存原始强类型业务参数,worker 在统一计费操作预扣成功后执行 ExtraParam LLM、构建最终 prompt,再进入共享图片生成和既有后处理 / 持久化链。余额不足不调用文本 LLM,执行失败沿用统一退款;普通 `editor_image_generation` 的 DTO、payload 与执行行为保持不变。 +- 本条取代上一条“不新增图标规范专用 worker job”和“图标图集主规范允许 objectKey”的结论。图标规范生成使用独立 `editor_icon_spec_generation` job kind;队列保存原始强类型业务参数,worker 在统一计费操作预扣成功后执行 ExtraParam LLM、构建最终 prompt,再进入共享图片生成和既有后处理 / 持久化链。SpacetimeDB 编辑器生成结果 operation kind 白名单必须显式包含该独立 job kind,确保 Provider 成功后可以进入统一 durable receipt 原子提交;新增正式生成 job kind 时必须同步扩展白名单和模块回归。余额不足不调用文本 LLM,执行失败沿用统一退款;普通 `editor_image_generation` 的 DTO、payload 与执行行为保持不变。 - `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。 - 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。 - External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 862535314..c9f278922 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -740,7 +740,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorGenerationOperation` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:编辑器生成结果的私有 durable commit receipt,queue 与受控 inline 路径共用。主键 `operation_key` 由 owner 与 operation ID 稳定派生,同 owner 不得跨 kind 复用 operation ID;`operation_fingerprint` 绑定规范用户请求,`commit_sha256` 绑定全部 slot 候选、object/resource/asset/binding、可选 canvas 候选与 job completion。表内另固化 owner/kind/ID、可选 `project_id`、queue 的 `job_id / job_worker_id / job_lease_token / job_result_payload_sha256` 和首次提交时间;重放从已完成 job 回读权威 compact result 并核对摘要,不在 receipt 复制正文。它只证明一笔业务结果已原子提交,不是第二套 job 状态或 resource/asset/canvas read model,不保存大快照。 +- 说明:编辑器生成结果的私有 durable commit receipt,queue 与受控 inline 路径共用。主键 `operation_key` 由 owner 与 operation ID 稳定派生,同 owner 不得跨 kind 复用 operation ID;`operation_fingerprint` 绑定规范用户请求,`commit_sha256` 绑定全部 slot 候选、object/resource/asset/binding、可选 canvas 候选与 job completion。表内另固化 owner/kind/ID、可选 `project_id`、queue 的 `job_id / job_worker_id / job_lease_token / job_result_payload_sha256` 和首次提交时间;重放从已完成 job 回读权威 compact result 并核对摘要,不在 receipt 复制正文。它只证明一笔业务结果已原子提交,不是第二套 job 状态或 resource/asset/canvas read model,不保存大快照。结果提交的 operation kind 白名单必须覆盖所有进入统一持久化的正式 job kind,其中包括独立图标规范任务 `editor_icon_spec_generation`;新增 job kind 时必须在同一变更中同步白名单和模块回归。 - 重放:receipt 存在时必须核对全部绑定和权威记录,完全一致才返回 `AlreadyApplied`;同 operation 的请求或提交摘要漂移、project/job 绑定漂移、receipt 缺失但稳定 resource/asset/binding 等业务记录已存在均失败关闭。事务前单独确认的 asset object 只在全部字段与稳定候选完全一致时允许复用,不能据此补造 receipt。重放不更新 receipt 时间,不重复 job/binding 事件,不推进 canvas revision。 - 时间:`completed_at_micros` 必须为正数并固化为 receipt 完成时间;object/resource/asset/binding/canvas 候选的原时间字段与它一起进入 commit SHA-256,重放必须复用原 prepared commit 而不得重新取时。queue job 完成时间与完成事件仍使用 SpacetimeDB `ctx.timestamp`,不信任调用方时钟。 - 索引:主键 `operation_key`;`by_editor_generation_operation_owner(owner_user_id, operation_id)` 仅用于受控定位和诊断,不允许同 owner 跨 operation kind 复用同一 operation ID。 diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 41dc759ae..a61eff8c0 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -23,8 +23,9 @@ const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; // 透明图集最多产出 64 个切片,另有 provider 原图和透明整图两个正式 item。 const EDITOR_GENERATION_RESULT_MAX_ITEMS: usize = 66; -const EDITOR_GENERATION_OPERATION_KINDS: [&str; 9] = [ +const EDITOR_GENERATION_OPERATION_KINDS: [&str; 10] = [ "editor_image_generation", + "editor_icon_spec_generation", "editor_image_edit", "editor_background_removal", "editor_icon_spritesheet_generation", @@ -13703,6 +13704,36 @@ mod tests { ); } + #[test] + fn editor_generation_result_shape_allows_icon_spec_operation_kind() { + let operation_kind = "editor_icon_spec_generation"; + let operation_id = "icon-spec-job-1"; + let mut input = editor_generation_result_input(); + input.operation_kind = operation_kind.to_string(); + input.operation_id = operation_id.to_string(); + let resource = input.items[0] + .project_resource + .as_mut() + .expect("icon spec resource"); + resource.resource_id = shared_contracts::editor_generation_stable_resource_id( + "user-1", + operation_kind, + operation_id, + "primary", + ); + resource.asset_kind = Some("icon-spec".to_string()); + + assert!( + validate_editor_generation_result_shape( + &input, + "user-1", + operation_kind, + operation_id, + ) + .is_ok() + ); + } + #[test] fn editor_generation_result_rejects_cross_record_object_drift_without_candidate() { let mut input = editor_generation_result_input(); From c188accafbdb347463a8a3429f34ded808efed04 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 21:37:14 +0800 Subject: [PATCH 66/66] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=86=85=E8=81=94=E5=BC=95=E7=94=A8=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在共享执行入口转换 referenceId 前校验引用 ID 与 owner。 新增调用顺序回归测试并同步决策记录。 --- .../project-memory/shared-memory/decision-log.md | 2 +- .../crates/api-server/src/editor_project.rs | 16 ++++++++++++++++ .../crates/api-server/src/editor_project_icon.rs | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2ed0f3e4b..b01925615 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6775,7 +6775,7 @@ - `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。 - 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。 - External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。 -- `resolve_editor_reference_and_return` 的歧义只在当前 owner 范围内判断:两张表先按 `owner_user_id` 过滤,同名但属于其它账号的行不阻断合法引用。记录存在 `asset_object_id` 时按该 ID 定位并同时核对 bucket、object key 与 owner,只有缺少 ID 的兼容旧行才按位置查询。图标规范入队 / inline 预检只验证这组行与对象元数据,最终共享图片执行器再下载一次参考图正文,避免同一引用在入队、worker 预检和生成阶段重复下载。 +- `resolve_editor_reference_and_return` 的歧义只在当前 owner 范围内判断:两张表先按 `owner_user_id` 过滤,同名但属于其它账号的行不阻断合法引用。记录存在 `asset_object_id` 时按该 ID 定位并同时核对 bucket、object key 与 owner,只有缺少 ID 的兼容旧行才按位置查询。图标规范入队 / inline 预检只验证这组行与对象元数据;inline 与 worker 共用的最终执行入口还必须在把 `referenceId` 转入通用图片请求前再次执行同一 ID / owner 校验。最终共享图片执行器再下载一次参考图正文,避免同一引用在入队、worker 预检和生成阶段重复下载。 - 关联:`server-rs/crates/api-server/src/editor_project_icon.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。 ## 2026-08-06 音效与背景音乐恢复共享音频 Composer diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 406c87e9f..65ebbbd93 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -16098,6 +16098,22 @@ mod tests { ); } + #[test] + fn icon_spec_execution_validates_id_reference_before_shared_image_dispatch() { + let source = include_str!("editor_project_icon.rs"); + + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_icon_spec_for_owner", + "fn validate_icon_spec_request_fields", + &[ + "validate_icon_spec_request_fields(", + "validate_icon_spec_reference_for_owner(", + "build_icon_spec_image_request(", + ], + ); + } + #[test] fn image_generation_preflight_checks_references_provider_and_pricing() { let source = concat!( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 80a19e7e5..b9579a1a7 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -549,6 +549,7 @@ pub(crate) async fn generate_icon_spec_for_owner( mut payload: EditorIconSpecGenerationRequest, ) -> Result, AppError> { validate_icon_spec_request_fields(&mut payload)?; + validate_icon_spec_reference_for_owner(state, &caller, &payload).await?; let request_param = payload.request_param.clone(); let has_reference_image = payload.reference_id.is_some(); let generation_inputs = payload.generation_inputs.clone();