184 lines
6.1 KiB
JavaScript
184 lines
6.1 KiB
JavaScript
/**
|
||
* 演示数据播种脚本 —— 供管理端界面、Windows 客户端联调与集成测试使用。
|
||
*
|
||
* 与 smoke.mjs 不同:本脚本**保留**数据并把三类资产都置为 published,
|
||
* 使 GET /api/catalog/export 能返回可用于客户端联调的完整目录。
|
||
*
|
||
* 用法:
|
||
* API_BASE=http://127.0.0.1:3000 node test/seed.mjs
|
||
*
|
||
* 幂等:重复执行只会追加新记录,不会破坏既有数据。
|
||
*/
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const API = process.env.API_BASE ?? 'http://127.0.0.1:3000';
|
||
|
||
const SOURCE = {
|
||
sourceType: 'original',
|
||
sourceName: 'OpenDance 官方示例库',
|
||
sourceUrl: 'https://example.com/opendance/samples',
|
||
license: 'CC-BY-4.0',
|
||
author: 'OpenDance Studio',
|
||
};
|
||
|
||
async function request(method, path, { json, form } = {}) {
|
||
const init = { method };
|
||
if (json !== undefined) {
|
||
init.headers = { 'Content-Type': 'application/json' };
|
||
init.body = JSON.stringify(json);
|
||
}
|
||
if (form !== undefined) init.body = form;
|
||
const res = await fetch(`${API}${path}`, init);
|
||
const text = await res.text();
|
||
let body = null;
|
||
try {
|
||
body = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
body = text;
|
||
}
|
||
return { status: res.status, body };
|
||
}
|
||
|
||
function fileForm(fields, filename, content, mime = 'application/octet-stream') {
|
||
const form = new FormData();
|
||
for (const [key, value] of Object.entries(fields)) form.append(key, String(value));
|
||
form.append('file', new Blob([content], { type: mime }), filename);
|
||
return form;
|
||
}
|
||
|
||
function buildGlb(json) {
|
||
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||
const padded = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
|
||
const header = Buffer.alloc(12);
|
||
header.write('glTF', 0, 'ascii');
|
||
header.writeUInt32LE(2, 4);
|
||
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||
const chunk = Buffer.alloc(8);
|
||
chunk.writeUInt32LE(padded.length, 0);
|
||
chunk.writeUInt32LE(0x4e4f534a, 4);
|
||
return Buffer.concat([header, chunk, padded]);
|
||
}
|
||
|
||
async function publish(targetType, id, reviewer = 'seed') {
|
||
const submit = await request('POST', `/api/reviews/${targetType}/${id}/submit`, { json: { reviewer } });
|
||
if (submit.status === 409) return submit; // 已处于目标状态
|
||
const approve = await request('POST', `/api/reviews/${targetType}/${id}/approve`, {
|
||
json: { reviewer, comment: '演示数据自动发布' },
|
||
});
|
||
return approve;
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`\n== 播种演示数据 @ ${API} ==\n`);
|
||
|
||
const health = await request('GET', '/health');
|
||
if (health.status !== 200) {
|
||
console.error('服务端不可达,先启动:cd web-server && npm run start:prod');
|
||
process.exit(1);
|
||
}
|
||
console.log(`服务端正常,数据库 ${health.body.database.server}`);
|
||
|
||
// ---------- 曲目 ----------
|
||
const song = await request('POST', '/api/songs', {
|
||
json: {
|
||
title: '示例曲目 · 小星星(Demo)',
|
||
artist: 'OpenDance 示例',
|
||
album: '客户端联调样例',
|
||
language: 'instrumental',
|
||
durationMs: 96000,
|
||
bpm: 100,
|
||
difficulty: 2,
|
||
tags: ['示例', '联调', '儿童'],
|
||
...SOURCE,
|
||
},
|
||
});
|
||
if (song.status !== 201) {
|
||
console.error('创建曲目失败:', song.status, song.body);
|
||
process.exit(1);
|
||
}
|
||
const songId = song.body.id;
|
||
console.log(` 曲目 #${songId} ${song.body.title}`);
|
||
|
||
await publish('song', songId);
|
||
|
||
// ---------- 动作(BVH + 预处理) ----------
|
||
const bvh = readFileSync(join(here, 'fixtures', 'sample.bvh'));
|
||
const motion = await request('POST', '/api/motions', {
|
||
form: fileForm(
|
||
{ name: '示例动作 · 挥手深蹲(Demo)', songId, fps: 30, ...SOURCE },
|
||
'sample.bvh',
|
||
bvh,
|
||
'text/plain',
|
||
),
|
||
});
|
||
if (motion.status !== 201) {
|
||
console.error('上传动作失败:', motion.status, motion.body);
|
||
process.exit(1);
|
||
}
|
||
const motionId = motion.body.id;
|
||
console.log(` 动作 #${motionId} ${motion.body.name}`);
|
||
|
||
const pre = await request('POST', `/api/motions/${motionId}/preprocess`);
|
||
if (pre.status !== 200) {
|
||
console.error('预处理失败:', pre.status, pre.body);
|
||
process.exit(1);
|
||
}
|
||
console.log(` 预处理完成:${pre.body.keypointFrames} 帧 × 33 点,fps=${pre.body.fps}`);
|
||
await publish('motion', motionId);
|
||
|
||
// ---------- 模型(VRM 优先) ----------
|
||
const vrm = buildGlb({
|
||
asset: { version: '2.0', generator: 'OpenDance seed' },
|
||
extensions: { VRMC_vrm: { specVersion: '1.0' } },
|
||
meshes: [],
|
||
nodes: [],
|
||
scenes: [{ nodes: [] }],
|
||
scene: 0,
|
||
});
|
||
const model = await request('POST', '/api/models', {
|
||
form: fileForm(
|
||
{ name: '示例模型 · VRM 1.0 占位(Demo)', isPrimary: true, ...SOURCE },
|
||
'demo-avatar.vrm',
|
||
vrm,
|
||
'model/gltf-binary',
|
||
),
|
||
});
|
||
if (model.status !== 201) {
|
||
console.error('上传模型失败:', model.status, model.body);
|
||
process.exit(1);
|
||
}
|
||
const modelId = model.body.id;
|
||
console.log(` 模型 #${modelId} ${model.body.name}(vrmVersion=${model.body.vrmVersion})`);
|
||
await publish('model', modelId);
|
||
|
||
// ---------- 校验目录导出 ----------
|
||
const bundle = await request('GET', '/api/catalog/export');
|
||
const summary = {
|
||
songs: bundle.body.songs.length,
|
||
motions: bundle.body.motions.length,
|
||
models: bundle.body.models.length,
|
||
defaultModelId: bundle.body.defaultModelId,
|
||
};
|
||
console.log('\n已发布目录:', JSON.stringify(summary));
|
||
|
||
const scorable = bundle.body.motions.filter((m) => m.preprocessState === 'done');
|
||
if (scorable.length === 0) {
|
||
console.error('目录中没有可评分动作,客户端集成测试将被跳过');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('\n完成。Windows 客户端现在可联调:');
|
||
console.log(` 可评分动作:${scorable.map((m) => `#${m.id}(${m.keypointFrames} 帧)`).join(', ')}`);
|
||
console.log(` 默认模型:#${bundle.body.defaultModelId}`);
|
||
console.log('');
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('播种失败:', err);
|
||
process.exit(1);
|
||
});
|