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 越界(测试数据已从数据库清理)。
This commit is contained in:
2026-09-15 08:47:37 +08:00
parent 76725e5ce3
commit bba5bcf59e
35 changed files with 2658 additions and 192 deletions
+142
View File
@@ -0,0 +1,142 @@
#!/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)。');