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
+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 |
+37 -37
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(见下) |
@@ -60,36 +60,36 @@ 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
.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
└─ 每步 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 元数据差异、
未选择模型、加载失败三种状态,卸载时释放资源
+25 -25
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 端仅需要业务需求,反代等工作交由另外的服务处理。
@@ -24,12 +24,12 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
> 4. 本次追加两项:
> a) **首次启动的 .env 引导**:新增 `deploy/init-env.sh`(交互式逐项询问数据库
> a) **首次启动的.env 引导**:新增 `deploy/init-env.sh`(交互式逐项询问数据库
> 与端口,写盘前校验必填项/端口范围,可用 Node + mysql2 做连通性自检);
> `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次
> 安装留下的 .env 则直接复用,保持原语义)。
> 安装留下的.env 则直接复用,保持原语义)。
> b) **默认监听 0.0.0.0 固化**:确认 `configuration.ts` 的
> `bindHost = process.env.HOST ?? '0.0.0.0'`、`adminHost` 默认跟随 HOST,
> `bindHost = process.env.HOST?? '0.0.0.0'`、`adminHost` 默认跟随 HOST,
> 两个 `.env.example` 与 `init-env.sh` 的 HOST 默认值均为 `0.0.0.0`。
## load
@@ -50,14 +50,14 @@ deploy/proxy/README.md
**职责边界**:本端只提供**业务服务**;反向代理由另外的服务/团队负责。
```
Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供接口,无管理页面)
Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供接口,无管理页面)
├── /api/ REST API
├── /health 健康检查(数据库不可用返回 503)
├── /static/ 上传文件
└── /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,11 +123,11 @@ 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` 的
`bindHost = process.env.HOST ?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
`bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
(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`。
@@ -151,23 +151,23 @@ 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(有意设计)
- **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用
`0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在
本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用
- **首次部署的 .env 由引导生成**:`install.sh` 缺 `deploy/.env` 时先调用
- **首次部署的.env 由引导生成**:`install.sh` 缺 `deploy/.env` 时先调用
`deploy/init-env.sh`(安装目录已有上次安装的 `.env` 则直接复用);引导脚本在
非交互环境下只校验、绝不阻塞,校验失败时 install.sh 才 die
- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
@@ -185,17 +185,17 @@ 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`、
`AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`**
(新增 `models.stats TEXT`,存模型分析结果,已在真实库执行)
- **依赖安装显式带 dev**:安装脚本先 `set -a; . deploy/.env` 导出了
- **依赖安装显式带 dev**:安装脚本先 `set -a;. 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` 一致)。