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:
@@ -27,6 +27,8 @@ export enum PublishStatus {
|
||||
|
||||
export enum MotionFormat {
|
||||
Bvh = 'bvh',
|
||||
/** MikuMikuDance 动作数据(二进制,含 Shift-JIS 骨骼名与四元数关键帧) */
|
||||
Vmd = 'vmd',
|
||||
Fbx = 'fbx',
|
||||
Gltf = 'gltf',
|
||||
Glb = 'glb',
|
||||
@@ -78,6 +80,7 @@ export const TARGET_TYPE_VALUES = Object.values(TargetType) as string[];
|
||||
/** 允许上传的扩展名 → 动作格式 */
|
||||
export const MOTION_EXTENSION_MAP: Record<string, MotionFormat> = {
|
||||
'.bvh': MotionFormat.Bvh,
|
||||
'.vmd': MotionFormat.Vmd,
|
||||
'.fbx': MotionFormat.Fbx,
|
||||
'.gltf': MotionFormat.Gltf,
|
||||
'.glb': MotionFormat.Glb,
|
||||
|
||||
@@ -10,6 +10,35 @@ export interface AccessHint {
|
||||
loopbackOnly: boolean;
|
||||
}
|
||||
|
||||
/** 软件 API 端口对外提供的接口路径范围,用于启动日志。 */
|
||||
const API_SCOPE = '/api、/health、/static、/contracts';
|
||||
|
||||
/** 双端口模式(软件 API 端口 + 管理端端口)的访问提示。 */
|
||||
export interface DualAccessHint {
|
||||
/** 软件 API 端口(Windows/Godot 客户端、Android App 调用)。 */
|
||||
api: AccessHint;
|
||||
/** 管理端端口(浏览器访问)。 */
|
||||
admin: AccessHint;
|
||||
/** 是否为单端口兼容模式(ADMIN_PORT === PORT)。 */
|
||||
sharedPort: boolean;
|
||||
/** 是否只监听回环(两个端口都只在本机可访问时才为 true)。 */
|
||||
loopbackOnly: boolean;
|
||||
/** 启动日志第一行:软件 API 端口。 */
|
||||
apiLine: string;
|
||||
/** 启动日志第二行:管理端端口。 */
|
||||
adminLine: string;
|
||||
/** 单端口兼容模式下的提示文案;未分端口时为空串。 */
|
||||
sharedNotice: string;
|
||||
}
|
||||
|
||||
/** describeDualAccess 的入参。 */
|
||||
export interface DualAccessOptions {
|
||||
apiHost: string;
|
||||
apiPort: number;
|
||||
adminHost: string;
|
||||
adminPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把绑定地址翻译成"人能看懂、能直接用"的访问地址。
|
||||
*
|
||||
@@ -37,3 +66,41 @@ export function describeAccess(bindHost: string, port: number): AccessHint {
|
||||
}
|
||||
return { listen: `0.0.0.0:${port}(全部网卡,外部可访问)`, urls, loopbackOnly: false };
|
||||
}
|
||||
|
||||
/** 取一个"外部设备也能访问"的地址优先展示;没有真实网卡时回落到回环地址。 */
|
||||
export function preferredUrl(hint: AccessHint): string {
|
||||
const external = hint.urls.find((url) => !/127\.0\.0\.1|localhost|\[::1\]/.test(url));
|
||||
return external ?? hint.urls[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 描述"软件 API 端口 + 管理端端口"两个端口,供启动日志直接打印。
|
||||
*
|
||||
* 客户端(Windows/Godot、Android)与管理端(浏览器)的职责不同,
|
||||
* 必须分别告诉使用者该访问哪个地址、该地址提供什么,避免把管理页面
|
||||
* 挂到客户端调用的端口上。
|
||||
*/
|
||||
export function describeDualAccess(options: DualAccessOptions): DualAccessHint {
|
||||
const api = describeAccess(options.apiHost, options.apiPort);
|
||||
const admin = describeAccess(options.adminHost, options.adminPort);
|
||||
const sharedPort = options.adminPort === options.apiPort;
|
||||
|
||||
const apiLine = `软件 API 端口:${preferredUrl(api)}(${API_SCOPE})`;
|
||||
const adminLine = sharedPort
|
||||
? `管理端端口: ${preferredUrl(admin)}(未分端口,与软件 API 同一端口;` +
|
||||
'浏览器访问,含管理页面与全部接口)'
|
||||
: `管理端端口: ${preferredUrl(admin)}(浏览器访问,含管理页面与全部接口)`;
|
||||
|
||||
return {
|
||||
api,
|
||||
admin,
|
||||
sharedPort,
|
||||
loopbackOnly: api.loopbackOnly || admin.loopbackOnly,
|
||||
apiLine,
|
||||
adminLine,
|
||||
sharedNotice: sharedPort
|
||||
? `ADMIN_PORT 与 PORT 相同(${options.apiPort}),未分端口:` +
|
||||
'管理页面与软件 API 共用同一端口,行为与旧版一致'
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,16 @@ export class AppConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听地址。
|
||||
* 管理端端口(浏览器访问)。
|
||||
* 未设置 ADMIN_PORT 时默认取 PORT + 1,与管理端端口分开:
|
||||
* 软件 API 端口只提供接口,管理端口同时提供管理端页面与全部接口。
|
||||
*/
|
||||
get adminPort(): number {
|
||||
return this.config.get<number>('adminPort') ?? this.port + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听地址(软件 API 端口)。
|
||||
* 反向代理与后端同机时建议设为 127.0.0.1(仅本机可访问);
|
||||
* 代理在其他机器上时必须为 0.0.0.0,并用防火墙限制来源。
|
||||
*/
|
||||
@@ -42,6 +51,14 @@ export class AppConfig {
|
||||
return this.config.get<string>('bindHost') ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端监听地址,默认与 HOST 一致。
|
||||
* 需要"接口对外、管理端只对本机"时,可单独把 ADMIN_HOST 设为 127.0.0.1。
|
||||
*/
|
||||
get adminHost(): string {
|
||||
return this.config.get<string>('adminHost') ?? this.bindHost;
|
||||
}
|
||||
|
||||
get storageDir(): string {
|
||||
return this.config.get<string>('storageDir') ?? resolve(process.cwd(), 'storage');
|
||||
}
|
||||
@@ -89,40 +106,114 @@ function bool(value: string | undefined, fallback = false): boolean {
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析端口环境变量。
|
||||
* 未设置或空白时使用 fallback;非法值(NaN、越界、非整数)原样返回,
|
||||
* 交由 validateEnv 统一报问题,避免在这里静默纠正错误配置。
|
||||
*/
|
||||
export function parsePort(raw: string | undefined, fallback: number): number {
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端端口:优先 ADMIN_PORT,未设置时回落到 PORT + 1。
|
||||
* 允许 ADMIN_PORT === PORT(单端口兼容模式,行为与旧版一致)。
|
||||
*/
|
||||
export function resolveAdminPort(port: number, rawAdminPort?: string): number {
|
||||
if (rawAdminPort === undefined || rawAdminPort.trim() === '') return port + 1;
|
||||
return Number(rawAdminPort);
|
||||
}
|
||||
|
||||
/** 是否为单端口兼容模式(管理端与软件 API 共用同一端口)。 */
|
||||
export function isSamePort(port: number, adminPort: number): boolean {
|
||||
return Number.isInteger(port) && port === adminPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按请求进入的端口判断是否为管理端请求。
|
||||
*
|
||||
* 软件 API 端口不提供管理端页面:根路径 `/` 与任意前端路径都必须 404;
|
||||
* 单端口兼容模式(ADMIN_PORT === PORT)下所有请求都按管理端处理。
|
||||
* localPort 缺失时按软件 API 端口处理(宁可少给页面,不可把管理页挂到接口端口)。
|
||||
*/
|
||||
export function isAdminPortRequest(
|
||||
localPort: number | undefined,
|
||||
port: number,
|
||||
adminPort: number,
|
||||
): boolean {
|
||||
if (isSamePort(port, adminPort)) return true;
|
||||
return localPort === adminPort;
|
||||
}
|
||||
|
||||
/** @nestjs/config 的 load 工厂 */
|
||||
export const configuration = () => ({
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
bindHost: process.env.HOST ?? '0.0.0.0',
|
||||
storageDir: process.env.STORAGE_DIR ?? resolve(process.cwd(), 'storage'),
|
||||
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
|
||||
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
|
||||
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
|
||||
contractsDir: process.env.CONTRACTS_DIR ?? '',
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
user: process.env.DB_USER ?? 'opendance_app',
|
||||
pass: process.env.DB_PASS ?? '',
|
||||
name: process.env.DB_NAME ?? 'opendance',
|
||||
synchronize: bool(process.env.DB_SYNCHRONIZE, false),
|
||||
logging: bool(process.env.DB_LOGGING, false),
|
||||
},
|
||||
});
|
||||
export const configuration = () => {
|
||||
const port = parsePort(process.env.PORT, 3000);
|
||||
return {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port,
|
||||
bindHost: process.env.HOST ?? '0.0.0.0',
|
||||
adminPort: resolveAdminPort(port, process.env.ADMIN_PORT),
|
||||
// 管理端监听地址默认与 HOST 一致;未设置 HOST 时同样回落到 0.0.0.0
|
||||
adminHost: process.env.ADMIN_HOST ?? process.env.HOST ?? '0.0.0.0',
|
||||
storageDir: process.env.STORAGE_DIR ?? resolve(process.cwd(), 'storage'),
|
||||
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
|
||||
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
|
||||
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
|
||||
contractsDir: process.env.CONTRACTS_DIR ?? '',
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
user: process.env.DB_USER ?? 'opendance_app',
|
||||
pass: process.env.DB_PASS ?? '',
|
||||
name: process.env.DB_NAME ?? 'opendance',
|
||||
synchronize: bool(process.env.DB_SYNCHRONIZE, false),
|
||||
logging: bool(process.env.DB_LOGGING, false),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** 环境变量校验结果:errors 为必须处理的问题,warnings 仅提示、不阻断启动。 */
|
||||
export interface EnvValidation {
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 端口必须是 1..65535 的整数。 */
|
||||
function validatePort(name: string, value: unknown, errors: string[]): void {
|
||||
const valid =
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
if (!valid) {
|
||||
errors.push(`${name} 非法(当前值 ${String(value)}),必须是 1..65535 的整数`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产环境下禁止 DB_SYNCHRONIZE=true —— 契约 database.md 明确要求。
|
||||
* 返回错误信息数组,供 main.ts 启动时快速失败。
|
||||
* 端口非法属于 errors,供 main.ts 启动时快速失败;
|
||||
* ADMIN_PORT === PORT 只是"未分端口"的兼容模式,属于 warnings,不阻断启动。
|
||||
*/
|
||||
export function validateEnv(env: Record<string, unknown>): string[] {
|
||||
const problems: string[] = [];
|
||||
export function validateEnv(env: Record<string, unknown>): EnvValidation {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const db = (env.db ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (!db.host) problems.push('DB_HOST 未设置');
|
||||
if (!db.user) problems.push('DB_USER 未设置');
|
||||
if (!db.name) problems.push('DB_NAME 未设置');
|
||||
if (!db.host) errors.push('DB_HOST 未设置');
|
||||
if (!db.user) errors.push('DB_USER 未设置');
|
||||
if (!db.name) errors.push('DB_NAME 未设置');
|
||||
if (env.nodeEnv === 'production' && db.synchronize === true) {
|
||||
problems.push('生产环境禁止 DB_SYNCHRONIZE=true,请使用 TypeORM migration');
|
||||
errors.push('生产环境禁止 DB_SYNCHRONIZE=true,请使用 TypeORM migration');
|
||||
}
|
||||
return problems;
|
||||
|
||||
validatePort('PORT', env.port, errors);
|
||||
validatePort('ADMIN_PORT', env.adminPort, errors);
|
||||
|
||||
if (isSamePort(env.port as number, env.adminPort as number)) {
|
||||
warnings.push(
|
||||
`ADMIN_PORT 与 PORT 相同(${String(env.port)}),未分端口:` +
|
||||
'管理页面与软件 API 共用同一端口,建议分开部署',
|
||||
);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { join } from 'node:path';
|
||||
* 未设置该变量时回落到这里的常量)。
|
||||
*/
|
||||
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'rest-api': '0.1.0',
|
||||
'rest-api': '0.2.0',
|
||||
'websocket-keypoints': '0.1.0',
|
||||
'data-models': '0.1.0',
|
||||
'data-models': '0.2.0',
|
||||
'skeleton-topology': '0.1.0',
|
||||
'model-formats': '0.1.0',
|
||||
'model-formats': '0.2.0',
|
||||
database: '0.1.0',
|
||||
});
|
||||
|
||||
|
||||
+96
-23
@@ -1,20 +1,47 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import { createServer, Server } from 'node:http';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
import { describeAccess } from './config/access-hint';
|
||||
import { AppConfig, configuration, validateEnv } from './config/configuration';
|
||||
import { describeDualAccess, preferredUrl } from './config/access-hint';
|
||||
import {
|
||||
AppConfig,
|
||||
configuration,
|
||||
isAdminPortRequest,
|
||||
validateEnv,
|
||||
} from './config/configuration';
|
||||
|
||||
/** 关闭一个 HTTP server;未监听时直接返回。 */
|
||||
function closeServer(server: Server | undefined): Promise<void> {
|
||||
if (!server || !server.listening) return Promise.resolve();
|
||||
return new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
||||
});
|
||||
}
|
||||
|
||||
/** 在指定地址监听一个 HTTP server,失败时 reject(例如端口被占用)。 */
|
||||
function listenServer(server: Server, port: number, host: string): Promise<void> {
|
||||
return new Promise<void>((resolveListen, rejectListen) => {
|
||||
const onError = (error: Error) => rejectListen(error);
|
||||
server.once('error', onError);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', onError);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true 等)
|
||||
const problems = validateEnv(configuration());
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) logger.warn(`环境变量问题:${problem}`);
|
||||
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true、端口必须合法等)
|
||||
const { errors, warnings } = validateEnv(configuration());
|
||||
for (const warning of warnings) logger.warn(`环境变量提示:${warning}`);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) logger.warn(`环境变量问题:${error}`);
|
||||
if ((process.env.NODE_ENV ?? 'development') === 'production') {
|
||||
logger.error('生产环境配置校验失败,启动中止');
|
||||
process.exit(1);
|
||||
@@ -26,6 +53,11 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
const config = app.get(AppConfig);
|
||||
const apiPort = config.port;
|
||||
const adminPort = config.adminPort;
|
||||
const adminHost = config.adminHost;
|
||||
// 单端口兼容模式:ADMIN_PORT === PORT 时只创建一个 HTTP server,避免 EADDRINUSE
|
||||
const sharedPort = adminPort === apiPort;
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
@@ -53,18 +85,29 @@ async function bootstrap(): Promise<void> {
|
||||
app.useStaticAssets(resolve(config.contractsDir), { prefix: '/contracts/', index: false });
|
||||
}
|
||||
|
||||
// ---- 管理端静态站点 ----
|
||||
// 本服务同时提供管理端页面,因此反向代理只需要把全部请求转发到本端口,
|
||||
// 不需要在代理层再单独配置一个静态站点(IIS / nginx 示例见 deploy/proxy/)。
|
||||
// ---- 管理端静态站点(只在管理端口生效)----
|
||||
// 软件 API 端口(Windows/Godot 客户端、Android App 调用)只提供接口,
|
||||
// 根路径 `/` 与任意前端路径都必须 404,避免把管理页面暴露给客户端调用的端口。
|
||||
const adminDir = resolve(config.adminDir);
|
||||
const adminIndex = join(adminDir, 'index.html');
|
||||
if (existsSync(adminIndex)) {
|
||||
app.useStaticAssets(adminDir, { index: 'index.html', fallthrough: true });
|
||||
const adminAvailable = existsSync(adminIndex);
|
||||
if (adminAvailable) {
|
||||
// 请求是否从管理端口进入:用实际建立连接的本地端口判断(express 实例由两个 server 共用)
|
||||
const fromAdminPort = (req: Request): boolean =>
|
||||
isAdminPortRequest(req.socket.localPort, apiPort, adminPort);
|
||||
|
||||
// 管理端构建产物(/assets/**、/index.html 等)
|
||||
const adminStatic = express.static(adminDir, { index: 'index.html', fallthrough: true });
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!fromAdminPort(req)) return next();
|
||||
adminStatic(req, res, next);
|
||||
});
|
||||
|
||||
// SPA 深链接回退:/songs、/models 这类前端路由在磁盘上没有对应文件,
|
||||
// 需要返回 index.html 交给前端路由处理。
|
||||
// 注意必须放行后端路径,否则会吞掉 API 请求。
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!fromAdminPort(req)) return next();
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
||||
const path = req.path;
|
||||
if (
|
||||
@@ -89,26 +132,56 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const db = config.db;
|
||||
await app.listen(config.port, config.bindHost);
|
||||
await app.listen(apiPort, config.bindHost);
|
||||
|
||||
const access = describeAccess(config.bindHost, config.port);
|
||||
const externalUrls = access.urls.filter((url) => !url.includes('127.0.0.1'));
|
||||
// 第二个 HTTP server:与管理端口分开监听,复用同一个 express 实例
|
||||
let adminServer: Server | undefined;
|
||||
if (!sharedPort) {
|
||||
adminServer = createServer(app.getHttpAdapter().getInstance());
|
||||
await listenServer(adminServer, adminPort, adminHost);
|
||||
}
|
||||
|
||||
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.listen}`);
|
||||
if (access.loopbackOnly) {
|
||||
// 让 app.close() 一并关闭管理端 server,保证优雅退出
|
||||
const originalClose = app.close.bind(app);
|
||||
app.close = async (): Promise<void> => {
|
||||
await Promise.all([closeServer(adminServer), originalClose()]);
|
||||
};
|
||||
|
||||
const access = describeDualAccess({
|
||||
apiHost: config.bindHost,
|
||||
apiPort,
|
||||
adminHost,
|
||||
adminPort,
|
||||
});
|
||||
const primaryApiUrl = preferredUrl(access.api);
|
||||
const primaryAdminUrl = preferredUrl(access.admin);
|
||||
|
||||
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.api.listen}`);
|
||||
// 明确打印两行:哪个端口给客户端调接口,哪个端口给浏览器看管理页面
|
||||
logger.log(access.apiLine);
|
||||
if (!sharedPort) logger.log(`管理端监听:${access.admin.listen}`);
|
||||
logger.log(access.adminLine);
|
||||
if (sharedPort) logger.warn(access.sharedNotice);
|
||||
|
||||
if (access.api.loopbackOnly) {
|
||||
logger.warn(
|
||||
`可访问地址:${access.urls[0]}(只能本机访问;` +
|
||||
`软件 API 地址:${access.api.urls[0]}(只能本机访问;` +
|
||||
'要让外部访问,请在 .env 中设置 HOST=0.0.0.0 后重启)',
|
||||
);
|
||||
} else if (externalUrls.length > 0) {
|
||||
logger.log(`可访问地址:${externalUrls.join('、')}(管理端页面与 API 同一端口)`);
|
||||
}
|
||||
logger.log(`健康检查:${externalUrls[0] ?? access.urls[0]}/health`);
|
||||
if (access.admin.loopbackOnly && !access.api.loopbackOnly) {
|
||||
logger.warn(
|
||||
`管理端地址:${access.admin.urls[0]}(只能本机访问;` +
|
||||
'如需外部浏览器访问,请在 .env 中设置 ADMIN_HOST=0.0.0.0)',
|
||||
);
|
||||
}
|
||||
|
||||
logger.log(`健康检查:${primaryApiUrl}/health`);
|
||||
logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`);
|
||||
logger.log(`文件存储:${storageDir}`);
|
||||
logger.log(
|
||||
existsSync(adminIndex)
|
||||
? `管理端站点:${externalUrls[0] ?? access.urls[0]}/(由本服务直接提供)`
|
||||
adminAvailable
|
||||
? `管理端站点:${primaryAdminUrl}/(仅管理端口 ${adminPort} 提供)`
|
||||
: '管理端站点:未部署(仅 API)',
|
||||
);
|
||||
logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);
|
||||
|
||||
@@ -54,6 +54,33 @@ export function matMul(a: Mat3, b: Mat3): Mat3 {
|
||||
return out as Mat3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 四元数 (x, y, z, w) → 3×3 行主序旋转矩阵。
|
||||
* 输入会自动归一化;零四元数视为单位四元数。
|
||||
*/
|
||||
export function quatToMat3(q: readonly [number, number, number, number]): Mat3 {
|
||||
let [x, y, z, w] = q;
|
||||
const len = Math.hypot(x, y, z, w);
|
||||
if (len < 1e-9) return IDENTITY3;
|
||||
x /= len;
|
||||
y /= len;
|
||||
z /= len;
|
||||
w /= len;
|
||||
return [
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
||||
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
||||
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
|
||||
];
|
||||
}
|
||||
|
||||
export function matTranspose(m: Mat3): Mat3 {
|
||||
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
|
||||
}
|
||||
|
||||
export function vCross(a: Vec3, b: Vec3): Vec3 {
|
||||
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
}
|
||||
|
||||
export function matVec(m: Mat3, v: Vec3): Vec3 {
|
||||
return [
|
||||
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Vec3 } from './math3d';
|
||||
|
||||
/**
|
||||
* MMD 标准骨骼静止姿态(T-WEB-006 VMD 支持)。
|
||||
*
|
||||
* VMD 只存**旋转**(以及少数骨骼的位移),不含骨骼长度,因此必须由服务端
|
||||
* 提供一套标准骨架才能做 FK。
|
||||
*
|
||||
* ## 这些数值从哪来
|
||||
*
|
||||
* 不是凭经验拍的比例,而是从真实 MMD 模型里量出来的:
|
||||
* 解析参考模型的 PMX 骨骼段(`示例文件/【奥黛塔】_by_原神/奥黛塔.pmx`,
|
||||
* 18432 条记录中的骨骼段),读出每根标准骨骼的静止世界坐标与父骨骼索引,
|
||||
* 再去掉模型自身的原点偏移、抹平该模型特有的左右/前后不对称(±0.5 单位级),
|
||||
* 得到下方这套对称的标准比例。复现方式见 `test/mmd-vmd.spec.ts` 中的
|
||||
* 比例自检(髋高比、腿长比、肩高比、腿长 = 大腿 + 小腿)。
|
||||
*
|
||||
* ## 坐标系
|
||||
*
|
||||
* 本文件用的是**本项目的右手系**(与 BVH 通路一致):Y 向上、+Z 为角色
|
||||
* 正面、+X 为**角色自身左侧**(与 MediaPipe 的 left_* 语义一致)。
|
||||
* MMD 原生是左手系(正面为 -Z),VMD 的旋转与位移由
|
||||
* `mmd-pose.ts` 统一做 Z 镜像后送入本骨架,两者保持一致。
|
||||
*
|
||||
* 关键静止高度(单位与 MMD 一致,模型总高约 19.4):
|
||||
* つま先 y = 0.00 足首 y = 1.43 ひざ y = 6.57 足(股関節) y = 11.50
|
||||
* センター y = 10.88 上半身2 y = 14.10 首 y = 16.21 頭 y = 17.16
|
||||
* 左腕(肩関節) y = 15.70
|
||||
*/
|
||||
export interface MmdBoneDef {
|
||||
/** 规范名(MMD 标准日文名) */
|
||||
readonly name: string;
|
||||
/** 父骨骼规范名;null 表示根 */
|
||||
readonly parent: string | null;
|
||||
/** 相对父骨骼头部的位置偏移(静止姿态) */
|
||||
readonly offset: Vec3;
|
||||
/** 其他等价写法(全角/半角、常见改写),用于匹配真实 VMD 中的骨骼名 */
|
||||
readonly aliases?: readonly string[];
|
||||
}
|
||||
|
||||
/** 标准骨架的静止比例常量(实测值,见文件头注释) */
|
||||
const REST = {
|
||||
/** センター(骨盆中心)高度 */
|
||||
centerHeight: 10.88,
|
||||
/** 骨盆到上半身分段 */
|
||||
groove: 0.12,
|
||||
waist: 1.23,
|
||||
upperBody: 0.56,
|
||||
chest: 1.31,
|
||||
neck: 2.11,
|
||||
head: 0.95,
|
||||
/** 下半身到髋关节 */
|
||||
lowerBody: 0.5,
|
||||
hipToJoint: -1.23,
|
||||
/** 肩关节链 */
|
||||
shoulderX: 0.15,
|
||||
shoulderY: 1.97,
|
||||
shoulderJointX: 0.98,
|
||||
shoulderJointY: -0.37,
|
||||
upperArmX: 2.11,
|
||||
upperArmY: -1.5,
|
||||
forearmX: 2.08,
|
||||
forearmY: -1.47,
|
||||
/** 腿 */
|
||||
hipX: 0.95,
|
||||
kneeX: -0.31,
|
||||
thighY: -4.93,
|
||||
ankleX: -0.33,
|
||||
shinY: -5.14,
|
||||
ankleHeight: 1.43,
|
||||
toeX: 0.12,
|
||||
toeY: -1.43,
|
||||
toeZ: 1.07,
|
||||
} as const;
|
||||
|
||||
/** 上半身系(腰以上) */
|
||||
const UPPER_BODY: MmdBoneDef[] = [
|
||||
{ name: '全ての親', parent: null, offset: [0, 0, 0], aliases: ['全ての親', 'AllParent'] },
|
||||
{
|
||||
name: 'センター',
|
||||
parent: '全ての親',
|
||||
offset: [0, REST.centerHeight, 0],
|
||||
aliases: ['センター', 'Center'],
|
||||
},
|
||||
{
|
||||
name: 'グルーブ',
|
||||
parent: 'センター',
|
||||
offset: [0, REST.groove, 0],
|
||||
aliases: ['グルーブ', 'Groove'],
|
||||
},
|
||||
{ name: '腰', parent: 'グルーブ', offset: [0, REST.waist, 0], aliases: ['腰', 'Waist'] },
|
||||
{
|
||||
name: '上半身',
|
||||
parent: '腰',
|
||||
offset: [0, REST.upperBody, 0],
|
||||
aliases: ['上半身', 'UpperBody'],
|
||||
},
|
||||
{
|
||||
name: '上半身2',
|
||||
parent: '上半身',
|
||||
offset: [0, REST.chest, 0],
|
||||
aliases: ['上半身2', 'UpperBody2'],
|
||||
},
|
||||
{ name: '首', parent: '上半身2', offset: [0, REST.neck, 0], aliases: ['首', 'Neck'] },
|
||||
{ name: '頭', parent: '首', offset: [0, REST.head, 0], aliases: ['頭', 'Head'] },
|
||||
// 注意:下半身是躯干骨(腰的子节点),必须放在这里而不是 limbBones(),
|
||||
// 否则会被左右两侧各生成一次,造成重名。
|
||||
{
|
||||
name: '下半身',
|
||||
parent: '腰',
|
||||
offset: [0, REST.lowerBody, 0],
|
||||
aliases: ['下半身', 'LowerBody'],
|
||||
},
|
||||
];
|
||||
|
||||
/** 手臂与腿(左右对称生成) */
|
||||
function limbBones(side: '左' | '右', sign: 1 | -1): MmdBoneDef[] {
|
||||
return [
|
||||
{
|
||||
name: `${side}肩`,
|
||||
parent: '上半身2',
|
||||
offset: [sign * REST.shoulderX, REST.shoulderY, 0],
|
||||
aliases: [`${side}肩`],
|
||||
},
|
||||
{
|
||||
name: `${side}腕`,
|
||||
parent: `${side}肩`,
|
||||
offset: [sign * REST.shoulderJointX, REST.shoulderJointY, 0],
|
||||
aliases: [`${side}腕`],
|
||||
},
|
||||
{
|
||||
name: `${side}ひじ`,
|
||||
parent: `${side}腕`,
|
||||
offset: [sign * REST.upperArmX, REST.upperArmY, 0],
|
||||
aliases: [`${side}ひじ`, `${side}肘`],
|
||||
},
|
||||
{
|
||||
name: `${side}手首`,
|
||||
parent: `${side}ひじ`,
|
||||
offset: [sign * REST.forearmX, REST.forearmY, 0],
|
||||
aliases: [`${side}手首`],
|
||||
},
|
||||
{
|
||||
name: `${side}足`,
|
||||
parent: '下半身',
|
||||
offset: [sign * REST.hipX, REST.hipToJoint, 0],
|
||||
aliases: [`${side}足`],
|
||||
},
|
||||
{
|
||||
name: `${side}ひざ`,
|
||||
parent: `${side}足`,
|
||||
offset: [sign * REST.kneeX, REST.thighY, 0],
|
||||
aliases: [`${side}ひざ`, `${side}膝`],
|
||||
},
|
||||
{
|
||||
name: `${side}足首`,
|
||||
parent: `${side}ひざ`,
|
||||
offset: [sign * REST.ankleX, REST.shinY, 0],
|
||||
aliases: [`${side}足首`],
|
||||
},
|
||||
{
|
||||
name: `${side}つま先`,
|
||||
parent: `${side}足首`,
|
||||
offset: [sign * REST.toeX, REST.toeY, REST.toeZ],
|
||||
aliases: [`${side}つま先`],
|
||||
},
|
||||
// ---- IK 目标(不参与蒙皮,只作为腿的解算目标)----
|
||||
// 实测:参考模型里「足IK親」挂在「全ての親」下(不在 センター 下),
|
||||
// 因此 IK 目标**不随 センター 的位移移动**;这里照实还原。
|
||||
{
|
||||
name: `${side}足IK親`,
|
||||
parent: '全ての親',
|
||||
offset: [sign * REST.ankleX, 0, 0],
|
||||
aliases: [`${side}足IK親`, `${side}足IK親`],
|
||||
},
|
||||
{
|
||||
name: `${side}足IK`,
|
||||
parent: `${side}足IK親`,
|
||||
offset: [0, REST.ankleHeight, 0],
|
||||
aliases: [`${side}足IK`, `${side}足IK`],
|
||||
},
|
||||
{
|
||||
name: `${side}つま先IK`,
|
||||
parent: `${side}足IK`,
|
||||
offset: [sign * REST.toeX, REST.toeY, REST.toeZ],
|
||||
aliases: [`${side}つま先IK`, `${side}つま先IK`],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const MMD_SKELETON: readonly MmdBoneDef[] = Object.freeze([
|
||||
...UPPER_BODY,
|
||||
...limbBones('左', 1),
|
||||
...limbBones('右', -1),
|
||||
]);
|
||||
|
||||
/** 规范名 → 定义 */
|
||||
export const MMD_BONE_BY_NAME: ReadonlyMap<string, MmdBoneDef> = new Map(
|
||||
MMD_SKELETON.map((bone) => [bone.name, bone]),
|
||||
);
|
||||
|
||||
/** 别名(含规范名本身)→ 规范名,用于把 VMD 中的骨骼名归一到标准骨架 */
|
||||
export const MMD_ALIAS_TO_NAME: ReadonlyMap<string, string> = (() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const bone of MMD_SKELETON) {
|
||||
map.set(bone.name, bone.name);
|
||||
for (const alias of bone.aliases ?? []) map.set(alias, bone.name);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
/** 双腿 IK 目标骨骼规范名 */
|
||||
export const LEG_IK: Readonly<Record<'左' | '右', { ik: string; knee: string; ankle: string; toe: string }>> = {
|
||||
左: { ik: '左足IK', knee: '左ひざ', ankle: '左足首', toe: '左つま先' },
|
||||
右: { ik: '右足IK', knee: '右ひざ', ankle: '右足首', toe: '右つま先' },
|
||||
};
|
||||
|
||||
/** 骨骼静止长度,供 IK 解算与自检使用 */
|
||||
export const LEG_LENGTHS = Object.freeze({
|
||||
thigh: Math.hypot(REST.kneeX, REST.thighY),
|
||||
shin: Math.hypot(REST.ankleX, REST.shinY),
|
||||
toe: Math.hypot(REST.toeX, REST.toeY, REST.toeZ),
|
||||
});
|
||||
|
||||
/** 关键静止高度,供比例自检使用 */
|
||||
export const REST_HEIGHTS = Object.freeze({
|
||||
toe: 0,
|
||||
ankle: REST.ankleHeight,
|
||||
knee: REST.ankleHeight - REST.shinY,
|
||||
hip: REST.ankleHeight - REST.shinY - REST.thighY,
|
||||
center: REST.centerHeight,
|
||||
chest: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest,
|
||||
neck: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest + REST.neck,
|
||||
head: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest + REST.neck + REST.head,
|
||||
});
|
||||
|
||||
/** 把 VMD 中的骨骼名归一到标准骨架名;无法识别时返回 null */
|
||||
export function normalizeMmdBoneName(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
return MMD_ALIAS_TO_NAME.get(trimmed) ?? null;
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import { StorageService } from '../storage/storage.service';
|
||||
import { parseBvh } from './bvh-parser';
|
||||
import { parseKeypointsJson, serializeFramesNdjson } from './keypoint-codec';
|
||||
import { KEYPOINT_FORMAT, KeypointFrame } from './mediapipe33';
|
||||
import { bvhToKeypointFrames } from './retarget';
|
||||
import { VMD_FPS } from './vmd-parser';
|
||||
import { bvhToKeypointFrames, worldFramesToKeypointFrames } from './retarget';
|
||||
import { convertToBvhWithAssimp } from './assimp';
|
||||
import { parseVmd } from './vmd-parser';
|
||||
import { vmdToWorldFrames } from './mmd-pose';
|
||||
|
||||
export interface PreprocessOutcome {
|
||||
frames: number;
|
||||
@@ -25,6 +28,8 @@ export interface PreprocessOutcome {
|
||||
* T-WEB-006:BVH / FBX → MediaPipe 33 点关键点序列预处理。
|
||||
*
|
||||
* - BVH:内置纯 TS 解析器 + FK + 重定向,无外部依赖
|
||||
* - VMD(MikuMikuDance):内置二进制解析器 + 标准 MMD 骨架 FK + 腿部 IK,
|
||||
* 再复用同一套重定向;无外部依赖
|
||||
* - glTF / GLB / FBX:调用可选的外部 Assimp 转为 BVH 后再走同一通路
|
||||
* - keypoints-json:直通校验与规范化
|
||||
*/
|
||||
@@ -104,6 +109,10 @@ export class PreprocessService {
|
||||
return this.fromBvh(text, motion.fps ?? undefined);
|
||||
}
|
||||
|
||||
if (motion.format === MotionFormat.Vmd) {
|
||||
return await this.fromVmd(sourcePath);
|
||||
}
|
||||
|
||||
if (motion.format === MotionFormat.KeypointsJson) {
|
||||
const text = await readFile(sourcePath, 'utf8');
|
||||
const { frames, fps } = parseKeypointsJson(text);
|
||||
@@ -130,6 +139,36 @@ export class PreprocessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VMD 通路:二进制解析 → 标准 MMD 骨架 FK(腿用 IK 目标解算)→ 世界坐标帧
|
||||
* → 复用 BVH 的重定向(骨盆归零 / 自适应缩放 / 图像空间投影 / 9 个关节角)。
|
||||
*
|
||||
* VMD 固定 30 fps,因此忽略上传时填写的 fps,避免时间轴被写错。
|
||||
*/
|
||||
private async fromVmd(sourcePath: string): Promise<{
|
||||
frames: KeypointFrame[];
|
||||
fps: number;
|
||||
durationMs: number;
|
||||
}> {
|
||||
const buffer = await readFile(sourcePath);
|
||||
const vmd = parseVmd(buffer);
|
||||
const { frames: worldFrames } = vmdToWorldFrames(vmd, { fps: VMD_FPS });
|
||||
if (worldFrames.length === 0) throw new BadRequestException('VMD 中没有可用的骨骼关键帧');
|
||||
const frames = worldFramesToKeypointFrames(worldFrames, VMD_FPS);
|
||||
|
||||
this.logger.log(
|
||||
`VMD 解析:模型「${vmd.modelName}」${vmd.maxFrame + 1} 帧 / ` +
|
||||
`${vmd.tracks.size} 条骨骼轨道 / morph ${vmd.morphCount} 条,` +
|
||||
`未识别骨骼 ${vmd.unknownBones.length} 种`,
|
||||
);
|
||||
|
||||
return {
|
||||
frames,
|
||||
fps: VMD_FPS,
|
||||
durationMs: Math.round((frames.length / VMD_FPS) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
private fromBvh(text: string, fpsOverride?: number): { frames: KeypointFrame[]; fps: number; durationMs: number } {
|
||||
const motion = parseBvh(text);
|
||||
if (motion.frameCount === 0) throw new BadRequestException('BVH 中没有帧');
|
||||
|
||||
@@ -36,19 +36,38 @@ const MIN_TORSO = 1e-3;
|
||||
* 骨盆归零 → 全局自适应缩放 → 投影到 MediaPipe 归一化图像空间 → 计算 9 个关节角。
|
||||
*/
|
||||
export function bvhToKeypointFrames(motion: BvhMotion, options: RetargetOptions = {}): KeypointFrame[] {
|
||||
const fps = options.fps && options.fps > 0 ? options.fps : 1 / motion.frameTime;
|
||||
|
||||
const worldFrames: Array<Map<string, Vec3>> = [];
|
||||
for (let f = 0; f < motion.frameCount; f += 1) {
|
||||
const positions = new Map<string, Vec3>();
|
||||
for (const [name, joint] of forwardKinematics(motion, f)) positions.set(name, joint.position);
|
||||
worldFrames.push(positions);
|
||||
}
|
||||
|
||||
return worldFramesToKeypointFrames(worldFrames, fps, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意骨架的逐帧世界坐标 → MediaPipe 33 点关键点序列。
|
||||
*
|
||||
* 与 `bvhToKeypointFrames` 共用同一套重定向规则(骨盆归零、自适应缩放、
|
||||
* 图像空间投影、9 个关节角),因此 BVH / VMD 等不同来源的动作产物完全一致。
|
||||
* `worldFrames[i]` 的键名需匹配 `jointMap` 中的候选名。
|
||||
*/
|
||||
export function worldFramesToKeypointFrames(
|
||||
worldFrames: ReadonlyArray<ReadonlyMap<string, Vec3>>,
|
||||
fps: number,
|
||||
options: RetargetOptions = {},
|
||||
): KeypointFrame[] {
|
||||
const jointMap = options.jointMap ?? DEFAULT_JOINT_MAP;
|
||||
const scaleFactor = options.scaleFactor ?? 0.35;
|
||||
const fps = options.fps && options.fps > 0 ? options.fps : 1 / motion.frameTime;
|
||||
|
||||
// 第一遍:逐帧解算原始 3D 位置(骨盆归零前)
|
||||
const rawFrames: RawPoint[][] = [];
|
||||
let maxAbs = 0;
|
||||
|
||||
for (let f = 0; f < motion.frameCount; f += 1) {
|
||||
const world = forwardKinematics(motion, f);
|
||||
const positions = new Map<string, Vec3>();
|
||||
for (const [name, joint] of world) positions.set(name, joint.position);
|
||||
|
||||
for (const positions of worldFrames) {
|
||||
const points = resolveFrame(positions, jointMap);
|
||||
|
||||
// 骨盆归零
|
||||
@@ -103,7 +122,7 @@ export function bvhToKeypointFrames(motion: BvhMotion, options: RetargetOptions
|
||||
* 依赖顺序:躯干 → 肩髋 → 肘/膝 → 腕/踝 → 头/手足末端。
|
||||
*/
|
||||
function resolveFrame(
|
||||
world: Map<string, Vec3>,
|
||||
world: ReadonlyMap<string, Vec3>,
|
||||
jointMap: Record<number, readonly string[]>,
|
||||
): RawPoint[] {
|
||||
const lookup = (index: number): Vec3 | null => {
|
||||
@@ -300,13 +319,13 @@ function resolveFrame(
|
||||
return points as RawPoint[];
|
||||
}
|
||||
|
||||
function firstValue(map: Map<string, Vec3>): Vec3 | undefined {
|
||||
function firstValue(map: ReadonlyMap<string, Vec3>): Vec3 | undefined {
|
||||
for (const value of map.values()) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 无躯干信息时的尺度兜底:取根关节到最远关节的距离 */
|
||||
function torsoGuess(world: Map<string, Vec3>): number {
|
||||
function torsoGuess(world: ReadonlyMap<string, Vec3>): number {
|
||||
const root = world.get('hips') ?? world.get('root') ?? world.get('pelvis');
|
||||
if (!root) return 1;
|
||||
let maxDistance = 0;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { Vec3 } from './math3d';
|
||||
import { normalizeMmdBoneName } from './mmd-skeleton';
|
||||
|
||||
/** VMD 骨骼关键帧 */
|
||||
export interface VmdBoneKey {
|
||||
frame: number;
|
||||
position: Vec3;
|
||||
/** 四元数 (x, y, z, w),MMD 原始数值 */
|
||||
rotation: readonly [number, number, number, number];
|
||||
/** 插值参数(贝塞尔),16 字节:x1[4] / y1[4] / x2[4] / y2[4],通道序 X, Y, Z, R */
|
||||
interpolation: Uint8Array;
|
||||
}
|
||||
|
||||
export interface VmdBoneTrack {
|
||||
/** VMD 中的原始骨骼名 */
|
||||
rawName: string;
|
||||
/** 归一化到标准骨架后的名字 */
|
||||
name: string;
|
||||
/** 按帧号升序 */
|
||||
keys: VmdBoneKey[];
|
||||
}
|
||||
|
||||
export interface VmdMotion {
|
||||
/** 文件头,例如 `Vocaloid Motion Data 0002` */
|
||||
header: string;
|
||||
/** 模型名(Shift-JIS 解码) */
|
||||
modelName: string;
|
||||
/** 最大帧号 */
|
||||
maxFrame: number;
|
||||
/** 归一化骨骼名 → 轨道 */
|
||||
tracks: Map<string, VmdBoneTrack>;
|
||||
/** 出现但未纳入标准骨架的骨骼名(去重、升序),用于日志与诊断 */
|
||||
unknownBones: string[];
|
||||
/** morph 关键帧数量(当前不使用,仅统计) */
|
||||
morphCount: number;
|
||||
/** 是否包含位移通道(区分 IK / FK 驱动需要) */
|
||||
hasPosition: boolean;
|
||||
}
|
||||
|
||||
const HEADER_SIZE = 30;
|
||||
const BONE_NAME_SIZE = 15;
|
||||
const BONE_KEY_SIZE = 111;
|
||||
const MORPH_KEY_SIZE = 23;
|
||||
/** MMD 固定 30 fps */
|
||||
export const VMD_FPS = 30;
|
||||
|
||||
const decoders = new Map<string, TextDecoder | null>();
|
||||
|
||||
function decoderFor(encoding: string): TextDecoder | null {
|
||||
if (!decoders.has(encoding)) {
|
||||
try {
|
||||
decoders.set(encoding, new TextDecoder(encoding, { fatal: false }));
|
||||
} catch {
|
||||
decoders.set(encoding, null);
|
||||
}
|
||||
}
|
||||
return decoders.get(encoding) ?? null;
|
||||
}
|
||||
|
||||
/** Shift-JIS 解码;Node 内置 ICU 不可用时退化为 latin1,避免直接抛错 */
|
||||
function decodeShiftJis(bytes: Uint8Array): string {
|
||||
const decoder = decoderFor('shift_jis');
|
||||
if (!decoder) return Buffer.from(bytes).toString('latin1');
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
/** 读取以 \0 填充的定长字符串(骨骼名:MMD 标准名是日文,按 Shift-JIS 解) */
|
||||
function readFixedString(buffer: Buffer, offset: number, size: number): string {
|
||||
return decodeShiftJis(buffer.subarray(offset, offset + size)).replace(/\0.*$/s, '').trim();
|
||||
}
|
||||
|
||||
/** 半角片假名(U+FF61–U+FF9F):GBK 被当成 Shift-JIS 解码时的典型症状 */
|
||||
const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/;
|
||||
|
||||
/**
|
||||
* 读取模型名。
|
||||
*
|
||||
* 标准 VMD 用 Shift-JIS,但相当多中文作者产出的 VMD 把模型名写成了 GBK。
|
||||
* GBK 字节按 Shift-JIS 解出来会夹杂半角片假名与生僻汉字(例如「キ釗ェウ瑶」),
|
||||
* 因此这里做一次启发式回退:出现半角片假名或替换字符时改用 GBK 再解一次。
|
||||
* 只影响日志里显示的模型名,不影响骨骼名匹配与解算结果。
|
||||
*/
|
||||
function readModelName(buffer: Buffer, offset: number, size: number): string {
|
||||
const bytes = buffer.subarray(offset, offset + size);
|
||||
const shiftJis = decodeShiftJis(bytes).replace(/\0.*$/s, '').trim();
|
||||
if (!HALF_WIDTH_KATAKANA.test(shiftJis) && !shiftJis.includes('\uFFFD')) return shiftJis;
|
||||
const gbkDecoder = decoderFor('gbk');
|
||||
if (!gbkDecoder) return shiftJis;
|
||||
const gbk = gbkDecoder.decode(bytes).replace(/\0.*$/s, '').trim();
|
||||
if (!gbk || gbk.includes('\uFFFD')) return shiftJis;
|
||||
return gbk;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERPOLATION = (() => {
|
||||
// 线性(x1=20, y1=20, x2=107, y2=107 是 MMD 默认的缓入缓出;这里默认用线性 0/0/127/127)
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
bytes[channel] = 0;
|
||||
bytes[4 + channel] = 0;
|
||||
bytes[8 + channel] = 127;
|
||||
bytes[12 + channel] = 127;
|
||||
}
|
||||
return bytes;
|
||||
})();
|
||||
|
||||
/**
|
||||
* 解析 VMD(MikuMikuDance 动作数据)二进制文件。
|
||||
*
|
||||
* 只依赖骨骼与 morph 两段;相机 / 灯光 / 自阴影 / IK / 可见性段落一律跳过,
|
||||
* 长度不足时按"文件被截断"报错,而不是静默产出错误数据。
|
||||
*/
|
||||
export function parseVmd(buffer: Buffer): VmdMotion {
|
||||
if (buffer.length < HEADER_SIZE + 4) {
|
||||
throw new Error('VMD 文件过小,可能不是有效的 VMD');
|
||||
}
|
||||
const header = readFixedString(buffer, 0, HEADER_SIZE);
|
||||
if (!header.startsWith('Vocaloid Motion Data')) {
|
||||
throw new Error(`VMD 文件头不正确:期望 "Vocaloid Motion Data",实际 "${header.slice(0, 32)}"`);
|
||||
}
|
||||
// 0001 版本模型名 10 字节,0002 起为 20 字节
|
||||
const modelNameSize = header.includes('0002') ? 20 : 10;
|
||||
let offset = HEADER_SIZE;
|
||||
const modelName = readModelName(buffer, offset, modelNameSize);
|
||||
offset += modelNameSize;
|
||||
|
||||
if (offset + 4 > buffer.length) throw new Error('VMD 文件在骨骼数量处被截断');
|
||||
const boneCount = buffer.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
const boneSectionEnd = offset + boneCount * BONE_KEY_SIZE;
|
||||
if (boneSectionEnd > buffer.length) {
|
||||
throw new Error(
|
||||
`VMD 骨骼段长度不足:声明 ${boneCount} 帧需要 ${boneCount * BONE_KEY_SIZE} 字节,` +
|
||||
`实际只剩 ${buffer.length - offset} 字节`,
|
||||
);
|
||||
}
|
||||
|
||||
const tracks = new Map<string, VmdBoneTrack>();
|
||||
const unknown = new Set<string>();
|
||||
let maxFrame = 0;
|
||||
let hasPosition = false;
|
||||
|
||||
for (let i = 0; i < boneCount; i += 1) {
|
||||
const base = offset + i * BONE_KEY_SIZE;
|
||||
const rawName = readFixedString(buffer, base, BONE_NAME_SIZE);
|
||||
const frame = buffer.readUInt32LE(base + BONE_NAME_SIZE);
|
||||
const position: Vec3 = [
|
||||
buffer.readFloatLE(base + 19),
|
||||
buffer.readFloatLE(base + 23),
|
||||
buffer.readFloatLE(base + 27),
|
||||
];
|
||||
const rotation: [number, number, number, number] = [
|
||||
buffer.readFloatLE(base + 31),
|
||||
buffer.readFloatLE(base + 35),
|
||||
buffer.readFloatLE(base + 39),
|
||||
buffer.readFloatLE(base + 43),
|
||||
];
|
||||
const interpolation = Uint8Array.from(buffer.subarray(base + 47, base + 63));
|
||||
|
||||
if (!Number.isFinite(frame)) continue;
|
||||
maxFrame = Math.max(maxFrame, frame);
|
||||
if (
|
||||
Math.abs(position[0]) > 1e-6 ||
|
||||
Math.abs(position[1]) > 1e-6 ||
|
||||
Math.abs(position[2]) > 1e-6
|
||||
) {
|
||||
hasPosition = true;
|
||||
}
|
||||
|
||||
const name = normalizeMmdBoneName(rawName);
|
||||
if (!name) {
|
||||
unknown.add(rawName);
|
||||
continue;
|
||||
}
|
||||
const track = tracks.get(name) ?? { rawName, name, keys: [] };
|
||||
track.keys.push({
|
||||
frame,
|
||||
position,
|
||||
rotation,
|
||||
interpolation: interpolation.length === 16 ? interpolation : DEFAULT_INTERPOLATION.slice(),
|
||||
});
|
||||
tracks.set(name, track);
|
||||
}
|
||||
|
||||
for (const track of tracks.values()) track.keys.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
offset = boneSectionEnd;
|
||||
|
||||
// ---- morph 段(跳过,仅统计)----
|
||||
let morphCount = 0;
|
||||
if (offset + 4 <= buffer.length) {
|
||||
morphCount = buffer.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
}
|
||||
const morphEnd = offset + morphCount * MORPH_KEY_SIZE;
|
||||
if (morphEnd > buffer.length) {
|
||||
throw new Error(
|
||||
`VMD morph 段长度不足:声明 ${morphCount} 帧需要 ${morphCount * MORPH_KEY_SIZE} 字节,` +
|
||||
`实际只剩 ${buffer.length - offset} 字节`,
|
||||
);
|
||||
}
|
||||
|
||||
if (tracks.size === 0) {
|
||||
throw new Error(`VMD 中没有可识别的标准骨骼关键帧(共 ${boneCount} 帧,全部未匹配)`);
|
||||
}
|
||||
|
||||
return {
|
||||
header,
|
||||
modelName,
|
||||
maxFrame,
|
||||
tracks,
|
||||
unknownBones: [...unknown].sort(),
|
||||
morphCount,
|
||||
hasPosition,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 采样:MMD 贝塞尔插值 ----------
|
||||
|
||||
/**
|
||||
* 求解 MMD 的三次贝塞尔缓动:控制点 (0,0) → (x1,y1) → (x2,y2) → (1,1)。
|
||||
* 以 x 为时间轴二分求参数,再取 y 作为缓动后的时间。
|
||||
*/
|
||||
function bezierEase(t: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
const curveX = (u: number): number => {
|
||||
const v = 1 - u;
|
||||
return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u;
|
||||
};
|
||||
const curveY = (u: number): number => {
|
||||
const v = 1 - u;
|
||||
return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u;
|
||||
};
|
||||
let low = 0;
|
||||
let high = 1;
|
||||
let u = t;
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
const x = curveX(u);
|
||||
if (Math.abs(x - t) < 1e-6) break;
|
||||
if (x < t) low = u;
|
||||
else high = u;
|
||||
u = (low + high) / 2;
|
||||
}
|
||||
return curveY(u);
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
/** 通道序号:0=X, 1=Y, 2=Z, 3=旋转 */
|
||||
function channelEase(t: number, interpolation: Uint8Array, channel: number): number {
|
||||
const x1 = (interpolation[channel] ?? 0) / 127;
|
||||
const y1 = (interpolation[4 + channel] ?? 0) / 127;
|
||||
const x2 = (interpolation[8 + channel] ?? 127) / 127;
|
||||
const y2 = (interpolation[12 + channel] ?? 127) / 127;
|
||||
return bezierEase(t, x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定帧号上采样轨道;超出关键帧范围时保持首/末帧姿态(MMD 行为)。
|
||||
* 返回 null 表示该轨道没有关键帧。
|
||||
*/
|
||||
export function sampleBoneTrack(
|
||||
track: VmdBoneTrack,
|
||||
frame: number,
|
||||
): { position: Vec3; rotation: [number, number, number, number] } | null {
|
||||
const keys = track.keys;
|
||||
if (keys.length === 0) return null;
|
||||
if (frame <= keys[0].frame) {
|
||||
return { position: [...keys[0].position] as Vec3, rotation: [...keys[0].rotation] as [number, number, number, number] };
|
||||
}
|
||||
const last = keys[keys.length - 1];
|
||||
if (frame >= last.frame) {
|
||||
return { position: [...last.position] as Vec3, rotation: [...last.rotation] as [number, number, number, number] };
|
||||
}
|
||||
|
||||
// 线性查找相邻关键帧(同一骨骼的关键帧通常不多,且轨道已按帧号排序)
|
||||
let hi = 1;
|
||||
while (hi < keys.length && keys[hi].frame < frame) hi += 1;
|
||||
const a = keys[hi - 1];
|
||||
const b = keys[hi];
|
||||
const span = b.frame - a.frame;
|
||||
const t = span > 0 ? (frame - a.frame) / span : 1;
|
||||
|
||||
const position: Vec3 = [
|
||||
lerp(a.position[0], b.position[0], channelEase(t, a.interpolation, 0)),
|
||||
lerp(a.position[1], b.position[1], channelEase(t, a.interpolation, 1)),
|
||||
lerp(a.position[2], b.position[2], channelEase(t, a.interpolation, 2)),
|
||||
];
|
||||
|
||||
// 四元数:MMD 用同一条缓动曲线做 nlerp,先做符号对齐避免走远路
|
||||
const e = channelEase(t, a.interpolation, 3);
|
||||
let dot =
|
||||
a.rotation[0] * b.rotation[0] +
|
||||
a.rotation[1] * b.rotation[1] +
|
||||
a.rotation[2] * b.rotation[2] +
|
||||
a.rotation[3] * b.rotation[3];
|
||||
const sign = dot < 0 ? -1 : 1;
|
||||
dot = Math.abs(dot);
|
||||
const rotation: [number, number, number, number] = [
|
||||
lerp(a.rotation[0], sign * b.rotation[0], e),
|
||||
lerp(a.rotation[1], sign * b.rotation[1], e),
|
||||
lerp(a.rotation[2], sign * b.rotation[2], e),
|
||||
lerp(a.rotation[3], sign * b.rotation[3], e),
|
||||
];
|
||||
const len = Math.hypot(rotation[0], rotation[1], rotation[2], rotation[3]);
|
||||
if (len > 1e-9) {
|
||||
rotation[0] /= len;
|
||||
rotation[1] /= len;
|
||||
rotation[2] /= len;
|
||||
rotation[3] /= len;
|
||||
}
|
||||
return { position, rotation };
|
||||
}
|
||||
|
||||
/** 轨道是否存在非零位移(用于判断该骨骼是否被当作 IK 目标驱动) */
|
||||
export function trackHasPositionMotion(track: VmdBoneTrack | undefined): boolean {
|
||||
if (!track) return false;
|
||||
return track.keys.some(
|
||||
(key) =>
|
||||
Math.abs(key.position[0]) > 1e-4 ||
|
||||
Math.abs(key.position[1]) > 1e-4 ||
|
||||
Math.abs(key.position[2]) > 1e-4,
|
||||
);
|
||||
}
|
||||
|
||||
/** 轨道是否存在非单位旋转 */
|
||||
export function trackHasRotationMotion(track: VmdBoneTrack | undefined): boolean {
|
||||
if (!track) return false;
|
||||
return track.keys.some((key) => {
|
||||
const [x, y, z, w] = key.rotation;
|
||||
return Math.hypot(x, y, z) > 1e-4 || Math.abs(Math.abs(w) - 1) > 1e-4;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user