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:
@@ -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)。');
|
||||
Reference in New Issue
Block a user