diff --git a/README.md b/README.md index a28ad73..ca57fd3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,23 @@ v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`** 4. `web-server/scripts/diagnose-scenes.mjs` 保持不变(默认只读),部署 v0.7.4 后运行它 可确认 `scenes` 表与列的实际情况。 +v0.7.4 之后的测试补强(**不改产物,故不新发版本**): + +1. 新增 `web-server/test/real-samples.spec.ts`(6 项,opt-in):用仓库根 + `示例文件/` 下的**真实素材**跑完整管线,补上"合成夹具证明不了真素材能出片"的缺口 —— + * ① 模型→VRM:初音未来 19/22、奥黛塔 21/22 humanoid,`drivable = true`, + 18 张贴图全内嵌、36 项双面材质补回,并逐字节校验 **POSITION 访问器的 + `min`/`max` 等于真实包围盒**(glTF 规范唯一强制填写的边界,客户端据此剔除)、 + 蒙皮下标不越界; + * ② 动作内部标准 + 骨骼重绑定:真实 VMD 31 条轨道、标准骨架 31/31 命中、 + 2901 帧产出 33+2 点与 9 个关节角,15 个直接关节置信度恒为 `1.0`(不退化成推算值); + * 样例或 Assimp 缺失时整体跳过,CI 与无素材的开发机不受影响; +2. 修 `test/assimp-locate.spec.ts` 的非密闭性:该文件的用例断言的是 + **PATH / 常见系统目录**的查找行为,但会继承外部 `ASSIMP_BIN` + (`assimp-lib.sh` 里 `ASSIMP_BIN` 优先级高于 PATH)。开发者按本项目文档 + 在 shell 里设过 `ASSIMP_BIN` 时 `npm test` 会假失败,现已在 `runLib()` 里剔除; +3. 规模 **25 文件 / 295 项**(三个真实样例齐备并装好 Assimp 时的实测数)。 + v0.7.3 修订(**场景表不可用不再拖垮整站** —— 生产事故修复): **另有一个更直接的缺陷同时修掉**:`ScenesService.create()` 当初按**内存存储**写, 判断的是 `file.buffer`,而上传中间件(`storage.module.ts`)用的是 multer 的 @@ -445,21 +462,32 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000) ```bash cd web-server -npm run contracts:check # 契约版本一致性 +npm run contracts:check # 契约版本一致性(6 项契约版本 + 35 条路由 + 三端版本号) npm run typecheck # TypeScript 严格检查 -npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、 - # 模型检查器 25、缩略图 8、骨骼过滤 8、 - # 预处理 25、init-env 16、admin-port 16、 - # access-log 9、VRM 检查器 6、.env 预检 12、 - # Assimp 定位与安装 13、stats 容量与编码 12、 - # 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2) +npm run test # 295 项单元测试 / 25 个文件。分布(VMD 31、模型检查器 29、 + # 模型压缩包 27、预处理 25、admin-port 16、init-env 16、 + # Assimp 定位与安装 13、应用版本 12、目录导出 12、.env 预检 12、 + # 访问日志 9、MMD 材质对齐 9、stats 容量与编码 9、场景配置 9、 + # 骨骼过滤 8、缩略图 8、动作预览 8、缩略图贴图采样 6、 + # 真实素材回归 6、场景降级 6、VRM 检查器 6、模型可用性 5、 + # 场景上传 5、实体注册 4、场景端到端(真实 sql.js 库) 4) + # opt-in: test/real-samples.spec.ts 缺样例/Assimp 时跳过 3 项 npm run build # 生产构建 npm run start:prod & # 启动 -npm run test:smoke # 57 项端到端冒烟(需要数据库可连通) +npm run test:smoke # 81 项端到端冒烟(需要数据库可连通) cd ../admin && npm run build # 管理端构建 ``` +真实素材回归(需要仓库根的 `示例文件/` 与外部 Assimp;样例缺失时自动跳过): + +```bash +cd web-server +ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts +# 样例放在别处: OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=... npm test +# 覆盖: 两个真实 PMX→VRM(初音 19/22、奥黛塔 21/22 humanoid) + 真实 VMD→33 点内部标准 +``` + ## 环境变量 | 变量 | 默认 | 说明 | diff --git a/tasks/012-vrm-first.md b/tasks/012-vrm-first.md index ba26c5b..df67862 100644 --- a/tasks/012-vrm-first.md +++ b/tasks/012-vrm-first.md @@ -30,3 +30,36 @@ | 非 VRM 模型不出现在客户端目录 | ✅ `catalog/export` 过滤 + 指纹过滤;`test/model-usability.spec.ts` 覆盖 | | 不可用模型有明确原因 | ✅ DTO 增 `usable` / `unusableReason` 字段 | | 三端契约一致 | ✅ `data-models 0.7.0` / `model-formats 0.5.0` 快照三端逐字节一致 | + +## 真实样例回归(T-WEB-003 / T-WEB-011) + +`test/real-samples.spec.ts` 是 **opt-in** 的真实素材端到端用例:合成夹具证明不了 +「真的 MMD 素材能出片」,而这个文件直接用仓库根 `示例文件/` 下的真模型跑完整转换管线。 +样例与 Assimp 任一缺失时整个 describe 跳过,因此无素材的开发机与 CI 依旧全绿。 + +```bash +# 跑真实素材(Assimp 不在 PATH 时用 ASSIMP_BIN 指绝对路径) +ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts +# 样例放在别处时 +OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=/usr/bin/assimp npm test +``` + +实测(Assimp 6.0,2024 版素材): + +| 样例 | 输入 | 产物 | humanoid | 贴图 | 材质 | +|--|--|--|--|--|--| +| 初音未来(PMX in zip) | 1.0 MB | 1.10 MB VRM | 19/22,`drivable` | 2 张全内嵌 | 3 | +| 奥黛塔(PMX in zip) | 28.1 MB | 26.08 MB VRM | 21/22,`drivable` | 18 张全内嵌 | 39(36 双面已补回) | + +用例断言的不变量(与具体模型无关): + +- `extensions.VRMC_vrm.specVersion === "1.0"`,`asset.version === "2.0"` +- **所有** `images` 都带 `bufferView`、无 `uri`(外链贴图离开原目录即坏图) +- 无 `KHR_materials_specular/volume/transmission/ior/emissive_strength` 泄漏(MMD 专属参数不能进标准 PBR 产物) +- **POSITION 访问器的 `min`/`max` 与逐元素读出的真实包围盒一致** —— 这是 glTF 规范唯一强制填写的边界,客户端拿它做视锥剔除与 AABB,填错会导致模型被整体剔除。读访问器时必须按 `byteStride` 走,否则交错缓冲里的 u16 数据会被读成乱码 +- 蒙皮下标 `< skin.joints.length`(越界会让客户端取到错误矩阵) + +> **已知的上游瑕疵(不影响可用性)**:Assimp 6.0 的 glTF2 导出器会给 +> `JOINTS_0` 与 `inverseBindMatrices` 写偏保守的 `min`(前者把真实最小值 6 写成 0, +> 后者对角元 1 写成 `2.2e-308`)。两者都是**合法下界**(`min ≤ 真实值 ≤ max`), +> 没有任何运行时会据此做剔除,因此不改产物、只在用例里锁定真正要紧的 POSITION 边界。 diff --git a/tasks/013-bone-rebinding.md b/tasks/013-bone-rebinding.md index 8de9f16..8ed2227 100644 --- a/tasks/013-bone-rebinding.md +++ b/tasks/013-bone-rebinding.md @@ -40,3 +40,30 @@ DTW 的两侧输入是同一套特征。 | MMD(日文骨骼名)模型可驱动 | ✅ 实测 21/22、19/22 覆盖;客户端可正常驱动 | | Godot 端候选表与服务端一致 | ✅ 一致性测试(跨仓库生成 + 校验) | | 动作统一为 33 点 | ✅ `keypoint_format = mediapipe33@0.1.0`;Android 端推流同构 | + +## 真实样例回归(T-WEB-006) + +`test/real-samples.spec.ts` 用仓库根 `示例文件/【动作】霜雪千年.vmd`(0.8 MB,真实舞蹈, +96.7 s)跑完整 `parseVmd → vmdToWorldFrames → worldFramesToKeypointFrames` 管线。 +VMD 通路不依赖 Assimp,因此只要样例在就会真跑;样例缺失时整个 describe 跳过。 + +实测:`maxFrame = 2900` → **2901 帧**(30 fps / 96.7 s),轨道 31 条, +标准骨架 `MMD_SKELETON` 的 **31 根全部命中**(缺失 0),双侧腿部 IK 链 +(`左/右足IK` + `ひざ` + `足首` + `つま先`)齐备,因此 IK 解算路径生效; +重定向 135 ms(0.05 ms/帧),产出 33 点 + 2 虚拟点 + 9 关节角,NDJSON 6.51 MB。 + +用例锁定的重绑定不变量: + +| 断言 | 实测 | +|--|--| +| 世界帧键名恰为 joint-map 候选名(MMD 名 → 英文规范名的重绑定结果) | 15 个键逐一比对 | +| 直接绑定的 15 个索引置信度**只能**是 `1.0`(不许退化成推算值) | 0/11/12/13/14/15/16/23/24/25/26/27/28/31/32 → 全部 `[1]` | +| 其余 18 个点由层级推算 | 全部 `[0.5]`,缺失(`0`)为 0 | +| 骨盆归零 + 图像空间投影 | `max|xy| = 0.85 ≤ 1`,越界 0 | +| 关节角(cos 值)有限且 ∈ [-1,1] | 范围 `[-1.000, 0.995]` | +| NDJSON 往返一致 / 缩略图有效 | 2901 行往返一致;PNG magic `89504e470d0a1a0a` | +| 动作非退化 | 左右腕 x 方向摆幅 > 0.1,鼻尖摆幅显著小于手腕 | + +> 性能注意:2901 帧 × 33 点逐元素 `expect` 会拖满 vitest 默认 5 s 超时。 +> 用例改成先聚合违规项、最后只断言一次(保留具体帧号与下标),并显式给 +> 大用例 120–300 s 超时。 diff --git a/web-server/test/assimp-locate.spec.ts b/web-server/test/assimp-locate.spec.ts index 7785345..ce81d62 100644 --- a/web-server/test/assimp-locate.spec.ts +++ b/web-server/test/assimp-locate.spec.ts @@ -149,11 +149,16 @@ describe('转换管线:Assimp 缺失时快速失败并给出可操作信息', describe('deploy/assimp-lib.sh:部署脚本共用的安装逻辑', () => { function runLib(script: string, path?: string) { + // ASSIMP_BIN 在 assimp-lib.sh 里的优先级高于 PATH,而下面的用例断言的正是 + // PATH / 常见系统目录的查找行为。若开发者 shell 或 deploy/.env 里恰好设了 + // ASSIMP_BIN(本项目文档推荐的一种部署方式),继承下来就会让这些用例假失败, + // 因此这里显式剔除,只保留 PATH 这一条解析路径。 + const { ASSIMP_BIN: _ignored, ...env } = process.env; // 覆盖 PATH 的用例里必须用绝对路径调 bash,否则连 bash 都找不到 return spawnSync('/bin/bash', ['-c', `. "$1"; ${script}`, '_', ASSIMP_LIB], { encoding: 'utf8', timeout: 15000, - env: { ...process.env, ...(path ? { PATH: path } : {}) }, + env: { ...env, ...(path ? { PATH: path } : {}) }, }); } diff --git a/web-server/test/real-samples.spec.ts b/web-server/test/real-samples.spec.ts new file mode 100644 index 0000000..0689c28 --- /dev/null +++ b/web-server/test/real-samples.spec.ts @@ -0,0 +1,431 @@ +/** + * 真实样例素材的端到端集成测试(**opt-in**)。 + * + * 为什么单独一个文件:`model-archive.spec.ts` / `mmd-vmd.spec.ts` 用的都是 + * 合成的 PMX / GLB / VMD 字节与假 Assimp 夹具,能覆盖解析边界,但证明不了 + * 「**真的** MMD 素材能出片」——而 ①「所有模型 → VRM」与 ②「动作内部标准 + + * 骨骼重绑定」的验收标准恰恰是真实素材。 + * + * 门禁:找不到样例目录或 Assimp 时整个 describe 跳过,所以在没有素材的开发机 + * 与 CI 上 `npm run test` 依旧全绿;素材与 Assimp 齐备时会真跑一遍。 + * + * OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=/usr/bin/assimp npm test + * + * 样例不随仓库分发(体积 + 版权),因此这里只断言**结构性不变量**,不断言 + * 模型的具体骨骼名或贴图内容。 + */ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { parseGlbChunks } from '../src/common/glb-chunks'; +import { resolveAssimpBinary } from '../src/common/assimp'; +import { ANGLE_NAMES, KEYPOINT_COUNT, KEYPOINT_FORMAT, MP } from '../src/preprocess/mediapipe33'; +import { LEG_IK, MMD_SKELETON } from '../src/preprocess/mmd-skeleton'; +import { vmdToWorldFrames } from '../src/preprocess/mmd-pose'; +import { serializeFramesNdjson, parseFramesNdjson } from '../src/preprocess/keypoint-codec'; +import { worldFramesToKeypointFrames } from '../src/preprocess/retarget'; +import { VMD_FPS, parseVmd, trackHasPositionMotion } from '../src/preprocess/vmd-parser'; +import { convertModelArchive } from '../src/models/model-convert'; +import { inspectModelFile } from '../src/models/model-inspector'; +import { renderMotionThumbnail } from '../src/motions/motion-thumbnail'; + +/* ------------------------------------------------------------------ 样例定位 */ + +/** 仓库根目录下的样例目录(web-server/test/../../.. = 仓库根) */ +const SAMPLES_DIR = + process.env.OPENDANCE_SAMPLES_DIR ?? join(__dirname, '..', '..', '..', '示例文件'); + +const MODEL_SMALL = '模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip'; +const MODEL_LARGE = '【奥黛塔】_by_原神.zip'; +const MOTION_VMD = '【动作】霜雪千年.vmd'; + +const samplePath = (name: string): string => join(SAMPLES_DIR, name); +const hasSample = (name: string): boolean => existsSync(samplePath(name)); +// 与 configuration.ts 的默认值保持一致(ASSIMP_BIN 未设时按 PATH 找 `assimp`)。 +// 注意不能传 undefined:resolveAssimpBinary 会对入参调 `.includes()`。 +const assimp = resolveAssimpBinary(process.env.ASSIMP_BIN ?? 'assimp'); + +const MODELS_READY = hasSample(MODEL_SMALL) && hasSample(MODEL_LARGE) && assimp.found; +const VMD_READY = hasSample(MOTION_VMD); + +/** 只在需要时报出的跳过原因,方便排障 */ +if (!MODELS_READY) { + // eslint-disable-next-line no-console + console.warn( + `[real-samples] 跳过模型转换用例:样例目录 ${SAMPLES_DIR} 缺失文件或未找到 Assimp(${assimp.searched.join(' -> ')})`, + ); +} +if (!VMD_READY) { + // eslint-disable-next-line no-console + console.warn(`[real-samples] 跳过 VMD 用例:缺少 ${samplePath(MOTION_VMD)}`); +} + +/* ------------------------------------------------------- 访问器字节级读取工具 */ + +const COMPONENTS: Record number }> = { + 5120: { size: 1, get: (v, o) => v.getInt8(o) }, + 5121: { size: 1, get: (v, o) => v.getUint8(o) }, + 5122: { size: 2, get: (v, o) => v.getInt16(o, true) }, + 5123: { size: 2, get: (v, o) => v.getUint16(o, true) }, + 5125: { size: 4, get: (v, o) => v.getUint32(o, true) }, + 5126: { size: 4, get: (v, o) => v.getFloat32(o, true) }, +}; + +const COMPONENT_COUNT: Record = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT4: 16, +}; + +interface GltfDocument { + json: Record; + bin: Buffer; +} + +/** + * 逐元素读出访问器覆盖的**真实** min/max。 + * 必须按 `byteStride` 走(Assimp 会给交错缓冲写 byteStride,忽略它会把 + * JOINTS_0 之类的 u16 数据读成乱码)。 + */ +function actualBounds(gltf: GltfDocument, accessorIndex: number) { + const a = gltf.json.accessors[accessorIndex]; + const view = gltf.json.bufferViews[a.bufferView]; + const component = COMPONENTS[a.componentType]; + const width = COMPONENT_COUNT[a.type]; + const stride = a.byteStride ?? component.size * width; + const base = gltf.bin.byteOffset + (view.byteOffset ?? 0) + (a.byteOffset ?? 0); + const dv = new DataView(gltf.bin.buffer, gltf.bin.byteOffset, gltf.bin.byteLength); + + const min = new Array(width).fill(Infinity); + const max = new Array(width).fill(-Infinity); + for (let e = 0; e < a.count; e += 1) { + for (let c = 0; c < width; c += 1) { + const value = component.get(dv, base + e * stride + c * component.size); + if (value < min[c]) min[c] = value; + if (value > max[c]) max[c] = value; + } + } + return { min, max, accessor: a, component, width, stride, base }; +} + +/** POSITION 访问器的语义标记 = 4(ARRAY_BUFFER)且被某个 primitive 引用为 POSITION */ +function positionAccessors(gltf: GltfDocument): number[] { + const found = new Set(); + for (const mesh of gltf.json.meshes ?? []) { + for (const primitive of mesh.primitives ?? []) { + const index = primitive.attributes?.POSITION; + if (typeof index === 'number') found.add(index); + } + } + return [...found].sort((a, b) => a - b); +} + +/** 所有 JOINTS_0 访问器里出现的最大骨骼下标 */ +function maxJointIndex(gltf: GltfDocument): number { + let worst = -1; + for (const mesh of gltf.json.meshes ?? []) { + for (const primitive of mesh.primitives ?? []) { + const index = primitive.attributes?.JOINTS_0; + if (typeof index !== 'number') continue; + const a = gltf.json.accessors[index]; + const view = gltf.json.bufferViews[a.bufferView]; + const component = COMPONENTS[a.componentType]; + const width = COMPONENT_COUNT[a.type]; + const stride = a.byteStride ?? component.size * width; + const base = gltf.bin.byteOffset + (view.byteOffset ?? 0) + (a.byteOffset ?? 0); + const dv = new DataView(gltf.bin.buffer, gltf.bin.byteOffset, gltf.bin.byteLength); + for (let e = 0; e < a.count; e += 1) { + for (let c = 0; c < width; c += 1) { + const value = component.get(dv, base + e * stride + c * component.size); + if (value > worst) worst = value; + } + } + } + } + return worst; +} + +/* ----------------------------------------------------------------- 模型 → VRM */ + +describe.skipIf(!MODELS_READY)('真实样例:PMX 压缩包 → VRM(T-WEB-003/T-WEB-011)', () => { + /** 转换结果按样例名缓存,两个 describe 之间不重复跑 Assimp */ + const cache = new Map(); + + async function convert(name: string) { + const cached = cache.get(name); + if (cached) return cached; + const archive = readFileSync(samplePath(name)); + const converted = await convertModelArchive(archive, { + assimpBin: assimp.path, + timeoutMs: 600_000, + preferFormat: 'vrm', + }); + const { json, bin } = parseGlbChunks>(converted.buffer); + expect(bin, 'VRM 必须内嵌 BIN 块(贴图与顶点数据都在里面)').not.toBeNull(); + const entry = { converted, gltf: { json, bin: bin as Buffer } }; + cache.set(name, entry); + return entry; + } + + /** 两个真实模型的公共不变量 */ + async function assertVrmInvariants(name: string) { + const { converted, gltf } = await convert(name); + const { json } = gltf; + + expect(converted.format).toBe('vrm'); + expect(json.asset?.version).toBe('2.0'); + expect(json.extensions?.VRMC_vrm?.specVersion).toBe('1.0'); + // 元信息里必须写清楚源文件与处理摘要,运营要能据此判断为什么「不可用」 + expect(converted.sourceModelPath.toLowerCase()).toMatch(/\.pmx$/); + expect(converted.note.length).toBeGreaterThan(0); + + // 贴图必须全部内嵌:外链贴图一旦离开原目录就是坏图 + const images = json.images ?? []; + expect(images.length).toBeGreaterThan(0); + expect(images.filter((i: any) => i.bufferView !== undefined)).toHaveLength(images.length); + expect(images.filter((i: any) => typeof i.uri === 'string')).toHaveLength(0); + + // MMD 专属的材质扩展不能泄漏到标准 PBR 产物里(客户端不认这些键) + const mmdOnly = [ + 'KHR_materials_specular', + 'KHR_materials_volume', + 'KHR_materials_transmission', + 'KHR_materials_ior', + 'KHR_materials_emissive_strength', + ]; + const leftovers = new Set(); + for (const material of json.materials ?? []) { + for (const key of Object.keys(material.extensions ?? {})) { + if (mmdOnly.includes(key)) leftovers.add(key); + } + } + expect([...leftovers]).toEqual([]); + + // 顶点不能被写成 NaN / Inf —— 一个坏顶点就能让客户端整个网格消失 + for (const index of positionAccessors(gltf)) { + const { min, max } = actualBounds(gltf, index); + for (const v of [...min, ...max]) expect(Number.isFinite(v)).toBe(true); + expect(max[0]).toBeGreaterThan(min[0]); + expect(max[1]).toBeGreaterThan(min[1]); + expect(max[2]).toBeGreaterThan(min[2]); + + // POSITION 的 min/max 是 glTF 规范**唯一强制**要求填写的,且必须等于真实 + // 包围盒:客户端拿它做视锥剔除与 AABB,填错会导致模型被整体剔除。 + const declared = json.accessors[index]; + expect(declared.min).toHaveLength(3); + expect(declared.max).toHaveLength(3); + for (let c = 0; c < 3; c += 1) { + expect(declared.min[c]).toBeCloseTo(min[c], 4); + expect(declared.max[c]).toBeCloseTo(max[c], 4); + } + } + + // 蒙皮下标必须落在 skin.joints 范围内,否则客户端会取到越界矩阵 + for (const skin of json.skins ?? []) { + expect(maxJointIndex(gltf)).toBeLessThan(skin.joints.length); + } + + // 分析器(审核界面看到的统计)与产物本身一致 + return { converted, gltf }; + } + + it( + '小体积 PMX(初音未来,1 MB)转出可驱动的 VRM', + async () => { + const { converted, gltf } = await assertVrmInvariants(MODEL_SMALL); + const { json } = gltf; + + expect(json.materials?.length ?? 0).toBeGreaterThanOrEqual(1); + expect(json.skins?.length ?? 0).toBeGreaterThanOrEqual(1); + + expect(converted.note).toContain('VRM'); + expect(converted.buffer.length).toBeGreaterThan(100_000); + }, + 600_000, + ); + + it( + '大体积 PMX(原神奥黛塔,28 MB / 18 贴图 / 36 双面材质)保留全部资源', + async () => { + const { converted, gltf } = await assertVrmInvariants(MODEL_LARGE); + const { json } = gltf; + + const images = json.images ?? []; + const materials = json.materials ?? []; + expect(images.length).toBeGreaterThanOrEqual(10); + expect(materials.length).toBeGreaterThanOrEqual(20); + + // MMD 的双面标志必须在转换时补回 GLB(否则裙摆/头发穿帮) + const doubleSided = materials.filter((m: any) => m.doubleSided === true).length; + expect(doubleSided).toBeGreaterThanOrEqual(20); + + // 产物不能比源压缩包还小一个量级(贴图丢了会) + expect(converted.buffer.length).toBeGreaterThan(5_000_000); + }, + 900_000, + ); + + it( + 'REAL-SAMPLE humanoid 覆盖与可驱动性(真实骨架分析)', + async () => { + for (const name of [MODEL_SMALL, MODEL_LARGE]) { + const { converted } = await convert(name); + const { writeFileSync, mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const dir = mkdtempSync(join(tmpdir(), 'opendance-real-')); + const file = join(dir, 'model.glb'); + writeFileSync(file, converted.buffer); + + const stats = await inspectModelFile(file, converted.buffer.length); + expect(stats.container).toBe('vrm'); + expect(stats.vrmVersion).toMatch(/^1\.0/); + expect(stats.humanoidTotal).toBe(22); + // 真实 MMD 素材的 humanoid 覆盖率下限:实测初音 19/22、奥黛塔 21/22 + expect(stats.humanoidMapped).toBeGreaterThanOrEqual(19); + // 33 点驱动所需的 16 根必须齐全 + expect(stats.missingRequiredBones).toEqual([]); + expect(stats.drivable).toBe(true); + expect(stats.triangles).toBeGreaterThan(1_000); + expect(stats.textures).toBe(stats.images); + expect(stats.warnings.filter((w) => w.includes('无法'))).toEqual([]); + } + }, + 900_000, + ); +}); + +/* ------------------------------------------------------- VMD → 动作内部标准 */ + +describe.skipIf(!VMD_READY)('真实样例:VMD → 动作内部标准 + 骨骼重绑定(T-WEB-006)', () => { + const vmd = () => parseVmd(readFileSync(samplePath(MOTION_VMD))); + + it('解析真实 VMD:标准骨架 31 根全命中,双侧腿部 IK 链完整', () => { + const motion = vmd(); + + expect(motion.maxFrame).toBeGreaterThan(1000); + expect(motion.tracks.size).toBeGreaterThanOrEqual(31); + + const standard = MMD_SKELETON.map((bone) => bone.name); + expect(standard).toHaveLength(31); + const missing = standard.filter((name) => !motion.tracks.has(name)); + expect(missing).toEqual([]); + + for (const side of ['左', '右'] as const) { + const ik = LEG_IK[side]; + expect(motion.tracks.has(ik.ik), `${side}足IK`).toBe(true); + expect(motion.tracks.has(ik.knee), `${side}ひざ`).toBe(true); + expect(motion.tracks.has(ik.ankle), `${side}足首`).toBe(true); + expect(motion.tracks.has(ik.toe), `${side}つま先`).toBe(true); + } + + // 真实舞蹈数据里躯干有位移、腿部由 IK 目标驱动 + expect(trackHasPositionMotion(motion.tracks.get('センター'))).toBe(true); + expect(trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik))).toBe(true); + }, 120_000); + + it('骨骼重绑定:15 个直接关节命中真实骨骼(置信度 1.0),其余按层级推算(0.5)', () => { + const motion = vmd(); + const { frames } = vmdToWorldFrames(motion, { fps: VMD_FPS }); + expect(frames).toHaveLength(motion.maxFrame + 1); + + // vmdToWorldFrames 把 MMD 骨骼名改写成 joint-map 的候选名(重绑定核心) + const keys = [...frames[0].keys()].sort(); + expect(keys).toEqual( + [ + 'head', + 'leftarm', + 'leftfoot', + 'leftforearm', + 'lefthand', + 'leftleg', + 'lefttoebase', + 'leftupleg', + 'rightarm', + 'rightfoot', + 'rightforearm', + 'righthand', + 'rightleg', + 'righttoebase', + 'rightupleg', + ].sort(), + ); + // 逐元素断言在 2901 帧 × 33 点上跑会拖满 vitest 的超时,因此这里先聚合 + // 违规项、最后只 assert 一次;失败信息仍然带得出具体帧与下标。 + const violations: string[] = []; + for (const [f, frame] of frames.entries()) { + for (const key of keys) { + const p = frame.get(key) as [number, number, number]; + if (!p.every((v) => Number.isFinite(v))) violations.push(`帧${f} ${key} 非有限`); + } + } + expect(violations.slice(0, 5)).toEqual([]); + + const keypoints = worldFramesToKeypointFrames(frames, VMD_FPS); + expect(keypoints).toHaveLength(frames.length); + + // 显式标注 number[]:MP 是 as const,直接从它推出的数组是字面量联合, + // 会让 `DIRECT.includes(index)` 这类按 number 查询的调用过不了类型检查。 + const DIRECT: number[] = [MP.NOSE, MP.LEFT_SHOULDER, MP.RIGHT_SHOULDER, MP.LEFT_ELBOW, MP.RIGHT_ELBOW, + MP.LEFT_WRIST, MP.RIGHT_WRIST, MP.LEFT_HIP, MP.RIGHT_HIP, MP.LEFT_KNEE, MP.RIGHT_KNEE, + MP.LEFT_ANKLE, MP.RIGHT_ANKLE, MP.LEFT_FOOT_INDEX, MP.RIGHT_FOOT_INDEX]; + + const confidence = new Map>(); + for (const [f, frame] of keypoints.entries()) { + if (frame.points.length !== KEYPOINT_COUNT) violations.push(`帧${f} 点数 ${frame.points.length}`); + if (frame.angles.length !== ANGLE_NAMES.length) violations.push(`帧${f} 角度数 ${frame.angles.length}`); + frame.points.forEach((point, index) => { + const [x, y, z, c] = point; + if (![x, y, z].every(Number.isFinite)) violations.push(`帧${f} 点${index} 非有限`); + if (!(c === 0 || c === 0.5 || c === 1)) violations.push(`帧${f} 点${index} 置信度 ${c}`); + // 骨盆归零后投影到图像空间,两个方向都必须落在 [0,1] + if (Math.abs(x) > 1.0001 || Math.abs(y) > 1.0001) violations.push(`帧${f} 点${index} 越界 ${x},${y}`); + const set = confidence.get(index) ?? new Set(); + set.add(c); + confidence.set(index, set); + }); + for (const angle of frame.angles) { + // 关节角按契约存 cos 值,必须有限且落在 [-1,1] + if (!Number.isFinite(angle) || Math.abs(angle) > 1.0001) violations.push(`帧${f} 角度 ${angle}`); + } + } + expect(violations.slice(0, 5)).toEqual([]); + + // 直接绑定的索引必须**只能**是置信度 1.0,绝不退化成推算值 + for (const index of DIRECT) expect([...confidence.get(index)!]).toEqual([1]); + // 其余 18 个点全部由层级推算,且没有任何点彻底缺失 + const derived = [...confidence.keys()].filter((i) => !DIRECT.includes(i)); + expect(derived).toHaveLength(KEYPOINT_COUNT - DIRECT.length); + for (const index of derived) expect([...confidence.get(index)!]).toEqual([0.5]); + }, 300_000); + + it('内部标准序列化:NDJSON 往返一致、缩略图有效、动作幅度非退化', () => { + const motion = vmd(); + const { frames } = vmdToWorldFrames(motion, { fps: VMD_FPS }); + const keypoints = worldFramesToKeypointFrames(frames, VMD_FPS); + + const ndjson = serializeFramesNdjson(keypoints); + expect(ndjson.endsWith('\n')).toBe(true); + const roundTrip = parseFramesNdjson(ndjson); + expect(roundTrip).toHaveLength(keypoints.length); + expect(roundTrip[0].points).toHaveLength(KEYPOINT_COUNT); + expect(roundTrip.every((f, i) => f.frame === keypoints[i].frame)).toBe(true); + expect(KEYPOINT_FORMAT).toBe('mediapipe33@0.1.0'); + + const thumbnail = renderMotionThumbnail(keypoints, { size: 256 }); + expect(thumbnail).not.toBeNull(); + expect(thumbnail!.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + + // 动作幅度:手腕必须真的在动,鼻子相对稳定 + const spread = (index: number) => { + const xs = keypoints.map((f) => f.points[index][0]); + return Math.max(...xs) - Math.min(...xs); + }; + expect(spread(MP.LEFT_WRIST)).toBeGreaterThan(0.1); + expect(spread(MP.RIGHT_WRIST)).toBeGreaterThan(0.1); + expect(spread(MP.LEFT_ANKLE)).toBeGreaterThan(0.05); + expect(spread(MP.NOSE)).toBeLessThan(spread(MP.LEFT_WRIST)); + }, 120_000); +});