/** * 为「Windows 客户端 × Web 服务端」跨端集成验收准备一份**真实**目录: * 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM; * 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。 */ import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { dirname, join } 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'; /** 用于把真实 PMX 转成 GLB(模拟"转换只产出 GLB"的入库形态) */ const ASSIMP_BIN = process.env.ASSIMP_BIN ?? '/usr/bin/assimp'; 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; // ---------- 边界 A:不可用模型(只有 GLB、没有 VRM 容器)---------- // 形态等价于"管线只产出了 GLB":契约 model-formats §1.1 要求这类模型 // **可入库、可审核,但 usable=false,且不进入 catalog/export**(① 的硬性要求)。 let unusable = null; try { const pmxZip = `${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`; const entries = execFileSync('unzip', ['-Z1', pmxZip], { encoding: 'utf8' }) .split('\n').map((line) => line.trim()) .filter((line) => line.toLowerCase().endsWith('.pmx')); if (entries.length === 0) throw new Error('压缩包里没有 .pmx'); const pmxPath = join('/tmp', 'opendance-seed-model.pmx'); const glbPath = join('/tmp', 'opendance-seed-model.glb'); execFileSync('unzip', ['-p', pmxZip, entries[0]], { stdio: ['ignore', openSync(pmxPath, 'w'), 'ignore'] }); execFileSync(ASSIMP_BIN, ['export', pmxPath, glbPath], { stdio: 'ignore' }); const glb = readFileSync(glbPath); must(glb.subarray(0, 4).toString('ascii') === 'glTF', `Assimp 转出 GLB(${glb.length} 字节)`); const created = await request('POST', '/api/models', { form: fileForm({ name: '不可用模型(仅 GLB,未产出 VRM)', ...SOURCE }, 'unusable.glb', glb, 'model/gltf-binary'), }); must(created.status === 201, '上传仅 GLB 的模型', `${created.status} ${JSON.stringify(created.body)}`); const id = created.body.id; must(created.body.format === 'glb', `入库格式为 glb(实际 ${created.body.format})`); must(created.body.usable === false, '该模型标记为不可用(usable=false)', JSON.stringify(created.body.unusableReason)); const approve = await request('POST', `/api/reviews/model/${id}/approve`, { json: { reviewer: 'qa', comment: '通过但不设为默认' }, }); must(approve.status === 200 || approve.status === 201, '审核通过(不设为默认模型)', String(approve.status)); const detail = await request('GET', `/api/models/${id}`); const reason = String(detail.body?.unusableReason ?? ''); const drivable = detail.body?.stats?.drivable === true; must(reason.includes('未产出 VRM'), `不可用原因说明"未产出 VRM":${reason}`); // 归因准确性(T-WEB-017):骨架已达标时**不得**说"humanoid 不达标" if (drivable) { must(!reason.includes('humanoid 不达标'), `骨架达标(drivable=true,humanoid ${detail.body?.stats?.humanoidMapped}/${detail.body?.stats?.humanoidTotal})` + `时不得归因于 humanoid:${reason}`); } else { console.log(` · 该 GLB 的 drivable=false(humanoid ${detail.body?.stats?.humanoidMapped}/` + `${detail.body?.stats?.humanoidTotal}),原因归因于 humanoid 属正确`); } unusable = { id, reason, drivable }; rmSync(pmxPath, { force: true }); rmSync(glbPath, { force: true }); } catch (error) { console.log(` · 跳过不可用模型场景(${error.message})—— 需要 unzip 与 ASSIMP_BIN`); } // ---------- 边界 B:场景 config 只给最少字段,服务端必须补齐默认 ---------- const minimalConfig = { physics: { gravity: { y: -9.8 } } }; const minimalScene = await request('POST', '/api/scenes', { form: fileForm( { name: '最简配置场景(只有 gravity)', config: JSON.stringify(minimalConfig), ...SOURCE }, 'minimal.glb', glbBytes, 'model/gltf-binary', ), }); must(minimalScene.status === 201, '上传最简配置场景', `${minimalScene.status} ${JSON.stringify(minimalScene.body)}`); const minimalSceneId = minimalScene.body.id; // ---------- 审核发布 ---------- for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId], ['scene', minimalSceneId]]) { // 契约:**上传即 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 modelsInCatalog = (catalog.body?.models ?? []); const nonVrm = modelsInCatalog.filter((m) => m.format !== 'vrm'); must(nonVrm.length === 0, '目录里的模型全是 VRM(不可用模型不进目录)', JSON.stringify(nonVrm)); if (unusable) { must(!modelsInCatalog.some((m) => m.id === unusable.id), `不可用模型 #${unusable.id} 不在目录里`); must(catalog.body?.defaultModelId === modelId, `defaultModelId 仍是可用的 VRM 模型(实际 ${catalog.body?.defaultModelId},期望 ${modelId})`); } // 最简配置场景:服务端必须补齐默认值(契约 0.5.0 的字段边界) const minimalEntry = (catalog.body?.scenes ?? []).find((s) => s.id === minimalSceneId); must(minimalEntry !== undefined, `最简配置场景 #${minimalSceneId} 在目录里`); const mc = minimalEntry?.config ?? {}; must(mc.physics?.gravity?.y === -9.8, `重力保留(${JSON.stringify(mc.physics?.gravity)})`); must(mc.physics?.colliders === 'auto', `碰撞体补默认 auto(实际 ${mc.physics?.colliders})`); must(mc.physics?.ground?.friction === 0.6 && mc.physics?.ground?.slopeLimit === 45, `地面补默认 0.6/45°(实际 ${JSON.stringify(mc.physics?.ground)})`); must(mc.physics?.wind?.gustAmplitude === 0 && mc.physics?.wind?.base?.x === 0, `风补默认(静风):${JSON.stringify(mc.physics?.wind)}`); must(mc.camera?.mode === 'follow', `相机补默认 follow(实际 ${mc.camera?.mode})`); must(mc.environment?.ambientIntensity === 1, `环境光补默认 1(实际 ${mc.environment?.ambientIntensity})`); must(mc.spawnPoints === undefined || mc.spawnPoints.length <= 1, `出生点补默认(最多一条):${JSON.stringify(mc.spawnPoints)}`); 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 ?? {}, unusableModelId: unusable?.id ?? null, unusableDrivable: unusable?.drivable ?? null, minimalSceneId, minimalSceneExpect: { gravityY: -9.8, colliders: 'auto', groundFriction: 0.6, groundSlopeLimit: 45, windCalm: true, cameraMode: 'follow', ambientIntensity: 1, }, }, null, 2)); console.log(`\n期望值已写入 ${EXPECT_PATH}`); }; run().catch((e) => { console.error('ERR', e); process.exit(1); });