修复画布副本持久化与素材类型覆盖
Project CI / Frontend tests (push) Failing after 21s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m44s
Project CI / Native shell tests (push) Successful in 10m42s

复制布局复用项目资源并区分资源登记状态
新增图层素材类型覆盖并保持资源默认类型
补齐结构化存储迁移、绑定与权威类型校验
同步前后端回归测试和画布持久化文档
This commit is contained in:
2026-08-04 18:06:15 +08:00
parent 99d239c8bf
commit 7f18f7ce04
29 changed files with 1994 additions and 834 deletions
@@ -5935,3 +5935,12 @@
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``.codex/skills/genarrative-external-editor-api/SKILL.md`
## 2026-08-04 图片画布素材类型采用资源默认值与布局覆盖双层模型
- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,导致同一媒体被伪装成未登记资源;随后改为复用 `resourceId`,但手动修改图层标签仍通过“按新 `assetKind` 查找 / 创建项目资源并换绑当前图层”实现。这会让单纯标签修改增加资源行、漂移 `resourceId`,并在异步回填与复制交错时形成“新类型 + 旧资源”的副本。
- 决策:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例的可空类型覆盖;有效类型唯一按 `override ?? resource default` 计算。点击当前图层标签只新增、修改或清除 override,不创建资源、不更换 `resourceId`;清除后恢复继承。若另行提供资源默认类型编辑,必须原地更新同一资源行,并只影响没有 override 的引用图层。
- 复制语义:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多图层共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId` 并复制 `assetKindOverride`;已登记资源不得再次上传或创建,副本后续可独立修改 override。
- local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。
- schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::<String>)] asset_kind_override: Option<String>`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate``npm run check:spacetime-schema`
- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。
@@ -457,6 +457,22 @@
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布不能用 `local-*` 前缀代替资源登记状态
- 现象:刚加入画布、资源登记仍在途的图层被复制后,权威项目快照刷新与资源创建响应交错,副本可能刷新后消失;反过来,历史自包含角色动作序列虽然也使用 `local-*`,却会被永久禁用并持续提示“素材仍在保存”。
- 原因:`local-*` 同时覆盖两种不同状态:新素材的临时 ID,以及没有项目资源行、但已凭完整持久化帧成为终态的历史兼容序列。ID 前缀不是状态机;把所有本地 ID 当 pending,或把多个布局调用压进一个临时 ID single-flight Promise,都无法表达每次请求的真实生命周期与恢复上下文。
- 处理:新素材是否 pending 必须读资源登记在途集合;存在明确在途请求时,复制、剪切、创建副本和内部粘贴整体拒绝,正式 `resourceId` 回填后开放。历史序列按结构化持久化的严格自包含谓词识别,不能显示保存中;暂不支持复制时用准确原因失败关闭。既非 pending 又不满足历史谓词的 unresolved local 图层也拒绝,但提示“资源尚未登记”而非“仍在保存”。不要把普通 layout PATCH pending 当资源登记状态,也不要阻止系统剪贴板图片导入。资源创建不再按临时 ID single-flight 合并;布局保存的串行 latest-wins 队列仍保留。
- 验证:`ImageCanvasLayerCommandModel.test.ts``useImageCanvasLayerCommands.test.tsx``ImageCanvasContextMenusView.test.tsx` 分别覆盖登记在途整体拒绝、正式 ID 回填后恢复、历史 self-contained local sequence 不误报保存中,以及 unsupported / unresolved 的准确提示;`useImageCanvasProjectPersistence.test.tsx` 继续覆盖单个资源响应与权威快照交错恢复。
- 关联:`src/components/image-editor/ImageCanvasLayerCommandModel.ts``src/components/image-editor/useImageCanvasLayerCommands.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`
## 图片画布修改图层标签不能通过创建资源和换绑实现
- 现象:用户只修改一个图层的素材类型,项目资源数量却增加且该图层的 `resourceId` 改变;如果修改请求完成前复制,原图层会换绑到新资源,副本仍引用旧资源,刷新后副本类型回退。
- 原因:把资源 `assetKind` 同时当作共享默认值和布局实例标签,只能通过按类型查找 / 创建资源来模拟局部修改。异步响应只知道原 `layerId`,无法自动追踪期间复制出的新布局实例;layout 又没有独立覆盖字段,最终形成资源身份漂移和类型错位。
- 处理:固定双层模型:`editor_project_resource.asset_kind` 是资源默认类型,`editor_canvas_layer.asset_kind_override` 是可空布局覆盖,effective 值为 `override ?? resource default`。图层标签动作只写 / 清除 override,保持资源行数量和 `resourceId` 不变;复制复用 `resourceId` 并复制 override。`asset_kind_override` 必须是追加在表末尾、默认 `None` 的 typed 字段,不能塞入 `item_json`schema 同步 migration、表目录、bindings、DTO 和 canonical hash。
- 验证:覆盖“修改标签不新增资源且不换 ID”“同资源两个图层可有不同 override”“复制保留 override 后可独立修改”“清除 override 恢复资源默认值”“刷新与 structured round-trip 不丢覆盖”,并运行 `npm run spacetime:generate``npm run check:spacetime-schema`、定向 Rust / API / 前端测试。
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-module/src/migration.rs``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/services/image-editor/editorProjectClient.ts`
## 图片画布项目封面上传失败要有本地展示兜底
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
@@ -14,7 +14,7 @@
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`不能触发图层选择 / 拖拽事件图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。
@@ -57,6 +57,7 @@
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。
- 缩放菜单支持 `Ctrl/Cmd +``Ctrl/Cmd -``Shift + 1`;快捷键只改变 viewport,不修改工程资源。
- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销与恢复、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。撤销与恢复的内容保护、提示和验收边界统一以 [`图片画布撤销、恢复范围与操作提示方案`](../【图片画布】撤销范围与操作提示方案-2026-07-17.md) 为准。
- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一项目 `resourceId`,并原样复制可空 `assetKindOverride`;副本可独立修改几何、排序、分组、显隐、删除和类型覆盖,但不得重复上传媒体或为同一图片创建项目资源。是否“素材仍在保存”必须读取资源登记在途状态,不能仅凭 `resourceId``local-*` 前缀推断:新上传 / 新生成且登记请求尚未完成的本地图层,复制、剪切、创建副本和内部画布粘贴整体拒绝,正式 ID 回填后恢复;满足结构化持久化兼容条件的历史自包含本地角色动作序列虽然也使用 `local-*`,但已经是持久化终态,不得误报“仍在保存”。若当前版本尚不能安全复制这类历史序列,应以“历史动作序列暂不支持复制”等准确原因失败关闭,不能让用户无限等待。普通 layout PATCH pending 不参与判断;没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。临时资源创建不再做同 ID single-flight 合并,每个合法新增图层独立承接响应、快照恢复和回调。
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
@@ -72,12 +73,12 @@
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId``ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
- 新增 `editor_asset` 表保存账号级素材:`assetId``ownerUserId``folderId`、名称、图片读取地址、可选封面 `thumbnailSrc`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、可选 `groupTaskId`、可选 `groupTaskExpectedAssetCount``assetKind``generationInputs``generationCostMudPoints`、创建时间和更新时间。素材只跟账号走,不跟 project 走;`taskId` 保留真实生成 / 拆分操作身份,`groupTaskId` 保存服务端验证后的来源任务,`groupTaskExpectedAssetCount` 保存该拆分批次完整时应有的素材数;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
- 新增 `editor_showcase_asset``editor_showcase_asset_like``editor_showcase_campaign_config` 表承接 `陶泥儿精选`:生成素材默认不公开,用户在素材菜单中提交精选审核后生成独立快照;后台审核通过后先返还 50% 生成成本泥点,但仍需运营手动设置精选分类并开启展示才进入公开精选。公开列表不再读取 `editor_project_resource.public_showcase_enabled`,而是读取已通过、展示开启且分类合法的精选快照,支持点赞数和首位活动卡。
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind``generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、作为资源默认类型的 `assetKind``generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层通过 `resourceId` 引用该资源,并可用自身的 `assetKindOverride` 覆盖默认类型;修改当前图层标签不得创建另一条 project resource 或换绑 `resourceId`图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 WebPquality `0.82`):只在这些既有触发点采样画布尺寸,`canvasSize` / `ResizeObserver` 尺寸变化本身不得单独调度封面生成。取景使用画布实际渲染的 `viewport.x / viewport.y / viewport.scale`,并以当前视口中心为锚点把取景范围扩大到至少 `1280x960`;实际工作区宽或高更大时保留更大值,不向下缩小。先绘制工作区背景色,再以视口中心等比放大并裁成 4:3,不得把持久化显示倍率当成渲染倍率,也不得从左上角单向扩展取景;存在 drawable 图层但当前取景全部离屏时仍输出纯背景封面。快照继续走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"`。常规编辑允许封面滞后于最新 layout,但用户主动返回项目页时必须取消待执行防抖,以当前权威 revision 立即保存 layout,并等待同一视口封面写入本地缓存、上传 OSS 和创建项目资源后再导航;不得用 `beforeunload` 代替受控返回。`/project``/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
- 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 IDgroupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先`editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色``抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId``publicationGameInfo``publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 IDData URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 IDgroupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind``generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色``抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId``publicationGameInfo``publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 IDData URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character``icon-spritesheet``character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel``canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
@@ -597,7 +597,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorCanvasLayer`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布结构化图层表,一行保存一个 layer 的 canvas / project / owner 归属、几何、层级顺序、分组、hidden / locked / flip 状态 `resource_id`。V1 的 `item_json` 只保留未结构化扩展字段,单行最大 512 KiB;快照以 typed 列重组,不得把完整图层 JSON 当作平行真相。媒体业务真相仍由 `editor_project_resource` / `editor_asset` 持有。历史 layout 的 `sourceResourceId == resourceId` 属于无意义自引用,迁移时按资源表真相剥离;其他资源字段冲突继续 fail-closed。唯一存量缺资源例外是已缺资源行、但帧与预览均为稳定站内对象路径的 `local-* + generated + image-sequence` 历史角色动作图层:迁移保留其有界媒体扩展并纳入 canonical hashactive 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。
- 说明:图片画布结构化图层表,一行保存一个 layer 的 canvas / project / owner 归属、几何、层级顺序、分组、hidden / locked / flip 状态`resource_id` 与可空 `asset_kind_override``asset_kind_override` 是布局实例的素材类型覆盖,资源默认值仍由 `editor_project_resource.asset_kind` 持有,有效类型统一按 `override ?? resource default` 计算;修改图层标签不得创建资源或替换 `resource_id`。V1 的 `item_json` 只保留未结构化扩展字段,单行最大 512 KiB;快照以 typed 列重组,不得把完整图层 JSON 当作平行真相。历史 layout 的 `sourceResourceId == resourceId` 属于无意义自引用,迁移时按资源表真相剥离;其他资源字段冲突继续 fail-closed。唯一存量缺资源例外是已缺资源行、但帧与预览均为稳定站内对象路径的 `local-* + generated + image-sequence` 历史角色动作图层:迁移保留其有界媒体扩展并纳入 canonical hashactive 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。
- 索引:按 canvas 读取完整结构化快照,按 project 做级联清理;owner 保留在行内用于归属校验。同一 canvas 的 layer id 必须稳定且唯一。
### `editor_canvas_generation_dialog`
@@ -618,7 +618,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorProjectResource`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json` 和历史 `public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation``陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-runapply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json` 和历史 `public_showcase_enabled``asset_kind` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,不能通过新增资源行模拟标签修改。`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation``陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-runapply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。
- `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs``background_mode``cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。
- 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。
- 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。
@@ -18,10 +18,10 @@
| 数据 | 权威位置 | 说明 |
| --- | --- | --- |
| 工程归属、默认画布、viewport、当前 revision | `editor_project` / `editor_canvas` | `editor_canvas` 保留 `layers_json` legacy 列,但其内容不参与结构化模式下的正常写入仲裁 |
| 图层实例、几何、层级、分组、显示与锁定状态、资源引用 | `editor_canvas_layer` | 一行一个图层;模式专属的有界扩展字段可放 JSON,不能复制媒体正文或完整资源元数据 |
| 图层实例、几何、层级、分组、显示与锁定状态、资源引用、素材类型覆盖 | `editor_canvas_layer` | 一行一个图层;`asset_kind_override` 是可空 typed 列,模式专属的有界扩展字段可放 JSON,不能复制媒体正文或完整资源元数据 |
| 生成对话框、占位层、来源层、结果层、任务与状态 | `editor_canvas_generation_dialog` | 生成任务和 UI 对话框的持久关联,不再嵌入全量 layout JSON |
| 存储模式、迁移阶段、校验 hash、激活与回滚审计 | `editor_canvas_layout_migration` | 每个 canvas 一行,控制 legacy / structured 读取,不允许前端自行切换 |
| 图片、视频、音频等媒体及生成元数据 | `editor_project_resource` / `editor_asset` | layer/dialog 只保存稳定 ID 引用资源创建成功不能被当作布局保存成功 |
| 图片、视频、音频等媒体及生成元数据 | `editor_project_resource` / `editor_asset` | `editor_project_resource.asset_kind` 是资源默认类型;layer/dialog 只保存稳定 ID 引用和布局覆盖,资源创建成功不能被当作布局保存成功 |
结构化快照由后端在同一 revision 下读取 `editor_canvas`、layer、dialog 和 layout migration state 后组装。前端只消费 BFF 快照,不直接订阅表、做 join 或推断迁移状态。
@@ -29,9 +29,17 @@
### 3.1 `editor_canvas_layer`
保存 `layer_id``canvas_id``project_id``owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`、有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。
保存 `layer_id``canvas_id``project_id``owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`可空 `asset_kind_override`有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。
layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / assetKind / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated``mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:``blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `assetKind / generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,兼容现有画布快照语义
同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一 `resource_id` 并复制其 `asset_kind_override`不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。图层素材类型采用两层真相:`editor_project_resource.asset_kind` 是共享资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例的可空覆盖,所有标签展示和类型能力判断统一使用 `effective_asset_kind = asset_kind_override.or(resource.asset_kind)`。修改当前图层标签只更新 override,不创建资源、不替换 `resource_id`、不修改资源默认类型;清除 override 后恢复继承默认类型。若以后提供“修改资源默认类型”的独立动作,应原地更新同一资源行,并让所有未覆盖图层自然继承,不能复用图层标签动作暗中换绑资源
拆分图集、角色动作等依赖素材类型的图层级正式操作,后端必须按 owner / project / canvas / `layer_id` 读取权威 layer 与 resource 后计算 effective 类型,不能信任客户端直接提交的 `assetKind` 或只校验资源默认类型;纯资源级流程才使用资源默认类型。前端标签、菜单、工具栏、快捷键和提交前校验复用同一 effective helper,但前端判断只负责交互反馈,不能代替后端授权与业务校验。
`asset_kind_override` 是现有持久表新增字段,必须追加在 `EditorCanvasLayer` Rust 结构体末尾,不得重排原字段,并使用明确默认值 `#[default(None::<String>)]`。schema 变更同批更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录 / 数据契约、生成 bindings 与对应 DTO;生成 TypeScript 的缺字段、`undefined``null` 在兼容输入中统一归一为 `None`。完成后必须运行 `npm run spacetime:generate``npm run check:spacetime-schema`,不能只修改 Rust 结构体或手写前端类型。
layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;资源默认 `assetKind` 也不得进入 `item_json`,只有 typed `asset_kind_override` 属于 layer 真相。迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated``mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:``blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;其存量显式 `assetKind` 迁移到 typed `asset_kind_override`,作为缺少资源默认值时的有效类型,不据此伪造资源行。`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,再与 layer override 合并,兼容现有画布快照语义。
`local-*` 只是历史 ID 形状,不是资源登记状态机。新上传 / 新生成图层是否 pending,以当前资源登记请求的在途集合为准;存在明确在途请求时才提示“素材仍在保存”,并对整次复制、剪切、创建副本或内部粘贴失败关闭,正式 ID 回填后恢复。通过上述严格谓词读取的历史自包含本地角色动作序列是已持久化终态,不得误报保存中;若当前实现不能安全复制其自包含帧扩展,应以“历史动作序列暂不支持复制”等准确原因失败关闭。既非登记在途、又不满足历史序列谓词的 unresolved local 图层同样拒绝操作,但提示“素材资源尚未登记,暂不能复制”,不能伪装成一定会结束的等待状态。该约束不绑定 layout 自动保存状态,也不影响从系统剪贴板导入新图片。每个合法新增图层独立完成自己的资源登记,前端不再按临时资源 ID 合并创建请求。
### 3.2 `editor_canvas_generation_dialog`
@@ -47,7 +55,7 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后冲突布局立即作废,权威快照暂时加载失败时保留冲突恢复状态并定时只重试 GET,不能把旧布局换上新 revision 后重放,也不能等待用户再次拖动画布才恢复。
本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐 typed 布局字段。前端序列化按正常资源真相边界省略 `assetKind / generationInputs` 时,后端只从既有结构化行恢复这两个冻结字段再校验;显式修改仍拒绝。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。
本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段`assetKindOverride` 必须与其它 typed layer 字段一起 round-trip,兼容输入中的资源默认 `assetKind` 不得覆盖它。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐 typed `asset_kind_override`。前端序列化按正常资源真相边界省略资源默认 `assetKind``generationInputs` 时,后端只从既有结构化行恢复自包含序列冻结的 `generationInputs` 再校验;资源类型展示由 typed override 和资源默认值合并,不能再从 `item_json.assetKind` 恢复。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。
### 3.5 worker 原子完成
@@ -65,8 +73,8 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等:
1. **Backfill**:在短事务中读取 `editor_canvas.layers_json`、canvas revision 和更新时间;解析 legacy items,把普通图层与 generation dialog 分别归一到两张结构化表。按稳定 `canvas_id + item id` upsert,重复运行不能产生新 ID 或重复行。记录本次基准 revision 和 legacy canonical hash。
2. **Hash 核对**:按固定字段顺序、数值归一规则和稳定 item 排序,把结构化行重组成 canonical legacy 语义;分别计算 layer 数、dialog 数、资源引用集合以及 SHA-256 canonical hash。raw JSON 的空白、对象 key 顺序和无意义默认值差异不作为不一致;任何不可识别字段必须保存在有界扩展字段中或使迁移失败,不能静默丢弃。项目资源存在时继续逐字段核对;除 `sourceResourceId == resourceId` 的历史自引用按资源表真相剥离外,其他 `sourceResourceId` 等冲突都拒绝迁移。只有上一节定义的缺资源自包含本地图片序列保留 layout 内元数据并参与 hash。
1. **Backfill**:在短事务中读取 `editor_canvas.layers_json`、canvas revision 和更新时间;解析 legacy items,把普通图层与 generation dialog 分别归一到两张结构化表。按稳定 `canvas_id + item id` upsert,重复运行不能产生新 ID 或重复行。legacy `assetKind` 按资源分组迁移:资源已有非空默认类型时,相同值归一为 `asset_kind_override=None`,不同值写入该图层 override;资源默认类型为空时,只有所有引用图层都显式给出同一非空值,才可把它补成资源默认值并令这些 override 为空,否则资源默认值保持为空,每个显式 legacy 值各自写入 override,不能任选四舍五入、创建新资源或替换 `resource_id`。缺资源自包含本地图片序列把其显式 legacy 类型写入 override。记录本次基准 revision 和 legacy canonical hash。
2. **Hash 核对**:按固定字段顺序、数值归一规则和稳定 item 排序,把结构化行重组成 canonical legacy 语义;分别计算 layer 数、dialog 数、资源引用集合以及 SHA-256 canonical hash。`asset_kind_override` 必须作为 typed layer 字段参与 canonical hash;缺失与 `None` 等价,资源默认类型与图层 override 必须先按同一公式得到 effective 值再核对用户可见旧语义。raw JSON 的空白、对象 key 顺序和无意义默认值差异不作为不一致;任何不可识别字段必须保存在有界扩展字段中或使迁移失败,不能静默丢弃。项目资源存在时继续逐字段核对;合法的 legacy `assetKind` 差异已经由 override 表达,不再作为资源冲突或新建资源的理由。`sourceResourceId == resourceId` 的历史自引用按资源表真相剥离外,其他 `sourceResourceId` 等冲突都拒绝迁移。只有上一节定义的缺资源自包含本地图片序列保留 layout 内媒体 / 生成元数据并参与 hash。
3. **Activate**:只有 structured hash、数量和资源引用集合均匹配,且 `editor_canvas.revision` 仍等于 backfill 基准 revision 时,才以 CAS 把 layout migration state 切为 `active` 并把 canvas storage version 切到 structured。revision 已变化时丢弃本轮验证结果并重新 backfill;不得覆盖迁移期间的用户更新。
当前提供单项目、可重跑的运维入口:`npm run spacetime:editor-canvas-layout:migrate -- --database <db> --server <server> --project-id <id> --owner-user-id <id> --action <backfill|activate|rollback>`。默认 dry-run,只有显式追加 `--apply` 才写入;backfill dry-run 即使遇到尚无 `editor_canvas` 的旧工程,也只用 `editor_project` 构造内存预览,不创建 canvas、不补资源元数据。每个项目依次执行 backfill dry-run / apply 和 activate dry-run / apply,不在一个长事务中扫全表。脚本只调用受 migration operator 保护的 procedure,不直接修改生产表。
@@ -86,7 +94,7 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存
## 6. 发布次序
1. 发布 2 MiB hotfix:先发布不变更 schema 的 SpacetimeDB 模块校验,再部署同一上限和足够 body envelope 的 `api-server`;确认 release manifest、服务状态和健康检查。
2. 发布新增三表、revision / CAS、typed 快照重组与迁移 procedure 的 SpacetimeDB 模块;同步 migration table 白名单和生成绑定。此时保持 legacy 主读写。
2. 发布新增三表、revision / CAS、typed 快照重组与迁移 procedure 的 SpacetimeDB 模块;在既有 `editor_canvas_layer` 末尾追加默认 `None``asset_kind_override`同步 migration table 白名单、表目录、数据契约和生成绑定。此时保持 legacy 主读写。
3. 部署可同时读取 legacy / structured 的 `spacetime-client``api-server`,再部署携带 revision 的前端。混合版本期间未激活 canvas 必须仍可正常编辑,旧 API 写 structured canvas 必须失败而不是无 CAS 覆盖。
4. 先对测试账号和单个真实 canvas dry-run、backfill、核对、activate,再按批次扩大;持续观测 CAS conflict、迁移失败、快照缺资源和 worker completion 指标。
5. 验收完成并经过观察期后,才把新 canvas 默认设为 structured。legacy 回滚能力继续保留。
@@ -97,10 +105,10 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端
- 2 MiB hotfix 后,原先约 254 KiB 的受影响画布和大于 256 KiB 的测试布局可保存并刷新恢复;超过 2 MiB 的 legacy layout 在 API 与 SpacetimeDB 两层都稳定拒绝,错误可观测。
- backfill 重跑不会增加 layer / dialog 行,canonical hash、数量和资源引用集合一致;迁移中发生用户写入时 activate CAS 失败并安全重跑。
- release 存量抽样中的缺资源 `local-*` 角色动作序列可无损 round-trip;同形状但空帧、相对路径、HTTP / 签名 URL、`data:` / `blob:` 引用必须拒绝。已有资源的 `sourceResourceId == resourceId` 历史自引用应按资源表真相安全剥离,其他来源 ID 或资源字段冲突仍必须拒绝。
- release 存量抽样中的缺资源 `local-*` 角色动作序列可无损 round-trip,并被识别为已持久化终态而非资源登记 pending;同形状但空帧、相对路径、HTTP / 签名 URL、`data:` / `blob:` 引用必须拒绝。已有资源的 `sourceResourceId == resourceId` 历史自引用应按资源表真相安全剥离,其他来源 ID 或资源字段冲突仍必须拒绝。
- release 全量审计暴露的普通缺资源行必须先通过定向 repair dry-run;图片只能复用同工程唯一资源,音频只能从已登记 private asset_object 恢复。修复后同一 plan 全部命中 `already_repaired`,再重跑全量 backfill dry-run,要求所有 canvas 均通过。
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用和 dialog 状态;两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。
- 完成 `npm run spacetime:generate``npm run check:spacetime-runtime-access``npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding``git diff --check`
- 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access``npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding``git diff --check`
+178 -11
View File
@@ -356,6 +356,7 @@ pub struct EditorIconSpritesheetGenerationRequest {
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceRequest {
pub(crate) project_id: String,
pub(crate) source_layer_id: String,
pub(crate) source_resource_id: String,
pub(crate) asset_folder_id: Option<String>,
pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
@@ -2744,8 +2745,20 @@ fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
"targetLayerId": target_layer_id,
}))
})?;
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
return Ok(Some(asset_kind));
match target_layer.get("assetKindOverride") {
Some(Value::String(value)) if !value.trim().is_empty() => {
return Ok(Some(value.trim()));
}
Some(Value::Null) | None => {}
Some(_) => {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "快速编辑目标图层的素材类型覆盖无效",
"targetLayerId": target_layer_id,
})),
);
}
}
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
Ok(resource_id.and_then(|resource_id| {
@@ -5420,6 +5433,61 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
))
}
fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>(
layers: &'a Value,
source_layer_id: &str,
source_resource_id: &str,
resource_asset_kind: Option<&'a str>,
) -> Result<Option<&'a str>, AppError> {
let source_layer = layers
.as_array()
.and_then(|items| {
items
.iter()
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(source_layer_id))
})
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集图层。",
"sourceLayerId": source_layer_id,
}))
})?;
let layer_resource_id = source_layer
.get("resourceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层缺少有效的资源引用。",
"sourceLayerId": source_layer_id,
}))
})?;
if layer_resource_id != source_resource_id {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层与请求资源不一致。",
"sourceLayerId": source_layer_id,
"sourceResourceId": source_resource_id,
})),
);
}
match source_layer.get("assetKindOverride") {
Some(Value::String(value)) => Ok(Some(value.trim())),
Some(Value::Null) | None => Ok(resource_asset_kind),
Some(_) => Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层的素材类型覆盖无效。",
"sourceLayerId": source_layer_id,
})),
),
}
}
pub async fn split_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -5428,12 +5496,13 @@ pub async fn split_editor_icon_spritesheet(
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project_id = payload.project_id.trim().to_string();
let source_layer_id = payload.source_layer_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_resource_id.is_empty() {
if project_id.is_empty() || source_layer_id.is_empty() || source_resource_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "projectId 和 sourceResourceId 不能为空。",
"message": "projectId、sourceLayerId 和 sourceResourceId 不能为空。",
})),
);
}
@@ -5468,7 +5537,13 @@ pub async fn split_editor_icon_spritesheet(
})),
);
}
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
let effective_asset_kind = resolve_editor_icon_spritesheet_source_layer_asset_kind(
&project.layers,
source_layer_id.as_str(),
source_resource_id.as_str(),
source_resource.asset_kind.as_deref(),
)?;
if effective_asset_kind != Some("icon-spritesheet") {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
@@ -13366,20 +13441,50 @@ mod tests {
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
let layers = json!([
{ "layerId": "layer-icon", "assetKind": "icon" },
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
{
"layerId": "layer-icon",
"resourceId": "resource-default-spec",
"assetKind": "character",
"assetKindOverride": "icon"
},
{
"layerId": "layer-icon-spec",
"resourceId": "resource-icon-spec",
"assetKind": "icon"
}
]);
let resources = vec![
editor_project_resource_for_canvas_test("resource-default-spec", "spec", 512, 512),
editor_project_resource_for_canvas_test("resource-icon-spec", "icon-spec", 512, 512),
];
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
resolve_editor_image_edit_target_layer_asset_kind(&layers, &resources, "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
let icon_spec_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
.expect("icon spec layer should exist");
let icon_spec_kind = resolve_editor_image_edit_target_layer_asset_kind(
&layers,
&resources,
"layer-icon-spec",
)
.expect("icon spec layer should exist");
assert_eq!(icon_spec_kind, Some("icon-spec"));
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
);
let invalid_override = json!([{
"layerId": "layer-invalid",
"resourceId": "resource-icon-spec",
"assetKindOverride": ""
}]);
assert!(
resolve_editor_image_edit_target_layer_asset_kind(
&invalid_override,
&resources,
"layer-invalid",
)
.is_err()
);
}
#[test]
@@ -13549,6 +13654,7 @@ mod tests {
fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() {
let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({
"projectId": "project-1",
"sourceLayerId": "layer-atlas-1",
"sourceResourceId": "resource-atlas-1",
"assetFolderId": "project",
"canvasCompletion": {
@@ -13566,12 +13672,73 @@ mod tests {
.expect("spritesheet slice request should deserialize");
assert_eq!(request.project_id, "project-1");
assert_eq!(request.source_layer_id, "layer-atlas-1");
assert_eq!(request.source_resource_id, "resource-atlas-1");
assert_eq!(request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(request.canvas_completion.title, "拆分图集");
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
}
#[test]
fn editor_icon_spritesheet_slice_uses_authoritative_layer_override() {
let layers = json!([
{
"layerId": "layer-default",
"resourceId": "resource-atlas"
},
{
"layerId": "layer-override",
"resourceId": "resource-image",
"assetKindOverride": "icon-spritesheet"
},
{
"layerId": "layer-blocked",
"resourceId": "resource-atlas",
"assetKindOverride": "character"
}
]);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-default",
"resource-atlas",
Some("icon-spritesheet"),
)
.expect("resource default should be used"),
Some("icon-spritesheet")
);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-override",
"resource-image",
Some("character"),
)
.expect("layer override should win"),
Some("icon-spritesheet")
);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-blocked",
"resource-atlas",
Some("icon-spritesheet"),
)
.expect("blocking override should win"),
Some("character")
);
assert!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
&layers,
"layer-default",
"resource-other",
Some("icon-spritesheet"),
)
.is_err()
);
}
#[test]
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
let mut copied_resource = editor_project_resource_for_canvas_test(
@@ -31,6 +31,7 @@ pub struct EditorCanvasLayer {
pub item_json: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub asset_kind_override: Option<String>,
}
impl __sdk::InModule for EditorCanvasLayer {
@@ -65,6 +66,7 @@ pub struct EditorCanvasLayerCols {
pub item_json: __sdk::__query_builder::Col<EditorCanvasLayer, String>,
pub created_at: __sdk::__query_builder::Col<EditorCanvasLayer, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorCanvasLayer, __sdk::Timestamp>,
pub asset_kind_override: __sdk::__query_builder::Col<EditorCanvasLayer, Option<String>>,
}
impl __sdk::__query_builder::HasCols for EditorCanvasLayer {
@@ -95,6 +97,10 @@ impl __sdk::__query_builder::HasCols for EditorCanvasLayer {
item_json: __sdk::__query_builder::Col::new(table_name, "item_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
asset_kind_override: __sdk::__query_builder::Col::new(
table_name,
"asset_kind_override",
),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1390,6 +1390,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_canvas_layer" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:图层素材类型覆盖晚于结构化画布图层表加入,旧迁移包默认继承资源类型。
object
.entry("asset_kind_override".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "editor_showcase_campaign_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:活动卡图片 Object Key 晚于活动卡表加入,旧迁移包按仅有图片地址兼容。
@@ -1732,6 +1740,16 @@ mod migration_bootstrap_secret_tests {
assert_eq!(normalized["background_color"], serde_json::Value::Null);
}
#[test]
fn old_editor_canvas_layer_rows_default_to_no_asset_kind_override() {
let normalized = normalize_migration_row(
"editor_canvas_layer",
&serde_json::json!({ "layer_key": "layer-1" }),
);
assert_eq!(normalized["asset_kind_override"], serde_json::Value::Null);
}
#[test]
fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() {
let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -36,6 +36,8 @@ function renderContextMenus(
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canCopyContextLayers: true,
isCanvasClipboardCopyBlocked: false,
onPasteCanvasClipboard: vi.fn(),
onCopyContextLayers: vi.fn(),
onDuplicateContextLayers: vi.fn(),
@@ -183,6 +185,87 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
it('disables copy actions while the target resource is still being saved', () => {
const layer = createLayer({ resourceId: 'local-resource-pending' });
const props = renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'pending-resource',
});
expect(
(screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(props.onCopyContextLayers).not.toHaveBeenCalled();
expect(props.onDuplicateContextLayers).not.toHaveBeenCalled();
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('素材仍在保存,请稍后重试');
});
it('explains that persisted historical sequences cannot be copied', () => {
const layer = createLayer({
resourceId: 'local-resource-historical-sequence',
resourcePersistenceState: 'self-contained-local',
mediaType: 'image-sequence',
});
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'self-contained-local-unsupported',
});
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('历史序列帧素材暂不支持复制');
});
it('explains that unresolved local resources are not registered', () => {
const layer = createLayer({
resourceId: 'local-resource-unresolved',
resourcePersistenceState: 'unresolved-local',
});
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 16,
y: 18,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
canCopyContextLayers: false,
contextLayerCopyBlockReason: 'unresolved-resource',
});
expect(
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
).toBe('素材资源尚未登记,暂不能复制');
});
it('keeps measured layer menus and submenus inside the viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
@@ -274,9 +357,7 @@ describe('ImageCanvasContextMenusView', () => {
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '查看图片信息' })).toBeTruthy();
});
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
@@ -19,6 +19,7 @@ import type {
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
type ImageCanvasContextMenusViewProps = {
viewport: CanvasViewport;
@@ -29,6 +30,9 @@ type ImageCanvasContextMenusViewProps = {
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canCopyContextLayers: boolean;
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
isCanvasClipboardCopyBlocked: boolean;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
onCopyContextLayers: (options?: { cut?: boolean }) => void;
onDuplicateContextLayers: () => void;
@@ -69,6 +73,9 @@ export function ImageCanvasContextMenusView({
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason = null,
isCanvasClipboardCopyBlocked,
onPasteCanvasClipboard,
onCopyContextLayers,
onDuplicateContextLayers,
@@ -90,6 +97,14 @@ export function ImageCanvasContextMenusView({
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const layerCopyBlockedTitle =
contextLayerCopyBlockReason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: contextLayerCopyBlockReason === 'pending-resource'
? '素材仍在保存,请稍后重试'
: contextLayerCopyBlockReason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: undefined;
const menuRef = useRef<HTMLDivElement | null>(null);
const [measuredMenuLayout, setMeasuredMenuLayout] =
useState<MeasuredMenuLayout | null>(null);
@@ -282,6 +297,7 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={isCanvasClipboardCopyBlocked}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
@@ -331,6 +347,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers()}
>
@@ -338,6 +356,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={() => onCopyContextLayers({ cut: true })}
>
@@ -345,6 +365,7 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={isCanvasClipboardCopyBlocked}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
@@ -352,6 +373,8 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canCopyContextLayers}
title={layerCopyBlockedTitle}
onClick={onDuplicateContextLayers}
>
@@ -17,6 +17,8 @@ import {
createAuthValue,
createDataTransferStub,
createDeferred,
defaultEditorProjectLayers,
defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
@@ -871,37 +873,39 @@ describe('ImageCanvasEditorView asset library integration', () => {
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
const user = userEvent.setup();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -947,14 +951,15 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
});
await waitFor(() => {
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
(item: { layerId?: unknown }) =>
item.layerId !== 'layer-generated-a',
),
);
const savedLayoutWithoutDeletedLayer =
saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
(item: { layerId?: unknown }) =>
item.layerId !== 'layer-generated-a',
),
);
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
@@ -965,21 +970,25 @@ describe('ImageCanvasEditorView asset library integration', () => {
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-added-asset-a',
projectId: 'editor-project-default',
imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/asset-a.png',
assetObjectId: 'assetobj-asset-a',
width: 320,
height: 240,
sourceType: 'uploaded',
});
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}));
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision({
projectId: 'editor-project-default',
title: '空画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -1109,7 +1118,6 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(within(batchToolbar).getByText(/ 2/u)).toBeTruthy();
});
it('drops an image file on the canvas as a new canvas layer', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
@@ -1141,9 +1149,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
@@ -1287,6 +1295,145 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
});
it('restores a copied layout that references the same project resource after re-entering', async () => {
const firstRender = render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
saveEditorProjectLayoutMock.mockClear();
createEditorProjectResourceMock.mockClear();
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
?.layers as Array<{ layerId: string; resourceId: string }>;
const copiedPuzzleLayouts = savedLayers.filter(
(layer) => layer.resourceId === 'resource-puzzle',
);
expect(copiedPuzzleLayouts).toHaveLength(2);
expect(
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
).toBe(2);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: savedLayers,
resources: defaultEditorProjectResources,
updatedAt: '2026-08-03T00:00:00.000Z',
},
1,
),
);
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(screen.getAllByAltText(//u)).toHaveLength(2);
});
});
it('restores different layer overrides for one shared resource after re-entering', async () => {
const sharedResourceProject = withEditorProjectCanvasRevision(
{
projectId: 'editor-project-default',
title: '共享资源标签项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-default',
title: '共享默认角色',
resourceId: 'resource-shared-kind',
assetKindOverride: null,
},
{
...defaultEditorProjectLayers[0],
layerId: 'layer-shared-override',
title: '共享图标覆盖',
resourceId: 'resource-shared-kind',
assetKindOverride: 'icon',
x: 1140,
},
],
resources: [
{
resourceId: 'resource-shared-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
},
],
updatedAt: '2026-08-04T00:00:00.000Z',
},
1,
);
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
const firstRender = render(<ImageCanvasEditorView />);
const defaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const overrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(defaultLayer).getByRole('button', { name: '素材标签:角色' }),
).toBeTruthy();
expect(
within(overrideLayer).getByRole('button', { name: '素材标签:图标' }),
).toBeTruthy();
firstRender.unmount();
window.sessionStorage.clear();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
sharedResourceProject,
);
render(<ImageCanvasEditorView />);
const restoredDefaultLayer = await screen.findByRole('button', {
name: '选择共享默认角色',
});
const restoredOverrideLayer = await screen.findByRole('button', {
name: '选择共享图标覆盖',
});
expect(
within(restoredDefaultLayer).getByRole('button', {
name: '素材标签:角色',
}),
).toBeTruthy();
expect(
within(restoredOverrideLayer).getByRole('button', {
name: '素材标签:图标',
}),
).toBeTruthy();
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render(<ImageCanvasEditorView />);
@@ -1410,5 +1557,4 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import {
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveLayerResourceAssetKind,
resolveSnappedLayerPosition,
serializeCanvasLayout,
serializeLayer,
@@ -25,6 +26,33 @@ import type {
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('keeps the resource default kind separate from a layer override', () => {
const layer = {
id: 'layer-shared',
resourceId: 'resource-shared',
title: '共享素材',
src: '/shared.png',
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
resourceAssetKind: 'character',
assetKindOverride: 'icon',
assetKind: 'icon',
} satisfies CanvasLayer;
expect(resolveLayerResourceAssetKind(layer)).toBe('character');
expect(serializeLayer(layer)).toMatchObject({
resourceId: 'resource-shared',
assetKindOverride: 'icon',
});
expect(serializeLayer(layer)).not.toHaveProperty('assetKind');
});
it('maps viewport scale to the user-facing canvas zoom scale', () => {
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
@@ -39,9 +67,9 @@ describe('ImageCanvasEditorModel', () => {
expect(
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
).toEqual({ x: 10, y: 20, scale: 0.5 });
expect(
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
).toEqual({ x: 10, y: 20, scale: 1 });
expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual(
{ x: 10, y: 20, scale: 1 },
);
});
it('normalizes valid canvas background hex values and rejects invalid input', () => {
@@ -83,7 +111,9 @@ describe('ImageCanvasEditorModel', () => {
});
expect(layout).toEqual([]);
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
expect(
splitCanvasLayoutItems(layout).canvasBackgroundColor,
).toBeUndefined();
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
});
@@ -128,7 +158,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-audio',
folderId: 'project',
label: '胜利音效.mp3',
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
imageSrc:
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
width: 420,
height: 120,
sourceType: 'uploaded',
@@ -139,7 +170,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-video',
folderId: 'project',
label: '开场动画.mp4',
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
imageSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
thumbnailSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
width: 560,
@@ -252,7 +284,8 @@ describe('ImageCanvasEditorModel', () => {
assetId: 'asset-generated',
folderId: 'project',
label: '生成素材',
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/generated.png',
width: 640,
height: 640,
sourceType: 'generated',
@@ -284,6 +317,8 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'generated/object.png',
model: 'birefnet',
sourceResourceId: 'resource-provider-source',
resourceAssetKind: 'character',
assetKindOverride: null,
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
@@ -295,6 +330,7 @@ describe('ImageCanvasEditorModel', () => {
const snapshot = serializeLayer(layer);
expect(snapshot).not.toHaveProperty('src');
expect(snapshot).not.toHaveProperty('assetKind');
expect(snapshot.assetKindOverride).toBeNull();
expect(snapshot).not.toHaveProperty('generationInputs');
const hydrated = hydrateLayer(
@@ -327,6 +363,8 @@ describe('ImageCanvasEditorModel', () => {
id: 'layer-generated',
src: '/read/generated.png',
sourceType: 'generated',
resourceAssetKind: 'character',
assetKindOverride: null,
assetKind: 'character',
objectKey: 'generated/object.png',
model: 'gpt-image-2',
@@ -335,6 +373,55 @@ describe('ImageCanvasEditorModel', () => {
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('distinguishes persisted self-contained sequences from unresolved local resources', () => {
const validSequence = {
layerId: 'layer-sequence',
resourceId: 'local-resource-sequence',
title: '历史动作',
src: '/generated/sequence/frame-1.png',
sourceType: 'generated',
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated/sequence/frame-1.png',
objectKey: 'generated/sequence/frame-1.png',
width: 320,
height: 240,
},
],
thumbnailSrc: '/generated/sequence/thumbnail.png',
previewVideoPath: '/generated/sequence/preview.mp4',
};
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
resourcePersistenceState: 'self-contained-local',
});
expect(
hydrateLayer(
{
...validSequence,
imageSequenceFrames: [
{ ...validSequence.imageSequenceFrames[0], frameIndex: 0 },
],
},
new Map(),
),
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
expect(
hydrateLayer(
{
layerId: 'layer-unresolved',
resourceId: 'local-resource-unresolved',
title: '未登记图片',
src: '/generated/unresolved.png',
sourceType: 'uploaded',
},
new Map(),
),
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
});
it('recovers the normal source model after the owner payload removes an internal model', () => {
const hydrated = hydrateLayer(
{
@@ -391,8 +478,10 @@ describe('ImageCanvasEditorModel', () => {
[
'resource-uploaded',
{
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image.png',
objectKey:
'generated-character-drafts/editor/asset-library/image.png',
assetObjectId: 'asset-object-uploaded',
sourceResourceId: 'resource-source',
},
@@ -409,7 +498,7 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates manually selected publication material tags from saved layout', () => {
it('hydrates a layer override ahead of the shared resource default', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-publication',
@@ -423,13 +512,20 @@ describe('ImageCanvasEditorModel', () => {
originalHeight: 1024,
zIndex: 5,
sourceType: 'generated',
assetKind: 'publication-material',
assetKindOverride: 'publication-material',
},
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
new Map([
[
'resource-publication',
{ imageSrc: '/read/publication.png', assetKind: 'ui-design' },
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-publication',
resourceAssetKind: 'ui-design',
assetKindOverride: 'publication-material',
assetKind: 'publication-material',
});
});
@@ -708,12 +804,16 @@ describe('ImageCanvasEditorModel', () => {
});
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
layout.find(
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
),
),
).not.toContain('data:image');
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
layout.find(
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
),
),
).toContain('ref:project-resource:resource-reference-1');
@@ -181,6 +181,7 @@ export function createLayerFromAsset(
return {
id: `layer-${asset.id}-${index}`,
resourceId: `local-resource-${asset.id}-${index}`,
resourcePersistenceState: 'pending',
title: asset.label,
src: asset.src,
mediaType: asset.mediaType,
@@ -203,7 +204,9 @@ export function createLayerFromAsset(
sourceResourceId: asset.sourceResourceId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? assetKind,
resourceAssetKind: asset.assetKind ?? assetKind ?? null,
assetKindOverride: null,
assetKind: asset.assetKind ?? assetKind ?? null,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
@@ -258,7 +261,10 @@ function serializeImageSequenceFrames(
return undefined;
}
const serializedFrames = frames.flatMap((frame) => {
const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey);
const imageSrc = serializeOptionalMediaSource(
frame.imageSrc,
frame.objectKey,
);
return imageSrc
? [
{
@@ -271,9 +277,7 @@ function serializeImageSequenceFrames(
return serializedFrames.length ? serializedFrames : undefined;
}
export function serializeLayer(
layer: CanvasLayer,
): EditorProjectLayerSnapshot {
export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
return {
layerId: layer.id,
resourceId: layer.resourceId,
@@ -291,7 +295,9 @@ export function serializeLayer(
layer.thumbnailSrc,
layer.objectKey,
),
imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames),
imageSequenceFrames: serializeImageSequenceFrames(
layer.imageSequenceFrames,
),
previewVideoPath: serializeOptionalMediaSource(
layer.previewVideoPath,
layer.objectKey,
@@ -307,6 +313,7 @@ export function serializeLayer(
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
groupId: layer.groupId,
assetKindOverride: layer.assetKindOverride ?? null,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
@@ -332,8 +339,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:'),
);
}
@@ -487,9 +494,7 @@ export function isCanvasGenerationDialogLayoutItem(
return (
item.itemType === 'generation-dialog' &&
Boolean(
hydrateCanvasGenerationDialog(
(item as { dialog?: unknown }).dialog,
),
hydrateCanvasGenerationDialog((item as { dialog?: unknown }).dialog),
)
);
}
@@ -570,9 +575,7 @@ export function hydrateCanvasGenerationDialog(
prompt,
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
composerOpen:
typeof snapshot.composerOpen === 'boolean'
? snapshot.composerOpen
: true,
typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
@@ -629,9 +632,7 @@ export function hydrateCanvasGenerationDialog(
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
: undefined,
typeof snapshot.videoModel === 'string' ? snapshot.videoModel : undefined,
videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio),
videoResolution:
snapshot.videoResolution === '480p' ||
@@ -719,10 +720,26 @@ export function hydrateLayer(
if (!resourceId || !layerId || !src) {
return null;
}
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
const assetKindOverride = Object.prototype.hasOwnProperty.call(
snapshot,
'assetKindOverride',
)
? canvasAssetKindOrNull(snapshot.assetKindOverride)
: canvasAssetKindOrNull(snapshot.assetKind);
const isSelfContainedLocalResource =
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
return {
id: layerId,
resourceId,
resourcePersistenceState: resource
? 'registered'
: isSelfContainedLocalResource
? 'self-contained-local'
: resourceId.startsWith('local-')
? 'unresolved-local'
: undefined,
title,
src,
x: numberFromSnapshot(snapshot.x, 0),
@@ -751,13 +768,17 @@ export function hydrateLayer(
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
actualPrompt:
stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
stringOrNull(snapshot.actualPrompt) ??
stringOrNull(resource?.actualPrompt),
model: resolveHydratedLayerModel(snapshot, resource, resourcesById),
provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
provider:
stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
objectKey:
stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
assetObjectId:
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
stringOrNull(snapshot.assetObjectId) ??
stringOrNull(resource?.assetObjectId),
durationSeconds:
audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(resource?.durationSeconds),
@@ -766,9 +787,9 @@ export function hydrateLayer(
stringOrNull(resource?.sourceResourceId),
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
assetKind:
canvasAssetKindOrNull(resource?.assetKind) ??
canvasAssetKindOrNull(snapshot.assetKind),
resourceAssetKind,
assetKindOverride,
assetKind: assetKindOverride ?? resourceAssetKind,
generationInputs:
generationInputsOrNull(resource?.generationInputs) ??
generationInputsOrNull(snapshot.generationInputs),
@@ -779,6 +800,120 @@ export function hydrateLayer(
};
}
function isSelfContainedLegacyLocalImageSequence(
snapshot: EditorProjectLayerSnapshot,
) {
if (
!snapshot.resourceId.startsWith('local-') ||
snapshot.sourceType !== 'generated' ||
snapshot.mediaType !== 'image-sequence'
) {
return false;
}
if (
!isEmptyLegacyLocalSequenceField(snapshot.imageSrc) ||
!isEmptyLegacyLocalSequenceField(snapshot.objectKey) ||
!isEmptyLegacyLocalSequenceField(snapshot.assetObjectId)
) {
return false;
}
if (
!Array.isArray(snapshot.imageSequenceFrames) ||
snapshot.imageSequenceFrames.length === 0
) {
return false;
}
let firstFrameSrc = '';
for (const [index, value] of snapshot.imageSequenceFrames.entries()) {
if (!value || typeof value !== 'object') {
return false;
}
const frame = value as Record<string, unknown>;
const imageSrc =
typeof frame.imageSrc === 'string' ? frame.imageSrc.trim() : '';
if (
frame.frameIndex !== index + 1 ||
typeof frame.width !== 'number' ||
!Number.isFinite(frame.width) ||
frame.width <= 0 ||
typeof frame.height !== 'number' ||
!Number.isFinite(frame.height) ||
frame.height <= 0 ||
!isStableLegacyCanvasObjectPath(imageSrc)
) {
return false;
}
if (index === 0) {
firstFrameSrc = imageSrc;
}
if (frame.objectKey !== undefined) {
if (
typeof frame.objectKey !== 'string' ||
!isStableLegacyCanvasObjectKey(frame.objectKey) ||
frame.objectKey.trim() !== imageSrc.replace(/^\/+/, '')
) {
return false;
}
}
}
const source = typeof snapshot.src === 'string' ? snapshot.src.trim() : '';
if (
source &&
(!isStableLegacyCanvasObjectPath(source) || source !== firstFrameSrc)
) {
return false;
}
return [snapshot.thumbnailSrc, snapshot.previewVideoPath].every(
(value) =>
value === undefined ||
value === null ||
(typeof value === 'string' && isStableLegacyCanvasObjectPath(value)),
);
}
function isEmptyLegacyLocalSequenceField(value: unknown) {
return (
value === undefined ||
value === null ||
(typeof value === 'string' && !value.trim())
);
}
function isStableLegacyCanvasObjectPath(value: string) {
const normalized = value.trim();
return (
normalized.startsWith('/') &&
!normalized.startsWith('//') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function isStableLegacyCanvasObjectKey(value: string) {
const normalized = value.trim();
return (
Boolean(normalized) &&
!normalized.startsWith('/') &&
!normalized.includes('?') &&
!normalized.includes('#') &&
!normalized.includes('\\') &&
!normalized.split('/').includes('..') &&
!hasControlCharacter(normalized)
);
}
function hasControlCharacter(value: string) {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
});
}
function resolveHydratedLayerModel(
snapshot: EditorProjectLayerSnapshot,
resource: CanvasLayerResourceMetadata | undefined,
@@ -806,9 +941,7 @@ function resolveHydratedLayerModel(
return persistedModel;
}
export function mapAssetLibrarySnapshot(
library: EditorAssetLibrarySnapshot,
): {
export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
folders: EditorAssetFolder[];
assets: EditorAsset[];
} {
@@ -929,6 +1062,17 @@ export type CanvasLayerResourceMetadata = {
updatedAt?: string | null;
};
export function resolveLayerResourceAssetKind(
layer: CanvasLayer,
): CanvasAssetKind | null {
if (layer.resourceAssetKind !== undefined) {
return layer.resourceAssetKind;
}
return layer.assetKindOverride === undefined
? (layer.assetKind ?? null)
: null;
}
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
const mapped = mapAssetLibrarySnapshot(library);
let hasDefaultFolder = false;
@@ -992,7 +1136,8 @@ export function soundDurationOrDefault(
value: unknown,
fallbackValue?: unknown,
) {
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
const resolvedValue =
audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
if (!resolvedValue) {
return undefined;
}
@@ -1103,10 +1248,7 @@ export function resolveContextMenuPosition(
};
}
export function hasDataTransferType(
dataTransfer: DataTransfer,
type: string,
) {
export function hasDataTransferType(dataTransfer: DataTransfer, type: string) {
return Array.from(dataTransfer.types).includes(type);
}
@@ -1136,10 +1278,12 @@ 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,
) ||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
Boolean(assetSrc && layerSrc === assetSrc)
);
@@ -1295,9 +1439,7 @@ function hydrateSpecFormValues(
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
characterView:
typeof snapshot.characterView === 'string'
? snapshot.characterView
: '',
typeof snapshot.characterView === 'string' ? snapshot.characterView : '',
customPrompt:
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
};
@@ -1355,7 +1497,8 @@ function hydrateCharacterReference(
return null;
}
const objectKey =
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
stringOrUndefined(snapshot.objectKey) ??
stringOrUndefined(resource?.objectKey);
const assetObjectId =
stringOrUndefined(snapshot.assetObjectId) ??
stringOrUndefined(resource?.assetObjectId);
@@ -1683,8 +1826,7 @@ function snapItemsOverlapOnCrossAxis(
const itemCrossStart = getSnapItemCrossStart(item, axis);
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
return (
proposedCrossStart <=
itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
);
}
@@ -180,6 +180,8 @@ function createStageProps(): ImageCanvasStageViewProps {
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canCopyContextLayers: true,
isCanvasClipboardCopyBlocked: false,
canUndo: false,
canRedo: false,
historyFeedback: null,
@@ -84,6 +84,8 @@ export type CanvasGenerationInputs = {
export type CanvasLayer = {
id: string;
resourceId: string;
resourcePersistenceState?:
'pending' | 'registered' | 'self-contained-local' | 'unresolved-local';
title: string;
src: string;
mediaType?: CanvasMediaType;
@@ -107,6 +109,8 @@ export type CanvasLayer = {
sourceAssetId?: string | null;
generatedAssetSnapshot?: EditorAssetSnapshot | null;
groupId?: string | null;
resourceAssetKind?: CanvasAssetKind | null;
assetKindOverride?: CanvasAssetKind | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
@@ -390,12 +394,7 @@ export type CropExpandPanelState = {
};
export type CropExpandRatioValue =
| 'free'
| '1:1'
| '4:3'
| '3:4'
| '16:9'
| '9:16';
'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
export type CropExpandResizeHandle =
| 'left'
@@ -2032,42 +2032,31 @@ describe('ImageCanvasEditorView', () => {
await screen.findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/creation-type-references/puzzle.webp',
assetKind: 'character',
}),
);
});
await waitFor(() => {
const savedLayout = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.some(
(layer: { resourceId?: unknown; title?: unknown }) =>
(layer: {
resourceId?: unknown;
title?: unknown;
assetKindOverride?: unknown;
}) =>
layer.title === '拼图素材' &&
layer.resourceId === 'resource-editor-project-default-640',
layer.resourceId === 'resource-puzzle' &&
layer.assetKindOverride === 'character',
),
);
expect(savedLayout).toBeTruthy();
expect(savedLayout.layers).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
title: '拼图素材',
assetKind: expect.anything(),
}),
]),
);
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
it('flushes the layer override before splitting without replacing its resource', async () => {
const savedLayout = createDeferred<unknown>();
saveEditorProjectLayoutMock.mockImplementationOnce(
() => savedLayout.promise,
);
render(<ImageCanvasEditorView />);
@@ -2085,80 +2074,48 @@ describe('ImageCanvasEditorView', () => {
),
);
const pendingSplitButton = await screen.findByRole('button', {
name: '素材类型保存中',
});
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
fireEvent.click(pendingSplitButton);
const splitButton = await screen.findByRole('button', { name: '拆分图集' });
fireEvent.click(splitButton);
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
await act(async () => {
persistedResource.resolve({
resourceId: 'resource-confirmed-atlas',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spritesheet',
});
await persistedResource.promise;
savedLayout.resolve({});
await savedLayout.promise;
});
const splitButton = await screen.findByRole('button', {
name: '拆分图集',
});
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(splitButton);
await waitFor(() => {
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-default',
sourceResourceId: 'resource-confirmed-atlas',
sourceLayerId: 'layer-puzzle',
sourceResourceId: 'resource-puzzle',
}),
);
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('rolls back the asset kind and resource when persistence fails', async () => {
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock.mockImplementationOnce(
() => persistedResource.promise,
);
it('undoes and redoes only the layer override while keeping the resource stable', async () => {
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材类型保存中' });
await act(async () => {
persistedResource.reject(new Error('资源类型写入失败'));
try {
await persistedResource.promise;
} catch {
// The component consumes the rejection and restores the prior layer.
}
});
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', {
name: '素材标签:未知',
});
expect(
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
@@ -2166,45 +2123,13 @@ describe('ImageCanvasEditorView', () => {
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
assetKindOverride: null,
}),
]),
);
});
});
it('rolls back when the resource response keeps the old asset kind', async () => {
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-stale-icon-spec',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spec',
});
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
),
);
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
expect(
screen.getByText(
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
@@ -2212,249 +2137,12 @@ describe('ImageCanvasEditorView', () => {
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
assetKindOverride: 'character',
}),
]),
);
});
});
it('persists an undone asset kind and ignores the older pending response', async () => {
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => characterResource.promise)
.mockImplementationOnce(() => restoredResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await screen.findByRole('button', { name: '素材标签:角色' });
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ assetKind: null }),
);
await act(async () => {
restoredResource.resolve({
resourceId: 'resource-restored-kind',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: null,
});
await restoredResource.promise;
});
await waitFor(() => {
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-restored-kind',
),
),
).toBe(true);
});
await act(async () => {
characterResource.resolve({
resourceId: 'resource-stale-character',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await characterResource.promise;
});
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character',
),
),
).toBe(false);
});
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
const staleCharacterResource =
createDeferred<EditorProjectResourceSnapshot>();
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => failedUndoResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await within(layerButton).findByRole('button', {
name: '素材标签:角色',
});
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
});
await act(async () => {
failedUndoResource.reject(new Error('撤销后资源写入失败'));
try {
await failedUndoResource.promise;
} catch {
// The latest failure must restore the server-confirmed original layer.
}
});
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
expect(
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
).toBeTruthy();
await waitFor(() => {
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
expect(latestLayout.layers).toEqual(
expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-puzzle',
resourceId: 'resource-puzzle',
}),
]),
);
});
const undoButton = screen.getByRole('button', { name: '撤销' });
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(undoButton);
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
await act(async () => {
staleCharacterResource.resolve({
resourceId: 'resource-stale-character-after-failed-undo',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'character',
});
await staleCharacterResource.promise;
});
expect(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
).toBeTruthy();
expect(
saveEditorProjectLayoutMock.mock.calls.some((call) =>
call[1].layers.some(
(layer: { resourceId?: unknown }) =>
layer.resourceId === 'resource-stale-character-after-failed-undo',
),
),
).toBe(false);
});
it('keeps the newer asset kind pending when an older request fails', async () => {
const staleCharacterResource =
createDeferred<EditorProjectResourceSnapshot>();
const currentAtlasResource =
createDeferred<EditorProjectResourceSnapshot>();
createEditorProjectResourceMock
.mockImplementationOnce(() => staleCharacterResource.promise)
.mockImplementationOnce(() => currentAtlasResource.promise);
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(layerButton);
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await within(layerButton).findByRole('button', {
name: '素材标签:角色',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '图集' },
),
);
await screen.findByRole('button', { name: '素材类型保存中' });
await act(async () => {
staleCharacterResource.reject(new Error('旧请求失败'));
try {
await staleCharacterResource.promise;
} catch {
// The stale rejection must not affect the newer request state.
}
});
expect(
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
).toBeTruthy();
const stillPendingButton = screen.getByRole('button', {
name: '素材类型保存中',
});
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByText(//u)).toBeNull();
await act(async () => {
currentAtlasResource.resolve({
resourceId: 'resource-current-atlas',
projectId: 'editor-project-default',
imageSrc: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
sourceType: 'uploaded',
assetKind: 'icon-spritesheet',
});
await currentAtlasResource.promise;
});
expect(
await screen.findByRole('button', { name: '拆分图集' }),
).toBeTruthy();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('offers Lovart-style zoom menu commands', async () => {
@@ -2580,10 +2268,10 @@ describe('ImageCanvasEditorView', () => {
expect(
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
).toBe(false);
fireEvent.contextMenu(
within(messageLog).getByRole('presentation'),
{ clientX: 30, clientY: 40 },
);
fireEvent.contextMenu(within(messageLog).getByRole('presentation'), {
clientX: 30,
clientY: 40,
});
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
expect(world.style.transform).toBe(initialTransform);
@@ -39,6 +39,7 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
generationInputsOrNull,
isInlineEditorMediaSource,
resolveLayerResourceAssetKind,
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
@@ -66,6 +67,7 @@ import {
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
@@ -319,15 +321,7 @@ export function ImageCanvasEditorView({
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
(matchesLayer: (layer: CanvasLayer) => boolean) => void
>(() => {});
const persistRestoredAssetKindRef = useRef<
(layer: CanvasLayer, historyEntryId?: number) => void
>(() => {});
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
new Map<string, Set<number>>(),
);
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
@@ -792,35 +786,24 @@ export function ImageCanvasEditorView({
}),
[],
);
const canvasHistoryEffects = useMemo(
() => ({
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
persistRestoredAssetKindRef.current(layer, historyEntryId),
}),
[],
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
discardCanvasHistoryEntriesById,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
effects: canvasHistoryEffects,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
discardCanvasHistoryEntriesContainingLayerRef.current =
discardCanvasHistoryEntriesContainingLayer;
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(null);
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
if (!historyFeedback) {
return;
@@ -828,6 +811,21 @@ export function ImageCanvasEditorView({
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [historyFeedback]);
const showCanvasLayerCopyWarning = useCallback(
(reason: CanvasLayerCopyBlockReason) => {
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: 'warning',
text:
reason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: reason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: '素材仍在保存,请稍后重试',
}));
},
[],
);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
@@ -1234,105 +1232,19 @@ export function ImageCanvasEditorView({
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
}, [applyGeneratedProjectSnapshot, projectId]);
const setLayerAssetKindPersistencePending = useCallback(
(layerId: string, isPending: boolean) => {
const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current);
if (isPending) {
nextLayerIds.add(layerId);
} else {
nextLayerIds.delete(layerId);
}
persistingAssetKindLayerIdsRef.current = nextLayerIds;
setPersistingAssetKindLayerIds(nextLayerIds);
},
[],
);
const persistUpdatedLayerResource = useCallback(
(
layer: CanvasLayer,
rollbackLayer?: CanvasLayer,
historyEntryId?: number,
) => {
(layer: CanvasLayer) => {
const requestVersion =
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
const confirmedRollbackLayer = rollbackLayer
? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer)
: undefined;
if (confirmedRollbackLayer) {
assetKindRollbackLayerByIdRef.current.set(
layer.id,
confirmedRollbackLayer,
);
if (historyEntryId !== undefined) {
const historyEntryIds = new Set(
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id),
);
historyEntryIds.add(historyEntryId);
assetKindHistoryEntryIdsByLayerIdRef.current.set(
layer.id,
historyEntryIds,
);
}
setLayerAssetKindPersistencePending(layer.id, true);
}
const rollbackLatestRequest = (error: unknown) => {
if (
layerResourcePersistenceVersionRef.current.get(layer.id) !==
requestVersion ||
!confirmedRollbackLayer
) {
return;
}
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
assetKind: confirmedRollbackLayer.assetKind,
resourceId: confirmedRollbackLayer.resourceId,
}
: currentLayer,
);
layersRef.current = nextLayers;
return nextLayers;
});
setMetadataLayer((currentLayer) =>
currentLayer?.id === layer.id
? {
...currentLayer,
assetKind: confirmedRollbackLayer.assetKind,
resourceId: confirmedRollbackLayer.resourceId,
}
: currentLayer,
);
const historyEntryIds =
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id);
if (historyEntryIds) {
discardCanvasHistoryEntriesById(historyEntryIds);
}
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
assetKindRollbackLayerByIdRef.current.delete(layer.id);
setLayerAssetKindPersistencePending(layer.id, false);
const reason =
error instanceof Error && error.message.trim()
? error.message.trim()
: '请稍后重试';
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: 'warning',
text: `${reason}`,
}));
};
if (!projectId) {
rollbackLatestRequest(new Error('项目尚未就绪'));
return;
}
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
if (!imageSrc) {
rollbackLatestRequest(new Error('素材图片尚未就绪'));
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(layer);
void createEditorProjectResource(projectId, {
imageSrc,
objectKey: layer.objectKey,
@@ -1347,7 +1259,7 @@ export function ImageCanvasEditorView({
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
assetKind: resourceAssetKind,
generationInputs: layer.generationInputs,
})
.then((resource) => {
@@ -1357,21 +1269,19 @@ export function ImageCanvasEditorView({
) {
return;
}
if (
confirmedRollbackLayer &&
(resource.assetKind !== layer.assetKind ||
!resource.resourceId.trim())
) {
rollbackLatestRequest(new Error('服务器未确认新的素材类型'));
return;
}
const confirmedResourceAssetKind =
canvasAssetKindOrNull(resource.assetKind) ?? resourceAssetKind;
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
assetKind: layer.assetKind,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
@@ -1382,31 +1292,20 @@ export function ImageCanvasEditorView({
currentLayer?.id === layer.id
? {
...currentLayer,
assetKind: layer.assetKind,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
if (confirmedRollbackLayer) {
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
assetKindRollbackLayerByIdRef.current.delete(layer.id);
setLayerAssetKindPersistencePending(layer.id, false);
}
})
.catch(rollbackLatestRequest);
.catch(() => undefined);
},
[
discardCanvasHistoryEntriesById,
projectId,
setLayerAssetKindPersistencePending,
],
[projectId],
);
persistRestoredAssetKindRef.current = (layer, historyEntryId) => {
const rollbackLayer = layersRef.current.find(
(currentLayer) => currentLayer.id === layer.id,
);
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
};
const {
assetExportStatus,
isExportingAssets,
@@ -1514,11 +1413,7 @@ export function ImageCanvasEditorView({
}
refreshEditorWalletState();
},
[
projectId,
refreshEditorWalletState,
showGenerationWarning,
],
[projectId, refreshEditorWalletState, showGenerationWarning],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
@@ -1775,6 +1670,9 @@ export function ImageCanvasEditorView({
: null;
const {
canvasClipboard,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
pasteCanvasClipboard,
copyContextLayers,
copySelectedLayers,
@@ -1799,6 +1697,7 @@ export function ImageCanvasEditorView({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -1811,6 +1710,7 @@ export function ImageCanvasEditorView({
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
});
const {
canvasMarquee,
@@ -1955,18 +1855,23 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
const historyEntryId = captureCanvasHistory({
captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
layerIds: [layerId],
});
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
const assetKindOverride =
assetKind === resourceAssetKind ? null : assetKind;
const nextLayer = {
...targetLayer,
assetKind,
resourceAssetKind,
assetKindOverride,
assetKind: assetKindOverride ?? resourceAssetKind,
};
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((layer) =>
layer.id === layerId ? { ...layer, assetKind } : layer,
layer.id === layerId ? nextLayer : layer,
);
layersRef.current = nextLayers;
return nextLayers;
@@ -1974,11 +1879,10 @@ export function ImageCanvasEditorView({
setMetadataLayer((currentLayer) =>
currentLayer?.id === layerId ? nextLayer : currentLayer,
);
persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId);
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory, persistUpdatedLayerResource],
[captureCanvasHistory],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
@@ -2381,7 +2285,6 @@ export function ImageCanvasEditorView({
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds,
persistingAssetKindLayerIds,
uploadDropTarget,
contextMenu,
canvasClipboard,
@@ -2389,6 +2292,9 @@ export function ImageCanvasEditorView({
imageContextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
canUndo,
canRedo,
historyFeedback,
@@ -2432,10 +2338,9 @@ export function ImageCanvasEditorView({
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onSplitIconSpritesheet: (layer: CanvasLayer) => {
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
return;
}
void splitSelectedIconSpritesheet(layer);
void flushProjectPersistence().then(() =>
splitSelectedIconSpritesheet(layer),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
@@ -136,13 +136,27 @@ function mergeLayerForHistoryRestore(
if (!current) {
return { ...target };
}
const resourceAssetKind =
current.resourceAssetKind ?? target.resourceAssetKind;
const restoresLayerOverride =
target.assetKindOverride !== undefined ||
current.assetKindOverride !== undefined;
const assetKindOverride = restoreAssetKind
? (target.assetKindOverride ?? null)
: current.assetKindOverride;
return {
...current,
x: target.x,
y: target.y,
zIndex: target.zIndex,
groupId: target.groupId,
assetKind: restoreAssetKind ? target.assetKind : current.assetKind,
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
...(restoresLayerOverride ? { assetKindOverride } : {}),
assetKind: restoreAssetKind
? restoresLayerOverride
? (assetKindOverride ?? resourceAssetKind ?? null)
: target.assetKind
: current.assetKind,
hidden: target.hidden,
locked: target.locked,
flipX: target.flipX,
@@ -5,10 +5,12 @@ import type {
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
canCopyCanvasLayers,
createCanvasLayerClipboard,
duplicateCanvasLayers,
flipCanvasLayers,
getCanvasLayersByIds,
getCanvasLayerCopyBlockReason,
groupCanvasLayers,
moveCanvasLayers,
removeCanvasLayers,
@@ -51,7 +53,9 @@ describe('ImageCanvasLayerCommandModel', () => {
'layer-a',
'layer-b',
]);
expect(resolveContextTargetLayerIds(menu, ['layer-a'])).toEqual(['layer-b']);
expect(resolveContextTargetLayerIds(menu, ['layer-a'])).toEqual([
'layer-b',
]);
expect(
resolveContextTargetLayerIds(
{ kind: 'blank', x: 0, y: 0, canvasPoint: { x: 0, y: 0 } },
@@ -61,7 +65,15 @@ describe('ImageCanvasLayerCommandModel', () => {
});
it('duplicates layers to a canvas point while preserving relative offsets', () => {
const first = createLayer({ id: 'first', title: '第一张', x: 20, y: 30 });
const first = createLayer({
id: 'first',
title: '第一张',
x: 20,
y: 30,
resourceAssetKind: 'character',
assetKindOverride: 'icon',
assetKind: 'icon',
});
const second = createLayer({
id: 'second',
title: '第二张',
@@ -81,7 +93,7 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(duplicated).toMatchObject([
{
id: 'layer-copy-test-0',
resourceId: 'local-resource-copy-test-0',
resourceId: 'resource-first',
title: '第一张 副本',
x: 300,
y: 200,
@@ -90,7 +102,7 @@ describe('ImageCanvasLayerCommandModel', () => {
},
{
id: 'layer-copy-test-1',
resourceId: 'local-resource-copy-test-1',
resourceId: 'resource-second',
title: '第二张 副本',
x: 350,
y: 260,
@@ -107,12 +119,67 @@ describe('ImageCanvasLayerCommandModel', () => {
});
expect(cutPaste[0]?.title).toBe('第一张');
expect(cutPaste[0]?.x).toBe(first.x + 32);
expect(cutPaste[0]?.resourceId).toBe(first.resourceId);
expect(cutPaste[0]?.assetKindOverride).toBe('icon');
});
it('only allows copy semantics after every resource is registered', () => {
const readyLayer = createLayer({ resourceId: 'resource-ready' });
const pendingLayer = createLayer({
id: 'pending',
resourceId: 'local-resource-pending',
resourcePersistenceState: 'pending',
});
const selfContainedLayer = createLayer({
id: 'historical-sequence',
resourceId: 'local-resource-historical-sequence',
resourcePersistenceState: 'self-contained-local',
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/historical/frame-1.png',
width: 100,
height: 80,
},
],
});
const unresolvedLayer = createLayer({
id: 'unresolved',
resourceId: 'local-resource-unresolved',
resourcePersistenceState: 'unresolved-local',
});
expect(canCopyCanvasLayers([readyLayer])).toBe(true);
expect(canCopyCanvasLayers([readyLayer, pendingLayer])).toBe(false);
expect(getCanvasLayerCopyBlockReason([pendingLayer])).toBe(
'pending-resource',
);
expect(getCanvasLayerCopyBlockReason([selfContainedLayer])).toBe(
'self-contained-local-unsupported',
);
expect(getCanvasLayerCopyBlockReason([unresolvedLayer])).toBe(
'unresolved-resource',
);
expect(
duplicateCanvasLayers({
sourceLayers: [pendingLayer],
allLayers: [pendingLayer],
stamp: 'pending',
}),
).toEqual([]);
expect(
createCanvasLayerClipboard([pendingLayer], [pendingLayer.id], 'copy'),
).toBeNull();
});
it('creates a cloned clipboard and removes target layers', () => {
const layers = [
createLayer({ id: 'first' }),
createLayer({ id: 'second', generationInputs: { fields: [], references: [] } }),
createLayer({
id: 'second',
generationInputs: { fields: [], references: [] },
}),
];
const clipboard = createCanvasLayerClipboard(layers, ['second'], 'copy');
@@ -133,11 +200,13 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(moveCanvasLayers(layers, ['middle'], 'up')[1]?.zIndex).toBe(4);
expect(moveCanvasLayers(layers, ['middle'], 'down')[1]?.zIndex).toBe(2);
expect(moveCanvasLayers(layers, ['bottom', 'middle'], 'top')).toMatchObject([
{ id: 'bottom', zIndex: 9 },
{ id: 'middle', zIndex: 10 },
{ id: 'top', zIndex: 8 },
]);
expect(moveCanvasLayers(layers, ['bottom', 'middle'], 'top')).toMatchObject(
[
{ id: 'bottom', zIndex: 9 },
{ id: 'middle', zIndex: 10 },
{ id: 'top', zIndex: 8 },
],
);
expect(
moveCanvasLayers(layers, ['bottom', 'middle'], 'bottom'),
).toMatchObject([
@@ -170,10 +239,13 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(hiddenLayers[1]?.hidden).toBe(true);
expect(hiddenLayers[2]?.hidden).toBeUndefined();
const shownLayers = toggleCanvasLayersVisibility([
createLayer({ id: 'first', hidden: true }),
createLayer({ id: 'second', hidden: true }),
], targetIds);
const shownLayers = toggleCanvasLayersVisibility(
[
createLayer({ id: 'first', hidden: true }),
createLayer({ id: 'second', hidden: true }),
],
targetIds,
);
expect(shownLayers[0]?.hidden).toBe(false);
expect(shownLayers[1]?.hidden).toBe(false);
@@ -9,6 +9,11 @@ export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom';
export type CanvasLayerFlipAxis = 'x' | 'y';
export type CanvasLayerCopyBlockReason =
| 'pending-resource'
| 'self-contained-local-unsupported'
| 'unresolved-resource';
type CanvasPoint = {
x: number;
y: number;
@@ -39,7 +44,9 @@ export function resolveContextTargetLayerIds(
menu.kind === 'generation-dialog'
? getCanvasGenerationSelectionId(menu.dialogId)
: menu.layerId;
return selectedLayerIds.includes(targetId) ? [...selectedLayerIds] : [targetId];
return selectedLayerIds.includes(targetId)
? [...selectedLayerIds]
: [targetId];
}
export function getCanvasLayersByIds(
@@ -49,6 +56,33 @@ export function getCanvasLayersByIds(
return layers.filter((layer) => targetIds.includes(layer.id));
}
export function getCanvasLayerCopyBlockReason(
layers: CanvasLayer[],
): CanvasLayerCopyBlockReason | null {
for (const layer of layers) {
if (layer.resourcePersistenceState === 'registered') {
continue;
}
if (layer.resourcePersistenceState === 'pending') {
return 'pending-resource';
}
if (layer.resourcePersistenceState === 'self-contained-local') {
return 'self-contained-local-unsupported';
}
if (
layer.resourcePersistenceState === 'unresolved-local' ||
layer.resourceId.startsWith('local-')
) {
return 'unresolved-resource';
}
}
return null;
}
export function canCopyCanvasLayers(layers: CanvasLayer[]) {
return layers.length > 0 && getCanvasLayerCopyBlockReason(layers) === null;
}
export function duplicateCanvasLayers({
sourceLayers,
allLayers,
@@ -62,7 +96,7 @@ export function duplicateCanvasLayers({
renameCopies?: boolean;
stamp?: number | string;
}) {
if (!sourceLayers.length) {
if (!canCopyCanvasLayers(sourceLayers)) {
return [];
}
@@ -76,7 +110,6 @@ export function duplicateCanvasLayers({
return sourceLayers.map((layer, index) => ({
...cloneLayer(layer),
id: `layer-copy-${stamp}-${index}`,
resourceId: `local-resource-copy-${stamp}-${index}`,
title: renameCopies ? `${layer.title} 副本` : layer.title,
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
@@ -91,7 +124,7 @@ export function createCanvasLayerClipboard(
mode: CanvasClipboard['mode'],
): CanvasClipboard | null {
const targetLayers = getCanvasLayersByIds(layers, targetIds);
if (!targetLayers.length) {
if (!canCopyCanvasLayers(targetLayers)) {
return null;
}
return {
@@ -32,6 +32,7 @@ import type {
SnapGuide,
} from './ImageCanvasEditorTypes';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView';
@@ -87,6 +88,9 @@ export type ImageCanvasStageViewProps = {
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
canCopyContextLayers: boolean;
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
isCanvasClipboardCopyBlocked: boolean;
canUndo: boolean;
canRedo: boolean;
historyFeedback: CanvasHistoryFeedback | null;
@@ -241,6 +245,9 @@ export function ImageCanvasStageView({
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
canUndo,
canRedo,
historyFeedback,
@@ -447,6 +454,9 @@ export function ImageCanvasStageView({
contextMenuLayer={contextMenuLayer}
contextShouldShowLayer={contextShouldShowLayer}
contextShouldUnlockLayer={contextShouldUnlockLayer}
canCopyContextLayers={canCopyContextLayers}
contextLayerCopyBlockReason={contextLayerCopyBlockReason}
isCanvasClipboardCopyBlocked={isCanvasClipboardCopyBlocked}
onPasteCanvasClipboard={onPasteCanvasClipboard}
onCopyContextLayers={onCopyContextLayers}
onDuplicateContextLayers={onDuplicateContextLayers}
@@ -1443,7 +1443,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
vi.advanceTimersByTime(1);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'-',
);
} finally {
vi.useRealTimers();
}
@@ -1474,7 +1476,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
vi.advanceTimersByTime(2_999);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'-',
);
} finally {
vi.useRealTimers();
}
@@ -1935,6 +1939,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
await waitFor(() => {
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith({
projectId: 'project-1',
sourceLayerId: 'layer-source',
sourceResourceId: 'resource-source',
assetFolderId: undefined,
canvasCompletion: {
@@ -2010,9 +2015,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '拆图状态' }).textContent,
).toBe('空闲');
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
'空闲',
);
});
});
@@ -2180,9 +2185,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '对当前图层生成动作' }),
);
fireEvent.click(screen.getByRole('button', { name: '对当前图层生成动作' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'character-animation:idle:open:-:placeholder',
);
@@ -236,19 +236,17 @@ function findSourceGenerationDialog(
dialogs: CanvasGenerationDialogState[],
sourceLayer: CanvasLayer,
) {
return [...dialogs]
.reverse()
.find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
// sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。
(!dialog.generatedLayerId &&
dialog.sourceLayerId === sourceLayer.id &&
isGenerationDialogModeCompatibleWithSourceLayer(
dialog.mode,
sourceLayer,
)),
);
return [...dialogs].reverse().find(
(dialog) =>
dialog.generatedLayerId === sourceLayer.id ||
// sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。
(!dialog.generatedLayerId &&
dialog.sourceLayerId === sourceLayer.id &&
isGenerationDialogModeCompatibleWithSourceLayer(
dialog.mode,
sourceLayer,
)),
);
}
function isGenerationDialogModeCompatibleWithSourceLayer(
@@ -675,8 +673,10 @@ export function useImageCanvasGenerationWorkflow({
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
const [
splittingIconSpritesheetLayerIds,
setSplittingIconSpritesheetLayerIds,
] = useState<Set<string>>(() => new Set());
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
@@ -1758,6 +1758,7 @@ export function useImageCanvasGenerationWorkflow({
try {
const result = await splitEditorIconSpritesheet({
projectId,
sourceLayerId: sourceLayer.id,
sourceResourceId: sourceLayer.resourceId,
assetFolderId,
canvasCompletion: {
@@ -71,6 +71,7 @@ function LayerCommandsHarness({
exportLayerImage = vi.fn(),
onDeleteLayerSideEffects = vi.fn(),
onDeleteGenerationDialogSideEffects = vi.fn(),
onCanvasLayerCopyBlocked = vi.fn(),
initialLayers,
}: {
exportLayerImage?: (
@@ -79,6 +80,12 @@ function LayerCommandsHarness({
) => void;
onDeleteLayerSideEffects?: (layerId: string) => void;
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
onCanvasLayerCopyBlocked?: (
reason:
| 'pending-resource'
| 'self-contained-local-unsupported'
| 'unresolved-resource',
) => void;
initialLayers?: CanvasLayer[];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(
@@ -140,6 +147,8 @@ function LayerCommandsHarness({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -154,6 +163,7 @@ function LayerCommandsHarness({
onDeleteLayerSideEffects,
onDeleteGenerationDialogSideEffects,
exportLayerImage,
onCanvasLayerCopyBlocked,
});
return (
@@ -173,9 +183,7 @@ function LayerCommandsHarness({
<span data-testid="hovered">{hoveredLayerId ?? '-'}</span>
<span data-testid="context">{contextMenu ? 'open' : 'closed'}</span>
<span data-testid="history">{historyCount}</span>
<span data-testid="image-context-closed">
{imageContextClosedCount}
</span>
<span data-testid="image-context-closed">{imageContextClosedCount}</span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="clipboard">
{commands.canvasClipboard
@@ -283,6 +291,24 @@ function LayerCommandsHarness({
>
</button>
<button
type="button"
onClick={() =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.resourceId.startsWith('local-')
? {
...layer,
resourceId: `resource-saved-${layer.id}`,
resourcePersistenceState: 'registered' as const,
}
: layer,
),
)
}
>
</button>
</div>
);
}
@@ -312,8 +338,7 @@ describe('useImageCanvasLayerCommands', () => {
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: ClipboardImageItems }>
| undefined;
Array<{ items: ClipboardImageItems }> | undefined;
await expect(
clipboardItems?.[0]?.items['image/png'],
).resolves.toBeInstanceOf(Blob);
@@ -337,8 +362,7 @@ describe('useImageCanvasLayerCommands', () => {
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: ClipboardImageItems }>
| undefined;
Array<{ items: ClipboardImageItems }> | undefined;
const pngBlobPromise = clipboardItems?.[0]?.items['image/png'];
expect(pngBlobPromise).toBeInstanceOf(Promise);
@@ -378,8 +402,7 @@ describe('useImageCanvasLayerCommands', () => {
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
const clipboardItems = write.mock.calls[0]?.[0] as
| Array<{ items: ClipboardImageItems }>
| undefined;
Array<{ items: ClipboardImageItems }> | undefined;
await expect(
Promise.resolve(clipboardItems?.[0]?.items['image/png']).then(
(blob) => blob?.type,
@@ -492,6 +515,11 @@ describe('useImageCanvasLayerCommands', () => {
expect(screen.getByTestId('selection').textContent).toContain(
'layer-copy-',
);
fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
const layerIds = (screen.getByTestId('layers').textContent ?? '')
.split('|')
.map((entry) => entry.split(':')[0]);
expect(new Set(layerIds).size).toBe(layerIds.length);
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
fireEvent.click(screen.getByRole('button', { name: '选中置顶' }));
@@ -504,6 +532,97 @@ describe('useImageCanvasLayerCommands', () => {
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third');
});
it('blocks the whole copy operation until every selected resource is registered', () => {
const onCanvasLayerCopyBlocked = vi.fn();
render(
<LayerCommandsHarness
initialLayers={[
createLayer('first', 10, 1),
{
...createLayer('second', 160, 2),
resourceId: 'local-resource-second',
resourcePersistenceState: 'pending',
},
createLayer('third', 310, 3),
]}
onCanvasLayerCopyBlocked={onCanvasLayerCopyBlocked}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
fireEvent.click(screen.getByRole('button', { name: '剪切选中' }));
fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
expect(onCanvasLayerCopyBlocked).toHaveBeenCalledTimes(3);
expect(onCanvasLayerCopyBlocked).toHaveBeenLastCalledWith(
'pending-resource',
);
expect(screen.getByTestId('clipboard').textContent).toBe('-');
expect(screen.getByTestId('layers').textContent).not.toContain('副本');
expect(screen.getByTestId('layers').textContent).toContain('second');
fireEvent.click(screen.getByRole('button', { name: '完成素材保存' }));
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
expect(onCanvasLayerCopyBlocked).toHaveBeenCalledTimes(3);
});
it('reports persisted self-contained sequences as unsupported instead of pending', () => {
const onCanvasLayerCopyBlocked = vi.fn();
render(
<LayerCommandsHarness
initialLayers={[
{
...createLayer('first', 10, 1),
resourceId: 'local-resource-historical-sequence',
resourcePersistenceState: 'self-contained-local',
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/historical/frame-1.png',
width: 100,
height: 80,
},
],
},
]}
onCanvasLayerCopyBlocked={onCanvasLayerCopyBlocked}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '复制' }));
expect(onCanvasLayerCopyBlocked).toHaveBeenCalledWith(
'self-contained-local-unsupported',
);
expect(screen.getByTestId('clipboard').textContent).toBe('-');
});
it('reports unresolved local resources without claiming a save is in progress', () => {
const onCanvasLayerCopyBlocked = vi.fn();
render(
<LayerCommandsHarness
initialLayers={[
{
...createLayer('first', 10, 1),
resourceId: 'local-resource-unresolved',
resourcePersistenceState: 'unresolved-local',
},
]}
onCanvasLayerCopyBlocked={onCanvasLayerCopyBlocked}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '复制' }));
expect(onCanvasLayerCopyBlocked).toHaveBeenCalledWith(
'unresolved-resource',
);
expect(screen.getByTestId('clipboard').textContent).toBe('-');
});
it('applies layer commands and clears menus without owning menu positioning', () => {
const exportLayerImage = vi.fn();
render(<LayerCommandsHarness exportLayerImage={exportLayerImage} />);
@@ -563,7 +682,9 @@ describe('useImageCanvasLayerCommands', () => {
render(
<LayerCommandsHarness
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
onDeleteGenerationDialogSideEffects={onDeleteGenerationDialogSideEffects}
onDeleteGenerationDialogSideEffects={
onDeleteGenerationDialogSideEffects
}
/>,
);

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