合并生成参数恢复与改造入口修复

合入 PR 135 的 GenerationInputs V2 稳定字段与严格恢复链路
统一缺失来源时保留改造入口并在点击后复检拒绝
保留主分支 SFX V2 自动时长、Loop 与权威元数据语义
将队列引用槽位与场景配方修复移植到 PR 137 的模块结构
This commit is contained in:
2026-08-08 22:03:50 +08:00
38 changed files with 5278 additions and 708 deletions
@@ -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(() => {
@@ -164,6 +164,17 @@
---
## 2026-08-04 画布生成输入 V2 原地收紧并统一回落当前默认值
- 背景:画布 `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` 缺失时仍按 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`
---
## 2026-07-30 抠图实际后端作为 generationInputs 顶层内部元数据保存
- 背景:角色、图标图集和 UI 图集抠图派生资产需要保留最终实际执行的处理后端,供后台诊断 BgFilter、阿里云通用抠图和本地键色的降级结果;把抠图模型写成 `generationInputs.fields` 的“处理模型”会进入图片信息,与用户可见输入快照语义冲突,而覆盖正式资产 `model` 又会丢失源生图模型。
@@ -177,10 +188,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`
---
@@ -1181,7 +1192,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"` `refType="asset"` 只用于匹配当前画布中已 hydrate 图层的 `resourceId/sourceAssetId`,媒体类型取匹配图层的运行时数据,不新增 owner-only 工程资源 / 素材库 resolver。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`。面板直接上传引用不是画布图层,不承诺刷新或复用恢复;已移出画布的引用同样不恢复。不兼容旧 `src` 型参考图元数据。
- 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。
- 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -6618,6 +6629,15 @@
- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.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` 只用于展示,字段值保留基础类型。引用只匹配当前已 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重绘` 或常规 `快速编辑`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType
- 背景:结构化画布保存要求图层布局项里的资源权威字段与 `editor_project_resource` 行逐字相等,否则整次 PATCH 报「与项目资源不一致」,而该 400 属于 non-retryable,会被前端保存队列静默吞掉。但读边界并不把这些值原样下发:`sanitize_editor_user_model` 会脱敏内部处理模型、`provider` 被无条件省略(见 2026-07-31 修正抠图内部元数据的普通用户读取边界),`sourceType` 则在结构化保存校验通过后被归还资源行、图层列置空,读回时整个键不存在。客户端拿不到权威值只能自己补——`resolveHydratedLayerModel` 沿来源链推导出展示用生图模型,`hydrateLayer` 把缺失的 `sourceType` 猜成 `uploaded`——再原样回写,判等于是必然失败。前者命中含 2026-07-30 之前抠图派生资源的画布,后者命中所有 generated 图层;两者都在项目重新加载后的首次保存触发,用户侧表现为「改动悄悄没保存」,完美像素因为提交前是严格保存才把服务端原文暴露出来。
@@ -6854,6 +6874,21 @@
- 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134
## 2026-08-07 确定性派生配方与改造 capability 分离
- 决策:`generationInputs` 是持久化配方 / 来源账本,不直接代表“允许改造”。完美像素、裁扩、所有手动与自动图集切片、手动去背景分别写 `image.perfect-pixel``image.crop-expand``spritesheet.split``image.remove-background`,固定 `fields: []`;有正式来源行时只保存服务端权威 `references[id="source"]`,没有正式行时为空。这四个 action 不进入改造 allowlist,历史 `pixel-art-snap-*` 同样失败关闭;整张生成图集继续保留生成 action,自动抠图仍是生成流程内部后处理。
- 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 allowlistV2 上线前的场景仅在 `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`
## 2026-08-08 AGC Vite 纳入统一用户端口段
- 背景:Linux 主开发栈已按用户分配 `100` 端口段,但后加入的 AGC Tauri 壳仍固定监听全机共享的 `3080`。同机任一用户的旧客户端都会阻塞其它用户,且 marker 中出现的动态 API 端口无法解决 Vite 本身的跨用户冲突。
File diff suppressed because one or more lines are too long
@@ -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 不启动任何 childSupervisor 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 决策和程序侧审计。
@@ -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` 仅用于中文展示,不参与路由或恢复。前端提交快照和再次提交使用同一组规范化后的模型、比例与清晰度。请求兼容字段 `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 可复用标题列表,其他素材即使带同名字段也不能获得改造入口。
## 8. 验收
@@ -164,3 +177,5 @@ prompt = 后端完整 Prompt
- 展示价、入队价、实际扣费和资源成本一致。
- 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。
- 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。
- 新场景产物保存 `scene.generate` V2 配方,点击“改造”后恢复画面内容、风格、自定义风格、模型、比例、清晰度和当前画布中仍存在的参考图,再次提交仍保存规范 V2。
- V2 场景、窄化匹配的历史场景显示“改造”;非场景素材即使 legacy 字段标题为“画面内容”也不得显示“改造”。
@@ -448,7 +448,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <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-032026-08-08 增补
新增前先确定稳定字符串、主媒体含义、专属元数据、来源血缘、下载产物、复用规则和公开边界。只修改新类别实际经过的链路,不机械改动全部结构。
@@ -464,6 +464,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/` | 新类别允许公开时贯通正式字段、卡片、弹窗和损坏数据行为 |
@@ -473,6 +474,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
其它注意事项:
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的唯一媒体类别真相;不要新增或返回并列的 `mediaType`。前端 renderer 可以保留内部派生类型,但不得回写后端。
- `assetKind` 只描述素材类别,不代表生成配方可执行或允许“改造”;新增类别必须单独完成上表的 capability 决策和验收。
- 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。
- legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。
@@ -674,6 +676,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` 图层才能构造面板的 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`
@@ -72,6 +72,7 @@
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 宣发素材的 `游戏首图``详情五图``运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data``gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。
- 图片改造入口也要保持同样约束:恢复历史参数时优先使用 `generationInputs` 中保存的模型、比例和尺寸,失败回退到当前关联参数后再映射为实际 `size`,并一并回传到编辑请求;`canvasCompletion` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数。
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -114,8 +115,9 @@
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器
- 角色动作结果图层点击“改造”时,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` 图层才能构造面板的 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。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -192,7 +194,7 @@
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示额外参考图条。图标与图集素材不展示快速编辑入口,图标规范仍可快速编辑。
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`
- 快速编辑提交前保留提示词里对原图的 `原图``当前图片``当前图``图1` 引用,不再改写成 `图N`
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`
- 普通快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`;从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图除外
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
@@ -356,8 +356,8 @@ idle
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`。BGM 生成输入快照保存输入框已经写回的 canonical Prompt,不保存助手系统模板、内部引导或预设元数据;SFX V2 快照由服务端权威构造用户 Prompt、实际英文 Prompt、请求参数、实际时长和 Loop,不保存预设 ID、助手 envelope、撤销快照或未验收翻译候选。
- 音频图层上方浮动工具栏只保留 `改造``下载按钮`点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。BGM 快照保存输入框已经写回的 canonical Prompt,不保存助手系统模板、内部引导或预设元数据;SFX V2 快照由服务端权威构造用户 Prompt、实际英文 Prompt、请求参数、实际时长和 Loop,不保存预设 ID、助手 envelope、撤销快照或未验收翻译候选。
- 音频图层上方浮动工具栏只保留 `改造``下载按钮`只有当前 owner 的可执行生成输入存在时才显示 `改造`;点击后按 `action` 恢复对应的音效或背景音乐生成面板,SFX V2 优先按服务端权威 metadata 恢复模型、自动 / 手动时长与 Loop,稳定字段 ID 作为兼容回退;面板不展示参考图组件,允许修改后生成新音频,新结果落在原音频旁边。
## 前端与 BFF 契约
@@ -506,7 +506,7 @@ POST /api/editor/audios/background-music/prompts/simplifications
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。
- 鼠标未悬停音频卡时底部播放器辅助控件不可见,悬停时从底部滑动显现;提示词位于卡片左上角。
- 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。
- 音频素材浮动工具栏显示 `改造``下载按钮``改造`对应生成面板且没有参考图组件。
- 音频素材浮动工具栏在存在可执行生成输入时显示 `改造``下载按钮`否则只显示下载;`改造` 复对应生成面板且没有参考图组件。
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
- 刷新后 layout 能恢复音频生成器和音频图层。
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,至少含 1 个有效字符且为 2012000 个 code point 时只能一键简化,超过 2000 时三个动作全部禁止且完整保留文本。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
@@ -206,14 +206,6 @@ fn ensure_editor_generation_job_matches_request(
Ok(job)
}
fn generation_input_references(value: &Value) -> Option<&Value> {
value
.as_object()
.and_then(|payload| payload.get("generationInputs"))
.and_then(Value::as_object)
.and_then(|generation_inputs| generation_inputs.get("references"))
}
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
let Some(generation_inputs) = value
.as_object_mut()
@@ -225,6 +217,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,
@@ -244,22 +240,26 @@ fn editor_generation_request_payloads_match(
if existing == requested {
return true;
}
let (Ok(mut existing), Ok(requested)) = (
let (Ok(mut existing), Ok(mut requested)) = (
serde_json::from_str::<Value>(existing),
serde_json::from_str::<Value>(requested),
) else {
return false;
};
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
// 比较,不能把两个不同请求错误复用成同一任务。
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|| generation_input_references(&existing).is_none()
|| generation_input_references(&requested).is_some()
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
// 这些站内 job 的真实来源始终由 sourceImageSrc / referenceImageSrcs 等请求字段表达,
// generationInputs.references 只携带随后由 owner-scoped 记录重建的展示槽位。滚动部署
// 期间旧任务可能没有 references、新任务可能只含安全 id;比较幂等请求时两边都移除
// 该冗余字段,实际媒体来源或其它参数的任何变化仍会冲突。视频等未迁移 job 保持严格比较。
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
}
@@ -623,6 +623,84 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn replayed_migrated_job_accepts_new_safe_reference_slots() {
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": [{"id": "prompt", "title": "提示词", "value": "same"}]
}
}))
.expect("existing payload should serialize");
let requested = serde_json::to_string(&json!({
"prompt": "same",
"referenceImageSrcs": ["asset-1"],
"generationInputs": {
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "提示词", "value": "same"}],
"references": [{"id": "reference"}]
}
}))
.expect("requested payload should serialize");
assert!(
ensure_editor_generation_job_matches_request(
job,
"user-1",
EDITOR_IMAGE_GENERATION_JOB_KIND,
requested.as_str(),
"幂等冲突",
)
.is_ok()
);
}
#[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);
+130 -43
View File
@@ -2459,19 +2459,20 @@ 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::<Vec<_>>();
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"
&& let Some(custom_style) = payload
@@ -2480,9 +2481,15 @@ 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 {
@@ -2635,7 +2642,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
) -> Result<ExternalGenerationJobRecord, AppError> {
validate_editor_image_generation_prompt(payload.prompt.as_str())?;
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
@@ -5262,7 +5269,7 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner(
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-image-edit",
@@ -5692,7 +5699,7 @@ pub async fn remove_editor_image_background(
Json(mut payload): Json<EditorBackgroundRemovalRequest>,
) -> Result<Json<Value>, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
@@ -6465,7 +6472,7 @@ pub async fn snap_editor_image_to_pixel_art(
// 任何 matting 元数据出现在这里本身就是伪造。与其余生成入口共用同一个 sanitizer,位置也
// 保持一致:在任何 IO 之前。
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
payload.generation_inputs = payload
.generation_inputs
.take()
@@ -7247,7 +7254,7 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner(
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-ui-design-asset-extraction",
@@ -8349,6 +8356,29 @@ pub(crate) fn sanitize_editor_client_generation_inputs(value: Option<Value>) ->
value.map(sanitize_editor_reserved_generation_inputs)
}
fn strip_editor_generation_input_reference_provenance(mut value: Value) -> Value {
if let Some(object) = value.as_object_mut()
&& let Some(references) = object.get_mut("references")
{
let safe_slots = references
.as_array()
.into_iter()
.flatten()
.map(|reference| {
reference
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(|id| json!({ "id": id }))
.unwrap_or_else(|| json!({}))
})
.collect();
*references = Value::Array(safe_slots);
}
value
}
fn strip_editor_generation_input_references(mut value: Value) -> Value {
if let Some(object) = value.as_object_mut() {
object.remove("references");
@@ -8360,9 +8390,57 @@ pub(crate) fn sanitize_editor_untrusted_generation_inputs(value: Option<Value>)
sanitize_editor_client_generation_inputs(value).map(strip_editor_generation_input_references)
}
pub(crate) fn sanitize_editor_queued_generation_inputs(value: Option<Value>) -> Option<Value> {
sanitize_editor_client_generation_inputs(value)
.map(strip_editor_generation_input_reference_provenance)
}
fn editor_generation_reference_slot_ids(generation_inputs: Option<&Value>) -> Vec<Option<String>> {
generation_inputs
.and_then(|value| value.get("references"))
.and_then(Value::as_array)
.map(|references| {
references
.iter()
.map(|reference| {
reference
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
})
.collect()
})
.unwrap_or_default()
}
fn build_editor_generation_reference(
id: Option<&str>,
title: String,
label: &str,
ref_type: &str,
ref_id: &str,
) -> Value {
let mut reference = json!({
"title": title,
"label": label,
"refType": ref_type,
"refId": ref_id,
});
if let Some(id) = id.map(str::trim).filter(|id| !id.is_empty()) {
reference
.as_object_mut()
.expect("generation reference should be an object")
.insert("id".to_string(), Value::String(id.to_string()));
}
reference
}
fn editor_generation_reference_from_records(
projects: &[EditorProjectRecord],
assets: &[EditorAssetRecord],
id: Option<&str>,
title: String,
source: &str,
) -> Option<Value> {
@@ -8376,20 +8454,22 @@ fn editor_generation_reference_from_records(
.flat_map(|project| project.resources.iter())
.find(|resource| resource.resource_id.trim() == source)
{
return Some(json!({
"title": title,
"label": resource.asset_kind.as_deref().unwrap_or("项目资源"),
"refType": "project-resource",
"refId": resource.resource_id,
}));
return Some(build_editor_generation_reference(
id,
title,
resource.asset_kind.as_deref().unwrap_or("项目资源"),
"project-resource",
resource.resource_id.as_str(),
));
}
if let Some(asset) = assets.iter().find(|asset| asset.asset_id.trim() == source) {
return Some(json!({
"title": title,
"label": asset.label,
"refType": "asset",
"refId": asset.asset_id,
}));
return Some(build_editor_generation_reference(
id,
title,
asset.label.as_str(),
"asset",
asset.asset_id.as_str(),
));
}
let object_key = normalize_editor_reference_object_key(source).ok()?;
@@ -8404,12 +8484,13 @@ fn editor_generation_reference_from_records(
)
})
{
return Some(json!({
"title": title,
"label": resource.asset_kind.as_deref().unwrap_or("项目资源"),
"refType": "project-resource",
"refId": resource.resource_id,
}));
return Some(build_editor_generation_reference(
id,
title,
resource.asset_kind.as_deref().unwrap_or("项目资源"),
"project-resource",
resource.resource_id.as_str(),
));
}
assets
.iter()
@@ -8421,12 +8502,13 @@ fn editor_generation_reference_from_records(
)
})
.map(|asset| {
json!({
"title": title,
"label": asset.label,
"refType": "asset",
"refId": asset.asset_id,
})
build_editor_generation_reference(
id,
title,
asset.label.as_str(),
"asset",
asset.asset_id.as_str(),
)
})
}
@@ -8436,6 +8518,7 @@ pub(crate) async fn rebuild_editor_generation_input_references(
generation_inputs: Option<Value>,
reference_sources: Vec<(String, String)>,
) -> Result<Option<Value>, AppError> {
let reference_slot_ids = editor_generation_reference_slot_ids(generation_inputs.as_ref());
let sanitized = sanitize_editor_client_generation_inputs(generation_inputs);
if sanitized.is_none() && reference_sources.is_empty() {
return Ok(None);
@@ -8456,10 +8539,14 @@ pub(crate) async fn rebuild_editor_generation_input_references(
.map_err(map_editor_project_error)?;
reference_sources
.into_iter()
.filter_map(|(title, source)| {
.enumerate()
.filter_map(|(index, (title, source))| {
editor_generation_reference_from_records(
projects.as_slice(),
library.assets.as_slice(),
reference_slot_ids
.get(index)
.and_then(|id| id.as_deref()),
title,
source.as_str(),
)
@@ -514,6 +514,8 @@ pub(crate) async fn enqueue_icon_spec_generation_for_owner(
external_idempotency_key: Option<&str>,
) -> Result<spacetime_client::ExternalGenerationJobRecord, AppError> {
validate_icon_spec_request_fields(&mut payload)?;
payload.generation_inputs =
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
let target = preflight_editor_billable_generation_target(
state,
caller.owner_user_id.as_str(),
@@ -1375,7 +1377,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
payload.icon_descriptions =
normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();
ensure_editor_reference_image_sources_are_stable(
@@ -573,6 +573,35 @@ describe('creationShowcaseModel', () => {
);
});
it('infers video cost from typed V2 duration metadata', () => {
const items = buildCreationShowcaseItems({
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'video-v2',
label: 'V2 角色宣传片',
assetKind: 'video',
imageSrc: '/generated-editor-videos/video-v2.mp4',
model: 'seedance2.0-fast',
generationInputs: {
version: 2,
action: 'video.generate',
fields: [
{ id: 'resolution', title: '清晰度', value: '720p' },
{ id: 'durationSeconds', title: '时长', value: 6 },
],
references: [],
},
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.cost).toBe(
`${calculateEditorVideoPrice('seedance2.0-fast', '720p', 6)}泥点`,
);
});
it('keeps character animation videos out of the marketing tab', () => {
const items = buildCreationShowcaseItems({
activeTab: 'marketing',
@@ -21,10 +21,7 @@ import {
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
export type ShowcaseAssetMediaType =
| 'image'
| 'video'
| 'audio'
| 'image-sequence';
'image' | 'video' | 'audio' | 'image-sequence';
export type ShowcaseAssetPreview = {
id: string;
@@ -657,7 +654,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(
@@ -666,7 +663,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
!isBuiltInPromptText(value)
);
});
const promptValue = promptField?.value.trim();
const promptValue = promptField ? String(promptField.value).trim() : '';
if (promptValue) {
return promptValue;
}
@@ -679,7 +676,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}`]
: [];
@@ -837,7 +834,14 @@ function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
const title = field.title.trim().toLowerCase();
return title === '时长' || title === 'duration';
});
const matchedValue = durationField?.value.match(/\d+(?:\.\d+)?/u)?.[0];
const durationValue = durationField?.value;
if (typeof durationValue === 'number') {
return Number.isFinite(durationValue) ? durationValue : undefined;
}
if (typeof durationValue !== 'string') {
return undefined;
}
const matchedValue = durationValue.match(/\d+(?:\.\d+)?/u)?.[0];
return matchedValue ? Number(matchedValue) : undefined;
}
@@ -849,7 +853,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)) {
@@ -957,9 +961,8 @@ function toLibraryShowcaseItem(
const sortedAssets = sortGroupAssets(group.assets);
const previews = sortedAssets
.map(toAssetPreview)
.filter(
(preview): preview is ShowcaseAssetPreview =>
Boolean(preview?.src.trim()),
.filter((preview): preview is ShowcaseAssetPreview =>
Boolean(preview?.src.trim()),
);
if (!previews.length) {
return null;
@@ -895,7 +895,15 @@ 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(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
@@ -2549,18 +2557,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: '大鱼素材',
}),
@@ -2724,8 +2737,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设置按钮',
}),
@@ -3420,7 +3436,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();
@@ -3431,7 +3446,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(
@@ -3706,8 +3720,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,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
@@ -3715,6 +3732,7 @@ describe('ImageCanvasEditorView generation integration', () => {
]),
references: expect.arrayContaining([
expect.objectContaining({
id: 'source',
title: 'UI设计图',
label: '战斗UI设计图',
}),
@@ -83,6 +83,153 @@ function buildPerfectPixelOperation(
}
describe('ImageCanvasEditorModel', () => {
it('hydrates complete V2 generation inputs without coercing values or requiring labels', () => {
expect(
generationInputsOrNull({
version: 2,
action: 'video.generate',
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset',
refId: 'asset-video-reference',
},
],
}),
).toEqual({
version: 2,
action: 'video.generate',
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset',
refId: 'asset-video-reference',
},
],
});
});
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({
version: 2,
fields: [{ title: '生成提示词', value: '不得降级' }],
references: [],
}),
).toBeNull();
expect(
generationInputsOrNull({
fields: [{ title: '生成提示词', value: '旧版配方' }],
references: [],
}),
).toEqual({
fields: [{ title: '生成提示词', value: '旧版配方' }],
references: [],
});
expect(
generationInputsOrNull({
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
}),
).toEqual({
fields: [{ title: '生成提示词', value: '仅字段旧版配方' }],
references: [],
});
});
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',
@@ -413,6 +560,43 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores V2 recipes from the persisted asset library after refresh', () => {
const generationInputs = {
version: 2 as const,
action: 'video.generate' as const,
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: true },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'asset' as const,
refId: 'asset-reference',
},
],
};
const library = normalizeAssetLibrary({
folders: [],
assets: [
{
assetId: 'asset-v2',
folderId: 'project',
label: 'V2 视频',
imageSrc: '/generated/video.mp4',
width: 1280,
height: 720,
sourceType: 'generated',
assetKind: 'video',
generationInputs,
},
],
});
expect(library.assets[0]?.generationInputs).toEqual(generationInputs);
});
it('round-trips an explicit character action into a movable sequence layer', () => {
const frames = [
{
@@ -838,6 +1022,52 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('restores V2 recipes from project resources after refresh', () => {
const generationInputs = {
version: 2 as const,
action: 'video.generate' as const,
fields: [
{ id: 'durationSeconds', title: '时长', value: 8 },
{ id: 'webSearchEnabled', title: '联网搜索', value: false },
],
references: [
{
id: 'reference',
title: '参考视频',
refType: 'project-resource' as const,
refId: 'resource-reference',
},
],
};
const hydrated = hydrateLayer(
{
layerId: 'layer-v2',
resourceId: 'resource-v2',
title: 'V2 视频',
x: 0,
y: 0,
width: 1280,
height: 720,
originalWidth: 1280,
originalHeight: 720,
zIndex: 1,
sourceType: 'generated',
},
new Map([
[
'resource-v2',
{
imageSrc: '/generated/video.mp4',
assetKind: 'video',
generationInputs,
},
],
]),
);
expect(hydrated?.generationInputs).toEqual(generationInputs);
});
it('hydrates a layer override ahead of the shared resource default', () => {
const hydrated = hydrateLayer(
{
@@ -1418,6 +1648,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 会永久留在
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
@@ -27,7 +27,10 @@ import type {
PerfectPixelOperationSnapshot,
SnapCandidate,
} from './ImageCanvasEditorTypes';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
import {
hydrateCanvasGenerationInputs,
hydrateCanvasGenerationInputsResult,
} from './ImageCanvasGenerationInputsModel';
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{
@@ -221,6 +224,7 @@ export function createLayerFromAsset(
assetKindOverride: null,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
generationInputsHydrationState: asset.generationInputsHydrationState,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
} satisfies CanvasLayer;
@@ -432,15 +436,6 @@ const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([
'originalWidth',
'originalHeight',
]);
const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']);
const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -485,62 +480,6 @@ function isStableEditorMediaReference(value: unknown): value is string {
}
}
function hydratePerfectPixelGenerationInputs(
value: unknown,
): EditorAssetGenerationInputs | null {
if (
!isSnapshotRecord(value) ||
!hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return null;
}
const fields = value.fields.flatMap((field) => {
if (
!isSnapshotRecord(field) ||
!hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) ||
typeof field.title !== 'string' ||
typeof field.value !== 'string'
) {
return [];
}
return [{ title: field.title, value: field.value }];
});
const references: EditorAssetGenerationInputs['references'] =
value.references.flatMap((reference) => {
if (
!isSnapshotRecord(reference) ||
!hasOnlySnapshotKeys(
reference,
PERFECT_PIXEL_GENERATION_REFERENCE_KEYS,
) ||
typeof reference.title !== 'string' ||
typeof reference.label !== 'string' ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
typeof reference.refId !== 'string'
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
refType: reference.refType as 'project-resource' | 'asset',
refId: reference.refId,
},
];
});
if (
fields.length !== value.fields.length ||
references.length !== value.references.length
) {
return null;
}
return { fields, references };
}
/**
* 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。
* 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份;
@@ -566,8 +505,7 @@ export function hydratePerfectPixelOperation(
value.reconcileUntil < value.submittedAt ||
value.reconcileUntil - value.submittedAt >
LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ||
value.submittedAt >
now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
value.submittedAt > now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
) {
return null;
}
@@ -613,9 +551,9 @@ export function hydratePerfectPixelOperation(
} else if (request.generationInputs === null) {
generationInputs = null;
} else {
generationInputs = hydratePerfectPixelGenerationInputs(
request.generationInputs,
);
generationInputs = hydrateCanvasGenerationInputs(request.generationInputs, {
strictWhitelist: true,
});
}
if (
request.generationInputs !== undefined &&
@@ -679,8 +617,8 @@ function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
const normalizedResourceId = resourceId?.trim();
return Boolean(
normalizedResourceId &&
!normalizedResourceId.startsWith('local-') &&
!normalizedResourceId.startsWith('generation-dialog:'),
!normalizedResourceId.startsWith('local-') &&
!normalizedResourceId.startsWith('generation-dialog:'),
);
}
@@ -1435,12 +1373,12 @@ export function hydrateLayer(
: canvasAssetKindOrNull(snapshot.assetKind);
const assetKindOverrideFellBack = Boolean(
resource &&
hasPersistedAssetKindOverride &&
persistedAssetKindOverride &&
!isCanvasAssetKindOverrideCompatible(
resourceAssetKind,
persistedAssetKindOverride,
),
hasPersistedAssetKindOverride &&
persistedAssetKindOverride &&
!isCanvasAssetKindOverrideCompatible(
resourceAssetKind,
persistedAssetKindOverride,
),
);
const assetKindOverride = assetKindOverrideFellBack
? null
@@ -1520,6 +1458,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,
@@ -1590,10 +1541,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),
@@ -1810,6 +1759,9 @@ 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,
};
@@ -1928,7 +1880,10 @@ export type CanvasAssetKindOverrideFallbackIssue = {
};
type CanvasAssetMediaFamily =
'image' | 'character-animation' | 'video' | 'audio';
| 'image'
| 'character-animation'
| 'video'
| 'audio';
function resolveCanvasAssetMediaFamily(
assetKind: CanvasAssetKind | null | undefined,
@@ -2178,8 +2133,8 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
layer.sourceAssetId === asset.id ||
Boolean(
assetSourceResourceId &&
(layer.resourceId === assetSourceResourceId ||
layer.sourceResourceId === assetSourceResourceId),
(layer.resourceId === assetSourceResourceId ||
layer.sourceResourceId === assetSourceResourceId),
) ||
Boolean(
asset.assetObjectId && layer.assetObjectId === asset.assetObjectId,
@@ -2192,124 +2147,7 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
export function generationInputsOrNull(
value: unknown,
): CanvasGenerationInputs | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
fields?: unknown;
references?: unknown;
soundEffect?: unknown;
};
const fields = Array.isArray(snapshot.fields)
? snapshot.fields.flatMap((field) => {
if (!field || typeof field !== 'object') {
return [];
}
const item = field as { title?: unknown; value?: unknown };
const title = stringOrNull(item.title);
const fieldValue = stringOrNull(item.value);
return title && fieldValue ? [{ title, value: fieldValue }] : [];
})
: [];
const references = Array.isArray(snapshot.references)
? snapshot.references.flatMap((reference) => {
if (!reference || typeof reference !== 'object') {
return [];
}
const item = reference as {
title?: unknown;
label?: unknown;
refType?: unknown;
refId?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const refType: 'project-resource' | 'asset' | null =
item.refType === 'project-resource' || item.refType === 'asset'
? item.refType
: null;
const refId = stringOrNull(item.refId);
return title && label && refType && refId
? [{ title, label, refType, refId }]
: [];
})
: [];
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
snapshot,
'soundEffect',
);
const soundEffect = hasSoundEffect
? soundEffectGenerationMetadataOrNull(snapshot.soundEffect)
: null;
if (hasSoundEffect && !soundEffect) {
return null;
}
return fields.length || references.length || soundEffect
? {
fields,
references,
...(soundEffect ? { soundEffect } : {}),
}
: null;
}
function soundEffectGenerationMetadataOrNull(
value: unknown,
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
if (!isSnapshotRecord(value)) {
return null;
}
const userPrompt =
typeof value.userPrompt === 'string' ? value.userPrompt : '';
const actualPrompt =
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
if (
value.schemaVersion !== 2 ||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
typeof value.loop !== 'boolean' ||
!userPromptValidation.ok ||
userPromptValidation.prompt !== userPrompt ||
!actualPromptValidation.ok ||
actualPromptValidation.prompt !== actualPrompt ||
typeof value.actualDurationSeconds !== 'number' ||
!Number.isFinite(value.actualDurationSeconds) ||
value.actualDurationSeconds <= 0 ||
value.actualDurationSeconds > 600
) {
return null;
}
let requestedDurationSeconds: number | null;
if (value.durationMode === 'auto') {
if (value.requestedDurationSeconds !== null) {
return null;
}
requestedDurationSeconds = null;
} else {
if (
typeof value.requestedDurationSeconds !== 'number' ||
!Number.isFinite(value.requestedDurationSeconds) ||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
) {
return null;
}
requestedDurationSeconds = value.requestedDurationSeconds;
}
return {
schemaVersion: 2,
userPrompt,
actualPrompt,
model: EDITOR_SOUND_EFFECT_MODEL,
durationMode: value.durationMode,
requestedDurationSeconds,
actualDurationSeconds: value.actualDurationSeconds,
loop: value.loop,
};
return hydrateCanvasGenerationInputs(value);
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
@@ -68,6 +68,7 @@ export type EditorAsset = {
showcaseSubmitError?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
uploadStatus?: 'uploading' | 'failed';
@@ -75,24 +76,56 @@ export type EditorAsset = {
uploadMessage?: string;
};
export type CanvasGenerationAction =
| 'image.generate'
| 'scene.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'
| 'image.perfect-pixel'
| 'spritesheet.split'
| 'image.remove-background'
| 'image.crop-expand';
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[];
soundEffect?: EditorSoundEffectGenerationMetadataV2;
};
// hydrate 后的客户端运行时来源状态;不属于 generationInputs 持久化契约。
// 用于区分真正缺少配方的 legacy 数据与损坏/未来版本的版本化配方。
export type CanvasGenerationInputsHydrationState =
| 'absent'
| 'valid'
| 'invalid-versioned';
export type CanvasLayer = {
id: string;
resourceId: string;
@@ -125,6 +158,7 @@ export type CanvasLayer = {
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
generationInputsHydrationState?: CanvasGenerationInputsHydrationState;
imageSequenceFrames?: EditorImageSequenceFrameResult[];
imageSequenceDurationMs?: number;
previewVideoPath?: string | null;
@@ -229,6 +263,7 @@ export type GenerateDialogState = {
assetLabel?: string;
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
composerOpen?: boolean;
isRedrawAsNewArtifact?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
specType?: SpecGenerationType;
@@ -1407,7 +1407,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 () => {
@@ -1925,7 +1927,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: '打开素材' }));
@@ -63,6 +63,7 @@ import type {
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
@@ -313,6 +314,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,
};
@@ -1092,6 +1094,9 @@ export function ImageCanvasEditorView({
asset.imageSequenceDurationMs ?? undefined,
assetKind: persistedAssetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs)
.state,
},
]);
setAssetFolders((currentFolders) =>
@@ -1164,6 +1169,8 @@ export function ImageCanvasEditorView({
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs).state,
},
]);
setAssetFolders((currentFolders) =>
@@ -1840,6 +1847,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);
@@ -1857,6 +1870,7 @@ export function ImageCanvasEditorView({
layersRef.current,
layer,
),
availableLayers: layersRef.current,
});
if (!restoredDraft) {
return false;
@@ -1872,10 +1886,14 @@ export function ImageCanvasEditorView({
openCanvasGenerationDialog,
setSelectedLayerId,
setSelectedLayerIds,
showGenerationWarning,
],
);
const openGeneratedLayerGenerationDialog = useCallback(
(layer: CanvasLayer) => {
// TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层?
// 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式
// 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。
if (layer.sourceType !== 'generated') {
return false;
}
@@ -205,6 +205,29 @@ describe('ImageCanvasExportModel', () => {
expect(metadata.visible.type).toBe('游戏场景');
});
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({
@@ -537,7 +537,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 (
@@ -559,7 +559,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,
})) ?? [];
@@ -31,6 +31,7 @@ import {
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
@@ -60,6 +61,22 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): 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 };
@@ -475,6 +492,41 @@ describe('ImageCanvasGenerationDialogModel', () => {
expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull();
});
it('restores persisted image parameters when creating edit drafts', () => {
const sourceLayer = createLayer({
model: IMAGE_MODEL_NANOBANANA2,
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '修改要求', value: '改成雨天' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [],
},
});
expect(createEditDialogDraft(sourceLayer)).toMatchObject({
prompt: '改成雨天',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
});
expect(
createEditDialogDraft(sourceLayer, {
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
}),
).toMatchObject({
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
});
});
it('preserves the resolved asset label in quick-edit dialog snapshots', () => {
expect(
createQuickEditGenerationDialogDraft({
@@ -882,6 +934,408 @@ 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 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, videoReference, 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' },
{ id: 'webSearchEnabled', title: '显示快照', value: false },
],
references: [
{
id: 'imageReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-image-reference',
},
{
id: 'videoReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-video-reference',
},
{
id: 'audioReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-audio-reference',
},
],
},
}),
}),
).toMatchObject({
mode: 'video',
prompt: '角色追逐镜头',
videoModel: 'seedance2.0-fast',
videoAspectRatio: '9:16',
videoResolution: '480p',
videoDurationSeconds: 8,
videoSound: 'off',
videoWebSearchEnabled: false,
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-image-reference',
label: '视觉参考',
}),
expect.objectContaining({
id: 'canvas-layer-video-reference',
label: '源视频',
mediaType: 'video',
}),
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('treats references outside the current canvas as unavailable', () => {
const availableReference = createLayer({
id: 'layer-available-reference',
resourceId: 'resource-available-reference',
});
const sourceLayer = createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '场景图' }],
references: [
{
id: 'reference',
title: '画布参考图',
refType: 'project-resource',
refId: 'resource-available-reference',
},
{
id: 'reference',
title: '面板上传参考图',
refType: 'project-resource',
refId: 'resource-panel-upload',
},
],
},
});
expect(
getUnavailableNormalizedGenerationInputReferences(sourceLayer, [
availableReference,
]),
).toEqual([
expect.objectContaining({
title: '面板上传参考图',
refId: 'resource-panel-upload',
}),
]);
});
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('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',
resourceId: 'resource-edit-source',
});
const styleReference = createLayer({
id: 'layer-style-reference',
resourceId: 'resource-style-reference',
title: '风格参考',
});
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-edit-result',
resourceId: 'resource-edit-result',
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '提示词', value: '改成雨天' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 },
{ id: 'aspectRatio', title: '比例', value: '16:9' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-edit-source',
},
{
id: 'reference',
title: '参考图 1',
refType: 'project-resource',
refId: 'resource-style-reference',
},
],
},
}),
availableLayers: [editSource, styleReference],
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'quick-edit',
prompt: '改成雨天',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '2K',
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-style-reference',
label: '风格参考',
}),
],
});
});
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(
@@ -1135,7 +1589,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
generationReferences: [{ label: '参考图' }],
});
expect(
appendGenerationReference(sceneWithReference, createLayer({ id: 'other' })),
appendGenerationReference(
sceneWithReference,
createLayer({ id: 'other' }),
),
).toMatchObject({
generationReferences: [{ label: '参考图' }, { label: '源图' }],
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
import {
EDITOR_SOUND_EFFECT_MODEL,
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import type {
CanvasGenerationAction,
CanvasGenerationInputField,
CanvasGenerationInputReference,
CanvasGenerationInputs,
CanvasGenerationInputsHydrationState,
} from './ImageCanvasEditorTypes';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
export const CANVAS_GENERATION_ACTIONS = [
'image.generate',
'scene.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',
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const satisfies readonly CanvasGenerationAction[];
const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
'image.generate',
'scene.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',
'ui-design.extract-assets',
]);
// 中文注释:image.edit 是图片快速编辑的原位替换记录,结果层就是被覆盖后的原层;
// 其 source 指向替换前的编辑目标,不能再作为“改造”新产物可靠恢复。继续修改应重新
// 使用“快速编辑”,所以 image.edit 只属于已知 action,不属于可改造 action。
const CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
const V2_GENERATION_INPUT_KEYS = new Set([
'version',
'action',
'fields',
'references',
'soundEffect',
]);
const V2_GENERATION_FIELD_KEYS = new Set(['id', 'title', 'value']);
const V2_GENERATION_REFERENCE_KEYS = new Set([
'id',
'title',
'label',
'refType',
'refId',
]);
const LEGACY_GENERATION_INPUT_KEYS = new Set([
'fields',
'references',
'soundEffect',
]);
const LEGACY_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const LEGACY_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOnlyKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
) {
return Object.keys(value).every((key) => allowedKeys.has(key));
}
function isPresentString(value: unknown): value is string {
return typeof value === 'string' && Boolean(value.trim());
}
function isGenerationInputValue(
value: unknown,
): value is CanvasGenerationInputField['value'] {
return (
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value)) ||
typeof value === 'boolean'
);
}
function soundEffectGenerationMetadataOrNull(
value: unknown,
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
if (!isRecord(value)) {
return null;
}
const userPrompt =
typeof value.userPrompt === 'string' ? value.userPrompt : '';
const actualPrompt =
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
if (
value.schemaVersion !== 2 ||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
typeof value.loop !== 'boolean' ||
!userPromptValidation.ok ||
userPromptValidation.prompt !== userPrompt ||
!actualPromptValidation.ok ||
actualPromptValidation.prompt !== actualPrompt ||
typeof value.actualDurationSeconds !== 'number' ||
!Number.isFinite(value.actualDurationSeconds) ||
value.actualDurationSeconds <= 0 ||
value.actualDurationSeconds > 600
) {
return null;
}
let requestedDurationSeconds: number | null;
if (value.durationMode === 'auto') {
if (value.requestedDurationSeconds !== null) {
return null;
}
requestedDurationSeconds = null;
} else {
if (
typeof value.requestedDurationSeconds !== 'number' ||
!Number.isFinite(value.requestedDurationSeconds) ||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
) {
return null;
}
requestedDurationSeconds = value.requestedDurationSeconds;
}
return {
schemaVersion: 2,
userPrompt,
actualPrompt,
model: EDITOR_SOUND_EFFECT_MODEL,
durationMode: value.durationMode,
requestedDurationSeconds,
actualDurationSeconds: value.actualDurationSeconds,
loop: value.loop,
};
}
export function isCanvasGenerationAction(
value: unknown,
): value is CanvasGenerationAction {
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
}
function hasNormalizedCanvasGenerationInputsStructure(
value: unknown,
strictWhitelist: boolean,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
} {
if (
!isRecord(value) ||
value.version !== 2 ||
!isCanvasGenerationAction(value.action) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return false;
}
return (
(!Object.prototype.hasOwnProperty.call(value, 'soundEffect') ||
soundEffectGenerationMetadataOrNull(value.soundEffect) !== null) &&
value.fields.every(
(field) =>
isRecord(field) &&
(!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) &&
isPresentString(field.id) &&
typeof field.title === 'string' &&
isGenerationInputValue(field.value),
) &&
value.references.every(
(reference) =>
isRecord(reference) &&
(!strictWhitelist ||
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) &&
isPresentString(reference.id) &&
typeof reference.title === 'string' &&
(reference.label === undefined ||
typeof reference.label === 'string') &&
(reference.refType === 'project-resource' ||
reference.refType === 'asset') &&
isPresentString(reference.refId),
)
);
}
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<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
!hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist)
) {
return null;
}
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
value,
'soundEffect',
);
const soundEffect = hasSoundEffect
? soundEffectGenerationMetadataOrNull(value.soundEffect)
: null;
if (hasSoundEffect && !soundEffect) {
return null;
}
return {
version: 2,
action: value.action,
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,
})),
...(soundEffect ? { soundEffect } : {}),
};
}
function hydrateLegacyGenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
strictWhitelist &&
(!hasOnlyKeys(value, LEGACY_GENERATION_INPUT_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references))
) {
return null;
}
const rawFields = Array.isArray(value.fields) ? value.fields : [];
const rawReferences = Array.isArray(value.references) ? value.references : [];
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
value,
'soundEffect',
);
const soundEffect = hasSoundEffect
? soundEffectGenerationMetadataOrNull(value.soundEffect)
: null;
if (hasSoundEffect && !soundEffect) {
return null;
}
const fields = rawFields.flatMap((field) => {
if (
!isRecord(field) ||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
(strictWhitelist
? typeof field.title !== 'string'
: !isPresentString(field.title)) ||
(strictWhitelist
? typeof field.value !== 'string'
: !isPresentString(field.value))
) {
return [];
}
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)) ||
(strictWhitelist
? typeof reference.title !== 'string'
: !isPresentString(reference.title)) ||
(strictWhitelist
? typeof reference.label !== 'string'
: !isPresentString(reference.label)) ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
(strictWhitelist
? typeof reference.refId !== 'string'
: !isPresentString(reference.refId))
) {
return [];
}
return [
{
title: reference.title as string,
label: reference.label as string,
refType: reference.refType,
refId: reference.refId as string,
} satisfies CanvasGenerationInputReference,
];
});
if (
strictWhitelist &&
(fields.length !== rawFields.length ||
references.length !== rawReferences.length)
) {
return null;
}
return strictWhitelist || fields.length || references.length || soundEffect
? { fields, references, ...(soundEffect ? { soundEffect } : {}) }
: 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 {
return hydrateCanvasGenerationInputsResult(value, options).inputs;
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More