From e2fbef79e19477bbe5219d7af946092142818c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 19:44:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=85=8D=E6=96=B9=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=A4=B1=E7=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保持背景音乐规范提示词与生成输入快照一致。 统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。 为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。 由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。 补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。 --- .../shared-memory/decision-log.md | 7 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 5 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 3 +- .../api-server/src/editor_generation_queue.rs | 64 ++- .../crates/api-server/src/editor_project.rs | 365 +++++++++++++++--- .../ImageCanvasEditorModel.test.ts | 147 +++++++ .../image-editor/ImageCanvasEditorModel.ts | 116 +----- .../image-editor/ImageCanvasEditorTypes.ts | 6 +- .../ImageCanvasGenerationInputsModel.ts | 225 +++++++++++ .../ImageCanvasGenerationModel.test.ts | 25 ++ .../ImageCanvasGenerationModel.ts | 92 ++--- .../ImageCanvasMetadataModalView.test.tsx | 23 ++ .../ImageCanvasMetadataModalView.tsx | 3 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 20 + .../perfectPixelOperationStore.test.ts | 22 +- .../useImageCanvasGenerationWorkflow.test.tsx | 63 ++- .../useImageCanvasGenerationWorkflow.ts | 32 +- .../image-editor/editorProjectClient.ts | 6 +- 18 files changed, 968 insertions(+), 256 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasGenerationInputsModel.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1ff4d2bc4..0c0170bd2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6747,3 +6747,10 @@ - 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。 - 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。 - 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能。 + +## 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;不回填历史记录。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7f06a9861..075c2e2cf 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,7 +34,7 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 -- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 references 只属于非权威展示候选,api-server 入队及直接创建资源 / 素材时删除,生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。`title/label` 只作为展示快照,不提升为资源身份。 +- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 `title/label/refType/refId` 都不可信;站内已迁移生成队列只允许在入队 payload 中保留按位置对应的非 provenance 槽位 `id`,直接创建资源 / 素材及 External API 仍删除整段客户端 references。生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建 `id + title/label/refType/refId` 后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。滚动部署的队列幂等比较会忽略这些冗余展示槽位,实际 `sourceImageSrc/referenceImageSrcs` 或其它参数变化仍必须冲突。 - 普通图片生成最多选择 5 张参考图;带主图 / 规范图的图片修改、图标素材和 UI 素材提取需要从打开面板起预留这 1 张主引用,再与 provider 容量取最小值(GPT-image-2 总计 5 张,nanobanana2 总计 14 张)。画布选择、上传和最终提交都必须阻止第 N+1 张进入请求;并发上传要把在途批次计入容量,并在创建项目资源 / 账号素材前按最新模型复验。reservation 必须绑定发起上传的 dialog / 快速编辑 / UI 提取上下文;任一参考图上传批次在途时,所有图片模型切换、从画布添加主图 / 规范图 / 参考图、提交生成、删除 / 剪切关联源图、删除其来源素材以及生成面板切换 / 关闭都要明确拒绝,批次持久化前后还要复验上下文未变化。批次部分失败时要保留并挂接已经成功持久化的引用,不能因其中一项失败而丢弃整批成功项;完成或失败并释放 reservation 后才允许继续操作。模型降容或后补主图若在操作当下已经超限,应保留原模型 / 原参考图并明确提示用户先删除,不得用 `slice` 静默丢弃;旧 dialog 或直接 API 请求由后端返回明确超限错误。 - 主站编辑器生成 POST 在浏览器端不自动重试;队列模式仍按同一 `x-request-id` 幂等重放,External v1 使用显式 `Idempotency-Key`。inline 模式没有结果级幂等时,不得因 408 / 429 / 5xx 或传输异常自动再次调用 provider。 @@ -52,6 +52,7 @@ - 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 - **完美像素的优先级判据(约束实现、评审与复查)**:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整,重做一次的代价接近于零。因此凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**;只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 - **明确禁止的补救方向**:不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。历史上引入过的同类封锁(未收口 operation 不可删除、随源图层清理豁免)已被逐条作废,不得以任何理由重新引入。既有的 `existingOperation` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。 @@ -186,7 +187,7 @@ - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 - 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 -- 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 +- 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,其新请求配方固定为 `version: 2 / action: image.perfect-pixel / fields: []`。账本外层版本不升级;嵌套 generationInputs 以共享严格白名单恢复,V2 完整接受稳定 `id`、有限数字、布尔值和可省略 `label`,旧 `{fields,references}` 请求继续按原形恢复以维持历史 fingerprint,未知键或畸形 V2 失败关闭。`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 - 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 - 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 - legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index ea143b4fe..61ec6f413 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -114,7 +114,8 @@ - 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。 - 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。 - 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。 -- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。 +- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。 +- 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。 - 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。 ## 画布保存 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 7970c8b75..1dc135a83 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -205,14 +205,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() @@ -243,22 +235,21 @@ 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::(existing), serde_json::from_str::(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; } + strip_untrusted_generation_input_references_from_payload(&mut existing); + strip_untrusted_generation_input_references_from_payload(&mut requested); existing == requested } @@ -613,6 +604,45 @@ 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_jobs_with_references_on_both_sides_compare_them_strictly() { let mut job = queue_job_fixture("queued", None); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 72a5c98f7..5c5409849 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1375,12 +1375,20 @@ pub async fn create_editor_project_resource( Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + Json(mut payload): Json, ) -> Result, AppError> { - let generation_inputs_json = serialize_editor_generation_inputs( - payload.asset_kind.as_deref(), - sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), - )?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let source_resource_id = normalize_optional_string(payload.source_resource_id.take()); + let generation_inputs = resolve_editor_project_resource_generation_inputs( + &state, + owner_user_id.as_str(), + project_id.as_str(), + source_resource_id.as_deref(), + payload.generation_inputs.take(), + ) + .await?; + let generation_inputs_json = + serialize_editor_generation_inputs(payload.asset_kind.as_deref(), generation_inputs)?; let image_sequence_frames_json = serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_editor_object_key(payload.object_key); @@ -1390,7 +1398,7 @@ pub async fn create_editor_project_resource( .create_editor_project_resource(EditorProjectResourceCreateRecordInput { resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), project_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, @@ -1402,7 +1410,7 @@ pub async fn create_editor_project_resource( model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), - source_resource_id: normalize_optional_string(payload.source_resource_id), + source_resource_id, asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, updated_at_micros: current_utc_micros(), @@ -1734,7 +1742,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { 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", @@ -2528,6 +2536,104 @@ fn build_editor_derived_asset_generation_inputs( })) } +fn build_editor_deterministic_asset_generation_inputs( + action: &str, + source_title: &str, + source: &EditorGeneratedAssetRecord, +) -> Option { + let reference = source + .resource + .as_ref() + .map(|resource| { + build_editor_generation_reference( + Some("source"), + source_title.to_string(), + source + .asset + .as_ref() + .map(|asset| asset.label.as_str()) + .unwrap_or("上一步产物"), + "project-resource", + resource.resource_id.as_str(), + ) + }) + .or_else(|| { + source.asset.as_ref().map(|asset| { + build_editor_generation_reference( + Some("source"), + source_title.to_string(), + asset.label.as_str(), + "asset", + asset.asset_id.as_str(), + ) + }) + }); + build_editor_deterministic_generation_inputs(action, reference) +} + +fn build_editor_deterministic_generation_inputs( + action: &str, + reference: Option, +) -> Option { + Some(json!({ + "version": 2, + "action": action, + "fields": [], + "references": reference.into_iter().collect::>(), + })) +} + +fn build_editor_crop_expand_generation_inputs( + source: Option<&EditorProjectResourceRecord>, +) -> Option { + let reference = source.map(|resource| { + build_editor_generation_reference( + Some("source"), + "输入图片".to_string(), + resource.asset_kind.as_deref().unwrap_or("项目资源"), + "project-resource", + resource.resource_id.as_str(), + ) + }); + build_editor_deterministic_generation_inputs("image.crop-expand", reference) +} + +async fn resolve_editor_project_resource_generation_inputs( + state: &AppState, + owner_user_id: &str, + project_id: &str, + source_resource_id: Option<&str>, + generation_inputs: Option, +) -> Result, AppError> { + let is_crop_expand = generation_inputs.as_ref().is_some_and(|value| { + value.get("version") == Some(&json!(2)) + && value.get("action").and_then(Value::as_str) == Some("image.crop-expand") + }); + if !is_crop_expand { + return Ok(sanitize_editor_untrusted_generation_inputs( + generation_inputs, + )); + } + + let Some(source_resource_id) = source_resource_id else { + return Ok(build_editor_crop_expand_generation_inputs(None)); + }; + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(map_editor_project_error)?; + let source = project.resources.iter().find(|resource| { + resource.resource_id == source_resource_id + && resource.project_id == project_id + && resource.owner_user_id == owner_user_id + }); + Ok(build_editor_crop_expand_generation_inputs(source)) +} + fn editor_generated_image_storage_profile( normalized_kind: Option<&str>, ) -> EditorGeneratedImageStorageProfile { @@ -4125,7 +4231,7 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( external_idempotency_key: Option<&str>, ) -> Result { 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", @@ -4480,7 +4586,7 @@ pub async fn remove_editor_image_background( Json(mut payload): Json, ) -> Result, 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(), @@ -4517,8 +4623,10 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller: EditorGenerationCaller, mut payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = build_editor_deterministic_generation_inputs( + "image.remove-background", + Some(json!({ "id": "source" })), + ); payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), @@ -5020,6 +5128,7 @@ async fn resolve_editor_pixel_art_source_for_owner( )?; let generation_input_reference = if let Some(source_resource) = source_resource { Some(json!({ + "id": "source", "title": "原图", "label": source_resource .label @@ -5033,6 +5142,7 @@ async fn resolve_editor_pixel_art_source_for_owner( editor_generation_reference_from_records( projects.as_slice(), library.assets.as_slice(), + Some("source"), "原图".to_string(), source, ) @@ -5040,6 +5150,7 @@ async fn resolve_editor_pixel_art_source_for_owner( editor_generation_reference_from_records( projects.as_slice(), library.assets.as_slice(), + Some("source"), "原图".to_string(), object_key.as_str(), ) @@ -5200,7 +5311,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() @@ -5320,7 +5431,7 @@ pub async fn snap_editor_image_to_pixel_art( let asset_kind = source.asset_kind; let authoritative_generation_inputs = rebuild_editor_generation_inputs_with_authoritative_references( - payload.generation_inputs.take(), + build_editor_deterministic_generation_inputs("image.perfect-pixel", None), source.generation_input_reference.into_iter().collect(), ); // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 @@ -6358,7 +6469,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { 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.reference_image_src.as_str(), "editor-icon-spritesheet", @@ -6868,8 +6979,9 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), - generation_inputs: build_editor_derived_asset_generation_inputs( - "图集拆分", + generation_inputs: build_editor_deterministic_asset_generation_inputs( + "spritesheet.split", + "输入图集", &spritesheet_record, ), path_kind: "icon-spritesheet-assets", @@ -7144,8 +7256,17 @@ pub async fn split_editor_icon_spritesheet( .provider .clone() .unwrap_or_else(|| "Genarrative".to_string()); - // 手动拆图是确定性派生操作,切片不得继承源图集的生成输入,避免错误展示“改造”。 - let generation_inputs = None; + // 手动拆图是确定性派生操作,只记录动作和已鉴权源图集,不具备“改造”能力。 + let generation_inputs = build_editor_deterministic_generation_inputs( + "spritesheet.split", + Some(build_editor_generation_reference( + Some("source"), + "输入图集".to_string(), + source_resource.asset_kind.as_deref().unwrap_or("项目资源"), + "project-resource", + source_resource.resource_id.as_str(), + )), + ); let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, @@ -7539,7 +7660,7 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( external_idempotency_key: Option<&str>, ) -> Result { 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", @@ -8013,8 +8134,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), - generation_inputs: build_editor_derived_asset_generation_inputs( - "图集拆分", + generation_inputs: build_editor_deterministic_asset_generation_inputs( + "spritesheet.split", + "输入图集", &spritesheet_record, ), path_kind: "ui-design-assets", @@ -8509,6 +8631,29 @@ pub(crate) fn sanitize_editor_client_generation_inputs(value: Option) -> 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"); @@ -8520,9 +8665,57 @@ pub(crate) fn sanitize_editor_untrusted_generation_inputs(value: Option) sanitize_editor_client_generation_inputs(value).map(strip_editor_generation_input_references) } +fn sanitize_editor_queued_generation_inputs(value: Option) -> Option { + sanitize_editor_client_generation_inputs(value) + .map(strip_editor_generation_input_reference_provenance) +} + +fn editor_generation_reference_slot_ids(generation_inputs: Option<&Value>) -> Vec> { + 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 { @@ -8536,20 +8729,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()?; @@ -8564,12 +8759,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() @@ -8581,12 +8777,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(), + ) }) } @@ -8601,6 +8798,7 @@ async fn rebuild_editor_generation_input_references( return Ok(None); } + let reference_slot_ids = editor_generation_reference_slot_ids(sanitized.as_ref()); let references = if reference_sources.is_empty() { Vec::new() } else { @@ -8616,10 +8814,12 @@ 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(), ) @@ -12748,10 +12948,13 @@ mod tests { } #[test] - fn untrusted_generation_inputs_drop_client_claimed_references() { - let sanitized = sanitize_editor_untrusted_generation_inputs(Some(json!({ - "fields": [{"title": "角色设定", "value": "红发骑士"}], + fn untrusted_generation_inputs_keep_only_safe_reference_slot_ids() { + let sanitized = sanitize_editor_queued_generation_inputs(Some(json!({ + "version": 2, + "action": "image.edit", + "fields": [{"id": "prompt", "title": "角色设定", "value": "红发骑士"}], "references": [{ + "id": "source", "title": "伪造引用", "label": "其他用户素材", "refType": "asset", @@ -12762,9 +12965,52 @@ mod tests { assert_eq!( sanitized["fields"], - json!([{"title": "角色设定", "value": "红发骑士"}]) + json!([{"id": "prompt", "title": "角色设定", "value": "红发骑士"}]) + ); + assert_eq!(sanitized["references"], json!([{"id": "source"}])); + } + + #[test] + fn deterministic_generation_inputs_keep_action_and_authoritative_source_only() { + let reference = build_editor_generation_reference( + Some("source"), + "输入图集".to_string(), + "图标图集", + "project-resource", + "resource-sheet", + ); + let inputs = + build_editor_deterministic_generation_inputs("spritesheet.split", Some(reference)) + .expect("deterministic metadata should exist"); + + assert_eq!(inputs["version"], json!(2)); + assert_eq!(inputs["action"], json!("spritesheet.split")); + assert_eq!(inputs["fields"], json!([])); + assert_eq!(inputs["references"][0]["id"], json!("source")); + assert_eq!(inputs["references"][0]["refId"], json!("resource-sheet")); + + let without_source = + build_editor_deterministic_generation_inputs("image.perfect-pixel", None) + .expect("action metadata should survive without a formal source row"); + assert_eq!(without_source["references"], json!([])); + + let mut crop_source = test_editor_project_resource_record( + "resource-crop-source", + "project-1", + "generated-character-drafts/editor/crop-source.png", + None, + None, + ); + crop_source.asset_kind = Some("character".to_string()); + let crop_inputs = build_editor_crop_expand_generation_inputs(Some(&crop_source)) + .expect("crop-expand metadata should exist"); + assert_eq!(crop_inputs["action"], json!("image.crop-expand")); + assert_eq!(crop_inputs["fields"], json!([])); + assert_eq!(crop_inputs["references"][0]["id"], json!("source")); + assert_eq!( + crop_inputs["references"][0]["refId"], + json!("resource-crop-source") ); - assert!(sanitized.get("references").is_none()); } #[test] @@ -12787,27 +13033,32 @@ mod tests { let resource_reference = editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), + Some("specReference"), "参考图 1".to_string(), "generated-character-drafts/editor/shared.png", ) .expect("owned resource object key should produce provenance"); assert_eq!(resource_reference["refType"], "project-resource"); assert_eq!(resource_reference["refId"], "resource-1"); + assert_eq!(resource_reference["id"], "specReference"); let asset_reference = editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), + Some("reference"), "参考图 2".to_string(), "asset-1", ) .expect("owned asset id should produce provenance"); assert_eq!(asset_reference["refType"], "asset"); assert_eq!(asset_reference["refId"], "asset-1"); + assert_eq!(asset_reference["id"], "reference"); assert!( editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), + Some("reference"), "参考图 3".to_string(), "asset-forged", ) @@ -12818,8 +13069,11 @@ mod tests { #[test] fn perfect_pixel_replaces_client_references_with_authoritative_source() { let untrusted = sanitize_editor_untrusted_generation_inputs(Some(json!({ - "fields": [{"title": "处理", "value": "完美像素"}], + "version": 2, + "action": "image.perfect-pixel", + "fields": [], "references": [{ + "id": "source", "title": "伪造跨账号引用", "label": "其他账号素材", "refType": "asset", @@ -12829,6 +13083,7 @@ mod tests { let rebuilt = rebuild_editor_generation_inputs_with_authoritative_references( untrusted, vec![json!({ + "id": "source", "title": "原图", "label": "当前项目资源", "refType": "project-resource", @@ -12837,9 +13092,13 @@ mod tests { ) .expect("perfect pixel metadata should be rebuilt"); + assert_eq!(rebuilt["version"], json!(2)); + assert_eq!(rebuilt["action"], json!("image.perfect-pixel")); + assert_eq!(rebuilt["fields"], json!([])); assert_eq!( rebuilt["references"], json!([{ + "id": "source", "title": "原图", "label": "当前项目资源", "refType": "project-resource", diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 09306c90b..9d344345f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -13,6 +13,7 @@ import { DEFAULT_CANVAS_BACKGROUND_COLOR, dropDeadInlineGenerationPlaceholders, formatCanvasDisplayScalePercent, + generationInputsOrNull, hydrateCanvasGenerationDialog, hydrateLayer, INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, @@ -82,6 +83,69 @@ 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('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('keeps the resource default kind separate from a layer override', () => { const layer = { id: 'layer-shared', @@ -412,6 +476,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 = [ { @@ -837,6 +938,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( { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 76670abdd..5165abaaa 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -21,6 +21,7 @@ import type { PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; +import { hydrateCanvasGenerationInputs } from './ImageCanvasGenerationInputsModel'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { @@ -425,15 +426,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 { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -478,62 +470,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 身份; @@ -606,9 +542,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 && @@ -2164,49 +2100,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; - }; - 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 }] - : []; - }) - : []; - - return fields.length || references.length ? { fields, references } : null; + return hydrateCanvasGenerationInputs(value); } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 7e5a21133..a4b1b9276 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -80,7 +80,11 @@ export type CanvasGenerationAction = | 'audio.background-music.generate' | 'character-animation.generate' | 'image.edit' - | 'ui-design.extract-assets'; + | 'ui-design.extract-assets' + | 'image.perfect-pixel' + | 'spritesheet.split' + | 'image.remove-background' + | 'image.crop-expand'; export type CanvasGenerationInputValue = string | number | boolean; diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts new file mode 100644 index 000000000..0e729d260 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -0,0 +1,225 @@ +import type { + CanvasGenerationAction, + CanvasGenerationInputField, + CanvasGenerationInputReference, + CanvasGenerationInputs, +} from './ImageCanvasEditorTypes'; + +export const CANVAS_GENERATION_ACTIONS = [ + 'image.generate', + 'spec.generate', + 'character.generate', + 'icon.generate', + 'ui-design.generate', + 'publication.generate', + 'video.generate', + 'audio.sound-effect.generate', + 'audio.background-music.generate', + 'character-animation.generate', + 'image.edit', + 'ui-design.extract-assets', + 'image.perfect-pixel', + 'spritesheet.split', + 'image.remove-background', + 'image.crop-expand', +] as const satisfies readonly CanvasGenerationAction[]; + +export const REMIXABLE_CANVAS_GENERATION_ACTIONS = + new Set([ + 'image.generate', + 'spec.generate', + 'character.generate', + 'icon.generate', + 'ui-design.generate', + 'publication.generate', + 'video.generate', + 'audio.sound-effect.generate', + 'audio.background-music.generate', + 'character-animation.generate', + 'image.edit', + 'ui-design.extract-assets', + ]); + +const CANVAS_GENERATION_ACTION_SET = new Set(CANVAS_GENERATION_ACTIONS); +const V2_GENERATION_INPUT_KEYS = new Set([ + 'version', + 'action', + 'fields', + 'references', +]); +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']); +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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Record, + allowedKeys: ReadonlySet, +) { + 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' + ); +} + +export function isCanvasGenerationAction( + value: unknown, +): value is CanvasGenerationAction { + return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value); +} + +export function isNormalizedCanvasGenerationInputsStructure( + value: unknown, +): 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 ( + value.fields.every( + (field) => + isRecord(field) && + hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) && + isPresentString(field.id) && + typeof field.title === 'string' && + isGenerationInputValue(field.value), + ) && + value.references.every( + (reference) => + isRecord(reference) && + 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), + ) + ); +} + +function cloneV2GenerationInputs( + value: Record, + strictWhitelist: boolean, +): CanvasGenerationInputs | null { + if ( + (strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) || + !isNormalizedCanvasGenerationInputsStructure(value) + ) { + return null; + } + return { + version: 2, + action: value.action, + fields: value.fields.map((field) => ({ ...field })), + references: value.references.map((reference) => ({ ...reference })), + }; +} + +function hydrateLegacyGenerationInputs( + value: Record, + 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 fields = rawFields.flatMap((field) => { + if ( + !isRecord(field) || + (strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) || + !isPresentString(field.title) || + !isPresentString(field.value) + ) { + return []; + } + return [{ title: field.title, value: field.value }]; + }); + const references = rawReferences.flatMap((reference) => { + if ( + !isRecord(reference) || + (strictWhitelist && + !hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) || + !isPresentString(reference.title) || + !isPresentString(reference.label) || + (reference.refType !== 'project-resource' && + reference.refType !== 'asset') || + !isPresentString(reference.refId) + ) { + return []; + } + return [ + { + title: reference.title, + label: reference.label, + refType: reference.refType, + refId: reference.refId, + } satisfies CanvasGenerationInputReference, + ]; + }); + if ( + strictWhitelist && + (fields.length !== rawFields.length || + references.length !== rawReferences.length) + ) { + return null; + } + return strictWhitelist || fields.length || references.length + ? { fields, references } + : null; +} + +export function hydrateCanvasGenerationInputs( + value: unknown, + options: { strictWhitelist?: boolean } = {}, +): CanvasGenerationInputs | null { + if (!isRecord(value)) { + return null; + } + if ('version' in value || 'action' in value) { + return cloneV2GenerationInputs(value, options.strictWhitelist === true); + } + return hydrateLegacyGenerationInputs(value, options.strictWhitelist === true); +} diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 512bb8923..86293356f 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -495,6 +495,31 @@ describe('ImageCanvasGenerationModel', () => { generationInputs: buildImageGenerationInputs('上传文件'), }), ).toBe(false); + for (const action of [ + 'image.perfect-pixel', + 'spritesheet.split', + 'image.remove-background', + 'image.crop-expand', + ] as const) { + expect( + canOpenRedrawPanel({ + ...generatedLayer, + generationInputs: { + version: 2, + action, + fields: [], + references: [], + }, + }), + ).toBe(false); + } + expect( + canOpenRedrawPanel({ + ...generatedLayer, + taskId: 'pixel-art-snap-legacy-dialog', + generationInputs: buildImageGenerationInputs('历史继承配方'), + }), + ).toBe(false); }); it('rejects malformed persisted generation metadata without throwing', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 547c75f2f..ba2a17356 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -23,6 +23,10 @@ import type { SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; +import { + isNormalizedCanvasGenerationInputsStructure, + REMIXABLE_CANVAS_GENERATION_ACTIONS, +} from './ImageCanvasGenerationInputsModel'; import { getPublicationMaterialsWorkflow, type PublicationMaterialsWorkflow, @@ -1110,6 +1114,25 @@ export function createLayerGenerationInputReference( ); } +export function buildDeterministicGenerationInputs( + action: + | 'image.perfect-pixel' + | 'spritesheet.split' + | 'image.remove-background' + | 'image.crop-expand', + sourceLayer: CanvasLayer, + sourceTitle = '原图', +): CanvasGenerationInputs { + return { + version: 2, + action, + fields: [], + references: createLayerGenerationInputReference(sourceTitle, sourceLayer, { + id: 'source', + }), + }; +} + export function appendLimitedQuickEditReferences( references: CharacterReferenceImage[] | undefined, nextReferences: CharacterReferenceImage[], @@ -1154,57 +1177,13 @@ export function formatGenerationInputValue(value: CanvasGenerationInputValue) { return typeof value === 'string' ? value : String(value); } -const CANVAS_GENERATION_ACTIONS = new Set([ - 'image.generate', - 'spec.generate', - 'character.generate', - 'icon.generate', - 'ui-design.generate', - 'publication.generate', - 'video.generate', - 'audio.sound-effect.generate', - 'audio.background-music.generate', - 'character-animation.generate', - 'image.edit', - 'ui-design.extract-assets', -]); - export function isNormalizedCanvasGenerationInputs( value: CanvasGenerationInputs | null | undefined, ): value is CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; } { - return Boolean( - value?.version === 2 && - value.action && - CANVAS_GENERATION_ACTIONS.has(value.action) && - Array.isArray(value.fields) && - Array.isArray(value.references) && - value.fields.every( - (field) => - Boolean(field) && - typeof field.id === 'string' && - Boolean(field.id.trim()) && - typeof field.title === 'string' && - (typeof field.value === 'string' || - (typeof field.value === 'number' && Number.isFinite(field.value)) || - typeof field.value === 'boolean'), - ) && - value.references.every( - (reference) => - Boolean(reference) && - typeof reference.id === 'string' && - Boolean(reference.id.trim()) && - typeof reference.title === 'string' && - (reference.label === undefined || - typeof reference.label === 'string') && - (reference.refType === 'project-resource' || - reference.refType === 'asset') && - typeof reference.refId === 'string' && - Boolean(reference.refId.trim()), - ), - ); + return isNormalizedCanvasGenerationInputsStructure(value); } type NormalizedCanvasGenerationInputs = CanvasGenerationInputs & { @@ -1566,11 +1545,17 @@ export function canOpenRedrawPanel( layer: CanvasLayer, availableLayers: CanvasLayer[] = [], ) { - if (layer.sourceType === 'uploaded') { + if ( + layer.sourceType === 'uploaded' || + layer.taskId?.startsWith('pixel-art-snap-') + ) { return false; } const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs); if (decodedInputs.ok) { + if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { + return false; + } if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { return true; } @@ -1741,11 +1726,16 @@ export function buildBackgroundMusicGenerationInputs( return { version: 2, action: 'audio.background-music.generate', - fields: createGenerationInputField( - 'gpt_description_prompt', - gptDescriptionPrompt, - 'prompt', - ), + fields: + gptDescriptionPrompt === '' + ? [] + : [ + { + id: 'prompt', + title: 'gpt_description_prompt', + value: gptDescriptionPrompt, + }, + ], references: [], }; } diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index 37b31d860..ceabe0db1 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -224,6 +224,29 @@ describe('ImageCanvasMetadataModalView', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('renders true and false generation input values as visible text', () => { + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(within(dialog).getByText('true')).toBeTruthy(); + expect(within(dialog).getByText('false')).toBeTruthy(); + }); + it('renders audio duration from generation inputs instead of layer metadata', () => { render( {field.title} - {field.value} + {formatGenerationInputValue(field.value)} ))} {generationInputReferences.length ? ( diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index d01d498a6..11a574730 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -372,6 +372,26 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy(); }); + it.each([ + 'image.perfect-pixel', + 'spritesheet.split', + 'image.remove-background', + 'image.crop-expand', + ] as const)('never exposes redraw for deterministic action %s', (action) => { + renderSelectedToolbar({ + selectedLayer: createLayer({ + generationInputs: { + version: 2, + action, + fields: [], + references: [], + }, + }), + }); + + expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); + }); + it('removes raster edit actions for video and character animation layers', () => { renderSelectedToolbar({ selectedLayer: createLayer({ diff --git a/src/components/image-editor/perfectPixelOperationStore.test.ts b/src/components/image-editor/perfectPixelOperationStore.test.ts index 8860263df..42d4a1f2c 100644 --- a/src/components/image-editor/perfectPixelOperationStore.test.ts +++ b/src/components/image-editor/perfectPixelOperationStore.test.ts @@ -62,7 +62,27 @@ describe('perfectPixelOperationStore', () => { }); it('round-trips an operation for the same owner and project', () => { - const operation = buildOperation('dialog-1'); + const operation = buildOperation('dialog-1', { + request: { + ...buildOperation('dialog-1').request, + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [ + { id: 'scale', title: '数字兼容', value: 2 }, + { id: 'enabled', title: '布尔兼容', value: false }, + ], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, + }, + }); savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation); const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 6a846bf38..978b4f4f1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -291,6 +291,19 @@ function createHydratedPerfectPixelDialog({ sourceImageSrc: 'generated-images/editor/source.png', projectId, sourceResourceId: 'resource-source', + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, assetLabel: '源图 · 完美像素', canvasCompletion: { dialogId: operationId, @@ -2401,6 +2414,18 @@ describe('useImageCanvasGenerationWorkflow', () => { height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', + generationInputs: { + version: 2, + action: 'image.crop-expand', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, }), ); await waitFor(() => { @@ -2572,7 +2597,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: undefined, targetLayerId: 'layer-source', assetKind: 'character', - generationInputs: null, + generationInputs: { + version: 2, + action: 'image.remove-background', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, assetFolderId: undefined, assetLabel: '源图 去背景', sourceResourceId: 'resource-source', @@ -2619,6 +2655,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: 'project-1', targetLayerId: 'layer-source', assetLabel: '源图 去背景', + generationInputs: { + version: 2, + action: 'image.remove-background', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, canvasCompletion: expect.objectContaining({ dialogId: 'generation-dialog-1', title: '源图 去背景', @@ -2751,7 +2799,18 @@ describe('useImageCanvasGenerationWorkflow', () => { projectId: 'project-1', sourceResourceId: 'resource-source', assetKind: 'character', - generationInputs: sourceLayer.generationInputs, + generationInputs: { + version: 2, + action: 'image.perfect-pixel', + fields: [], + references: [ + expect.objectContaining({ + id: 'source', + refType: 'project-resource', + refId: 'resource-source', + }), + ], + }, assetLabel: '源图 · 完美像素', canvasCompletion: expect.objectContaining({ dialogId: flushedOperation!.operationId, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 29dfcd3ca..2c042fe46 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -87,6 +87,7 @@ import { } from './ImageCanvasGenerationDialogModel'; import { appendLimitedImageReferences, + buildDeterministicGenerationInputs, calculateCharacterAnimationPrice, CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, CHARACTER_ANIMATION_DURATION_OPTIONS, @@ -2177,7 +2178,10 @@ export function useImageCanvasGenerationWorkflow({ taskId: cropExpandSourceLayer.taskId, sourceResourceId: cropExpandSourceLayer.resourceId, assetKind: cropExpandSourceLayer.assetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.crop-expand', + cropExpandSourceLayer, + ), }, ); cropExpandResourceId = cropExpandResource.resourceId; @@ -2212,7 +2216,10 @@ export function useImageCanvasGenerationWorkflow({ assetObjectId: cropExpandAssetObjectId, sourceAssetId: null, assetKind: cropExpandAssetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.crop-expand', + cropExpandSourceLayer, + ), }; captureCanvasHistory({ type: 'expand-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); @@ -2289,7 +2296,10 @@ export function useImageCanvasGenerationWorkflow({ projectId, targetLayerId: sourceLayer.id, assetKind: sourceLayer.assetKind, - generationInputs: null, + generationInputs: buildDeterministicGenerationInputs( + 'image.remove-background', + sourceLayer, + ), assetFolderId, assetLabel, sourceResourceId: sourceLayer.resourceId, @@ -2570,18 +2580,10 @@ export function useImageCanvasGenerationWorkflow({ ...(sourceLayer.assetKind ? { assetKind: sourceLayer.assetKind } : {}), - ...(sourceLayer.generationInputs - ? { - generationInputs: { - fields: sourceLayer.generationInputs.fields.map((field) => ({ - ...field, - })), - references: sourceLayer.generationInputs.references.map( - (reference) => ({ ...reference }), - ), - }, - } - : {}), + generationInputs: buildDeterministicGenerationInputs( + 'image.perfect-pixel', + sourceLayer, + ), ...(assetFolderId ? { assetFolderId } : {}), assetLabel, canvasCompletion: { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index f499089eb..6676526d3 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -138,7 +138,11 @@ export type EditorAssetGenerationInputs = { | 'audio.background-music.generate' | 'character-animation.generate' | 'image.edit' - | 'ui-design.extract-assets'; + | 'ui-design.extract-assets' + | 'image.perfect-pixel' + | 'spritesheet.split' + | 'image.remove-background' + | 'image.crop-expand'; fields: EditorAssetGenerationInputField[]; references: EditorAssetGenerationInputReference[]; [key: string]: unknown;