From 74d4972ecc435c829626e280cccabc8476b99005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 4 Aug 2026 19:05:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=85=83=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB=E4=B8=8E?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移按计划态分类排除同任务预览视频并区分零候选与多候选。 历史画布迁移忽略复制的来源链,正式生成继续保持严格血缘校验。 逐帧核验登记对象并补齐对象路径与对象 ID,支持前置 scope 的安全 dry-run。 补充迁移 HTTP 调用、撤权能力、回归测试和长期数据契约。 --- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 2 +- ...ime-normalize-editor-character-actions.mjs | 14 +- ...normalize-editor-character-actions.test.ts | 20 + .../spacetime-revoke-migration-operator.mjs | 20 +- .../src/editor_project_storage.rs | 547 ++++++++++++++++-- 9 files changed, 539 insertions(+), 72 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c09e9be6..543551919 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5814,7 +5814,7 @@ - 生成关联边界:角色动作生成响应同时返回已经持久化的最终 `resource` / `asset`;带项目上下文时前端结果图层必须直接使用 `resource.resourceId` 及其预览视频来源血缘,缺少 resource 直接失败。无项目放置也必须绑定响应中的正式账号素材,不能从响应 `frames` 构造无资产真相的本地动作结果。 - repair 幂等边界:legacy 音频 repair 不再派生或回填任何资源级通用时长,因此新列上线前已完成 repair 的重放继续按原字段精确匹配;图片序列字段在音频行上均为 `None`。 - 布局边界:`editor_project_resource` 的正式字段是角色动作唯一媒体真相;动作 layout 只保存资源引用和 placement,不保存帧、时长、预览、生成输入、资源元数据或顶层 `mediaType`。前端仍可从 `assetKind` 派生内部 `CanvasMediaType`,但后端响应清洗不得把普通 layer 的旧 `mediaType` 传回前端,也不得递归删除 `generationInputs.references[*].mediaType`。 -- 迁移边界(2026-08-03 收口):存量 `generation_inputs_json.characterAnimation`、已证明动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧 `frameIndex`、误标预览 MP4 和动作 layout 副本,由 `normalize_editor_character_animation_metadata_and_return` 按 `asset → project-resource → showcase → canvas` 一次性规范化。顶层字段只有先证明动作身份后才解释;普通图片 / 视频任意 JSON 中的同名字段不动。正式/旧版冲突与无法唯一补建的本地动作资源形成 blocker;apply 必须绑定同批 dry-run SHA-256,结束后全量复核零匹配、零 blocker。迁移完成后删除 api-server、admin、Web 与 helper 的 action fallback。 +- 迁移边界(2026-08-04 收口):存量 `generation_inputs_json.characterAnimation`、已证明动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧 `frameIndex`、误标预览 MP4 和动作 layout 副本,由 `normalize_editor_character_animation_metadata_and_return` 按 `asset → project-resource → showcase → canvas` 一次性规范化。顶层字段只有先证明动作身份后才解释;普通图片 / 视频任意 JSON 中的同名字段不动。canvas dry-run 可消费前置 scope 的计划态结果,但 apply 仍要求前置 scope 已物理完成;同 task 候选先按权威对象规划分类并排除预览视频,只有唯一最终图片序列可补建资源。正式序列每帧必须按首帧 bucket 的稳定路径精确匹配同 owner / task 的图片 `editor_character_animation` 对象并补齐 `objectKey/assetObjectId`。迁移永不验证 layout 复制的 `sourceResourceId`,补建资源采用最终素材的 DB 血缘;新生成直接来源链仍严格校验。正式/旧版冲突、最终候选为零或多个、对象不匹配形成 blocker;apply 必须绑定同批 dry-run SHA-256,结束后全量复核零匹配、零 blocker。迁移完成后删除 api-server、admin、Web 与 helper 的 action fallback。 - 新写入边界:`assetKind=character-animation` 的 `generationInputs` 若含旧运行字段或 `screenColorHex`,正式帧若含 `frameIndex`,api-server 和 SpacetimeDB storage 均失败关闭;门禁只对角色动作生效,不误伤其它素材的任意 generation input JSON。 - 交互边界:角色动作素材下载导出完整序列 ZIP;点击、HTML5 拖放和指针拖放创建可移动、循环播放且可保存恢复的序列图层。`assetKind=character-animation` 但缺少有效帧时按损坏素材失败关闭,不回退首帧 PNG。 - 精选展示边界:`/creation` 精选卡片和预览弹窗继续扩展各自现有媒体 renderer,不复用或重构画布图层组件;卡片静止时只读取首帧,hover / focus 后才加载并播放完整序列,预览弹窗提供播放暂停。后台素材查询与精选审核继续共用 `AdminEditorAssetMedia`,列表只读首帧,预览弹窗才逐帧使用管理员换签。两端均按 `imageSequenceDurationMs / imageSequenceFrames.length` 切帧,下一帧未就绪时保留上一帧且禁止淡入;损坏动作不退回普通图片。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b8e0afdbc..6b2fb4919 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3916,7 +3916,7 @@ - 原因:`editor_asset.image_src` 只指向首帧;若 worker 把完整帧集塞进 `generation_inputs_json`,素材 DTO、用户输入清洗或画布布局任一层丢字段,就会退化成 PNG。再增加一个 `mediaType` 只能掩盖结果字段没有落到正式资源的问题。 - 处理:worker 只把完整帧集与图片序列毫秒时长写入 `editor_project_resource` / `editor_asset` 的 `image_sequence_frames_json`、`image_sequence_duration_ms`;数组位置是唯一帧序,不保存 `frameIndex`,帧数和 FPS 均按需派生。`assetKind=character-animation` 决定序列渲染。素材映射、单项下载和拖回画布只读取这两个正式字段,项目 resource 在保存 / 刷新后继续作为主真相;动作 layout 只保留资源引用和 placement,不再复制正式媒体结果。账号素材提交精选审核时,`editor_showcase_asset` 必须冻结复制相同字段,公开 read model 只返回正式字段。外部 helper 只调用一次动作生成接口并直接使用响应 `resource` / `asset`。 - 画布回填:角色动作会形成“原角色资源 → 预览视频资源 → 最终序列资源”的血缘链。生成响应必须返回已经持久化的最终 resource,前端图层直接使用其 `resourceId`;不能继续构造 `local-resource-character-animation-*`,否则 `appendCanvasLayersWithResources` 会再次创建重复资源。新图层的 `sourceResourceId` 同时使用最终 resource 的直接来源(预览视频 resource),不能继续沿用请求中的原角色 resource;否则结构化保存会在已生成并计费后因血缘不一致而拒绝。修复时只替换资源关联与血缘字段,不要顺带把动作图层显示尺寸从生成占位尺寸改成原始帧分辨率。 -- 历史处理:不要再在 read mapper 增加 `generationInputs` / layout fallback。使用 migration operator procedure 按 `asset → project-resource → showcase → canvas` 迁移;只有动作身份已由 `assetKind`、正式字段、嵌套 `characterAnimation` 或权威对象证明后,才解释顶层 `frames/durationSeconds`,否则会把无关任意 JSON 误分类。正式与旧版结果冲突必须形成 blocker,不能任选一份;旧 `local-*` 动作 layer 只有唯一匹配账号动作素材时才能补建项目资源。普通 layer 顶层 `mediaType` 在迁移和响应清洗时删除,但嵌套生成参考的 `mediaType` 保留。 +- 历史处理:不要再在 read mapper 增加 `generationInputs` / layout fallback。使用 migration operator procedure 按 `asset → project-resource → showcase → canvas` 迁移;只有动作身份已由 `assetKind`、正式字段、嵌套 `characterAnimation` 或权威对象证明后,才解释顶层 `frames/durationSeconds`,否则会把无关任意 JSON 误分类。同一 task 可能同时存在误标为动作的预览 MP4 和最终首帧 PNG,候选查找必须先按权威对象类型做计划态分类,排除视频并要求唯一正式图片序列,不能按原始 `assetKind` 计数。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker。普通 layer 顶层 `mediaType` 在迁移和响应清洗时删除,但嵌套生成参考的 `mediaType` 保留。 - 新写入与验证:动作 `generationInputs` 出现 `characterAnimation/frames/previewVideoPath/frameCount/fps/durationSeconds/screenColorHex`,或正式帧出现 `frameIndex`,HTTP 与 storage 双层拒绝;其它 asset kind 的任意 JSON 不受该动作门禁影响。测试覆盖两种历史 JSON、无关顶层同名字段、正式/旧版相等与冲突、可选帧引用合并、screen color 和 frameIndex 清理、预览 MP4 重分类、幂等、blocker/hash apply、画布 placement 清理/资源补建、正式字段缺失失败关闭和 helper 单请求。 ## 图片序列时长不要复用通用媒体秒数 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 48f63802e..56cf5c3d5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -433,7 +433,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 角色动作 worker 在透明帧全部持久化后创建最终项目资源与账号素材,并写入帧数组与 `image_sequence_duration_ms`;生成响应可继续返回请求 / 结果层面的 `frameCount`、`fps`、`durationSeconds`,但它们不是持久化真相。预览视频仍作为独立 `asset_kind = video` 中间素材保留并承担生成成本,最终派生素材成本为 0。 - `create_editor_project_resource`、`create_editor_asset` 和同源资源回填必须在 procedure/storage 边界验证最终状态:`asset_kind = character-animation` 必须同时包含至少两帧的有效数组和大于 0 的 `image_sequence_duration_ms`;其他类别不得携带任一图片序列字段。同源资源只允许从 `None` 单调补齐字段,非空冲突失败关闭,补写后的 `updated_at` 不得早于既有时间。 - `generation_inputs_json` 只保存用户生成 / 重放输入,例如 `fields`、`references`、`artSpec`;动作输出和内部背景决策色不得再写入该 JSON。背景决策审计继续走独立审计链路。 -- 存量 `generation_inputs_json.characterAnimation`、已确认动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧中的 `frameIndex` 和误标动作预览 MP4,统一由 migration operator procedure `normalize_editor_character_animation_metadata_and_return` 分 `asset → project-resource → showcase → canvas` 四个 scope 迁移。顶层旧字段只有在 `asset_kind=character-animation`、正式序列字段、嵌套 `characterAnimation` 或权威 `asset_object` 已证明该行属于角色动作时才解释,普通图片 / 视频任意 JSON 中同名字段保持原样。正式与旧版帧或时长冲突、无法唯一补建的 `local-*` 动作资源和缺失正式帧集均作为 blocker,整批拒绝 apply。 +- 存量 `generation_inputs_json.characterAnimation`、已确认动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧中的 `frameIndex` 和误标动作预览 MP4,统一由 migration operator procedure `normalize_editor_character_animation_metadata_and_return` 分 `asset → project-resource → showcase → canvas` 四个 scope 迁移。顶层旧字段只有在 `asset_kind=character-animation`、正式序列字段、嵌套 `characterAnimation` 或权威 `asset_object` 已证明该行属于角色动作时才解释,普通图片 / 视频任意 JSON 中同名字段保持原样。迁移在计划态先把同 task 的已验证预览 MP4 重分类为 `video`,只有能形成正式图片序列的候选才可用于补建 `local-*` 动作资源;每一帧还必须按首帧 bucket 下的精确对象路径匹配同 owner、同 task、`editor_character_animation` 且为图片的 `asset_object`,并从登记对象补齐 `objectKey/assetObjectId`。canvas layout 复制的 `sourceResourceId` 不参与迁移血缘校验,补建资源只采用最终账号素材的 DB 血缘;新生成链路仍严格校验直接来源。正式与旧版帧或时长冲突、最终候选为零或多于一个、帧对象无法精确验证和缺失正式帧集均作为 blocker,整批拒绝 apply。 - 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `;默认全量 dry-run,`--apply` 时每批先 dry-run,再用返回的 batch SHA-256 写入,四个 scope 完成后从头执行零匹配 / 零 blocker 复核。迁移完成后 api-server、后台、前端和外部 helper 只读取正式字段,不再包含 legacy fallback。新建动作资源 / 素材若在 `generationInputs` 提交旧运行字段,或正式帧包含 `frameIndex`,api-server 与 SpacetimeDB storage 均失败关闭;其它素材的任意生成输入不受动作专属门禁影响。 - 普通画布 layer 的持久化和前端响应不包含顶层 `mediaType`;渲染类型只由资源 / 素材 `assetKind` 在前端派生。`generationInputs.references[*].mediaType` 是生成参考输入契约,不属于画布 layer legacy 字段,迁移和响应清洗不得递归删除。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 24923d63f..09296b334 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -92,7 +92,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 外部生成任务摘要投影与历史 payload 维护使用 `npm run spacetime:external-generation:maintain -- ...`,且只能由已授权 migration operator 的 SpacetimeDB CLI 登录态执行。脚本默认 dry-run、每次只处理一批,绝不自动循环全表;`--apply` 才写入。先发布包含 `external_generation_job_summary` 与 cursor 索引的 SpacetimeDB 模块,在维护模式内对事故时间以前的编辑器终态任务执行小批 dry-run,例如 `npm run spacetime:external-generation:maintain -- --database --server-url --limit 5 --completed-before-micros `;核对 `matched_count`、`before_bytes`、`after_bytes` 和 `inline_media_count` 后,保持本批输入 cursor 不变并追加 `--apply` 重跑同一批,即使最后一批 `has_more = false`,只要 dry-run 仍有 `matched_count` / `selected_count` 也必须 apply;只有 apply 成功后才使用它返回的 `next_cursor_job_id` 继续。B-tree cursor 的选择阶段最多反序列化 `limit + 1` 行,apply 会再按主键逐条读取选中行但不会同时保留整批 payload;如怀疑存在单行异常巨型历史 JSON,先用 `--limit 1`。payload 压缩硬限制 `source_module = editor-canvas`;终态压缩完成后,用 `--backfill-summaries` 先 dry-run、再 `--apply` 分批补齐仍缺失的活动任务或无内联媒体历史任务摘要,直到 `has_more = false`,最后再切换使用 summary procedure 的 api-server。Stdb 构建 artifact 和完整 release 包都必须包含 `scripts/spacetime-maintain-external-generation-jobs.mjs` 与 `scripts/spacetime-migration-common.mjs`。首次上线不得让 Full Build 从 Stdb 自动直落 API:`STDB_API_ROLLOUT_MODE` 默认 fail-closed 为 `pause-after-stdb`,必须填写受限的 `STDB_API_ROLLOUT_APPROVERS`;Stdb Publish 通过 `KEEP_MAINTENANCE_MODE` 保持维护文件并停止旧 API/controller/worker,暂停点最多等待 4 小时,完成上述维护并确认无后续批次后才由指定审批人放行 API。定时构建缺少审批人时必须在发布前失败,不能静默退回 `normal`;也可分开运行 Stdb publish、维护、API deploy 三个受控 Job。任一批次都不得处理 pending / running payload;不要用 runtime writer、bootstrap secret 或匿名 identity 代替 migration operator,也不要在未核对 dry-run 时直接 apply。 -角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。出现 blocker 时先按输出 ID 人工核对正式/旧版冲突或缺失资源,不得跳过 scope;确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。 +角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把前置 scope 的计划结果投影给 canvas 检查,因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份;apply 时仍要求前置 scope 已按顺序物理完成。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时先按输出 ID 区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope;确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。 自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index d13a2a2bb..d7ba60b41 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -187,5 +187,5 @@ - 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。 - 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。 - 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。 -- 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。 +- 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;迁移按权威对象类型排除同 task 的预览视频、只接受唯一最终图片序列并逐帧补齐登记对象身份,且不把 layout 复制的 `sourceResourceId` 当作历史血缘证据。该宽容只属于迁移;新生成仍严格使用“原角色资源 → 预览视频资源 → 最终序列资源”直接来源链。上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。 - `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。 diff --git a/scripts/spacetime-normalize-editor-character-actions.mjs b/scripts/spacetime-normalize-editor-character-actions.mjs index c798aa6bb..9548780c9 100644 --- a/scripts/spacetime-normalize-editor-character-actions.mjs +++ b/scripts/spacetime-normalize-editor-character-actions.mjs @@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url'; import { + callSpacetimeProcedure, callSpacetimeProcedureViaCli, encodeSpacetimeCliOption, ensureProcedureOk, @@ -34,6 +35,8 @@ export function parseOptions(argv, env = process.env) { passthrough: [], server: env.GENARRATIVE_SPACETIME_SERVER || '', serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '', + token: env.GENARRATIVE_SPACETIME_TOKEN || '', + useHttp: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -51,10 +54,14 @@ export function parseOptions(argv, env = process.env) { options.server = readValue(); } else if (arg === '--server-url') { options.serverUrl = readValue(); + } else if (arg === '--token') { + options.token = readValue(); } else if (arg === '--chunk-size') { options.chunkSize = parsePositiveInteger(readValue(), arg); } else if (arg === '--apply') { options.apply = true; + } else if (arg === '--use-http') { + options.useHttp = true; } else if (arg === '--no-config' || arg === '--anonymous') { options.passthrough.push(arg); } else if (arg === '--help' || arg === '-h') { @@ -121,7 +128,9 @@ function assertSafeBatch(result, scope) { } async function callBatch(options, input) { - return callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); + return options.useHttp + ? callSpacetimeProcedure(options, PROCEDURE_NAME, input) + : callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); } export async function scanScopes( @@ -198,6 +207,9 @@ export async function main(argv = process.argv.slice(2)) { if (!options.server && !options.serverUrl) { throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。'); } + if (options.useHttp && !options.token) { + throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。'); + } const migration = await scanScopes(options, { apply: options.apply }); const verification = options.apply diff --git a/scripts/spacetime-normalize-editor-character-actions.test.ts b/scripts/spacetime-normalize-editor-character-actions.test.ts index c4fa5fd02..c464d8deb 100644 --- a/scripts/spacetime-normalize-editor-character-actions.test.ts +++ b/scripts/spacetime-normalize-editor-character-actions.test.ts @@ -21,6 +21,26 @@ describe('角色动作元数据规范化脚本', () => { }); }); + it('支持使用环境 token 的 HTTP 调用模式', () => { + expect( + parseOptions( + [ + '--database', + 'genarrative-dev', + '--server-url', + 'http://127.0.0.1:3101', + '--use-http', + ], + { GENARRATIVE_SPACETIME_TOKEN: 'dev-migration-token' }, + ), + ).toMatchObject({ + database: 'genarrative-dev', + serverUrl: 'http://127.0.0.1:3101', + token: 'dev-migration-token', + useHttp: true, + }); + }); + it('编码 dry-run cursor 并禁止 canvas 超过五行', () => { expect( buildNormalizationInput({ diff --git a/scripts/spacetime-revoke-migration-operator.mjs b/scripts/spacetime-revoke-migration-operator.mjs index 71f72058d..9e47b08ad 100644 --- a/scripts/spacetime-revoke-migration-operator.mjs +++ b/scripts/spacetime-revoke-migration-operator.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { + callSpacetimeProcedure, callSpacetimeProcedureViaCli, ensureProcedureOk, parseArgs, @@ -15,11 +16,20 @@ try { const input = { operator_identity_hex: options.operatorIdentity, }; - const result = await callSpacetimeProcedureViaCli( - options, - 'revoke_database_migration_operator', - input, - ); + if (options.useHttp && !options.token) { + throw new Error('--use-http 需要同时传入 --token。'); + } + const result = options.useHttp + ? await callSpacetimeProcedure( + options, + 'revoke_database_migration_operator', + input, + ) + : await callSpacetimeProcedureViaCli( + options, + 'revoke_database_migration_operator', + input, + ); ensureProcedureOk(result); console.log( 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 9a84ff48b..d879dd0ea 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -8852,6 +8852,182 @@ fn normalize_editor_character_animation_row( plan } +fn migration_frame_object_key(frame: &JsonMap) -> Result { + let image_src = json_required_string(frame.get("imageSrc"), "frame.imageSrc")?; + let image_object_key = image_src.trim_start_matches('/'); + if image_object_key.is_empty() + || image_object_key.starts_with("data:") + || image_object_key.starts_with("blob:") + || image_object_key.contains("://") + || image_object_key.contains(['?', '#']) + { + return Err("角色动作帧 imageSrc 不是稳定对象路径".to_string()); + } + if let Some(stored_object_key) = json_optional_string(frame.get("objectKey")) + && stored_object_key.trim_start_matches('/') != image_object_key + { + return Err("角色动作帧 imageSrc 与 objectKey 冲突".to_string()); + } + Ok(image_object_key.to_string()) +} + +fn validate_migration_character_animation_frame_object( + object: &module_assets::AssetObjectUpsertSnapshot, + object_key: &str, + owner_user_id: &str, + task_id: &str, +) -> Result<(), String> { + if object.object_key.trim_start_matches('/') != object_key + || object.owner_user_id.as_deref() != Some(owner_user_id) + || object.source_job_id.as_deref() != Some(task_id) + || object.asset_kind != EDITOR_CHARACTER_ANIMATION_OBJECT_KIND + || !object + .content_type + .as_deref() + .is_some_and(|content_type| content_type.starts_with("image/")) + { + return Err(format!( + "角色动作帧对象 {object_key} 的位置、owner、task、类型或 Content-Type 不匹配" + )); + } + Ok(()) +} + +fn enrich_migration_character_animation_frames_with_objects( + frames_json: &str, + owner_user_id: &str, + task_id: &str, + mut find_object: impl FnMut( + &str, + ) -> Result, String>, +) -> Result { + let mut frames = serde_json::from_str::>(frames_json) + .map_err(|_| "角色动作正式帧集不是合法数组".to_string())?; + for frame in &mut frames { + let frame = frame + .as_object_mut() + .ok_or_else(|| "角色动作正式帧项不是对象".to_string())?; + let object_key = migration_frame_object_key(frame)?; + let object = find_object(object_key.as_str())? + .ok_or_else(|| format!("角色动作帧对象 {object_key} 尚未登记"))?; + validate_migration_character_animation_frame_object( + &object, + object_key.as_str(), + owner_user_id, + task_id, + )?; + if let Some(stored_asset_object_id) = json_optional_string(frame.get("assetObjectId")) + && stored_asset_object_id != object.asset_object_id + { + return Err(format!("角色动作帧对象 {object_key} 的 assetObjectId 冲突")); + } + frame.insert("objectKey".to_string(), JsonValue::String(object_key)); + frame.insert( + "assetObjectId".to_string(), + JsonValue::String(object.asset_object_id), + ); + } + serde_json::to_string(&frames).map_err(|_| "角色动作正式帧集无法序列化".to_string()) +} + +fn plan_editor_character_animation_row( + ctx: &ReducerContext, + owner_user_id: &str, + task_id: Option<&str>, + asset_object_id: Option<&str>, + object_key: Option<&str>, + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, + image_sequence_frames_json: Option<&str>, + image_sequence_duration_ms: Option, +) -> EditorCharacterAnimationRowPlan { + let evidence = + editor_character_animation_object_evidence(ctx, asset_object_id, owner_user_id, object_key); + let mut plan = normalize_editor_character_animation_row( + asset_kind, + generation_inputs_json, + image_sequence_frames_json, + image_sequence_duration_ms, + evidence, + ); + if plan.blocker.is_some() + || plan.asset_kind.as_deref() != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + { + return plan; + } + let Some(task_id) = task_id.map(str::trim).filter(|value| !value.is_empty()) else { + plan.blocker = Some("角色动作缺少可验证帧对象的 task_id".to_string()); + return plan; + }; + let Some(asset_object_id) = asset_object_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + plan.blocker = Some("角色动作缺少首帧 asset_object_id".to_string()); + return plan; + }; + let Some(anchor) = + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + else { + plan.blocker = Some("角色动作首帧 asset_object 尚未登记".to_string()); + return plan; + }; + if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) + && object_key.trim_start_matches('/') != anchor.object_key.trim_start_matches('/') + { + plan.blocker = Some("角色动作首帧 objectKey 与 asset_object 冲突".to_string()); + return plan; + } + let Some(frames_json) = plan.image_sequence_frames_json.as_deref() else { + plan.blocker = Some("角色动作缺少可验证的正式帧集".to_string()); + return plan; + }; + let bucket = anchor.bucket.clone(); + match enrich_migration_character_animation_frames_with_objects( + frames_json, + owner_user_id, + task_id, + |frame_object_key| { + crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: bucket.clone(), + object_key: frame_object_key.to_string(), + }, + ) + }, + ) { + Ok(enriched_frames_json) => { + let first_asset_object_id = + serde_json::from_str::>(enriched_frames_json.as_str()) + .ok() + .and_then(|frames| frames.into_iter().next()) + .and_then(|frame| frame.get("assetObjectId").cloned()) + .and_then(|value| value.as_str().map(str::to_string)); + if first_asset_object_id.as_deref() != Some(asset_object_id) { + plan.blocker = Some("角色动作正式首帧与素材主 asset_object_id 不一致".to_string()); + return plan; + } + if plan.image_sequence_frames_json.as_deref() != Some(enriched_frames_json.as_str()) { + plan.image_sequence_frames_json = Some(enriched_frames_json); + plan.changed = true; + } + } + Err(message) => plan.blocker = Some(message), + } + plan +} + +fn normalization_plan_is_final_character_animation(plan: &EditorCharacterAnimationRowPlan) -> bool { + plan.blocker.is_none() + && !plan.reclassified_preview + && plan.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + && plan.image_sequence_frames_json.is_some() + && plan + .image_sequence_duration_ms + .is_some_and(|duration| duration > 0) +} + fn update_normalization_stats_for_plan( stats: &mut EditorCharacterAnimationNormalizationStats, id: &str, @@ -9042,18 +9218,16 @@ fn normalize_editor_character_animation_assets( let mut hasher = Sha256::new(); let mut plans = Vec::with_capacity(rows.len()); for row in &rows { - let evidence = editor_character_animation_object_evidence( + let plan = plan_editor_character_animation_row( ctx, - row.asset_object_id.as_deref(), row.owner_user_id.as_str(), + row.task_id.as_deref(), + row.asset_object_id.as_deref(), row.object_key.as_deref(), - ); - let plan = normalize_editor_character_animation_row( row.asset_kind.as_deref(), row.generation_inputs_json.as_deref(), row.image_sequence_frames_json.as_deref(), row.image_sequence_duration_ms, - evidence, ); update_normalization_stats_for_plan(&mut stats, row.asset_id.as_str(), &plan); update_normalization_batch_hash( @@ -9121,18 +9295,16 @@ fn normalize_editor_character_animation_project_resources( let mut hasher = Sha256::new(); let mut plans = Vec::with_capacity(rows.len()); for row in &rows { - let evidence = editor_character_animation_object_evidence( + let plan = plan_editor_character_animation_row( ctx, - row.asset_object_id.as_deref(), row.owner_user_id.as_str(), + row.task_id.as_deref(), + row.asset_object_id.as_deref(), row.object_key.as_deref(), - ); - let plan = normalize_editor_character_animation_row( row.asset_kind.as_deref(), row.generation_inputs_json.as_deref(), row.image_sequence_frames_json.as_deref(), row.image_sequence_duration_ms, - evidence, ); update_normalization_stats_for_plan(&mut stats, row.resource_id.as_str(), &plan); update_normalization_batch_hash( @@ -9200,18 +9372,16 @@ fn normalize_editor_character_animation_showcases( let mut hasher = Sha256::new(); let mut plans = Vec::with_capacity(rows.len()); for row in &rows { - let evidence = editor_character_animation_object_evidence( + let plan = plan_editor_character_animation_row( ctx, - row.asset_object_id.as_deref(), row.owner_user_id.as_str(), + row.task_id.as_deref(), + row.asset_object_id.as_deref(), row.object_key.as_deref(), - ); - let plan = normalize_editor_character_animation_row( row.asset_kind.as_deref(), row.generation_inputs_json.as_deref(), row.image_sequence_frames_json.as_deref(), row.image_sequence_duration_ms, - evidence, ); update_normalization_stats_for_plan(&mut stats, row.showcase_id.as_str(), &plan); update_normalization_batch_hash( @@ -9289,12 +9459,51 @@ fn remove_editor_character_animation_layout_copies( changed } +fn validate_legacy_action_layout_object_identity( + resource_id: &str, + item: &JsonMap, + object_key: Option<&str>, + asset_object_id: Option<&str>, +) -> Result<(), String> { + for (field, expected) in [ + ("objectKey", object_key), + ("assetObjectId", asset_object_id), + ] { + if let Some(value) = json_optional_string(item.get(field)) + && expected != Some(value.as_str()) + { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材冲突" + )); + } + } + Ok(()) +} + +fn validate_final_action_asset_candidate_count( + resource_id: &str, + raw_candidate_count: usize, + candidate_ids: &[String], +) -> Result<(), String> { + match candidate_ids { + [] => Err(format!( + "本地角色动作资源 {resource_id} 没有匹配的最终图片序列账号素材(同 task 原始候选 {raw_candidate_count} 个)" + )), + [_] => Ok(()), + _ => Err(format!( + "本地角色动作资源 {resource_id} 匹配到多个最终图片序列账号素材:{}", + candidate_ids.join(", ") + )), + } +} + fn canonical_action_asset_for_legacy_canvas_layer( ctx: &ReducerContext, project_id: &str, owner_user_id: &str, resource_id: &str, item: &JsonMap, + allow_planned_dependencies: bool, ) -> Result { if !resource_id.starts_with("local-") { return Err(format!( @@ -9323,35 +9532,41 @@ fn canonical_action_asset_for_legacy_canvas_layer( .filter(|asset| asset.task_id.as_deref() == Some(task_id)) .collect::>() }; - candidates.retain(|asset| { - asset.owner_user_id == owner_user_id - && asset.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) - }); - if candidates.len() != 1 { - return Err(format!( - "本地角色动作资源 {resource_id} 没有唯一匹配的账号动作素材" - )); - } - let asset = candidates.pop().expect("candidate length checked"); - let evidence = editor_character_animation_object_evidence( - ctx, - asset.asset_object_id.as_deref(), - owner_user_id, - asset.object_key.as_deref(), - ); - let normalized = normalize_editor_character_animation_row( - asset.asset_kind.as_deref(), - asset.generation_inputs_json.as_deref(), - asset.image_sequence_frames_json.as_deref(), - asset.image_sequence_duration_ms, - evidence, - ); - if normalized.blocker.is_some() || normalized.changed { + candidates.retain(|asset| asset.owner_user_id == owner_user_id); + let raw_candidate_count = candidates.len(); + let mut candidates = candidates + .into_iter() + .filter_map(|asset| { + let plan = plan_editor_character_animation_row( + ctx, + owner_user_id, + asset.task_id.as_deref(), + asset.asset_object_id.as_deref(), + asset.object_key.as_deref(), + asset.asset_kind.as_deref(), + asset.generation_inputs_json.as_deref(), + asset.image_sequence_frames_json.as_deref(), + asset.image_sequence_duration_ms, + ); + normalization_plan_is_final_character_animation(&plan).then_some((asset, plan)) + }) + .collect::>(); + let candidate_ids = candidates + .iter() + .map(|(asset, _)| asset.asset_id.clone()) + .collect::>(); + validate_final_action_asset_candidate_count(resource_id, raw_candidate_count, &candidate_ids)?; + let (mut asset, normalized) = candidates.pop().expect("candidate length checked"); + if normalized.changed && !allow_planned_dependencies { return Err(format!( "账号动作素材 {} 尚未完成 asset scope 规范化", asset.asset_id )); } + asset.asset_kind = normalized.asset_kind; + asset.generation_inputs_json = normalized.generation_inputs_json; + asset.image_sequence_frames_json = normalized.image_sequence_frames_json; + asset.image_sequence_duration_ms = normalized.image_sequence_duration_ms; let frames_json = asset .image_sequence_frames_json .as_deref() @@ -9433,19 +9648,12 @@ fn canonical_action_asset_for_legacy_canvas_layer( "本地角色动作资源 {resource_id} 的 taskId 与账号素材不一致" )); } - for (field, expected) in [ - ("objectKey", asset.object_key.as_deref()), - ("assetObjectId", asset.asset_object_id.as_deref()), - ("sourceResourceId", asset.source_resource_id.as_deref()), - ] { - if let Some(value) = json_optional_string(item.get(field)) - && expected != Some(value.as_str()) - { - return Err(format!( - "本地角色动作资源 {resource_id} 的 {field} 与账号素材冲突" - )); - } - } + validate_legacy_action_layout_object_identity( + resource_id, + item, + asset.object_key.as_deref(), + asset.asset_object_id.as_deref(), + )?; for (field, expected) in [ ("originalWidth", asset.width), ("originalHeight", asset.height), @@ -9500,6 +9708,7 @@ fn patch_editor_character_animation_canvas_layout( layers_json: &str, project_id: &str, owner_user_id: &str, + allow_planned_dependencies: bool, ) -> Result<(String, Vec, Vec, Vec), String> { let mut value = parse_legacy_layout_value(layers_json)?; let items = value @@ -9533,28 +9742,34 @@ fn patch_editor_character_animation_canvas_layout( if !layout_marks_action && !resource_marks_action { continue; } - let resource = if let Some(resource) = existing_resource { + let resource = if let Some(mut resource) = existing_resource { if resource.project_id != project_id || resource.owner_user_id != owner_user_id { return Err(format!("角色动作资源 {resource_id} 不属于当前工程或 owner")); } - let evidence = editor_character_animation_object_evidence( + let normalized = plan_editor_character_animation_row( ctx, - resource.asset_object_id.as_deref(), owner_user_id, + resource.task_id.as_deref(), + resource.asset_object_id.as_deref(), resource.object_key.as_deref(), - ); - let normalized = normalize_editor_character_animation_row( resource.asset_kind.as_deref(), resource.generation_inputs_json.as_deref(), resource.image_sequence_frames_json.as_deref(), resource.image_sequence_duration_ms, - evidence, ); - if normalized.blocker.is_some() || normalized.changed { + if normalized.blocker.is_some() || (normalized.changed && !allow_planned_dependencies) { return Err(format!( - "角色动作资源 {resource_id} 尚未完成 project-resource scope 规范化" + "角色动作资源 {resource_id} 无法完成 project-resource scope 规范化:{}", + normalized + .blocker + .as_deref() + .unwrap_or("尚未物理应用前置 scope") )); } + resource.asset_kind = normalized.asset_kind; + resource.generation_inputs_json = normalized.generation_inputs_json; + resource.image_sequence_frames_json = normalized.image_sequence_frames_json; + resource.image_sequence_duration_ms = normalized.image_sequence_duration_ms; resource } else if let Some(resource) = resources.get(&resource_id) { resource.clone() @@ -9565,6 +9780,7 @@ fn patch_editor_character_animation_canvas_layout( owner_user_id, resource_id.as_str(), object, + allow_planned_dependencies, )?; resources.insert(resource_id.clone(), resource.clone()); resource @@ -9664,6 +9880,7 @@ fn canvas_layout_may_contain_character_animation(ctx: &ReducerContext, layers_js fn plan_editor_character_animation_canvas( ctx: &ReducerContext, canvas: &EditorCanvas, + allow_planned_dependencies: bool, ) -> EditorCharacterAnimationCanvasPlan { if !canvas_layout_may_contain_character_animation(ctx, canvas.layers_json.as_str()) { return EditorCharacterAnimationCanvasPlan { @@ -9717,6 +9934,7 @@ fn plan_editor_character_animation_canvas( canvas.layers_json.as_str(), canvas.project_id.as_str(), canvas.owner_user_id.as_str(), + allow_planned_dependencies, ) { Ok((layers_json, resources, resource_fingerprints, cleaned_layer_ids)) => { EditorCharacterAnimationCanvasPlan { @@ -9842,7 +10060,7 @@ fn normalize_editor_character_animation_canvases( let mut hasher = Sha256::new(); let mut plans = Vec::with_capacity(rows.len()); for canvas in &rows { - let plan = plan_editor_character_animation_canvas(ctx, canvas); + let plan = plan_editor_character_animation_canvas(ctx, canvas, input.dry_run); let changed = plan.canvas_layers_json != canvas.layers_json || !plan.resources.is_empty(); stats.matched_count = stats.matched_count.saturating_add(u32::from(changed)); stats.cleaned_canvas_layer_count = stats @@ -9916,6 +10134,29 @@ mod tests { JsonValue::Array(frames) } + fn migration_frame_object( + object_key: impl Into, + asset_object_id: impl Into, + ) -> module_assets::AssetObjectUpsertSnapshot { + module_assets::AssetObjectUpsertSnapshot { + asset_object_id: asset_object_id.into(), + bucket: "editor-private".to_string(), + object_key: object_key.into(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 128, + content_hash: None, + version: 1, + source_job_id: Some("task-action-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: Some("layer-action-1".to_string()), + asset_kind: EDITOR_CHARACTER_ANIMATION_OBJECT_KIND.to_string(), + created_at_micros: 1, + updated_at_micros: 1, + } + } + #[test] fn character_animation_normalization_ignores_unproven_top_level_frames() { let inputs = json!({ @@ -10173,6 +10414,182 @@ mod tests { ); } + #[test] + fn character_animation_migration_selects_final_sequence_and_excludes_preview_video() { + let preview = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some( + json!({ "fields": [{ "value": "挥手" }] }) + .to_string() + .as_str(), + ), + None, + None, + EditorCharacterAnimationObjectEvidence { + is_action_object: true, + is_preview_video: true, + }, + ); + let final_sequence = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some( + json!({ + "fields": [{ "value": "挥手" }], + "characterAnimation": { + "frames": migration_frames("final", true), + "durationSeconds": 4 + } + }) + .to_string() + .as_str(), + ), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(preview.reclassified_preview); + assert!(!normalization_plan_is_final_character_animation(&preview)); + assert!(normalization_plan_is_final_character_animation( + &final_sequence + )); + } + + #[test] + fn character_animation_migration_enriches_every_frame_from_registered_objects() { + let frames = JsonValue::Array( + (1..=32) + .map(|index| { + json!({ + "imageSrc": format!("/generated/editor/action/frame{index:02}.png"), + "width": 192, + "height": 256 + }) + }) + .collect(), + ) + .to_string(); + + let enriched = enrich_migration_character_animation_frames_with_objects( + frames.as_str(), + "user-1", + "task-action-1", + |object_key| { + let index = object_key + .strip_prefix("generated/editor/action/frame") + .and_then(|value| value.strip_suffix(".png")) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "unexpected frame key".to_string())?; + Ok(Some(migration_frame_object( + object_key, + format!("asset-object-{index:02}"), + ))) + }, + ) + .expect("all registered frames should be enriched"); + let enriched = serde_json::from_str::>(enriched.as_str()) + .expect("enriched frames should parse"); + + assert_eq!(enriched.len(), 32); + for (offset, frame) in enriched.iter().enumerate() { + let index = offset + 1; + assert_eq!( + frame["objectKey"], + json!(format!("generated/editor/action/frame{index:02}.png")) + ); + assert_eq!( + frame["assetObjectId"], + json!(format!("asset-object-{index:02}")) + ); + } + } + + #[test] + fn character_animation_migration_rejects_missing_or_mismatched_frame_objects() { + let frames = json!([ + { "imageSrc": "/generated/editor/action/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/editor/action/frame02.png", "width": 192, "height": 256 } + ]) + .to_string(); + let missing = enrich_migration_character_animation_frames_with_objects( + frames.as_str(), + "user-1", + "task-action-1", + |_| Ok(None), + ) + .expect_err("unregistered frames must block migration"); + assert!(missing.contains("尚未登记")); + + let mismatched = enrich_migration_character_animation_frames_with_objects( + frames.as_str(), + "user-1", + "task-action-1", + |object_key| { + let mut object = migration_frame_object(object_key, "asset-object-frame"); + object.owner_user_id = Some("user-other".to_string()); + Ok(Some(object)) + }, + ) + .expect_err("mismatched owner must block migration"); + assert!(mismatched.contains("owner")); + } + + #[test] + fn character_animation_migration_ignores_copied_source_lineage() { + let item = json!({ + "objectKey": "generated/editor/action/frame01.png", + "assetObjectId": "asset-object-01", + "sourceResourceId": "legacy-original-character" + }) + .as_object() + .expect("layout item should be an object") + .clone(); + + validate_legacy_action_layout_object_identity( + "local-resource-action-1", + &item, + Some("generated/editor/action/frame01.png"), + Some("asset-object-01"), + ) + .expect("migration must ignore copied sourceResourceId lineage"); + + let mut conflicting_object = item; + conflicting_object.insert("assetObjectId".to_string(), json!("asset-object-other")); + assert!( + validate_legacy_action_layout_object_identity( + "local-resource-action-1", + &conflicting_object, + Some("generated/editor/action/frame01.png"), + Some("asset-object-01"), + ) + .is_err() + ); + } + + #[test] + fn character_animation_migration_distinguishes_zero_and_multiple_final_candidates() { + let zero = validate_final_action_asset_candidate_count("local-resource-action-1", 2, &[]) + .expect_err("zero final candidates must block"); + assert!(zero.contains("没有匹配")); + assert!(zero.contains("原始候选 2 个")); + + let multiple = validate_final_action_asset_candidate_count( + "local-resource-action-1", + 3, + &["asset-final-1".to_string(), "asset-final-2".to_string()], + ) + .expect_err("multiple final candidates must block"); + assert!(multiple.contains("多个")); + assert!(multiple.contains("asset-final-1, asset-final-2")); + + validate_final_action_asset_candidate_count( + "local-resource-action-1", + 2, + &["asset-final-1".to_string()], + ) + .expect("exactly one final candidate should pass"); + } + #[test] fn character_animation_normalization_is_idempotent_for_canonical_rows() { let frames = migration_frames("canonical", false).to_string(); @@ -10315,6 +10732,8 @@ mod tests { source_resource_id: Some("source-resource".to_string()), asset_kind: Some("icon".to_string()), generation_inputs_json: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, updated_at_micros: 1_000_000, }), asset: Some(EditorAssetCreateInput { @@ -10335,6 +10754,8 @@ mod tests { task_id: Some("task-1".to_string()), asset_kind: Some("icon".to_string()), generation_inputs_json: None, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, source_resource_id: Some(resource_id), now_micros: 1_000_000, thumbnail_src: None, @@ -10379,6 +10800,8 @@ mod tests { asset_kind: Some("icon-spritesheet".to_string()), generation_inputs_json: None, public_showcase_enabled: false, + image_sequence_frames_json: None, + image_sequence_duration_ms: None, } } @@ -10504,6 +10927,8 @@ mod tests { asset_kind: input.asset_kind.clone(), generation_inputs_json: input.generation_inputs_json.clone(), source_resource_id: input.source_resource_id.clone(), + image_sequence_frames_json: input.image_sequence_frames_json.clone(), + image_sequence_duration_ms: input.image_sequence_duration_ms, thumbnail_src: input.thumbnail_src.clone(), generation_cost_mud_points: input.generation_cost_mud_points, group_task_id: input.group_task_id.clone(),