From 60c182f1b281d5dc347b907bc38066d7df2f9291 Mon Sep 17 00:00:00 2001
From: opendance
Date: Tue, 15 Sep 2026 21:45:51 +0800
Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B6=88=E9=99=A4=20Gitea=20=E6=A8=A1?=
=?UTF-8?q?=E6=A3=B1=E4=B8=A4=E5=8F=AF=20Unicode=20=E5=91=8A=E8=AD=A6?=
=?UTF-8?q?=EF=BC=88=E5=85=A8=E8=A7=92=E6=A0=87=E7=82=B9=E7=B4=A7=E9=82=BB?=
=?UTF-8?q?=20ASCII=20=E6=97=B6=E6=94=B9=E4=B8=BA=E5=8D=8A=E8=A7=92?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
依据 Gitea v1.27.3 modules/charset/escape_stream.go 的判定规则
(analyzeDetectResults:仅当左右相邻字符都不是非 ASCII 时才标记,
且 _common 表恒定生效),把说明文档中会被标记的全角标点
(,:;?!() 等)改为半角,并保证成对括号宽度一致。
中文语境中的全角标点(左右均为中文)保持原样,不动代码与契约语义。
同时删除临时探针目录 _probe/(仅用于确认 Gitea 判定规则)。
---
DSH.md | 102 +++++++++++++++----------------
THIRD_PARTY.md | 2 +-
_probe/ambiguity.txt | 16 -----
_probe/chunk-0.txt | 40 ------------
_probe/chunk-1.txt | 41 -------------
_probe/chunk-2.txt | 40 ------------
_probe/chunk-3.txt | 41 -------------
_probe/chunk-4.txt | 41 -------------
_probe/chunk-5.txt | 40 ------------
_probe/chunk-6.txt | 40 ------------
_probe/chunk-7.txt | 40 ------------
contracts/data-models.md | 6 +-
contracts/database.md | 22 +++----
contracts/model-formats.md | 58 +++++++++---------
contracts/skeleton-topology.md | 14 ++---
contracts/websocket-keypoints.md | 12 ++--
deploy/proxy/README.md | 26 ++++----
tasks/001-scaffold.md | 6 +-
tasks/002-db-migration.md | 8 +--
tasks/003-song-crud.md | 6 +-
tasks/004-motion-upload.md | 26 ++++----
tasks/005-model-upload.md | 72 +++++++++++-----------
tasks/006-keypoint-preprocess.md | 20 +++---
tasks/007-upload-review.md | 26 ++++----
tasks/008-admin-ui-songs.md | 12 ++--
tasks/009-admin-ui-models.md | 12 ++--
tasks/010-deploy.md | 94 ++++++++++++++--------------
web-server/admin/README.md | 28 ++++-----
28 files changed, 276 insertions(+), 615 deletions(-)
delete mode 100644 _probe/ambiguity.txt
delete mode 100644 _probe/chunk-0.txt
delete mode 100644 _probe/chunk-1.txt
delete mode 100644 _probe/chunk-2.txt
delete mode 100644 _probe/chunk-3.txt
delete mode 100644 _probe/chunk-4.txt
delete mode 100644 _probe/chunk-5.txt
delete mode 100644 _probe/chunk-6.txt
delete mode 100644 _probe/chunk-7.txt
diff --git a/DSH.md b/DSH.md
index 8e0e22f..d70048b 100644
--- a/DSH.md
+++ b/DSH.md
@@ -1,4 +1,4 @@
-# DSH.md -- opendance-web(OpenDance Studio 项目一:Web 管理服务端)
+# DSH.md -- opendance-web(OpenDance Studio 项目一:Web 管理服务端)
## 0. 本文件的作用
@@ -31,9 +31,9 @@ opendance-web ←── 本仓库
| 管理前端 | React 18 + Vite + TypeScript |
| 3D 预览 | three.js + @pixiv/three-vrm |
| 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
-| 模型主力格式 | **GLB / VRM 为一等公民**:`.glb`/`.vrm`/`.gltf` 直接导入(不重编码),`.pmx`/`.fbx` 服务端转换(优先 GLB、其次 VRM) |
+| 模型主力格式 | **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 |
@@ -69,7 +69,7 @@ opendance-web ←── 本仓库
opendance-web/
├── project.yaml # 任务清单与状态
├── DSH.md # 本文件
-├── tasks/ # 单任务说明书(001~010)
+├── tasks/ # 单任务说明书(001~010)
├── contracts/ # ★ 跨端契约(权威)
│ ├── VERSION # 契约版本(唯一来源)
│ ├── rest-api.yaml # OpenAPI 3.0
@@ -80,11 +80,11 @@ opendance-web/
│ ├── database.md # 现有 MySQL 连接与迁移约定
│ └── open-source-credits.md # 开源声明锚点(三端共用)
├── web-server/ # NestJS 服务端
-│ ├── src/ # 源码(entities / models / preprocess)
+│ ├── 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 打包
+│ │ │ ├── gltf-packer.ts # glTF(.gltf +.bin + 贴图)-> GLB 打包
│ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk
│ │ │ ├── vrm-writer.ts # GLB -> VRM 1.0(写 VRMC_vrm 扩展)
│ │ │ ├── model-inspector.ts # 面数 / 顶点 / 骨骼树 / humanoid 覆盖 / 可驱动性
@@ -92,12 +92,12 @@ opendance-web/
│ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts
│ │ │ └── vrm-inspector.ts
│ │ ├── config/default-model.ts # 默认模型种子(由审核 / CLI 写入,运行期只读)
-│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats)
+│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats)
│ │ └── preprocess/ # BVH / VMD / FBX -> MediaPipe33
│ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值
-│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
+│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系->右手系 Z 镜像
-│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
+│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
│ ├── admin/ # React 管理端
│ │ ├── src/components/ModelReviewPanel.tsx # 模型审核面板 + 骨骼详细界面
│ │ └── src/lib/bone-tree.ts # 骨骼树过滤(搜索保留祖先链)
@@ -107,7 +107,7 @@ opendance-web/
│ │ # / preprocess 25 / init-env 16 / admin-port 16
│ │ # / access-log 9 / vrm-inspector 6
│ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs
-│ # / inspect-vmd.mjs(VMD 诊断)
+│ # / inspect-vmd.mjs(VMD 诊断)
│ # / import-default-model.mjs(默认模型导入 / 补写源码)
├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代)
│ ├── init-env.sh # ★ 首次启动的.env 交互引导(install.sh 缺.env 时调用)
@@ -126,15 +126,15 @@ opendance-web/
## 6. 硬性约束
1. **不部署 MySQL**。数据库为部署方提供的现有实例,连接信息只经环境变量注入。
- **也不使用 Docker**,**也不部署反向代理**:本端只提供业务服务
- (API + 自带的管理端页面),反代由另外的服务负责,
+ **也不使用 Docker**, **也不部署反向代理**:本端只提供业务服务
+ (API + 自带的管理端页面),反代由另外的服务负责,
本项目只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。
业务服务监听**两个端口**(软件 API 端口 `PORT` + 管理端端口 `ADMIN_PORT`),
约定见 9。
2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个
migration 必须可逆。
3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。
-4. **来源五元组强制必填**:`source_type` / `source_name` / `source_url` / `license`
+4. **来源五元组强制必填**: `source_type` / `source_name` / `source_url` / `license`
/ `author`。任何资产入库缺一即 400。
5. **契约先行**:改接口先改 `contracts/`,再提升 `contracts/VERSION`,同步
`web-server/src/config/contract-versions.ts`,最后才改实现。
@@ -142,12 +142,12 @@ opendance-web/
6. **不使用 SMPL** 及其衍生拓扑(许可风险),统一 MediaPipe 33 点。
7. **上传文件永不使用原始文件名作为路径**,一律 `sha256` 去重后归档。
8. **GLB / VRM 是模型主力格式**:服务端与客户端(Godot 内置 `GLTFDocument`)都把
- 这两种格式作为一等公民。`.glb` / `.vrm` / `.gltf` **直接导入、不重编码**;
+ 这两种格式作为一等公民。`.glb` / `.vrm` / `.gltf` **直接导入、不重编码**;
`.pmx` / `.fbx` 才由服务端转换,且**优先 GLB、其次 VRM**。
humanoid 候选表必须与客户端 `opendance-windows/src/model/humanoid_map.gd` 逐字对齐,
由 `test/model-inspector.spec.ts` 断言兜底。
9. **模型上传一律 `pending`(待审核)**:上传不得自动发布、不得自动设为默认模型。
- 默认模型只在审核通过勾选 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)
+ 默认模型只在审核通过勾选 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)
或显式调用 `POST /api/models/:id/primary`(仅 `published`,否则 409)时设置。
## 7. 每任务的执行步骤
@@ -186,7 +186,7 @@ DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
DB_SYNCHRONIZE=false
PORT=3000 # 软件 API 端口(客户端调用,不含管理页面)
ADMIN_PORT=3001 # 管理端端口(默认 PORT + 1;等于 PORT 为单端口兼容模式)
-HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0)
+HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.0.0.0)
ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST
STORAGE_DIR=/data/storage
UPLOAD_MAX_BYTES=536870912
@@ -202,10 +202,10 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
## 9. 双端口约定(软件 API 端口 / 管理端端口)
-- **软件 API 端口**:`PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App
- 调用,只提供 `/api`、`/health`、`/static`、`/contracts`;**不提供管理页面**,
+- **软件 API 端口**: `PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App
+ 调用,只提供 `/api`、`/health`、`/static`、`/contracts`; **不提供管理页面**,
`/`、`/songs` 等一律 404(有意设计,避免管理页暴露在客户端调用的端口上)。
-- **管理端端口**:`ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问,
+- **管理端端口**: `ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问,
提供管理页面(含 SPA 深链接回退)与上面全部接口。
- `ADMIN_HOST` 可选,默认跟随 `HOST`;可用它把管理端限制在本机或内网。
- `ADMIN_PORT === PORT` 为**单端口兼容模式**:管理页面回到 API 端口,日志提示
@@ -215,7 +215,7 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
- 反向代理按用途分别转发两个端口;`/health` 两个端口都提供,可用于存活探测。
细则见 `deploy/proxy/README.md` 与 `tasks/010-deploy.md`。
-## 10. VMD 动作通路(MikuMikuDance)
+## 10. VMD 动作通路(MikuMikuDance)
```
.vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts
@@ -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. 模型格式策略、转换、分析与审核
@@ -252,7 +252,7 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
├─ 2) 失败 ->.gltf -> gltf-packer.ts 打包回 GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm -> vrm-writer.ts 补 VRMC_vrm
└─ 每步 assimp info 回读校验
- ─▶ 入库(status=pending)
+ ─▶ 入库(status=pending)
─▶ model-inspector.ts 分析 -> models.stats
─▶ model-thumbnail.ts 缩略图 -> models.thumbnail_asset_id
```
@@ -266,7 +266,7 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
回读校验 -> 回退。
- `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。
@@ -274,20 +274,20 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
- `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。
- `config/default-model.ts`:默认模型种子(审核 / CLI 写入,运行期只读)。
- **压缩包自动搜索**:优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内
- 按 `MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大。命中 glTF 系格式时**直接导入**,
+ 按 `MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大。命中 glTF 系格式时**直接导入**,
只有 `.pmx`/`.fbx` 才转换。压缩包原件存为 `model-archive` 资产。
-- **转换与回退**:GLB 直出失败 -> glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB
- 之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。
+- **转换与回退**: GLB 直出失败 -> glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB
+ 之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。
**每一步产物都必须用 `assimp info` 回读校验**:实测 Assimp 会"写出损坏文件"
- (FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码
+ (FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码
会把坏文件入库。
-- **入库后自动**:`inspectModelFile()` 分析写入 `models.stats`
+- **入库后自动**: `inspectModelFile()` 分析写入 `models.stats`
(迁移 `AddModelStats1789460000000`,新增 `models.stats TEXT`);
`renderModelThumbnail()` 写入 `models.thumbnail_asset_id` / `thumbnailUrl`。
**两者都是"尽力而为"**:失败只记 warning,不影响入库。
-- **状态与默认模型(硬性)**:上传**一律 `status=pending`**,`is_primary=false`,
+- **状态与默认模型(硬性)**:上传**一律 `status=pending`**, `is_primary=false`,
不自动发布、不自动设为默认。默认模型只在
- ① 审核通过带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)或
+ ① 审核通过带 `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` -> 源码种子
@@ -295,17 +295,17 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
-> 已发布且 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
+- **文件名编码**: `common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart
文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`)。
-- 契约:`contracts/model-formats.md` 1.1、`contracts/data-models.md` 4.1;
+- 契约:`contracts/model-formats.md` 1.1、`contracts/data-models.md` 4.1;
DTO 新增 `conversionNote` / `fileSha256` / `stats` / `thumbnailUrl`。
### 11.1 审核要求(模型)
管理端「审核」页对 `pending` 模型展示 **模型审核面板**
-(`admin/src/components/ModelReviewPanel.tsx`):
+(`admin/src/components/ModelReviewPanel.tsx`):
- **指标**:体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 /
骨骼 / 节点 / 形变目标 / 动画。
@@ -317,24 +317,24 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
- **通过**:可勾选「同时设为全局默认模型」-> 请求体 `setPrimary:true`。
- **驳回**:必须填写审核意见(400)。
- 实测:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200、
- `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
+ `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;
重建后 `defaultModelId` 由 VRM 变为 **22**。
-### 11.2 实测数据(v0.2.0)
+### 11.2 实测数据(v0.2.0)
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 -> **HTTP 201 / 0.86s**、`format=glb`、
- `status=pending`、`conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`;
+ `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,未做重编码)`,
+ maxBoneDepth=23`, `missingRequiredBones` 为全部 16 根(日文 MMD 骨名无法映射)。
+- 直接上传 `.glb`: **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
入库文件 sha256 与源文件**一致**。
- 缩略图:512x512 PNG / 15,043 字节,纯 TS 光栅化(含蒙皮),目视确认为居中的
- T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
-- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM");
+ T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
+- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM");
列表页显示 40px 缩略图。
- 测试:`npx vitest run` -> **10 个文件 / 168 项通过**
- (`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、
+ (`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)。
@@ -346,22 +346,22 @@ POST /api/models ─┬─.glb /.vrm /.gltf ─▶ importModelFile() 直接导
(口令只经环境变量传递、失败仅告警);只替换/追加这 8 个键、保留模板注释,
写完 `chmod 600` 并回读校验;结尾打印摘要(口令 `***`)与变更列表。
- 开关:`--force` / `--check` / `-y|--yes|--non-interactive` / `-i|--interactive` /
- `--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`;
+ `--skip-db-check` / `--dir` / `--env-file` / `--example` / `--timeout` / `-h`;
环境变量 `OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
-- **非交互绝不挂住**:stdin 非 TTY 且是 `
+- **非交互绝不挂住**: stdin 非 TTY 且是 `
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
- 接入点:`deploy/install.sh` 缺 `deploy/.env` 时先调用 `init-env.sh` 引导,
失败才 die(若 `$APP_DIR/deploy/.env` 已存在则复用,保持原语义)。
-- **监听地址默认 `0.0.0.0`**:`configuration.ts` 的
- `bindHost = process.env.HOST?? '0.0.0.0'`,`adminHost` 默认跟随 `HOST`
+- **监听地址默认 `0.0.0.0`**: `configuration.ts` 的
+ `bindHost = process.env.HOST?? '0.0.0.0'`, `adminHost` 默认跟随 `HOST`
(同样回落 `0.0.0.0`);`deploy/.env.example` 与 `web-server/.env.example`
- 均为 `HOST=0.0.0.0`;`install.sh` 的 `BIND_HOST="${HOST:-0.0.0.0}"`,
+ 均为 `HOST=0.0.0.0`; `install.sh` 的 `BIND_HOST="${HOST:-0.0.0.0}"`,
`init-env.sh` 的 HOST 默认值也是 `0.0.0.0`。
- (`install.sh` 的 `HEALTH_HOST`/`API_HOST` 是**本机健康检查**用的回环地址,
+ (`install.sh` 的 `HEALTH_HOST`/`API_HOST` 是**本机健康检查**用的回环地址,
与对外监听的默认值无关。)
-- 测试 `test/init-env.spec.ts`(16 项)。
+- 测试 `test/init-env.spec.ts`(16 项)。
## 13. 上下文超标时的拆分策略
diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md
index b54ab22..8074d44 100644
--- a/THIRD_PARTY.md
+++ b/THIRD_PARTY.md
@@ -95,7 +95,7 @@
## 14. class-validator / class-transformer
- 许可证:MIT
-- 源链接:https://github.com/typestack/class-validator ,
+- 源链接:https://github.com/typestack/class-validator ,
https://github.com/typestack/class-transformer
- 使用端:Web 服务端(请求校验与转换)
diff --git a/_probe/ambiguity.txt b/_probe/ambiguity.txt
deleted file mode 100644
index 6ccf80b..0000000
--- a/_probe/ambiguity.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-P01 fullwidth comma after ascii: value,next
-P02 fullwidth comma between cjk: 值,下
-P03 fullwidth colon after ascii: key:value
-P04 fullwidth colon between cjk: 键:值
-P05 fullwidth semicolon after ascii: a;b
-P06 fullwidth semicolon between cjk: 甲;乙
-P07 fullwidth question after ascii: ok?
-P08 fullwidth question between cjk: 好?吧
-P09 fullwidth exclam after ascii: ok!
-P10 fullwidth lparen pair ascii inside: x(API 29)y
-P11 fullwidth lparen pair cjk inside: 值(括号)下
-P12 fullwidth paren pair with ascii right after close: 地址(默认 http://127.0.0.1:3000),后
-P13 ascii paren pair: x(API 29)y
-P14 ascii then fullwidth comma: value,下
-P15 fullwidth dot after ascii: ok。
-P16 fullwidth ideographic comma after ascii: a、b
diff --git a/_probe/chunk-0.txt b/_probe/chunk-0.txt
deleted file mode 100644
index 01d864f..0000000
--- a/_probe/chunk-0.txt
+++ /dev/null
@@ -1,40 +0,0 @@
-================================================================
-OpenDance Studio -- Android 辅助 App v0.1.0 使用说明
-================================================================
-
-手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
-WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评分。
-
-隐私:画面不会离开手机。通过网络发送的只有 33 个关键点坐标。
-
---
-一、安装
---
-
-方式一(推荐):直接安装随附的 app-release.apk
- - 在手机上允许「安装未知来源应用」
- - 点击 APK 安装
-
-方式二:用 adb 安装
- adb install -r app-release.apk
-
-要求:Android 10(API 29)或更高版本。
-
---
-二、初次部署(从零跑起来)
---
-
-本节面向第一次部署的人,给出从零到跑通的最短路径。
-下文地址、路径、口令一律是占位符,请替换成你自己的值,且不要提交进仓库。
-
-1) 前置条件
-
- JDK 17 或更高 AGP 8.7.3 要求,构建脚本会校验主版本 >= 17
- Gradle 8.9 仓库自带 gradlew,本机无需另装
- Android SDK platforms;android-34 与 build-tools;34.0.0
- compileSdk 34 / minSdk 29(Android 10+)/ targetSdk 34
- 设备 需要真机(Android 10 及以上、带摄像头)
-
- 真机与模拟器:
- release 包只含 arm64-v8a + armeabi-v7a,装不到 x86/x86_64 模拟器;
- debug 包含 x86/x86_64,可装进模拟器,但相机是虚拟场景,姿态识别
\ No newline at end of file
diff --git a/_probe/chunk-1.txt b/_probe/chunk-1.txt
deleted file mode 100644
index eb7af49..0000000
--- a/_probe/chunk-1.txt
+++ /dev/null
@@ -1,41 +0,0 @@
- 效果不能代表真机,不能作为验收依据。联调验收请用真机。
-
- 权限:相机权限为运行时授予;INTERNET / ACCESS_NETWORK_STATE /
- ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于推流与局域网扫描。
-
-2) 获取代码与本地配置
-
- git clone https:////opendance-android.git
- cd opendance-android
-
- local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
- sdk.dir=
-
- build/build-apk.sh 在缺失时会依据 ANDROID_HOME / ANDROID_SDK_ROOT 自动生成。
-
- 签名材料不入库:仓库只有生成脚本,没有私钥。首次发布前执行
- bash build/gen-keystore.sh
- 生成 keystore/opendance-release.jks 与 keystore.properties(含 storeFile、
- keyAlias 与两个口令字段)。请自行备份,且不要提交。
- 取值优先级:keystore.properties -> 环境变量(KEYSTORE_PATH /
- KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD)-> 回退 debug 签名
- (仅供 CI 产出已签名 APK,正式发布必须自备 keystore)。
-
-3) 首次构建
-
- export JAVA_HOME=
- export ANDROID_HOME=
-
- bash build/build-apk.sh debug # 仅 debug
- bash build/build-apk.sh release # 仅 release
- bash build/build-apk.sh all # 两者都构建(含产物校验)
-
- 或直接用 Gradle:
- ./gradlew assembleDebug
- ./gradlew assembleRelease
-
- 产物(固定路径):
- app/build/outputs/apk/debug/app-debug.apk
- app/build/outputs/apk/release/app-release.apk
-
- 发布整包(APK + THIRD_PARTY.md + README.txt + contracts/):
\ No newline at end of file
diff --git a/_probe/chunk-2.txt b/_probe/chunk-2.txt
deleted file mode 100644
index 353ee8f..0000000
--- a/_probe/chunk-2.txt
+++ /dev/null
@@ -1,40 +0,0 @@
- bash build/package-release.sh -> dist-release/
-
-4) 安装到手机
-
- adb install -r app/build/outputs/apk/debug/app-debug.apk
- adb install -r app/build/outputs/apk/release/app-release.apk
-
- 也可把 APK 拷到手机,允许「安装未知来源应用」后点击安装。
- 首次进入运行页会申请相机权限;拒绝后可在运行页点「授予相机权限」
- 重新申请,或到系统设置里手动开启。
-
-5) 配置与联通
-
- 本 App 只连接 Windows 客户端,不访问 Web 服务端。
- 地址来自 AppSettings.webSocketUrl():ws://:/pose
- 子协议 opendance.pose.v1。
-
- 连接页字段与默认值(见 data/AppSettings.kt):
- IP 地址 host 默认是 RFC 5737 文档网段占位地址
- (源码为 192.0.2.100,非真实主机)
- 端口 port 8765
- 目标帧率 targetFps 30(5~60)
- 分辨率 resolution 640x480(可选 480x360 / 1280x720)
- 镜像画面 mirror 开
- 仅上半身模式 upperBodyOnly 关
-
- 填写示例(占位符,请替换成你电脑的局域网 IP):
- IP 地址:192.0.2.10
- 端口: 8765
-
- 不想手填可点「扫描局域网」:App 由本机 WiFi IPv4 推导 /24 网段,
- 以 32 并发、单机 300ms 超时探测开放 8765 的主机(约 2~3 秒),
- 按延迟升序展示,点候选即填入。也可先点「测试连接」确认端口可达。
-
-6) 第一次跑通
-
- 手机与电脑连同一局域网 -> 电脑启动 Windows 客户端(监听 8765)->
- App 填地址并测试连接 -> 点「开始推流」并授予相机权限 -> 站到画面中央。
-
- 成功标志:
\ No newline at end of file
diff --git a/_probe/chunk-3.txt b/_probe/chunk-3.txt
deleted file mode 100644
index b1f8da9..0000000
--- a/_probe/chunk-3.txt
+++ /dev/null
@@ -1,41 +0,0 @@
- App 运行页 「连接状态」为「已连接」,「目标」显示 ws://:<端口>/pose
- App 运行页 服务端返回 welcome 后出现「会话」ID
- App 运行页 「已发送」持续增长,「发送帧率」「识别帧率」大于 0
- App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
- Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
-
-7) 自检清单
-
- ./gradlew testDebugUnitTest
- 预期:BUILD SUCCESSFUL,全部用例通过
- 报告:app/build/reports/tests/testDebugUnitTest/index.html
-
- bash build/build-apk.sh all
- 预期:debug 与 release 均显示 "OK ... APK",release 显示
- "已签名(APK Signature Scheme v2)"
-
- $ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
- 预期:退出码 0(无输出即通过)
-
- adb install -r app/build/outputs/apk/debug/app-debug.apk
- 预期:Success
-
-8) 常见问题
-
- Q: 相机黑屏 / 一直提示需要相机权限
- A: 权限被拒绝或已被永久拒绝,或相机被其他应用占用。在运行页点
- 「授予相机权限」;不再弹窗时到系统「设置 -> 应用 -> OpenDance -> 权限」
- 手动开启相机,并关闭占用相机的其它 App。
-
- Q: 连不上 Windows 客户端 / 扫描不到主机
- A: 核对 IP 与端口、确认客户端已启动并监听 8765、在 Windows 防火墙放行
- OpenDance 的 TCP 8765 入站、确认手机与电脑同一 WiFi 且路由器未开 AP 隔离。
-
- Q: 点「开始推流」闪退(或提示 MediaPipe 初始化失败)
- A: MediaPipe 原生库加载 / 静态初始化失败抛出的是 Error 子类
- (UnsatisfiedLinkError、ExceptionInInitializerError),不是 Exception;
- 常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)
- 或模型资源缺失。处理:改用与设备 ABI 匹配的包(真机用 release、
- 模拟器调试用 debug);确认 APK 内含 assets/pose_landmarker_lite.task。
- 仓库已在 PoseDetector.initialize() 中 catch Throwable 兜底并把原因显示在
- 运行页;CrashLogger 会把未捕获堆栈写入应用私有目录,并在连接页展示,
\ No newline at end of file
diff --git a/_probe/chunk-4.txt b/_probe/chunk-4.txt
deleted file mode 100644
index e1c98ef..0000000
--- a/_probe/chunk-4.txt
+++ /dev/null
@@ -1,41 +0,0 @@
- 可截图反馈,或用 adb logcat -s PoseDetector 查看。
-
---
-三、快速开始
---
-
-1. 在 Windows 电脑上启动 OpenDance Studio 客户端
- (Windows 客户端会自动监听 8765 端口)
-
-2. 确保手机与电脑连在同一个 WiFi
-
-3. 打开本应用,进入「连接设置」页
-
-4. 填写电脑的 IP 地址,或直接点「扫描局域网」自动发现
- (扫描会探测局域网内开放 8765 端口的主机,约 2~3 秒)
-
-5. 可点「测试连接」确认端口可达
-
-6. 按需调整推流参数(见下)
-
-7. 点「开始推流」,授予相机权限
-
-8. 站到画面中央,屏幕上会出现骨架叠加:
- - 黄色圆点 = 识别良好的关节
- - 红色圆点/红色连线 = 置信度低(光线不足或被遮挡)
-
-9. 在 Windows 客户端上确认已连接,即可开始跟练
-
---
-四、推流参数
---
-
-分辨率
- 低(480x360) 最省电,适合老机型
- 中(640x480) 推荐,识别精度与功耗平衡
- 高(1280x720) 最清晰,但推理更慢、更耗电
-
-目标帧率
- 默认 30 FPS。低端机若「识别帧率」明显低于目标值,可降到 20 或 15。
-
-镜像画面
\ No newline at end of file
diff --git a/_probe/chunk-5.txt b/_probe/chunk-5.txt
deleted file mode 100644
index 86ed013..0000000
--- a/_probe/chunk-5.txt
+++ /dev/null
@@ -1,40 +0,0 @@
- 默认开启。前置摄像头的常见用法。
- 注意:本应用发送的是未镜像的原始坐标,是否镜像由帧头 flags.bit0 声明,
- Windows 客户端据此正确还原左右。因此无论开关如何,动作方向都不会错。
-
-仅上半身模式
- 下半身关键点置信度置 0(显示为红点),Windows 端会插值。
- 带宽或算力不足时开启可明显降负载。
-
---
-五、运行页状态说明
---
-
-连接状态 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开
-目标 正在连接的 WebSocket 地址
-会话 服务端返回的会话 ID
-已发送 累计发送帧数
-已丢弃 因推理或连接跟不上而丢弃的帧数(>0 说明设备吃力)
-发送帧率 实际推流帧率
-识别帧率 实际推理帧率
-推理耗时 单帧 MediaPipe 推理毫秒数
-人体识别 已识别 / 未识别到人体
-平均置信度 < 70% 时转黄,提示改善光线或减少遮挡
-最近错误 连接或推理的错误原因
-
-网络中断时会自动重连(1s -> 2s -> 4s... 最长 15s 退避),
-无需手动操作。
-
---
-六、常见问题
---
-
-Q: 扫描不到电脑
-A: 1) 确认手机与电脑在同一 WiFi(不是手机热点)
- 2) 确认 Windows 客户端已启动并正在监听 8765
- 3) 在 Windows 防火墙放行 OpenDance.exe 的 TCP 8765 入站
- 4) 部分路由器开启了「AP 隔离」,会阻断设备互访,需在路由器关闭
-
-Q: 显示「已连接」但电脑端没有画面
-A: 查看运行页的「已发送」是否在增长。若为 0,说明相机或推理没出帧;
- 若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。
\ No newline at end of file
diff --git a/_probe/chunk-6.txt b/_probe/chunk-6.txt
deleted file mode 100644
index ef3cd2a..0000000
--- a/_probe/chunk-6.txt
+++ /dev/null
@@ -1,40 +0,0 @@
-
-Q: 帧率很低 / 已丢弃帧数持续增长
-A: 依次尝试:降低分辨率 -> 降低目标帧率 -> 开启「仅上半身模式」-> 关闭其他后台应用。
-
-Q: 关键点抖动明显
-A: 正常现象,Windows 端有 DTW 带状对齐与平滑处理,不要求逐帧完全一致。
- 改善光照可显著提高稳定性。
-
-Q: 应用退到后台会怎样?
-A: 返回键或离开运行页会立即停止推流并释放相机,不会在后台占用摄像头。
-
---
-七、从源码构建
---
-
-环境要求:
- JDK 17 AGP 8.7.3 要求(JDK 25 未经测试)
- Android SDK platforms;android-34 与 build-tools;34.0.0
- Gradle 8.9 已随 gradlew 提供
-
-构建:
-
- export JAVA_HOME=/path/to/jdk17
- export ANDROID_HOME=/path/to/android-sdk
-
- # 生成发布签名密钥库(首次;文件不会入库,请自行备份)
- bash build/gen-keystore.sh
-
- # 构建并校验产物
- bash build/build-apk.sh all
-
- # 一键验证(单元测试 -> 构建 -> 产物校验)
- bash build/verify.sh
-
-产物:
- app/build/outputs/apk/debug/app-debug.apk
- app/build/outputs/apk/release/app-release.apk
-
-Android Studio 路径:
- 打开本目录 -> Build -> Generate Signed Bundle / APK -> APK -> 选择 keystore -> release
diff --git a/_probe/chunk-7.txt b/_probe/chunk-7.txt
deleted file mode 100644
index ad4a00b..0000000
--- a/_probe/chunk-7.txt
+++ /dev/null
@@ -1,40 +0,0 @@
---
-八、测试
---
-
- ./gradlew testDebugUnitTest
-
-30 项单元测试(JVM,无需设备),核心是金标准字节向量测试:
-用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量,
-断言本端 Kotlin 实现逐位相同 -- 这是三端契约一致性的硬证据。
-
-测试报告:app/build/reports/tests/testDebugUnitTest/index.html
-
---
-九、目录结构
---
-
- settings.gradle.kts / build.gradle.kts 工程与插件配置
- gradle/wrapper/ Gradle 8.9 wrapper
- project.yaml 任务清单与状态
- DSH.md 本项目 DSH 执行约束
- tasks/ 单任务说明书(T-AND-001 ~ 009)
- contracts/ 只读契约快照(来源 opendance-web v0.1.0)
- app/src/main/java/com/opendance/studio/
- MainActivity.kt 三页导航
- model/ 239 字节帧编解码 + 33 点拓扑
- camera/ CameraX 采集
- pose/ MediaPipe 端侧推理
- net/ WebSocket 推流 + 主机发现
- data/ 设置
- ui/ 连接页 / 运行页 / 关于页
- app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
- build/ 打包与验证脚本
-
---
-十、许可
---
-
-本应用代码以 MIT 许可发布。第三方开源项目声明见 THIRD_PARTY.md
-(应用内「关于」页同样可查看)。
-未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。
diff --git a/contracts/data-models.md b/contracts/data-models.md
index f377aea..be11338 100644
--- a/contracts/data-models.md
+++ b/contracts/data-models.md
@@ -5,7 +5,7 @@ Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视
## 1. 通用约定
-- 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。
+- 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。
- 所有时间为 ISO 8601 UTC 字符串,例:`2026-09-14T10:00:00.000Z`。
- 列表接口统一分页响应:
@@ -117,7 +117,7 @@ rejected 已驳回
```
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的
- DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型,
+ DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型,
换库/重建库后仍然有效;没有压缩包时退回按产物 sha256,最后按名称兜底。
⚠ 不能用产物 sha256 当主键:实测 Assimp 的 GLB 导出不是字节级确定的,
同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 有十几字节浮动)
@@ -145,7 +145,7 @@ rejected 已驳回
| `comment` | string\|null | 审核意见 |
| `createdAt` | string | |
-## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`)
+## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`)
```json
{
diff --git a/contracts/database.md b/contracts/database.md
index 161f404..53762cd 100644
--- a/contracts/database.md
+++ b/contracts/database.md
@@ -3,11 +3,11 @@
## 环境
- 类型:MySQL 兼容实例(外部提供,含 MariaDB 10.5+),TypeORM
- 使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+
+ 使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+
共同子集内,不使用 MySQL 8 专有语法(窗口函数 CTE 递归除外)。
-- 数据库名:由部署方提供,通过 `DB_NAME` 注入(本文档示例统一用 `opendance`)
+- 数据库名:由部署方提供,通过 `DB_NAME` 注入(本文档示例统一用 `opendance`)
- 字符集:`utf8mb4` / `utf8mb4_general_ci`(沿用现有实例默认排序规则)
-- 迁移工具:TypeORM migration,**不使用 `DB_SYNCHRONIZE`**
+- 迁移工具:TypeORM migration, **不使用 `DB_SYNCHRONIZE`**
## 连接信息(由部署方提供)
@@ -25,10 +25,10 @@ DB_LOGGING=false
## 约定
-- 所有表名使用 `snake_case`,复数形式(`songs` / `motions` / `models`)
+- 所有表名使用 `snake_case`,复数形式(`songs` / `motions` / `models`)
- 主键统一为 `BIGINT AUTO_INCREMENT`,字段名 `id`
-- 时间字段统一 `DATETIME`,`created_at` / `updated_at`,`updated_at` 由应用层维护
-- 软删除统一使用 `deleted_at DATETIME NULL`(`@DeleteDateColumn`)
+- 时间字段统一 `DATETIME`, `created_at` / `updated_at`, `updated_at` 由应用层维护
+- 软删除统一使用 `deleted_at DATETIME NULL`(`@DeleteDateColumn`)
- 布尔字段使用 `TINYINT(1)`
- 来源(出处)字段为 5 元组,任何资产入库必须齐备:
`source_type` / `source_name` / `source_url` / `license` / `author`
@@ -36,24 +36,24 @@ DB_LOGGING=false
- 每个 migration 必须可逆(提供 `down` 方法)
- 迁移文件名格式:`-.ts`
-## 增量迁移(v0.2.0 / v0.3.0)
+## 增量迁移(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)
+## 表清单(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 |
-## 状态机(`status` 列,v0.1.0)
+## 状态机(`status` 列,v0.1.0)
```
draft ──submit──▶ pending ──approve──▶ published
diff --git a/contracts/model-formats.md b/contracts/model-formats.md
index 4155755..b6a50c2 100644
--- a/contracts/model-formats.md
+++ b/contracts/model-formats.md
@@ -1,21 +1,21 @@
# contracts/model-formats.md - 模型与动作格式契约 v0.4.0
-## 1. 角色模型(models)
+## 1. 角色模型(models)
**系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。
| 格式 | 扩展名 | 支持级别 | 服务端行为 |
|--|--|--|--|
-| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 |
-| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
-| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) |
+| 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 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 |
-| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) |
+| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) |
### 1.1 上传与转换策略
-**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**:
+**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**:
**优先 GLB,其次 VRM**。
#### 单文件上传
@@ -28,7 +28,7 @@
#### 压缩包上传(自动搜索)
-不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**:
+不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**:
```
.vrm ->.glb ->.gltf (直接导入,优先)
@@ -38,7 +38,7 @@
同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。
压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。
-#### 转换与校验(PMX / FBX)
+#### 转换与校验(PMX / FBX)
| 步骤 | 规则 |
|--|--|
@@ -46,7 +46,7 @@
| 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 张贴图`) |
+| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,不产生模型记录 |
#### 入库后(自动)
@@ -63,14 +63,14 @@
| 项 | 规则 |
|--|--|
| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 |
-| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE |
+| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE |
**VRM 优先**的选取顺序见 `data-models.md` 4.1。
-VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
-`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
+VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
+`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
-## 2. 动作数据(motions)
+## 2. 动作数据(motions)
| 格式 | 扩展名 | 预处理支持 | 说明 |
|--|--|--|--|
@@ -82,7 +82,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
### 2.1 预处理产物(冻结)
-- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM
+- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM
- 一行结构:
```json
@@ -93,9 +93,9 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
- `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 轴向上、右手系、骨盆为原点**,
+- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**,
再投影到 MediaPipe 归一化图像空间:
- `x_img = 0.5 + x_world * scale`,`y_img = 0.5 - y_world * scale`,`z_img = -z_world * scale`,
+ `x_img = 0.5 + x_world * scale`, `y_img = 0.5 - y_world * scale`, `z_img = -z_world * scale`,
`scale = 0.35 / max(|x|,|y|,|z|)`(按整段动作的最大范围自适应,保证不出画)
- 缺失关节(BVH 中不存在对应骨骼):`confidence = 0.0`,坐标沿用最近可用父节点
@@ -122,18 +122,18 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
未映射到的点按 `skeleton-topology.md` 5 的层级由父节点推算,
并置 `confidence` 降低(`0.5`),表示估算而非实测。
-### 2.3 VMD(MikuMikuDance)解析约定
+### 2.3 VMD(MikuMikuDance)解析约定
-VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节,
-Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**:15 字节骨骼名、uint32 帧号、
+VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节,
+Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**: 15 字节骨骼名、uint32 帧号、
float32x3 位移、float32x4 旋转四元数、64 字节贝塞尔插值参数)+ morph / 相机 /
灯光 / 自阴影 / IK / 可见性段落。**VMD 不含骨骼长度**,只有旋转(少数骨骼带位移),
因此服务端必须自带一套标准 MMD 骨架才能做 FK。
处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`
-(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的
+(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的
`worldFramesToKeypointFrames()`(与 BVH 完全共用骨盆归零 / 自适应缩放 / 图像空间投影 /
-9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。
+9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。
| MediaPipe 点 | MMD 标准骨骼 | 说明 |
|--|--|--|
@@ -149,11 +149,11 @@ float32x3 位移、float32x4 旋转四元数、64 字节贝塞尔插值参数)
骨骼名按 `mmd-skeleton.ts` 的别名表归一(含全角 `IK` / 半角 `IK` 两种写法);
未纳入标准骨架的骨骼(裙摆、物理、表情等)被忽略但会计数并写入预处理日志。
-**腿部驱动判定**:`足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK
+**腿部驱动判定**: `足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK
目标点,膝盖朝角色正面弯,不可达时收敛为伸直);否则走 FK(部分动作数据把腿烘焙成
FK 旋转)。两份示例文件分别命中两种路径。
-**坐标系**:MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色
+**坐标系**: MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色
左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反)后
送入标准骨架。该选择由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下
膝盖朝前占比 94.1%,不转换时仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
@@ -167,15 +167,15 @@ FK 旋转)。两份示例文件分别命中两种路径。
| 项 | 值 |
|--|--|
-| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
+| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
| 允许扩展名 | 上表全部;其他一律 422 |
| 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` |
| 去重 | 相同 sha256 复用同一 `assets` 记录,不重复落盘 |
| 文件名 | 服务端不使用原始文件名做路径,只存 `assets.original_name` |
-## 4. 开源依赖与许可证(引用 `open-source-credits.md`)
+## 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 预览
diff --git a/contracts/skeleton-topology.md b/contracts/skeleton-topology.md
index 2fd6624..0130e2d 100644
--- a/contracts/skeleton-topology.md
+++ b/contracts/skeleton-topology.md
@@ -47,7 +47,7 @@
| 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` |
| 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` |
-## 3. 父节点表(`parent[i]`,-1 表示世界根)
+## 3. 父节点表(`parent[i]`, -1 表示世界根)
对称树,便于骨骼重定向与关节角计算:
@@ -91,16 +91,16 @@
## 4. 坐标系
-- **归一化图像坐标**:`x, y ∈ [0, 1]`,原点左上角,`y` 向下;`x` 相对图像宽,`y` 相对图像高。
-- **深度**:`z` 与 `x` 同尺度,原点位于髋中心,**负值朝向相机**。
-- **镜像**:Android 前置摄像头默认输出镜像画面;是否镜像由帧头 `flags.bit0` 声明,
- 接收端据此决定是否翻转 `x`(`x' = 1 - x`)。
-- **置信度**:`visibility ∈ [0, 1]`,落盘/传输为 `uint8`(`round(v * 255)`)。
+- **归一化图像坐标**: `x, y ∈ [0, 1]`,原点左上角,`y` 向下;`x` 相对图像宽,`y` 相对图像高。
+- **深度**: `z` 与 `x` 同尺度,原点位于髋中心,**负值朝向相机**。
+- **镜像**: Android 前置摄像头默认输出镜像画面;是否镜像由帧头 `flags.bit0` 声明,
+ 接收端据此决定是否翻转 `x`(`x' = 1 - x`)。
+- **置信度**: `visibility ∈ [0, 1]`,落盘/传输为 `uint8`(`round(v * 255)`)。
归一化比例统一使用 `32767`(见 `websocket-keypoints.md`)。
## 5. 派生特征(DTW 输入,Windows 端实现,服务端预处理产物同构)
-服务端 T-WEB-006 预处理产出的关键点序列与 Android 端输出的帧**结构完全一致**,
+服务端 T-WEB-006 预处理产出的关键点序列与 Android 端输出的帧**结构完全一致**,
因此 Windows 端 DTW 引擎只有一条输入通路。
关节角特征(弧度,余弦值便于插值):
diff --git a/contracts/websocket-keypoints.md b/contracts/websocket-keypoints.md
index ce1ec53..f6ed088 100644
--- a/contracts/websocket-keypoints.md
+++ b/contracts/websocket-keypoints.md
@@ -10,17 +10,17 @@ ws://:8765/pose
```
- Windows 客户端监听,Android App 主动连接。
-- 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`)
+- 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`)
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
## 2. 二进制帧:239 字节(冻结)
-**小端序(little-endian)**。
+**小端序(little-endian)**。
```
偏移 长度 字段 类型 说明
-0 2 magic u16 LE 固定 0x4F44(ASCII "OD")
+0 2 magic u16 LE 固定 0x4F44(ASCII "OD")
2 1 version u8 固定 0x01
3 1 flags u8 bit0 = 镜像画面(mirrored)
bit1 = 本帧含低置信度点(<0.5)
@@ -32,7 +32,7 @@ ws://:8765/pose
合计 239
```
-### 单个关键点(7 字节,`i` 为 0..32)
+### 单个关键点(7 字节,`i` 为 0..32)
```
偏移 长度 字段 类型 换算
@@ -43,7 +43,7 @@ ws://:8765/pose
```
- 点索引与坐标语义见 `skeleton-topology.md`。
-- 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`,`y`、`z` 同理。
+- 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`, `y`、`z` 同理。
- 低置信度点(`conf < 128`)坐标无意义,接收端必须插值,不得直接使用。
- `i16` 为有符号数:`0x8000` 表示 `-32768`。
@@ -68,7 +68,7 @@ func decode(buf: PackedByteArray) -> Dictionary:
return {"flags": flags, "frame_id": frame_id, "points": pts}
```
-## 3. 文本控制帧(JSON,UTF-8)
+## 3. 文本控制帧(JSON, UTF-8)
### 3.1 `hello` - Android -> Windows,连接后立即发送
diff --git a/deploy/proxy/README.md b/deploy/proxy/README.md
index b5c31f9..5b36ae5 100644
--- a/deploy/proxy/README.md
+++ b/deploy/proxy/README.md
@@ -1,6 +1,6 @@
-# 反向代理说明(nginx / IIS)
+# 反向代理说明(nginx / IIS)
-> **职责边界**:本 Web 端**只提供业务服务**(NestJS API + 管理端页面),
+> **职责边界**:本 Web 端**只提供业务服务**(NestJS API + 管理端页面),
> **不部署、不配置反向代理**。反向代理由另外的服务或团队负责,本文档只提供配置说明。
>
> 安装脚本 `deploy/install.sh` 不会安装或修改任何反代软件。
@@ -11,16 +11,16 @@
| 端口 | 环境变量 | 默认 | 给谁用 | 提供什么 |
|--|--|--|--|--|
-| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`;**不含管理页面**(未知路径 404) |
+| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`; **不含管理页面**(未知路径 404) |
| 管理端端口 | `ADMIN_PORT` | `3001` | 浏览器(管理员) | 管理页面 + 上面全部接口 |
```
-软件 API: http://<后端地址>:3000
+软件 API: http://<后端地址>:3000
管理端: http://<后端地址>:3001
```
两者都不需要在代理层托管静态文件,也不需要配置 SPA 回退(服务端已处理);
-各用一条 `location /`(nginx)或全匹配规则(IIS)即可。
+各用一条 `location /`(nginx)或全匹配规则(IIS)即可。
**推荐做法**:把两个端口挂在两个不同的站点/域名上,便于分别做访问控制,例如
@@ -33,12 +33,12 @@
| 路径 | 内容 | 哪个端口提供 |
|--|--|--|
| `/`、`/assets/*` | 管理端页面与静态资源 | **仅管理端**;前端路由深链接(如 `/songs`)由服务端回退到 `index.html` |
-| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) |
+| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) |
| `/health` | 健康检查 | 两个端口都提供;可用作反代的存活探测,数据库不可用时返回 **503** |
| `/static/*` | 上传文件 | 两个端口都提供(模型 / 音频 / 关键点序列) |
| `/contracts/*` | 契约快照 | 两个端口都提供,只读 |
-> Windows 客户端与 Android 之间的**关键点 WebSocket 链路不经过本服务**,
+> Windows 客户端与 Android 之间的**关键点 WebSocket 链路不经过本服务**,
> 反代无需配置 WebSocket 转发。
>
> `ADMIN_PORT` 设为与 `PORT` 相同时是**单端口兼容模式**:两个端口合一,
@@ -58,7 +58,7 @@
## 三、必须同步调整的两项
-1. **上传上限**:本服务默认允许 512 MiB(`UPLOAD_MAX_BYTES`)。
+1. **上传上限**:本服务默认允许 512 MiB(`UPLOAD_MAX_BYTES`)。
代理层默认值通常远小于此(nginx 1 MB、IIS 约 28.6 MB),不改会导致大模型上传失败。
2. **超时**:上传大文件与下载关键点序列可能较慢,代理的读写超时必须放大。
@@ -138,7 +138,7 @@ IIS 管理器 -> 选中服务器节点 -> Application Request Routing Cache
-
+
@@ -181,10 +181,10 @@ curl -i http://127.0.0.1:3001/health
# 2. 经反代可访问
curl -i http://<反代地址>/health
-# 3. 管理端页面可打开(应返回 HTML)
+# 3. 管理端页面可打开(应返回 HTML)
curl -i http://<反代地址>/ | head -5
-# 4. 前端深链接可用(应返回同一份 HTML,而不是 404)
+# 4. 前端深链接可用(应返回同一份 HTML,而不是 404)
curl -i http://<反代地址>/songs | head -5
# 5. API 可用
@@ -196,7 +196,7 @@ curl -i http://<反代地址>/api/catalog/version
| 现象 | 原因 |
|--|--|
-| 反代 502 / 504 | 后端未启动,或 `HOST` 绑定地址不对(同机应为 `127.0.0.1`,异机应为 `0.0.0.0`) |
+| 反代 502 / 504 | 后端未启动,或 `HOST` 绑定地址不对(同机应为 `127.0.0.1`,异机应为 `0.0.0.0`) |
| 页面能开、接口 404 | 只转发了 `/`,需要把 `/api/`、`/health`、`/static/` 一并转发(最简单是全匹配) |
| 上传大文件 413 | 代理层上传上限没放开(见第三、四、五节) |
| 上传中途断开 | 代理超时太短 |
@@ -210,7 +210,7 @@ curl -i http://<反代地址>/api/catalog/version
在运维侧配好反代之前,可以直接访问后端业务服务(本项目开箱即用,不依赖反代):
1. `deploy/.env` 中 `HOST=0.0.0.0`(若只想让某个网段访问,也可填具体网卡地址)
-2. 防火墙放行后端端口(默认 `3000/tcp`)
+2. 防火墙放行后端端口(默认 `3000/tcp`)
3. 浏览器访问 `http://<服务器IP>:3000`
> 注意:服务默认在 **3000** 端口,不在 80/443。直接访问 `http://<服务器IP>/`
diff --git a/tasks/001-scaffold.md b/tasks/001-scaffold.md
index 3634be4..4ec0a0f 100644
--- a/tasks/001-scaffold.md
+++ b/tasks/001-scaffold.md
@@ -1,6 +1,6 @@
# T-WEB-001 骨架 + 连接现有 MySQL
-- **状态**:done
+- **状态**: done
- **依赖**:无
## 目标
@@ -29,7 +29,7 @@ web-server/src/db/data-source.ts
| `web-server/src/app.module.ts` | 根模块,`TypeOrmModule.forRootAsync` 由 `AppConfig` 注入连接信息 |
| `web-server/src/config/configuration.ts` | 类型化配置层,唯一读取 `process.env` 的位置 |
| `web-server/src/config/config.module.ts` | 全局配置模块 |
-| `web-server/src/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用),`synchronize: false` |
+| `web-server/src/db/data-source.ts` | TypeORM DataSource(CLI + 运行期共用), `synchronize: false` |
| `web-server/src/health/health.controller.ts` | `GET /health`,真实探测数据库连通性 |
| `web-server/.env.example` | 环境变量样例 |
@@ -61,4 +61,4 @@ curl -s http://localhost:3000/health
## 结果
-- 已连通 `<数据库主机>:3306/opendance`,`/health` 返回 200 且 `connected=true`。
+- 已连通 `<数据库主机>:3306/opendance`, `/health` 返回 200 且 `connected=true`。
diff --git a/tasks/002-db-migration.md b/tasks/002-db-migration.md
index 277cafc..294c371 100644
--- a/tasks/002-db-migration.md
+++ b/tasks/002-db-migration.md
@@ -1,7 +1,7 @@
# T-WEB-002 TypeORM 迁移生成表结构
-- **状态**:done
-- **依赖**:T-WEB-001
+- **状态**: done
+- **依赖**: T-WEB-001
## 目标
@@ -25,7 +25,7 @@ web-server/src/db/data-source.ts
|---|---|---|
| `assets` | 上传文件元数据 | `sha256` 唯一索引(去重)、`stored_path`、`size` |
| `songs` | 曲目 | 来源五元组、`status`、`deleted_at` |
-| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 |
+| `motions` | 动作数据 | `song_id` FK(ON DELETE SET NULL)、预处理产物列 |
| `models` | 角色模型 | `format`、`vrm_version`、`is_primary` |
| `review_records` | 审核流水 | `(target_type, target_id)` 复合索引 |
@@ -33,7 +33,7 @@ web-server/src/db/data-source.ts
- 表名 `snake_case` 复数;主键 `BIGINT AUTO_INCREMENT`
- 时间列 `DATETIME`;软删除 `deleted_at`
-- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异)
+- Tags 使用 `simple-json`(MariaDB 下落 LONGTEXT,规避 JSON 类型差异)
## 交付物
diff --git a/tasks/003-song-crud.md b/tasks/003-song-crud.md
index e0959f9..22cbf5f 100644
--- a/tasks/003-song-crud.md
+++ b/tasks/003-song-crud.md
@@ -1,7 +1,7 @@
# T-WEB-003 /api/songs CRUD
-- **状态**:done
-- **依赖**:T-WEB-002
+- **状态**: done
+- **依赖**: T-WEB-002
## 目标
@@ -27,7 +27,7 @@ web-server/src/common/pagination.ts
| GET | `/api/songs/:id` | 详情 |
| PATCH | `/api/songs/:id` | 更新(来源需五元组同时提交) |
| DELETE | `/api/songs/:id` | **软删除** |
-| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) |
+| POST | `/api/songs/:id/audio` | 上传音频(multipart,字段名 `file`) |
| POST | `/api/songs/:id/cover` | 上传封面 |
## 关键实现
diff --git a/tasks/004-motion-upload.md b/tasks/004-motion-upload.md
index d64b3c4..b82ea57 100644
--- a/tasks/004-motion-upload.md
+++ b/tasks/004-motion-upload.md
@@ -1,7 +1,7 @@
# T-WEB-004 动作数据上传 + 来源字段
-- **状态**:done
-- **依赖**:T-WEB-002
+- **状态**: done
+- **依赖**: T-WEB-002
## 目标
@@ -29,17 +29,17 @@ 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`) |
+| 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}
```
-- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存
-- `StorageService` 流式计算 sha256;**相同内容只落盘一次**,复用 `assets` 记录
+- 上传先落 `${STORAGE_DIR}/tmp`(multer diskStorage),避免大文件占用内存
+- `StorageService` 流式计算 sha256; **相同内容只落盘一次**,复用 `assets` 记录
- 并发同文件上传靠 `assets.sha256` 唯一索引兜底,冲突后回退到已存在记录
- 路径永不使用原始文件名;`absolutePath()` 拒绝逃逸存储根目录
@@ -48,14 +48,14 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
- 扩展名白名单:`.bvh.vmd.fbx.gltf.glb.json`,其他 -> 400
- 来源五元组缺任一项 -> 400
- `songId` 指向不存在的曲目 -> 400
-- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
+- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
-## VMD 支持(`.vmd`)
+## VMD 支持(`.vmd`)
-- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
-- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
+- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
+- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`(标准骨架)
- -> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的
+ -> `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)-> `retarget.ts` 的
`worldFramesToKeypointFrames()`(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角)
- 固定 30 fps;腿部按 `足IK` 是否带位移自动选 IK 或 FK
- 上传后处理失败与 BVH 一致:写入 `preprocessState=failed` 与可读错误
@@ -73,8 +73,8 @@ VMD 实测(用 `../示例文件/` 下的两个样本):
| 样本 | 帧数 | 腿部驱动 | 结果 |
|--|--|--|--|
-| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
-| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
+| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
+| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
上传前可先用诊断工具自查(只读):
diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md
index e371181..5f8c274 100644
--- a/tasks/005-model-upload.md
+++ b/tasks/005-model-upload.md
@@ -1,12 +1,12 @@
# T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审)
-- **状态**:done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
-- **依赖**:T-WEB-002
+- **状态**: done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
+- **依赖**: T-WEB-002
## 目标
系统以 **GLB / VRM 为主力格式**(服务端与客户端都一等公民):`.glb`/`.vrm`/`.gltf`
-**直接导入、不重编码**,`.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip`
+**直接导入、不重编码**, `.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip`
**自动搜索包内可用模型**。入库后自动**分析**(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性)
与生成**缩略图**;状态**一律 `pending`**,经审核后才可能成为默认模型。
@@ -38,11 +38,11 @@ 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`) |
+| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`; **入库一律 pending** |
+| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) |
| PATCH | `/api/models/:id` | 更新 |
| DELETE | `/api/models/:id` | 软删除 |
-| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) |
+| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) |
## 格式策略(服务端与客户端一致)
@@ -54,7 +54,7 @@ web-server/scripts/import-default-model.mjs
| `.zip` | **自动搜索包内可用模型**(见下) |
客户端(Godot)本来就用内置 `GLTFDocument` 加载 glTF/GLB/VRM
-(`opendance-windows/src/model/vrm_loader.gd`),**无需改动**。服务端 humanoid 候选表
+(`opendance-windows/src/model/vrm_loader.gd`), **无需改动**。服务端 humanoid 候选表
与客户端 `humanoid_map.gd` **逐字对齐**,由 `test/model-inspector.spec.ts` 断言两边一致。
## 压缩包自动搜索与转换
@@ -74,14 +74,14 @@ web-server/scripts/import-default-model.mjs
| 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`,大小写不敏感)-> 目录最浅 -> 文件最大" |
| 直接导入 | `importModelFile()` | `.vrm`/`.glb`/`.gltf` 原样入库;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
-| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
+| 转换 | `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**,缺失骨骼写进说明 |
-| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
+| 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 乱码(中文文件名原先存成 `模å_...`) |
+| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_...`) |
| DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` 4、`rest-api.yaml` |
## 入库后自动:分析 + 缩略图(尽力而为,失败不影响入库)
@@ -93,11 +93,11 @@ web-server/scripts/import-default-model.mjs
## 状态与默认模型(硬性)
-- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`),
- `is_primary=false`:**不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。
-- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`:
- 1. 审核通过带 `setPrimary:true`:`POST /api/reviews/model/:id/approve`;
- 2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。
+- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`),
+ `is_primary=false`: **不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。
+- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`:
+ 1. 审核通过带 `setPrimary:true`: `POST /api/reviews/model/:id/approve`;
+ 2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。
- `GET /api/catalog/export` 的 `defaultModelId` 解析顺序(`resolveDefaultModelId()`):
```
@@ -116,27 +116,27 @@ web-server/scripts/import-default-model.mjs
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
-- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`)
+- `extensions.VRMC_vrm` -> VRM **1.0**(取 `specVersion`)
- `extensions.VRM` -> VRM **0.x**
-- 两者皆无 -> `null`(普通 GLB)
+- 两者皆无 -> `null`(普通 GLB)
-## 实测(v0.2.0)
+## 实测(v0.2.0)
- 上传含 `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`;
+ 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 与源文件**一致**(不重编码)。
- 缩略图:512x512 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)。
-### 运维 CLI:`web-server/scripts/import-default-model.mjs`
+### 运维 CLI: `web-server/scripts/import-default-model.mjs`
服务进程在 systemd 加固(`ProtectSystem=strict`)下写不了程序目录,源码种子
通常由本脚本在沙箱外补写。需先 `npm run build`(复用 `dist/models/default-model-source.js`)。
@@ -146,15 +146,15 @@ 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
```
上传一律 pending,因此要让 CLI 写出的种子在 `defaultModelId` 中生效,仍需审核通过
-(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`;
-`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。
+(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`;
+`--url` 默认 `http://127.0.0.1:3000`, `--source-type` 默认 `other`。
## 验收
@@ -170,7 +170,7 @@ npm run test:smoke # 冒烟 [4] 段落
| `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字->目录最浅->文件最大 |
| `.pmx` 转换优先 GLB | ✅ 实测 201 / 0.86s、`format=glb`、2 张贴图内嵌 |
| GLB 失败回退 glTF->GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 |
-| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`;humanoid 不全回退 GLB |
+| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`; humanoid 不全回退 GLB |
| 自动分析写入 `models.stats` | ✅ 实测 `triangles=9071 bones=238 humanoidMapped=0/22 drivable=false` |
| 缩略图生成且不影响入库 | ✅ 512x512 / 15,043 B;失败只告警 |
| 上传一律 pending、不自动设默认 | ✅ `create()` 固定 `PublishStatus.Pending` + `isPrimary=false` |
@@ -179,15 +179,15 @@ npm run test:smoke # 冒烟 [4] 段落
| 失败语义 | ✅ 无可用模型 / 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 例) |
+| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) |
-`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、
+`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、
主模型挑选优先级、GLB 贴图内嵌、用**假 Assimp** 对照多种回退路径、源码渲染 + `tsc` 校验。
-`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
+`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。
-`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
+`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
-## 追加:历史模型回填(`POST /api/models/:id/reanalyze`)
+## 追加:历史模型回填(`POST /api/models/:id/reanalyze`)
升级到带 `models.stats` 的版本后,已有模型还没有分析与缩略图。重新分析接口只改
`stats` 与 `thumbnailAssetId` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。
@@ -198,8 +198,8 @@ node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 回填
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单
```
-实测(真实 21MB VRM,290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 /
+实测(真实 21MB VRM, 290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 /
humanoid 7/22,并重建 512x512 缩略图。
文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。
-另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
+另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。
diff --git a/tasks/006-keypoint-preprocess.md b/tasks/006-keypoint-preprocess.md
index 36e4e51..82c07b3 100644
--- a/tasks/006-keypoint-preprocess.md
+++ b/tasks/006-keypoint-preprocess.md
@@ -1,7 +1,7 @@
# T-WEB-006 BVH/VMD/FBX -> 关键点序列预处理
-- **状态**:done
-- **依赖**:T-WEB-004
+- **状态**: done
+- **依赖**: T-WEB-004
## 目标
@@ -28,7 +28,7 @@ web-server/src/preprocess/*
| 文件 | 职责 |
|--|--|
-| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
+| `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 镜像,输出逐帧世界坐标 |
@@ -36,14 +36,14 @@ web-server/src/preprocess/*
| `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` | 编排、落盘、状态与错误记录 |
## 拓扑与缩放(契约冻结)
- 33 点顺序与父节点表来自 `contracts/skeleton-topology.md`,虚拟关节
`neck=33`、`pelvis=34` 只参与关节角计算,**不进入输出序列**。
-- 投影:`x_img = 0.5 + x s`,`y_img = 0.5 - y s`,`z_img = -z s`,
+- 投影:`x_img = 0.5 + x s`, `y_img = 0.5 - y s`, `z_img = -z s`,
`s = 0.35 / max(|x|,|y|,|z|)`(按整段动作自适应,保证不出画)。
- 置信度分层:直接映射 **1.0**、按层级推算 **0.5**、无法推算 **0.0**。
@@ -53,7 +53,7 @@ web-server/src/preprocess/*
|--|--|
| `.bvh` | 内置解析器,无外部依赖 |
| `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 |
-| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
+| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
| `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` |
## VMD 通路
@@ -67,13 +67,13 @@ 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()` 负责骨盆归零 /
+- 重定向**完全复用 BVH 通路**: `worldFramesToKeypointFrames()` 负责骨盆归零 /
自适应缩放 / 图像空间投影 / 9 个关节角,因此两种来源的产物结构一致。
- 固定 **30 fps**,上传时填写的 `fps` 被忽略。
@@ -91,7 +91,7 @@ NDJSON 往返、解码器拒绝错误长度与 magic。
VMD 侧另测:二进制解析与截断报错、贝塞尔/四元数插值、标准骨架比例与镜像、
FK 与腿部 IK(可达/不可达/退化)、手性对照、合成 VMD 端到端 0 NaN / 0 越界。
-两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
+两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
约 0.4s、`恋爱循环.vmd` 4604 帧(烘焙 FK)约 2.5s,均 0 NaN / 0 越界。
样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲,
diff --git a/tasks/007-upload-review.md b/tasks/007-upload-review.md
index d3071c0..2b77d62 100644
--- a/tasks/007-upload-review.md
+++ b/tasks/007-upload-review.md
@@ -1,7 +1,7 @@
# T-WEB-007 上传审核流程
-- **状态**:done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`)
-- **依赖**:T-WEB-003 / 004 / 005
+- **状态**: done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`)
+- **依赖**: T-WEB-003 / 004 / 005
## 目标
@@ -34,45 +34,45 @@ draft ──submit──▶ pending ──approve──▶ published
| 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/approve` | -> `published`; **模型**可带 `setPrimary:true` |
+| POST | `/api/reviews/:targetType/:id/reject` | -> `rejected`, **必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | -> `draft` |
## 关键实现
-- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
+- `STATUS_TRANSITIONS` 是状态机的唯一来源;非法流转一律 **409**,
重复审核同一状态同样 409。
- 驳回未填 `comment` -> **400**(合规要求:拒绝侵权内容需留痕)。
- 每次成功流转都写一条 `review_records`,记录 `fromStatus` / `toStatus` /
`reviewer` / `comment`。
- 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。
- `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。
-- **模型上传只能 pending**:`ModelsService.create()` 固定
+- **模型上传只能 pending**: `ModelsService.create()` 固定
`status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault`
等旁路一律不生效(硬性约束,见 DSH.md 6.9)。
-## 模型审核面板(v0.2.0)
+## 模型审核面板(v0.2.0)
待审列表只有 `PendingItem`(不含模型详情),面板用 `item.targetId` 现拉
-`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
+`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
| 区域 | 内容 |
|--|--|
| 基本 | 缩略图、格式、状态、文件地址、转换说明 |
-| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
+| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 -> 可驱动;不全 -> 列出缺哪些(直接标红);非 glTF 容器 -> 只能审核来源与体积 |
-| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
+| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
| warnings | `ModelStats.warnings` 逐条列出 |
| 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 |
| stats 为空 | 提示"暂无分析数据(旧数据或后端尚未完成分析)",只能按格式/体积/来源判断 |
### 通过 / 驳回 / 设为默认
-- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`:
+- 面板底部勾选「同时设为全局默认模型」-> 点「通过」时请求体带 `setPrimary:true`:
`ReviewsService.transition()` 保存模型为 `published` 后调用
`ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码
`src/config/default-model.ts`。
-- 单独设置走 `POST /api/models/:id/primary`:**仅 `published` 允许,否则 409**。
+- 单独设置走 `POST /api/models/:id/primary`: **仅 `published` 允许,否则 409**。
- 驳回必须填意见(400)。
### 骨骼树过滤(`admin/src/lib/bone-tree.ts`,纯函数、可单测)
@@ -101,4 +101,4 @@ npm run test:smoke
| `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) |
+| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) |
diff --git a/tasks/008-admin-ui-songs.md b/tasks/008-admin-ui-songs.md
index af61007..c91ce15 100644
--- a/tasks/008-admin-ui-songs.md
+++ b/tasks/008-admin-ui-songs.md
@@ -1,7 +1,7 @@
# T-WEB-008 歌曲管理界面
-- **状态**:done
-- **依赖**:T-WEB-003
+- **状态**: done
+- **依赖**: T-WEB-003
## 目标
@@ -19,11 +19,11 @@ web-server/admin/package.json
## 交付物
-`web-server/admin/`(React 18 + Vite + TypeScript):
+`web-server/admin/`(React 18 + Vite + TypeScript):
| 路径 | 说明 |
|--|--|
-| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) |
+| `src/pages/SongsPage.tsx` | 歌曲列表 + 分页 + 检索(`q` / `status` / `artist`) |
| `src/components/SongForm*` | 新建/编辑表单,含来源五元组 |
| `src/api/client.ts` | fetch 封装;非 2xx 抛 `ApiError`,兼容 Nest 的 `message: string[]` |
| `src/api/types.ts` | 与契约对齐的 TypeScript 类型 |
@@ -48,6 +48,6 @@ npm run build # tsc -b && vite build 必须成功
## 构建与运行
-- 开发:`npm run dev`(5173,`/api` 与 `/health` 代理到 `localhost:3000`)
-- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`)
+- 开发:`npm run dev`(5173, `/api` 与 `/health` 代理到 `localhost:3000`)
+- 生产:`npm run build` 产物由**后端服务自身**托管(`ADMIN_DIR`,含 SPA 回退),反向代理只需整体转发到后端端口(见 `deploy/proxy/README.md`)
- `VITE_API_BASE` 可覆盖 API 基地址;留空表示同源
diff --git a/tasks/009-admin-ui-models.md b/tasks/009-admin-ui-models.md
index bedb387..a281d77 100644
--- a/tasks/009-admin-ui-models.md
+++ b/tasks/009-admin-ui-models.md
@@ -1,7 +1,7 @@
# T-WEB-009 模型管理界面 + 来源展示
-- **状态**:done
-- **依赖**:T-WEB-005
+- **状态**: done
+- **依赖**: T-WEB-005
## 目标
@@ -30,11 +30,11 @@ web-server/admin/src/components/VrmPreview.tsx
- 表格列:预览缩略图、名称、格式(VRM 高亮徽章)、VRM 版本、是否默认、
**来源五元组**、许可、状态、操作
- **来源展示(本任务验收重点)**:整行展示 `sourceType` + `sourceName` + `author`
- + `license`,`sourceUrl` 可点击外链跳转
-- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
+ + `license`, `sourceUrl` 可点击外链跳转
+- 上传:`POST /api/models`(multipart `file` + 名称 + `isPrimary` + 来源五元组 +
状态),客户端校验与歌曲页一致
- 「设为默认」-> `POST /api/models/:id/primary`,成功后刷新列表以反映互斥结果
-- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
+- 3D 预览:`GLTFLoader` + `VRMLoaderPlugin` 加载 `fileUrl`,
支持鼠标拖拽旋转与滚轮缩放,正确处理 VRM 0.x / 1.0 元数据差异、
未选择模型、加载失败三种状态,卸载时释放资源
@@ -45,5 +45,5 @@ cd web-server/admin
npm run build # 必须成功
```
-另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段,
+另由 `npm run test:smoke`([4] 段落)验证后端确实在列表接口返回完整来源字段,
供本页面展示。
diff --git a/tasks/010-deploy.md b/tasks/010-deploy.md
index 7fd51a9..28ab7d5 100644
--- a/tasks/010-deploy.md
+++ b/tasks/010-deploy.md
@@ -1,17 +1,17 @@
# T-WEB-010 部署(Linux 业务服务,不使用 Docker,不部署反代)
-- **状态**:done(两次需求变更后重做)
-- **依赖**:T-WEB-001 ~ 009
+- **状态**: done(两次需求变更后重做)
+- **依赖**: T-WEB-001 ~ 009
## 目标
Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程由系统服务
-(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。
-**不使用 Docker**,**不部署 MySQL**(数据库为部署方提供的现有实例),
+(systemd / OpenRC / SysVinit / 后台进程)托管,**同时提供 REST API 与管理端页面**。
+**不使用 Docker**, **不部署 MySQL**(数据库为部署方提供的现有实例),
**也不部署反向代理**(由另外的服务/团队负责)。
> **需求变更记录**
-> 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 端仅需要业务需求,反代等工作交由另外的服务处理。
@@ -20,7 +20,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
> 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。
> 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT`
> (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口
-> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404),
+> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`, `/` 等返回 404),
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
> 4. 本次追加两项:
@@ -29,7 +29,7 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
> `install.sh` 在缺 `deploy/.env` 时先调用它,失败才 die(安装目录已有上次
> 安装留下的.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
@@ -52,10 +52,10 @@ deploy/proxy/README.md
```
Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供接口,无管理页面)
├── /api/ REST API
- ├── /health 健康检查(数据库不可用返回 503)
+ ├── /health 健康检查(数据库不可用返回 503)
├── /static/ 上传文件
└── /contracts/ 契约快照
- (/、/songs 等返回 404,有意设计)
+ (/、/songs 等返回 404,有意设计)
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关...)──▶ 管理端端口:3001
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
@@ -81,15 +81,15 @@ Windows/Godot 客户端、Android App ─▶ 软件 API 端口:3000(只提供
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 |
| `deploy/openrc/opendance-api` | OpenRC 服务脚本 |
-| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
+| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
| `deploy/ctl.sh` | 无 init 系统时的进程控制(后台进程 + PID 文件),start/stop/restart/status/logs |
| `deploy/proxy/README.md` | **反代说明文档**:转发目标、绑定地址、自检清单、故障对照 |
-| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) |
+| `deploy/proxy/nginx.conf.example` | nginx 配置样例(上传上限、超时、keepalive、可选 HTTPS) |
| `deploy/proxy/web.config.example` | IIS 配置样例(需 ARR + URL Rewrite 模块) |
-## deploy/.env 初始化引导(init-env.sh)
+## deploy/.env 初始化引导(init-env.sh)
-首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`:
+首次部署不再要求手工 `cp deploy/.env.example deploy/.env && vi deploy/.env`:
```
install.sh 检测到缺 deploy/.env
@@ -97,11 +97,11 @@ install.sh 检测到缺 deploy/.env
└── 否则调用 deploy/init-env.sh
├── 缺.env -> 从.env.example 复制,chmod 600
├── 交互(TTY / 管道)-> 逐项询问并写回
- └── 非交互(CI、 只校验,绝不阻塞;失败则 install.sh die
+ └── 非交互(CI、 只校验,绝不阻塞;失败则 install.sh die
```
逐项询问 `DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME / PORT / ADMIN_PORT / HOST`
-共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**;
+共 8 个键,回车保留默认;`DB_PASS` 用 `read -rs` 不回显、按约定写盘**不加引号**;
校验必填项与端口范围;可用 Node + mysql2 做连通性自检(口令只经环境变量传给子进程,
失败仅告警);只替换/追加这 8 个键、保留模板注释,写完 `chmod 600` 并回读校验;
结尾打印摘要(口令 `***`)与本次变更列表。
@@ -116,21 +116,21 @@ install.sh 检测到缺 deploy/.env
| `--skip-db-check` | 跳过数据库连通性自检 |
| `--dir DIR` | 等价于 `--env-file DIR/.env --example DIR/.env.example` |
| `--env-file PATH` / `--example PATH` | 指定 `.env` / 模板路径 |
-| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) |
+| `--timeout SECONDS` | 非 TTY 下每次读取超时秒数(默认 10) |
| `-h` / `--help` | 显示帮助 |
环境变量:`OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` /
`OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` /
`OPENDANCE_PROMPT_TIMEOUT`。
-**非交互绝不挂住**:stdin 非 TTY 且是 `
+**非交互绝不挂住**: stdin 非 TTY 且是 `
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
-**默认监听 0.0.0.0**:`configuration.ts` 的
-`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`。
+**默认监听 0.0.0.0**: `configuration.ts` 的
+`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`。
注意 `install.sh` 里的 `HEALTH_HOST` / `API_HOST` 用的是**回环地址**做本机健康检查,
与对外监听的默认值无关。
@@ -142,7 +142,7 @@ install.sh 检测到缺 deploy/.env
1. systemd /run/systemd/system 存在且 systemctl 可用
2. OpenRC rc-service + rc-update 可用
3. SysVinit /etc/init.d 存在且 update-rc.d 或 chkconfig 可用
-4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh)
+4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh)
并写 crontab @reboot 实现开机自启
```
@@ -156,45 +156,45 @@ install.sh 检测到缺 deploy/.env
## 关键实现
-- **双端口**:一个 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 回退中间件
+ `ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
+- **服务自身托管管理端**: `app.useStaticAssets(adminDir,...)` + SPA 回退中间件
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
-- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
+- **软件 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 的整数;非法值在生产环境
+- **端口校验**: `PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn
- **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与
防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口)
-- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
+- **systemd 加固**: `NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
-- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
+- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值
-- **模型转换配置**(`deploy/.env.example` 已写入):
+- **模型转换配置**(`deploy/.env.example` 已写入):
- `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到,
否则转换失败 **422**
- `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。
`glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展
- (humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB**
+ (humanoid 骨骼 / 表情语义), **humanoid 骨骼名映射不全时自动回退交付 GLB**
并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README 四
-- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
- `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
+- **迁移用编译产物**: `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` 导出了
- `NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
+ `NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
故用 `env -u NODE_ENV npm ci --include=dev`
-- **复制文件时锚定排除**:`storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
+- **复制文件时锚定排除**: `storage/` 这类模式会误伤 `src/storage/` 与 `dist/storage/`
(曾导致 `MODULE_NOT_FOUND...storage.module`),排除规则一律用 `/storage/` 锚定
- **`/contracts/` 静态暴露**:另两端可直接从站点取契约快照
@@ -202,7 +202,7 @@ install.sh 检测到缺 deploy/.env
```bash
bash deploy/init-env.sh # 或手工 cp deploy/.env.example deploy/.env 后填写 DB_*
-bash deploy/install.sh # 需 root(不需要 sudo)
+bash deploy/install.sh # 需 root(不需要 sudo)
curl -fsS http://localhost:3000/health
curl -fsS http://localhost:3001/health
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面)
@@ -212,12 +212,12 @@ 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`)已在真实库执行 |
+| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200, `database.connected=true`) |
+| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
| 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 |
| `curl http://:3000/health` 通过 | ✅ 直连后端端口已验证 |
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
-| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
+| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
| 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 |
| `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 |
| 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error |
@@ -225,11 +225,11 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 |
| 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 |
| 缺 `deploy/.env` 时由 `init-env.sh` 引导 | ✅ 交互逐项询问 8 个键;只改这 8 个键、保留模板注释、`chmod 600`、回读校验;安装目录已有 `.env` 时直接复用 |
-| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 |
+| `init-env.sh --check` / 非交互不阻塞 | ✅ `test/init-env.spec.ts`(16 项)覆盖 `--check`、`-y`、EOF/超时立即退出、口令不加引号写盘 |
| `HOST` 默认 `0.0.0.0` | ✅ `configuration.ts`、`deploy/.env.example`、`web-server/.env.example`、`install.sh`、`init-env.sh` 五处一致 |
> **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。
-> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
+> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
> 未能在本机实机跑通。已完成的等价验证:
> - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径),
@@ -238,7 +238,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
> - `npm run migration:run` 走编译产物成功
> - 端到端冒烟 57 项全通过
> - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验
-> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖
+> - `init-env.sh` 通过 `bash -n`,并由 `test/init-env.spec.ts`(16 项)覆盖
> 交互写回 / `--check` / 非交互 / 超时 / 幂等 / 口令不加引号
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
>
@@ -253,13 +253,13 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`
(含 `proxy/` 反代文档)、`contracts/` 快照、`THIRD_PARTY.md`、`README.txt`。
-打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
+打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、
`deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。
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`**
-> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
+> **发布包名变更**: `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` 一致)。
diff --git a/web-server/admin/README.md b/web-server/admin/README.md
index ceb8dfc..9c20648 100644
--- a/web-server/admin/README.md
+++ b/web-server/admin/README.md
@@ -1,7 +1,7 @@
-# OpenDance Studio 管理端(opendance-admin)
+# OpenDance Studio 管理端(opendance-admin)
基于 **React 18 + TypeScript + Vite** 的管理后台,对接 `web-server` 的 NestJS API。
-接口契约以 [`../contracts/rest-api.yaml`](../contracts/rest-api.yaml)(rest-api 0.1.0)为准,
+接口契约以 [`../contracts/rest-api.yaml`](../contracts/rest-api.yaml)(rest-api 0.1.0)为准,
字段语义见 [`../contracts/data-models.md`](../contracts/data-models.md)。
界面语言为简体中文;无任何 UI 组件框架,全部样式集中在 `src/styles.css`。
@@ -11,16 +11,16 @@
```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)
+npm run typecheck # 只做类型检查(tsc --noEmit)
```
> 管理端请求的是**同源**的 `/api/**` 与 `/health`,不含任何硬编码域名。
-## 开发代理(dev proxy)
+## 开发代理(dev proxy)
-`vite.config.ts` 已把 `/api` 与 `/health` 代理到 `http://localhost:3000`:
+`vite.config.ts` 已把 `/api` 与 `/health` 代理到 `http://localhost:3000`:
```ts
server: {
@@ -73,24 +73,24 @@ bash deploy/install.sh
```
- 后端通过 `ADMIN_DIR` 定位管理端产物,默认 `../admin`
-- 反代配置样例:`deploy/proxy/nginx.conf.example`(nginx)、
- `deploy/proxy/web.config.example`(IIS)
+- 反代配置样例:`deploy/proxy/nginx.conf.example`(nginx)、
+ `deploy/proxy/web.config.example`(IIS)
- 完整说明:`deploy/proxy/README.md`
## 目录结构
```
src/
- main.tsx 入口(BrowserRouter)
+ main.tsx 入口(BrowserRouter)
App.tsx 路由表
styles.css 全部样式
api/
- client.ts fetch 封装(ApiError / 分页 / multipart)
+ client.ts fetch 封装(ApiError / 分页 / multipart)
types.ts 契约类型(与 rest-api.yaml 一一对应)
endpoints.ts 契约端点封装
components/
Layout.tsx 左侧导航骨架
- ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单...)
+ ui.tsx 通用组件(表格/弹窗/分页/来源五元组展示与表单...)
VrmPreview.tsx three + @pixiv/three-vrm 的 3D 预览(懒加载分包)
lib/
labels.ts 枚举中文文案
@@ -119,12 +119,12 @@ src/
## 3D 预览说明
-`src/components/VrmPreview.tsx`:`GLTFLoader` + `VRMLoaderPlugin`(来自 `@pixiv/three-vrm`),
+`src/components/VrmPreview.tsx`: `GLTFLoader` + `VRMLoaderPlugin`(来自 `@pixiv/three-vrm`),
-- 依据 `vrm.meta.metaVersion` 区分 VRM 0.x / 1.0,0.x 自动执行 `VRMUtils.rotateVRM0`;
+- 依据 `vrm.meta.metaVersion` 区分 VRM 0.x / 1.0, 0.x 自动执行 `VRMUtils.rotateVRM0`;
- 自实现鼠标拖拽旋转与滚轮缩放(未引入 OrbitControls);
- `requestAnimationFrame` 循环内调用 `vrm.update(delta)`,并按包围盒自动取景;
-- 卸载时取消 RAF、断开 ResizeObserver、释放 renderer / geometry / material / texture;
+- 卸载时取消 RAF、断开 ResizeObserver、释放 renderer / geometry / material / texture;
- 无 WebGL 上下文时降级为错误提示,不会让页面崩溃。
`three` + `@pixiv/three-vrm`(约 750 kB)通过 `React.lazy` 单独分包,仅进入模型页时加载。