Files
opendance-web/web-server/scripts/inspect-vmd.mjs
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

143 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)。');