Files
Genarrative/scripts/spacetime-normalize-editor-character-actions.mjs
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

254 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import {
callSpacetimeProcedure,
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 <name> --server <name-or-url> [--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 || '',
token: env.GENARRATIVE_SPACETIME_TOKEN || '',
useHttp: false,
};
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 === '--token') {
options.token = readValue();
} else if (arg === '--chunk-size') {
options.chunkSize = parsePositiveInteger(readValue(), arg);
} else if (arg === '--apply') {
options.apply = true;
} else if (arg === '--use-http') {
options.useHttp = true;
} else if (arg === '--no-config' || arg === '--anonymous') {
options.passthrough.push(arg);
} else if (arg === '--help' || arg === '-h') {
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_sha_256: 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 options.useHttp
? callSpacetimeProcedure(options, PROCEDURE_NAME, input)
: 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 seenCursors = new Set();
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;
}
const nextCursor = dryRun.has_more ? dryRun.next_cursor : null;
if (dryRun.has_more && !nextCursor) {
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
}
if (nextCursor && seenCursors.has(nextCursor)) {
throw new Error(`${scope} scope 返回了重复的 next_cursor: ${nextCursor}。`);
}
if (nextCursor) {
seenCursors.add(nextCursor);
}
cursor = nextCursor;
} 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。');
}
if (options.useHttp && !options.token) {
throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。');
}
const migration = await scanScopes(options, { apply: options.apply });
const verification = options.apply
? 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;
});
}