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:
@@ -5,7 +5,7 @@
|
||||
|
||||
## 目标
|
||||
|
||||
上传动作文件(BVH / FBX / glTF / GLB / keypoints-json),**强制填写来源五元组**。
|
||||
上传动作文件(BVH / VMD / FBX / glTF / GLB / keypoints-json),**强制填写来源五元组**。
|
||||
|
||||
## load
|
||||
|
||||
@@ -16,6 +16,10 @@ contracts/data-models.md # 锚点:§3 Motion
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§3 上传限制
|
||||
web-server/src/motions/*
|
||||
web-server/src/storage/storage.service.ts
|
||||
web-server/src/preprocess/vmd-parser.ts # VMD 上传后的预处理链路
|
||||
web-server/src/preprocess/mmd-skeleton.ts
|
||||
web-server/src/preprocess/mmd-pose.ts
|
||||
web-server/src/preprocess/retarget.ts # worldFramesToKeypointFrames()
|
||||
```
|
||||
|
||||
## 端点
|
||||
@@ -41,11 +45,21 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
|
||||
|
||||
## 校验
|
||||
|
||||
- 扩展名白名单:`.bvh .fbx .gltf .glb .json`,其他 → 400
|
||||
- 扩展名白名单:`.bvh .vmd .fbx .gltf .glb .json`,其他 → 400
|
||||
- 来源五元组缺任一项 → 400
|
||||
- `songId` 指向不存在的曲目 → 400
|
||||
- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
|
||||
|
||||
## VMD 支持(`.vmd`)
|
||||
|
||||
- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
|
||||
- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
|
||||
`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)→ `mmd-skeleton.ts`(标准骨架)
|
||||
→ `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)→ `retarget.ts` 的
|
||||
`worldFramesToKeypointFrames()`(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角)
|
||||
- 固定 30 fps;腿部按 `足IK` 是否带位移自动选 IK 或 FK
|
||||
- 上传后处理失败与 BVH 一致:写入 `preprocessState=failed` 与可读错误
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
@@ -54,3 +68,16 @@ npm run test:smoke
|
||||
|
||||
冒烟 [2] 段落:上传 201、自动识别 `format=bvh`、`fileSize` 记录、缺来源 400、
|
||||
`evil.exe` 400。
|
||||
|
||||
VMD 实测(用 `../示例文件/` 下的两个样本):
|
||||
|
||||
| 样本 | 帧数 | 腿部驱动 | 结果 |
|
||||
|---|---|---|---|
|
||||
| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
|
||||
| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
|
||||
|
||||
上传前可先用诊断工具自查(只读):
|
||||
|
||||
```bash
|
||||
node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]
|
||||
```
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# T-WEB-006 BVH/FBX → 关键点序列预处理
|
||||
# T-WEB-006 BVH/VMD/FBX → 关键点序列预处理
|
||||
|
||||
- **状态**:done
|
||||
- **依赖**:T-WEB-004
|
||||
|
||||
## 目标
|
||||
|
||||
把上传的 3D 动作数据转换为与 Android 端输出**同构**的 MediaPipe 33 点关键点序列,
|
||||
使 Windows 端 DTW 引擎只有一条输入通路。
|
||||
把上传的 3D 动作数据(BVH / VMD / FBX / glTF / keypoints-json)转换为与 Android
|
||||
端输出**同构**的 MediaPipe 33 点关键点序列,使 Windows 端 DTW 引擎只有一条输入通路。
|
||||
|
||||
## load
|
||||
|
||||
```
|
||||
DSH.md
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射、§2.3 VMD
|
||||
contracts/skeleton-topology.md # 锚点:§1 33 点、§3 父节点表、§5 关节角
|
||||
web-server/src/preprocess/*
|
||||
```
|
||||
@@ -29,9 +29,12 @@ web-server/src/preprocess/*
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
|
||||
| `vmd-parser.ts` | VMD 二进制解析:30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼关键帧 + morph 段;MMD 贝塞尔插值,固定 30fps |
|
||||
| `mmd-skeleton.ts` | 标准 MMD 骨架(含全角/半角 `IK`/`IK` 别名归一);静止比例从参考 PMX 实测 |
|
||||
| `mmd-pose.ts` | MMD FK + 两骨骼解析腿部 IK + 左手系→右手系 Z 镜像,输出逐帧世界坐标 |
|
||||
| `math3d.ts` | 向量/矩阵工具、旋转合成、关节角余弦 |
|
||||
| `joint-map.ts` | BVH 关节名 → MediaPipe 点索引的默认映射表 |
|
||||
| `retarget.ts` | 单帧解算 35 项(33 点 + neck/pelvis 虚拟关节)→ 骨盆归零 → 自适应缩放 → 投影 |
|
||||
| `joint-map.ts` | 关节名 → MediaPipe 点索引的默认映射表 |
|
||||
| `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 → 骨盆归零 → 自适应缩放 → 投影 → 9 个关节角;BVH 与 VMD 共用 |
|
||||
| `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 |
|
||||
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF → BVH) |
|
||||
| `preprocess.service.ts` | 编排、落盘、状态与错误记录 |
|
||||
@@ -49,13 +52,35 @@ web-server/src/preprocess/*
|
||||
| 格式 | 处理 |
|
||||
|---|---|
|
||||
| `.bvh` | 内置解析器,无外部依赖 |
|
||||
| `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 |
|
||||
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
|
||||
| `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` |
|
||||
|
||||
## VMD 通路
|
||||
|
||||
```
|
||||
.vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts
|
||||
二进制 + Shift-JIS 标准 MMD 骨架 FK + 腿部 IK worldFramesToKeypointFrames()
|
||||
+ 贝塞尔插值 + Z 镜像 (与 BVH 共用)
|
||||
```
|
||||
|
||||
- VMD 不含骨骼长度、只有旋转(少数骨骼带位移),必须靠 `mmd-skeleton.ts` 的
|
||||
标准骨架做 FK;骨骼名按别名表归一(全角 `IK` / 半角 `IK`),未纳入标准骨架的
|
||||
骨骼(裙摆 / 物理 / 表情等)被忽略但计数。
|
||||
- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 → 两骨骼解析 IK(膝盖朝角色
|
||||
正面弯,目标不可达时收敛为伸直,不产生 NaN);否则走 FK 旋转(部分动作数据把
|
||||
腿烘焙成 FK)。
|
||||
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z
|
||||
取反)后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,
|
||||
`zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
|
||||
- 重定向**完全复用 BVH 通路**:`worldFramesToKeypointFrames()` 负责骨盆归零 /
|
||||
自适应缩放 / 图像空间投影 / 9 个关节角,因此两种来源的产物结构一致。
|
||||
- 固定 **30 fps**,上传时填写的 `fps` 被忽略。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
npm run test # 25 例:解析 / FK / 重定向 / 编解码 / 拓扑 / 契约版本
|
||||
npm run test # 85 例(含 31 例 VMD):解析 / FK / 重定向 / 编解码 / 拓扑 / 契约版本
|
||||
npm run test:smoke # 冒烟 [3] 段落
|
||||
```
|
||||
|
||||
@@ -63,5 +88,11 @@ npm run test:smoke # 冒烟 [3] 段落
|
||||
MediaPipe 归一化范围、置信度分层、时间戳按帧率推进、序列随时间变化、
|
||||
NDJSON 往返、解码器拒绝错误长度与 magic。
|
||||
|
||||
VMD 侧另测:二进制解析与截断报错、贝塞尔/四元数插值、标准骨架比例与镜像、
|
||||
FK 与腿部 IK(可达/不可达/退化)、手性对照、合成 VMD 端到端 0 NaN / 0 越界。
|
||||
|
||||
两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
|
||||
约 0.4s、`恋爱循环.vmd` 4604 帧(烘焙 FK)约 2.5s,均 0 NaN / 0 越界。
|
||||
|
||||
样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲,
|
||||
21 关节 / 63 通道),可复现。
|
||||
21 关节 / 63 通道),可复现;VMD 诊断可用 `scripts/inspect-vmd.mjs`。
|
||||
|
||||
+47
-24
@@ -18,6 +18,11 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
|
||||
> 只需要给出 iis 和 nginx 反代的说明文档,不用部署反代服务。"
|
||||
> 于是 install.sh **彻底移除 nginx 的安装与站点配置**,管理端改由 Node 服务自身
|
||||
> 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。
|
||||
> 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT`
|
||||
> (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口
|
||||
> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404),
|
||||
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
|
||||
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
|
||||
|
||||
## load
|
||||
|
||||
@@ -36,22 +41,23 @@ deploy/proxy/README.md
|
||||
**职责边界**:本端只提供**业务服务**;反向代理由另外的服务/团队负责。
|
||||
|
||||
```
|
||||
浏览器 ──▶ 反向代理(外部提供,IIS / nginx / 网关…)
|
||||
│ 整体转发,不需要托管静态文件
|
||||
Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供接口,无管理页面)
|
||||
├── /api/ REST API
|
||||
├── /health 健康检查(数据库不可用返回 503)
|
||||
├── /static/ 上传文件
|
||||
└── /contracts/ 契约快照
|
||||
(/、/songs 等返回 404,有意设计)
|
||||
|
||||
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关…)──▶ 管理端端口 :3001
|
||||
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
|
||||
│ ├── /assets/ 前端静态资源
|
||||
│ └── 上面全部接口
|
||||
▼
|
||||
Node 业务服务 :3000
|
||||
├── / 管理端页面(本服务自带,含 SPA 深链接回退)
|
||||
├── /assets/ 前端静态资源
|
||||
├── /api/ REST API
|
||||
├── /health 健康检查(数据库不可用返回 503)
|
||||
├── /static/ 上传文件
|
||||
└── /contracts/ 契约快照
|
||||
│
|
||||
▼
|
||||
现有 MySQL / MariaDB 实例
|
||||
现有 MySQL / MariaDB 实例(两个端口共用同一 Node 进程)
|
||||
```
|
||||
|
||||
本服务同时提供 API 与管理端页面(`ADMIN_DIR`),因此反代**只需一条全匹配转发规则**,
|
||||
本服务一个 Node 进程**同时监听两个端口**(软件 API 端口 + 管理端端口),
|
||||
管理页面只在管理端口提供;反代按用途分别转发,每个端口各一条全匹配规则即可,
|
||||
既不用在代理层托管静态文件,也不用配置 SPA 回退(`try_files`)。
|
||||
|
||||
`deploy/install.sh` **不安装、不配置任何反向代理软件**,收尾时只打印反代交接指引。
|
||||
@@ -60,9 +66,9 @@ deploy/proxy/README.md
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查 |
|
||||
| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查;汇总打印两个端口与防火墙放行提示 |
|
||||
| `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 |
|
||||
| `deploy/.env.example` | 部署环境变量样例(含 `HOST` 说明与上传上限告警) |
|
||||
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
|
||||
| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 |
|
||||
| `deploy/openrc/opendance-api` | OpenRC 服务脚本 |
|
||||
| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
|
||||
@@ -93,11 +99,21 @@ deploy/proxy/README.md
|
||||
|
||||
## 关键实现
|
||||
|
||||
- **服务自身托管管理端**:`app.useStaticAssets(adminDir, …)` + SPA 回退中间件;
|
||||
回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/` 以及带扩展名的路径,
|
||||
保证未知 API 仍返回 404 而不是 HTML
|
||||
- **`HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用 `0.0.0.0` + 防火墙;
|
||||
默认值取 `0.0.0.0` 以保证开箱可用
|
||||
- **双端口**:一个 Node 进程创建两个 HTTP server——软件 API 端口(`PORT`,默认 3000)
|
||||
与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用
|
||||
`req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。
|
||||
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
|
||||
- **服务自身托管管理端**:`app.useStaticAssets(adminDir, …)` + SPA 回退中间件
|
||||
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
|
||||
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
|
||||
- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
|
||||
- **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用
|
||||
`0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在
|
||||
本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用
|
||||
- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
|
||||
启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn
|
||||
- **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与
|
||||
防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口)
|
||||
- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
|
||||
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
|
||||
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
|
||||
@@ -117,6 +133,9 @@ deploy/proxy/README.md
|
||||
cp deploy/.env.example deploy/.env # 填写 DB_*
|
||||
bash deploy/install.sh # 需 root(不需要 sudo)
|
||||
curl -fsS http://localhost:3000/health
|
||||
curl -fsS http://localhost:3001/health
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200(管理页面)
|
||||
```
|
||||
|
||||
| 验收项 | 状态 |
|
||||
@@ -125,8 +144,11 @@ curl -fsS http://localhost:3000/health
|
||||
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
|
||||
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证 |
|
||||
| `curl http://<host>:3000/health` 通过 | ✅ 直连后端端口已验证 |
|
||||
| 管理端页面可访问 | ✅ `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
|
||||
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
|
||||
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
|
||||
| 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 |
|
||||
| `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 |
|
||||
| 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error |
|
||||
| 不部署、不管理 MySQL | ✅ 部署脚本不含任何数据库安装/建库操作 |
|
||||
| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 |
|
||||
| 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 |
|
||||
@@ -134,13 +156,14 @@ curl -fsS http://localhost:3000/health
|
||||
> **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。
|
||||
> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
|
||||
> 未能在本机实机跑通。已完成的等价验证:
|
||||
> - 后端以 `node dist/main.js` 直连真实数据库启动,`/health` 200
|
||||
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径)
|
||||
> - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200
|
||||
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径),
|
||||
> 并确认 3000 上返回 404、3001 上返回 200
|
||||
> - `ADMIN_DIR` 环境变量生效,`index.html` 缺失时打印告警而非崩溃
|
||||
> - `npm run migration:run` 走编译产物成功
|
||||
> - 端到端冒烟 57 项全通过
|
||||
> - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验
|
||||
> - 反代文档中的占位符与后端实际行为逐条对照(端口、路径、上传上限、健康检查)
|
||||
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
|
||||
>
|
||||
> 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user