Files
opendance-web/DSH.md
T
zijunchiang ccced34dd6 fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464
字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。
因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。

- default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的
  sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录)
- models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 →
  名称 匹配,都落空才回落到 VRM 优先规则并告警
- ModelDto 增加 sourceSha256(非压缩包上传为 null)
- scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析
  (文件当第一个参数时识别不出,会打印用法)
- 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论;
  README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正

另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系;
PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。

验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重
导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
2026-09-15 19:36:37 +08:00

293 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DSH.md —— opendance-web(OpenDance Studio 项目一:Web 管理服务端)
## 0. 本文件的作用
本文件是 **opendance-web 仓库内**的 DSH 执行约束。顶层说明书见用户提供的
《DSH 开发文档:OpenDance Studio》,二者冲突时以顶层文档为准。
## 1. 项目定位
Web 管理服务端是另两端(Windows 客户端、Android 辅助 App)的**唯一数据源**与
**契约权威**。它管理歌曲、VRM 模型与动作来源,并输出冻结的 REST / WebSocket /
数据模型契约。
```
现有 MySQL 实例(外部,本项目不部署)
│
▼
opendance-web ←── 本仓库
│ 冻结 REST API + 数据模型 + WebSocket 关键点协议
├──▶ opendance-windows
└──▶ opendance-android
```
## 2. 技术栈
| 组件 | 选型 |
|---|---|
| 后端 | NestJS 11 |
| ORM | TypeORM 0.3 |
| 数据库 | **现有 MySQL 兼容实例**(当前部署为 MariaDB 10.11.19),mysql2 驱动 |
| 管理前端 | React 18 + Vite + TypeScript |
| 3D 预览 | three.js + @pixiv/three-vrm |
| 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 外部 Assimp 转换(`models/model-convert.ts`);GLB 贴图内嵌见 `models/glb-textures.ts` |
| 默认模型种子 | `web-server/src/config/default-model.ts`(服务端/CLI 写入,运行期只读) |
| 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 |
## 3. 单次会话加载范围
允许加载:
1. 本文件 `DSH.md`
2. `project.yaml`
3. 当前 `tasks/xxx.md`
4. 该任务文件中 `load` 列出的文件
5. `contracts/` 中被明确引用的**锚点片段**
禁止加载:
- `opendance-windows` / `opendance-android` 的任何源码
- 非当前任务的 `tasks/*.md`
- 完整 `contracts/` 目录(只引用锚点)
- `node_modules/`、`dist/`、构建产物、二进制、模型文件、上传存储
## 4. 上下文预算
| 项 | 建议值 |
|---|---|
| 单任务文件数 | ≤ 6 |
| 单任务代码行数 | ≤ 1500 |
| 单任务 Token | ≤ 12K |
| 契约引用 | 只引用锚点,不加载全文 |
## 5. 目录结构
```
opendance-web/
├── project.yaml # 任务清单与状态
├── DSH.md # 本文件
├── tasks/ # 单任务说明书(001~010)
├── contracts/ # ★ 跨端契约(权威)
│ ├── VERSION # 契约版本(唯一来源)
│ ├── rest-api.yaml # OpenAPI 3.0
│ ├── websocket-keypoints.md # 239 字节关键点帧 + 控制消息
│ ├── data-models.md # 资源字段与默认模型选择规则
│ ├── skeleton-topology.md # MediaPipe 33 点拓扑 + 关节角
│ ├── model-formats.md # 支持格式、预处理产物、上传限制
│ ├── database.md # 现有 MySQL 连接与迁移约定
│ └── open-source-credits.md # 开源声明锚点(三端共用)
├── web-server/ # NestJS 服务端
│ ├── src/ # 源码(entities / models / preprocess)
│ │ ├── common/zip-reader.ts # 纯 TS ZIP 读取器(STORE/DEFLATE、ZIP64、zip-slip 防护)
│ │ ├── models/ # 模型上传 + 压缩包转换 + VRM 识别
│ │ │ ├── model-convert.ts # 解压 → 选 PMX → Assimp → 回读校验 → 回退
│ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk
│ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts
│ │ │ └── vrm-inspector.ts
│ │ ├── config/default-model.ts # 默认模型种子(由服务端/CLI 写入,运行期只读)
│ │ ├── migrations/ # TypeORM 迁移(含 AddModelConversionFields)
│ │ └── preprocess/ # BVH / VMD / FBX → MediaPipe33
│ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值
│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系→右手系 Z 镜像
│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
│ ├── admin/ # React 管理端
│ ├── test/ # 单元测试 + 端到端冒烟
│ │ # (mmd-vmd / model-archive / init-env / admin-port …)
│ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs
│ # / inspect-vmd.mjs(VMD 诊断)
│ # / import-default-model.mjs(默认模型导入 / 补写源码)
├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代)
│ ├── init-env.sh # ★ 首次启动的 .env 交互引导(install.sh 缺 .env 时调用)
│ ├── install.sh # 一键部署(systemd / OpenRC / SysVinit / 后台进程)
│ ├── uninstall.sh
│ ├── ctl.sh # 无 init 系统时的进程控制
│ ├── .env.example
│ ├── systemd/opendance-api.service
│ ├── openrc/opendance-api
│ ├── initd/opendance-api
│ └── proxy/ # ★ 反代配置说明(nginx / IIS,仅文档不部署)
├── scripts/package-release.sh # 发布产物打包
└── THIRD_PARTY.md
```
## 6. 硬性约束
1. **不部署 MySQL**。数据库为部署方提供的现有实例,连接信息只经环境变量注入。
**也不使用 Docker**,**也不部署反向代理**:本端只提供业务服务
(API + 自带的管理端页面),反代由另外的服务负责,
本项目只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。
业务服务监听**两个端口**(软件 API 端口 `PORT` + 管理端端口 `ADMIN_PORT`),
约定见 §9。
2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个
migration 必须可逆。
3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。
4. **来源五元组强制必填**:`source_type` / `source_name` / `source_url` / `license`
/ `author`。任何资产入库缺一即 400。
5. **契约先行**:改接口先改 `contracts/`,再提升 `contracts/VERSION`,同步
`web-server/src/config/contract-versions.ts`,最后才改实现。
`npm run contracts:check` 在构建前拦截漂移。
6. **不使用 SMPL** 及其衍生拓扑(许可风险),统一 MediaPipe 33 点。
7. **上传文件永不使用原始文件名作为路径**,一律 `sha256` 去重后归档。
## 7. 每任务的执行步骤
```bash
# 1. 静态与单元校验
cd web-server
npm run contracts:check
npm run typecheck
npm run test
# 2. 构建
npm run build
# 3. 端到端冒烟(需数据库可连通)
npm run start:prod &
npm run test:smoke
# 4. 数据库迁移
npm run migration:run
# 5. 提交
git checkout -b task/T-WEB-0XX
git add . && git commit -m "feat(web): <scope>"
git push origin task/T-WEB-0XX
# Gitea 创建 PR → 合并到 dev
```
## 8. 环境变量
见 `web-server/.env.example` 与 `deploy/.env.example`。首次部署的 `deploy/.env`
可由 `deploy/init-env.sh` 交互生成(见 §12)。生产关键项:
```
DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
DB_SYNCHRONIZE=false
PORT=3000 # 软件 API 端口(客户端调用,不含管理页面)
ADMIN_PORT=3001 # 管理端端口(默认 PORT + 1;等于 PORT 为单端口兼容模式)
HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0)
ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST
STORAGE_DIR=/data/storage
UPLOAD_MAX_BYTES=536870912
ASSIMP_BIN=assimp # 可选;上传 .zip 模型包时必需
MODEL_CONVERT_FORMAT=fbx # 压缩包首选格式;导出后回读校验,失败自动回退 glb
MODEL_CONVERT_PMX="Hatsune Miku.pmx" # 优先选用的 PMX 文件名(大小写不敏感)
MODEL_CONVERT_TIMEOUT_MS=180000
DEFAULT_MODEL_ID=1 # 可选;未设置时按 src/config/default-model.ts 的种子匹配
CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
```
## 9. 双端口约定(软件 API 端口 / 管理端端口)
- **软件 API 端口**:`PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App
调用,只提供 `/api`、`/health`、`/static`、`/contracts`;**不提供管理页面**,
`/`、`/songs` 等一律 404(有意设计,避免管理页暴露在客户端调用的端口上)。
- **管理端端口**:`ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问,
提供管理页面(含 SPA 深链接回退)与上面全部接口。
- `ADMIN_HOST` 可选,默认跟随 `HOST`;可用它把管理端限制在本机或内网。
- `ADMIN_PORT === PORT` 为**单端口兼容模式**:管理页面回到 API 端口,日志提示
「未分端口」,只 warn 不中止启动。
- 端口非法(非 1..65535 整数)在生产环境会中断启动。
- 启动日志分别打印两行:`软件 API 端口:…` 与 `管理端端口:…`。
- 反向代理按用途分别转发两个端口;`/health` 两个端口都提供,可用于存活探测。
细则见 `deploy/proxy/README.md` 与 `tasks/010-deploy.md`。
## 10. VMD 动作通路(MikuMikuDance)
```
.vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts
二进制 + Shift-JIS 标准 MMD 骨架 FK + 腿部 IK worldFramesToKeypointFrames()
+ 贝塞尔插值 + Z 镜像 (与 BVH 共用)
```
- `MotionFormat.Vmd = 'vmd'`,扩展名 `.vmd`,固定 **30 fps**(上传时填写的 `fps` 被忽略)。
- VMD 二进制:30 字节头 + 20 字节 Shift-JIS 模型名 + 111 字节/条的骨骼关键帧 + morph 段;
只有旋转(少数骨骼带位移)、不含骨骼长度,因此必须靠 `mmd-skeleton.ts` 的标准骨架
(静止比例从参考 PMX 实测)做 FK。
- **腿部驱动自动判定**:`足IK` 有非零位移 → 两骨骼解析 IK(膝盖朝角色正面弯,
不可达时收敛为伸直);否则用 FK 旋转。部分动作数据把腿烘焙成 FK。
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z 取反)
后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored`
下「膝盖朝前」占 94.1%,不转换仅 5.9%。
- `worldFramesToKeypointFrames()` 为 BVH / VMD 共用:骨盆归零 / 自适应缩放 /
图像空间投影 / 9 个关节角完全一致。
- 诊断工具:`node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]`
(打印头部/模型名/帧数/轨道数、未识别骨骼、IK 还是 FK、关键点序列异常统计、膝肘角分布)。
- 契约 `contracts/model-formats.md` §2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。
## 11. 模型压缩包转换(.zip → FBX / GLB)与默认模型种子
```
.zip(MMD 包)─▶ zip-reader.ts ─▶ pickModelFile() ─▶ model-convert.ts ─▶ glb-textures.ts
中央目录/EOCD 优先 MODEL_CONVERT_PMX Assimp 导出 GLB 贴图内嵌
STORE+DEFLATE 否则目录最浅、文件最大 + info 回读校验
拒绝 zip-slip/加密 失败回退 FBX → GLB
```
- `POST /api/models` 的 `file` 也接受 **`.zip`**,视为 MMD 模型包;只认包内 `.pmx`。
- 新增模块(均在 `web-server/src/`):
- `common/zip-reader.ts`:纯 TS 极简 ZIP 读取器(中央目录/EOCD、STORE+DEFLATE、
ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目、
不支持的压缩方式报错)。
- `models/model-convert.ts`:解压 → 选主模型 → Assimp 转换 → 回读校验 → 回退。
- `models/glb-textures.ts`:把 GLB 里 Assimp 写成外部 URI 的贴图内嵌进 BIN chunk。
- `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。
- `config/default-model.ts`:默认模型种子(服务端/CLI 写入,运行期只读)。
- **选主模型**:优先 `MODEL_CONVERT_PMX`(默认 `Hatsune Miku.pmx`,大小写不敏感);
否则取目录最浅、其次文件最大者。
- **转换与回退**:先试 `MODEL_CONVERT_FORMAT`(默认 `fbx`);**导出后必须用
`assimp info` 回读校验**,失败自动回退 `glb`,两次尝试写进 `conversion_note`。
实测 Assimp 6.0.4 对 714 骨骼的 Miku PMX 导出 FBX 失败(退出码 11,产物回读报
`FBX-DOM no Objects dictionary found`),实际入库为 GLB。
- **默认模型**:转换成功且 `autoDefault` 不为 false(默认 true)→ `is_primary=1` +
`status=published`,并把身份写进源码种子;`GET /api/catalog/export` 的
`defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子(按**上传压缩包**的 sha256 匹配;
产物 sha256 每次转换都变,只作记录)
→ 已发布且 is_primary 的 VRM → 任意已发布 VRM → id 最小的已发布模型。
- **溯源**:压缩包原件存为 `model-archive` 资产,记入 `models.source_asset_id`
(迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。
- **失败**:包内没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,
不产生模型记录。
- **文件名编码**:`common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart
文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`)。
- 契约:`contracts/model-formats.md` §1.1、`contracts/data-models.md` §4.1;
DTO 新增 `conversionNote` / `fileSha256`;测试 `test/model-archive.spec.ts`(22 项)。
## 12. 首次启动的 .env 引导(deploy/init-env.sh)与监听地址
- `deploy/init-env.sh`:交互式逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS /
DB_NAME / PORT / ADMIN_PORT / HOST`,回车保留默认;口令 `read -rs` 不回显、
按约定写盘不加引号;校验必填与端口范围;可用 Node + mysql2 做连通性自检
(口令只经环境变量传递、失败仅告警);只替换/追加这 8 个键、保留模板注释,
写完 `chmod 600` 并回读校验;结尾打印摘要(口令 `***`)与变更列表。
- 开关:`--force` / `--check` / `-y|--yes|--non-interactive` / `-i|--interactive` /
`--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`;
环境变量 `OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
- **非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null`/关闭,或带 `-y` 等开关 →
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
- 接入点:`deploy/install.sh` 缺 `deploy/.env` 时先调用 `init-env.sh` 引导,
失败才 die(若 `$APP_DIR/deploy/.env` 已存在则复用,保持原语义)。
- **监听地址默认 `0.0.0.0`**:`configuration.ts` 的
`bindHost = process.env.HOST ?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
(同样回落 `0.0.0.0`);`deploy/.env.example` 与 `web-server/.env.example`
均为 `HOST=0.0.0.0`;`install.sh` 的 `BIND_HOST="${HOST:-0.0.0.0}"`,
`init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。
(`install.sh` 的 `HEALTH_HOST`/`API_HOST` 是**本机健康检查**用的回环地址,
与对外监听的默认值无关。)
- 测试 `test/init-env.spec.ts`(16 项)。
## 13. 上下文超标时的拆分策略
| 超标原因 | 拆分方式 |
|---|---|
| 单任务涉及多个端点 | 按端点拆 |
| 单文件过大 | 按类/函数拆,先生成接口再分实现 |
| 契约全文加载 | 改用锚点引用 |
| 需要参考其他项目 | 只读契约,不读源码 |
| UI 与逻辑耦合 | UI 任务只调用已定义接口 |
| 测试与实现同任务 | 测试单独任务,共享接口摘要 |