docs(readme): 新增初次部署清单与常见问题;统一 root 执行写法;清除 markdown 粗体标记与残留真实库名

This commit is contained in:
2026-09-15 21:27:29 +08:00
parent 9c8379e5eb
commit ebd871018f
19 changed files with 345 additions and 296 deletions
+42 -42
View File
@@ -1,4 +1,4 @@
# DSH.md —— opendance-web(OpenDance Studio 项目一:Web 管理服务端)
# DSH.md -- opendance-web(OpenDance Studio 项目一:Web 管理服务端)
## 0. 本文件的作用
@@ -24,7 +24,7 @@ opendance-web ←── 本仓库
## 2. 技术栈
| 组件 | 选型 |
|---|---|
|--|--|
| 后端 | NestJS 11 |
| ORM | TypeORM 0.3 |
| 数据库 | **现有 MySQL 兼容实例**(当前部署为 MariaDB 10.11.19),mysql2 驱动 |
@@ -33,7 +33,7 @@ opendance-web ←── 本仓库
| 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
| 模型主力格式 | **GLB / VRM 为一等公民**:`.glb`/`.vrm`/`.gltf` 直接导入(不重编码),`.pmx`/`.fbx` 服务端转换(优先 GLB、其次 VRM) |
| 模型检查 / 缩略图 | `models/model-inspector.ts`(面数 / 顶点 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性)、`models/model-thumbnail.ts`(纯 TS 光栅化 PNG,零原生依赖) |
| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 自动搜索包内模型 + 外部 Assimp 转换(`models/model-convert.ts`);GLB 打包 `models/gltf-packer.ts`、贴图内嵌 `models/glb-textures.ts`、GLB→VRM 封装 `models/vrm-writer.ts` |
| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 自动搜索包内模型 + 外部 Assimp 转换(`models/model-convert.ts`);GLB 打包 `models/gltf-packer.ts`、贴图内嵌 `models/glb-textures.ts`、GLB->VRM 封装 `models/vrm-writer.ts` |
| 默认模型种子 | `web-server/src/config/default-model.ts`(服务端/CLI 写入,运行期只读) |
| 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 |
@@ -57,7 +57,7 @@ opendance-web ←── 本仓库
## 4. 上下文预算
| 项 | 建议值 |
|---|---|
|--|--|
| 单任务文件数 | ≤ 6 |
| 单任务代码行数 | ≤ 1500 |
| 单任务 Token | ≤ 12K |
@@ -83,20 +83,20 @@ opendance-web/
│ ├── src/ # 源码(entities / models / preprocess)
│ │ ├── common/zip-reader.ts # 纯 TS ZIP 读取器(STORE/DEFLATE、ZIP64、zip-slip 防护)
│ │ ├── models/ # 模型上传 + 自动搜索/转换 + 分析 + 缩略图
│ │ │ ├── model-convert.ts # 选主模型 → 直接导入或 Assimp 转换 → 回读校验
│ │ │ ├── gltf-packer.ts # glTF(.gltf + .bin + 贴图)→ GLB 打包
│ │ │ ├── model-convert.ts # 选主模型 -> 直接导入或 Assimp 转换 -> 回读校验
│ │ │ ├── gltf-packer.ts # glTF(.gltf +.bin + 贴图)-> GLB 打包
│ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk
│ │ │ ├── vrm-writer.ts # GLB → VRM 1.0(写 VRMC_vrm 扩展)
│ │ │ ├── vrm-writer.ts # GLB -> VRM 1.0(写 VRMC_vrm 扩展)
│ │ │ ├── model-inspector.ts # 面数 / 顶点 / 骨骼树 / humanoid 覆盖 / 可驱动性
│ │ │ ├── model-thumbnail.ts # 纯 TS 光栅化 512×512 PNG(含蒙皮,零原生依赖)
│ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts
│ │ │ └── vrm-inspector.ts
│ │ ├── config/default-model.ts # 默认模型种子(由审核 / CLI 写入,运行期只读)
│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats)
│ │ └── preprocess/ # BVH / VMD / FBX → MediaPipe33
│ │ └── preprocess/ # BVH / VMD / FBX -> MediaPipe33
│ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值
│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系→右手系 Z 镜像
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系->右手系 Z 镜像
│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
│ ├── admin/ # React 管理端
│ │ ├── src/components/ModelReviewPanel.tsx # 模型审核面板 + 骨骼详细界面
@@ -130,7 +130,7 @@ opendance-web/
(API + 自带的管理端页面),反代由另外的服务负责,
本项目只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。
业务服务监听**两个端口**(软件 API 端口 `PORT` + 管理端端口 `ADMIN_PORT`),
约定见 §9。
约定见 9。
2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个
migration 必须可逆。
3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。
@@ -173,13 +173,13 @@ npm run migration:run
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
# Gitea 创建 PR -> 合并到 dev
```
## 8. 环境变量
见 `web-server/.env.example` 与 `deploy/.env.example`。首次部署的 `deploy/.env`
可由 `deploy/init-env.sh` 交互生成(见 §12)。生产关键项:
可由 `deploy/init-env.sh` 交互生成(见 12)。生产关键项:
```
DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
@@ -211,7 +211,7 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
- `ADMIN_PORT === PORT` 为**单端口兼容模式**:管理页面回到 API 端口,日志提示
「未分端口」,只 warn 不中止启动。
- 端口非法(非 1..65535 整数)在生产环境会中断启动。
- 启动日志分别打印两行:`软件 API 端口:…` 与 `管理端端口:…`。
- 启动日志分别打印两行:`软件 API 端口:...` 与 `管理端端口:...`。
- 反向代理按用途分别转发两个端口;`/health` 两个端口都提供,可用于存活探测。
细则见 `deploy/proxy/README.md` 与 `tasks/010-deploy.md`。
@@ -227,16 +227,16 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
- VMD 二进制:30 字节头 + 20 字节 Shift-JIS 模型名 + 111 字节/条的骨骼关键帧 + morph 段;
只有旋转(少数骨骼带位移)、不含骨骼长度,因此必须靠 `mmd-skeleton.ts` 的标准骨架
(静止比例从参考 PMX 实测)做 FK。
- **腿部驱动自动判定**:`足IK` 有非零位移 → 两骨骼解析 IK(膝盖朝角色正面弯,
- **腿部驱动自动判定**:`足IK` 有非零位移 -> 两骨骼解析 IK(膝盖朝角色正面弯,
不可达时收敛为伸直);否则用 FK 旋转。部分动作数据把腿烘焙成 FK。
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z 取反)
- **手性**: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 项)。
- 契约 `contracts/model-formats.md` 2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。
## 11. 模型格式策略、转换、分析与审核
@@ -244,17 +244,17 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
```
POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导入(不重编码)
├─ .pmx / .fbx ─────────▶ convertModelFile() Assimp → GLB(首选)
├─.pmx /.fbx ─────────▶ convertModelFile() Assimp -> GLB(首选)
└─.zip ─▶ zip-reader.ts ─▶ 自动搜索包内模型
├─.vrm /.glb /.gltf ─▶ 直接导入
└─.pmx /.fbx ─▶ model-convert.ts
├─ 1) assimp export → .glb
├─ 2) 失败 → .gltf → gltf-packer.ts 打包回 GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm → vrm-writer.ts 补 VRMC_vrm
├─ 1) assimp export ->.glb
├─ 2) 失败 ->.gltf -> gltf-packer.ts 打包回 GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm -> vrm-writer.ts 补 VRMC_vrm
└─ 每步 assimp info 回读校验
─▶ 入库(status=pending)
─▶ model-inspector.ts 分析 → models.stats
─▶ model-thumbnail.ts 缩略图 → models.thumbnail_asset_id
─▶ model-inspector.ts 分析 -> models.stats
─▶ model-thumbnail.ts 缩略图 -> models.thumbnail_asset_id
```
- `POST /api/models` 的 `file` 接受 `.glb` / `.vrm` / `.gltf` / `.pmx` / `.fbx` / `.zip`。
@@ -262,21 +262,21 @@ POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接
- `common/zip-reader.ts`:纯 TS 极简 ZIP 读取器(中央目录/EOCD、STORE+DEFLATE、
ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目、
不支持的压缩方式报错)。
- `models/model-convert.ts`:解压 → 自动搜索主模型 → 直接导入或 Assimp 转换 →
回读校验 → 回退。
- `models/model-convert.ts`:解压 -> 自动搜索主模型 -> 直接导入或 Assimp 转换 ->
回读校验 -> 回退。
- `models/gltf-packer.ts`:把 Assimp 导出的分离 `.gltf` + `.bin` + 贴图自行打包成 GLB。
- `models/glb-textures.ts`:把 GLB 里 Assimp 写成外部 URI 的贴图内嵌进 BIN chunk。
- `models/vrm-writer.ts`:GLB → VRM 1.0,只改 JSON chunk 写 `extensions.VRMC_vrm`,
- `models/vrm-writer.ts`:GLB -> VRM 1.0,只改 JSON chunk 写 `extensions.VRMC_vrm`,
不做任何几何/材质损失。
- `models/model-inspector.ts`:解析 GLB/VRM,算面数 / 顶点 / 贴图 / **骨骼树** /
humanoid 覆盖 / 可驱动性(缺哪些必需骨骼)/ warnings。
- `models/model-thumbnail.ts`:纯 TS 软件光栅化 512×512 PNG(含蒙皮),零原生依赖。
- `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。
- `config/default-model.ts`:默认模型种子(审核 / CLI 写入,运行期只读)。
- **压缩包自动搜索**:优先级 `.vrm` → `.glb` → `.gltf` → `.pmx` → `.fbx`;同一优先级内
按 `MODEL_CONVERT_PMX` 指名字 → 目录最浅 → 文件最大。命中 glTF 系格式时**直接导入**,
- **压缩包自动搜索**:优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内
按 `MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大。命中 glTF 系格式时**直接导入**,
只有 `.pmx`/`.fbx` 才转换。压缩包原件存为 `model-archive` 资产。
- **转换与回退**:GLB 直出失败 → glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB
- **转换与回退**:GLB 直出失败 -> glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB
之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。
**每一步产物都必须用 `assimp info` 回读校验**:实测 Assimp 会"写出损坏文件"
(FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码
@@ -290,16 +290,16 @@ POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接
① 审核通过带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)或
② `POST /api/models/:id/primary`(仅 `published`,否则 **409**)时设置,此时写库
`is_primary` 并把身份写进源码种子。
`GET /api/catalog/export` 的 `defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子
`GET /api/catalog/export` 的 `defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` -> 源码种子
(按**上传压缩包**的 sha256 匹配;产物 sha256 每次转换都变,只作记录)
→ 已发布且 is_primary 的 VRM → 任意已发布 VRM → id 最小的已发布模型。
-> 已发布且 is_primary 的 VRM -> 任意已发布 VRM -> id 最小的已发布模型。
- **溯源**:压缩包原件记入 `models.source_asset_id`
(迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。
- **失败**:包内没有可用模型、Assimp 不可用、两条路径都失败 → **HTTP 422**,
- **失败**:包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,
不产生模型记录。
- **文件名编码**:`common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart
文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`)。
- 契约:`contracts/model-formats.md` §1.1、`contracts/data-models.md` §4.1;
文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`)。
- 契约:`contracts/model-formats.md` 1.1、`contracts/data-models.md` 4.1;
DTO 新增 `conversionNote` / `fileSha256` / `stats` / `thumbnailUrl`。
### 11.1 审核要求(模型)
@@ -314,26 +314,26 @@ POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接
- **骨骼详细界面**:扁平骨骼树按 `depth` 缩进,显示骨骼名、是否参与蒙皮、
humanoid 映射徽章、子节点数;支持按名搜索(`admin/src/lib/bone-tree.ts`
保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行。
- **通过**:可勾选「同时设为全局默认模型」→ 请求体 `setPrimary:true`。
- **通过**:可勾选「同时设为全局默认模型」-> 请求体 `setPrimary:true`。
- **驳回**:必须填写审核意见(400)。
- 实测:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` → 200、
`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…`;
- 实测:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200、
`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
重建后 `defaultModelId` 由 VRM 变为 **22**。
### 11.2 实测数据(v0.2.0)
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 → **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`;
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 -> **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`;
分析 `vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240
bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false
maxBoneDepth=23`,`missingRequiredBones` 为全部 16 根(日文 MMD 骨名无法映射)。
- 直接上传 `.glb`:**HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
入库文件 sha256 与源文件**一致**。
- 缩略图:512×512 PNG / 15,043 字节,纯 TS 光栅化(含蒙皮),目视确认为居中的
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60–121ms(中位 79ms)。
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM");
列表页显示 40px 缩略图。
- 测试:`npx vitest run` → **10 个文件 / 168 项通过**
- 测试:`npx vitest run` -> **10 个文件 / 168 项通过**
(`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、
`model-archive` 24、`mmd-vmd` 31、`preprocess` 25、`init-env` 16、
`admin-port` 16、`access-log` 9、`vrm-inspector` 6)。
@@ -350,7 +350,7 @@ POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接
环境变量 `OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
- **非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null`/关闭,或带 `-y` 等开关 →
- **非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null`/关闭,或带 `-y` 等开关 ->
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
- 接入点:`deploy/install.sh` 缺 `deploy/.env` 时先调用 `init-env.sh` 引导,
失败才 die(若 `$APP_DIR/deploy/.env` 已存在则复用,保持原语义)。
@@ -366,7 +366,7 @@ POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接
## 13. 上下文超标时的拆分策略
| 超标原因 | 拆分方式 |
|---|---|
|--|--|
| 单任务涉及多个端点 | 按端点拆 |
| 单文件过大 | 按类/函数拆,先生成接口再分实现 |
| 契约全文加载 | 改用锚点引用 |
+86 -37
View File
@@ -5,27 +5,56 @@ OpenDance Studio -- Web 管理服务端 v0.2.0 使用说明
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
动作来源, 是 Windows 客户端与 Android 辅助 App 的唯一数据源与契约权威。
重要: 本服务端 **不部署、不管理 MySQL**。数据库为部署方提供的现有实例,
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
连接信息通过 .env 注入。
--
一、部署到 Linux 服务器(业务服务, 不含反向代理)
--
初次部署(从零跑起来)
按顺序做, 每步都有验证方法:
1. 准备一台 Linux 服务器, 以 root 登录(不需要 sudo), 装好 Node.js 22+
node -v # 期望 v22 或更高
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
本项目不会创建数据库本身
3. 取得发布包并校验(地址用你们自己的制品库替换)
curl -LO https://<制品库地址>/opendance-web-v0.2.0.tar.gz
curl -LO https://<制品库地址>/SHA256SUMS
sha256sum -c SHA256SUMS # 期望输出 "OK"
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
tar -xzf opendance-web-v0.2.0.tar.gz && cd opendance-web-v0.2.0
bash deploy/init-env.sh
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
bash deploy/install.sh
6. 验证(把 <服务器地址> 换成实际地址)
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
curl -sI http://<服务器地址>:3001/ | head -1 # 期望 200, 管理端页面
curl -sI http://<服务器地址>:3000/ | head -1 # 期望 404, API 端口不提供管理页
7. 可选: 回填历史模型的分析与缩略图(新装可跳过)
cd /opt/opendance/web-server
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
8. 浏览器打开 http://<服务器地址>:3001/ 进入管理端, 上传曲目/动作/模型并审核
模型上传后状态为"待审核", 审核通过才会被客户端拉取
安全约定: deploy/.env 已被 .gitignore 忽略, 不要提交; 文档、截图、工单里
也不要贴真实的数据库口令、内网地址或域名, 需要时用 <占位符> 代替。
职责边界
本端**只提供业务服务**:NestJS API + 管理端页面。
**反向代理由另外的服务/团队负责**, 本项目不安装、不配置任何反代软件,
本端只提供业务服务:NestJS API + 管理端页面。
反向代理由另外的服务/团队负责, 本项目不安装、不配置任何反代软件,
只在 deploy/proxy/ 下提供 IIS 与 nginx 的配置说明文档。
部署形态
Node 业务服务由系统服务托管, 同时监听**两个端口**, 职责分开:
Node 业务服务由系统服务托管, 同时监听两个端口, 职责分开:
软件 API 端口(默认 3000, 给 Windows/Godot 客户端与 Android App)
/api/ REST API
/health 健康检查
/static/ 上传文件
/contracts/ 契约快照
该端口**不提供管理页面**,/ 与 /songs 等一律返回 404(有意设计)
该端口不提供管理页面,/ 与 /songs 等一律返回 404(有意设计)
管理端端口(默认 3001, 给浏览器)
管理页面(本服务自带, 含 SPA 深链接回退)+ 上面全部接口
@@ -35,16 +64,16 @@ OpenDance Studio -- Web 管理服务端 v0.2.0 使用说明
前置要求
- Linux 服务器, 有 root 权限(不需要 sudo, 直接以 root 执行即可)
- Node.js 22 或更高版本
- 进程管理: systemd / OpenRC / SysVinit 均可; **三者都没有也能装**
- 进程管理: systemd / OpenRC / SysVinit 均可; 三者都没有也能装
(自动降级为 nohup 后台进程 + PID 文件 + crontab @reboot 开机自启)
- 反向代理: 由另外的服务提供, 本项目不要求、也不安装
- 一个可连通的现有 MySQL / MariaDB 实例, 并已创建数据库与账号:
CREATE DATABASE IF NOT EXISTS dpendance
CREATE DATABASE IF NOT EXISTS opendance
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'dpendance'@'%' IDENTIFIED BY '<密码>';
CREATE USER IF NOT EXISTS 'opendance_app'@'%' IDENTIFIED BY '<数据库口令>';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
ON dpendance.* TO 'dpendance'@'%';
ON opendance.* TO 'opendance_app'@'%';
FLUSH PRIVILEGES;
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
@@ -53,7 +82,7 @@ OpenDance Studio -- Web 管理服务端 v0.2.0 使用说明
tar -xzf opendance-web-v0.2.0.tar.gz
cd opendance-web-v0.2.0
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
sudo bash deploy/install.sh
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
也可跳过引导手工填写:
cp deploy/.env.example deploy/.env
@@ -69,7 +98,7 @@ deploy/.env 初始化引导(deploy/init-env.sh)
(若安装目录里已有上次安装留下的 .env, 则直接复用, 不会重复提问)。
也可以单独运行: 逐项询问数据库与端口, 回车保留默认值, 写盘前做必填项与
端口范围校验, 并可用项目自带的 mysql2 做一次数据库连通性自检--口令只经
环境变量传给子进程, 出错信息与摘要一律显示 ***。
环境变量传给子进程, 出错信息与摘要一律显示 *。
bash deploy/init-env.sh # 交互引导(终端里逐项询问)
bash deploy/init-env.sh --force # 已有 .env 也重新逐项询问
@@ -77,10 +106,10 @@ deploy/.env 初始化引导(deploy/init-env.sh)
逐项询问的字段(共 8 个):
DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST
DB_PASS 用 read -s 不回显; 所有字段按约定写成 `键=值`(**不加引号**),
DB_PASS 用 read -s 不回显; 所有字段按约定写成 `键=值`(不加引号),
因此口令请勿含会破坏 shell 语法的字符(& $ ` \ 空格; | < > ()!),
脚本检测到会告警。写回只替换或追加这 8 个键、保留模板注释, 写完
chmod 600 并回读校验; 结尾打印摘要(口令 ***)与本次变更列表。
chmod 600 并回读校验; 结尾打印摘要(口令 *)与本次变更列表。
开关:
--force 已有 .env 也重新逐项询问
@@ -111,7 +140,7 @@ deploy/.env 初始化引导(deploy/init-env.sh)
(软件 API 端口上没有管理页面: http://<host>:3000/ 返回 404, 属正常)
对外访问
由反向代理**按用途分别转发两个端口**:
由反向代理按用途分别转发两个端口:
浏览器管理界面 -> http://<后端地址>:3001
Windows/Godot 客户端与 Android App -> http://<后端地址>:3000
配置样例与说明(含两个端口的转发写法):
@@ -137,7 +166,7 @@ deploy/.env 初始化引导(deploy/init-env.sh)
例: APP_DIR=/srv/opendance bash deploy/install.sh
升级
解压新版本后重新执行 sudo bash deploy/install.sh 即可(会覆盖程序文件,
解压新版本后重新执行 bash deploy/install.sh 即可(会覆盖程序文件,
保留 deploy/.env 与上传数据)。
卸载
@@ -164,6 +193,26 @@ deploy/.env 初始化引导(deploy/init-env.sh)
反代配置: 不在本机, 无需在此维护
常见问题(初次部署)
- 安装脚本报"未找到 node" 或 Node 版本过低
装 Node.js 22+ 后重跑; 脚本会打印检测到的版本号。
- 安装脚本报"deploy/.env 中 DB_* 未设置"
先跑 bash deploy/init-env.sh 生成配置, 或手工 cp deploy/.env.example deploy/.env 后填写。
- 健康检查返回 503
服务起来了但连不上数据库: 核对 deploy/.env 的 DB_HOST/DB_PORT/DB_USER/DB_PASS/DB_NAME,
以及数据库账号是否允许从本机 IP 连接(GRANT ... TO 'user'@'%' 或具体主机)。
- 服务本机 curl 通、外部访问不了
bash deploy/check-access.sh 一次定位: 它会打印 HOST/PORT、实际监听地址、
对外 IP 自测、80/443 有无反代、防火墙放行情况, 并给出修复命令。
- 管理页面打开是 404
说明访问的是软件 API 端口(默认 3000); 管理页面在管理端端口(默认 3001)。
- 上传 .zip 模型包报 422 且提示 Assimp 不可用
PMX/FBX 转换依赖 Assimp: apt-get install -y assimp-utils libassimp6;
只上传 .glb / .vrm 则不需要它。
- 模型审核界面显示"未分析 / 无预览图"
该模型是升级前入库的: 调 POST /api/models/<id>/reanalyze, 或批量跑
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000。
--
二、本地开发
--
@@ -211,11 +260,11 @@ deploy/.env 初始化引导(deploy/init-env.sh)
DB_LOGGING 是否打印 SQL(默认 false)
PORT 软件 API 监听端口(默认 3000)。给 Windows/Godot 客户端
与 Android App 调用, 只提供 /api、/health、/static、
/contracts,**不提供管理页面**
/contracts,不提供管理页面
ADMIN_PORT 管理端端口(默认 PORT + 1, 即 3001)。浏览器访问管理界面
(管理页面 + 上面全部接口); 设为与 PORT 相同即为
单端口兼容模式, 日志会提示「未分端口」
HOST 监听地址(软件 API 端口),**默认 0.0.0.0**, 因此装好即可
HOST 监听地址(软件 API 端口),默认 0.0.0.0, 因此装好即可
被客户端访问(此时请用防火墙限制来源)。反代与后端同机时
建议改为 127.0.0.1, 让后端不直接对外暴露
ADMIN_HOST 管理端监听地址(可选, 默认跟随 HOST;HOST 未设置时同样
@@ -230,7 +279,7 @@ deploy/.env 初始化引导(deploy/init-env.sh)
会在代理层被拒(nginx client_max_body_size /
IIS requestLimits), 详见 deploy/proxy/README.md
ASSIMP_BIN 可选: Assimp 可执行文件, FBX/glTF 动作预处理需要;
上传 .zip 模型包、或 .pmx/.fbx 单文件模型时**必须**能调用到,
上传 .zip 模型包、或 .pmx/.fbx 单文件模型时必须能调用到,
否则转换失败(422)
MODEL_CONVERT_FORMAT 需要转换的模型(PMX/FBX)的交付格式:
glb(默认, 两个客户端都原生加载)| vrm
@@ -279,19 +328,19 @@ deploy/.env 初始化引导(deploy/init-env.sh)
相同内容只落盘一次(sha256 去重)。
支持的模型格式(POST /api/models 上传)
**系统以 GLB / VRM 为主力格式**: 服务端与客户端都把这两种格式作为一等公民
系统以 GLB / VRM 为主力格式: 服务端与客户端都把这两种格式作为一等公民
(Godot 客户端用内置 GLTFDocument 加载 glTF/GLB/VRM, 见
opendance-windows/src/model/vrm_loader.gd, 本次无需改动)。
glTF Binary .glb 主力(P0)-- **直接导入**, 不重编码
glTF Binary .glb 主力(P0)-- 直接导入, 不重编码
VRM .vrm 主力(P0)-- 直接导入; GLB + VRM 扩展
(humanoid / 表情 / springBone / meta)
glTF .gltf P1 -- 直接导入(分离的 .bin / 贴图需随 .zip 一起上传)
MMD 压缩包 .zip P1 -- **自动搜索包内可用模型**(见下)
MMD 压缩包 .zip P1 -- 自动搜索包内可用模型(见下)
MMD 模型 .pmx P2(输入)-- 服务端转换, 优先 GLB、其次 VRM
FBX .fbx P2(输入)-- 服务端转换, 优先 GLB、其次 VRM
入库后**一律 status=pending(待审核)**: 上传不再自动发布、不再自动设为默认,
入库后一律 status=pending(待审核): 上传不再自动发布、不再自动设为默认,
必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。
升级后回填已有模型(面数 / 骨骼 / 缩略图)
@@ -303,17 +352,17 @@ deploy/.env 初始化引导(deploy/init-env.sh)
若模型文件在存储中已丢失, 接口返回 409 并提示先恢复文件, 不会用空结果覆盖已有数据。
模型压缩包(.zip)自动搜索与转换
上传的 .zip 不再"只找 .pmx", 而是**自动搜索包内可用模型**, 走
上传的 .zip 不再"只找 .pmx", 而是自动搜索包内可用模型, 走
"解压 -> 选主模型 -> 直接导入或转换 -> 回读校验 -> 入库", 而不是把压缩包当模型存下来。
选主模型 优先级 .vrm -> .glb -> .gltf -> .pmx -> .fbx; 同一优先级内按
"MODEL_CONVERT_PMX 指名字 -> 目录最浅 -> 文件最大"。
命中 .vrm/.glb/.gltf 时**直接导入**(不重编码)。
命中 .vrm/.glb/.gltf 时直接导入(不重编码)。
转换 只有 .pmx/.fbx 需要转换。链路: assimp export -> .glb(首选);
失败则 assimp export -> .gltf, 再由 gltf-packer.ts 自行打包回 GLB;
MODEL_CONVERT_FORMAT=vrm 时在 GLB 之上补 VRMC_vrm 扩展封装成
VRM 1.0(humanoid 骨骼不全则回退交付 GLB, 缺失骨骼写进说明)。
校验 每一步产物**必须**用 `assimp info` 回读校验。Assimp 会"写出损坏文件"
校验 每一步产物必须用 `assimp info` 回读校验。Assimp 会"写出损坏文件"
(实测 PMX 导出 FBX 退出码 11、回读报 FBX-DOM 错误), 只看退出码
会把坏文件入库。
贴图 GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠), 服务端把
@@ -339,16 +388,16 @@ deploy/.env 初始化引导(deploy/init-env.sh)
maxBoneDepth=23;missingRequiredBones 为全部 16 根
(日文 MMD 骨名无法映射到 humanoid, 审核界面会直接标红)。
- 直接上传 .glb -> HTTP 201 / 0.36s、conversionNote=`直接导入(GLB, 未做重编码)`,
入库文件 sha256 与源文件**一致**(不重编码, 字节级保留)。
入库文件 sha256 与源文件一致(不重编码, 字节级保留)。
模型分析、缩略图与 3D 预览(入库后自动执行, 失败不影响入库)
分析 model-inspector.ts 解析 GLB/VRM: 面数 / 顶点 / 网格 / 材质 / 贴图 /
节点 / 形变目标 / 动画 / 蒙皮骨骼数 / **骨骼树** / humanoid 覆盖 /
节点 / 形变目标 / 动画 / 蒙皮骨骼数 / 骨骼树 / humanoid 覆盖 /
可驱动性(缺哪些必需骨骼)/ warnings, 写入 models.stats
(迁移 AddModelStats1789460000000, 新增 models.stats TEXT)。
缩略图 model-thumbnail.ts 用纯 TS 软件光栅化生成 PNG(读 GLB/glTF -> 应用节点
层级与蒙皮 -> 包围盒自动构图 -> 透视投影 -> z-buffer 逐三角形平面着色 ->
手写 PNG 编码),**零原生依赖**(不需要 canvas / headless-gl / puppeteer)。
手写 PNG 编码),零原生依赖(不需要 canvas / headless-gl / puppeteer)。
默认 512x512, 写入 models.thumbnail_asset_id / thumbnailUrl;
失败或非 glTF 容器时留空, 列表页退化为格式占位图。
实测: 512x512 PNG / 15,043 字节, 目视确认为居中的 T-pose 角色图;
@@ -357,11 +406,11 @@ deploy/.env 初始化引导(deploy/init-env.sh)
GLB/VRM」); 列表页显示 40px 小缩略图, 审核面板显示 116px 缩略图。
审核流程(模型)
模型上传后为 pending, 在管理端「审核」页的**模型审核面板**里查看并决定:
模型上传后为 pending, 在管理端「审核」页的模型审核面板里查看并决定:
面板内容 体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 /
骨骼 / 节点 / 形变目标 / 动画 / humanoid 覆盖(已映射/总数 + 明细)/
**可驱动性结论**(缺哪些必需骨骼)/ warnings。
可驱动性结论(缺哪些必需骨骼)/ warnings。
骨骼预览 骨骼详细界面: 扁平骨骼树按 depth 缩进、显示骨骼名、是否参与蒙皮、
humanoid 映射徽章、子节点数; 支持按名搜索(保留祖先链)、
「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行(千根骨骼不卡)。
@@ -375,23 +424,23 @@ deploy/.env 初始化引导(deploy/init-env.sh)
GET /api/catalog/export 的 defaultModelId 按以下顺序解析:
0. 环境变量 DEFAULT_MODEL_ID(且模型存在且已发布)
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED--按**上传压缩包的
sha256** 匹配已发布模型(用户文件永远稳定; 不能用转换产物的 sha256 --
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED--按上传压缩包的
sha256 匹配已发布模型(用户文件永远稳定; 不能用转换产物的 sha256 --
Assimp 每次导出的字节都略有不同), 换库 / 重建库后仍有效
2. format='vrm' 且 published 且 is_primary=1
3. 任意 published 的 VRM(id 升序取第一个)
4. id 最小的已发布模型
5. 无可用模型 -> 客户端用内置占位模型
源码种子 src/config/default-model.ts 当前为**空种子**(占位: id=null、sha256 空串),
源码种子 src/config/default-model.ts 当前为空种子(占位: id=null、sha256 空串),
此时 defaultModelId 直接从第 2 步的 VRM 优先规则开始解析; 按下面的方式设定默认
模型后, 服务端会把种子写成真实内容。
实测(v0.2.0):`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200,
模型 status=published、isPrimary=true, 源码种子写入 sourceSha256=72eb6882...;
重建后 `GET /api/catalog/export` 的 defaultModelId 由 VRM 变为 **22**
重建后 `GET /api/catalog/export` 的 defaultModelId 由 VRM 变为 22
(库中另有 2 个已发布 VRM)。
也可手工指定: POST /api/models/:id/primary。
第 1 步的源码写入由服务端在**审核通过并勾选 setPrimary**(或调用 primary 接口)
第 1 步的源码写入由服务端在审核通过并勾选 setPrimary(或调用 primary 接口)
时完成; 生产 systemd 加固(ProtectSystem=strict)下服务进程写不了程序目录,
可改用下面的 CLI 补写。
@@ -416,7 +465,7 @@ deploy/.env 初始化引导(deploy/init-env.sh)
说明: --url 默认 http://127.0.0.1:3000,--source-type 默认 other。
v0.2.0 起上传一律 pending, 因此 CLI 上传路径只负责转换入库与写源码种子;
要让它在 `defaultModelId` 中生效, 仍需在管理端**审核通过**(勾选「同时设为全局
要让它在 `defaultModelId` 中生效, 仍需在管理端审核通过(勾选「同时设为全局
默认模型」)或调用 `POST /api/models/:id/primary`。
支持的动作格式(POST /api/motions 上传 -> POST /api/motions/:id/preprocess 预处理)
@@ -478,7 +527,7 @@ VMD 动作上传前后自查
2. 数据库账号使用最小权限, 不授予 GRANT OPTION / FILE / SUPER。
3. 本版本未内置管理端登录认证, 请部署在内网, 或在反向代理层加 Basic Auth / SSO。
4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见。
**模型上传后一律 pending**: 不再自动发布、不再自动设为默认; 默认模型只在
模型上传后一律 pending: 不再自动发布、不再自动设为默认; 默认模型只在
审核通过时勾选「同时设为全局默认模型」(`setPrimary:true`)或调用
`POST /api/models/:id/primary`(仅 published)时才设置(见 五)。
5. 未使用 SMPL 拓扑, 统一 MediaPipe 33 点, 规避非商用许可限制。
+3 -3
View File
@@ -29,7 +29,7 @@
- 许可证:BSD-3-Clause
- 源链接:https://github.com/assimp/assimp
- 使用端:Web 服务端(**可选**外部依赖,FBX/glTF 动作 → BVH 转换)、Windows 客户端
- 使用端:Web 服务端(**可选**外部依赖,FBX/glTF 动作 -> BVH 转换)、Windows 客户端
## 5. Godot Engine
@@ -68,7 +68,7 @@
- 源链接:https://github.com/aimoonchen/bvh
- 使用端:Web 服务端(BVH 解析参考实现)、Android 辅助 App
---
--
## 补充声明(实际引入的构建期/运行时依赖)
@@ -111,7 +111,7 @@
- 源链接:https://developer.android.com/jetpack/compose
- 使用端:Android 辅助 App
---
--
## 许可合规说明
+12 -12
View File
@@ -1,4 +1,4 @@
# contracts/data-models.md — 数据模型契约 v0.4.0
# contracts/data-models.md - 数据模型契约 v0.4.0
Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。
字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。
@@ -16,13 +16,13 @@ Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视
- 错误响应统一:
```json
{ "statusCode": 400, "message": "…", "error": "Bad Request", "details": {} }
{ "statusCode": 400, "message": "...", "error": "Bad Request", "details": {} }
```
- **来源(出处)五元组**是必填项,出现在 `songs` / `motions` / `models` 三处:
| 字段 | 类型 | 说明 |
|---|---|---|
|--|--|--|
| `sourceType` | enum | 见 1.1 |
| `sourceName` | string | 来源名称,如「官方曲库」「作者名」 |
| `sourceUrl` | string | 来源链接,必须为 http(s) |
@@ -52,7 +52,7 @@ rejected 已驳回
## 2. `Song`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
|--|--|--|--|
| `id` | number | 读 | |
| `title` | string | ✅ | 曲名,≤200 |
| `artist` | string | ✅ | 演唱者,≤200 |
@@ -71,7 +71,7 @@ rejected 已驳回
## 3. `Motion`(动作数据)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
|--|--|--|--|
| `id` | number | 读 | |
| `songId` | number\|null | | 关联曲目 |
| `name` | string | ✅ | 动作名称,≤200 |
@@ -93,19 +93,19 @@ rejected 已驳回
## 4. `Model`(角色模型,VRM 优先)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
|--|--|--|--|
| `id` | number | 读 | |
| `name` | string | ✅ | 模型名称,≤200 |
| `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md §1.1 |
| `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md 1.1 |
| `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null |
| `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** |
| `fileUrl` | string | 读 | |
| `fileSize` | number | 读 | |
| `thumbnailUrl` | string\|null | 读 | |
| `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null |
| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 —— **每次转换都会变**,仅作记录 |
| `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`);直传模型为 null |
| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 -- **每次转换都会变**,仅作记录 |
| `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null |
| `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md §1.1 |
| `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md 1.1 |
| `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | |
| `createdAt` / `updatedAt` | string | 读 | |
@@ -124,7 +124,7 @@ rejected 已驳回
2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1
3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个
4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个
5. 无可用模型 → 客户端使用内置占位模型
5. 无可用模型 -> 客户端使用内置占位模型
```
第 0/1 步是 v0.3.0 新增:上传 MMD 模型压缩包并转换成功后,服务端会把该模型
@@ -135,7 +135,7 @@ rejected 已驳回
## 5. `ReviewRecord`(审核流水)
| 字段 | 类型 | 说明 |
|---|---|---|
|--|--|--|
| `id` | number | |
| `targetType` | enum | `song` / `motion` / `model` |
| `targetId` | number | |
+5 -5
View File
@@ -1,4 +1,4 @@
# contracts/database.md — 数据库契约 v0.1.0
# contracts/database.md - 数据库契约 v0.1.0
## 环境
@@ -39,17 +39,17 @@ DB_LOGGING=false
## 增量迁移(v0.2.0 / v0.3.0)
| 迁移 | 变更 |
|---|---|
| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md §1.1) |
|--|--|
| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md 1.1) |
| `AddModelStats` | `models` 增加 `stats TEXT NULL`(模型分析结果:面数、骨骼树、humanoid 覆盖、可驱动性),供审核界面展示 |
## 表清单(v0.1.0)
| 表 | 用途 | 任务 |
|---|---|---|
|--|--|--|
| `songs` | 曲目元数据 | T-WEB-003 |
| `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 |
| `motions` | 动作数据(BVH/FBX…)+ 来源 + 预处理产物 | T-WEB-004 / 006 |
| `motions` | 动作数据(BVH/FBX...)+ 来源 + 预处理产物 | T-WEB-004 / 006 |
| `models` | 角色模型(VRM 优先)+ 来源 + 压缩包转换溯源 | T-WEB-005 |
| `review_records` | 上传审核流水 | T-WEB-007 |
+33 -33
View File
@@ -1,15 +1,15 @@
# contracts/model-formats.md — 模型与动作格式契约 v0.4.0
# contracts/model-formats.md - 模型与动作格式契约 v0.4.0
## 1. 角色模型(models)
**系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。
| 格式 | 扩展名 | 支持级别 | 服务端行为 |
|---|---|---|---|
|--|--|--|--|
| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 |
| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) |
| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 §1.1 |
| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 1.1 |
| MMD 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 |
| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) |
@@ -21,18 +21,18 @@
#### 单文件上传
| 输入 | 行为 |
|---|---|
|--|--|
| `.glb` / `.vrm` | 直接导入;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| `.gltf` | 直接导入(外部 `.bin` / 贴图不在同一文件里,会缺资源) |
| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 → 回读校验(见下) |
| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 -> 回读校验(见下) |
#### 压缩包上传(自动搜索)
不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**:
```
.vrm → .glb → .gltf (直接导入,优先)
.pmx → .fbx (需要转换)
.vrm ->.glb ->.gltf (直接导入,优先)
.pmx ->.fbx (需要转换)
```
同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。
@@ -41,31 +41,31 @@
#### 转换与校验(PMX / FBX)
| 步骤 | 规则 |
|---|---|
| 1. GLB 直出 | `assimp export → .glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk |
| 2. glTF 打包 | 上一步失败时 `assimp export → .gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB |
|--|--|
| 1. GLB 直出 | `assimp export ->.glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk |
| 2. glTF 打包 | 上一步失败时 `assimp export ->.gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB |
| 3. VRM 封装 | `MODEL_CONVERT_FORMAT=vrm` 时,在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼来自骨骼名映射,见 `model-inspector.ts` 的候选表,与 Godot 客户端一致);**humanoid 不全则回退交付 GLB**,缺失骨骼写进 `conversion_note` |
| 回读校验 | 每一步产物都要 `assimp info` 回读。Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库 |
| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`) |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 → **HTTP 422**,不产生模型记录 |
| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,不产生模型记录 |
#### 入库后(自动)
| 项 | 说明 |
|---|---|
|--|--|
| 状态 | **一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费,见 `database.md` 状态机 |
| 分析 | `model-inspector.ts` 计算面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,写入 `models.stats`,供审核界面展示 |
| 缩略图 | `model-thumbnail.ts` 纯 TS 光栅化生成 PNG,写入 `models.thumbnail_asset_id`(失败不影响入库) |
| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` §4.1 |
| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` 4.1 |
#### 其它约定
| 项 | 规则 |
|---|---|
|--|--|
| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 |
| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE |
**VRM 优先**的选取顺序见 `data-models.md` §4.1。
**VRM 优先**的选取顺序见 `data-models.md` 4.1。
VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
@@ -73,9 +73,9 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
## 2. 动作数据(motions)
| 格式 | 扩展名 | 预处理支持 | 说明 |
|---|---|---|---|
| BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 → FK → MediaPipe33 |
| VMD | `.vmd` | ✅ 内置纯 TS 解析器 | MikuMikuDance 动作数据(二进制,Shift-JIS 骨骼名 + 四元数关键帧);服务端按标准 MMD 骨架做 FK + 腿部 IK 求解 → MediaPipe33,见 §2.3 |
|--|--|--|--|
| BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 -> FK -> MediaPipe33 |
| VMD | `.vmd` | ✅ 内置纯 TS 解析器 | MikuMikuDance 动作数据(二进制,Shift-JIS 骨骼名 + 四元数关键帧);服务端按标准 MMD 骨架做 FK + 腿部 IK 求解 -> MediaPipe33,见 2.3 |
| FBX | `.fbx` | ⚠️ 需 Assimp | 服务端调用 `assimp` 可执行文件导出为 BVH/glTF 后再解析;未安装则 `preprocessState=failed` |
| glTF/GLB | `.gltf` / `.glb` | ⚠️ 需 Assimp | 同上 |
| 关键点 JSON | `.json` | ✅ 直通校验 | 已是 `mediapipe33@0.1.0` 结构,仅做校验与归一化 |
@@ -86,11 +86,11 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
- 一行结构:
```json
{"frame":0,"timeMs":0.0,"points":[[0.5,0.5,0.0,1.0],"…33 项"],"angles":[1.0,"…9 项"]}
{"frame":0,"timeMs":0.0,"points":[[0.5,0.5,0.0,1.0],"...33 项"],"angles":[1.0,"...9 项"]}
```
- `points[i] = [x, y, z, confidence]`,语义与 `websocket-keypoints.md` 解码后完全一致
- `angles` 为 `skeleton-topology.md` §5 定义的 9 个关节角余弦,顺序固定:
- `angles` 为 `skeleton-topology.md` 5 定义的 9 个关节角余弦,顺序固定:
`[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]`
- 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30
- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**,
@@ -102,7 +102,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
### 2.2 BVH 关节名映射(默认表,可在 `web-server/src/preprocess/joint-map.ts` 扩展)
| MediaPipe 点 | 常见 BVH 关节名(不区分大小写,按序匹配) |
|---|---|
|--|--|
| 0 nose | `Head`, `Neck1`, `Head_Nub` |
| 11 left_shoulder | `LeftShoulder`, `LeftArm`, `LShoulder` |
| 12 right_shoulder | `RightShoulder`, `RightArm`, `RShoulder` |
@@ -119,7 +119,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
| 31 left_foot_index | `LeftToeBase`, `LeftToe`, `LToe` |
| 32 right_foot_index | `RightToeBase`, `RightToe`, `RToe` |
未映射到的点按 `skeleton-topology.md` §5 的层级由父节点推算,
未映射到的点按 `skeleton-topology.md` 5 的层级由父节点推算,
并置 `confidence` 降低(`0.5`),表示估算而非实测。
### 2.3 VMD(MikuMikuDance)解析约定
@@ -130,13 +130,13 @@ float32×3 位移、float32×4 旋转四元数、64 字节贝塞尔插值参数
灯光 / 自阴影 / IK / 可见性段落。**VMD 不含骨骼长度**,只有旋转(少数骨骼带位移),
因此服务端必须自带一套标准 MMD 骨架才能做 FK。
处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)→ `mmd-skeleton.ts`
(标准骨架)→ `mmd-pose.ts`(FK + 腿部 IK)→ `retarget.ts` 的
处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`
(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的
`worldFramesToKeypointFrames()`(与 BVH 完全共用骨盆归零 / 自适应缩放 / 图像空间投影 /
9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。
| MediaPipe 点 | MMD 标准骨骼 | 说明 |
|---|---|---|
|--|--|--|
| 0 nose | `頭` | 取头骨根部再上移,近似面部中心 |
| 11 / 12 left/right_shoulder | `左腕` / `右腕` | 肩关节(不是 `左肩` 骨骼) |
| 13 / 14 left/right_elbow | `左ひじ` / `右ひじ` | |
@@ -149,12 +149,12 @@ float32×3 位移、float32×4 旋转四元数、64 字节贝塞尔插值参数
骨骼名按 `mmd-skeleton.ts` 的别名表归一(含全角 `IK` / 半角 `IK` 两种写法);
未纳入标准骨架的骨骼(裙摆、物理、表情等)被忽略但会计数并写入预处理日志。
**腿部驱动判定**:`足IK` 轨道存在非零位移 → 走 IK(用两骨骼解析 IK 把踝拉到 IK
**腿部驱动判定**:`足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK
目标点,膝盖朝角色正面弯,不可达时收敛为伸直);否则走 FK(部分动作数据把腿烘焙成
FK 旋转)。两份示例文件分别命中两种路径。
**坐标系**:MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色
左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) → (-x,-y,z,w)`、位移 z 取反)后
左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反)后
送入标准骨架。该选择由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下
膝盖朝前占比 94.1%,不转换时仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
@@ -166,7 +166,7 @@ FK 旋转)。两份示例文件分别命中两种路径。
## 3. 上传限制
| 项 | 值 |
|---|---|
|--|--|
| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
| 允许扩展名 | 上表全部;其他一律 422 |
| 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` |
@@ -175,7 +175,7 @@ FK 旋转)。两份示例文件分别命中两种路径。
## 4. 开源依赖与许可证(引用 `open-source-credits.md`)
- `bvh`(MIT)— BVH 解析参考实现
- `Assimp`(BSD-3-Clause)— FBX/glTF 转换,**可选外部依赖**
- `@pixiv/three-vrm`(MIT)— 管理端 VRM 预览
- `three`(MIT)— 管理端 3D 预览
- `bvh`(MIT)- BVH 解析参考实现
- `Assimp`(BSD-3-Clause)- FBX/glTF 转换,**可选外部依赖**
- `@pixiv/three-vrm`(MIT)- 管理端 VRM 预览
- `three`(MIT)- 管理端 3D 预览
+3 -3
View File
@@ -1,10 +1,10 @@
# contracts/open-source-credits.md — 开源声明锚点 v0.1.0
# contracts/open-source-credits.md - 开源声明锚点 v0.1.0
三端发布产物中的 `THIRD_PARTY.md` 与各自「关于」页面必须包含下表全部条目。
本文件是**唯一来源**,任一端新增依赖须回到 `opendance-web` 更新此表。
| # | 项目 | 许可证 | 源链接 | 使用端 |
|---|---|---|---|---|
|--|--|--|--|--|
| 1 | MediaPipe | Apache License 2.0 | https://github.com/google-ai-edge/mediapipe | Android |
| 2 | Three.js | MIT | https://github.com/mrdoob/three.js | Web 管理端 |
| 3 | @pixiv/three-vrm | MIT | https://github.com/pixiv/three-vrm | Web 管理端 |
@@ -21,7 +21,7 @@
| 14 | Jetpack Compose | Apache License 2.0 | https://developer.android.com/jetpack/compose | Android |
| 15 | mysql2 | MIT | https://github.com/sidorares/node-mysql2 | Web 服务端 |
> 注意:文档模板中列出的 10 项为**最低要求**;第 11–15 项为实际引入的构建期/运行时依赖,
> 注意:文档模板中列出的 10 项为**最低要求**;第 11-15 项为实际引入的构建期/运行时依赖,
> 一并声明更完整。发布时必须保留各许可证副本(见 `web-server/LICENSE-THIRD-PARTY/`)。
## 版权声明补充
+3 -3
View File
@@ -1,11 +1,11 @@
# contracts/skeleton-topology.md — 骨骼拓扑契约 v0.1.0
# contracts/skeleton-topology.md - 骨骼拓扑契约 v0.1.0
**唯一拓扑:MediaPipe Pose 33 点。禁止引入 SMPL 及其衍生拓扑(许可风险)。**
## 1. 33 点定义与索引
| idx | name | 中文 | 说明 |
|---|---|---|---|
|--|--|--|--|
| 0 | nose | 鼻 | |
| 1 | left_eye_inner | 左眼内 | |
| 2 | left_eye | 左眼 | |
@@ -43,7 +43,7 @@
## 2. 虚拟关节(由 33 点派生,索引 33/34,仅用于层级与角度特征)
| idx | name | 计算 |
|---|---|---|
|--|--|--|
| 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` |
| 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` |
+9 -9
View File
@@ -1,6 +1,6 @@
# contracts/websocket-keypoints.md — 关键点传输契约 v0.1.0
# contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
Android 辅助 App(发送端)→ Windows 客户端(接收端)。
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
## 1. 端点
@@ -28,7 +28,7 @@ ws://<windows-host>:8765/pose
bit3-7 保留,必须为 0
4 4 frame_id u32 LE 单调递增,从 0 开始
8 231 keypoints 33 × 7B 见下
--------------------------------------------------------------
--
合计 239
```
@@ -70,7 +70,7 @@ func decode(buf: PackedByteArray) -> Dictionary:
## 3. 文本控制帧(JSON,UTF-8)
### 3.1 `hello` — Android → Windows,连接后立即发送
### 3.1 `hello` - Android -> Windows,连接后立即发送
```json
{
@@ -84,25 +84,25 @@ func decode(buf: PackedByteArray) -> Dictionary:
}
```
### 3.2 `welcome` — Windows → Android
### 3.2 `welcome` - Windows -> Android
```json
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
```
### 3.3 `config` — Windows → Android,运行中调整
### 3.3 `config` - Windows -> Android,运行中调整
```json
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
```
### 3.4 `stats` — Android → Windows,每秒一次
### 3.4 `stats` - Android -> Windows,每秒一次
```json
{ "type": "stats", "fps": 29.6, "dropped": 3, "latencyMs": 42, "confidenceAvg": 0.93 }
```
### 3.5 `bye` — 双向
### 3.5 `bye` - 双向
```json
{ "type": "bye", "reason": "user_stop" }
@@ -117,7 +117,7 @@ func decode(buf: PackedByteArray) -> Dictionary:
"frameId": 1024,
"timestampMs": 1730000000000,
"flags": { "mirrored": false, "upperBodyOnly": false },
"points": [[0.51, 0.22, -0.03, 0.99], "…共 33 项,顺序固定 [x, y, z, conf]"]
"points": [[0.51, 0.22, -0.03, 0.99], "...共 33 项,顺序固定 [x, y, z, conf]"]
}
```
+11 -11
View File
@@ -10,7 +10,7 @@
本服务**同时监听两个端口**,职责不同:
| 端口 | 环境变量 | 默认 | 给谁用 | 提供什么 |
|---|---|---|---|---|
|--|--|--|--|--|
| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`;**不含管理页面**(未知路径 404) |
| 管理端端口 | `ADMIN_PORT` | `3001` | 浏览器(管理员) | 管理页面 + 上面全部接口 |
@@ -24,14 +24,14 @@
**推荐做法**:把两个端口挂在两个不同的站点/域名上,便于分别做访问控制,例如
- `https://dance-api.example.com` → 转发到 `3000`(给客户端,可用 IP 白名单)
- `https://dance-admin.example.com` → 转发到 `3001`(给管理员,建议加 Basic Auth / SSO 或只在内网放行)
- `https://dance-api.example.com` -> 转发到 `3000`(给客户端,可用 IP 白名单)
- `https://dance-admin.example.com` -> 转发到 `3001`(给管理员,建议加 Basic Auth / SSO 或只在内网放行)
如果客户端与管理端在同一个内网、也不想配域名,可以**让客户端直连 `3000`**(不经过反代),
只把管理端 `3001` 挂到反代上;反之亦可。
| 路径 | 内容 | 哪个端口提供 |
|---|---|---|
|--|--|--|
| `/`、`/assets/*` | 管理端页面与静态资源 | **仅管理端**;前端路由深链接(如 `/songs`)由服务端回退到 `index.html` |
| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) |
| `/health` | 健康检查 | 两个端口都提供;可用作反代的存活探测,数据库不可用时返回 **503** |
@@ -49,7 +49,7 @@
`deploy/.env` 中的 `HOST`(软件 API 端口)与可选的 `ADMIN_HOST`(管理端端口,缺省跟随 `HOST`):
| 部署形态 | 建议值 | 说明 |
|---|---|---|
|--|--|--|
| 反代与后端**同机** | `HOST=127.0.0.1` | 后端不直接对外暴露,最安全 |
| 反代与后端**不同机** | `HOST=0.0.0.0` | 必须用防火墙把端口限制为仅反代来源可访问 |
| 只想让管理端在内网可见 | `ADMIN_HOST=<内网网卡地址>` | 与 `HOST=0.0.0.0` 组合使用,把管理端限制在内网 |
@@ -113,14 +113,14 @@ nginx -t && systemctl reload nginx
IIS 需要先安装两个模块(**默认都没有**):
1. **URL Rewrite** — https://www.iis.net/downloads/microsoft/url-rewrite
2. **Application Request Routing (ARR)** — https://www.iis.net/downloads/microsoft/application-request-routing
1. **URL Rewrite** - https://www.iis.net/downloads/microsoft/url-rewrite
2. **Application Request Routing (ARR)** - https://www.iis.net/downloads/microsoft/application-request-routing
装完 ARR 后还需启用代理(否则会报 404.4):
```
IIS 管理器 → 选中服务器节点 → Application Request Routing Cache
→ 右侧 Server Proxy Settings → 勾选 Enable proxy → 应用
IIS 管理器 -> 选中服务器节点 -> Application Request Routing Cache
-> 右侧 Server Proxy Settings -> 勾选 Enable proxy -> 应用
```
完整 `web.config` 样例见 [`web.config.example`](web.config.example),放到站点根目录即可:
@@ -195,7 +195,7 @@ curl -i http://<反代地址>/api/catalog/version
```
| 现象 | 原因 |
|---|---|
|--|--|
| 反代 502 / 504 | 后端未启动,或 `HOST` 绑定地址不对(同机应为 `127.0.0.1`,异机应为 `0.0.0.0`) |
| 页面能开、接口 404 | 只转发了 `/`,需要把 `/api/`、`/health`、`/static/` 一并转发(最简单是全匹配) |
| 上传大文件 413 | 代理层上传上限没放开(见第三、四、五节) |
@@ -214,7 +214,7 @@ curl -i http://<反代地址>/api/catalog/version
3. 浏览器访问 `http://<服务器IP>:3000`
> 注意:服务默认在 **3000** 端口,不在 80/443。直接访问 `http://<服务器IP>/`
> 是打不开的——那需要反代把 80/443 转到 3000,属于本节的配置范围。
> 是打不开的--那需要反代把 80/443 转到 3000,属于本节的配置范围。
一条命令定位「服务起来了但外部访问不了」:
+6 -6
View File
@@ -5,14 +5,14 @@
## 目标
曲目的增删改查与分页检索,字段语义符合 `contracts/data-models.md` §2。
曲目的增删改查与分页检索,字段语义符合 `contracts/data-models.md` 2。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1songs
contracts/data-models.md # 锚点:§1 通用约定、§2 Song
contracts/data-models.md # 锚点:1 通用约定、2 Song
web-server/src/songs/*
web-server/src/common/source.dto.ts
web-server/src/common/pagination.ts
@@ -21,7 +21,7 @@ web-server/src/common/pagination.ts
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
|--|--|--|
| GET | `/api/songs` | 分页 + `q`/`artist`/`status` 过滤 |
| POST | `/api/songs` | 新建(来源五元组必填) |
| GET | `/api/songs/:id` | 详情 |
@@ -33,9 +33,9 @@ web-server/src/common/pagination.ts
## 关键实现
- `likePattern()` 转义 `%` `_` `\`,用户输入不会被当作 LIKE 模式。
- `assertSourceTupleComplete()` 实现「全有或全无」:PATCH 只给部分来源字段 → 400。
- PATCH 直接改 `status` 时校验状态机;非法流转 → 409 并提示改用 `/api/reviews`。
- `toDtos()` 批量解析 asset → URL,避免 N+1。
- `assertSourceTupleComplete()` 实现「全有或全无」:PATCH 只给部分来源字段 -> 400。
- PATCH 直接改 `status` 时校验状态机;非法流转 -> 409 并提示改用 `/api/reviews`。
- `toDtos()` 批量解析 asset -> URL,避免 N+1。
## 验收
+10 -10
View File
@@ -12,8 +12,8 @@
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1motions
contracts/data-models.md # 锚点:§3 Motion
contracts/model-formats.md # 锚点:§2 动作格式、§3 上传限制
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 上传后的预处理链路
@@ -25,14 +25,14 @@ web-server/src/preprocess/retarget.ts # worldFramesToKeypointFrames()
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
|--|--|--|
| GET | `/api/motions` | 分页 + `q`/`songId`/`format`/`status` 过滤 |
| POST | `/api/motions` | multipart 上传 |
| GET | `/api/motions/:id` | 详情 |
| PATCH | `/api/motions/:id` | 更新元数据(含 `detachSong`) |
| DELETE | `/api/motions/:id` | 软删除 |
## 存储策略(`contracts/model-formats.md` §3)
## 存储策略(`contracts/model-formats.md` 3)
```
${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
@@ -45,17 +45,17 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
## 校验
- 扩展名白名单:`.bvh .vmd .fbx .gltf .glb .json`,其他 → 400
- 来源五元组缺任一项 → 400
- `songId` 指向不存在的曲目 → 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` 的
`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` 与可读错误
@@ -72,7 +72,7 @@ npm run test:smoke
VMD 实测(用 `../示例文件/` 下的两个样本):
| 样本 | 帧数 | 腿部驱动 | 结果 |
|---|---|---|---|
|--|--|--|--|
| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
+34 -34
View File
@@ -15,8 +15,8 @@
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
contracts/data-models.md # 锚点:§4 Model、§4.1 默认模型选择规则
contracts/model-formats.md # 锚点:§1 角色模型、§1.1 上传与转换策略
contracts/data-models.md # 锚点:4 Model、4.1 默认模型选择规则
contracts/model-formats.md # 锚点:1 角色模型、1.1 上传与转换策略
web-server/src/models/*
web-server/src/models/model-inspector.ts
web-server/src/models/model-thumbnail.ts
@@ -36,7 +36,7 @@ web-server/scripts/import-default-model.mjs
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
|--|--|--|
| GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`;**入库一律 pending** |
| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) |
@@ -47,7 +47,7 @@ web-server/scripts/import-default-model.mjs
## 格式策略(服务端与客户端一致)
| 输入 | 行为 |
|---|---|
|--|--|
| `.glb` / `.vrm` | **直接导入**,不重编码;`.vrm` = GLB + VRM 扩展 |
| `.gltf` | **直接导入**(分离的 `.bin` / 贴图不在同一文件里,会缺资源,建议随 zip 上传) |
| `.pmx` / `.fbx` | 服务端转换:优先 GLB、其次 VRM(见下) |
@@ -63,33 +63,33 @@ web-server/scripts/import-default-model.mjs
.zip ─▶ readZipEntries() ─▶ 自动搜索主模型 ─┬─.vrm/.glb/.gltf ─▶ 直接导入
zip-reader.ts.vrm>.glb> │
.gltf>.pmx>.fbx └─.pmx/.fbx ─▶ Assimp 转换
├─ 1) → .glb
├─ 2) → .gltf → gltf-packer → GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm → vrm-writer
├─ 1) ->.glb
├─ 2) ->.gltf -> gltf-packer -> GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm -> vrm-writer
└─ 每步 assimp info 回读
```
| 环节 | 实现 | 规则 |
|---|---|---|
|--|--|--|
| ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 |
| 选主模型 | `model-convert.ts` | 格式优先级 `.vrm` → `.glb` → `.gltf` → `.pmx` → `.fbx`;同一优先级内"`MODEL_CONVERT_PMX` 指名字(默认 `Hatsune Miku.pmx`,大小写不敏感)→ 目录最浅 → 文件最大" |
| 选主模型 | `model-convert.ts` | 格式优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内"`MODEL_CONVERT_PMX` 指名字(默认 `Hatsune Miku.pmx`,大小写不敏感)-> 目录最浅 -> 文件最大" |
| 直接导入 | `importModelFile()` | `.vrm`/`.glb`/`.gltf` 原样入库;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 回读校验 | `exportAndValidate()` | 每步产物**必须** `assimp info` 回读;退出码 0 但产物损坏也判失败 |
| GLB 打包 | `gltf-packer.ts` | GLB 直出失败时导出 `.gltf`,把 `.bin` 与外部贴图自行合成单文件 GLB |
| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`;humanoid 骨骼不全 → **回退交付 GLB**,缺失骨骼写进说明 |
| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`;humanoid 骨骼不全 -> **回退交付 GLB**,缺失骨骼写进说明 |
| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 → `models.source_asset_id`;说明 → `models.conversion_note` |
| 失败 | `models.service.ts` | 无可用模型 / Assimp 不可用 / 两条路径都失败 → **HTTP 422**,不产生模型记录 |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`) |
| DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` §4、`rest-api.yaml` |
| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 -> `models.source_asset_id`;说明 -> `models.conversion_note` |
| 失败 | `models.service.ts` | 无可用模型 / Assimp 不可用 / 两条路径都失败 -> **HTTP 422**,不产生模型记录 |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) |
| DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` 4、`rest-api.yaml` |
## 入库后自动:分析 + 缩略图(尽力而为,失败不影响入库)
| 项 | 实现 | 说明 |
|---|---|---|
| 分析 | `model-inspector.ts` → `models.stats` | 面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / **骨骼树** / humanoid 覆盖 / **可驱动性(缺哪些必需骨骼)** / warnings;迁移 `AddModelStats1789460000000` 新增 `models.stats TEXT` |
| 缩略图 | `model-thumbnail.ts` → `models.thumbnail_asset_id` | 纯 TS 软件光栅化 512×512 PNG(读 GLB/glTF → 节点层级与蒙皮 → 包围盒构图 → 透视投影 → z-buffer 平面着色 → 手写 PNG),**零原生依赖**;失败或非 glTF 容器留空,前端退化为格式占位图 |
|--|--|--|
| 分析 | `model-inspector.ts` -> `models.stats` | 面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / **骨骼树** / humanoid 覆盖 / **可驱动性(缺哪些必需骨骼)** / warnings;迁移 `AddModelStats1789460000000` 新增 `models.stats TEXT` |
| 缩略图 | `model-thumbnail.ts` -> `models.thumbnail_asset_id` | 纯 TS 软件光栅化 512×512 PNG(读 GLB/glTF -> 节点层级与蒙皮 -> 包围盒构图 -> 透视投影 -> z-buffer 平面着色 -> 手写 PNG),**零原生依赖**;失败或非 glTF 容器留空,前端退化为格式占位图 |
## 状态与默认模型(硬性)
@@ -107,7 +107,7 @@ web-server/scripts/import-default-model.mjs
2. format='vrm' 且 published 且 is_primary=1
3. format='vrm' 且 published,按 id 升序取第一个
4. published,按 (format='vrm' DESC, id ASC) 取第一个
5. 无 → 客户端使用内置占位模型
5. 无 -> 客户端使用内置占位模型
```
`POST /:id/primary` 在**同一事务**内先清零其余 `is_primary`,保证互斥。
@@ -116,23 +116,23 @@ web-server/scripts/import-default-model.mjs
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`)
- `extensions.VRM` → VRM **0.x**
- 两者皆无 → `null`(普通 GLB)
- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`)
- `extensions.VRM` -> VRM **0.x**
- 两者皆无 -> `null`(普通 GLB)
## 实测(v0.2.0)
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 → **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 -> **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`。
分析结果:`vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240
bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23`;
`missingRequiredBones` 为**全部 16 根**(日文 MMD 骨名无法映射到 humanoid)。
- 直接上传 `.glb` → **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
- 直接上传 `.glb` -> **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
入库文件 sha256 与源文件**一致**(不重编码)。
- 缩略图:512×512 PNG / 15,043 字节,服务端纯 TS 光栅化(含蒙皮),目视确认为居中的
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60–121ms(中位 79ms)。
- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` → 200,
模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…`;
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200,
模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
重建后 `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**
(库中另有 2 个已发布 VRM)。
@@ -146,7 +146,7 @@ cd web-server
node scripts/import-default-model.mjs 模型.zip --url http://127.0.0.1:3000 \
--name "初音未来 Hatsune Miku" --author "Boku Wa Luka" \
--license "MMD 模型使用条款(见压缩包内说明)" --source-type other
# 上传 → 服务端转换入库(pending)→ 把身份写进 src/config/default-model.ts
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts
node scripts/import-default-model.mjs --write-source-only --model-id 22 # 只补写源码
node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
@@ -165,18 +165,18 @@ npm run test:smoke # 冒烟 [4] 段落
```
| 验收项 | 状态 |
|---|---|
|--|--|
| `.glb`/`.vrm`/`.gltf` 直接导入不重编码 | ✅ 实测 GLB 上传 201、`conversionNote=直接导入(GLB,未做重编码)`、入库 sha256 与源文件一致 |
| `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字→目录最浅→文件最大 |
| `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字->目录最浅->文件最大 |
| `.pmx` 转换优先 GLB | ✅ 实测 201 / 0.86s、`format=glb`、2 张贴图内嵌 |
| GLB 失败回退 glTF→GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 |
| GLB 失败回退 glTF->GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 |
| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`;humanoid 不全回退 GLB |
| 自动分析写入 `models.stats` | ✅ 实测 `triangles=9071 bones=238 humanoidMapped=0/22 drivable=false` |
| 缩略图生成且不影响入库 | ✅ 512×512 / 15,043 B;失败只告警 |
| 上传一律 pending、不自动设默认 | ✅ `create()` 固定 `PublishStatus.Pending` + `isPrimary=false` |
| `setPrimary` 审核通过时生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` → 200、`isPrimary=true` |
| 非 published 设默认 | ✅ `POST /api/models/:id/primary` → 409 |
| 失败语义 | ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 → HTTP 422,不产生模型记录 |
| `setPrimary` 审核通过时生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` -> 200、`isPrimary=true` |
| 非 published 设默认 | ✅ `POST /api/models/:id/primary` -> 409 |
| 失败语义 | ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 -> HTTP 422,不产生模型记录 |
| ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts` 24 项) |
| humanoid 与客户端一致 | ✅ `model-inspector.spec.ts` 抽 `humanoid_map.gd` 逐键比对(25 项) |
| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) |
+12 -12
View File
@@ -1,4 +1,4 @@
# T-WEB-006 BVH/VMD/FBX → 关键点序列预处理
# T-WEB-006 BVH/VMD/FBX -> 关键点序列预处理
- **状态**:done
- **依赖**:T-WEB-004
@@ -12,31 +12,31 @@
```
DSH.md
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射、§2.3 VMD
contracts/skeleton-topology.md # 锚点:§1 33 点、§3 父节点表、§5 关节角
contracts/model-formats.md # 锚点:2 动作格式、2.1 预处理产物、2.2 关节名映射、2.3 VMD
contracts/skeleton-topology.md # 锚点:1 33 点、3 父节点表、5 关节角
web-server/src/preprocess/*
```
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
|--|--|--|
| POST | `/api/motions/:id/preprocess` | 执行预处理,返回更新后的 Motion |
| GET | `/api/motions/:id/keypoints` | 下载 NDJSON 关键点序列 |
## 实现分层
| 文件 | 职责 |
|---|---|
|--|--|
| `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 镜像,输出逐帧世界坐标 |
| `mmd-pose.ts` | MMD FK + 两骨骼解析腿部 IK + 左手系->右手系 Z 镜像,输出逐帧世界坐标 |
| `math3d.ts` | 向量/矩阵工具、旋转合成、关节角余弦 |
| `joint-map.ts` | 关节名 → MediaPipe 点索引的默认映射表 |
| `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 → 骨盆归零 → 自适应缩放 → 投影 → 9 个关节角;BVH 与 VMD 共用 |
| `joint-map.ts` | 关节名 -> MediaPipe 点索引的默认映射表 |
| `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 -> 骨盆归零 -> 自适应缩放 -> 投影 -> 9 个关节角;BVH 与 VMD 共用 |
| `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 |
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF → BVH) |
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF -> BVH) |
| `preprocess.service.ts` | 编排、落盘、状态与错误记录 |
## 拓扑与缩放(契约冻结)
@@ -50,7 +50,7 @@ web-server/src/preprocess/*
## 格式支持
| 格式 | 处理 |
|---|---|
|--|--|
| `.bvh` | 内置解析器,无外部依赖 |
| `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 |
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
@@ -67,10 +67,10 @@ web-server/src/preprocess/*
- VMD 不含骨骼长度、只有旋转(少数骨骼带位移),必须靠 `mmd-skeleton.ts` 的
标准骨架做 FK;骨骼名按别名表归一(全角 `IK` / 半角 `IK`),未纳入标准骨架的
骨骼(裙摆 / 物理 / 表情等)被忽略但计数。
- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 → 两骨骼解析 IK(膝盖朝角色
- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 -> 两骨骼解析 IK(膝盖朝角色
正面弯,目标不可达时收敛为伸直,不产生 NaN);否则走 FK 旋转(部分动作数据把
腿烘焙成 FK)。
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) -> (-x,-y,z,w)`、位移 z
取反)后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,
`zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
- 重定向**完全复用 BVH 通路**:`worldFramesToKeypointFrames()` 负责骨盆归零 /
+17 -17
View File
@@ -13,7 +13,7 @@
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1reviews
contracts/data-models.md # 锚点:§1.2 PublishStatus、§4.1 默认模型选择规则、§5 ReviewRecord
contracts/data-models.md # 锚点:1.2 PublishStatus、4.1 默认模型选择规则、5 ReviewRecord
contracts/database.md # 锚点:状态机
web-server/src/reviews/*
web-server/src/models/model-inspector.ts
@@ -30,26 +30,26 @@ draft ──submit──▶ pending ──approve──▶ published
```
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/reviews` | 不传 `status` → 审核流水;传 `status` → 该状态待审资产(三类合并) |
|--|--|--|
| GET | `/api/reviews` | 不传 `status` -> 审核流水;传 `status` -> 该状态待审资产(三类合并) |
| GET | `/api/reviews/:targetType/:id/history` | 单个资产的审核历史 |
| POST | `/api/reviews/:targetType/:id/submit` | → `pending` |
| POST | `/api/reviews/:targetType/:id/approve` | → `published`;**模型**可带 `setPrimary:true` |
| POST | `/api/reviews/:targetType/:id/reject` | → `rejected`,**必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | → `draft` |
| POST | `/api/reviews/:targetType/:id/submit` | -> `pending` |
| POST | `/api/reviews/:targetType/:id/approve` | -> `published`;**模型**可带 `setPrimary:true` |
| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`,**必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | -> `draft` |
## 关键实现
- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
重复审核同一状态同样 409。
- 驳回未填 `comment` → **400**(合规要求:拒绝侵权内容需留痕)。
- 驳回未填 `comment` -> **400**(合规要求:拒绝侵权内容需留痕)。
- 每次成功流转都写一条 `review_records`,记录 `fromStatus` / `toStatus` /
`reviewer` / `comment`。
- 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。
- `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。
- **模型上传只能 pending**:`ModelsService.create()` 固定
`status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault`
等旁路一律不生效(硬性约束,见 DSH.md §6.9)。
等旁路一律不生效(硬性约束,见 DSH.md 6.9)。
## 模型审核面板(v0.2.0)
@@ -57,10 +57,10 @@ draft ──submit──▶ pending ──approve──▶ published
`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
| 区域 | 内容 |
|---|---|
|--|--|
| 基本 | 缩略图、格式、状态、文件地址、转换说明 |
| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 → 可驱动;不全 → 列出缺哪些(直接标红);非 glTF 容器 → 只能审核来源与体积 |
| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 -> 可驱动;不全 -> 列出缺哪些(直接标红);非 glTF 容器 -> 只能审核来源与体积 |
| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
| warnings | `ModelStats.warnings` 逐条列出 |
| 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 |
@@ -68,7 +68,7 @@ draft ──submit──▶ pending ──approve──▶ published
### 通过 / 驳回 / 设为默认
- 面板底部勾选「同时设为全局默认模型」→ 点「通过」时请求体带 `setPrimary:true`:
- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`:
`ReviewsService.transition()` 保存模型为 `published` 后调用
`ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码
`src/config/default-model.ts`。
@@ -90,15 +90,15 @@ npx vitest run # 10 个文件 / 168 项通过(含 admin-bone-filter 8
npm run test:smoke
```
冒烟 [5] 段落:submit → pending、待审列表包含、驳回无意见 400、approve → published、
冒烟 [5] 段落:submit -> pending、待审列表包含、驳回无意见 400、approve -> published、
重复审核 409、流水恰好 2 条。
| 验收项 | 状态 |
|---|---|
|--|--|
| 模型上传只能是 pending | ✅ `create()` 固定 `PublishStatus.Pending`,不自动发布/设默认 |
| 模型审核面板展示指标 + 分析 | ✅ 体积/格式/容器/VRM 版本/面数/顶点/网格/材质/贴图/骨骼/节点/形变目标/动画 |
| 骨骼详细界面与过滤 | ✅ 缩进 + 蒙皮/映射徽章 + 搜索保留祖先链 + 仅蒙皮开关 + 限高 + 前 400 行(`admin-bone-filter.spec.ts` 8 项) |
| `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` → 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…` |
| 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published → 409 |
| 驳回必须填意见 | ✅ 无 `comment` → 400 |
| `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` -> 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...` |
| 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published -> 409 |
| 驳回必须填意见 | ✅ 无 `comment` -> 400 |
| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) |
+4 -4
View File
@@ -12,7 +12,7 @@ React 管理端的歌曲管理页面:列表、检索、新建/编辑、音频
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1songs、#/components/schemas/Song
contracts/data-models.md # 锚点:§2 Song
contracts/data-models.md # 锚点:2 Song
web-server/admin/src/**
web-server/admin/package.json
```
@@ -22,7 +22,7 @@ web-server/admin/package.json
`web-server/admin/`(React 18 + Vite + TypeScript):
| 路径 | 说明 |
|---|---|
|--|--|
| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) |
| `src/components/SongForm*` | 新建/编辑表单,含来源五元组 |
| `src/api/client.ts` | fetch 封装;非 2xx 抛 `ApiError`,兼容 Nest 的 `message: string[]` |
@@ -32,8 +32,8 @@ web-server/admin/package.json
## 页面能力
- 分页表格 + 关键词/状态/歌手过滤
- 新建与编辑:曲名、歌手、专辑、语言、时长(秒输入 → 毫秒提交)、BPM、
难度 1–5、标签(逗号分隔)、状态、**来源五元组**
- 新建与编辑:曲名、歌手、专辑、语言、时长(秒输入 -> 毫秒提交)、BPM、
难度 1-5、标签(逗号分隔)、状态、**来源五元组**
- 音频上传(`POST /api/songs/:id/audio`,字段名 `file`)与 `<audio controls>` 试听
- 删除需二次确认(软删除)
- 客户端校验:曲名/歌手与来源五项必填,`sourceUrl` 必须以 `http` 开头
+3 -3
View File
@@ -13,7 +13,7 @@ React 管理端的模型管理页面:列表、上传、**来源信息完整展
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
contracts/data-models.md # 锚点:§4 Model、§4.1 VRM 优先规则
contracts/data-models.md # 锚点:4 Model、4.1 VRM 优先规则
web-server/admin/src/pages/ModelsPage.tsx
web-server/admin/src/components/VrmPreview.tsx
```
@@ -21,7 +21,7 @@ web-server/admin/src/components/VrmPreview.tsx
## 交付物
| 路径 | 说明 |
|---|---|
|--|--|
| `src/pages/ModelsPage.tsx` | 模型列表 + 上传表单 + 设为默认 |
| `src/components/VrmPreview.tsx` | three.js + @pixiv/three-vrm 的 3D 预览面板 |
@@ -33,7 +33,7 @@ web-server/admin/src/components/VrmPreview.tsx
+ `license`,`sourceUrl` 可点击外链跳转
- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
状态),客户端校验与歌曲页一致
- 「设为默认」→ `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果
- 「设为默认」-> `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果
- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
支持鼠标拖拽旋转与滚轮缩放,正确处理 VRM 0.x / 1.0 元数据差异、
未选择模型、加载失败三种状态,卸载时释放资源
+18 -18
View File
@@ -11,7 +11,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
**也不部署反向代理**(由另外的服务/团队负责)。
> **需求变更记录**
> 1. 初版按文档 §4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站",
> 1. 初版按文档 4.4 实现为 Docker Compose;用户要求改为"运行在 Linux 的网站",
> 于是替换为 nginx + systemd 原生站点部署,并删除 `deploy/docker-compose.yml`、
> `web-server/Dockerfile`、`web-server/admin/Dockerfile`。
> 2. 随后用户明确:"web 端仅需要业务需求,反代等工作交由另外的服务处理。
@@ -57,7 +57,7 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供
└── /contracts/ 契约快照
(/、/songs 等返回 404,有意设计)
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关…)──▶ 管理端端口 :3001
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关...)──▶ 管理端端口:3001
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
│ ├── /assets/ 前端静态资源
│ └── 上面全部接口
@@ -74,8 +74,8 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供
## 交付物
| 文件 | 说明 |
|---|---|
| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查;汇总打印两个端口与防火墙放行提示;缺 `deploy/.env` 时先调用 `init-env.sh` 引导 |
|--|--|
| `deploy/install.sh` | 一键部署:前置检查 -> 建用户/目录 -> 复制并校验文件 -> 装依赖 -> 迁移 -> 注册系统服务 -> 健康检查;汇总打印两个端口与防火墙放行提示;缺 `deploy/.env` 时先调用 `init-env.sh` 引导 |
| `deploy/init-env.sh` | **首次启动的 `.env` 交互引导**:逐项询问 8 个键、写盘前校验必填项与端口范围、可选数据库连通性自检;只替换/追加这 8 个键并保留模板注释,写完 `chmod 600` 并回读;`--check` / `-y` 等非交互模式下绝不阻塞 |
| `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 |
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
@@ -93,11 +93,11 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供
```
install.sh 检测到缺 deploy/.env
├── $APP_DIR/deploy/.env 存在 → 直接复用(上次安装留下的配置,原语义保留)
├── $APP_DIR/deploy/.env 存在 -> 直接复用(上次安装留下的配置,原语义保留)
└── 否则调用 deploy/init-env.sh
├── 缺 .env → 从 .env.example 复制,chmod 600
├── 交互(TTY / 管道)→ 逐项询问并写回
└── 非交互(CI、</dev/null、-y)→ 只校验,绝不阻塞;失败则 install.sh die
├── 缺.env -> 从.env.example 复制,chmod 600
├── 交互(TTY / 管道)-> 逐项询问并写回
└── 非交互(CI、</dev/null、-y)-> 只校验,绝不阻塞;失败则 install.sh die
```
逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST`
@@ -107,7 +107,7 @@ install.sh 检测到缺 deploy/.env
结尾打印摘要(口令 `***`)与本次变更列表。
| 开关 | 说明 |
|---|---|
|--|--|
| (无) | 交互引导;`.env` 已存在且必填项齐全时跳过询问 |
| `--force` | 已有 `.env` 也重新逐项询问 |
| `--check` | 只校验,不修改文件、不提问 |
@@ -123,7 +123,7 @@ install.sh 检测到缺 deploy/.env
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
**非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y` 等开关 →
**非交互绝不挂住**:stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y` 等开关 ->
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
**默认监听 0.0.0.0**:`configuration.ts` 的
@@ -151,16 +151,16 @@ install.sh 检测到缺 deploy/.env
(注意:`nohup` 不能调用 shell 函数,故 `ctl.sh` 用
`spawn_service_process()` 拼装外部命令,而非包装函数。)
**不使用 sudo**:脚本已要求 root,降权到服务用户用 `runuser → setpriv → su`
**不使用 sudo**:脚本已要求 root,降权到服务用户用 `runuser -> setpriv -> su`
依次回退(部分服务器根本没装 sudo)。
## 关键实现
- **双端口**:一个 Node 进程创建两个 HTTP server——软件 API 端口(`PORT`,默认 3000)
- **双端口**:一个 Node 进程创建两个 HTTP server--软件 API 端口(`PORT`,默认 3000)
与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用
`req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
- **服务自身托管管理端**:`app.useStaticAssets(adminDir, …)` + SPA 回退中间件
- **服务自身托管管理端**:`app.useStaticAssets(adminDir,...)` + SPA 回退中间件
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
@@ -185,7 +185,7 @@ install.sh 检测到缺 deploy/.env
`glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展
(humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB**
并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README §四
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README 四
- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
本版本迁移共 3 个:`InitSchema1789382903060`、
@@ -195,7 +195,7 @@ install.sh 检测到缺 deploy/.env
`NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
故用 `env -u NODE_ENV npm ci --include=dev`
- **复制文件时锚定排除**:`storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
(曾导致 `MODULE_NOT_FOUND …storage.module`),排除规则一律用 `/storage/` 锚定
(曾导致 `MODULE_NOT_FOUND...storage.module`),排除规则一律用 `/storage/` 锚定
- **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照
## 验收
@@ -210,7 +210,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
```
| 验收项 | 状态 |
|---|---|
|--|--|
| `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
@@ -248,7 +248,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
```bash
./scripts/package-release.sh
# → dist-release/opendance-web-v0.2.0.tar.gz
# -> dist-release/opendance-web-v0.2.0.tar.gz
```
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`
@@ -260,6 +260,6 @@ v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`
`dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、
`dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。
> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` → **`opendance-web-v0.2.0.tar.gz`**
> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` -> **`opendance-web-v0.2.0.tar.gz`**
> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
> 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。
+5 -5
View File
@@ -11,7 +11,7 @@
```bash
npm install # 或 pnpm install
npm run dev # 开发服务器 http://localhost:5173
npm run build # 生产构建(tsc -b && vite build)→ dist/
npm run build # 生产构建(tsc -b && vite build)-> dist/
npm run preview # 本地预览 dist/
npm run typecheck # 只做类型检查(tsc --noEmit)
```
@@ -42,7 +42,7 @@ server: {
const BASE_URL = import.meta.env.VITE_API_BASE?? '';
```
- **不设置(默认,空串)**:同源请求 —— 开发期走上面的 vite 代理,生产期走 nginx 反代。**推荐**。
- **不设置(默认,空串)**:同源请求 -- 开发期走上面的 vite 代理,生产期走 nginx 反代。**推荐**。
- **设置**:例如服务端在另一台机器上:
```bash
@@ -90,7 +90,7 @@ src/
endpoints.ts 契约端点封装
components/
Layout.tsx 左侧导航骨架
ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单…)
ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单...)
VrmPreview.tsx three + @pixiv/three-vrm 的 3D 预览(懒加载分包)
lib/
labels.ts 枚举中文文案
@@ -108,8 +108,8 @@ src/
## 页面与任务对照
| 路由 | 说明 | 任务 |
|---|---|---|
| `/` | 概览:三类资产按状态计数、契约版本与目录指纹、健康检查、API 不可达告警 | — |
|--|--|--|
| `/` | 概览:三类资产按状态计数、契约版本与目录指纹、健康检查、API 不可达告警 | - |
| `/songs` | 歌曲管理:分页/搜索/筛选、来源五元组必填、音频上传、软删除 | T-WEB-008 |
| `/models` | 模型管理:VRM 优先、来源五元组完整展示、设为默认、3D 预览 | T-WEB-009 |
| `/motions` | 动作管理:上传、预处理、关键点 NDJSON 下载 | T-WEB-004/006 |