docs: 消除 Gitea 模棱两可 Unicode 告警(全角标点紧邻 ASCII 时改为半角)

依据 Gitea v1.27.3 modules/charset/escape_stream.go 的判定规则
(analyzeDetectResults:仅当左右相邻字符都不是非 ASCII 时才标记,
且 _common 表恒定生效),把说明文档中会被标记的全角标点
(,:;?!() 等)改为半角,并保证成对括号宽度一致。
中文语境中的全角标点(左右均为中文)保持原样,不动代码与契约语义。

同时删除临时探针目录 _probe/(仅用于确认 Gitea 判定规则)。
This commit is contained in:
opendance
2026-09-15 21:45:51 +08:00
parent a34cca007c
commit 60c182f1b2
28 changed files with 276 additions and 615 deletions
+51 -51
View File
@@ -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 且是 `</dev/null`/关闭,或带 `-y` 等开关 ->
- **非交互绝不挂住**: stdin 非 TTY 且是 `</dev/null`/关闭,或带 `-y` 等开关 ->
只校验不提问;所有 read 带超时,EOF/超时立即 `exit 1`。
- 接入点:`deploy/install.sh` 缺 `deploy/.env` 时先调用 `init-env.sh` 引导,
失败才 die(若 `$APP_DIR/deploy/.env` 已存在则复用,保持原语义)。
- **监听地址默认 `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. 上下文超标时的拆分策略
+1 -1
View File
@@ -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 服务端(请求校验与转换)
-16
View File
@@ -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
-40
View File
@@ -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,可装进模拟器,但相机是虚拟场景,姿态识别
-41
View File
@@ -1,41 +0,0 @@
效果不能代表真机,不能作为验收依据。联调验收请用真机。
权限:相机权限为运行时授予;INTERNET / ACCESS_NETWORK_STATE /
ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于推流与局域网扫描。
2) 获取代码与本地配置
git clone https://<Gitea 地址>/<owner>/opendance-android.git
cd opendance-android
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
sdk.dir=<Android SDK 路径>
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=<JDK 17 路径>
export ANDROID_HOME=<Android SDK 路径>
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/):
-40
View File
@@ -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://<host>:<port>/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 填地址并测试连接 -> 点「开始推流」并授予相机权限 -> 站到画面中央。
成功标志:
-41
View File
@@ -1,41 +0,0 @@
App 运行页 「连接状态」为「已连接」,「目标」显示 ws://<IP>:<端口>/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 会把未捕获堆栈写入应用私有目录,并在连接页展示,
-41
View File
@@ -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。
镜像画面
-40
View File
@@ -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,说明相机或推理没出帧;
若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。
-40
View File
@@ -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
-40
View File
@@ -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 点拓扑。
+3 -3
View File
@@ -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
{
+11 -11
View File
@@ -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` 方法)
- 迁移文件名格式:`<timestamp>-<kebab-name>.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
+29 -29
View File
@@ -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 预览
+7 -7
View File
@@ -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 引擎只有一条输入通路。
关节角特征(弧度,余弦值便于插值):
+6 -6
View File
@@ -10,17 +10,17 @@ ws://<windows-host>: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://<windows-host>:8765/pose
合计 239
```
### 单个关键点(7 字节,`i` 为 0..32)
### 单个关键点(7 字节,`i` 为 0..32)
```
偏移 长度 字段 类型 换算
@@ -43,7 +43,7 @@ ws://<windows-host>: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,连接后立即发送
+13 -13
View File
@@ -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
</rewrite>
<security>
<requestFiltering>
<!-- 512 MiB:IIS 默认约 28.6 MB,不改大文件上传必失败 -->
<!-- 512 MiB:IIS 默认约 28.6 MB,不改大文件上传必失败 -->
<requestLimits maxAllowedContentLength="536870912" />
</requestFiltering>
</security>
@@ -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>/`
+3 -3
View File
@@ -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`。
+4 -4
View File
@@ -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 类型差异)
## 交付物
+3 -3
View File
@@ -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` | 上传封面 |
## 关键实现
+13 -13
View File
@@ -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 |
上传前可先用诊断工具自查(只读):
+36 -36
View File
@@ -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 分析"。
+10 -10
View File
@@ -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 帧挥手 + 深蹲,
+13 -13
View File
@@ -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) |
+6 -6
View File
@@ -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 基地址;留空表示同源
+6 -6
View File
@@ -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] 段落)验证后端确实在列表接口返回完整来源字段,
供本页面展示。
+47 -47
View File
@@ -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、</dev/null、-y)-> 只校验,绝不阻塞;失败则 install.sh die
└── 非交互(CI、</dev/null、-y)-> 只校验,绝不阻塞;失败则 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 且是 `</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`
(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://<host>: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` 一致)。
+14 -14
View File
@@ -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` 单独分包,仅进入模型页时加载。