From 230613b07ae9bf285766d6bf5c7fdd5f1c05616d 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 10:17:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A7=92=E8=89=B2=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=AD=98=E9=87=8F=E6=95=B0=E6=8D=AE=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 migration operator 专用 asset、project-resource、showcase、canvas 四阶段 procedure。 顶层旧字段仅在动作身份已证明后解释,正式与旧版冲突形成 blocker。 apply 绑定同批 dry-run SHA-256,并在结束后执行零匹配与零 blocker 复核。 新增迁移脚本、SATS 解析测试、生成绑定和生产制品门禁。 --- .../Jenkinsfile.production-stdb-module-build | 2 +- ...Jenkinsfile.production-stdb-module-publish | 2 +- scripts/build-production-release.sh | 1 + scripts/check-production-ops-guardrails.mjs | 25 + scripts/spacetime-migration-common.mjs | 27 + scripts/spacetime-migration-common.test.ts | 49 + ...ime-normalize-editor-character-actions.mjs | 233 ++ ...normalize-editor-character-actions.test.ts | 143 + .../spacetime-client/src/module_bindings.rs | 6 + ...cter_animation_normalization_input_type.rs | 19 + ...ion_normalization_procedure_result_type.rs | 33 + ...animation_metadata_and_return_procedure.rs | 61 + .../src/editor_project_storage.rs | 2480 +++++++++++++---- 13 files changed, 2589 insertions(+), 492 deletions(-) create mode 100644 scripts/spacetime-normalize-editor-character-actions.mjs create mode 100644 scripts/spacetime-normalize-editor-character-actions.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 47d282eb8..6d748c4ef 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -153,7 +153,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index f2222f1d5..9e6be03ea 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -93,7 +93,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index 2ced31812..455636667 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本" copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本" +copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本" copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本" copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本" copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本" diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 00d92b4b3..6397b6a46 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -580,6 +580,21 @@ const checks = [ includes: 'dry_run: !options.apply', reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。', }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']", + reason: '角色动作规范化必须按固定依赖顺序执行四个 scope。', + }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: 'expectedBatchSha256: dryRun.batch_sha256', + reason: '角色动作规范化 apply 必须绑定同批 dry-run 返回的摘要。', + }, + { + file: 'scripts/spacetime-normalize-editor-character-actions.mjs', + includes: "await scanScopes(options, { verifyZero: true })", + reason: '角色动作规范化 apply 后必须执行全量零匹配复核。', + }, { file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)', @@ -600,11 +615,21 @@ const checks = [ includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', reason: 'Stdb Build 必须归档图片画布存量迁移脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'scripts/spacetime-normalize-editor-character-actions.mjs', + reason: 'Stdb Build 必须归档角色动作元数据规范化脚本。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-publish', includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: 'scripts/spacetime-normalize-editor-character-actions.mjs', + reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs', diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 435dda916..d1366a0a3 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -292,6 +292,33 @@ function normalizeProcedureResult(value, procedureName) { } function normalizeSatsProduct(value, procedureName) { + if ( + procedureName === 'normalize_editor_character_animation_metadata_and_return' && + value.length === 19 + ) { + return { + ok: normalizeSatsValue(value[0]), + scope: normalizeSatsValue(value[1]), + dry_run: normalizeSatsValue(value[2]), + scanned_count: normalizeSatsValue(value[3]), + matched_count: normalizeSatsValue(value[4]), + updated_count: normalizeSatsValue(value[5]), + backfilled_frames_count: normalizeSatsValue(value[6]), + backfilled_duration_count: normalizeSatsValue(value[7]), + reclassified_preview_count: normalizeSatsValue(value[8]), + cleaned_generation_inputs_count: normalizeSatsValue(value[9]), + cleaned_frame_index_count: normalizeSatsValue(value[10]), + cleaned_canvas_layer_count: normalizeSatsValue(value[11]), + materialized_resource_count: normalizeSatsValue(value[12]), + blocker_count: normalizeSatsValue(value[13]), + blocker_ids: normalizeSatsValue(value[14]), + next_cursor: normalizeSatsOption(value[15]), + has_more: normalizeSatsValue(value[16]), + batch_sha256: normalizeSatsValue(value[17]), + error_message: normalizeSatsOption(value[18]), + }; + } + if ( procedureName === 'repair_editor_canvas_resources_and_return' && value.length === 3 diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 5ba7114ab..5a89d09f5 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -99,4 +99,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => { error_message: null, }); }); + + it('normalizes character action metadata normalization results', () => { + const result = parseProcedureResult( + JSON.stringify([ + true, + 'asset', + true, + 25, + 3, + 0, + 2, + 1, + 0, + 3, + 2, + 0, + 0, + 1, + ['asset-blocked'], + [0, 'asset-25'], + true, + 'a'.repeat(64), + [1], + ]), + 'normalize_editor_character_animation_metadata_and_return', + ); + + expect(result).toEqual({ + ok: true, + scope: 'asset', + dry_run: true, + scanned_count: 25, + matched_count: 3, + updated_count: 0, + backfilled_frames_count: 2, + backfilled_duration_count: 1, + reclassified_preview_count: 0, + cleaned_generation_inputs_count: 3, + cleaned_frame_index_count: 2, + cleaned_canvas_layer_count: 0, + materialized_resource_count: 0, + blocker_count: 1, + blocker_ids: ['asset-blocked'], + next_cursor: 'asset-25', + has_more: true, + batch_sha256: 'a'.repeat(64), + error_message: null, + }); + }); }); diff --git a/scripts/spacetime-normalize-editor-character-actions.mjs b/scripts/spacetime-normalize-editor-character-actions.mjs new file mode 100644 index 000000000..db7a0cad9 --- /dev/null +++ b/scripts/spacetime-normalize-editor-character-actions.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url'; + +import { + callSpacetimeProcedureViaCli, + encodeSpacetimeCliOption, + ensureProcedureOk, + parsePositiveInteger, +} from './spacetime-migration-common.mjs'; + +const PROCEDURE_NAME = 'normalize_editor_character_animation_metadata_and_return'; +const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']; +const DEFAULT_CHUNK_SIZE = 25; +const CANVAS_MAX_CHUNK_SIZE = 5; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; + +function usage() { + return `用法: + node scripts/spacetime-normalize-editor-character-actions.mjs \\ + --database --server [--chunk-size <1-25>] [--apply] + +默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。 +追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。 +apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。 +必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`; +} + +export function parseOptions(argv, env = process.env) { + const options = { + apply: false, + chunkSize: DEFAULT_CHUNK_SIZE, + database: env.GENARRATIVE_SPACETIME_DATABASE || '', + passthrough: [], + server: env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + if (arg === '--database') { + options.database = readValue(); + } else if (arg === '--server') { + options.server = readValue(); + } else if (arg === '--server-url') { + options.serverUrl = readValue(); + } else if (arg === '--chunk-size') { + options.chunkSize = parsePositiveInteger(readValue(), arg); + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--no-config' || arg === '--anonymous') { + options.passthrough.push(arg); + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new Error(`未知参数: ${arg}`); + } + } + if (options.chunkSize > DEFAULT_CHUNK_SIZE) { + throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`); + } + return options; +} + +export function buildNormalizationInput({ + scope, + cursor = null, + limit, + dryRun, + expectedBatchSha256 = null, +}) { + if (!SCOPES.includes(scope)) { + throw new Error(`未知角色动作规范化 scope: ${scope}`); + } + if (!Number.isInteger(limit) || limit < 1) { + throw new Error('角色动作规范化 limit 必须是正整数。'); + } + if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) { + throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`); + } + if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) { + throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。'); + } + return { + scope, + cursor: encodeSpacetimeCliOption(cursor), + limit, + dry_run: dryRun, + expected_batch_sha256: encodeSpacetimeCliOption( + dryRun ? null : expectedBatchSha256, + ), + }; +} + +function scopeLimit(scope, chunkSize) { + return scope === 'canvas' + ? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE) + : chunkSize; +} + +function assertSafeBatch(result, scope) { + ensureProcedureOk(result); + if (result.scope !== scope) { + throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`); + } + if (result.blocker_count !== 0 || result.blocker_ids.length !== 0) { + throw new Error( + `${scope} scope 存在 ${result.blocker_count} 个 blocker:${result.blocker_ids.join(', ')}`, + ); + } + if (!SHA256_PATTERN.test(result.batch_sha256 || '')) { + throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`); + } +} + +async function callBatch(options, input) { + return callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input); +} + +export async function scanScopes( + options, + { apply = false, verifyZero = false, callProcedure = callBatch } = {}, +) { + const summaries = []; + for (const scope of SCOPES) { + let cursor = null; + const summary = { + scope, + scanned_count: 0, + matched_count: 0, + updated_count: 0, + batches: 0, + }; + do { + const limit = scopeLimit(scope, options.chunkSize); + const dryRun = await callProcedure( + options, + buildNormalizationInput({ scope, cursor, limit, dryRun: true }), + ); + assertSafeBatch(dryRun, scope); + summary.scanned_count += dryRun.scanned_count; + summary.matched_count += dryRun.matched_count; + summary.batches += 1; + + if (verifyZero && dryRun.matched_count !== 0) { + throw new Error( + `${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待规范化。`, + ); + } + if (apply && dryRun.matched_count > 0) { + const applied = await callProcedure( + options, + buildNormalizationInput({ + scope, + cursor, + limit, + dryRun: false, + expectedBatchSha256: dryRun.batch_sha256, + }), + ); + assertSafeBatch(applied, scope); + if (applied.batch_sha256 !== dryRun.batch_sha256) { + throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`); + } + if (applied.updated_count !== dryRun.matched_count) { + throw new Error( + `${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`, + ); + } + summary.updated_count += applied.updated_count; + } + cursor = dryRun.has_more ? dryRun.next_cursor : null; + if (dryRun.has_more && !cursor) { + throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`); + } + } while (cursor); + summaries.push(summary); + } + return summaries; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseOptions(argv); + if (options.help) { + console.log(usage()); + return; + } + if (!options.database) { + throw new Error('必须显式传入 --database。'); + } + if (!options.server && !options.serverUrl) { + throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。'); + } + + const migration = await scanScopes(options, { apply: options.apply }); + const verification = options.apply + ? await scanScopes(options, { verifyZero: true }) + : null; + console.log( + JSON.stringify( + { + procedure: PROCEDURE_NAME, + applied: options.apply, + scope_order: SCOPES, + migration, + verification, + }, + null, + 2, + ), + ); + if (!options.apply) { + console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。'); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + main().catch((error) => { + console.error( + `[spacetime:editor-character-actions:normalize] ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/spacetime-normalize-editor-character-actions.test.ts b/scripts/spacetime-normalize-editor-character-actions.test.ts new file mode 100644 index 000000000..ccbda298c --- /dev/null +++ b/scripts/spacetime-normalize-editor-character-actions.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildNormalizationInput, + parseOptions, + scanScopes, +} from './spacetime-normalize-editor-character-actions.mjs'; + +describe('角色动作元数据规范化脚本', () => { + it('默认 dry-run 并要求调用方显式选择 apply', () => { + expect( + parseOptions( + ['--database', 'genarrative-prod', '--server', 'prod'], + {}, + ), + ).toMatchObject({ + apply: false, + chunkSize: 25, + database: 'genarrative-prod', + server: 'prod', + }); + }); + + it('编码 dry-run cursor 并禁止 canvas 超过五行', () => { + expect( + buildNormalizationInput({ + scope: 'asset', + cursor: 'asset-25', + limit: 25, + dryRun: true, + }), + ).toEqual({ + scope: 'asset', + cursor: [0, 'asset-25'], + limit: 25, + dry_run: true, + expected_batch_sha256: null, + }); + expect(() => + buildNormalizationInput({ + scope: 'canvas', + limit: 6, + dryRun: true, + }), + ).toThrow('canvas scope limit'); + }); + + it('apply 必须绑定 dry-run 返回的批次哈希', () => { + expect(() => + buildNormalizationInput({ + scope: 'showcase', + limit: 25, + dryRun: false, + }), + ).toThrow('batch SHA-256'); + + expect( + buildNormalizationInput({ + scope: 'showcase', + cursor: null, + limit: 25, + dryRun: false, + expectedBatchSha256: 'a'.repeat(64), + }), + ).toEqual({ + scope: 'showcase', + cursor: null, + limit: 25, + dry_run: false, + expected_batch_sha256: [0, 'a'.repeat(64)], + }); + }); + + it('按固定 scope 顺序为每批执行 hash 绑定的 dry-run 与 apply', async () => { + const calls: Array> = []; + const callProcedure = async ( + _options: Record, + input: Record, + ) => { + calls.push(input); + const dryRun = input.dry_run === true; + const scope = String(input.scope); + const hashDigit = { + asset: 'a', + 'project-resource': 'b', + showcase: 'c', + canvas: 'd', + }[scope]!; + const batchSha256 = hashDigit.repeat(64); + return { + ok: true, + scope, + dry_run: dryRun, + scanned_count: 1, + matched_count: 1, + updated_count: dryRun ? 0 : 1, + blocker_count: 0, + blocker_ids: [], + next_cursor: null, + has_more: false, + batch_sha256: batchSha256, + error_message: null, + }; + }; + + const summaries = await scanScopes( + { chunkSize: 25 }, + { apply: true, callProcedure }, + ); + + expect(summaries.map((summary) => summary.scope)).toEqual([ + 'asset', + 'project-resource', + 'showcase', + 'canvas', + ]); + expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([ + 'asset:true', + 'asset:false', + 'project-resource:true', + 'project-resource:false', + 'showcase:true', + 'showcase:false', + 'canvas:true', + 'canvas:false', + ]); + for (let index = 1; index < calls.length; index += 2) { + const apply = calls[index]!; + const scope = String(apply.scope); + const hashDigit = { + asset: 'a', + 'project-resource': 'b', + showcase: 'c', + canvas: 'd', + }[scope]!; + expect(apply.expected_batch_sha256).toEqual([ + 0, + hashDigit.repeat(64), + ]); + } + expect(calls.at(-2)?.limit).toBe(5); + }); +}); diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b7d5cd579..b7ad70668 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -287,6 +287,8 @@ pub mod editor_canvas_resource_repair_snapshot_type; pub mod editor_canvas_snapshot_type; pub mod editor_canvas_table; pub mod editor_canvas_type; +pub mod editor_character_animation_normalization_input_type; +pub mod editor_character_animation_normalization_procedure_result_type; pub mod editor_generation_model_pricing_type; pub mod editor_generation_pricing_config_procedure_result_type; pub mod editor_generation_pricing_config_snapshot_type; @@ -466,6 +468,7 @@ pub mod match_3_d_runtime_run_row_type; pub mod match_3_d_runtime_run_table; pub mod match_3_d_work_profile_row_type; pub mod match_3_d_work_profile_table; +pub mod normalize_editor_character_animation_metadata_and_return_procedure; pub mod npc_relation_stance_type; pub mod npc_relation_state_type; pub mod npc_stance_profile_type; @@ -1105,6 +1108,8 @@ pub use editor_canvas_resource_repair_snapshot_type::EditorCanvasResourceRepairS pub use editor_canvas_snapshot_type::EditorCanvasSnapshot; pub use editor_canvas_table::*; pub use editor_canvas_type::EditorCanvas; +pub use editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; +pub use editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; pub use editor_generation_model_pricing_type::EditorGenerationModelPricing; pub use editor_generation_pricing_config_procedure_result_type::EditorGenerationPricingConfigProcedureResult; pub use editor_generation_pricing_config_snapshot_type::EditorGenerationPricingConfigSnapshot; @@ -1284,6 +1289,7 @@ pub use match_3_d_runtime_run_row_type::Match3DRuntimeRunRow; pub use match_3_d_runtime_run_table::*; pub use match_3_d_work_profile_row_type::Match3DWorkProfileRow; pub use match_3_d_work_profile_table::*; +pub use normalize_editor_character_animation_metadata_and_return_procedure::normalize_editor_character_animation_metadata_and_return; pub use npc_relation_stance_type::NpcRelationStance; pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs new file mode 100644 index 000000000..b1fcef0e6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCharacterAnimationNormalizationInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha_256: Option, +} + +impl __sdk::InModule for EditorCharacterAnimationNormalizationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs new file mode 100644 index 000000000..541bbd69b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_character_animation_normalization_procedure_result_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCharacterAnimationNormalizationProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub backfilled_frames_count: u32, + pub backfilled_duration_count: u32, + pub reclassified_preview_count: u32, + pub cleaned_generation_inputs_count: u32, + pub cleaned_frame_index_count: u32, + pub cleaned_canvas_layer_count: u32, + pub materialized_resource_count: u32, + pub blocker_count: u32, + pub blocker_ids: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha_256: String, + pub error_message: Option, +} + +impl __sdk::InModule for EditorCharacterAnimationNormalizationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs new file mode 100644 index 000000000..68712687d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/normalize_editor_character_animation_metadata_and_return_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_character_animation_normalization_input_type::EditorCharacterAnimationNormalizationInput; +use super::editor_character_animation_normalization_procedure_result_type::EditorCharacterAnimationNormalizationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct NormalizeEditorCharacterAnimationMetadataAndReturnArgs { + pub input: EditorCharacterAnimationNormalizationInput, +} + +impl __sdk::InModule for NormalizeEditorCharacterAnimationMetadataAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `normalize_editor_character_animation_metadata_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait normalize_editor_character_animation_metadata_and_return { + fn normalize_editor_character_animation_metadata_and_return( + &self, + input: EditorCharacterAnimationNormalizationInput, + ) { + self.normalize_editor_character_animation_metadata_and_return_then(input, |_, _| {}); + } + + fn normalize_editor_character_animation_metadata_and_return_then( + &self, + input: EditorCharacterAnimationNormalizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl normalize_editor_character_animation_metadata_and_return for super::RemoteProcedures { + fn normalize_editor_character_animation_metadata_and_return_then( + &self, + input: EditorCharacterAnimationNormalizationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, EditorCharacterAnimationNormalizationProcedureResult>( + "normalize_editor_character_animation_metadata_and_return", + NormalizeEditorCharacterAnimationMetadataAndReturnArgs { input, }, + __callback, + ); + } +} 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 e565fab21..3c96f34c7 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1,6 +1,7 @@ use crate::*; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::RangeFrom; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_CANVAS_DEFAULT_TITLE: &str = "默认画布"; @@ -17,6 +18,10 @@ const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back"; const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16; const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; +const EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE: u32 = 25; +const EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE: u32 = 5; +const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "character-animation"; +const EDITOR_CHARACTER_ANIMATION_OBJECT_KIND: &str = "editor_character_animation"; const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ "src", "imageSrc", @@ -94,7 +99,8 @@ pub struct EditorProject { #[spacetimedb::table( accessor = editor_canvas, index(accessor = by_editor_canvas_project_id, btree(columns = [project_id])), - index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])) + index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_editor_canvas_normalization_cursor, btree(columns = [canvas_id, project_id])) )] #[derive(Clone)] pub struct EditorCanvas { @@ -205,7 +211,8 @@ pub struct EditorCanvasLayoutMigration { #[spacetimedb::table( accessor = editor_project_resource, index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])), - index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])) + index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_editor_project_resource_normalization_cursor, btree(columns = [resource_id, project_id])) )] #[derive(Clone)] pub struct EditorProjectResource { @@ -259,7 +266,8 @@ pub struct EditorAssetFolder { #[spacetimedb::table( accessor = editor_asset, index(accessor = by_editor_asset_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])) + index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])), + index(accessor = by_editor_asset_normalization_cursor, btree(columns = [asset_id, owner_user_id])) )] pub struct EditorAsset { #[primary_key] @@ -326,7 +334,8 @@ pub struct EditorAssetGroupCohort { #[spacetimedb::table( accessor = editor_showcase_asset, index(accessor = by_editor_showcase_asset_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_showcase_asset_review_status, btree(columns = [review_status])) + index(accessor = by_editor_showcase_asset_review_status, btree(columns = [review_status])), + index(accessor = by_editor_showcase_asset_normalization_cursor, btree(columns = [showcase_id, asset_id])) )] pub struct EditorShowcaseAsset { #[primary_key] @@ -572,6 +581,38 @@ pub struct EditorCanvasResourceRepairProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCharacterAnimationNormalizationInput { + pub scope: String, + pub cursor: Option, + pub limit: u32, + pub dry_run: bool, + pub expected_batch_sha256: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCharacterAnimationNormalizationProcedureResult { + pub ok: bool, + pub scope: String, + pub dry_run: bool, + pub scanned_count: u32, + pub matched_count: u32, + pub updated_count: u32, + pub backfilled_frames_count: u32, + pub backfilled_duration_count: u32, + pub reclassified_preview_count: u32, + pub cleaned_generation_inputs_count: u32, + pub cleaned_frame_index_count: u32, + pub cleaned_canvas_layer_count: u32, + pub materialized_resource_count: u32, + pub blocker_count: u32, + pub blocker_ids: Vec, + pub next_cursor: Option, + pub has_more: bool, + pub batch_sha256: String, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceCreateInput { pub resource_id: String, @@ -1306,6 +1347,27 @@ pub fn repair_editor_canvas_resources_and_return( } } +#[spacetimedb::procedure] +pub fn normalize_editor_character_animation_metadata_and_return( + ctx: &mut ProcedureContext, + input: EditorCharacterAnimationNormalizationInput, +) -> EditorCharacterAnimationNormalizationProcedureResult { + let caller = ctx.sender(); + let fallback_scope = input.scope.clone(); + let fallback_dry_run = input.dry_run; + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + normalize_editor_character_animation_metadata(tx, input.clone()) + }) { + Ok(result) => result, + Err(message) => failed_editor_character_animation_normalization_result( + fallback_scope, + fallback_dry_run, + message, + ), + } +} + #[spacetimedb::procedure] pub fn rename_editor_project_and_return( ctx: &mut ProcedureContext, @@ -2015,7 +2077,6 @@ fn persist_editor_project_layout_inner( &mut structured_layout, now, true, - false, )?; let normalized_structured_layers_json = normalize_layout_json( serialize_structured_canvas_layout(&structured_layout) @@ -2111,6 +2172,7 @@ fn create_editor_project_resource( let generation_inputs_json = normalize_optional(input.generation_inputs_json); let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); let image_sequence_duration_ms = input.image_sequence_duration_ms; + validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?; let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(existing_resource) = find_reusable_project_resource_for_input( ctx, @@ -2654,7 +2716,9 @@ fn create_editor_asset( require_editor_generation_runtime_service_identity(ctx, caller)?; } let asset_kind = normalize_optional(input.asset_kind); + let generation_inputs_json = normalize_optional(input.generation_inputs_json); let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); + validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?; validate_editor_media_metadata( asset_kind.as_deref(), &image_sequence_frames_json, @@ -2679,7 +2743,7 @@ fn create_editor_asset( created_at: now, updated_at: now, asset_kind, - generation_inputs_json: normalize_optional(input.generation_inputs_json), + generation_inputs_json, source_resource_id, thumbnail_src: normalize_optional(input.thumbnail_src), generation_cost_mud_points: input.generation_cost_mud_points, @@ -5260,15 +5324,53 @@ fn validate_editor_media_metadata( Ok(()) } +fn validate_editor_generation_inputs( + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, +) -> Result<(), String> { + if asset_kind != Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + return Ok(()); + } + let Some(generation_inputs_json) = generation_inputs_json else { + return Ok(()); + }; + let value = serde_json::from_str::(generation_inputs_json) + .map_err(|_| "角色动作 generation_inputs_json 不是合法 JSON".to_string())?; + let Some(inputs) = value.as_object() else { + return Ok(()); + }; + let rejected = [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] + .into_iter() + .filter(|field| inputs.contains_key(*field)) + .collect::>(); + if rejected.is_empty() { + Ok(()) + } else { + Err(format!( + "角色动作 generation_inputs_json 不能包含旧版运行字段:{}", + rejected.join(", ") + )) + } +} + fn is_valid_editor_sequence_frame(frame: &JsonValue) -> bool { let Some(frame) = frame.as_object() else { return false; }; - frame - .get("imageSrc") - .and_then(JsonValue::as_str) - .map(str::trim) - .is_some_and(|value| !value.is_empty()) + !frame.contains_key("frameIndex") + && frame + .get("imageSrc") + .and_then(JsonValue::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) && frame .get("width") .and_then(JsonValue::as_u64) @@ -5672,15 +5774,7 @@ fn strip_canvas_layer_resource_metadata_for_canonical_hash( ) { continue; } - let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; - let source_type = json_optional_string(object.get("sourceType")); - if validate_self_contained_legacy_local_image_sequence( - resource_id.as_str(), - source_type.as_deref(), - object, - )? { - continue; - } + json_required_string(object.get("resourceId"), "resourceId")?; for field in EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS { object.remove(field); } @@ -5689,139 +5783,6 @@ fn strip_canvas_layer_resource_metadata_for_canonical_hash( Ok(()) } -fn validate_self_contained_legacy_local_image_sequence( - resource_id: &str, - source_type: Option<&str>, - item: &JsonMap, -) -> Result { - if !resource_id.starts_with("local-") - || source_type != Some("generated") - || item.get("mediaType").and_then(JsonValue::as_str) != Some("image-sequence") - { - return Ok(false); - } - - for field in ["imageSrc", "objectKey", "assetObjectId"] { - match item.get(field) { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if value.trim().is_empty() => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 包含不允许的 {field},拒绝结构化保存" - )); - } - } - } - - let frames = item - .get("imageSequenceFrames") - .and_then(JsonValue::as_array) - .filter(|frames| !frames.is_empty()) - .ok_or_else(|| format!("本地图片序列 {resource_id} 缺少有效帧,拒绝结构化保存"))?; - for (index, frame) in frames.iter().enumerate() { - let frame = frame.as_object().ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧不是对象,拒绝结构化保存", - index + 1 - ) - })?; - for field in ["width", "height"] { - if frame - .get(field) - .and_then(JsonValue::as_f64) - .filter(|value| value.is_finite() && *value > 0.0) - .is_none() - { - return Err(format!( - "本地图片序列 {resource_id} 的第 {} 帧 {field} 无效,拒绝结构化保存", - index + 1 - )); - } - } - let image_src = frame - .get("imageSrc") - .and_then(JsonValue::as_str) - .filter(|value| is_stable_canvas_object_path(value)) - .ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧不是稳定站内路径,拒绝结构化保存", - index + 1 - ) - })?; - if let Some(frame_object_key) = frame.get("objectKey") { - let frame_object_key = frame_object_key.as_str().ok_or_else(|| { - format!( - "本地图片序列 {resource_id} 的第 {} 帧 objectKey 无效,拒绝结构化保存", - index + 1 - ) - })?; - if !is_stable_canvas_object_key(frame_object_key) - || frame_object_key.trim() != image_src.trim().trim_start_matches('/') - { - return Err(format!( - "本地图片序列 {resource_id} 的第 {} 帧 objectKey 与 imageSrc 不一致,拒绝结构化保存", - index + 1 - )); - } - } - } - - match item.get("src") { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if value.trim().is_empty() => {} - Some(JsonValue::String(value)) - if is_stable_canvas_object_path(value) - && value.trim() - == frames[0] - .as_object() - .and_then(|frame| frame.get("imageSrc")) - .and_then(JsonValue::as_str) - .map(str::trim) - .unwrap_or_default() => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 的 src 与首帧不一致,拒绝结构化保存" - )); - } - } - - for (field, label) in [("thumbnailSrc", "缩略图"), ("previewVideoPath", "预览视频")] { - match item.get(field) { - None | Some(JsonValue::Null) => {} - Some(JsonValue::String(value)) if is_stable_canvas_object_path(value) => {} - Some(_) => { - return Err(format!( - "本地图片序列 {resource_id} 的{label}不是稳定站内路径,拒绝结构化保存" - )); - } - } - } - - Ok(true) -} - -fn is_stable_canvas_object_path(value: &str) -> bool { - let value = value.trim(); - value.starts_with('/') - && !value.starts_with("//") - && !value.contains('?') - && !value.contains('#') - && !value.contains('\\') - && !value.split('/').any(|segment| segment == "..") - && !value.chars().any(char::is_control) -} - -fn is_stable_canvas_object_key(value: &str) -> bool { - let value = value.trim(); - !value.is_empty() - && !value.starts_with('/') - && !value.contains('?') - && !value.contains('#') - && !value.contains('\\') - && !value.split('/').any(|segment| segment == "..") - && !value.chars().any(char::is_control) -} - fn canonical_layout_sha256(layers_json: &str) -> Result { canonical_layout_json(layers_json).map(|value| sha256_hex(value.as_bytes())) } @@ -5876,31 +5837,34 @@ fn normalize_structured_canvas_layer_against_resource( ) -> Result, Option)>, String> { let mut item = serde_json::from_str::>(&layer.item_json) .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; - let is_self_contained_local_sequence = validate_self_contained_legacy_local_image_sequence( - layer.resource_id.as_str(), - layer.source_type.as_deref(), - &item, - )?; let Some(resource) = resource else { - if !is_self_contained_local_sequence { - return Err(format!( - "图片画布图层 {} 缺少项目资源 {},拒绝结构化保存", - layer.layer_id, layer.resource_id - )); - } - if layer.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { - return Err(format!( - "图片画布图层 {} 的非资源扩展数据超过 512 KiB", - layer.layer_id - )); - } - return Ok(None); - }; - if is_self_contained_local_sequence { return Err(format!( - "本地图片序列 {} 同时存在项目资源行,无法确定权威元数据,拒绝结构化保存", - layer.resource_id + "图片画布图层 {} 缺少项目资源 {},拒绝结构化保存", + layer.layer_id, layer.resource_id )); + }; + if resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) { + let rejected = [ + "assetKind", + "generationInputs", + "mediaType", + "thumbnailSrc", + "sourceAssetId", + "imageSequenceFrames", + "imageSequenceDurationMs", + "previewVideoPath", + "durationSeconds", + ] + .into_iter() + .filter(|field| item.contains_key(*field)) + .collect::>(); + if !rejected.is_empty() { + return Err(format!( + "图片画布角色动作图层 {} 不能复制资源结果字段:{}", + layer.layer_id, + rejected.join(", ") + )); + } } take_matching_optional_resource_string( @@ -5986,51 +5950,14 @@ fn normalize_structured_canvas_layer_against_resource( Ok(Some((layout_asset_kind, layout_generation_inputs))) } -fn legacy_local_sequence_extension_matches_existing_layer( - existing_source_type: Option<&str>, - existing_item_json: &str, - 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() -} - -fn restore_omitted_legacy_local_sequence_metadata( - existing_item_json: &str, - incoming: &mut StructuredCanvasLayerDraft, -) -> Result<(), String> { - let existing = serde_json::from_str::>(existing_item_json) - .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()); - } - } - incoming.item_json = serde_json::to_string(&JsonValue::Object(item)) - .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", incoming.layer_id))?; - if incoming.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { - return Err(format!( - "图片画布图层 {} 的非资源扩展数据超过 512 KiB", - incoming.layer_id - )); - } - Ok(()) -} - fn normalize_structured_canvas_layer_resource_metadata( ctx: &ReducerContext, - canvas_id: &str, + _canvas_id: &str, project_id: &str, owner_user_id: &str, layout: &mut StructuredCanvasLayout, 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 { @@ -6042,67 +5969,9 @@ fn normalize_structured_canvas_layer_resource_metadata( .filter(|resource| { resource.project_id == project_id && resource.owner_user_id == owner_user_id }); - let layout_resource_metadata = - normalize_structured_canvas_layer_against_resource(layer, resource.as_ref())?; - let Some((layout_asset_kind, layout_generation_inputs)) = layout_resource_metadata else { - let item = serde_json::from_str::>(&layer.item_json) - .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; - if let Some(source_resource_id) = json_optional_string(item.get("sourceResourceId")) - && let Some(source_resource) = ctx - .db - .editor_project_resource() - .resource_id() - .find(&source_resource_id) - && (source_resource.project_id != project_id - || source_resource.owner_user_id != owner_user_id) - { - return Err(format!( - "图片画布图层 {} 的 sourceResourceId 不属于当前工程,拒绝结构化保存", - layer.layer_id - )); - } - if !allow_new_legacy_local_sequences { - let existing_layer_key = - structured_canvas_row_key(canvas_id, "layer", layer.layer_id.as_str()); - let existing_layer = ctx - .db - .editor_canvas_layer() - .layer_key() - .find(&existing_layer_key); - let Some(existing_layer) = existing_layer else { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - }; - if existing_layer.canvas_id != canvas_id - || existing_layer.project_id != project_id - || existing_layer.owner_user_id != owner_user_id - || existing_layer.layer_id != layer.layer_id - || existing_layer.resource_id != layer.resource_id - { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - } - restore_omitted_legacy_local_sequence_metadata( - existing_layer.item_json.as_str(), - layer, - )?; - if !legacy_local_sequence_extension_matches_existing_layer( - existing_layer.source_type.as_deref(), - existing_layer.item_json.as_str(), - layer, - ) { - return Err(format!( - "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", - layer.layer_id - )); - } - } - continue; - }; + let (layout_asset_kind, layout_generation_inputs) = + normalize_structured_canvas_layer_against_resource(layer, resource.as_ref())? + .ok_or_else(|| "图片画布项目资源归一结果缺失".to_string())?; let Some(resource) = resource else { return Err("图片画布项目资源在元数据归一时消失".to_string()); }; @@ -7559,7 +7428,6 @@ fn backfill_editor_canvas_layout( &mut layout, now, !input.dry_run, - true, )?; let preview_json = serialize_structured_canvas_layout(&layout) .map_err(|_| "图片画布结构化回填预览无法序列化".to_string())?; @@ -8301,11 +8169,1783 @@ fn editor_generation_pricing_config_error( } } +#[derive(Clone, Debug, Default)] +struct EditorCharacterAnimationNormalizationStats { + scanned_count: u32, + matched_count: u32, + updated_count: u32, + backfilled_frames_count: u32, + backfilled_duration_count: u32, + reclassified_preview_count: u32, + cleaned_generation_inputs_count: u32, + cleaned_frame_index_count: u32, + cleaned_canvas_layer_count: u32, + materialized_resource_count: u32, + blocker_ids: Vec, + next_cursor: Option, + has_more: bool, + batch_sha256: String, +} + +#[derive(Clone, Debug)] +struct EditorCharacterAnimationRowPlan { + asset_kind: Option, + generation_inputs_json: Option, + image_sequence_frames_json: Option, + image_sequence_duration_ms: Option, + changed: bool, + backfilled_frames: bool, + backfilled_duration: bool, + reclassified_preview: bool, + cleaned_generation_inputs: bool, + cleaned_frame_index: bool, + blocker: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +struct EditorCharacterAnimationObjectEvidence { + is_action_object: bool, + is_preview_video: bool, +} + +fn normalization_cursor_range(cursor: Option<&str>) -> RangeFrom<&str> { + cursor + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default().. +} + +fn editor_character_animation_object_evidence( + ctx: &ReducerContext, + asset_object_id: Option<&str>, + owner_user_id: &str, + row_object_key: Option<&str>, +) -> EditorCharacterAnimationObjectEvidence { + let Some(asset_object_id) = asset_object_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return EditorCharacterAnimationObjectEvidence::default(); + }; + let Some(object) = + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + else { + return EditorCharacterAnimationObjectEvidence::default(); + }; + let owner_matches = object.owner_user_id.as_deref() == Some(owner_user_id); + let object_key_matches = row_object_key + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .is_none_or(|value| value == object.object_key.trim_start_matches('/')); + let is_action_object = owner_matches + && object_key_matches + && object.asset_kind == EDITOR_CHARACTER_ANIMATION_OBJECT_KIND; + let is_preview_video = is_action_object + && object + .content_type + .as_deref() + .is_some_and(|content_type| content_type.starts_with("video/")); + EditorCharacterAnimationObjectEvidence { + is_action_object, + is_preview_video, + } +} + +fn normalize_migration_image_sequence_frames(value: &JsonValue) -> Option<(String, bool)> { + let frames = value.as_array()?; + if frames.len() < 2 { + return None; + } + let mut had_frame_index = false; + let normalized = frames + .iter() + .map(|frame| { + let frame = frame.as_object()?; + had_frame_index |= frame.contains_key("frameIndex"); + let image_src = frame + .get("imageSrc") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let width = frame.get("width").and_then(JsonValue::as_u64)?; + let height = frame.get("height").and_then(JsonValue::as_u64)?; + if width == 0 + || height == 0 + || u32::try_from(width).is_err() + || u32::try_from(height).is_err() + { + return None; + } + let mut normalized = JsonMap::from_iter([ + ( + "imageSrc".to_string(), + JsonValue::String(image_src.to_string()), + ), + ("width".to_string(), JsonValue::from(width)), + ("height".to_string(), JsonValue::from(height)), + ]); + for field in ["objectKey", "assetObjectId"] { + if let Some(value) = frame + .get(field) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + normalized.insert(field.to_string(), JsonValue::String(value.to_string())); + } + } + Some(JsonValue::Object(normalized)) + }) + .collect::>>()?; + serde_json::to_string(&normalized) + .ok() + .map(|json| (json, had_frame_index)) +} + +fn parse_migration_frames_json(value: Option<&str>) -> Option<(String, bool)> { + value + .and_then(|value| serde_json::from_str::(value).ok()) + .as_ref() + .and_then(normalize_migration_image_sequence_frames) +} + +fn merge_compatible_migration_frame_sets( + preferred_json: &str, + fallback_json: &str, +) -> Result { + let mut preferred = serde_json::from_str::>(preferred_json).map_err(|_| ())?; + let fallback = serde_json::from_str::>(fallback_json).map_err(|_| ())?; + if preferred.len() != fallback.len() { + return Err(()); + } + for (preferred_frame, fallback_frame) in preferred.iter_mut().zip(fallback) { + let preferred_frame = preferred_frame.as_object_mut().ok_or(())?; + let fallback_frame = fallback_frame.as_object().ok_or(())?; + for field in ["imageSrc", "width", "height"] { + if preferred_frame.get(field) != fallback_frame.get(field) { + return Err(()); + } + } + for field in ["objectKey", "assetObjectId"] { + match (preferred_frame.get(field), fallback_frame.get(field)) { + (Some(preferred), Some(fallback)) if preferred != fallback => return Err(()), + (None, Some(fallback)) => { + preferred_frame.insert(field.to_string(), fallback.clone()); + } + _ => {} + } + } + } + serde_json::to_string(&preferred).map_err(|_| ()) +} + +fn normalize_editor_character_animation_row( + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, + image_sequence_frames_json: Option<&str>, + image_sequence_duration_ms: Option, + evidence: EditorCharacterAnimationObjectEvidence, +) -> EditorCharacterAnimationRowPlan { + let stored_asset_kind = asset_kind.map(str::to_string); + let formal_frames = parse_migration_frames_json(image_sequence_frames_json); + let generation_value = + generation_inputs_json.and_then(|value| serde_json::from_str::(value).ok()); + let nested_runtime = generation_value + .as_ref() + .and_then(JsonValue::as_object) + .and_then(|inputs| inputs.get("characterAnimation")) + .and_then(JsonValue::as_object); + let nested_frames = nested_runtime + .and_then(|runtime| runtime.get("frames")) + .and_then(normalize_migration_image_sequence_frames); + let nested_duration = nested_runtime + .and_then(|runtime| runtime.get("durationSeconds")) + .and_then(JsonValue::as_u64) + .and_then(|seconds| seconds.checked_mul(1_000)) + .filter(|duration| *duration > 0); + let proven_before_top_level = asset_kind == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || formal_frames.is_some() + || image_sequence_duration_ms.is_some() + || nested_runtime.is_some() + || nested_frames.is_some() + || nested_duration.is_some() + || evidence.is_action_object; + let top_level_runtime = proven_before_top_level + .then_some(()) + .and_then(|_| generation_value.as_ref()) + .and_then(JsonValue::as_object); + let top_level_frames = top_level_runtime + .and_then(|runtime| runtime.get("frames")) + .and_then(normalize_migration_image_sequence_frames); + let top_level_duration = top_level_runtime + .and_then(|runtime| runtime.get("durationSeconds")) + .and_then(JsonValue::as_u64) + .and_then(|seconds| seconds.checked_mul(1_000)) + .filter(|duration| *duration > 0); + let legacy_frames = match (nested_frames, top_level_frames) { + (Some(nested), Some(top_level)) => { + let Ok(merged) = merge_compatible_migration_frame_sets(&nested.0, &top_level.0) else { + return EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind, + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: Some("嵌套与顶层旧版帧集冲突".to_string()), + }; + }; + Some((merged, nested.1 || top_level.1)) + } + (nested, top_level) => nested.or(top_level), + }; + let legacy_duration = match (nested_duration, top_level_duration) { + (Some(nested), Some(top_level)) if nested != top_level => { + return EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind, + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: Some("嵌套与顶层旧版时长冲突".to_string()), + }; + } + (nested, top_level) => nested.or(top_level), + }; + let proven_action = + proven_before_top_level || legacy_frames.is_some() || legacy_duration.is_some(); + + let mut plan = EditorCharacterAnimationRowPlan { + asset_kind: stored_asset_kind.clone(), + generation_inputs_json: generation_inputs_json.map(str::to_string), + image_sequence_frames_json: image_sequence_frames_json.map(str::to_string), + image_sequence_duration_ms, + changed: false, + backfilled_frames: false, + backfilled_duration: false, + reclassified_preview: false, + cleaned_generation_inputs: false, + cleaned_frame_index: false, + blocker: None, + }; + if !proven_action { + return plan; + } + if generation_inputs_json.is_some() && generation_value.is_none() { + plan.blocker = Some("generation_inputs_json 不是合法 JSON".to_string()); + return plan; + } + + let preview_without_frames = + evidence.is_preview_video && formal_frames.is_none() && legacy_frames.is_none(); + if preview_without_frames { + plan.asset_kind = Some("video".to_string()); + plan.image_sequence_frames_json = None; + plan.image_sequence_duration_ms = None; + plan.reclassified_preview = stored_asset_kind.as_deref() != Some("video"); + } else { + let next_frames = match (formal_frames.as_ref(), legacy_frames.as_ref()) { + (Some((formal, _)), Some((legacy, _))) => { + let Ok(merged) = merge_compatible_migration_frame_sets(formal, legacy) else { + plan.blocker = Some("正式帧集与旧版帧集冲突".to_string()); + return plan; + }; + Some(merged) + } + (Some((formal, _)), None) => Some(formal.clone()), + (None, Some((legacy, _))) => { + plan.backfilled_frames = true; + Some(legacy.clone()) + } + (None, None) => { + plan.blocker = Some("角色动作缺少完整帧集".to_string()); + return plan; + } + }; + let next_duration = match (image_sequence_duration_ms, legacy_duration) { + (Some(formal), Some(legacy)) if formal != legacy => { + plan.blocker = Some("正式图片序列时长与旧版时长冲突".to_string()); + return plan; + } + (Some(formal), _) if formal > 0 => Some(formal), + (None, Some(legacy)) => { + plan.backfilled_duration = true; + Some(legacy) + } + _ => { + plan.blocker = Some("角色动作缺少有效图片序列时长".to_string()); + return plan; + } + }; + plan.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + plan.image_sequence_frames_json = next_frames; + plan.image_sequence_duration_ms = next_duration; + } + + if let Some(mut generation_value) = generation_value { + if let Some(inputs) = generation_value.as_object_mut() { + let original = inputs.clone(); + inputs.remove("characterAnimation"); + for field in [ + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] { + inputs.remove(field); + } + plan.cleaned_generation_inputs = *inputs != original; + plan.generation_inputs_json = if inputs.is_empty() { + None + } else { + serde_json::to_string(&generation_value).ok() + }; + } + } + plan.cleaned_frame_index = formal_frames.as_ref().is_some_and(|(_, value)| *value) + || legacy_frames.as_ref().is_some_and(|(_, value)| *value); + plan.changed = plan.asset_kind != stored_asset_kind + || plan.generation_inputs_json.as_deref() != generation_inputs_json + || plan.image_sequence_frames_json.as_deref() != image_sequence_frames_json + || plan.image_sequence_duration_ms != image_sequence_duration_ms; + plan +} + +fn update_normalization_stats_for_plan( + stats: &mut EditorCharacterAnimationNormalizationStats, + id: &str, + plan: &EditorCharacterAnimationRowPlan, +) { + stats.matched_count = stats.matched_count.saturating_add(u32::from(plan.changed)); + stats.backfilled_frames_count = stats + .backfilled_frames_count + .saturating_add(u32::from(plan.backfilled_frames)); + stats.backfilled_duration_count = stats + .backfilled_duration_count + .saturating_add(u32::from(plan.backfilled_duration)); + stats.reclassified_preview_count = stats + .reclassified_preview_count + .saturating_add(u32::from(plan.reclassified_preview)); + stats.cleaned_generation_inputs_count = stats + .cleaned_generation_inputs_count + .saturating_add(u32::from(plan.cleaned_generation_inputs)); + stats.cleaned_frame_index_count = stats + .cleaned_frame_index_count + .saturating_add(u32::from(plan.cleaned_frame_index)); + if plan.blocker.is_some() { + stats.blocker_ids.push(id.to_string()); + } +} + +fn update_normalization_batch_hash( + hasher: &mut Sha256, + id: &str, + asset_kind: Option<&str>, + generation_inputs_json: Option<&str>, + frames_json: Option<&str>, + duration_ms: Option, + plan: &EditorCharacterAnimationRowPlan, +) { + for value in [ + id, + asset_kind.unwrap_or_default(), + generation_inputs_json.unwrap_or_default(), + frames_json.unwrap_or_default(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher.update(duration_ms.unwrap_or_default().to_le_bytes()); + hasher.update(format!("{plan:?}").as_bytes()); +} + +fn finalize_normalization_apply_gate( + input: &EditorCharacterAnimationNormalizationInput, + stats: &mut EditorCharacterAnimationNormalizationStats, + hasher: Sha256, +) -> Result<(), String> { + stats.batch_sha256 = format!("{:x}", hasher.finalize()); + if input.dry_run { + return Ok(()); + } + if input.expected_batch_sha256.as_deref() != Some(stats.batch_sha256.as_str()) { + return Err("角色动作规范化批次 hash 已变化,拒绝 apply".to_string()); + } + Ok(()) +} + +fn normalize_editor_character_animation_metadata( + ctx: &ReducerContext, + input: EditorCharacterAnimationNormalizationInput, +) -> Result { + let scope = input.scope.trim(); + if !matches!(scope, "asset" | "project-resource" | "showcase" | "canvas") { + return Err( + "角色动作规范化 scope 只支持 asset、project-resource、showcase、canvas".to_string(), + ); + } + let maximum = if scope == "canvas" { + EDITOR_CHARACTER_ANIMATION_CANVAS_NORMALIZATION_MAX_BATCH_SIZE + } else { + EDITOR_CHARACTER_ANIMATION_NORMALIZATION_MAX_BATCH_SIZE + }; + if input.limit == 0 || input.limit > maximum { + return Err(format!("角色动作规范化 limit 必须在 1 到 {maximum} 之间")); + } + if input.dry_run && input.expected_batch_sha256.is_some() { + return Err("dry-run 不能提交 expected_batch_sha256".to_string()); + } + if !input.dry_run + && input + .expected_batch_sha256 + .as_deref() + .is_none_or(str::is_empty) + { + return Err("apply 必须提交 dry-run 返回的 expected_batch_sha256".to_string()); + } + + let stats = match scope { + "asset" => normalize_editor_character_animation_assets(ctx, &input)?, + "project-resource" => normalize_editor_character_animation_project_resources(ctx, &input)?, + "showcase" => normalize_editor_character_animation_showcases(ctx, &input)?, + "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(", ") + ) + }); + Ok(editor_character_animation_normalization_result( + scope.to_string(), + input.dry_run, + stats, + error_message, + )) +} + +fn editor_character_animation_normalization_result( + scope: String, + dry_run: bool, + stats: EditorCharacterAnimationNormalizationStats, + error_message: Option, +) -> EditorCharacterAnimationNormalizationProcedureResult { + EditorCharacterAnimationNormalizationProcedureResult { + ok: error_message.is_none(), + scope, + dry_run, + scanned_count: stats.scanned_count, + matched_count: stats.matched_count, + updated_count: stats.updated_count, + backfilled_frames_count: stats.backfilled_frames_count, + backfilled_duration_count: stats.backfilled_duration_count, + reclassified_preview_count: stats.reclassified_preview_count, + cleaned_generation_inputs_count: stats.cleaned_generation_inputs_count, + cleaned_frame_index_count: stats.cleaned_frame_index_count, + cleaned_canvas_layer_count: stats.cleaned_canvas_layer_count, + materialized_resource_count: stats.materialized_resource_count, + blocker_count: stats.blocker_ids.len() as u32, + blocker_ids: stats.blocker_ids, + next_cursor: stats.next_cursor, + has_more: stats.has_more, + batch_sha256: stats.batch_sha256, + error_message, + } +} + +fn failed_editor_character_animation_normalization_result( + scope: String, + dry_run: bool, + message: String, +) -> EditorCharacterAnimationNormalizationProcedureResult { + editor_character_animation_normalization_result( + scope, + dry_run, + EditorCharacterAnimationNormalizationStats::default(), + Some(message), + ) +} + +fn normalize_editor_character_animation_assets( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_asset() + .by_editor_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.asset_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.asset_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + 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( + &mut hasher, + row.asset_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_asset().asset_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn normalize_editor_character_animation_project_resources( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_project_resource() + .by_editor_project_resource_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.resource_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.resource_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + 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( + &mut hasher, + row.resource_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_project_resource().resource_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +fn normalize_editor_character_animation_showcases( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_showcase_asset() + .by_editor_showcase_asset_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.showcase_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.showcase_id.clone()), + has_more, + ..Default::default() + }; + let mut hasher = Sha256::new(); + let mut plans = Vec::with_capacity(rows.len()); + for row in &rows { + let evidence = editor_character_animation_object_evidence( + ctx, + row.asset_object_id.as_deref(), + row.owner_user_id.as_str(), + 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( + &mut hasher, + row.showcase_id.as_str(), + row.asset_kind.as_deref(), + row.generation_inputs_json.as_deref(), + row.image_sequence_frames_json.as_deref(), + row.image_sequence_duration_ms, + &plan, + ); + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (mut row, plan) in rows.into_iter().zip(plans) { + if !plan.changed { + continue; + } + row.asset_kind = plan.asset_kind; + row.generation_inputs_json = plan.generation_inputs_json; + row.image_sequence_frames_json = plan.image_sequence_frames_json; + row.image_sequence_duration_ms = plan.image_sequence_duration_ms; + row.updated_at = ctx.timestamp; + ctx.db.editor_showcase_asset().showcase_id().update(row); + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + +#[derive(Clone)] +struct EditorCharacterAnimationCanvasPlan { + canvas_layers_json: String, + project_layers_json: String, + resources: Vec, + resource_fingerprints: Vec, + cleaned_layer_ids: Vec, + blocker: Option, +} + +const EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS: [&str; 20] = [ + "src", + "imageSrc", + "thumbnailSrc", + "prompt", + "actualPrompt", + "model", + "provider", + "taskId", + "objectKey", + "assetObjectId", + "sourceResourceId", + "sourceAssetId", + "assetKind", + "generationInputs", + "mediaType", + "imageSequenceFrames", + "imageSequenceDurationMs", + "previewVideoPath", + "durationSeconds", + "sourceType", +]; + +fn remove_editor_character_animation_layout_copies( + object: &mut JsonMap, +) -> bool { + let mut changed = false; + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + changed |= object.remove(field).is_some(); + } + changed +} + +fn canonical_action_asset_for_legacy_canvas_layer( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + resource_id: &str, + item: &JsonMap, +) -> Result { + if !resource_id.starts_with("local-") { + return Err(format!( + "缺失的角色动作资源 {resource_id} 不是允许补建的 local-* ID" + )); + } + let source_asset_id = json_optional_string(item.get("sourceAssetId")); + let task_id = json_optional_string(item.get("taskId")); + let mut candidates = if let Some(source_asset_id) = source_asset_id.as_deref() { + ctx.db + .editor_asset() + .asset_id() + .find(&source_asset_id.to_string()) + .into_iter() + .collect::>() + } else { + let Some(task_id) = task_id.as_deref() else { + return Err(format!( + "本地角色动作资源 {resource_id} 缺少 sourceAssetId 和 taskId" + )); + }; + ctx.db + .editor_asset() + .by_editor_asset_owner_user_id() + .filter(&owner_user_id.to_string()) + .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 { + return Err(format!( + "账号动作素材 {} 尚未完成 asset scope 规范化", + asset.asset_id + )); + } + let frames_json = asset + .image_sequence_frames_json + .as_deref() + .ok_or_else(|| format!("账号动作素材 {} 缺少正式帧集", asset.asset_id))?; + let frames = serde_json::from_str::(frames_json) + .ok() + .and_then(|value| value.as_array().cloned()) + .ok_or_else(|| format!("账号动作素材 {} 正式帧集无效", asset.asset_id))?; + if let Some(layout_frames) = item.get("imageSequenceFrames") { + let (layout_frames, _) = normalize_migration_image_sequence_frames(layout_frames) + .ok_or_else(|| format!("本地角色动作资源 {resource_id} 的 layout 帧集无效"))?; + if merge_compatible_migration_frame_sets(frames_json, layout_frames.as_str()).is_err() { + return Err(format!( + "本地角色动作资源 {resource_id} 的 layout 帧集与账号素材冲突" + )); + } + } + if let Some(layout_duration_ms) = item.get("imageSequenceDurationMs") { + let layout_duration_ms = layout_duration_ms.as_u64().ok_or_else(|| { + format!("本地角色动作资源 {resource_id} 的 imageSequenceDurationMs 无效") + })?; + if Some(layout_duration_ms) != asset.image_sequence_duration_ms { + return Err(format!( + "本地角色动作资源 {resource_id} 的图片序列时长与账号素材冲突" + )); + } + } + if let Some(layout_duration_seconds) = item.get("durationSeconds") { + let layout_duration_ms = layout_duration_seconds + .as_u64() + .and_then(|seconds| seconds.checked_mul(1_000)) + .ok_or_else(|| format!("本地角色动作资源 {resource_id} 的 durationSeconds 无效"))?; + if Some(layout_duration_ms) != asset.image_sequence_duration_ms { + return Err(format!( + "本地角色动作资源 {resource_id} 的旧版时长与账号素材冲突" + )); + } + } + if let Some(layout_generation_inputs) = item.get("generationInputs") { + let layout_generation_inputs_json = serde_json::to_string(layout_generation_inputs) + .map_err(|_| format!("本地角色动作资源 {resource_id} 的生成输入无法序列化"))?; + let layout_plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(layout_generation_inputs_json.as_str()), + Some(frames_json), + asset.image_sequence_duration_ms, + EditorCharacterAnimationObjectEvidence::default(), + ); + if layout_plan.blocker.is_some() + || !json_strings_are_semantically_equal( + layout_plan.generation_inputs_json.as_deref(), + asset.generation_inputs_json.as_deref(), + ) + { + return Err(format!( + "本地角色动作资源 {resource_id} 的生成输入与账号素材冲突" + )); + } + } + let first_frame = frames + .first() + .and_then(JsonValue::as_object) + .ok_or_else(|| format!("账号动作素材 {} 缺少首帧", asset.asset_id))?; + let first_image_src = json_required_string(first_frame.get("imageSrc"), "frame.imageSrc")?; + for field in ["src", "thumbnailSrc"] { + if let Some(layout_src) = json_optional_string(item.get(field)) { + if layout_src != first_image_src { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材首帧不一致" + )); + } + } + } + if task_id + .as_deref() + .is_some_and(|value| asset.task_id.as_deref() != Some(value)) + { + return Err(format!( + "本地角色动作资源 {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} 与账号素材冲突" + )); + } + } + for (field, expected) in [ + ("originalWidth", asset.width), + ("originalHeight", asset.height), + ] { + if let Some(value) = item.get(field).and_then(JsonValue::as_f64) { + if !value.is_finite() || value.round() as u32 != expected { + return Err(format!( + "本地角色动作资源 {resource_id} 的 {field} 与账号素材不一致" + )); + } + } + } + Ok(EditorProjectResource { + resource_id: resource_id.to_string(), + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: asset.asset_object_id, + image_src: asset.image_src, + object_key: asset.object_key, + width: asset.width, + height: asset.height, + source_type: asset.source_type, + prompt: asset.prompt, + actual_prompt: asset.actual_prompt, + model: asset.model, + provider: asset.provider, + task_id: asset.task_id, + source_resource_id: asset.source_resource_id, + created_at: ctx.timestamp, + updated_at: ctx.timestamp, + asset_kind: asset.asset_kind, + generation_inputs_json: asset.generation_inputs_json, + public_showcase_enabled: false, + image_sequence_frames_json: asset.image_sequence_frames_json, + image_sequence_duration_ms: asset.image_sequence_duration_ms, + }) +} + +fn json_strings_are_semantically_equal(left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => { + serde_json::from_str::(left).ok() + == serde_json::from_str::(right).ok() + } + _ => false, + } +} + +fn patch_editor_character_animation_canvas_layout( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, +) -> Result<(String, Vec, Vec, Vec), String> { + let mut value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut resources = BTreeMap::::new(); + let mut resource_fingerprints = BTreeMap::::new(); + let mut cleaned_layer_ids = Vec::new(); + for item in items { + let Some(object) = item.as_object_mut() else { + return Err("图片画布布局项必须是对象".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 existing_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id); + let layout_marks_action = object.get("assetKind").and_then(JsonValue::as_str) + == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || object.get("mediaType").and_then(JsonValue::as_str) == Some("image-sequence"); + let resource_marks_action = existing_resource.as_ref().is_some_and(|resource| { + resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + }); + if !layout_marks_action && !resource_marks_action { + continue; + } + 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 evidence = editor_character_animation_object_evidence( + ctx, + resource.asset_object_id.as_deref(), + owner_user_id, + 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 { + return Err(format!( + "角色动作资源 {resource_id} 尚未完成 project-resource scope 规范化" + )); + } + resource + } else if let Some(resource) = resources.get(&resource_id) { + resource.clone() + } else { + let resource = canonical_action_asset_for_legacy_canvas_layer( + ctx, + project_id, + owner_user_id, + resource_id.as_str(), + object, + )?; + 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} 引用了非动作资源")); + } + resource_fingerprints.insert( + resource_id.clone(), + editor_project_resource_normalization_fingerprint(&resource), + ); + let cleaned = remove_editor_character_animation_layout_copies(object); + if cleaned { + cleaned_layer_ids.push(json_required_string(object.get("layerId"), "layerId")?); + } + } + let layers_json = serde_json::to_string(&value) + .map_err(|_| "角色动作规范化后的画布布局无法序列化".to_string())?; + if layers_json.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("角色动作规范化后的画布布局超过 2 MiB".to_string()); + } + Ok(( + layers_json, + resources.into_values().collect(), + resource_fingerprints.into_values().collect(), + cleaned_layer_ids, + )) +} + +fn editor_project_resource_normalization_fingerprint(resource: &EditorProjectResource) -> String { + let mut hasher = Sha256::new(); + for value in [ + Some(resource.resource_id.as_str()), + Some(resource.project_id.as_str()), + Some(resource.owner_user_id.as_str()), + resource.asset_object_id.as_deref(), + Some(resource.image_src.as_str()), + resource.object_key.as_deref(), + Some(resource.source_type.as_str()), + resource.prompt.as_deref(), + resource.actual_prompt.as_deref(), + resource.model.as_deref(), + resource.provider.as_deref(), + resource.task_id.as_deref(), + resource.source_resource_id.as_deref(), + resource.asset_kind.as_deref(), + resource.generation_inputs_json.as_deref(), + resource.image_sequence_frames_json.as_deref(), + ] { + hasher.update(value.unwrap_or_default().as_bytes()); + hasher.update([u8::from(value.is_some())]); + hasher.update([0]); + } + hasher.update(resource.width.to_le_bytes()); + hasher.update(resource.height.to_le_bytes()); + hasher.update([u8::from(resource.public_showcase_enabled)]); + hasher.update( + resource + .image_sequence_duration_ms + .unwrap_or_default() + .to_le_bytes(), + ); + hasher.update([u8::from(resource.image_sequence_duration_ms.is_some())]); + format!("{:x}", hasher.finalize()) +} + +fn canvas_layout_may_contain_character_animation(ctx: &ReducerContext, layers_json: &str) -> bool { + let Ok(value) = serde_json::from_str::(layers_json) else { + return layers_json.contains(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || layers_json.contains("image-sequence"); + }; + let Some(items) = value.as_array() else { + return false; + }; + items.iter().any(|item| { + let Some(object) = item.as_object() else { + return false; + }; + object.get("assetKind").and_then(JsonValue::as_str) + == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + || object.get("mediaType").and_then(JsonValue::as_str) == Some("image-sequence") + || object + .get("resourceId") + .and_then(JsonValue::as_str) + .and_then(|resource_id| { + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id.to_string()) + }) + .is_some_and(|resource| { + resource.asset_kind.as_deref() == Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + }) + }) +} + +fn plan_editor_character_animation_canvas( + ctx: &ReducerContext, + canvas: &EditorCanvas, +) -> EditorCharacterAnimationCanvasPlan { + if !canvas_layout_may_contain_character_animation(ctx, canvas.layers_json.as_str()) { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: canvas.layers_json.clone(), + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: None, + }; + } + let Some(project) = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + else { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: String::new(), + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("图片画布对应的工程不存在".to_string()), + }; + }; + if project.owner_user_id != canvas.owner_user_id { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("图片画布与工程 owner 不一致".to_string()), + }; + } + if parse_legacy_layout_value(canvas.layers_json.as_str()).ok() + != parse_legacy_layout_value(project.layers_json.as_str()).ok() + { + return EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some("editor_canvas 与 editor_project 的布局不一致".to_string()), + }; + } + match patch_editor_character_animation_canvas_layout( + ctx, + canvas.layers_json.as_str(), + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + ) { + Ok((layers_json, resources, resource_fingerprints, cleaned_layer_ids)) => { + EditorCharacterAnimationCanvasPlan { + canvas_layers_json: layers_json.clone(), + project_layers_json: layers_json, + resources, + resource_fingerprints, + cleaned_layer_ids, + blocker: None, + } + } + Err(message) => EditorCharacterAnimationCanvasPlan { + canvas_layers_json: canvas.layers_json.clone(), + project_layers_json: project.layers_json, + resources: Vec::new(), + resource_fingerprints: Vec::new(), + cleaned_layer_ids: Vec::new(), + blocker: Some(message), + }, + } +} + +fn apply_editor_character_animation_canvas_plan( + ctx: &ReducerContext, + canvas: EditorCanvas, + plan: EditorCharacterAnimationCanvasPlan, +) -> Result<(), String> { + let mut project = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + .ok_or_else(|| "图片画布对应的工程不存在".to_string())?; + for resource in plan.resources { + ctx.db.editor_project_resource().try_insert(resource)?; + } + let mut structured_layout = parse_structured_canvas_layout(&plan.canvas_layers_json)?; + normalize_structured_canvas_layer_resource_metadata( + ctx, + canvas.canvas_id.as_str(), + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + &mut structured_layout, + ctx.timestamp, + true, + )?; + let next_revision = canvas + .revision + .checked_add(1) + .ok_or_else(|| "图片画布 revision 已达到上限".to_string())?; + replace_structured_canvas_layout(ctx, &canvas, &structured_layout, ctx.timestamp)?; + let mut next_canvas = canvas.clone(); + next_canvas.layers_json = plan.canvas_layers_json; + next_canvas.revision = next_revision; + next_canvas.updated_at = ctx.timestamp; + ctx.db + .editor_canvas() + .canvas_id() + .update(next_canvas.clone()); + project.layers_json = plan.project_layers_json; + project.updated_at = ctx.timestamp; + ctx.db.editor_project().project_id().update(project); + + if let Some(mut migration) = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&next_canvas.canvas_id) + { + let source_hash = canonical_layout_sha256(next_canvas.layers_json.as_str())?; + let structured_json = build_structured_canvas_layout_json_with_background( + ctx, + next_canvas.canvas_id.as_str(), + structured_layout.background_color.as_deref(), + )?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let integrity = canvas_layout_integrity(structured_json.as_str())?; + migration.source_layout_sha256 = source_hash; + migration.structured_layout_sha256 = structured_hash; + migration.verified_revision = next_revision; + migration.layer_count = integrity.layer_count; + migration.dialog_count = integrity.dialog_count; + migration.resource_refs_sha256 = integrity.resource_refs_sha256; + migration.updated_at = ctx.timestamp; + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .update(migration); + } + Ok(()) +} + +fn normalize_editor_character_animation_canvases( + ctx: &ReducerContext, + input: &EditorCharacterAnimationNormalizationInput, +) -> Result { + let cursor = input + .cursor + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let cursor_to_skip = cursor.map(str::to_string); + let mut rows = ctx + .db + .editor_canvas() + .by_editor_canvas_normalization_cursor() + .filter((normalization_cursor_range(cursor),)) + .filter(move |row| { + cursor_to_skip + .as_deref() + .is_none_or(|value| row.canvas_id != value) + }) + .take(input.limit as usize + 1) + .collect::>(); + let has_more = rows.len() > input.limit as usize; + rows.truncate(input.limit as usize); + let mut stats = EditorCharacterAnimationNormalizationStats { + scanned_count: rows.len() as u32, + next_cursor: rows.last().map(|row| row.canvas_id.clone()), + has_more, + ..Default::default() + }; + 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 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 + .cleaned_canvas_layer_count + .saturating_add(plan.cleaned_layer_ids.len() as u32); + stats.materialized_resource_count = stats + .materialized_resource_count + .saturating_add(plan.resources.len() as u32); + if plan.blocker.is_some() { + stats.blocker_ids.push(canvas.canvas_id.clone()); + } + for value in [ + canvas.canvas_id.as_str(), + canvas.project_id.as_str(), + canvas.layers_json.as_str(), + plan.canvas_layers_json.as_str(), + plan.project_layers_json.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher.update(canvas.revision.to_le_bytes()); + hasher.update(format!("{:?}", plan.cleaned_layer_ids).as_bytes()); + hasher.update(plan.blocker.as_deref().unwrap_or_default().as_bytes()); + for fingerprint in &plan.resource_fingerprints { + hasher.update(fingerprint.as_bytes()); + hasher.update([0]); + } + plans.push(plan); + } + finalize_normalization_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_ids.is_empty() { + return Ok(stats); + } + if !input.dry_run { + for (canvas, plan) in rows.into_iter().zip(plans) { + if plan.canvas_layers_json == canvas.layers_json && plan.resources.is_empty() { + continue; + } + apply_editor_character_animation_canvas_plan(ctx, canvas, plan)?; + stats.updated_count = stats.updated_count.saturating_add(1); + } + } + Ok(stats) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn migration_frames(prefix: &str, with_frame_index: bool) -> JsonValue { + let mut frames = vec![ + json!({ + "imageSrc": format!("/generated/{prefix}/frame01.png"), + "objectKey": format!("generated/{prefix}/frame01.png"), + "width": 192, + "height": 256 + }), + json!({ + "imageSrc": format!("/generated/{prefix}/frame02.png"), + "assetObjectId": format!("asset-object-{prefix}-02"), + "width": 192, + "height": 256 + }), + ]; + if with_frame_index { + frames[0]["frameIndex"] = json!(99); + frames[1]["frameIndex"] = json!(1); + } + JsonValue::Array(frames) + } + + #[test] + fn character_animation_normalization_ignores_unproven_top_level_frames() { + let inputs = json!({ + "frames": migration_frames("unrelated", false), + "durationSeconds": 4, + "mediaType": "custom-input", + "purpose": "unrelated generation input" + }); + let inputs_json = inputs.to_string(); + + let plan = normalize_editor_character_animation_row( + Some("image"), + Some(inputs_json.as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert!(plan.blocker.is_none()); + assert_eq!(plan.asset_kind.as_deref(), Some("image")); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs_json.as_str()) + ); + assert!(plan.image_sequence_frames_json.is_none()); + assert!(plan.image_sequence_duration_ms.is_none()); + } + + #[test] + fn character_animation_normalization_backfills_nested_runtime_and_preserves_provenance() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "references": [{ "src": "/generated/reference.png", "mediaType": "image" }], + "screenColorHex": "#00ff00", + "characterAnimation": { + "frames": migration_frames("nested", true), + "previewVideoPath": "/generated/nested/preview.mp4", + "frameCount": 2, + "fps": 8, + "durationSeconds": 4 + } + }); + + let plan = normalize_editor_character_animation_row( + None, + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.backfilled_frames); + assert!(plan.backfilled_duration); + assert!(plan.cleaned_generation_inputs); + assert!(plan.cleaned_frame_index); + assert!(plan.blocker.is_none()); + assert_eq!( + plan.asset_kind.as_deref(), + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND) + ); + assert_eq!(plan.image_sequence_duration_ms, Some(4_000)); + let frames: JsonValue = serde_json::from_str( + plan.image_sequence_frames_json + .as_deref() + .expect("formal frames should be backfilled"), + ) + .expect("formal frames should parse"); + assert!( + frames + .as_array() + .is_some_and(|frames| frames.iter().all(|frame| frame.get("frameIndex").is_none())) + ); + let generation_inputs: JsonValue = serde_json::from_str( + plan.generation_inputs_json + .as_deref() + .expect("generation provenance should remain"), + ) + .expect("generation provenance should parse"); + assert_eq!(generation_inputs["fields"][0]["value"], json!("挥手")); + assert_eq!( + generation_inputs["references"][0]["mediaType"], + json!("image") + ); + assert!(generation_inputs.get("characterAnimation").is_none()); + assert!(generation_inputs.get("screenColorHex").is_none()); + } + + #[test] + fn character_animation_normalization_blocks_incomplete_nested_runtime() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "characterAnimation": { + "previewVideoPath": "/generated/nested/preview.mp4", + "frameCount": 0 + } + }); + + let plan = normalize_editor_character_animation_row( + None, + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert_eq!(plan.blocker.as_deref(), Some("角色动作缺少完整帧集")); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs.to_string().as_str()) + ); + } + + #[test] + fn character_animation_normalization_reads_top_level_runtime_only_after_action_proof() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "frames": migration_frames("top-level", false), + "durationSeconds": 3, + "fps": 8 + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.backfilled_frames); + assert!(plan.backfilled_duration); + assert_eq!(plan.image_sequence_duration_ms, Some(3_000)); + let generation_inputs: JsonValue = serde_json::from_str( + plan.generation_inputs_json + .as_deref() + .expect("fields should remain"), + ) + .expect("generation inputs should parse"); + assert_eq!( + generation_inputs, + json!({ + "fields": [{ "title": "动作", "value": "待机" }] + }) + ); + } + + #[test] + fn character_animation_normalization_blocks_formal_legacy_conflicts() { + let formal_frames = migration_frames("formal", false).to_string(); + let inputs = json!({ + "characterAnimation": { + "frames": migration_frames("legacy", false), + "durationSeconds": 9 + } + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + Some(formal_frames.as_str()), + Some(4_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert_eq!(plan.blocker.as_deref(), Some("正式帧集与旧版帧集冲突")); + assert!(!plan.changed); + } + + #[test] + fn character_animation_normalization_merges_compatible_optional_frame_references() { + let formal_frames = json!([ + { "imageSrc": "/generated/shared/frame01.png", "width": 192, "height": 256 }, + { "imageSrc": "/generated/shared/frame02.png", "width": 192, "height": 256 } + ]) + .to_string(); + let legacy_frames = json!([ + { + "imageSrc": "/generated/shared/frame01.png", + "objectKey": "generated/shared/frame01.png", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/shared/frame02.png", + "assetObjectId": "asset-object-shared-02", + "width": 192, + "height": 256 + } + ]); + let inputs = json!({ + "fields": [], + "characterAnimation": { + "frames": legacy_frames, + "durationSeconds": 4 + } + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + Some(formal_frames.as_str()), + Some(4_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(plan.changed); + assert!(plan.blocker.is_none()); + let merged: JsonValue = serde_json::from_str( + plan.image_sequence_frames_json + .as_deref() + .expect("formal frames should remain"), + ) + .expect("merged frames should parse"); + assert_eq!( + merged[0]["objectKey"], + json!("generated/shared/frame01.png") + ); + assert_eq!(merged[1]["assetObjectId"], json!("asset-object-shared-02")); + } + + #[test] + fn character_animation_normalization_reclassifies_verified_preview_video() { + let inputs = json!({ + "fields": [{ "title": "动作", "value": "挥手" }], + "screenColorHex": "#00ff00" + }); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.to_string().as_str()), + None, + None, + EditorCharacterAnimationObjectEvidence { + is_action_object: true, + is_preview_video: true, + }, + ); + + assert!(plan.changed); + assert!(plan.reclassified_preview); + assert_eq!(plan.asset_kind.as_deref(), Some("video")); + assert!(plan.image_sequence_frames_json.is_none()); + assert!(plan.image_sequence_duration_ms.is_none()); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some( + json!({ "fields": [{ "title": "动作", "value": "挥手" }] }) + .to_string() + .as_str() + ) + ); + } + + #[test] + fn character_animation_normalization_is_idempotent_for_canonical_rows() { + let frames = migration_frames("canonical", false).to_string(); + let inputs = json!({ + "fields": [{ "title": "动作", "value": "奔跑" }], + "references": [] + }) + .to_string(); + + let plan = normalize_editor_character_animation_row( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.as_str()), + Some(frames.as_str()), + Some(2_000), + EditorCharacterAnimationObjectEvidence::default(), + ); + + assert!(!plan.changed); + assert!(plan.blocker.is_none()); + assert_eq!( + plan.generation_inputs_json.as_deref(), + Some(inputs.as_str()) + ); + assert_eq!( + plan.image_sequence_frames_json.as_deref(), + Some(frames.as_str()) + ); + } + + #[test] + fn character_animation_canvas_cleanup_preserves_placement_and_extensions_only() { + let mut item = json!({ + "id": "layer-action-1", + "resourceId": "resource-action-1", + "x": 12, + "y": 34, + "width": 192, + "height": 256, + "rotation": 15, + "opacity": 0.8, + "customExtension": { "lockedBy": "plugin-1" } + }) + .as_object() + .expect("test layout item should be an object") + .clone(); + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + item.insert(field.to_string(), json!(format!("legacy-{field}"))); + } + + assert!(remove_editor_character_animation_layout_copies(&mut item)); + + assert_eq!(item["id"], json!("layer-action-1")); + assert_eq!(item["resourceId"], json!("resource-action-1")); + assert_eq!(item["x"], json!(12)); + assert_eq!(item["y"], json!(34)); + assert_eq!(item["width"], json!(192)); + assert_eq!(item["height"], json!(256)); + assert_eq!(item["rotation"], json!(15)); + assert_eq!(item["opacity"], json!(0.8)); + assert_eq!(item["customExtension"], json!({ "lockedBy": "plugin-1" })); + for field in EDITOR_CHARACTER_ANIMATION_LAYOUT_COPY_FIELDS { + assert!( + !item.contains_key(field), + "legacy copy {field} should be removed" + ); + } + assert!(!remove_editor_character_animation_layout_copies(&mut item)); + } + + #[test] + fn character_animation_canvas_resource_fingerprint_ignores_timestamps_but_binds_results() { + let mut resource = + generated_editor_project_resource("resource-action-1", "task-action-1", None); + resource.asset_kind = Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()); + resource.image_sequence_frames_json = + Some(migration_frames("canonical", false).to_string()); + resource.image_sequence_duration_ms = Some(2_000); + + let fingerprint = editor_project_resource_normalization_fingerprint(&resource); + let mut timestamp_only_change = resource.clone(); + timestamp_only_change.created_at = Timestamp::from_micros_since_unix_epoch(20); + timestamp_only_change.updated_at = Timestamp::from_micros_since_unix_epoch(30); + assert_eq!( + editor_project_resource_normalization_fingerprint(×tamp_only_change), + fingerprint + ); + + let mut duration_change = resource.clone(); + duration_change.image_sequence_duration_ms = Some(3_000); + assert_ne!( + editor_project_resource_normalization_fingerprint(&duration_change), + fingerprint + ); + + let mut frames_change = resource; + frames_change.image_sequence_frames_json = + Some(migration_frames("other", false).to_string()); + assert_ne!( + editor_project_resource_normalization_fingerprint(&frames_change), + fingerprint + ); + } + #[test] fn editor_layout_accepts_json_up_to_two_mib() { let payload = format!( @@ -8527,6 +10167,48 @@ mod tests { assert!(validate_editor_canvas_audio_asset_kind("audio").is_err()); } + #[test] + fn character_animation_storage_rejects_legacy_generation_inputs_but_leaves_other_kinds_alone() { + for field in [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + "screenColorHex", + ] { + let mut inputs = JsonMap::from_iter([("fields".to_string(), json!([]))]); + inputs.insert(field.to_string(), json!("legacy")); + let inputs = JsonValue::Object(inputs).to_string(); + assert!( + validate_editor_generation_inputs( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + Some(inputs.as_str()), + ) + .is_err(), + "{field} must be rejected for character actions" + ); + assert!( + validate_editor_generation_inputs(Some("image"), Some(inputs.as_str())).is_ok() + ); + } + } + + #[test] + fn character_animation_storage_rejects_frame_index() { + let frames = Some(migration_frames("write-gate", true).to_string()); + + let error = validate_editor_media_metadata( + Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND), + &frames, + Some(4_000), + ) + .expect_err("formal action frames must not contain frameIndex"); + + assert!(error.contains("无效帧")); + } + #[test] fn reusable_project_resource_backfills_missing_media_fields_and_rejects_conflicts() { let existing = EditorProjectResource { @@ -8852,146 +10534,26 @@ mod tests { ); } - fn self_contained_local_sequence_layout() -> JsonValue { - json!([{ + #[test] + fn structured_canvas_layer_without_project_resource_fails_closed() { + let layout = json!([{ "layerId": "local-sequence-layer", "resourceId": "local-resource-sequence", - "title": "历史角色动作", - "x": 10, - "y": 20, - "width": 320, - "height": 320, - "originalWidth": 512, - "originalHeight": 512, - "zIndex": 2, - "sourceType": "generated", - "mediaType": "image-sequence", - "src": null, - "imageSrc": null, - "objectKey": null, - "assetObjectId": null, - "thumbnailSrc": "/generated/sequence/thumbnail.png", - "previewVideoPath": "/generated/sequence/preview.mp4", - "imageSequenceFrames": [{ - "frameIndex": 1, - "imageSrc": "/generated/sequence/frame01.png", - "width": 512, - "height": 512 - }], - "prompt": "角色向前走", - "actualPrompt": "角色向前走,透明背景", - "model": "legacy-animation", - "provider": "legacy-provider", - "taskId": "legacy-task", - "sourceResourceId": "deleted-source-resource", "assetKind": "character-animation", - "generationInputs": { "action": "walk" } - }]) - } + "mediaType": "image-sequence", + "imageSequenceFrames": [ + { "imageSrc": "/generated/sequence/frame01.png", "width": 512, "height": 512 }, + { "imageSrc": "/generated/sequence/frame02.png", "width": 512, "height": 512 } + ], + "imageSequenceDurationMs": 1000 + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("legacy action layout should parse before resource validation"); - fn normalize_single_layer_without_resource(layout: &JsonValue) -> Result { - let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str())?; - if parsed.layers.len() != 1 { - return Err("expected one layer".to_string()); - } - let normalized = - normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], None)?; - if normalized.is_some() { - return Err("local sequence unexpectedly produced resource metadata".to_string()); - } - serialize_structured_canvas_layout(&parsed) - .map_err(|error| format!("local sequence serialization failed: {error}")) - } + let error = normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], None) + .expect_err("formal structured canvas layers must reference a project resource"); - #[test] - fn structured_canvas_self_contained_local_sequence_round_trips_and_hashes_all_metadata() { - let layout = self_contained_local_sequence_layout(); - let rebuilt = normalize_single_layer_without_resource(&layout) - .expect("valid self-contained local sequence should normalize"); - assert_eq!( - canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), - canonical_layout_sha256(rebuilt.as_str()).expect("rebuilt hash") - ); - - let mut redundant_first_frame_src = layout.clone(); - redundant_first_frame_src[0]["src"] = json!("/generated/sequence/frame01.png"); - normalize_single_layer_without_resource(&redundant_first_frame_src) - .expect("stable src equal to the first sequence frame should normalize"); - - for (pointer, changed_value) in [ - ("/0/prompt", "changed"), - ( - "/0/imageSequenceFrames/0/imageSrc", - "/generated/sequence/frame01-changed.png", - ), - ( - "/0/thumbnailSrc", - "/generated/sequence/thumbnail-changed.png", - ), - ( - "/0/previewVideoPath", - "/generated/sequence/preview-changed.mp4", - ), - ("/0/assetKind", "changed"), - ("/0/generationInputs/action", "changed"), - ] { - let mut changed = layout.clone(); - *changed - .pointer_mut(pointer) - .expect("test pointer should exist") = JsonValue::String(changed_value.to_string()); - assert_ne!( - canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), - canonical_layout_sha256(changed.to_string().as_str()).expect("changed hash"), - "{pointer} must participate in the local sequence hash" - ); - } - } - - #[test] - fn structured_canvas_self_contained_local_sequence_rejects_unstable_or_incomplete_media() { - let base = self_contained_local_sequence_layout(); - let mut invalid_layouts = Vec::new(); - for invalid_path in [ - "relative/frame.png", - "//cdn.example/frame.png", - "https://example.test/frame.png", - "/generated/frame.png?signature=secret", - "/generated/frame.png#fragment", - "data:image/png;base64,AAAA", - "blob:browser-only", - "/generated/../private/frame.png", - ] { - let mut invalid = base.clone(); - invalid[0]["imageSequenceFrames"][0]["imageSrc"] = - JsonValue::String(invalid_path.to_string()); - invalid_layouts.push(invalid); - } - for (pointer, value) in [ - ("/0/resourceId", json!("resource-not-local")), - ("/0/sourceType", json!("uploaded")), - ("/0/mediaType", json!("image")), - ("/0/imageSequenceFrames", json!([])), - ("/0/imageSequenceFrames/0/width", json!(0)), - ("/0/imageSequenceFrames/0/height", json!(-1)), - ("/0/src", json!("/generated/sequence/other.png")), - ("/0/objectKey", json!("generated/sequence/frame01.png")), - ("/0/thumbnailSrc", json!("https://example.test/thumb.png")), - ("/0/previewVideoPath", json!("/preview.mp4?expires=1")), - ] { - let mut invalid = base.clone(); - *invalid - .pointer_mut(pointer) - .expect("test pointer should exist") = value; - invalid_layouts.push(invalid); - } - let mut mismatched_frame_key = base.clone(); - mismatched_frame_key[0]["imageSequenceFrames"][0]["objectKey"] = - json!("generated/sequence/other.png"); - invalid_layouts.push(mismatched_frame_key); - for invalid in invalid_layouts { - normalize_single_layer_without_resource(&invalid) - .expect_err("invalid local sequence must fail closed"); - } + assert!(error.contains("resource")); } #[test] @@ -9015,11 +10577,11 @@ mod tests { source_resource_id: Some("source-correct".to_string()), created_at: now, updated_at: now, - asset_kind: None, + asset_kind: Some(EDITOR_CHARACTER_ANIMATION_ASSET_KIND.to_string()), generation_inputs_json: None, public_showcase_enabled: true, - image_sequence_frames_json: None, - image_sequence_duration_ms: None, + image_sequence_frames_json: Some(migration_frames("resource", false).to_string()), + image_sequence_duration_ms: Some(1_000), }; let layout = json!([{ "layerId": "layer-1", @@ -9059,16 +10621,25 @@ mod tests { .contains("sourceResourceId") ); - let mut local = parse_structured_canvas_layout( - self_contained_local_sequence_layout().to_string().as_str(), - ) - .expect("local sequence should parse"); + let local_layout = json!([{ + "layerId": "local-sequence-layer", + "resourceId": "resource-1", + "assetKind": "character-animation", + "mediaType": "image-sequence", + "imageSequenceFrames": [ + { "imageSrc": "/generated/sequence/frame01.png", "width": 512, "height": 512 }, + { "imageSrc": "/generated/sequence/frame02.png", "width": 512, "height": 512 } + ], + "imageSequenceDurationMs": 1000 + }]); + let mut local = parse_structured_canvas_layout(local_layout.to_string().as_str()) + .expect("legacy action layout should parse"); let error = normalize_structured_canvas_layer_against_resource( &mut local.layers[0], Some(&resource), ) - .expect_err("local sequence with a resource row must be ambiguous"); - assert!(error.contains("权威元数据")); + .expect_err("layout action copies must not override resource truth"); + assert!(!error.is_empty()); let resource_without_source = EditorProjectResource { source_resource_id: None, @@ -9084,56 +10655,6 @@ mod tests { .expect("legacy self-reference should also be ignored when resource truth is empty"); } - #[test] - fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() { - let layout = self_contained_local_sequence_layout(); - let parsed = parse_structured_canvas_layout(layout.to_string().as_str()) - .expect("local sequence should parse"); - let existing_source_type = parsed.layers[0].source_type.clone(); - let existing_item_json = parsed.layers[0].item_json.clone(); - - let mut moved = parsed.layers[0].clone(); - moved.x = Some(999.0); - moved.y = Some(888.0); - assert!(legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &moved, - )); - - let mut omitted_frontend_metadata = moved.clone(); - let mut item = serde_json::from_str::>( - omitted_frontend_metadata.item_json.as_str(), - ) - .expect("local item extension should parse"); - item.remove("assetKind"); - item.remove("generationInputs"); - omitted_frontend_metadata.item_json = - serde_json::to_string(&item).expect("item should serialize"); - restore_omitted_legacy_local_sequence_metadata( - existing_item_json.as_str(), - &mut omitted_frontend_metadata, - ) - .expect("frontend-omitted frozen metadata should restore"); - assert!(legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &omitted_frontend_metadata, - )); - - let mut changed_media = moved; - let mut item = - serde_json::from_str::>(changed_media.item_json.as_str()) - .expect("local item extension should parse"); - item.insert("prompt".to_string(), json!("篡改后的提示词")); - changed_media.item_json = serde_json::to_string(&item).expect("item should serialize"); - assert!(!legacy_local_sequence_extension_matches_existing_layer( - existing_source_type.as_deref(), - existing_item_json.as_str(), - &changed_media, - )); - } - fn release_fixture_option_string(value: &JsonValue) -> Result, ()> { let value = value.as_array().ok_or(())?; if value.len() != 2 { @@ -9206,11 +10727,6 @@ mod tests { .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS is required") .parse::() .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS must be an integer"); - let expected_local_sequences = std::env::var("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES") - .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES is required") - .parse::() - .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES must be an integer"); - let mut layout_paths = fs::read_dir(&fixture_dir) .expect("fixture directory should be readable") .filter_map(Result::ok) @@ -9230,7 +10746,6 @@ mod tests { let mut ok_count = 0usize; let mut conflict_count = 0usize; - let mut local_sequence_count = 0usize; for layout_path in layout_paths { let sample_name = layout_path .file_name() @@ -9267,20 +10782,6 @@ mod tests { "fixture {sample_name} raw integrity drifted" ); - for layer in &layout.layers { - let item = serde_json::from_str::>(&layer.item_json) - .unwrap_or_else(|_| panic!("fixture {sample_name} item extension is invalid")); - match validate_self_contained_legacy_local_image_sequence( - layer.resource_id.as_str(), - layer.source_type.as_deref(), - &item, - ) { - Ok(true) => local_sequence_count += 1, - Ok(false) => {} - Err(_) => panic!("fixture {sample_name} local sequence validation failed"), - } - } - let resources = fs::read_to_string(resources_path) .unwrap_or_else(|_| panic!("fixture {sample_name} resources are unreadable")); let resources = serde_json::from_str::>(&resources) @@ -9368,7 +10869,6 @@ mod tests { assert_eq!(ok_count, expected_ok); assert_eq!(conflict_count, expected_conflicts); - assert_eq!(local_sequence_count, expected_local_sequences); } #[test]