/** * 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录: * 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM; * 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。 */ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; const API = process.env.API_BASE ?? 'http://127.0.0.1:3100'; // 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/) const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR ?? new URL('../../../示例文件', import.meta.url).pathname; const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json'; const SOURCE = { sourceType: 'original', sourceName: '跨端集成验收素材', sourceUrl: 'https://example.com/opendance', license: 'MIT', author: 'OpenDance QA', }; 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 [k, v] of Object.entries(fields)) form.append(k, String(v)); form.append('file', new Blob([content], { type: mime }), filename); return form; } const must = (cond, what, extra = '') => { if (!cond) { console.error(`✗ ${what} ${extra}`); process.exit(1); } console.log(` ✓ ${what}`); }; const run = async () => { // ---------- 歌曲 ---------- const song = await request('POST', '/api/songs', { json: { title: '霜雪千年', artist: '乐正绫 / 洛天依', album: '跨端验收', language: 'zh', durationMs: 96700, bpm: 120, difficulty: 3, ...SOURCE }, }); must(song.status === 201, '创建曲目', `${song.status} ${JSON.stringify(song.body)}`); const songId = song.body.id; // ---------- 动作:真实 VMD ---------- const vmd = readFileSync(`${SAMPLES}/【动作】霜雪千年.vmd`); const motion = await request('POST', '/api/motions', { form: fileForm({ name: '霜雪千年(真实 VMD)', songId, autoPreprocess: false, ...SOURCE }, '霜雪千年.vmd', vmd, 'application/octet-stream'), }); must(motion.status === 201, '上传真实 VMD 动作', `${motion.status} ${JSON.stringify(motion.body)}`); const motionId = motion.body.id; must(motion.body.format === 'vmd', `识别格式 vmd(实际 ${motion.body.format})`); const pre = await request('POST', `/api/motions/${motionId}/preprocess`); must(pre.status === 200, '预处理为内部标准 33 点', `${pre.status} ${JSON.stringify(pre.body)}`); console.log(` 预处理结果:${JSON.stringify(pre.body)}`); // ---------- 模型:真实 PMX 压缩包 → VRM ---------- const pmxZip = readFileSync(`${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`); const model = await request('POST', '/api/models', { form: fileForm({ name: '初音未来(真实 PMX → VRM)', ...SOURCE }, 'miku.zip', pmxZip, 'application/zip'), }); must(model.status === 201, '上传真实 PMX 压缩包', `${model.status} ${JSON.stringify(model.body)}`); const modelId = model.body.id; must(model.body.format === 'vrm', `转换产物格式 vrm(实际 ${model.body.format})`); must(model.body.usable === true, '模型可用(humanoid 达标)', JSON.stringify(model.body.unusableReason)); console.log(` 转换说明:${model.body.conversionNote}`); console.log(` humanoid:${model.body.stats?.humanoidMapped}/${model.body.stats?.humanoidTotal},可驱动=${model.body.stats?.drivable}`); // ---------- 场景:真实 GLB(复用刚转换出的 VRM 几何)+ 物理参数 ---------- const glbRes = await fetch(`${API}${model.body.fileUrl}`); const glbBytes = Buffer.from(await glbRes.arrayBuffer()); must(glbBytes.subarray(0, 4).toString('ascii') === 'glTF', `取回模型 GLB(${glbBytes.length} 字节)`); // 作为场景上传时补一个按客户端 colliders:auto 约定命名的碰撞节点不方便,直接用 mesh 模式 const sceneConfig = { physics: { gravity: { x: 0, y: -3.5, z: 0 }, wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, gustDirection: { x: 1, y: 0, z: 0 }, space: 'world', }, ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 }, colliders: 'mesh', }, environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 }, spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }], camera: { mode: 'orbit', distance: 6, height: 2 }, }; const scene = await request('POST', '/api/scenes', { form: fileForm({ name: '跨端验收场景(真实 GLB)', config: JSON.stringify(sceneConfig), ...SOURCE }, 'stage.glb', glbBytes, 'model/gltf-binary'), }); must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`); const sceneId = scene.body.id; // ---------- 审核发布 ---------- for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) { // 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的 const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } }); must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status)); const approve = await request('POST', `/api/reviews/${kind}/${id}/approve`, { json: kind === 'model' ? { reviewer: 'qa', comment: '通过', setPrimary: true } : { reviewer: 'qa', comment: '通过' }, }); must(approve.status === 200 || approve.status === 201, `审核通过 ${kind}#${id}`, `${approve.status} ${JSON.stringify(approve.body)}`); } // ---------- 目录导出 ---------- const health = await request('GET', '/health'); must(health.status === 200, 'GET /health(取契约版本写进期望值)', String(health.status)); const catalog = await request('GET', '/api/catalog/export'); must(catalog.status === 200, 'GET /api/catalog/export', String(catalog.status)); const data = catalog.body; console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`); must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])'); const version = await request('GET', '/api/catalog/version'); console.log(` 目录指纹:${JSON.stringify(version.body)}`); mkdirSync(dirname(EXPECT_PATH), { recursive: true }); writeFileSync(EXPECT_PATH, JSON.stringify({ songId, motionId, modelId, sceneId, sceneConfig, motionFrames: pre.body?.frameCount ?? null, // 注意:**上传响应**里的 keypointFormat 还是 null(预处理后才写入), // 必须取**预处理响应**的值 —— 这里踩过一次,期望值写成了 null。 keypointFormat: pre.body?.keypointFormat ?? null, catalogRevision: version.body?.revision ?? null, contractVersions: health.body?.contractVersion ?? {}, }, null, 2)); console.log(`\n期望值已写入 ${EXPECT_PATH}`); }; run().catch((e) => { console.error('ERR', e); process.exit(1); });