diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 061fb36fe..233190a14 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3933,7 +3933,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 误分类。同一 task 可能同时存在误标为动作的预览 MP4 和最终首帧 PNG,候选查找必须先按权威对象类型做计划态分类,排除视频并要求唯一正式图片序列,不能按原始 `assetKind` 计数。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker。普通 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` 计数。账号素材仍有旧帧、但后来拖入画布的 project-resource 只剩清洗后 `fields/references` 时,project-resource dry-run 必须按同 owner / task / 首帧对象精确消费 asset 计划态结果;apply 仍要求前置 asset scope 已物理完成。canvas 判断已有 resource 是否为动作时也必须消费 project-resource 的计划态类型:旧库误标为动作、但权威对象证明为 preview MP4 且 layout 本身是 video 的图层直接跳过动作清理;layout 明确为 `image-sequence` 却指向该视频时继续形成 blocker,资源规划本身有 blocker 时也不得静默跳过。正式序列还要逐帧用稳定对象路径匹配同 owner / task 的已登记图片对象并补齐 `objectKey/assetObjectId`。迁移不得验证 layout 复制的 `sourceResourceId`:历史 layer 可能仍指向原角色,而最终素材已指向预览资源;清理副本后采用最终素材的 DB 血缘即可,新生成链路仍保持严格校验。正式与旧版结果冲突、候选为零或多个均形成 blocker;脚本诊断应直接打印 scope、ID、原因、owner/project/task、对象身份和来源资源,不能只报 blocker ID。普通 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 ffaf559e8..7d45161b5 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 中同名字段保持原样。迁移在计划态先把同 task 的已验证预览 MP4 重分类为 `video`,只有能形成正式图片序列的候选才可用于补建 `local-*` 动作资源;每一帧还必须按首帧 bucket 下的精确对象路径匹配同 owner、同 task、`editor_character_animation` 且为图片的 `asset_object`,并从登记对象补齐 `objectKey/assetObjectId`。canvas layout 复制的 `sourceResourceId` 不参与迁移血缘校验,补建资源只采用最终账号素材的 DB 血缘;新生成链路仍严格校验直接来源。正式与旧版帧或时长冲突、最终候选为零或多于一个、帧对象无法精确验证和缺失正式帧集均作为 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`,只有能形成正式图片序列的候选才可用于后续 scope;project-resource 自身缺帧但能用同 owner、同 task、同首帧 `assetObjectId/objectKey` 精确定位唯一最终账号素材时,dry-run 可消费 asset 的计划态正式序列,apply 仍要求 asset scope 已物理完成。canvas 同样按 project-resource 的计划态类型分类:旧库误标为动作、但规划后为 `video` 且 layout 未声明动作的 preview 图层不参与动作副本清理;layout 明确声明动作却指向该视频,或原始动作资源的规划存在 blocker,继续失败关闭。每一帧必须按首帧 bucket 下的精确对象路径匹配同 owner、同 task、`editor_character_animation` 且为图片的 `asset_object`,并从登记对象补齐 `objectKey/assetObjectId`。canvas layout 复制的 `sourceResourceId` 不参与迁移血缘校验,补建资源只采用最终账号素材的 DB 血缘;新生成链路仍严格校验直接来源。正式与旧版帧或时长冲突、最终候选为零或多于一个、帧对象无法精确验证和缺失正式帧集均作为 blocker,整批拒绝 apply;procedure 同时返回不含生成输入正文的 blocker 完整诊断,包含 scope、ID、原因、owner、project、task、对象身份和来源资源。 - 运维入口固定为 `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 09296b334..a427bbcfe 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 行。全量 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`,让冲突直接失败。 +角色动作正式字段收口使用 `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 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 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/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index bb693c93c..833488e29 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -9071,6 +9071,7 @@ struct EditorCharacterAnimationNormalizationStats { cleaned_canvas_layer_count: u32, materialized_resource_count: u32, blocker_ids: Vec, + blocker_details: Vec, next_cursor: Option, has_more: bool, batch_sha256: String, @@ -9591,6 +9592,7 @@ fn update_normalization_stats_for_plan( stats: &mut EditorCharacterAnimationNormalizationStats, id: &str, plan: &EditorCharacterAnimationRowPlan, + blocker_detail: Option, ) { stats.matched_count = stats.matched_count.saturating_add(u32::from(plan.changed)); stats.backfilled_frames_count = stats @@ -9608,11 +9610,49 @@ fn update_normalization_stats_for_plan( stats.cleaned_frame_index_count = stats .cleaned_frame_index_count .saturating_add(u32::from(plan.cleaned_frame_index)); - if plan.blocker.is_some() { + if let Some(reason) = plan.blocker.as_deref() { stats.blocker_ids.push(id.to_string()); + stats + .blocker_details + .push(blocker_detail.unwrap_or_else(|| { + json!({ + "id": id, + "reason": reason, + }) + .to_string() + })); } } +#[allow(clippy::too_many_arguments)] +fn editor_character_animation_row_blocker_detail( + scope: &str, + id: &str, + plan: &EditorCharacterAnimationRowPlan, + owner_user_id: &str, + project_id: Option<&str>, + task_id: Option<&str>, + asset_object_id: Option<&str>, + object_key: Option<&str>, + source_resource_id: Option<&str>, +) -> Option { + let reason = plan.blocker.as_deref()?; + Some( + json!({ + "scope": scope, + "id": id, + "reason": reason, + "ownerUserId": owner_user_id, + "projectId": project_id, + "taskId": task_id, + "assetObjectId": asset_object_id, + "objectKey": object_key, + "sourceResourceId": source_resource_id, + }) + .to_string(), + ) +} + fn update_normalization_batch_hash( hasher: &mut Sha256, id: &str, @@ -9687,13 +9727,8 @@ fn normalize_editor_character_animation_metadata( "canvas" => normalize_editor_character_animation_canvases(ctx, &input)?, _ => unreachable!(), }; - let error_message = (!input.dry_run && !stats.blocker_ids.is_empty()).then(|| { - format!( - "角色动作规范化批次包含 {} 个 blocker,拒绝整批 apply:{}", - stats.blocker_ids.len(), - stats.blocker_ids.join(", ") - ) - }); + let error_message = + editor_character_animation_normalization_blocker_error_message(input.dry_run, &stats); Ok(editor_character_animation_normalization_result( scope.to_string(), input.dry_run, @@ -9702,6 +9737,25 @@ fn normalize_editor_character_animation_metadata( )) } +fn editor_character_animation_normalization_blocker_error_message( + dry_run: bool, + stats: &EditorCharacterAnimationNormalizationStats, +) -> Option { + (!stats.blocker_ids.is_empty()).then(|| { + let operation = if dry_run { + "dry-run 无法继续" + } else { + "拒绝整批 apply" + }; + format!( + "角色动作规范化批次包含 {} 个 blocker,{operation}:{}\nblocker 完整信息:\n{}", + stats.blocker_ids.len(), + stats.blocker_ids.join(", "), + stats.blocker_details.join("\n") + ) + }) +} + fn editor_character_animation_normalization_result( scope: String, dry_run: bool, @@ -9788,7 +9842,23 @@ fn normalize_editor_character_animation_assets( row.image_sequence_frames_json.as_deref(), row.image_sequence_duration_ms, ); - update_normalization_stats_for_plan(&mut stats, row.asset_id.as_str(), &plan); + let blocker_detail = editor_character_animation_row_blocker_detail( + "asset", + row.asset_id.as_str(), + &plan, + row.owner_user_id.as_str(), + None, + row.task_id.as_deref(), + row.asset_object_id.as_deref(), + row.object_key.as_deref(), + row.source_resource_id.as_deref(), + ); + update_normalization_stats_for_plan( + &mut stats, + row.asset_id.as_str(), + &plan, + blocker_detail, + ); update_normalization_batch_hash( &mut hasher, row.asset_id.as_str(), @@ -9821,6 +9891,165 @@ fn normalize_editor_character_animation_assets( Ok(stats) } +fn plan_editor_character_animation_project_resource( + ctx: &ReducerContext, + row: &EditorProjectResource, + allow_planned_asset_dependency: bool, +) -> EditorCharacterAnimationRowPlan { + let direct_plan = plan_editor_character_animation_row( + ctx, + row.owner_user_id.as_str(), + row.task_id.as_deref(), + row.asset_object_id.as_deref(), + row.object_key.as_deref(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + ); + if direct_plan.blocker.as_deref() != Some("角色动作缺少完整帧集") + || row.image_sequence_frames_json.is_some() + { + return direct_plan; + } + let Some(task_id) = row + .task_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return direct_plan; + }; + if row.asset_object_id.is_none() && row.object_key.is_none() { + return direct_plan; + } + + let raw_candidates = ctx + .db + .editor_asset() + .by_editor_asset_owner_user_id() + .filter(&row.owner_user_id) + .filter(|asset| asset.task_id.as_deref() == Some(task_id)) + .filter(|asset| { + row.asset_object_id + .as_deref() + .is_none_or(|value| asset.asset_object_id.as_deref() == Some(value)) + && row.object_key.as_deref().is_none_or(|value| { + asset + .object_key + .as_deref() + .map(str::trim) + .map(|key| key.trim_start_matches('/')) + == Some(value.trim().trim_start_matches('/')) + }) + }) + .collect::>(); + let raw_candidate_ids = raw_candidates + .iter() + .map(|asset| asset.asset_id.clone()) + .collect::>(); + let final_candidates = raw_candidates + .into_iter() + .filter_map(|asset| { + let plan = plan_editor_character_animation_row( + ctx, + row.owner_user_id.as_str(), + 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.asset_id, plan)) + }) + .collect::>(); + project_missing_frames_from_asset_plans( + row.image_sequence_duration_ms, + direct_plan, + raw_candidate_ids, + final_candidates, + allow_planned_asset_dependency, + |frames_json, duration_ms| { + plan_editor_character_animation_row( + ctx, + row.owner_user_id.as_str(), + row.task_id.as_deref(), + row.asset_object_id.as_deref(), + row.object_key.as_deref(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + frames_json, + duration_ms, + ) + }, + ) +} + +fn project_missing_frames_from_asset_plans( + image_sequence_duration_ms: Option, + direct_plan: EditorCharacterAnimationRowPlan, + raw_candidate_ids: Vec, + mut final_candidates: Vec<(String, EditorCharacterAnimationRowPlan)>, + allow_planned_asset_dependency: bool, + build_projected_plan: impl FnOnce(Option<&str>, Option) -> EditorCharacterAnimationRowPlan, +) -> EditorCharacterAnimationRowPlan { + if final_candidates.len() != 1 { + let mut blocked = direct_plan; + let final_candidate_ids = final_candidates + .iter() + .map(|(asset_id, _)| asset_id.clone()) + .collect::>(); + blocked.blocker = Some(format!( + "角色动作项目资源缺少正式帧集,且无法从同 owner/task/object 的唯一最终账号素材投影(原始候选:{};最终候选:{})", + if raw_candidate_ids.is_empty() { + "无".to_string() + } else { + raw_candidate_ids.join(", ") + }, + if final_candidate_ids.is_empty() { + "无".to_string() + } else { + final_candidate_ids.join(", ") + }, + )); + return blocked; + } + let (asset_id, asset_plan) = final_candidates.pop().expect("candidate length checked"); + if asset_plan.changed && !allow_planned_asset_dependency { + let mut blocked = direct_plan; + blocked.blocker = Some(format!( + "角色动作项目资源依赖账号素材 {} 的 asset scope 规范化,但该前置 scope 尚未物理应用", + asset_id + )); + return blocked; + } + if let (Some(stored), Some(projected)) = ( + image_sequence_duration_ms, + asset_plan.image_sequence_duration_ms, + ) && stored != projected + { + let mut blocked = direct_plan; + blocked.blocker = Some(format!( + "角色动作项目资源图片序列时长与账号素材 {} 冲突", + asset_id + )); + return blocked; + } + + let mut projected = build_projected_plan( + asset_plan.image_sequence_frames_json.as_deref(), + image_sequence_duration_ms.or(asset_plan.image_sequence_duration_ms), + ); + if projected.blocker.is_none() { + projected.backfilled_frames = true; + projected.backfilled_duration = image_sequence_duration_ms.is_none(); + projected.changed = true; + } + projected +} + fn normalize_editor_character_animation_project_resources( ctx: &ReducerContext, input: &EditorCharacterAnimationNormalizationInput, @@ -9854,18 +10083,24 @@ 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 plan = plan_editor_character_animation_row( - ctx, + let plan = plan_editor_character_animation_project_resource(ctx, row, input.dry_run); + let blocker_detail = editor_character_animation_row_blocker_detail( + "project-resource", + row.resource_id.as_str(), + &plan, row.owner_user_id.as_str(), + Some(row.project_id.as_str()), row.task_id.as_deref(), row.asset_object_id.as_deref(), row.object_key.as_deref(), - row.asset_kind.as_deref(), - row.generation_inputs_json.as_deref(), - row.image_sequence_frames_json.as_deref(), - row.image_sequence_duration_ms, + row.source_resource_id.as_deref(), + ); + update_normalization_stats_for_plan( + &mut stats, + row.resource_id.as_str(), + &plan, + blocker_detail, ); - update_normalization_stats_for_plan(&mut stats, row.resource_id.as_str(), &plan); update_normalization_batch_hash( &mut hasher, row.resource_id.as_str(), @@ -9942,7 +10177,23 @@ fn normalize_editor_character_animation_showcases( row.image_sequence_frames_json.as_deref(), row.image_sequence_duration_ms, ); - update_normalization_stats_for_plan(&mut stats, row.showcase_id.as_str(), &plan); + let blocker_detail = editor_character_animation_row_blocker_detail( + "showcase", + row.showcase_id.as_str(), + &plan, + row.owner_user_id.as_str(), + None, + row.task_id.as_deref(), + row.asset_object_id.as_deref(), + row.object_key.as_deref(), + row.source_resource_id.as_deref(), + ); + update_normalization_stats_for_plan( + &mut stats, + row.showcase_id.as_str(), + &plan, + blocker_detail, + ); update_normalization_batch_hash( &mut hasher, row.showcase_id.as_str(), @@ -10262,6 +10513,35 @@ fn json_strings_are_semantically_equal(left: Option<&str>, right: Option<&str>) } } +fn project_normalized_character_animation_resource_to_canvas( + mut resource: EditorProjectResource, + normalized: EditorCharacterAnimationRowPlan, + layout_marks_action: bool, + allow_planned_dependencies: bool, +) -> Result, String> { + let resource_id = resource.resource_id.clone(); + if normalized.blocker.is_some() || (normalized.changed && !allow_planned_dependencies) { + return Err(format!( + "角色动作资源 {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; + if resource.asset_kind.as_deref() != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + if layout_marks_action { + return Err(format!("画布动作图层 {resource_id} 引用了非动作资源")); + } + return Ok(None); + } + Ok(Some(resource)) +} + fn patch_editor_character_animation_canvas_layout( ctx: &ReducerContext, layers_json: &str, @@ -10301,34 +10581,24 @@ fn patch_editor_character_animation_canvas_layout( if !layout_marks_action && !resource_marks_action { continue; } - let resource = if let Some(mut resource) = existing_resource { + let resource = if let Some(resource) = existing_resource { if resource.project_id != project_id || resource.owner_user_id != owner_user_id { return Err(format!("角色动作资源 {resource_id} 不属于当前工程或 owner")); } - let normalized = plan_editor_character_animation_row( + let normalized = plan_editor_character_animation_project_resource( ctx, - owner_user_id, - resource.task_id.as_deref(), - resource.asset_object_id.as_deref(), - resource.object_key.as_deref(), - resource.asset_kind.as_deref(), - resource.generation_inputs_json.as_deref(), - resource.image_sequence_frames_json.as_deref(), - resource.image_sequence_duration_ms, + &resource, + allow_planned_dependencies, ); - if normalized.blocker.is_some() || (normalized.changed && !allow_planned_dependencies) { - return Err(format!( - "角色动作资源 {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; + let Some(resource) = project_normalized_character_animation_resource_to_canvas( + resource, + normalized, + layout_marks_action, + allow_planned_dependencies, + )? + else { + continue; + }; resource } else if let Some(resource) = resources.get(&resource_id) { resource.clone() @@ -10344,9 +10614,10 @@ fn patch_editor_character_animation_canvas_layout( resources.insert(resource_id.clone(), resource.clone()); resource }; - if resource.asset_kind.as_deref() != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { - return Err(format!("画布动作图层 {resource_id} 引用了非动作资源")); - } + debug_assert_eq!( + resource.asset_kind.as_deref(), + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + ); resource_fingerprints.insert( resource_id.clone(), editor_project_resource_normalization_fingerprint(&resource), @@ -10629,8 +10900,19 @@ fn normalize_editor_character_animation_canvases( stats.materialized_resource_count = stats .materialized_resource_count .saturating_add(plan.resources.len() as u32); - if plan.blocker.is_some() { + if let Some(reason) = plan.blocker.as_deref() { stats.blocker_ids.push(canvas.canvas_id.clone()); + stats.blocker_details.push( + json!({ + "scope": "canvas", + "id": canvas.canvas_id, + "reason": reason, + "ownerUserId": canvas.owner_user_id, + "projectId": canvas.project_id, + "revision": canvas.revision, + }) + .to_string(), + ); } for value in [ canvas.canvas_id.as_str(), @@ -11150,6 +11432,270 @@ mod tests { .expect("exactly one final candidate should pass"); } + #[test] + fn character_animation_project_resource_dry_run_consumes_planned_asset_scope() { + let direct = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(r#"{"fields":[{"value":"挥手"}]}"#), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + assert_eq!(direct.blocker.as_deref(), Some("角色动作缺少完整帧集")); + let asset_plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some( + json!({ + "fields": [{ "value": "挥手" }], + "characterAnimation": { + "frames": migration_frames("projected", true), + "durationSeconds": 4 + } + }) + .to_string() + .as_str(), + ), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + assert!(asset_plan.changed); + + let projected = project_missing_frames_from_asset_plans( + None, + direct.clone(), + vec!["asset-action-1".to_string()], + vec![("asset-action-1".to_string(), asset_plan.clone())], + true, + |frames_json, duration_ms| { + normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(r#"{"fields":[{"value":"挥手"}]}"#), + frames_json, + duration_ms, + EditorCharacterAnimationObjectEvidence::default(), + ) + }, + ); + assert!(projected.blocker.is_none()); + assert!(projected.changed); + assert!(projected.backfilled_frames); + assert!(projected.backfilled_duration); + assert_eq!(projected.image_sequence_duration_ms, Some(4_000)); + + let apply_without_asset_scope = project_missing_frames_from_asset_plans( + None, + direct, + vec!["asset-action-1".to_string()], + vec![("asset-action-1".to_string(), asset_plan)], + false, + |_, _| unreachable!("unapplied dependency must block before projection"), + ); + assert!( + apply_without_asset_scope + .blocker + .as_deref() + .is_some_and(|reason| reason.contains("尚未物理应用")) + ); + } + + #[test] + fn character_animation_project_resource_projection_requires_unique_final_asset() { + let direct = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(r#"{"fields":[]}"#), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + let zero = project_missing_frames_from_asset_plans( + None, + direct.clone(), + vec!["asset-preview".to_string()], + Vec::new(), + true, + |_, _| unreachable!("zero candidates must block"), + ); + assert!(zero.blocker.as_deref().is_some_and(|reason| { + reason.contains("最终候选:无") && reason.contains("asset-preview") + })); + + let mut canonical = direct.clone(); + canonical.blocker = None; + canonical.changed = false; + canonical.image_sequence_frames_json = + Some(migration_frames("duplicate", false).to_string()); + canonical.image_sequence_duration_ms = Some(4_000); + let multiple = project_missing_frames_from_asset_plans( + None, + direct, + vec!["asset-1".to_string(), "asset-2".to_string()], + vec![ + ("asset-1".to_string(), canonical.clone()), + ("asset-2".to_string(), canonical), + ], + true, + |_, _| unreachable!("multiple candidates must block"), + ); + assert!( + multiple + .blocker + .as_deref() + .is_some_and(|reason| { reason.contains("asset-1, asset-2") }) + ); + } + + #[test] + fn character_animation_canvas_skips_preview_after_planned_video_reclassification() { + let mut resource = + generated_editor_project_resource("resource-preview", "task-action", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + resource.image_src = "/generated/action/preview.mp4".to_string(); + resource.object_key = Some("generated/action/preview.mp4".to_string()); + let preview_plan = normalize_editor_character_animation_row( + resource.asset_kind.as_deref(), + Some(r##"{"fields":[{"value":"挥手"}],"screenColorHex":"#00ff00"}"##), + None, + None, + EditorCharacterAnimationObjectEvidence { + is_action_object: true, + is_preview_video: true, + }, + ); + + let projected = project_normalized_character_animation_resource_to_canvas( + resource, + preview_plan, + false, + true, + ) + .expect("video layout should accept the planned preview reclassification"); + + assert!(projected.is_none()); + } + + #[test] + fn character_animation_canvas_rejects_sequence_layout_for_planned_video() { + let mut resource = + generated_editor_project_resource("resource-preview", "task-action", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + resource.image_src = "/generated/action/preview.mp4".to_string(); + resource.object_key = Some("generated/action/preview.mp4".to_string()); + let preview_plan = normalize_editor_character_animation_row( + resource.asset_kind.as_deref(), + Some(r#"{"fields":[{"value":"挥手"}]}"#), + None, + None, + EditorCharacterAnimationObjectEvidence { + is_action_object: true, + is_preview_video: true, + }, + ); + + let error = project_normalized_character_animation_resource_to_canvas( + resource, + preview_plan, + true, + true, + ) + .err() + .expect("an image-sequence layout must not point at a preview video"); + + assert!(error.contains("引用了非动作资源")); + } + + #[test] + fn character_animation_canvas_uses_planned_final_sequence() { + let mut resource = + generated_editor_project_resource("resource-action", "task-action", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + let inputs = json!({ + "fields": [{ "value": "挥手" }], + "characterAnimation": { + "frames": migration_frames("canvas-final", true), + "durationSeconds": 4 + } + }) + .to_string(); + let final_plan = normalize_editor_character_animation_row( + resource.asset_kind.as_deref(), + Some(inputs.as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + let projected = project_normalized_character_animation_resource_to_canvas( + resource, final_plan, true, true, + ) + .expect("planned final sequence should remain an action resource") + .expect("planned final sequence should participate in canvas cleanup"); + + assert_eq!( + projected.asset_kind.as_deref(), + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + ); + assert!(projected.image_sequence_frames_json.is_some()); + assert_eq!(projected.image_sequence_duration_ms, Some(4_000)); + assert_eq!( + projected.generation_inputs_json.as_deref(), + Some(r#"{"fields":[{"value":"挥手"}]}"#) + ); + } + + #[test] + fn character_animation_canvas_does_not_hide_blocked_raw_action_resource() { + let mut resource = + generated_editor_project_resource("resource-blocked", "task-action", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + let blocked_plan = normalize_editor_character_animation_row( + resource.asset_kind.as_deref(), + Some(r#"{"fields":[{"value":"挥手"}]}"#), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + let error = project_normalized_character_animation_resource_to_canvas( + resource, + blocked_plan, + false, + true, + ) + .err() + .expect("a blocked raw action resource must not be silently skipped"); + + assert!(error.contains("角色动作缺少完整帧集")); + } + + #[test] + fn character_animation_normalization_blocker_error_contains_full_context() { + let detail = json!({ + "scope": "project-resource", + "id": "resource-blocked", + "reason": "角色动作缺少完整帧集", + "ownerUserId": "user-1", + "projectId": "project-1", + "taskId": "task-1", + "assetObjectId": "object-1", + "objectKey": "generated/action/frame01.png", + "sourceResourceId": "preview-resource-1", + }) + .to_string(); + let stats = EditorCharacterAnimationNormalizationStats { + blocker_ids: vec!["resource-blocked".to_string()], + blocker_details: vec![detail], + ..Default::default() + }; + + let message = editor_character_animation_normalization_blocker_error_message(true, &stats) + .expect("blocker should produce an error message"); + assert!(message.contains("dry-run 无法继续")); + assert!(message.contains("角色动作缺少完整帧集")); + assert!(message.contains("preview-resource-1")); + assert!(message.contains("generated/action/frame01.png")); + } + #[test] fn character_animation_normalization_is_idempotent_for_canonical_rows() { let frames = migration_frames("canonical", false).to_string();