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
+274
View File
@@ -0,0 +1,274 @@
import { Mat3, Transform, Vec3, applyTransform, matVec, quatToMat3, vAdd, vLen, vNorm, vScale, vSub } from './math3d';
import { LEG_IK, LEG_LENGTHS, MMD_SKELETON, MmdBoneDef } from './mmd-skeleton';
import { VmdMotion, VmdBoneTrack, sampleBoneTrack, trackHasPositionMotion } from './vmd-parser';
/**
* VMD 数值的解释方式。
*
* MMD 使用左手坐标系,而本项目的重定向通路(与 BVH 一致)是右手系。
* 两种解释只有在真实数据上跑一遍才能判断哪种是"物理正确"的:
* `zMirrored` 相当于把动画在 Z 轴上做一次镜像(旋转四元数取
* (x,y,z,w) → (-x,-y,z,w),位移 z 取反)。
* 见 mmd-vmd.spec.ts 中基于真实 VMD 的"膝盖朝前"对照实验。
*/
export type MmdRotationMode = 'asIs' | 'zMirrored';
export interface MmdPoseOptions {
rotationMode?: MmdRotationMode;
/** 采样帧率,默认 VMD 固定的 30 */
fps?: number;
/** 最大采样帧数,用于防御异常文件(默认 30000 帧 ≈ 16 分钟) */
maxFrames?: number;
/** 是否启用腿部 IK 解算(默认自动判断;诊断与测试可强制关闭) */
legIk?: boolean;
}
/** 单帧解算结果:boneName(标准名)→ 世界变换 */
export type Pose = Map<string, Transform>;
const IDENTITY_TRANSFORM: Transform = { r: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [0, 0, 0] };
/** S·R·S,S = diag(1,1,-1):把左手系旋转搬到右手系 */
function mirrorRotationZ(r: Mat3): Mat3 {
return [
r[0], r[1], -r[2],
r[3], r[4], -r[5],
-r[6], -r[7], r[8],
];
}
function convertRotation(
quat: readonly [number, number, number, number],
mode: MmdRotationMode,
): Mat3 {
const matrix = quatToMat3(quat);
return mode === 'zMirrored' ? mirrorRotationZ(matrix) : matrix;
}
function convertPosition(position: Vec3, mode: MmdRotationMode): Vec3 {
return mode === 'zMirrored' ? [position[0], position[1], -position[2]] : [...position] as Vec3;
}
/** 骨架的拓扑顺序(父骨骼一定排在子骨骼之前) */
const SKELETON_ORDER: readonly MmdBoneDef[] = (() => {
const remaining = [...MMD_SKELETON];
const ordered: MmdBoneDef[] = [];
const done = new Set<string>();
while (remaining.length > 0) {
const index = remaining.findIndex((bone) => bone.parent === null || done.has(bone.parent));
if (index < 0) throw new Error('MMD 标准骨架存在环,无法拓扑排序');
const [bone] = remaining.splice(index, 1);
ordered.push(bone);
done.add(bone.name);
}
return ordered;
})();
/**
* 解算单帧:标准骨架 FK + 腿部 IK。
*
* MMD 的位移通道是**世界轴**上的偏移(不被父骨骼旋转带走),
* 因此平移单独叠加:`t = parentT + parentR * restOffset + animPos`。
*/
export function solveFrame(
motion: VmdMotion,
frame: number,
mode: MmdRotationMode,
useLegIk: { 左: boolean; 右: boolean },
): Pose {
const pose: Pose = new Map();
for (const bone of SKELETON_ORDER) {
const parent = bone.parent ? pose.get(bone.parent) ?? IDENTITY_TRANSFORM : IDENTITY_TRANSFORM;
const track: VmdBoneTrack | undefined = motion.tracks.get(bone.name);
const sampled = track ? sampleBoneTrack(track, frame) : null;
const localR = sampled
? convertRotation(sampled.rotation, mode)
: IDENTITY_TRANSFORM.r;
const animPos = sampled ? convertPosition(sampled.position, mode) : ([0, 0, 0] as Vec3);
pose.set(bone.name, {
r: applyTransform(parent, bone.offset, localR).r,
t: vAdd(vAdd(parent.t, matVec(parent.r, bone.offset)), animPos),
});
}
for (const side of ['左', '右'] as const) {
if (!useLegIk[side]) continue;
solveLegIk(pose, side);
}
return pose;
}
/**
* 两骨骼解析 IK:把小腿末端拉到 IK 目标点,膝盖朝 +Z(角色正面)弯。
* 目标超出可达范围时按比例收敛到伸直/极限,不产生 NaN。
*/
function solveLegIk(pose: Pose, side: '左' | '右'): void {
const names = LEG_IK[side];
const hip = pose.get(`${side}足`);
const ik = pose.get(names.ik);
if (!hip || !ik) return;
const upper = LEG_LENGTHS.thigh;
const lower = LEG_LENGTHS.shin;
const hipPos = hip.t;
const target = ik.t;
let delta = vSub(target, hipPos);
let distance = vLen(delta);
const minReach = Math.abs(upper - lower) + 1e-4;
const maxReach = upper + lower - 1e-4;
if (distance < 1e-6) {
// 退化:目标与髋重合,直接落地
delta = [0, -1, 0];
distance = 1;
}
const axis = vNorm(delta);
let ankle = target;
if (distance > maxReach) {
ankle = vAdd(hipPos, vScale(axis, maxReach));
distance = maxReach;
} else if (distance < minReach) {
ankle = vAdd(hipPos, vScale(axis, minReach));
distance = minReach;
}
// 髋到膝在髋-踝连线上的投影长度
const along = (upper * upper - lower * lower + distance * distance) / (2 * distance);
const heightSq = Math.max(0, upper * upper - along * along);
const height = Math.sqrt(heightSq);
// 膝盖参考方向:优先 +Z(正面),与连线正交化;退化时退回 +Y
const forward: Vec3 = [0, 0, 1];
let perp = vSub(forward, vScale(axis, forward[0] * axis[0] + forward[1] * axis[1] + forward[2] * axis[2]));
if (vLen(perp) < 1e-4) {
const up: Vec3 = [0, 1, 0];
perp = vSub(up, vScale(axis, up[0] * axis[0] + up[1] * axis[1] + up[2] * axis[2]));
}
perp = vNorm(perp);
const kneePos = vAdd(vAdd(hipPos, vScale(axis, along)), vScale(perp, height));
const thighDir = vNorm(vSub(kneePos, hipPos));
const shinDir = vNorm(vSub(ankle, kneePos));
const knee = pose.get(names.knee);
const ankleBone = pose.get(names.ankle);
if (knee) {
knee.t = kneePos;
if (vLen(thighDir) > 1e-6) {
// 让膝的旋转与"指向踝"的方向一致,供下游(如趾尖)使用
knee.r = alignRotation(knee.r, thighDir);
}
}
if (ankleBone) {
ankleBone.t = ankle;
// MMD 把 IK 骨骼的旋转直接赋给足首
ankleBone.r = ik.r;
if (vLen(shinDir) > 1e-6) ankleBone.r = alignRotation(ik.r, shinDir);
}
const toe = pose.get(names.toe);
if (toe && ankleBone) {
const restOffset: Vec3 = [0, -0.2, LEG_LENGTHS.toe > 0 ? 1.3 : 1.3];
toe.t = vAdd(ankle, matVec(ankleBone.r, restOffset));
toe.r = ankleBone.r;
}
}
/**
* 让骨骼的 Y 轴(MMD 骨骼默认朝向子骨骼的方向)对齐给定方向,
* 保留绕该方向的扭转分量。用于 IK 解算后给出合理的局部朝向。
*/
function alignRotation(base: Mat3, direction: Vec3): Mat3 {
const dir = vNorm(direction);
if (vLen(dir) < 1e-6) return base;
// 取一个与 dir 不平行的参考轴构造正交基(列优先:X, Y=dir, Z)
const reference: Vec3 = Math.abs(dir[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0];
const xAxis = vNorm([
reference[1] * dir[2] - reference[2] * dir[1],
reference[2] * dir[0] - reference[0] * dir[2],
reference[0] * dir[1] - reference[1] * dir[0],
]);
if (vLen(xAxis) < 1e-6) return base;
const zAxis: Vec3 = [
xAxis[1] * dir[2] - xAxis[2] * dir[1],
xAxis[2] * dir[0] - xAxis[0] * dir[2],
xAxis[0] * dir[1] - xAxis[1] * dir[0],
];
return [
xAxis[0], dir[0], zAxis[0],
xAxis[1], dir[1], zAxis[1],
xAxis[2], dir[2], zAxis[2],
];
}
/** 关节映射表使用的骨骼名(与 joint-map.ts 的候选名一致) */
const HEAD_KEY = 'head';
const JOINT_KEYS: ReadonlyArray<{ key: string; bone: string; offset?: Vec3 }> = [
{ key: HEAD_KEY, bone: '頭', offset: [0, 0.6, 0] },
{ key: 'leftarm', bone: '左腕' },
{ key: 'rightarm', bone: '右腕' },
{ key: 'leftforearm', bone: '左ひじ' },
{ key: 'rightforearm', bone: '右ひじ' },
{ key: 'lefthand', bone: '左手首' },
{ key: 'righthand', bone: '右手首' },
{ key: 'leftupleg', bone: '左足' },
{ key: 'rightupleg', bone: '右足' },
{ key: 'leftleg', bone: '左ひざ' },
{ key: 'rightleg', bone: '右ひざ' },
{ key: 'leftfoot', bone: '左足首' },
{ key: 'rightfoot', bone: '右足首' },
{ key: 'lefttoebase', bone: '左つま先' },
{ key: 'righttoebase', bone: '右つま先' },
];
/** 把一帧的姿态转成重定向模块使用的"骨骼名 → 世界坐标"表 */
function poseToWorld(pose: Pose): Map<string, Vec3> {
const world = new Map<string, Vec3>();
for (const { key, bone, offset } of JOINT_KEYS) {
const transform = pose.get(bone);
if (!transform) continue;
world.set(key, offset ? vAdd(transform.t, offset) : transform.t);
}
return world;
}
/**
* VMD → 逐帧世界坐标(键名与 `joint-map.ts` 的候选名一致)。
* 输出可直接交给 `worldFramesToKeypointFrames` 复用 BVH 通路的重定向。
*/
export function vmdToWorldFrames(
motion: VmdMotion,
options: MmdPoseOptions = {},
): { frames: Map<string, Vec3>[]; fps: number } {
const mode = options.rotationMode ?? 'zMirrored';
const fps = options.fps && options.fps > 0 ? options.fps : 30;
const maxFrames = options.maxFrames ?? 30000;
const frameCount = Math.min(motion.maxFrame + 1, maxFrames);
// 腿部是否由 IK 驱动:IK 目标有位移 → 用 IK;否则用 FK 旋转(部分动作数据是烘焙好的 FK)
const autoIk = {
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
};
const useLegIk =
options.legIk === false ? { 左: false, 右: false } : autoIk;
const frames: Map<string, Vec3>[] = [];
for (let frame = 0; frame < frameCount; frame += 1) {
frames.push(poseToWorld(solveFrame(motion, frame, mode, useLegIk)));
}
return { frames, fps };
}
/** 诊断用:返回本帧被 IK 驱动的腿(测试与日志使用) */
export function legIkDriven(motion: VmdMotion): { 左: boolean; 右: boolean } {
return {
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
};
}