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