正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:
1. 不可用模型:用 Assimp 把真实 PMX 转成 GLB 后入库(等价"管线只产出 GLB"),
Web 侧断言 usable=false、原因准确(骨架达标时不得说"humanoid 不达标")、
不在 catalog/export、defaultModelId 不变;Windows 侧(包内探针)断言
bundle.models 里没有任何非 VRM 模型、按 id 找不到该模型、defaultModelId 仍指向可用 VRM。
2. 场景 config 只给 {physics:{gravity:{y:-9.8}}}:Web 侧断言服务端补齐默认
(碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条);
Windows 侧断言客户端解析出的**同一组默认值**逐项一致。
实测(Web v0.7.5 × Windows v0.2.5 包内 pck,真实 MySQL):
✓ usable=false 且原因为「未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标)…」
✓ 目录里的模型全是 VRM/不可用模型 #2 不在目录里/defaultModelId 仍为 1
✓ 最简配置:重力 -9.8 保留,碰撞体 auto、地面 0.6/45°、静风、相机 follow、
环境光 1.0、出生点 1 条,两端逐项一致
跨端集成验收全部通过。
顺带修掉播种脚本自身的顺序错误(把依赖 catalog 的断言插到了 catalog 取回之前),
以及把断言改成「先取目录再断言」。不影响发布产物(build/ 已排除出 pck)。
242 lines
13 KiB
JavaScript
242 lines
13 KiB
JavaScript
/**
|
||
* 为「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); });
|