diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2111a13a5..5adeaaee6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6007,3 +6007,12 @@ - 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 - UI 决策:Project Supervisor 持有运行中 manifest 状态并向外层启动器同步完整快照;外层项目上下文继续是工作台投影的唯一输入,只接受当前项目路径的更新,不另建资产、任务或版本平行状态。 - 依赖图决策:Agent DB 尾部读取一旦截断,审计 producer 及其 task flow / depth 派生失败关闭;manifest 精确资源引用与审计生产者证据分离。SVG 保持装饰性,辅助技术消费画布关联的文本关系列表。 + +## 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::)] asset_kind_override: Option`,不删除、改名、重排或改类型。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 是不同机制。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20882e1e9..fc8e86587 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -481,6 +481,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` 列表卡片仍只显示“项目”占位,没有封面图。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fa82c0075..738f539e3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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,14 +73,14 @@ - 新增 `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 WebP(quality `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` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `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` 或资源 ID;Data 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。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、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。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 440bd9b5d..8e145d8fc 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 hash;active 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 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 hash;active 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 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-run,apply 必须绑定 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-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index bb6795e99..e04e3b0b2 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -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::)]`。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 --server --project-id --owner-user-id --action `。默认 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`。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 165f831e2..01aad43ae 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -61,7 +61,7 @@ - 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。 - 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 - 底部消息输入框随输入内容从单行高度自动增长,最大高度为 - 128px;输入框及其 Enter 提交、原生自适应和兼容降级统一封装在独立 `EditorAgentDraftTextarea` 组件中。支持 `field-sizing: content` 的浏览器使用原生内容尺寸自适应,不支持该属性的旧 Safari / iOS WebView 使用前端测量降级,并在宽度变化时重新计算换行高度。内容超过最大高度后停止增长并启用内部纵向滚动,内容缩短或清空后同步收缩。内部滚动条使用浅灰窄滑块和透明轨道,上下留白不得溢出输入框圆角边界;输入框在窄屏下允许收缩且不产生横向滚动。 + 128px;原生自适应和兼容降级统一由公共 `AutoGrowTextArea` 组件承载,`EditorAgentDraftTextarea` 只保留 Agent 专属视觉、粘贴和 Enter 提交语义。支持 `field-sizing: content` 的浏览器使用原生内容尺寸自适应;不支持该属性的旧 Safari / iOS WebView 使用前端测量降级,受控 `value` 更新、非受控输入事件和宽度变化都必须重新计算换行高度,同时继续转发调用方的 `onInput`。降级测量必须按元素实际 `box-sizing` 区分 border-box 与 content-box,不能重复计入 padding 或 border。内容超过最大高度后停止增长并启用内部纵向滚动,内容缩短或清空后同步收缩。内部滚动条使用浅灰窄滑块和透明轨道,上下留白不得溢出输入框圆角边界;输入框在窄屏下允许收缩且不产生横向滚动。 - Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7a4d13361..cd88a0e89 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, 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, 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, Extension(request_context): Extension, @@ -5428,12 +5496,13 @@ pub async fn split_editor_icon_spritesheet( ) -> Result, 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( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs index 8c0c28706..ca32c8b9c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs @@ -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, } impl __sdk::InModule for EditorCanvasLayer { @@ -65,6 +66,7 @@ pub struct EditorCanvasLayerCols { pub item_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub asset_kind_override: __sdk::__query_builder::Col>, } 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", + ), } } } diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 5df4afb18..48fb89c49 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -32,6 +32,19 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ "assetKind", "generationInputs", ]; +const EDITOR_CANVAS_ASSET_KINDS: [&str; 11] = [ + "spec", + "character", + "character-animation", + "icon", + "icon-spritesheet", + "icon-spec", + "publication-material", + "ui-design", + "video", + "sound-effect", + "background-music", +]; const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"]; const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending"; const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved"; @@ -149,6 +162,8 @@ pub struct EditorCanvasLayer { item_json: String, created_at: Timestamp, updated_at: Timestamp, + #[default(None::)] + asset_kind_override: Option, } #[spacetimedb::table( @@ -2023,7 +2038,12 @@ fn persist_editor_project_layout_inner( .find(&canvas.canvas_id) .filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE) .ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?; - let current_structured_hash = canonical_layout_sha256(current_layers_json.as_str())?; + let current_resource_asset_kinds = + project_resource_asset_kinds(ctx, project_id.as_str(), owner_user_id.as_str()); + let current_structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + current_layers_json.as_str(), + ¤t_resource_asset_kinds, + )?; let current_structured_integrity = canvas_layout_integrity(current_layers_json.as_str())?; verify_migration_layout( &active_migration, @@ -2032,7 +2052,7 @@ fn persist_editor_project_layout_inner( ¤t_structured_integrity, )?; let mut structured_layout = parse_structured_canvas_layout(layers_json.as_str())?; - normalize_structured_canvas_layer_resource_metadata( + let normalized_resource_asset_kinds = normalize_structured_canvas_layer_resource_metadata( ctx, canvas.canvas_id.as_str(), project_id.as_str(), @@ -2048,7 +2068,10 @@ fn persist_editor_project_layout_inner( )?; replace_structured_canvas_layout(ctx, &canvas, &structured_layout, now)?; let next_revision = canvas.revision.saturating_add(1); - let layout_sha256 = canonical_layout_sha256(normalized_structured_layers_json.as_str())?; + let layout_sha256 = canonical_layout_sha256_with_resource_asset_kinds( + normalized_structured_layers_json.as_str(), + &normalized_resource_asset_kinds, + )?; let integrity = canvas_layout_integrity(normalized_structured_layers_json.as_str())?; let legacy_source_hash = active_migration.source_layout_sha256; let legacy_canvas_layers_json = canvas.layers_json.clone(); @@ -5466,6 +5489,7 @@ struct StructuredCanvasLayerDraft { locked: bool, flip_x: bool, flip_y: bool, + asset_kind_override: Option, item_json: String, } @@ -5609,6 +5633,7 @@ fn parse_structured_canvas_layout(layers_json: &str) -> Result Result) -> Option { .filter(|value| value.is_finite()) } +fn parse_canvas_asset_kind_override(value: Option<&JsonValue>) -> Result, String> { + parse_optional_canvas_asset_kind(value, "assetKindOverride") +} + +fn parse_optional_canvas_asset_kind( + value: Option<&JsonValue>, + field: &str, +) -> Result, String> { + match value { + None | Some(JsonValue::Null) => Ok(None), + Some(JsonValue::String(value)) => { + let normalized = value.trim(); + if EDITOR_CANVAS_ASSET_KINDS.contains(&normalized) { + Ok(Some(normalized.to_string())) + } else { + Err(format!("图片画布图层 {field} 不是受支持的素材类型")) + } + } + Some(_) => Err(format!("图片画布图层 {field} 必须是字符串或 null")), + } +} + +fn parse_optional_legacy_canvas_asset_kind( + value: Option<&JsonValue>, +) -> Result, String> { + match value { + None | Some(JsonValue::Null) => Ok(None), + Some(JsonValue::String(value)) => { + let normalized = value.trim(); + if EDITOR_CANVAS_ASSET_KINDS.contains(&normalized) + || normalized == EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND + { + Ok(Some(normalized.to_string())) + } else { + Err("图片画布图层 assetKind 不是受支持的素材类型".to_string()) + } + } + Some(_) => Err("图片画布图层 assetKind 必须是字符串或 null".to_string()), + } +} + fn structured_canvas_row_key(canvas_id: &str, kind: &str, item_id: &str) -> String { sha256_hex(format!("{canvas_id}\0{kind}\0{item_id}").as_bytes()) } @@ -5685,8 +5752,16 @@ fn sha256_hex(bytes: &[u8]) -> String { } fn canonical_layout_json(layers_json: &str) -> Result { + canonical_layout_json_with_resource_asset_kinds(layers_json, &BTreeMap::new()) +} + +fn canonical_layout_json_with_resource_asset_kinds( + layers_json: &str, + resource_asset_kinds: &BTreeMap>, +) -> Result { let mut value = serde_json::from_str::(layers_json) .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + normalize_effective_canvas_asset_kinds_for_canonical_hash(&mut value, resource_asset_kinds)?; strip_canvas_layer_resource_metadata_for_canonical_hash(&mut value)?; let normalized = serde_json::to_string(&value).map_err(|_| "图片画布图层布局无法序列化".to_string())?; @@ -5696,6 +5771,36 @@ fn canonical_layout_json(layers_json: &str) -> Result { }) } +fn normalize_effective_canvas_asset_kinds_for_canonical_hash( + value: &mut JsonValue, + resource_asset_kinds: &BTreeMap>, +) -> Result<(), String> { + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + for item in items { + let object = item + .as_object_mut() + .ok_or_else(|| "图片画布布局项必须是对象".to_string())?; + if matches!( + object.get("itemType").and_then(JsonValue::as_str), + Some("canvas-settings" | "generation-dialog") + ) { + continue; + } + let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + let asset_kind_override = + parse_canvas_asset_kind_override(object.get("assetKindOverride"))?; + let legacy_asset_kind = parse_optional_legacy_canvas_asset_kind(object.get("assetKind"))? + .filter(|value| value != EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND); + let effective_asset_kind = asset_kind_override + .or(legacy_asset_kind) + .or_else(|| resource_asset_kinds.get(&resource_id).cloned().flatten()); + insert_optional_json_string(object, "assetKindOverride", effective_asset_kind.as_deref()); + } + Ok(()) +} + fn strip_canvas_layer_resource_metadata_for_canonical_hash( value: &mut JsonValue, ) -> Result<(), String> { @@ -5719,6 +5824,7 @@ fn strip_canvas_layer_resource_metadata_for_canonical_hash( source_type.as_deref(), object, )? { + object.remove("assetKind"); continue; } for field in EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS { @@ -5874,6 +5980,35 @@ fn canonical_layout_sha256(layers_json: &str) -> Result { canonical_layout_json(layers_json).map(|value| sha256_hex(value.as_bytes())) } +fn canonical_layout_sha256_with_resource_asset_kinds( + layers_json: &str, + resource_asset_kinds: &BTreeMap>, +) -> Result { + canonical_layout_json_with_resource_asset_kinds(layers_json, resource_asset_kinds) + .map(|value| sha256_hex(value.as_bytes())) +} + +fn project_resource_asset_kinds( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, +) -> BTreeMap> { + ctx.db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_id.to_string()) + .filter(|resource| resource.owner_user_id == owner_user_id) + .map(|resource| { + let asset_kind = normalize_legacy_editor_source_asset_kind( + normalize_optional(resource.asset_kind), + resource.object_key.as_deref(), + ) + .filter(|value| EDITOR_CANVAS_ASSET_KINDS.contains(&value.as_str())); + (resource.resource_id, asset_kind) + }) + .collect() +} + fn canvas_layout_integrity(layers_json: &str) -> Result { let value = serde_json::from_str::(layers_json) .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; @@ -5929,6 +6064,8 @@ fn normalize_structured_canvas_layer_against_resource( layer.source_type.as_deref(), &item, )?; + let legacy_asset_kind = parse_optional_legacy_canvas_asset_kind(item.get("assetKind"))?; + item.remove("assetKind"); let Some(resource) = resource else { if !is_self_contained_local_sequence { return Err(format!( @@ -5942,6 +6079,15 @@ fn normalize_structured_canvas_layer_against_resource( layer.layer_id )); } + if legacy_asset_kind.as_deref() == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) { + return Err(format!( + "本地图片序列 {} 的旧 assetKind 无法在缺少资源时归一", + layer.resource_id + )); + } + merge_legacy_asset_kind_into_layer_override(layer, legacy_asset_kind)?; + layer.item_json = serde_json::to_string(&JsonValue::Object(item)) + .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", layer.layer_id))?; return Ok(None); }; if is_self_contained_local_sequence { @@ -6021,7 +6167,6 @@ fn normalize_structured_canvas_layer_against_resource( } layer.source_type = None; - let layout_asset_kind = take_optional_layout_string(&mut item, "assetKind")?; let layout_generation_inputs = take_optional_layout_json(&mut item, "generationInputs")?; layer.item_json = serde_json::to_string(&JsonValue::Object(item)) .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", layer.layer_id))?; @@ -6031,7 +6176,31 @@ fn normalize_structured_canvas_layer_against_resource( layer.layer_id )); } - Ok(Some((layout_asset_kind, layout_generation_inputs))) + let legacy_asset_kind = normalize_legacy_editor_source_asset_kind( + legacy_asset_kind, + resource.object_key.as_deref(), + ); + Ok(Some((legacy_asset_kind, layout_generation_inputs))) +} + +fn merge_legacy_asset_kind_into_layer_override( + layer: &mut StructuredCanvasLayerDraft, + legacy_asset_kind: Option, +) -> Result<(), String> { + let Some(legacy_asset_kind) = legacy_asset_kind else { + return Ok(()); + }; + if let Some(asset_kind_override) = layer.asset_kind_override.as_deref() { + if asset_kind_override != legacy_asset_kind { + return Err(format!( + "图片画布图层 {} 的 assetKind 与 assetKindOverride 不一致", + layer.layer_id + )); + } + return Ok(()); + } + layer.asset_kind_override = Some(legacy_asset_kind); + Ok(()) } fn legacy_local_sequence_extension_matches_existing_layer( @@ -6040,8 +6209,14 @@ fn legacy_local_sequence_extension_matches_existing_layer( incoming: &StructuredCanvasLayerDraft, ) -> bool { existing_source_type == incoming.source_type.as_deref() - && serde_json::from_str::(existing_item_json).ok() - == serde_json::from_str::(&incoming.item_json).ok() + && normalized_legacy_local_sequence_extension(existing_item_json) + == normalized_legacy_local_sequence_extension(&incoming.item_json) +} + +fn normalized_legacy_local_sequence_extension(item_json: &str) -> Option { + let mut item = serde_json::from_str::>(item_json).ok()?; + item.remove("assetKind"); + Some(JsonValue::Object(item)) } fn restore_omitted_legacy_local_sequence_metadata( @@ -6052,12 +6227,10 @@ fn restore_omitted_legacy_local_sequence_metadata( .map_err(|_| "已迁移本地图片序列扩展数据不是合法对象".to_string())?; let mut item = serde_json::from_str::>(&incoming.item_json) .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", incoming.layer_id))?; - for field in ["assetKind", "generationInputs"] { - if !item.contains_key(field) - && let Some(value) = existing.get(field) - { - item.insert(field.to_string(), value.clone()); - } + if !item.contains_key("generationInputs") + && let Some(value) = existing.get("generationInputs") + { + item.insert("generationInputs".to_string(), value.clone()); } incoming.item_json = serde_json::to_string(&JsonValue::Object(item)) .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", incoming.layer_id))?; @@ -6070,6 +6243,12 @@ fn restore_omitted_legacy_local_sequence_metadata( Ok(()) } +struct StructuredResourceMetadataUpdate { + asset_kind: Option, + generation_inputs_json: Option, + legacy_layer_asset_kinds: Vec<(usize, Option)>, +} + fn normalize_structured_canvas_layer_resource_metadata( ctx: &ReducerContext, canvas_id: &str, @@ -6079,9 +6258,9 @@ fn normalize_structured_canvas_layer_resource_metadata( now: Timestamp, apply_resource_migrations: bool, allow_new_legacy_local_sequences: bool, -) -> Result<(), String> { - let mut resource_metadata_updates = BTreeMap::, Option)>::new(); - for layer in &mut layout.layers { +) -> Result>, String> { + let mut resource_metadata_updates = BTreeMap::::new(); + for (layer_index, layer) in layout.layers.iter_mut().enumerate() { let resource = ctx .db .editor_project_resource() @@ -6156,28 +6335,36 @@ fn normalize_structured_canvas_layer_resource_metadata( }; let update = resource_metadata_updates .entry(resource.resource_id.clone()) - .or_insert_with(|| { - ( - resource.asset_kind.clone(), - resource.generation_inputs_json.clone(), - ) + .or_insert_with(|| StructuredResourceMetadataUpdate { + asset_kind: normalize_legacy_editor_source_asset_kind( + normalize_optional(resource.asset_kind.clone()), + resource.object_key.as_deref(), + ), + generation_inputs_json: resource.generation_inputs_json.clone(), + legacy_layer_asset_kinds: Vec::new(), }); - merge_optional_resource_metadata( - &mut update.0, - layout_asset_kind, - "assetKind", - layer.layer_id.as_str(), - )?; + update + .legacy_layer_asset_kinds + .push((layer_index, layout_asset_kind)); merge_optional_resource_json_metadata( - &mut update.1, + &mut update.generation_inputs_json, layout_generation_inputs, "generationInputs", layer.layer_id.as_str(), )?; } + for update in resource_metadata_updates.values_mut() { + normalize_legacy_layer_asset_kinds(layout, update)?; + } + + let resource_asset_kinds = resource_metadata_updates + .iter() + .map(|(resource_id, update)| (resource_id.clone(), update.asset_kind.clone())) + .collect::>(); + if apply_resource_migrations { - for (resource_id, (asset_kind, generation_inputs_json)) in resource_metadata_updates { + for (resource_id, update) in resource_metadata_updates { let Some(resource) = ctx .db .editor_project_resource() @@ -6186,8 +6373,8 @@ fn normalize_structured_canvas_layer_resource_metadata( else { return Err(format!("图片画布项目资源 {resource_id} 在迁移时消失")); }; - if resource.asset_kind == asset_kind - && resource.generation_inputs_json == generation_inputs_json + if resource.asset_kind == update.asset_kind + && resource.generation_inputs_json == update.generation_inputs_json { continue; } @@ -6198,13 +6385,63 @@ fn normalize_structured_canvas_layer_resource_metadata( ctx.db .editor_project_resource() .insert(EditorProjectResource { - asset_kind, - generation_inputs_json, + asset_kind: update.asset_kind, + generation_inputs_json: update.generation_inputs_json, updated_at: now, ..resource }); } } + Ok(resource_asset_kinds) +} + +fn normalize_legacy_layer_asset_kinds( + layout: &mut StructuredCanvasLayout, + update: &mut StructuredResourceMetadataUpdate, +) -> Result<(), String> { + if let Some(resource_asset_kind) = update.asset_kind.as_deref() { + for (layer_index, legacy_asset_kind) in &update.legacy_layer_asset_kinds { + let Some(legacy_asset_kind) = legacy_asset_kind.as_deref() else { + continue; + }; + let layer = layout + .layers + .get_mut(*layer_index) + .ok_or_else(|| "图片画布图层素材类型归一索引无效".to_string())?; + if layer.asset_kind_override.is_none() && legacy_asset_kind != resource_asset_kind { + layer.asset_kind_override = Some(legacy_asset_kind.to_string()); + } + } + return Ok(()); + } + + let shared_legacy_asset_kind = update + .legacy_layer_asset_kinds + .first() + .and_then(|(_, asset_kind)| asset_kind.clone()) + .filter(|candidate| { + update + .legacy_layer_asset_kinds + .iter() + .all(|(_, asset_kind)| asset_kind.as_ref() == Some(candidate)) + }); + if let Some(shared_legacy_asset_kind) = shared_legacy_asset_kind { + update.asset_kind = Some(shared_legacy_asset_kind); + return Ok(()); + } + + for (layer_index, legacy_asset_kind) in &update.legacy_layer_asset_kinds { + let Some(legacy_asset_kind) = legacy_asset_kind else { + continue; + }; + let layer = layout + .layers + .get_mut(*layer_index) + .ok_or_else(|| "图片画布图层素材类型归一索引无效".to_string())?; + if layer.asset_kind_override.is_none() { + layer.asset_kind_override = Some(legacy_asset_kind.clone()); + } + } Ok(()) } @@ -6272,17 +6509,6 @@ fn take_matching_required_resource_string( } } -fn take_optional_layout_string( - item: &mut JsonMap, - field: &str, -) -> Result, String> { - match item.remove(field) { - None | Some(JsonValue::Null) => Ok(None), - Some(JsonValue::String(value)) => Ok(normalize_optional(Some(value))), - Some(_) => Err(format!("图片画布图层 {field} 必须是字符串")), - } -} - fn take_optional_layout_json( item: &mut JsonMap, field: &str, @@ -6432,6 +6658,7 @@ fn replace_structured_canvas_layout( flip_y: layer.flip_y, item_json: layer.item_json.clone(), updated_at: now, + asset_kind_override: layer.asset_kind_override.clone(), })?; } for dialog in &layout.dialogs { @@ -6486,27 +6713,8 @@ fn build_structured_canvas_layout_json( .editor_canvas_layer() .by_editor_canvas_layer_canvas_id() .filter(&canvas_key) - .map(|row| StructuredCanvasLayerDraft { - layer_id: row.layer_id, - resource_id: row.resource_id, - sort_order: row.sort_order, - title: row.title, - x: row.x, - y: row.y, - width: row.width, - height: row.height, - original_width: row.original_width, - original_height: row.original_height, - z_index: row.z_index, - source_type: row.source_type, - group_id: row.group_id, - hidden: row.hidden, - locked: row.locked, - flip_x: row.flip_x, - flip_y: row.flip_y, - item_json: row.item_json, - }) - .collect::>(); + .map(structured_canvas_layer_draft_from_row) + .collect::, _>>()?; let dialogs = ctx .db .editor_canvas_generation_dialog() @@ -6536,6 +6744,51 @@ fn build_structured_canvas_layout_json( .map_err(|error| format!("结构化图片画布布局无法序列化:{error}")) } +fn structured_canvas_layer_draft_from_row( + row: EditorCanvasLayer, +) -> Result { + let mut item = serde_json::from_str::>(&row.item_json) + .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", row.layer_id))?; + let legacy_asset_kind = parse_optional_canvas_asset_kind(item.get("assetKind"), "assetKind")?; + item.remove("assetKind"); + let mut asset_kind_override = row.asset_kind_override; + if let Some(legacy_asset_kind) = legacy_asset_kind { + match asset_kind_override.as_deref() { + Some(current) if current != legacy_asset_kind => { + return Err(format!( + "图片画布图层 {} 的旧 assetKind 与 typed override 不一致", + row.layer_id + )); + } + Some(_) => {} + None => asset_kind_override = Some(legacy_asset_kind), + } + } + let item_json = serde_json::to_string(&JsonValue::Object(item)) + .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", row.layer_id))?; + Ok(StructuredCanvasLayerDraft { + layer_id: row.layer_id, + resource_id: row.resource_id, + sort_order: row.sort_order, + title: row.title, + x: row.x, + y: row.y, + width: row.width, + height: row.height, + original_width: row.original_width, + original_height: row.original_height, + z_index: row.z_index, + source_type: row.source_type, + group_id: row.group_id, + hidden: row.hidden, + locked: row.locked, + flip_x: row.flip_x, + flip_y: row.flip_y, + asset_kind_override, + item_json, + }) +} + fn serialize_structured_canvas_layout( layout: &StructuredCanvasLayout, ) -> Result { @@ -6564,6 +6817,11 @@ fn serialize_structured_canvas_layout( insert_true_json_bool(&mut object, "locked", layer.locked); insert_true_json_bool(&mut object, "flipX", layer.flip_x); insert_true_json_bool(&mut object, "flipY", layer.flip_y); + insert_optional_json_string( + &mut object, + "assetKindOverride", + layer.asset_kind_override.as_deref(), + ); items.push(( layer.sort_order, format!("layer:{}", layer.layer_id), @@ -7567,7 +7825,12 @@ fn backfill_editor_canvas_layout( } else { ensure_default_canvas(ctx, &project_id, &owner_user_id, Some(&project), now)? }; - let source_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + let current_resource_asset_kinds = + project_resource_asset_kinds(ctx, project_id.as_str(), owner_user_id.as_str()); + let source_hash = canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + ¤t_resource_asset_kinds, + )?; let source_integrity = canvas_layout_integrity(canvas.layers_json.as_str())?; if let Some(existing) = ctx .db @@ -7577,7 +7840,10 @@ fn backfill_editor_canvas_layout( { if existing.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE { let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; - let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json.as_str(), + ¤t_resource_asset_kinds, + )?; let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; if existing.source_layout_sha256 != source_hash { return Err("已激活图片画布的 legacy shadow 与迁移记录不一致".to_string()); @@ -7592,7 +7858,7 @@ fn backfill_editor_canvas_layout( } } let mut layout = parse_structured_canvas_layout(canvas.layers_json.as_str())?; - normalize_structured_canvas_layer_resource_metadata( + let normalized_resource_asset_kinds = normalize_structured_canvas_layer_resource_metadata( ctx, canvas.canvas_id.as_str(), project_id.as_str(), @@ -7604,7 +7870,10 @@ fn backfill_editor_canvas_layout( )?; let preview_json = serialize_structured_canvas_layout(&layout) .map_err(|_| "图片画布结构化回填预览无法序列化".to_string())?; - let preview_hash = canonical_layout_sha256(preview_json.as_str())?; + let preview_hash = canonical_layout_sha256_with_resource_asset_kinds( + preview_json.as_str(), + &normalized_resource_asset_kinds, + )?; let preview_integrity = canvas_layout_integrity(preview_json.as_str())?; if preview_hash != source_hash || preview_integrity != source_integrity { return Err("图片画布结构化回填预览校验失败".to_string()); @@ -7624,7 +7893,10 @@ fn backfill_editor_canvas_layout( canvas.canvas_id.as_str(), layout.background_color.as_deref(), )?; - let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json.as_str(), + &normalized_resource_asset_kinds, + )?; let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; if structured_hash != source_hash || structured_integrity != source_integrity { return Err( @@ -7666,11 +7938,16 @@ fn activate_editor_canvas_layout( .canvas_id() .find(&canvas.canvas_id) .ok_or_else(|| "图片画布尚未执行结构化回填".to_string())?; + let resource_asset_kinds = + project_resource_asset_kinds(ctx, project_id.as_str(), owner_user_id.as_str()); if migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE && canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; - let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json.as_str(), + &resource_asset_kinds, + )?; let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; verify_migration_layout( &migration, @@ -7686,7 +7963,10 @@ fn activate_editor_canvas_layout( if canvas.revision != migration.verified_revision { return Err("图片画布在回填后 revision 已变化,请重新回填后再激活".to_string()); } - let current_source_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + let current_source_hash = canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + &resource_asset_kinds, + )?; if current_source_hash != migration.source_layout_sha256 { return Err("图片画布旧布局在回填后发生变化,请重新回填后再激活".to_string()); } @@ -7697,7 +7977,10 @@ fn activate_editor_canvas_layout( .background_color .as_deref(), )?; - let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json.as_str(), + &resource_asset_kinds, + )?; let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; verify_migration_layout( &migration, @@ -7754,12 +8037,17 @@ fn rollback_editor_canvas_layout( .canvas_id() .find(&canvas.canvas_id) .ok_or_else(|| "图片画布没有可回滚的结构化迁移记录".to_string())?; + let resource_asset_kinds = + project_resource_asset_kinds(ctx, project_id.as_str(), owner_user_id.as_str()); if migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK && canvas.layout_storage_version == 0 { let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; validate_legacy_layout_rollback_size(structured_json.as_str())?; - let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( + structured_json.as_str(), + &resource_asset_kinds, + )?; let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; verify_migration_layout( &migration, @@ -7767,7 +8055,10 @@ fn rollback_editor_canvas_layout( structured_hash.as_str(), &structured_integrity, )?; - let legacy_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + let legacy_hash = canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + &resource_asset_kinds, + )?; if legacy_hash != structured_hash { return Err( "已回滚图片画布的 legacy 布局与保留的结构化数据不一致,拒绝重复回滚".to_string(), @@ -7782,7 +8073,10 @@ fn rollback_editor_canvas_layout( } let layers_json = build_structured_canvas_layout_json(ctx, &canvas)?; validate_legacy_layout_rollback_size(layers_json.as_str())?; - let layout_hash = canonical_layout_sha256(layers_json.as_str())?; + let layout_hash = canonical_layout_sha256_with_resource_asset_kinds( + layers_json.as_str(), + &resource_asset_kinds, + )?; let integrity = canvas_layout_integrity(layers_json.as_str())?; verify_migration_layout( &migration, @@ -8894,6 +9188,7 @@ mod tests { "resourceId": "resource-1", "x": 12, "hidden": false, + "assetKindOverride": "character", "customExtension": { "tone": "warm" } }, { @@ -8911,6 +9206,11 @@ mod tests { let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) .expect("structured layout should parse"); assert!(!parsed.layers[0].item_json.contains("\"x\"")); + assert!(!parsed.layers[0].item_json.contains("assetKindOverride")); + assert_eq!( + parsed.layers[0].asset_kind_override.as_deref(), + Some("character") + ); assert!(!parsed.dialogs[0].dialog_json.contains("\"status\"")); parsed.layers[0].x = Some(88.0); @@ -8921,6 +9221,7 @@ mod tests { assert_eq!(value[0]["x"], json!(88.0)); assert_eq!(value[0]["hidden"], json!(true)); + assert_eq!(value[0]["assetKindOverride"], json!("character")); assert_eq!(value[0]["customExtension"]["tone"], json!("warm")); assert_eq!(value[1]["dialog"]["status"], json!("generating")); assert_eq!(value[1]["dialog"]["prompt"], json!("角色")); @@ -9102,6 +9403,13 @@ mod tests { let layout = self_contained_local_sequence_layout(); let rebuilt = normalize_single_layer_without_resource(&layout) .expect("valid self-contained local sequence should normalize"); + let rebuilt_value: JsonValue = + serde_json::from_str(&rebuilt).expect("rebuilt local sequence json"); + assert_eq!( + rebuilt_value[0]["assetKindOverride"], + json!("character-animation") + ); + assert!(rebuilt_value[0].get("assetKind").is_none()); assert_eq!( canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), canonical_layout_sha256(rebuilt.as_str()).expect("rebuilt hash") @@ -9126,7 +9434,7 @@ mod tests { "/0/previewVideoPath", "/generated/sequence/preview-changed.mp4", ), - ("/0/assetKind", "changed"), + ("/0/assetKind", "icon"), ("/0/generationInputs/action", "changed"), ] { let mut changed = layout.clone(); @@ -9691,6 +9999,233 @@ mod tests { ); } + #[test] + fn structured_canvas_asset_kind_override_is_optional_and_fail_closed() { + let legacy = r#"[{"layerId":"layer-1","resourceId":"resource-1"}]"#; + let parsed = parse_structured_canvas_layout(legacy) + .expect("layout rows written before override support must remain compatible"); + assert_eq!(parsed.layers[0].asset_kind_override, None); + let rebuilt = serialize_structured_canvas_layout(&parsed) + .expect("legacy layout without override should round trip"); + let rebuilt: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt layout json"); + assert!(rebuilt[0].get("assetKindOverride").is_none()); + + for invalid in [json!(""), json!("unknown-kind"), json!(42), json!({})] { + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKindOverride": invalid + }]); + assert!( + parse_structured_canvas_layout(layout.to_string().as_str()).is_err(), + "invalid assetKindOverride must fail closed" + ); + } + } + + #[test] + fn structured_canvas_asset_kind_override_does_not_replace_resource_asset_kind() { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let resource = EditorProjectResource { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: None, + image_src: "/generated/resource-1.png".to_string(), + object_key: Some("generated/resource-1.png".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + created_at: now, + updated_at: now, + asset_kind: Some("character".to_string()), + generation_inputs_json: None, + public_showcase_enabled: true, + }; + let mut layout = parse_structured_canvas_layout( + r#"[{"layerId":"layer-1","resourceId":"resource-1","assetKindOverride":"icon"}]"#, + ) + .expect("override layout should parse"); + + let resource_metadata = normalize_structured_canvas_layer_against_resource( + &mut layout.layers[0], + Some(&resource), + ) + .expect("override should normalize independently from resource metadata") + .expect("resource-backed layer should return compatibility metadata"); + + assert_eq!(resource_metadata.0, None); + assert_eq!(resource.asset_kind.as_deref(), Some("character")); + assert_eq!( + layout.layers[0].asset_kind_override.as_deref(), + Some("icon") + ); + assert!(!layout.layers[0].item_json.contains("assetKindOverride")); + } + + #[test] + fn structured_canvas_legacy_asset_kinds_migrate_deterministically() { + let legacy = r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","assetKind":"character"}, + {"layerId":"layer-2","resourceId":"resource-shared","assetKind":"icon"} + ]"#; + + let mut with_resource_default = + parse_structured_canvas_layout(legacy).expect("legacy layout should parse"); + let mut update = StructuredResourceMetadataUpdate { + asset_kind: Some("character".to_string()), + generation_inputs_json: None, + legacy_layer_asset_kinds: vec![ + (0, Some("character".to_string())), + (1, Some("icon".to_string())), + ], + }; + normalize_legacy_layer_asset_kinds(&mut with_resource_default, &mut update) + .expect("resource default should normalize legacy kinds"); + assert_eq!(with_resource_default.layers[0].asset_kind_override, None); + assert_eq!( + with_resource_default.layers[1] + .asset_kind_override + .as_deref(), + Some("icon") + ); + + let mut shared_missing_default = parse_structured_canvas_layout( + r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","assetKind":"character"}, + {"layerId":"layer-2","resourceId":"resource-shared","assetKind":"character"} + ]"#, + ) + .expect("shared legacy kind layout should parse"); + let mut update = StructuredResourceMetadataUpdate { + asset_kind: None, + generation_inputs_json: None, + legacy_layer_asset_kinds: vec![ + (0, Some("character".to_string())), + (1, Some("character".to_string())), + ], + }; + normalize_legacy_layer_asset_kinds(&mut shared_missing_default, &mut update) + .expect("unanimous legacy kind should become the resource default"); + assert_eq!(update.asset_kind.as_deref(), Some("character")); + assert!( + shared_missing_default + .layers + .iter() + .all(|layer| layer.asset_kind_override.is_none()) + ); + + let mut conflicting_missing_default = + parse_structured_canvas_layout(legacy).expect("conflicting legacy layout should parse"); + let mut update = StructuredResourceMetadataUpdate { + asset_kind: None, + generation_inputs_json: None, + legacy_layer_asset_kinds: vec![ + (0, Some("character".to_string())), + (1, Some("icon".to_string())), + ], + }; + normalize_legacy_layer_asset_kinds(&mut conflicting_missing_default, &mut update) + .expect("conflicting kinds should remain per-layer overrides"); + assert_eq!(update.asset_kind, None); + assert_eq!( + conflicting_missing_default.layers[0] + .asset_kind_override + .as_deref(), + Some("character") + ); + assert_eq!( + conflicting_missing_default.layers[1] + .asset_kind_override + .as_deref(), + Some("icon") + ); + } + + #[test] + fn structured_canvas_effective_asset_kind_hash_matches_legacy_and_typed_forms() { + let legacy = r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","assetKind":"character"}, + {"layerId":"layer-2","resourceId":"resource-shared","assetKind":"icon"} + ]"#; + let structured = r#"[ + {"layerId":"layer-1","resourceId":"resource-shared"}, + {"layerId":"layer-2","resourceId":"resource-shared","assetKindOverride":"icon"} + ]"#; + let resource_asset_kinds = + BTreeMap::from([("resource-shared".to_string(), Some("character".to_string()))]); + + assert_eq!( + canonical_layout_sha256_with_resource_asset_kinds(legacy, &resource_asset_kinds) + .expect("legacy effective hash"), + canonical_layout_sha256_with_resource_asset_kinds(structured, &resource_asset_kinds) + .expect("typed effective hash") + ); + } + + #[test] + fn structured_canvas_allows_distinct_layers_to_share_one_resource() { + let shared = r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","x":10,"assetKindOverride":"character"}, + {"layerId":"layer-2","resourceId":"resource-shared","x":42,"assetKindOverride":"icon"} + ]"#; + let layout = parse_structured_canvas_layout(shared) + .expect("distinct layout instances may share a resource"); + + assert_eq!(layout.layers.len(), 2); + assert_eq!(layout.layers[0].resource_id, "resource-shared"); + assert_eq!(layout.layers[1].resource_id, "resource-shared"); + assert_eq!( + layout.layers[0].asset_kind_override.as_deref(), + Some("character") + ); + assert_eq!( + layout.layers[1].asset_kind_override.as_deref(), + Some("icon") + ); + + let rebuilt = serialize_structured_canvas_layout(&layout) + .expect("shared-resource layout should serialize"); + let rebuilt_value: JsonValue = + serde_json::from_str(&rebuilt).expect("rebuilt layout should be valid JSON"); + let rebuilt_layer = rebuilt_value + .as_array() + .expect("rebuilt layout should be an array"); + assert_eq!(rebuilt_layer.len(), 2); + assert_eq!(rebuilt_layer[0]["layerId"], "layer-1"); + assert_eq!(rebuilt_layer[0]["resourceId"], "resource-shared"); + assert_eq!(rebuilt_layer[1]["layerId"], "layer-2"); + assert_eq!(rebuilt_layer[1]["resourceId"], "resource-shared"); + assert_eq!(rebuilt_layer[0]["assetKindOverride"], "character"); + assert_eq!(rebuilt_layer[1]["assetKindOverride"], "icon"); + + let shared_integrity = canvas_layout_integrity(shared).expect("shared integrity"); + let single_integrity = + canvas_layout_integrity(r#"[{"layerId":"layer-1","resourceId":"resource-shared"}]"#) + .expect("single integrity"); + assert_eq!(shared_integrity.layer_count, 2); + assert_eq!( + shared_integrity.resource_refs_sha256, + single_integrity.resource_refs_sha256 + ); + assert_ne!( + canonical_layout_sha256(shared).expect("shared override hash"), + canonical_layout_sha256( + r#"[ + {"layerId":"layer-1","resourceId":"resource-shared","x":10,"assetKindOverride":"icon"}, + {"layerId":"layer-2","resourceId":"resource-shared","x":42,"assetKindOverride":"icon"} + ]"# + ) + .expect("changed override hash") + ); + } + #[test] fn structured_canvas_rollback_rejects_layout_over_two_mib() { let oversized = "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES + 1); diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 1339ce33c..33cda7792 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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"; diff --git a/src/components/common/AutoGrowTextArea.test.tsx b/src/components/common/AutoGrowTextArea.test.tsx new file mode 100644 index 000000000..04caaa941 --- /dev/null +++ b/src/components/common/AutoGrowTextArea.test.tsx @@ -0,0 +1,288 @@ +/* @vitest-environment jsdom */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AutoGrowTextArea } from './AutoGrowTextArea'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('AutoGrowTextArea', () => { + it('uses native field sizing when the browser supports it', () => { + const supports = vi.fn().mockReturnValue(true); + const resizeObserver = vi.fn(); + const onChange = vi.fn(); + vi.stubGlobal('CSS', { supports }); + vi.stubGlobal('ResizeObserver', resizeObserver); + + render( + , + ); + + const input = screen.getByLabelText( + '自动增长输入框', + ) as HTMLTextAreaElement; + expect(supports).toHaveBeenCalledWith('field-sizing', 'content'); + expect(resizeObserver).not.toHaveBeenCalled(); + expect(input.getAttribute('rows')).toBe('1'); + expect(input.className).toContain('auto-grow-text-area'); + expect(input.className).toContain('[field-sizing:content]'); + expect(input.className).toContain('min-h-10'); + expect(input.className).toContain('max-h-32'); + expect(input.className).toContain('overflow-y-auto'); + expect(input.className).toContain('disabled:cursor-not-allowed'); + expect(input.className).toContain('disabled:opacity-60'); + expect(input.style.height).toBe(''); + + fireEvent.change(input, { target: { value: '新内容' } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('falls back to measured sizing, preserves css limits and remeasures width changes', () => { + let resizeObserverCallback!: ResizeObserverCallback; + const observe = vi.fn(); + const disconnect = vi.fn(); + vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); + vi.stubGlobal( + 'ResizeObserver', + vi.fn().mockImplementation((callback: ResizeObserverCallback) => { + resizeObserverCallback = callback; + return { + observe, + unobserve: vi.fn(), + disconnect, + }; + }), + ); + + const { rerender, unmount } = render( + , + ); + const input = screen.getByLabelText( + '自动增长输入框', + ) as HTMLTextAreaElement; + expect(observe).toHaveBeenCalledWith(input); + let contentHeight = 92; + Object.defineProperty(input, 'offsetHeight', { + configurable: true, + get: () => { + const styleHeight = Number.parseFloat(input.style.height); + return Number.isNaN(styleHeight) ? 82 : styleHeight; + }, + }); + Object.defineProperty(input, 'clientHeight', { + configurable: true, + get: () => input.offsetHeight - 2, + }); + Object.defineProperty(input, 'scrollHeight', { + configurable: true, + get: () => contentHeight, + }); + + rerender( + , + ); + expect(input.style.height).toBe('94px'); + expect(input.style.overflowY).toBe('hidden'); + + contentHeight = 180; + act(() => { + resizeObserverCallback( + [ + { + target: input, + contentRect: { width: 180 }, + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); + }); + expect(input.style.height).toBe('128px'); + expect(input.style.overflowY).toBe('auto'); + + contentHeight = 30; + rerender( + , + ); + expect(input.style.height).toBe('80px'); + expect(input.style.overflowY).toBe('hidden'); + + unmount(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('remeasures uncontrolled input changes and forwards the consumer input handler', () => { + const onInput = vi.fn(); + vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); + vi.stubGlobal( + 'ResizeObserver', + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + ); + + render( + , + ); + const input = screen.getByLabelText( + '非受控自动增长输入框', + ) as HTMLTextAreaElement; + let contentHeight = 90; + Object.defineProperty(input, 'offsetHeight', { + configurable: true, + get: () => { + const styleHeight = Number.parseFloat(input.style.height); + return Number.isNaN(styleHeight) ? 42 : styleHeight; + }, + }); + Object.defineProperty(input, 'clientHeight', { + configurable: true, + get: () => input.offsetHeight - 2, + }); + Object.defineProperty(input, 'scrollHeight', { + configurable: true, + get: () => contentHeight, + }); + + fireEvent.input(input, { + target: { value: '第一行\n第二行\n第三行\n第四行' }, + }); + expect(input.style.height).toBe('92px'); + expect(input.style.overflowY).toBe('hidden'); + + contentHeight = 180; + fireEvent.input(input, { + target: { value: '很多行内容'.repeat(30) }, + }); + expect(input.style.height).toBe('128px'); + expect(input.style.overflowY).toBe('auto'); + + contentHeight = 20; + fireEvent.input(input, { target: { value: '短内容' } }); + expect(input.style.height).toBe('40px'); + expect(input.style.overflowY).toBe('hidden'); + expect(onInput).toHaveBeenCalledTimes(3); + }); + + it('measures content-box height without double-counting padding or borders', () => { + vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) }); + vi.stubGlobal( + 'ResizeObserver', + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + ); + + const { rerender } = render( + , + ); + const input = screen.getByLabelText( + '内容盒输入框', + ) as HTMLTextAreaElement; + let contentHeight = 120; + Object.defineProperty(input, 'offsetHeight', { + configurable: true, + get: () => 122, + }); + Object.defineProperty(input, 'clientHeight', { + configurable: true, + get: () => 120, + }); + Object.defineProperty(input, 'scrollHeight', { + configurable: true, + get: () => contentHeight, + }); + + rerender( + , + ); + expect(input.style.height).toBe('104px'); + expect(input.style.overflowY).toBe('hidden'); + + contentHeight = 160; + rerender( + , + ); + expect(input.style.height).toBe('128px'); + expect(input.style.overflowY).toBe('auto'); + }); +}); diff --git a/src/components/common/AutoGrowTextArea.tsx b/src/components/common/AutoGrowTextArea.tsx new file mode 100644 index 000000000..282c7c549 --- /dev/null +++ b/src/components/common/AutoGrowTextArea.tsx @@ -0,0 +1,130 @@ +import { + type InputEventHandler, + type TextareaHTMLAttributes, + useLayoutEffect, + useRef, +} from 'react'; + +const DEFAULT_MIN_HEIGHT_PX = 40; +const DEFAULT_MAX_HEIGHT_PX = 128; + +function supportsNativeFieldSizing() { + return ( + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('field-sizing', 'content') + ); +} + +function parseComputedPixelValue(value: string, fallback: number) { + const parsedValue = Number.parseFloat(value); + return Number.isFinite(parsedValue) ? parsedValue : fallback; +} + +function resizeFallbackTextArea(textarea: HTMLTextAreaElement) { + textarea.style.height = 'auto'; + + const computedStyle = window.getComputedStyle(textarea); + const minHeight = parseComputedPixelValue( + computedStyle.minHeight, + DEFAULT_MIN_HEIGHT_PX, + ); + const maxHeight = Math.max( + minHeight, + parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX), + ); + const contentHeight = textarea.scrollHeight; + const borderHeight = Math.max( + 0, + textarea.offsetHeight - textarea.clientHeight, + ); + const isBorderBox = computedStyle.boxSizing === 'border-box'; + const paddingHeight = + parseComputedPixelValue(computedStyle.paddingTop, 0) + + parseComputedPixelValue(computedStyle.paddingBottom, 0); + const measuredHeight = isBorderBox + ? contentHeight + borderHeight + : Math.max(0, contentHeight - paddingHeight); + const nextHeight = Math.min( + Math.max(measuredHeight, minHeight), + maxHeight, + ); + + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden'; +} + +export function AutoGrowTextArea({ + className, + onInput, + rows = 1, + value, + ...textareaProps +}: TextareaHTMLAttributes) { + const textareaRef = useRef(null); + + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea || supportsNativeFieldSizing()) { + return; + } + resizeFallbackTextArea(textarea); + }, [value]); + + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea || supportsNativeFieldSizing()) { + return undefined; + } + + let previousWidth = textarea.getBoundingClientRect().width; + const resizeWhenWidthChanges = (nextWidth: number) => { + if (Math.abs(nextWidth - previousWidth) < 0.5) { + return; + } + previousWidth = nextWidth; + resizeFallbackTextArea(textarea); + }; + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + resizeWhenWidthChanges(entry.contentRect.width); + } + }); + observer.observe(textarea); + return () => observer.disconnect(); + } + + const handleWindowResize = () => { + resizeWhenWidthChanges(textarea.getBoundingClientRect().width); + }; + window.addEventListener('resize', handleWindowResize); + return () => window.removeEventListener('resize', handleWindowResize); + }, []); + + const resolvedClassName = [ + 'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60', + className, + ] + .filter(Boolean) + .join(' '); + const handleInput: InputEventHandler = (event) => { + if (!supportsNativeFieldSizing()) { + resizeFallbackTextArea(event.currentTarget); + } + onInput?.(event); + }; + + return ( +