#!/usr/bin/env node /** * VMD 动作文件诊断工具。 * * 用途:拿到一个 .vmd 后,先看服务端会怎么解析它——骨骼名认不认、腿部是 IK 还是 FK、 * 重定向成 MediaPipe 33 点后是否正常。上传前先跑一遍可以避免"传上去才发现预处理失败"。 * * 用法(在 web-server 目录下,先 npm run build): * node scripts/inspect-vmd.mjs 动作.vmd * node scripts/inspect-vmd.mjs 动作.vmd --frames 300 # 只采样前 300 帧 * node scripts/inspect-vmd.mjs 动作.vmd --json out.json # 导出关键点序列 * * 只读:不写数据库、不访问网络。 */ import { createRequire } from 'node:module'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; const require = createRequire(import.meta.url); const args = process.argv.slice(2); const file = args.find((a) => !a.startsWith('--')); if (!file) { console.error('用法:node scripts/inspect-vmd.mjs <文件.vmd> [--frames N] [--json out.json]'); process.exit(2); } const readFlag = (name, fallback) => { const index = args.indexOf(name); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; const maxFrames = Number(readFlag('--frames', 0)) || undefined; const jsonOut = readFlag('--json', null); const distDir = join(process.cwd(), 'dist', 'preprocess'); if (!existsSync(distDir)) { console.error('未找到 dist/preprocess,请先执行:npm run build'); process.exit(2); } const { parseVmd, trackHasPositionMotion, trackHasRotationMotion, VMD_FPS } = require( join(distDir, 'vmd-parser.js'), ); const { vmdToWorldFrames, legIkDriven } = require(join(distDir, 'mmd-pose.js')); const { worldFramesToKeypointFrames } = require(join(distDir, 'retarget.js')); const { LEG_LENGTHS, REST_HEIGHTS, MMD_SKELETON } = require(join(distDir, 'mmd-skeleton.js')); const { ANGLE_TRIPLETS } = require(join(distDir, 'mediapipe33.js')); const path = resolve(file); const buffer = readFileSync(path); const started = Date.now(); const vmd = parseVmd(buffer); const tParse = Date.now() - started; console.log(`文件:${basename(path)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)`); console.log(`头部:${vmd.header}`); console.log(`模型名:${vmd.modelName || '(空)'}`); console.log(`帧数:${vmd.maxFrame + 1}(${((vmd.maxFrame + 1) / VMD_FPS).toFixed(1)} 秒 @ ${VMD_FPS}fps)`); console.log(`骨骼轨道:${vmd.tracks.size} 条 / 标准骨架共 ${MMD_SKELETON.length} 根`); console.log(`morph 关键帧:${vmd.morphCount}`); console.log(`解析耗时:${tParse} ms`); const missing = MMD_SKELETON.filter((b) => !vmd.tracks.has(b.name)).map((b) => b.name); console.log( `\n未出现在文件中的标准骨骼(按静止姿态处理):${missing.length === 0 ? '(无,全部有键)' : missing.join('、')}`, ); if (vmd.unknownBones.length > 0) { const preview = vmd.unknownBones.slice(0, 12).join('、'); console.log( `未纳入标准骨架的骨骼:${vmd.unknownBones.length} 种(裙摆/物理/表情等,会被忽略)` + `\n 例如:${preview}${vmd.unknownBones.length > 12 ? ' …' : ''}`, ); } const ik = legIkDriven(vmd); console.log( `\n腿部驱动:左=${ik.左 ? 'IK(足IK 有位移)' : 'FK(烘焙旋转)'} 右=${ik.右 ? 'IK' : 'FK'}`, ); const fingerBones = vmd.unknownBones.filter((n) => n.includes('親指') || n.includes('小指')); console.log( `手指骨骼:${fingerBones.length > 0 ? `文件里有(${fingerBones.length} 种,当前不使用)` : '无'}`, ); console.log( `IK 段落:${trackHasRotationMotion(vmd.tracks.get('左足IK')) ? '足IK 带旋转' : '足IK 仅位移'}`, ); const t0 = Date.now(); const { frames: world, fps } = vmdToWorldFrames(vmd, { fps: VMD_FPS, maxFrames }); const tFk = Date.now() - t0; const t1 = Date.now(); const frames = worldFramesToKeypointFrames(world, fps); const tRt = Date.now() - t1; let nan = 0; let outOfRange = 0; const knee = []; const elbow = []; let shoulderGap = 0; for (const frame of frames) { for (const p of frame.points) { if (!p.every(Number.isFinite)) nan += 1; if (p[0] < 0 || p[0] > 1 || p[1] < 0 || p[1] > 1 || p[2] < -1 || p[2] > 1) outOfRange += 1; } knee.push(frame.angles[6], frame.angles[7]); elbow.push(frame.angles[2], frame.angles[3]); shoulderGap += frame.points[11][0] - frame.points[12][0]; } const stats = (values) => { const sorted = [...values].sort((a, b) => a - b); const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))]; return `min=${q(0).toFixed(3)} 中位=${q(0.5).toFixed(3)} max=${q(1).toFixed(3)}`; }; console.log(`\n关键点序列:${frames.length} 帧`); console.log(` 耗时:FK ${tFk} ms + 重定向 ${tRt} ms`); console.log(` 异常:NaN ${nan} 处 / 越界 ${outOfRange} 处`); console.log(` 膝角余弦(-1=伸直):${stats(knee)}`); console.log(` 肘角余弦:${stats(elbow)}`); console.log( ` 左肩x-右肩x 均值 ${(shoulderGap / frames.length).toFixed(3)}(>0 表示左肩在画面右侧,自拍视角)`, ); const rig = { 足首: REST_HEIGHTS.ankle, 膝: REST_HEIGHTS.knee, 髋: REST_HEIGHTS.hip, 首: REST_HEIGHTS.neck, 頭: REST_HEIGHTS.head, 大腿: LEG_LENGTHS.thigh, 小腿: LEG_LENGTHS.shin, }; console.log(`\n标准骨架比例(静止,MMD 单位):`); console.log( ` 足首 ${rig.足首.toFixed(2)} / 膝 ${rig.膝.toFixed(2)} / 髋 ${rig.髋.toFixed(2)} / ` + `首 ${rig.首.toFixed(2)} / 頭 ${rig.頭.toFixed(2)}`, ); console.log(` 大腿 ${rig.大腿.toFixed(2)} + 小腿 ${rig.小腿.toFixed(2)}(腿长 ${(rig.大腿 + rig.小腿).toFixed(2)})`); console.log(` 关节角数量:${ANGLE_TRIPLETS.length}`); if (jsonOut) { writeFileSync(jsonOut, JSON.stringify({ fps, frames }, null, 0)); console.log(`\n已导出关键点序列:${jsonOut}`); } console.log('\n结论:解析与重定向均正常,可上传(.vmd → format=vmd)。');