Files
opendance-web/web-server/test/seed.mjs
T

184 lines
6.1 KiB
JavaScript
Raw 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.
/**
* 演示数据播种脚本 —— 供管理端界面、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);
});