Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12041f2e58 | ||
|
|
5a78749e49 | ||
|
|
06b476c4e2 | ||
|
|
2da02ee423 | ||
|
|
d1c0dd6e7a | ||
|
|
7a74f598be |
@@ -10,7 +10,67 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
|
||||
|
||||
## 状态
|
||||
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.3**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-017 全部落地,当前版本 **v0.7.5**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
|
||||
|
||||
v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`**):
|
||||
|
||||
1. **根因(生产实测)**:`/health` 的 `scenes.error` 返回
|
||||
`No metadata for "Scene" was found.` —— `src/entities/index.ts` 的
|
||||
`ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]` **漏了 `Scene`**。
|
||||
TypeORM 只按 `ENTITIES` 注册 metadata,于是:
|
||||
* **启动完全正常**(DI 图可解析、`/health` 正常、`migrationsPending 0`),
|
||||
* 但**第一次查 Scene 就抛**`No metadata`,于是 `/api/scenes`、
|
||||
`/api/catalog/export`、`/api/catalog/version`、`/api/reviews?status=pending`
|
||||
全部 500 —— 客户端拉不到任何目录、管理端待审列表打不开;
|
||||
* 我此前"数据库缺 scenes 表"的判断**也是错的**:表其实是由 AddScenes 迁移建好的,
|
||||
缺的是**实体注册**;
|
||||
2. **修复**:`ENTITIES` 加入 `Scene`(并导出)。这也解释了为什么 v0.7.3 的降级修复是必要的
|
||||
—— 即使根因是漏注册,降级也保证了"场景坏了不拖垮整站";
|
||||
3. **新增静态防线**`test/entities-registry.spec.ts`(4 项):扫描
|
||||
`src/entities/*.entity.ts` 的 `@Entity()` 类,**逐一与 `ENTITIES` 对账**
|
||||
(漏一个即失败)、检查无重复/无多余,并核对 `AddScenes` 迁移文件确实存在。
|
||||
已做**反向验证**:把 `Scene` 从 `ENTITIES` 移除,该测试立刻失败。
|
||||
这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动),
|
||||
只能靠静态核对兜住;
|
||||
4. `web-server/scripts/diagnose-scenes.mjs` 保持不变(默认只读),部署 v0.7.4 后运行它
|
||||
可确认 `scenes` 表与列的实际情况。
|
||||
|
||||
v0.7.5 修订(**不可用模型的原因归因必须准确**):
|
||||
|
||||
1. `unusableReason()` 原来只要 `stats` 里有 humanoid 数字就归因于 humanoid,
|
||||
于是一个**直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器)
|
||||
会被写成「humanoid 不达标(19/22)」—— 审核界面据此会把修复方向引到"重新绑定骨骼",
|
||||
而实际要做的是"以 VRM 交付";
|
||||
2. 现在按 **`drivable`** 区分:确实不满足 16 根必需骨 → 仍报 humanoid 覆盖度;
|
||||
骨架已达标但没 VRM 容器 → 明确说明"已达标,需重新以 VRM 交付";
|
||||
3. 新增 2 项单测(5 → 7 项):「humanoid 已达标时**不得**出现『humanoid 不达标』字样」
|
||||
与「`drivable=false` 时仍归因于 humanoid」;
|
||||
4. **上传落盘的对称兜底**:`persistTempFile`(动作/模型/场景上传走的通路)在去重命中时
|
||||
会直接删掉刚上传的**唯一副本**并返回旧记录 —— 若记录指向的路径已不存在
|
||||
(换过 `STORAGE_DIR`、只恢复数据库、手工删过文件),上传仍返回 201,
|
||||
但**后续动作预处理 500**(实测 `ENOENT ... .vmd`)、模型分析与缩略图静默失败。
|
||||
`persistBuffer` 早有兜底,这条通路漏了;现按记录里的原路径补写。
|
||||
新增 `test/storage-persist.spec.ts`(5 项)覆盖首次上传 / 去重命中 / **文件已丢失补写**
|
||||
/ 内存通路回归 / 空文件拒绝,并做了反向验证(还原修复后该用例立刻失败);
|
||||
5. 跨端边界验收(Windows × Web)新增两类场景:不可用模型(真实 PMX 转 GLB 后入库)
|
||||
**不进入** `catalog/export` 且 `defaultModelId` 不变;场景 `config` 缺字段时服务端补齐默认值。
|
||||
|
||||
v0.7.4 之后的测试补强(**不改产物,故不新发版本**):
|
||||
|
||||
1. 新增 `web-server/test/real-samples.spec.ts`(6 项,opt-in):用仓库根
|
||||
`示例文件/` 下的**真实素材**跑完整管线,补上"合成夹具证明不了真素材能出片"的缺口 ——
|
||||
* ① 模型→VRM:初音未来 19/22、奥黛塔 21/22 humanoid,`drivable = true`,
|
||||
18 张贴图全内嵌、36 项双面材质补回,并逐字节校验 **POSITION 访问器的
|
||||
`min`/`max` 等于真实包围盒**(glTF 规范唯一强制填写的边界,客户端据此剔除)、
|
||||
蒙皮下标不越界;
|
||||
* ② 动作内部标准 + 骨骼重绑定:真实 VMD 31 条轨道、标准骨架 31/31 命中、
|
||||
2901 帧产出 33+2 点与 9 个关节角,15 个直接关节置信度恒为 `1.0`(不退化成推算值);
|
||||
* 样例或 Assimp 缺失时整体跳过,CI 与无素材的开发机不受影响;
|
||||
2. 修 `test/assimp-locate.spec.ts` 的非密闭性:该文件的用例断言的是
|
||||
**PATH / 常见系统目录**的查找行为,但会继承外部 `ASSIMP_BIN`
|
||||
(`assimp-lib.sh` 里 `ASSIMP_BIN` 优先级高于 PATH)。开发者按本项目文档
|
||||
在 shell 里设过 `ASSIMP_BIN` 时 `npm test` 会假失败,现已在 `runLib()` 里剔除;
|
||||
3. 规模 **25 文件 / 295 项**(三个真实样例齐备并装好 Assimp 时的实测数)。
|
||||
|
||||
v0.7.3 修订(**场景表不可用不再拖垮整站** —— 生产事故修复):
|
||||
**另有一个更直接的缺陷同时修掉**:`ScenesService.create()` 当初按**内存存储**写,
|
||||
@@ -171,15 +231,15 @@ v0.2.0 主要变更:
|
||||
3. 取得发布包并校验(**本仓库的真实发布地址**, 域名 `gitea.kasugano.cn`、属主 `zijunchiang` 属部署必需信息, 不是隐私项)
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/opendance-web-v0.7.3.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/SHA256SUMS
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/opendance-web-v0.7.4.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
```
|
||||
|
||||
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.7.3.tar.gz && cd opendance-web-v0.7.3
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh
|
||||
```
|
||||
|
||||
@@ -250,8 +310,8 @@ FLUSH PRIVILEGES;
|
||||
### 5. 部署步骤
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.7.3.tar.gz
|
||||
cd opendance-web-v0.7.3
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz
|
||||
cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
```
|
||||
@@ -422,21 +482,32 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
|
||||
|
||||
```bash
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run contracts:check # 契约版本一致性(6 项契约版本 + 35 条路由 + 三端版本号)
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 27、
|
||||
# 模型检查器 25、缩略图 8、骨骼过滤 8、
|
||||
# 预处理 25、init-env 16、admin-port 16、
|
||||
# access-log 9、VRM 检查器 6、.env 预检 12、
|
||||
# Assimp 定位与安装 13、stats 容量与编码 12、
|
||||
# 缩略图贴图采样 6、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||
npm run test # 309 项单元测试 / 27 个文件。分布(VMD 31、模型检查器 29、
|
||||
# 模型压缩包 27、预处理 25、admin-port 16、init-env 16、
|
||||
# Assimp 定位与安装 13、应用版本 12、目录导出 12、.env 预检 12、
|
||||
# 访问日志 9、MMD 材质对齐 9、stats 容量与编码 9、场景配置 9、
|
||||
# 骨骼过滤 8、缩略图 8、动作预览 8、缩略图贴图采样 6、
|
||||
# 真实素材回归 6、场景降级 6、VRM 检查器 6、模型可用性 5、
|
||||
# 场景上传 5、实体注册 4、场景端到端(真实 sql.js 库) 4)
|
||||
# opt-in: test/real-samples.spec.ts 缺样例/Assimp 时跳过 3 项
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
npm run test:smoke # 81 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
cd ../admin && npm run build # 管理端构建
|
||||
```
|
||||
|
||||
真实素材回归(需要仓库根的 `示例文件/` 与外部 Assimp;样例缺失时自动跳过):
|
||||
|
||||
```bash
|
||||
cd web-server
|
||||
ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts
|
||||
# 样例放在别处: OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=... npm test
|
||||
# 覆盖: 两个真实 PMX→VRM(初音 19/22、奥黛塔 21/22 humanoid) + 真实 VMD→33 点内部标准
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|
||||
+16
-5
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.4 使用说明
|
||||
================================================================
|
||||
|
||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||
@@ -8,6 +8,17 @@ OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
|
||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||
连接信息通过 .env 注入。
|
||||
|
||||
v0.7.4 修订 - 场景功能的真正根因: Scene 没有注册进 ENTITIES
|
||||
1. 生产实测 /health 的 scenes.error = 'No metadata for "Scene" was found.'
|
||||
-- src/entities/index.ts 的 ENTITIES 漏了 Scene。TypeORM 只按 ENTITIES 注册
|
||||
metadata, 于是启动完全正常、但第一次查 Scene 就抛错 => /api/scenes、
|
||||
/api/catalog/export、/api/catalog/version、/api/reviews?status=pending 全 500。
|
||||
此前"数据库缺 scenes 表"的判断也是错的: 表已由 AddScenes 迁移建好, 缺的是实体注册。
|
||||
2. 修复: ENTITIES 加入 Scene 并导出。
|
||||
3. 新增 test/entities-registry.spec.ts(4 项): 扫描 @Entity() 类与 ENTITIES 对账,
|
||||
漏一个即失败; 已反向验证(移除 Scene 立刻失败)。
|
||||
4. 部署后运行 node scripts/diagnose-scenes.mjs 可确认表与列的实际状态。
|
||||
|
||||
v0.7.3 修订 - 场景表不可用不再拖垮整站(生产事故修复)
|
||||
另修一个更直接的缺陷: ScenesService.create() 按内存存储写(判断 file.buffer),
|
||||
而上传中间件用的是 multer diskStorage(只有 path、没有 buffer) => 任何场景都传不上来,
|
||||
@@ -206,11 +217,11 @@ v0.2.1 修订
|
||||
本项目不会创建数据库本身
|
||||
3. 取得发布包并校验(以下是本仓库的真实发布地址; 域名 gitea.kasugano.cn
|
||||
与属主 zijunchiang 属部署必需信息, 不是隐私项)
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.3.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.4.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
||||
tar -xzf opendance-web-v0.7.3.tar.gz && cd opendance-web-v0.7.3
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh
|
||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||
bash deploy/install.sh
|
||||
@@ -269,8 +280,8 @@ v0.2.1 修订
|
||||
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
||||
|
||||
部署步骤
|
||||
tar -xzf opendance-web-v0.7.3.tar.gz
|
||||
cd opendance-web-v0.7.3
|
||||
tar -xzf opendance-web-v0.7.4.tar.gz
|
||||
cd opendance-web-v0.7.4
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
|
||||
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
project: opendance-web-server
|
||||
version: 0.7.3
|
||||
version: 0.7.5
|
||||
repository: opendance-web
|
||||
default_branch: main
|
||||
contracts_version:
|
||||
@@ -88,9 +88,13 @@ tasks:
|
||||
file: tasks/016-websocket-contract-020.md
|
||||
scope: 契约 websocket-keypoints 0.2.0(保活 / 设备去重 / 状态机,与 T-AND-011 对齐)
|
||||
status: done
|
||||
- id: T-WEB-017
|
||||
file: tasks/017-model-unusable-reason.md
|
||||
scope: 不可用模型的原因归因必须准确(按 drivable 判断)+ 跨端边界验收(不可用模型不进目录 / 场景 config 缺字段收敛)
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-web-v0.7.3.tar.gz
|
||||
artifact: opendance-web-v0.7.5.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||
|
||||
@@ -30,3 +30,36 @@
|
||||
| 非 VRM 模型不出现在客户端目录 | ✅ `catalog/export` 过滤 + 指纹过滤;`test/model-usability.spec.ts` 覆盖 |
|
||||
| 不可用模型有明确原因 | ✅ DTO 增 `usable` / `unusableReason` 字段 |
|
||||
| 三端契约一致 | ✅ `data-models 0.7.0` / `model-formats 0.5.0` 快照三端逐字节一致 |
|
||||
|
||||
## 真实样例回归(T-WEB-003 / T-WEB-011)
|
||||
|
||||
`test/real-samples.spec.ts` 是 **opt-in** 的真实素材端到端用例:合成夹具证明不了
|
||||
「真的 MMD 素材能出片」,而这个文件直接用仓库根 `示例文件/` 下的真模型跑完整转换管线。
|
||||
样例与 Assimp 任一缺失时整个 describe 跳过,因此无素材的开发机与 CI 依旧全绿。
|
||||
|
||||
```bash
|
||||
# 跑真实素材(Assimp 不在 PATH 时用 ASSIMP_BIN 指绝对路径)
|
||||
ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts
|
||||
# 样例放在别处时
|
||||
OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=/usr/bin/assimp npm test
|
||||
```
|
||||
|
||||
实测(Assimp 6.0,2024 版素材):
|
||||
|
||||
| 样例 | 输入 | 产物 | humanoid | 贴图 | 材质 |
|
||||
|--|--|--|--|--|--|
|
||||
| 初音未来(PMX in zip) | 1.0 MB | 1.10 MB VRM | 19/22,`drivable` | 2 张全内嵌 | 3 |
|
||||
| 奥黛塔(PMX in zip) | 28.1 MB | 26.08 MB VRM | 21/22,`drivable` | 18 张全内嵌 | 39(36 双面已补回) |
|
||||
|
||||
用例断言的不变量(与具体模型无关):
|
||||
|
||||
- `extensions.VRMC_vrm.specVersion === "1.0"`,`asset.version === "2.0"`
|
||||
- **所有** `images` 都带 `bufferView`、无 `uri`(外链贴图离开原目录即坏图)
|
||||
- 无 `KHR_materials_specular/volume/transmission/ior/emissive_strength` 泄漏(MMD 专属参数不能进标准 PBR 产物)
|
||||
- **POSITION 访问器的 `min`/`max` 与逐元素读出的真实包围盒一致** —— 这是 glTF 规范唯一强制填写的边界,客户端拿它做视锥剔除与 AABB,填错会导致模型被整体剔除。读访问器时必须按 `byteStride` 走,否则交错缓冲里的 u16 数据会被读成乱码
|
||||
- 蒙皮下标 `< skin.joints.length`(越界会让客户端取到错误矩阵)
|
||||
|
||||
> **已知的上游瑕疵(不影响可用性)**:Assimp 6.0 的 glTF2 导出器会给
|
||||
> `JOINTS_0` 与 `inverseBindMatrices` 写偏保守的 `min`(前者把真实最小值 6 写成 0,
|
||||
> 后者对角元 1 写成 `2.2e-308`)。两者都是**合法下界**(`min ≤ 真实值 ≤ max`),
|
||||
> 没有任何运行时会据此做剔除,因此不改产物、只在用例里锁定真正要紧的 POSITION 边界。
|
||||
|
||||
@@ -40,3 +40,30 @@ DTW 的两侧输入是同一套特征。
|
||||
| MMD(日文骨骼名)模型可驱动 | ✅ 实测 21/22、19/22 覆盖;客户端可正常驱动 |
|
||||
| Godot 端候选表与服务端一致 | ✅ 一致性测试(跨仓库生成 + 校验) |
|
||||
| 动作统一为 33 点 | ✅ `keypoint_format = mediapipe33@0.1.0`;Android 端推流同构 |
|
||||
|
||||
## 真实样例回归(T-WEB-006)
|
||||
|
||||
`test/real-samples.spec.ts` 用仓库根 `示例文件/【动作】霜雪千年.vmd`(0.8 MB,真实舞蹈,
|
||||
96.7 s)跑完整 `parseVmd → vmdToWorldFrames → worldFramesToKeypointFrames` 管线。
|
||||
VMD 通路不依赖 Assimp,因此只要样例在就会真跑;样例缺失时整个 describe 跳过。
|
||||
|
||||
实测:`maxFrame = 2900` → **2901 帧**(30 fps / 96.7 s),轨道 31 条,
|
||||
标准骨架 `MMD_SKELETON` 的 **31 根全部命中**(缺失 0),双侧腿部 IK 链
|
||||
(`左/右足IK` + `ひざ` + `足首` + `つま先`)齐备,因此 IK 解算路径生效;
|
||||
重定向 135 ms(0.05 ms/帧),产出 33 点 + 2 虚拟点 + 9 关节角,NDJSON 6.51 MB。
|
||||
|
||||
用例锁定的重绑定不变量:
|
||||
|
||||
| 断言 | 实测 |
|
||||
|--|--|
|
||||
| 世界帧键名恰为 joint-map 候选名(MMD 名 → 英文规范名的重绑定结果) | 15 个键逐一比对 |
|
||||
| 直接绑定的 15 个索引置信度**只能**是 `1.0`(不许退化成推算值) | 0/11/12/13/14/15/16/23/24/25/26/27/28/31/32 → 全部 `[1]` |
|
||||
| 其余 18 个点由层级推算 | 全部 `[0.5]`,缺失(`0`)为 0 |
|
||||
| 骨盆归零 + 图像空间投影 | `max|xy| = 0.85 ≤ 1`,越界 0 |
|
||||
| 关节角(cos 值)有限且 ∈ [-1,1] | 范围 `[-1.000, 0.995]` |
|
||||
| NDJSON 往返一致 / 缩略图有效 | 2901 行往返一致;PNG magic `89504e470d0a1a0a` |
|
||||
| 动作非退化 | 左右腕 x 方向摆幅 > 0.1,鼻尖摆幅显著小于手腕 |
|
||||
|
||||
> 性能注意:2901 帧 × 33 点逐元素 `expect` 会拖满 vitest 默认 5 s 超时。
|
||||
> 用例改成先聚合违规项、最后只断言一次(保留具体帧号与下标),并显式给
|
||||
> 大用例 120–300 s 超时。
|
||||
|
||||
@@ -140,3 +140,105 @@ if (!file || !file.buffer || file.buffer.length === 0) { …400… }
|
||||
**测试**:`test/scene-upload.spec.ts`(5 项)—— diskStorage 形状的文件不再被判成
|
||||
"缺少 file 字段"、GLB 直传标注"未做重编码"且落 `pending`、中文文件名被还原、
|
||||
缺文件仍 400、超限 400 且不落盘。该用例在修复前必然失败。
|
||||
|
||||
## 缺陷修复记录四(v0.7.4):**真正的根因** —— `Scene` 没注册进 `ENTITIES`
|
||||
|
||||
部署 v0.7.3 后,`/health` 的 `scenes.error` 给出了决定性线索:
|
||||
|
||||
```
|
||||
No metadata for "Scene" was found.
|
||||
```
|
||||
|
||||
`src/entities/index.ts` 是:
|
||||
|
||||
```ts
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]; // ← 少了 Scene
|
||||
```
|
||||
|
||||
TypeORM 的 DataSource **只按 `ENTITIES` 注册 metadata**,漏注册的后果非常隐蔽:
|
||||
|
||||
| 环节 | 表现 |
|
||||
|--|--|
|
||||
| 启动 | **完全正常** —— DI 图可解析、`/health` 正常、`migrationsPending 0` |
|
||||
| 迁移 | 正常(`AddScenes` 已执行,**表其实是建好的**) |
|
||||
| 第一次查该实体 | 抛 `No metadata for "Scene" was found.` |
|
||||
| 连带影响 | `/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、`/api/reviews?status=pending` 全部 500 → **客户端拉不到任何目录** |
|
||||
|
||||
也就是说:**此前"数据库缺 scenes 表"的判断是错的**(v0.7.3 的降级与诊断脚本因此仍然值得保留 ——
|
||||
它们把"场景坏了"限制成"场景不可用",而不是整站不可用)。
|
||||
|
||||
**修复**:`ENTITIES` 加入 `Scene` 并导出。
|
||||
|
||||
**新增静态防线** `test/entities-registry.spec.ts`(4 项):
|
||||
|
||||
* 扫描 `src/entities/*.entity.ts` 里所有 `@Entity()` 类,**逐一与 `ENTITIES` 对账**
|
||||
(漏一个即失败;已反向验证:移除 `Scene` 后测试立刻失败);
|
||||
* 检查 `ENTITIES` 无重复、无多余项;
|
||||
* 核对 `AddScenes` 迁移文件确实存在(迁移文件缺失时 `showMigrations()` 仍会显示 0 个待执行)。
|
||||
|
||||
这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动),因此**只能靠静态核对兜住**。
|
||||
|
||||
## 产物级端到端验收(v0.7.4 发布包实测)
|
||||
|
||||
上面那条"只能靠静态核对兜住"其实**还不够**:静态核对查的是 `src/`,
|
||||
而交付给部署方的是 `dist-release/*.tar.gz`。两者之间隔着
|
||||
「解包 → npm install --omit=dev → 迁移 → 起进程」四步,
|
||||
`nest build` 没重编译、打包带了旧的 `dist/` 都会让校验全绿而产物仍是坏的。
|
||||
|
||||
因此补了两层,并把整条验收自动化:
|
||||
|
||||
### 1. 产物级实体注册核对(`scripts/check-bootstrap.mjs` 新增一段)
|
||||
|
||||
直接读**产物**里的 `dist/entities/`:逐个 `import` 每个 `*.entity.js`
|
||||
(`@Entity()` 装饰器在模块加载时写 metadata),要求
|
||||
|
||||
- 每个实体文件里被 `@Entity()` 装饰的类都在 `ENTITIES` 里;
|
||||
- `ENTITIES` 里不允许出现没有 `@Entity()` 装饰的成员;
|
||||
- `ENTITIES` 不允许重复;
|
||||
- `Scene` 必须注册(点名,便于一眼看出是不是又漏了场景)。
|
||||
|
||||
原先的启动冒烟只证明"DI 图可解析",而**漏注册实体恰恰不影响启动** ——
|
||||
所以这条必须单独存在。已接入 `npm run verify`。
|
||||
|
||||
**反向验证**(在真实 v0.7.4 发布包里复现生产缺陷):把产物
|
||||
`dist/entities/index.js` 的 `ENTITIES` 去掉 `Scene` 后:
|
||||
|
||||
```
|
||||
✗ 产物级实体注册核对失败(dist/entities 与 ENTITIES 不一致):
|
||||
- scene.entity.js 里的 Scene 没有注册进 ENTITIES —— 查询该实体会抛
|
||||
`No metadata for "..." was found.`(接口直接 500)
|
||||
- ENTITIES 里没有 Scene —— 场景管理相关接口会全部 500(生产事故根因)
|
||||
退出码=1
|
||||
```
|
||||
|
||||
### 2. 发布包级端到端验收(`scripts/accept-release.sh`,新增)
|
||||
|
||||
对一个 tar.gz 一键跑完:解包 → 装生产依赖 → 迁移 → 起服务 → 检查
|
||||
`/health` 关键字段(版本 / 数据库 / `migrationsPending` / **`scenes.available`**)
|
||||
→ 产物级实体注册核对 → 跑**发布包自带的** `test/smoke.mjs` → 停服务。
|
||||
需要一个可连的 MySQL/MariaDB(脚本不装数据库,与项目边界一致)。
|
||||
|
||||
```bash
|
||||
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=opendance DB_PASS=*** DB_NAME=opendance_accept \
|
||||
ASSIMP_BIN=/usr/bin/assimp bash scripts/accept-release.sh dist-release/opendance-web-v0.7.4.tar.gz
|
||||
```
|
||||
|
||||
**实测(v0.7.4 发布包 + 真实 MySQL 9.7.2)**:
|
||||
|
||||
```
|
||||
✓ /health 的 version=0.7.4 与 package.json 的 0.7.4 一致
|
||||
✓ 数据库已连接(opendance_accept,pending=0)
|
||||
✓ 没有未执行的迁移
|
||||
✓ scenes.available=true(false 时 error=null)
|
||||
✓ 产物级实体注册核对通过:dist/entities/ 下 6 个实体文件全部注册进 ENTITIES(含 Scene)
|
||||
== 结果:80 通过 / 0 失败
|
||||
产物级验收全部通过
|
||||
```
|
||||
|
||||
其中冒烟的 `[7]` 段就是本任务的验收:`POST /api/scenes` 201、一律 `pending`、
|
||||
GLB 直传不重编码、`glbUrl` 生成、**重力/风/地面摩擦/坡度/出生点/相机模式按配置原样入库**、
|
||||
越界值夹紧与非法枚举回退、审核通过后 `catalog/export` 的 `scenes[]` 带完整物理参数与
|
||||
GLB/缩略图字段、**目录指纹纳入场景变更**、`GET /api/scenes/:id`、按状态过滤、`DELETE` 204。
|
||||
|
||||
> 这一步同时排除了「Linux 上导出/打包产物与源码不一致」的可能:
|
||||
> 跑的是发布包里**自带的** `dist/`、`scripts/`、`test/`,不是仓库源码。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# T-WEB-017:不可用模型的原因归因必须准确
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-012(模型一律转 VRM)
|
||||
- **文档**: v1.6 §4.9 / §7.6
|
||||
- **契约**: `model-formats 0.5.0`、`data-models 0.7.0`
|
||||
|
||||
## 缺陷
|
||||
|
||||
`unusableReason()` 原来只要 `stats` 里有 humanoid 数字就归因于 humanoid:
|
||||
|
||||
```ts
|
||||
if (stats && typeof stats.humanoidMapped === 'number' && typeof stats.humanoidTotal === 'number') {
|
||||
return `humanoid 不达标(${stats.humanoidMapped}/${stats.humanoidTotal}),未产出 VRM`;
|
||||
}
|
||||
```
|
||||
|
||||
于是一个**直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器)
|
||||
会被写成「**humanoid 不达标(19/22)**」—— 审核界面据此会把修复方向引到
|
||||
「重新绑定骨骼」,而实际要做的是「以 VRM 交付」。
|
||||
|
||||
## 修复
|
||||
|
||||
按 **`drivable`** 区分两种不可用:
|
||||
|
||||
| 情形 | 原因文案 |
|
||||
|--|--|
|
||||
| `drivable !== true`(确实不满足 16 根必需骨) | `humanoid 不达标(m/22),未产出 VRM` |
|
||||
| `drivable === true` 但没有 VRM 容器(直接上传的 GLB / 转换只产出 GLB) | `未产出 VRM(格式为 GLB;humanoid 覆盖 m/22 已达标),需重新以 VRM 交付才能进入客户端目录` |
|
||||
| 没有 stats(分析失败等) | `未产出 VRM(格式为 GLB),不可用于客户端`(不变) |
|
||||
|
||||
新增两条单测(`test/model-usability.spec.ts`,5 → 7 项):
|
||||
「humanoid 已达标时**不得**出现『humanoid 不达标』字样」与「`drivable=false` 时仍归因于 humanoid」。
|
||||
|
||||
## 顺带修掉:上传落盘的对称兜底(`persistTempFile`)
|
||||
|
||||
做跨端边界验收时,动作预处理在**换了 `STORAGE_DIR`** 的服务端上直接 500:
|
||||
|
||||
```
|
||||
ERROR [PreprocessService] 动作 #7 预处理失败:ENOENT ... /web075/storage/motion/2026/09/a8/<sha>.vmd
|
||||
```
|
||||
|
||||
根因:`persistTempFile`(动作 / 模型 / 场景上传走的通路)在去重命中时
|
||||
**直接 `safeUnlink(tempPath)` 删掉刚上传的唯一副本**并返回旧记录;一旦记录指向的路径
|
||||
不存在(换过 `STORAGE_DIR`、只恢复数据库、手工删过文件),上传仍返回 **201**,
|
||||
但后续动作预处理 500、模型分析与缩略图静默失败。`persistBuffer` 早在上一轮就补了
|
||||
「文件缺失则按原路径补写」,这条通路漏了。
|
||||
|
||||
**修复**:去重命中时先 `existsSync(absolutePath(existing))`;缺失则按记录里的原路径
|
||||
`rename`(跨设备退化为 `copyFile`)补写,只有确认目标存在才删临时文件。
|
||||
|
||||
新增 `test/storage-persist.spec.ts`(5 项):首次上传 / 去重命中且文件在 /
|
||||
**去重命中但文件已丢失(补写而非删除)** / `persistBuffer` 回归 / 空文件拒绝。
|
||||
**反向验证**:临时还原修复后,第 3 条用例立即失败(`existsSync(absolute) === false`),
|
||||
恢复后 5 项全过。
|
||||
|
||||
## 跨端边界验收(Windows × Web)
|
||||
|
||||
`accept-integration.sh` 的播种脚本新增两个**边界**场景,并用发布包里的客户端断言:
|
||||
|
||||
1. **不可用模型**:把真实 PMX 用 Assimp 转成 GLB(等价于"转换只产出 GLB"的入库形态),
|
||||
上传并审核通过(**不**设为默认模型),断言
|
||||
* `/api/models/{id}` 的 `usable === false` 且 `unusableReason` 不谎报 humanoid;
|
||||
* 该模型**不出现在** `GET /api/catalog/export`;
|
||||
* `defaultModelId` 仍是可用的 VRM 模型;
|
||||
* 包内客户端的 `bundle.models` 里**没有**任何非 VRM 模型(① 的消费侧形态)。
|
||||
2. **场景 config 缺字段**:只给 `physics.gravity`,断言服务端补齐默认值
|
||||
(碰撞体 `auto`、地面默认摩擦/坡度、静默风、相机 `follow`…),客户端解析后同样一致。
|
||||
|
||||
## 实测
|
||||
|
||||
见 `build/accept-integration.sh` 输出(需要已部署的 Web 服务端)。
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opendance-admin",
|
||||
"private": true,
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.5",
|
||||
"type": "module",
|
||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||
"scripts": {
|
||||
|
||||
Generated
+10
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
@@ -29,6 +29,7 @@
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql.js": "^1.14.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -5669,6 +5670,13 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",
|
||||
"integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.5",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -24,7 +24,8 @@
|
||||
"migration:revert": "node scripts/migrate.mjs revert",
|
||||
"migration:show": "node scripts/migrate.mjs show",
|
||||
"version:check": "node scripts/check-version.mjs",
|
||||
"bootstrap:check": "node scripts/check-bootstrap.mjs"
|
||||
"bootstrap:check": "node scripts/check-bootstrap.mjs",
|
||||
"vectors:frame": "vite-node scripts/gen-frame-vectors.mts --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.12",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql.js": "^1.14.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# OpenDance Studio Web 服务端 —— **发布产物级端到端验收**
|
||||
#
|
||||
# 为什么需要它:仓库里的 `npm run verify`(单元测试 + `check-bootstrap`)跑的是
|
||||
# **源码与刚构建的 dist**;而真正交付给部署方的是 `dist-release/` 里的 tar.gz。
|
||||
# 两者之间还隔着「解包 → npm install → 迁移 → 起进程」四步,历史上三次生产
|
||||
# 事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、v0.7.4 `No metadata for "Scene"`)
|
||||
# 全都只在**产物真正跑起来**时才暴露。
|
||||
#
|
||||
# 本脚本把那次验收完整自动化:解包发布包 → 装生产依赖 → 跑迁移 → 起服务 →
|
||||
# 跑发布包自带的 `test/smoke.mjs` → 停服务 → 汇报。跑完你就得到一份
|
||||
# 「这个 tarball 能不能用」的结论,而不是「源码测试是绿的」。
|
||||
#
|
||||
# 前置:需要一个可连的 **MySQL / MariaDB**(脚本不会安装数据库,与项目
|
||||
# 「不部署 MySQL」的边界一致)。本机验证时可用纯用户态方案,例如:
|
||||
# npm i mysql-memory-server # 或任何现成实例
|
||||
#
|
||||
# 用法:
|
||||
# DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=opendance DB_PASS=xxx DB_NAME=opendance_test \
|
||||
# bash scripts/accept-release.sh dist-release/opendance-web-v0.7.4.tar.gz
|
||||
#
|
||||
# 可选环境变量:
|
||||
# PORT / ADMIN_PORT 服务端口(默认 34100 / 34101,避免撞上真实服务)
|
||||
# WORK_DIR 解包与存储目录(默认 mktemp -d)
|
||||
# ASSIMP_BIN Assimp 可执行文件;设为空则跳过需要它的用例
|
||||
# SKIP_MIGRATE=1 跳过迁移(复用已有库)
|
||||
# KEEP_RUNNING=1 跑完不杀进程(便于手工继续查)
|
||||
#
|
||||
# 退出码:0 全通过 / 1 有失败 / 2 用法或前置问题。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
TARBALL="${1:-}"
|
||||
if [ -z "$TARBALL" ]; then
|
||||
echo "用法:DB_HOST=... DB_USER=... DB_NAME=... bash scripts/accept-release.sh <发布包 tar.gz>" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "✗ 找不到发布包:$TARBALL" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for name in DB_HOST DB_USER DB_NAME; do
|
||||
if [ -z "${!name:-}" ]; then
|
||||
echo "✗ 缺少环境变量 $name(需要现有 MySQL/MariaDB 连接信息)" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
PORT="${PORT:-34100}"
|
||||
ADMIN_PORT="${ADMIN_PORT:-34101}"
|
||||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||||
SKIP_MIGRATE="${SKIP_MIGRATE:-0}"
|
||||
KEEP_RUNNING="${KEEP_RUNNING:-0}"
|
||||
|
||||
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||||
FAILED=0
|
||||
|
||||
log "发布包:$TARBALL"
|
||||
log "工作目录:$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# ---- 1. 解包 ----
|
||||
log "解包…"
|
||||
tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "✗ 解包失败" >&2; exit 1; }
|
||||
PACKAGE_ROOT="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||||
SERVER_DIR="$PACKAGE_ROOT/web-server"
|
||||
[ -d "$SERVER_DIR" ] || { echo "✗ 包里没有 web-server/ 目录" >&2; exit 1; }
|
||||
|
||||
# ---- 2. 装生产依赖 ----
|
||||
# 与部署脚本一致:只装 dependencies,不装 devDependencies。
|
||||
log "安装生产依赖(npm install --omit=dev)…"
|
||||
( cd "$SERVER_DIR" && npm install --omit=dev --no-audit --no-fund >"$WORK_DIR/npm-install.log" 2>&1 ) \
|
||||
|| { echo "✗ 依赖安装失败,日志:$WORK_DIR/npm-install.log" >&2; tail -20 "$WORK_DIR/npm-install.log"; exit 1; }
|
||||
|
||||
# ---- 3. 环境变量 ----
|
||||
cat >"$WORK_DIR/accept.env" <<EOF
|
||||
NODE_ENV=production
|
||||
HOST=127.0.0.1
|
||||
PORT=$PORT
|
||||
ADMIN_PORT=$ADMIN_PORT
|
||||
ADMIN_HOST=127.0.0.1
|
||||
DB_HOST=$DB_HOST
|
||||
DB_PORT=${DB_PORT:-3306}
|
||||
DB_USER=$DB_USER
|
||||
DB_PASS=${DB_PASS:-}
|
||||
DB_NAME=$DB_NAME
|
||||
DB_SYNCHRONIZE=false
|
||||
STORAGE_DIR=$WORK_DIR/storage
|
||||
ADMIN_DIR=$PACKAGE_ROOT/admin
|
||||
CONTRACTS_DIR=$PACKAGE_ROOT/contracts
|
||||
EOF
|
||||
mkdir -p "$WORK_DIR/storage"
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . "$WORK_DIR/accept.env"; set +a
|
||||
|
||||
# ---- 4. 迁移 ----
|
||||
if [ "$SKIP_MIGRATE" = "1" ]; then
|
||||
log "跳过迁移(SKIP_MIGRATE=1)"
|
||||
else
|
||||
log "执行迁移(npm run migration:run)…"
|
||||
( cd "$SERVER_DIR" && npm run migration:run >"$WORK_DIR/migrate.log" 2>&1 ) \
|
||||
|| { echo "✗ 迁移失败,日志:$WORK_DIR/migrate.log" >&2; tail -30 "$WORK_DIR/migrate.log"; exit 1; }
|
||||
log "迁移完成"
|
||||
fi
|
||||
|
||||
# ---- 5. 起服务 ----
|
||||
log "启动产物(node dist/main.js)…"
|
||||
( cd "$SERVER_DIR" && nohup node dist/main.js >"$WORK_DIR/server.log" 2>&1 & echo $! >"$WORK_DIR/server.pid" )
|
||||
SERVER_PID="$(cat "$WORK_DIR/server.pid")"
|
||||
|
||||
cleanup() {
|
||||
if [ "$KEEP_RUNNING" != "1" ] && [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -9 "$SERVER_PID" 2>/dev/null || true
|
||||
log "已停止服务(pid $SERVER_PID)"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
API="http://127.0.0.1:$PORT"
|
||||
READY=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf -m 5 "$API/health" >"$WORK_DIR/health.json" 2>/dev/null; then READY=1; break; fi
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$READY" != "1" ]; then
|
||||
fail "服务 60 秒内没有就绪;日志:$WORK_DIR/server.log"
|
||||
tail -30 "$WORK_DIR/server.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 6. 健康检查关键字段(④ 的生产根因就在这几项上)----
|
||||
log "健康检查:"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const pkg = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
let bad = 0;
|
||||
const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; };
|
||||
say(j.version === pkg.version, `/health 的 version=${j.version} 与 package.json 的 ${pkg.version} 一致`);
|
||||
say(j.database.connected === true, `数据库已连接(${j.database.database},pending=${j.database.migrationsPending})`);
|
||||
say(j.database.migrationsPending === 0, "没有未执行的迁移");
|
||||
say(j.scenes.available === true, `scenes.available=${j.scenes.available}(false 时 error=${JSON.stringify(j.scenes.error)})`);
|
||||
process.exit(bad);
|
||||
' "$WORK_DIR/health.json" "$SERVER_DIR/package.json" || fail "健康检查关键字段不符合预期"
|
||||
|
||||
# ---- 7. 产物级实体注册核对 ----
|
||||
# 用**当前仓库**的检查脚本去核对包内产物:验收标准应当是最新的,而不是
|
||||
# 「这个包自带的那份检查」(包里那份可能比现在旧,旧规则查不出新增的检查项)。
|
||||
log "产物级实体注册核对:"
|
||||
CHECKER="$PROJECT_DIR/scripts/check-bootstrap.mjs"
|
||||
if [ -f "$CHECKER" ] && ! cmp -s "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"; then
|
||||
cp "$CHECKER" "$SERVER_DIR/scripts/check-bootstrap.mjs"
|
||||
log " 已用仓库当前的 check-bootstrap.mjs 覆盖包内版本(验收标准以最新为准)"
|
||||
fi
|
||||
if ( cd "$SERVER_DIR" && node scripts/check-bootstrap.mjs >"$WORK_DIR/bootstrap.log" 2>&1 ); then
|
||||
grep -E "^✓" "$WORK_DIR/bootstrap.log" | sed 's/^/ /'
|
||||
else
|
||||
fail "实体注册核对失败"
|
||||
sed 's/^/ /' "$WORK_DIR/bootstrap.log" | tail -20
|
||||
fi
|
||||
|
||||
# ---- 8. 发布包自带的端到端冒烟 ----
|
||||
log "端到端冒烟(发布包自带的 test/smoke.mjs)…"
|
||||
if ( cd "$SERVER_DIR" && API_BASE="$API" node test/smoke.mjs >"$WORK_DIR/smoke.log" 2>&1 ); then
|
||||
grep -E "^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
|
||||
grep -c "^ ✓" "$WORK_DIR/smoke.log" | sed 's/^/ 通过项:/'
|
||||
else
|
||||
fail "冒烟未全通过"
|
||||
grep -E "^ ✗|^== 结果" "$WORK_DIR/smoke.log" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# ---- 9. 契约快照与三端一致 ----
|
||||
log "包内契约快照:"
|
||||
if [ -f "$PACKAGE_ROOT/contracts/VERSION" ]; then
|
||||
sed 's/^/ /' "$PACKAGE_ROOT/contracts/VERSION"
|
||||
else
|
||||
fail "包内缺少 contracts/VERSION"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "============================================================"
|
||||
echo " 产物级验收失败(日志目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================================"
|
||||
echo " 产物级验收全部通过(日志目录:$WORK_DIR)"
|
||||
echo "============================================================"
|
||||
@@ -26,7 +26,8 @@
|
||||
* 用法:node scripts/check-bootstrap.mjs
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -60,12 +61,126 @@ const DB_STAGE_PATTERNS = [
|
||||
|
||||
const TIMEOUT_MS = Number(process.env.BOOTSTRAP_TIMEOUT_MS ?? 40_000);
|
||||
|
||||
/* ---------------------------------------------- 产物级实体注册核对 */
|
||||
|
||||
/**
|
||||
* 就地核对**编译产物**里的实体注册,而不是源码。
|
||||
*
|
||||
* 为什么必须有这一层:生产事故「`/health` 报 `No metadata for "Scene" was found.`」
|
||||
* 的根因是 `src/entities/index.ts` 的 `ENTITIES` 漏了 `Scene`。这类缺陷有三个特点:
|
||||
* 1. **启动完全正常** —— TypeORM 只按 `ENTITIES` 注册 metadata,漏注册不影响 DI 图;
|
||||
* 2. **`tsc` 与单元测试全绿** —— 类型没问题,`entities-registry.spec.ts` 只查源码;
|
||||
* 3. 只有**第一次查该实体**时才抛(`/api/scenes`、`/api/catalog/export`、
|
||||
* `/api/reviews?status=pending` 全部 500,客户端一个目录都拉不到)。
|
||||
* 也就是说:源码层的校验挡不住「产物里的 ENTITIES 与 dist 里的实体文件不一致」
|
||||
* (例如 `nest build` 没重新编译、打包时带了旧的 dist)。
|
||||
*
|
||||
* 因此按「校验必须落在产物上」的规则,这里直接读 `dist/entities/`:
|
||||
* * 每个 `*.entity.js` 里被 `@Entity()` 装饰的类都必须在 `ENTITIES` 里;
|
||||
* * `ENTITIES` 里不允许出现没有 `@Entity()` 装饰的成员(防复制粘贴写错);
|
||||
* * `ENTITIES` 不允许重复;
|
||||
* * `Scene` 必须注册(点名,一眼看出是不是又漏了场景)。
|
||||
*/
|
||||
function checkEntityRegistry() {
|
||||
const requireFromServer = createRequire(join(serverDir, 'package.json'));
|
||||
const errors = [];
|
||||
|
||||
let storage;
|
||||
try {
|
||||
requireFromServer('reflect-metadata');
|
||||
storage = requireFromServer('typeorm').getMetadataArgsStorage();
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
`无法加载 typeorm(${error instanceof Error ? error.message : String(error)})——` +
|
||||
'请先在 web-server 目录执行 npm install',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const entitiesDir = join(serverDir, 'dist', 'entities');
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(entitiesDir).filter((name) => name.endsWith('.entity.js'));
|
||||
} catch (error) {
|
||||
return { errors: [`无法读取 ${entitiesDir}:${error.message}(是否忘了 npm run build?)`] };
|
||||
}
|
||||
if (files.length === 0) {
|
||||
return { errors: [`${entitiesDir} 下没有任何 *.entity.js`] };
|
||||
}
|
||||
|
||||
// 逐个 import:@Entity() 装饰器在模块加载时写入 metadata storage
|
||||
const fileOfClass = new Map();
|
||||
for (const file of files) {
|
||||
const module_ = requireFromServer(join(entitiesDir, file));
|
||||
let decorated = null;
|
||||
for (const value of Object.values(module_)) {
|
||||
if (typeof value === 'function' && storage.tables.some((t) => t.target === value)) {
|
||||
decorated = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!decorated) {
|
||||
errors.push(`${file} 里没有找到被 @Entity() 装饰的类`);
|
||||
continue;
|
||||
}
|
||||
fileOfClass.set(decorated, file);
|
||||
}
|
||||
|
||||
let entities;
|
||||
try {
|
||||
entities = requireFromServer(join(entitiesDir, 'index.js')).ENTITIES;
|
||||
} catch (error) {
|
||||
return { errors: [`无法加载 dist/entities/index.js:${error.message}`] };
|
||||
}
|
||||
if (!Array.isArray(entities)) {
|
||||
return { errors: ['dist/entities/index.js 没有导出数组形式的 ENTITIES'] };
|
||||
}
|
||||
|
||||
const registered = new Set(entities);
|
||||
for (const [cls, file] of fileOfClass) {
|
||||
if (!registered.has(cls)) {
|
||||
errors.push(
|
||||
`${file} 里的 ${cls.name} 没有注册进 ENTITIES —— ` +
|
||||
'查询该实体会抛 `No metadata for "..." was found.`(接口直接 500)',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const cls of entities) {
|
||||
if (!fileOfClass.has(cls)) {
|
||||
errors.push(`ENTITIES 里的 ${cls?.name ?? String(cls)} 不是 dist/entities/ 下任何 @Entity() 类`);
|
||||
}
|
||||
}
|
||||
if (registered.size !== entities.length) {
|
||||
errors.push(`ENTITIES 有重复项(${entities.length} 项,去重后 ${registered.size} 项)`);
|
||||
}
|
||||
if (!entities.some((cls) => cls?.name === 'Scene')) {
|
||||
errors.push('ENTITIES 里没有 Scene —— 场景管理相关接口会全部 500(生产事故根因)');
|
||||
}
|
||||
|
||||
return { errors, count: entities.length, fileCount: files.length };
|
||||
}
|
||||
|
||||
function freePort(base) {
|
||||
// 用一个不常见的端口即可;连库失败是预期的,端口只要不撞上真实服务
|
||||
return base + Math.floor(Math.random() * 200);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// ---------- 第一步:产物级实体注册核对(不需要进程与数据库)----------
|
||||
const registry = checkEntityRegistry();
|
||||
if (registry.errors.length > 0) {
|
||||
console.error('✗ 产物级实体注册核对失败(dist/entities 与 ENTITIES 不一致):');
|
||||
for (const message of registry.errors) console.error(` - ${message}`);
|
||||
console.error(' 说明:TypeORM 只按 ENTITIES 注册 metadata,漏注册不影响启动,');
|
||||
console.error(' 但第一次查该实体会抛 No metadata,相关接口全部 500。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ 产物级实体注册核对通过:dist/entities/ 下 ${registry.fileCount} 个实体文件` +
|
||||
`全部注册进 ENTITIES(共 ${registry.count} 项,含 Scene)`,
|
||||
);
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'opendance-boot-'));
|
||||
|
||||
// 模拟 systemd:删掉 npm_* 环境变量(ExecStart=node dist/main.js 时它们不存在)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 生成 239 字节关键点帧的**跨实现一致性向量**(契约 websocket-keypoints §2)
|
||||
*
|
||||
* 为什么由 Web 端生成:`web-server/src/preprocess/keypoint-codec.ts` 是契约的
|
||||
* **参考实现**(`encodeBinaryFrame` 注释即「Android 端参考实现」,
|
||||
* `decodeBinaryFrame` 注释即「Windows 端参考实现(T-WIN-004)」)。
|
||||
* Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;
|
||||
* **Windows 的 GDScript 解码器此前没有与它逐值比对过** —— 本脚本产出的向量
|
||||
* 就是给那份比对用的输入与期望值。
|
||||
*
|
||||
* 用法(需在 opendance-web/web-server 下执行):
|
||||
* npx vite-node scripts/gen-frame-vectors.mts -- --out /path/frame-vectors.json
|
||||
*
|
||||
* 输出结构:
|
||||
* {
|
||||
* "format": "keypoint-frame-vectors@1",
|
||||
* "source": "opendance-web web-server/src/preprocess/keypoint-codec.ts",
|
||||
* "cases": [
|
||||
* { "name": "...", "frameId": 7, "flags": 3, "hex": "4f44...",
|
||||
* "points": [[x, y, z, conf] × 33], // **参考实现的解码结果**(float64)
|
||||
* "inputPoints": [...], // 生成该帧时喂给编码器的原始值(float64)
|
||||
* "f32Hex": "4f44...", // 用 f32InputPoints 编码出的字节(与 GDScript
|
||||
* // 重编码做"同输入"比较的唯一正确基准)
|
||||
* "f32InputPoints": [...] } // 同上,但已收敛到 float32(GDScript 侧点/置信度
|
||||
* // 就是 float32;用它重编码才能与参考比特流
|
||||
* // 做"同输入"比较,差异才只剩取整规则)
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* 注意:期望值里的坐标是 **float64**;GDScript 侧把点存进 `PackedVector3Array`
|
||||
* (float32),因此比对时必须对期望值做 `Math.fround` 等价的收敛 ——
|
||||
* 这是**表示精度**的差异,不是协议不兼容。向量里同时给出两者的对比口径。
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { encodeBinaryFrame, decodeBinaryFrame } from '../src/preprocess/keypoint-codec';
|
||||
import { KEYPOINT_COUNT } from '../src/preprocess/mediapipe33';
|
||||
|
||||
type Point = [number, number, number, number];
|
||||
|
||||
/** 33 点全同的构造器(便于构造边界用例) */
|
||||
function uniform(x: number, y: number, z: number, conf: number): Point[] {
|
||||
return Array.from({ length: KEYPOINT_COUNT }, () => [x, y, z, conf] as Point);
|
||||
}
|
||||
|
||||
/** 按索引摆动的一组"像真实动作"的值,覆盖正负与小数 */
|
||||
function varying(): Point[] {
|
||||
return Array.from({ length: KEYPOINT_COUNT }, (_, i) => {
|
||||
const x = -0.9 + (1.8 * i) / (KEYPOINT_COUNT - 1);
|
||||
const y = 0.5 * Math.sin(i) + 0.1;
|
||||
const z = 0.02 * i - 0.3;
|
||||
const conf = i === 5 ? 100 / 255 : 240 / 255;
|
||||
return [x, y, z, conf] as Point;
|
||||
});
|
||||
}
|
||||
|
||||
interface Case {
|
||||
name: string;
|
||||
frameId: number;
|
||||
flags: number;
|
||||
points: Point[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const cases: Case[] = [
|
||||
{ name: '全零', frameId: 0, flags: 0, points: uniform(0, 0, 0, 0) },
|
||||
{ name: '全 1 与满分置信度', frameId: 1, flags: 0, points: uniform(1, 1, 1, 1) },
|
||||
{ name: '负边界 -1', frameId: 2, flags: 0, points: uniform(-1, -1, -1, 1) },
|
||||
{
|
||||
name: 'i16 最小值 -32768(-32768/32767 = -1.0000305…)',
|
||||
frameId: 3,
|
||||
flags: 0,
|
||||
points: uniform(-32768 / 32767, -32768 / 32767, -32768 / 32767, 1),
|
||||
note: '解码后应得到 i16 的 -32768,而不是被夹到 -32767',
|
||||
},
|
||||
{ name: 'i16 最大值 32767', frameId: 4, flags: 0, points: uniform(32767 / 32767, 0, 0, 1) },
|
||||
{ name: '置信度 128/255(低置信度阈值)', frameId: 5, flags: 0x02, points: uniform(0.5, -0.5, 0.25, 128 / 255) },
|
||||
{ name: 'flags 全 3 位(镜像/低置信度/半身)', frameId: 6, flags: 0x07, points: varying() },
|
||||
{ name: '按索引摆动(接近真实动作)', frameId: 7, flags: 0, points: varying() },
|
||||
{ name: 'frame_id 取 u32 最大值', frameId: 4294967295, flags: 0, points: uniform(0.1, 0.2, 0.3, 0.5) },
|
||||
{ name: 'frame_id 高位(0x80000000)', frameId: 2147483648, flags: 0x01, points: uniform(-0.1, -0.2, -0.3, 1) },
|
||||
{
|
||||
name: '编码取整:正数恰好 .5',
|
||||
frameId: 9,
|
||||
flags: 0,
|
||||
points: uniform(2.5 / 32767, 3.5 / 32767, 0.5 / 32767, 1),
|
||||
note: 'x*32767 落在 .5 上:JS Math.round 与 GDScript roundi 对**负数**的进位方向不同',
|
||||
},
|
||||
{
|
||||
name: '编码取整:负数恰好 -.5',
|
||||
frameId: 10,
|
||||
flags: 0,
|
||||
points: uniform(-2.5 / 32767, -3.5 / 32767, -0.5 / 32767, 1),
|
||||
note: '同上;若 GDScript 侧编解码与 TS 不一致,这条会先暴露',
|
||||
},
|
||||
];
|
||||
|
||||
const out: {
|
||||
format: string;
|
||||
source: string;
|
||||
keypointCount: number;
|
||||
frameBytes: number;
|
||||
cases: Array<{
|
||||
name: string;
|
||||
frameId: number;
|
||||
flags: number;
|
||||
hex: string;
|
||||
points: Point[];
|
||||
inputPoints: Point[];
|
||||
f32InputPoints: Point[];
|
||||
f32Hex: string;
|
||||
note?: string;
|
||||
}>;
|
||||
} = {
|
||||
format: 'keypoint-frame-vectors@1',
|
||||
source: 'opendance-web web-server/src/preprocess/keypoint-codec.ts',
|
||||
keypointCount: KEYPOINT_COUNT,
|
||||
frameBytes: 239,
|
||||
cases: [],
|
||||
};
|
||||
|
||||
for (const c of cases) {
|
||||
const buf = encodeBinaryFrame(c.frameId, c.points, c.flags);
|
||||
if (buf.length !== 239) throw new Error(`编码结果长度异常:${buf.length}`);
|
||||
const decoded = decodeBinaryFrame(buf);
|
||||
const f32Points = c.points.map(
|
||||
([x, y, z, conf]) => [Math.fround(x), Math.fround(y), Math.fround(z), Math.fround(conf)] as Point,
|
||||
);
|
||||
if (decoded.frameId !== c.frameId || decoded.flags !== c.flags) {
|
||||
throw new Error(`用例 ${c.name} 头部字段不一致`);
|
||||
}
|
||||
// **关键**:写进向量的 `points` 必须是**参考解码器对同一字节的解码结果**,
|
||||
// 而不是喂进编码器的原始值 —— 否则下游比对就变成了「比编码量化」,
|
||||
// 而不是「比两个解码器是否一致」。(第一版就写错了,靠抽查数值发现。)
|
||||
out.cases.push({
|
||||
name: c.name,
|
||||
frameId: c.frameId,
|
||||
flags: c.flags,
|
||||
hex: buf.toString('hex'),
|
||||
points: decoded.points,
|
||||
inputPoints: c.points,
|
||||
f32InputPoints: f32Points,
|
||||
f32Hex: encodeBinaryFrame(c.frameId, f32Points, c.flags).toString('hex'),
|
||||
note: c.note,
|
||||
});
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const outIdx = args.indexOf('--out');
|
||||
const outPath = resolve(outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1] : 'frame-vectors.json');
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
|
||||
|
||||
console.log(`✓ 已生成 ${out.cases.length} 个向量 → ${outPath}`);
|
||||
for (const c of out.cases) {
|
||||
console.log(` · ${c.name}(${c.hex.length / 2} 字节${c.note ? ';' + c.note : ''})`);
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import { Asset } from './asset.entity';
|
||||
import { CharacterModel } from './model.entity';
|
||||
import { Motion } from './motion.entity';
|
||||
import { ReviewRecord } from './review-record.entity';
|
||||
import { Scene } from './scene.entity';
|
||||
import { Song } from './song.entity';
|
||||
|
||||
export { Asset, CharacterModel, Motion, ReviewRecord, Song };
|
||||
export { Asset, CharacterModel, Motion, ReviewRecord, Scene, Song };
|
||||
|
||||
/** 显式列出实体:glob 在 ts-node 与编译产物下行为不一致,显式最稳 */
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord];
|
||||
/**
|
||||
* 显式列出实体:glob 在 ts-node 与编译产物下行为不一致,显式最稳。
|
||||
*
|
||||
* ⚠️ **漏加一个实体不会在启动时报错,只会在第一次查它时报**
|
||||
* `No metadata for "<Name>" was found.` —— 生产上就踩过:`Scene` 忘了加进来,
|
||||
* 于是所有场景相关请求 500,连 `catalog/export` 都跟着挂(客户端拉不到目录)。
|
||||
* 因此新增实体时**必须同时**加到这里,`test/entities-registry.spec.ts` 会强制核对。
|
||||
*/
|
||||
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord, Scene];
|
||||
|
||||
@@ -56,13 +56,30 @@ export function isUsableModel(model: CharacterModel): boolean {
|
||||
return model.format === ModelFormat.Vrm;
|
||||
}
|
||||
|
||||
/** 不可用的原因(可用时返回 null);从 stats 取 humanoid 覆盖度,便于人工定位 */
|
||||
/**
|
||||
* 不可用的原因(可用时返回 null)。
|
||||
*
|
||||
* **归因必须准确**:审核界面靠这句话判断"要不要重新绑定骨骼"还是"只是没转 VRM"。
|
||||
* 早期实现只要 `stats` 里有 humanoid 数字就说「humanoid 不达标」—— 于是一个
|
||||
* **直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器)
|
||||
* 也会被写成「humanoid 不达标(19/22)」,把审核引向错误的修复方向。
|
||||
* 现在只有**确实不满足可驱动门槛**(`drivable !== true`)时才归因于 humanoid。
|
||||
*/
|
||||
export function unusableReason(model: CharacterModel): string | null {
|
||||
if (isUsableModel(model)) return null;
|
||||
const stats = (model.stats ?? null) as Partial<ModelStats> | null;
|
||||
if (stats && typeof stats.humanoidMapped === 'number' && typeof stats.humanoidTotal === 'number') {
|
||||
const hasHumanoid =
|
||||
!!stats &&
|
||||
typeof stats.humanoidMapped === 'number' &&
|
||||
typeof stats.humanoidTotal === 'number';
|
||||
if (hasHumanoid && stats.drivable !== true) {
|
||||
return `humanoid 不达标(${stats.humanoidMapped}/${stats.humanoidTotal}),未产出 VRM`;
|
||||
}
|
||||
if (hasHumanoid) {
|
||||
// 骨架达标却没交付 VRM:多见于是直接上传的 .glb(或转换只产出了 GLB)
|
||||
return `未产出 VRM(格式为 GLB;humanoid 覆盖 ${stats.humanoidMapped}/${stats.humanoidTotal} 已达标),` +
|
||||
'需重新以 VRM 交付才能进入客户端目录';
|
||||
}
|
||||
return '未产出 VRM(格式为 GLB),不可用于客户端';
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,24 @@ export class StorageService {
|
||||
|
||||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||||
if (existing) {
|
||||
await this.safeUnlink(tempPath);
|
||||
// 去重命中时**必须确认文件真的还在**:换过 STORAGE_DIR、或只恢复了数据库
|
||||
// 而没恢复上传目录时,旧记录会指向一个已经不存在的路径。此时若直接
|
||||
// `safeUnlink(tempPath)` 把刚上传的唯一副本删掉,上传仍返回 201,
|
||||
// 但后续预处理 / 模型分析 / 下载 / 缩略图全部 ENOENT
|
||||
// (实测:动作上传成功后 `POST /api/motions/:id/preprocess` 直接 500)。
|
||||
// 这里按**记录里的原路径**补写一份 —— 与 persistBuffer 的兜底一致。
|
||||
const absolute = this.absolutePath(existing);
|
||||
if (existsSync(absolute)) {
|
||||
await this.safeUnlink(tempPath);
|
||||
return { asset: existing, url: this.url(existing) as string };
|
||||
}
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
try {
|
||||
await rename(tempPath, absolute);
|
||||
} catch {
|
||||
await copyFile(tempPath, absolute);
|
||||
await this.safeUnlink(tempPath);
|
||||
}
|
||||
return { asset: existing, url: this.url(existing) as string };
|
||||
}
|
||||
|
||||
|
||||
@@ -149,11 +149,16 @@ describe('转换管线:Assimp 缺失时快速失败并给出可操作信息',
|
||||
|
||||
describe('deploy/assimp-lib.sh:部署脚本共用的安装逻辑', () => {
|
||||
function runLib(script: string, path?: string) {
|
||||
// ASSIMP_BIN 在 assimp-lib.sh 里的优先级高于 PATH,而下面的用例断言的正是
|
||||
// PATH / 常见系统目录的查找行为。若开发者 shell 或 deploy/.env 里恰好设了
|
||||
// ASSIMP_BIN(本项目文档推荐的一种部署方式),继承下来就会让这些用例假失败,
|
||||
// 因此这里显式剔除,只保留 PATH 这一条解析路径。
|
||||
const { ASSIMP_BIN: _ignored, ...env } = process.env;
|
||||
// 覆盖 PATH 的用例里必须用绝对路径调 bash,否则连 bash 都找不到
|
||||
return spawnSync('/bin/bash', ['-c', `. "$1"; ${script}`, '_', ASSIMP_LIB], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
env: { ...process.env, ...(path ? { PATH: path } : {}) },
|
||||
env: { ...env, ...(path ? { PATH: path } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ENTITIES } from '../src/entities';
|
||||
|
||||
/**
|
||||
* **每个 `@Entity()` 装饰的类都必须出现在 `ENTITIES` 里。**
|
||||
*
|
||||
* 生产事故:`scene.entity.ts` 定义了 `@Entity('scenes')` 的 `Scene`,
|
||||
* 但 `src/entities/index.ts` 的 `ENTITIES` 漏了它。TypeORM 的 DataSource 只按
|
||||
* `ENTITIES` 注册 metadata,于是:
|
||||
*
|
||||
* * **启动完全正常**(DI 图能解析、`/health` 也正常),
|
||||
* * 但**第一次查该实体**就抛 `No metadata for "Scene" was found.` →
|
||||
* `/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
|
||||
* `/api/reviews?status=pending` 全部 500,客户端拉不到任何目录。
|
||||
*
|
||||
* 这类"漏注册"既不会被 `tsc` 发现,也不会被启动冒烟发现,只能靠**静态核对**
|
||||
* 兜住,所以这里直接扫 `src/entities/*.entity.ts` 的 `@Entity` 装饰器,
|
||||
* 与 `ENTITIES` 逐一对账。
|
||||
*/
|
||||
const entitiesDir = join(__dirname, '..', 'src', 'entities');
|
||||
|
||||
function decoratedEntityClasses(): string[] {
|
||||
const files = readdirSync(entitiesDir).filter((f) => f.endsWith('.entity.ts'));
|
||||
const classes: string[] = [];
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(entitiesDir, file), 'utf8');
|
||||
// 只认真正带 @Entity() 装饰器的类;@Entity() 与 class 之间可能还有别的装饰器
|
||||
// (例如 `@Index(...)`)或注释,所以先定位 @Entity(,再往后找第一个 export class。
|
||||
const entityIndex = source.indexOf('@Entity(');
|
||||
if (entityIndex < 0) continue;
|
||||
const after = source.slice(entityIndex);
|
||||
const match = after.match(/export class (\w+)/);
|
||||
if (match) classes.push(match[1]);
|
||||
}
|
||||
return classes.sort();
|
||||
}
|
||||
|
||||
describe('ENTITIES 注册表完整性', () => {
|
||||
const decorated = decoratedEntityClasses();
|
||||
const registered = ENTITIES.map((e) => e.name).sort();
|
||||
|
||||
it('扫到了实体文件(防止本测试因路径变化而空跑)', () => {
|
||||
expect(decorated.length).toBeGreaterThanOrEqual(6);
|
||||
expect(decorated).toContain('Scene');
|
||||
});
|
||||
|
||||
it('每个 @Entity() 类都在 ENTITIES 中(漏一个就会在运行期抛 No metadata 错误)', () => {
|
||||
const missing = decorated.filter((name) => !registered.includes(name));
|
||||
expect(missing, `以下实体有 @Entity() 但没有加入 ENTITIES:${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('ENTITIES 里没有多余/重复项', () => {
|
||||
expect(new Set(registered).size).toBe(ENTITIES.length);
|
||||
const extra = registered.filter((name) => !decorated.includes(name));
|
||||
expect(extra, `ENTITIES 里有未定义 @Entity() 的类:${extra.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('runMigrations 会加载到 AddScenes(场景表迁移必须在迁移列表里)', () => {
|
||||
// 迁移文件与实体是两件事:漏了迁移文件时 showMigrations() 仍会显示 0 个待执行,
|
||||
// 因为 TypeORM 只比较"代码里加载到的"迁移。这里直接核对文件存在。
|
||||
const migrationsDir = join(__dirname, '..', 'src', 'migrations');
|
||||
const files = readdirSync(migrationsDir);
|
||||
expect(files.some((f) => f.includes('AddScenes'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 239 字节关键点帧编解码的**跨实现语义**(契约 websocket-keypoints §2)
|
||||
*
|
||||
* 为什么单独一个文件:`preprocess.spec.ts` 已经覆盖了「编码→解码往返」,
|
||||
* 但跨实现一致性真正会翻车的地方是**边界与取整规则** —— 本文件把这几条显式钉住,
|
||||
* 因为它们是「参考实现」对其他两端的承诺:
|
||||
*
|
||||
* * `i16` 最小值:`-32768/32767 = -1.0000305…`,编码必须得到 `0x8000`
|
||||
* (不能被夹成 `-32767`,也不能当成溢出);
|
||||
* * **取整规则**:JS `Math.round` 对「恰好 .5」是**朝 +∞** 取整
|
||||
* (`Math.round(-2.5) === -2`)。Android 的 Kotlin 实现用的是 `Math.round`,
|
||||
* 与本文件一致;**Windows 的 GDScript 用的是 `roundi`(半数远离零)**,
|
||||
* 两者在 `-X.5` 上会差 1 个 LSB —— 这一条已在
|
||||
* `opendance-windows/build/integration/probe-frame-vectors.gd` 里被实测并归类,
|
||||
* 此处把它写成参考实现的**显式契约**,避免以后有人"顺手"改掉;
|
||||
* * 置信度 `128/255` 是低置信度阈值(契约 §2:`conf < 128` 的点坐标无意义);
|
||||
* * `frame_id` 是 u32(含 `0xFFFFFFFF` 与 `0x80000000`);`flags` 只占低 3 位。
|
||||
*
|
||||
* 这些向量同时被 `opendance-windows/build/integration/frame-vectors.json` 使用
|
||||
* (由 `scripts/gen-frame-vectors.mts` 生成),所以本文件的改动会影响那边的比对基准。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BINARY_FRAME_BYTES,
|
||||
MAGIC,
|
||||
PROTOCOL_VERSION,
|
||||
decodeBinaryFrame,
|
||||
encodeBinaryFrame,
|
||||
} from '../src/preprocess/keypoint-codec';
|
||||
import { KEYPOINT_COUNT } from '../src/preprocess/mediapipe33';
|
||||
|
||||
type Point = [number, number, number, number];
|
||||
|
||||
const uniform = (x: number, y: number, z: number, conf: number): Point[] =>
|
||||
Array.from({ length: KEYPOINT_COUNT }, () => [x, y, z, conf] as Point);
|
||||
|
||||
describe('关键点二进制帧:跨实现边界语义', () => {
|
||||
it('帧头字段:magic / version / flags / frame_id(u32,小端)', () => {
|
||||
const buf = encodeBinaryFrame(0xffffffff, uniform(0, 0, 0, 0), 0x07);
|
||||
expect(buf.length).toBe(BINARY_FRAME_BYTES);
|
||||
expect(buf.readUInt16LE(0)).toBe(MAGIC);
|
||||
expect(buf.readUInt8(2)).toBe(PROTOCOL_VERSION);
|
||||
expect(buf.readUInt8(3)).toBe(0x07);
|
||||
expect(buf.readUInt32LE(4)).toBe(0xffffffff);
|
||||
// flags 只保留低 8 位(契约:bit3-7 保留,必须为 0;这里验证编码侧不越界)
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 0), 0x1ff).readUInt8(3)).toBe(0xff);
|
||||
});
|
||||
|
||||
it('i16 最小值边界:-32768/32767 必须编码成 0x8000 并原样解回', () => {
|
||||
const scaled = -32768 / 32767; // -1.000030518509476
|
||||
const buf = encodeBinaryFrame(3, uniform(scaled, scaled, scaled, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(-32768);
|
||||
expect(buf.readInt16LE(10)).toBe(-32768);
|
||||
expect(buf.readInt16LE(12)).toBe(-32768);
|
||||
const decoded = decodeBinaryFrame(buf);
|
||||
expect(decoded.points[0][0]).toBeCloseTo(scaled, 12);
|
||||
expect(decoded.points[0][1]).toBeCloseTo(scaled, 12);
|
||||
});
|
||||
|
||||
it('超出 ±1 的输入被夹到 i16 范围,不溢出', () => {
|
||||
const buf = encodeBinaryFrame(0, uniform(5, -5, 2, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(32767);
|
||||
expect(buf.readInt16LE(10)).toBe(-32768);
|
||||
expect(buf.readInt16LE(12)).toBe(32767);
|
||||
});
|
||||
|
||||
it('取整规则是 JS Math.round(半数朝 +∞)—— 与 Android 的 Math.round 一致', () => {
|
||||
// -2.5 → -2(而不是 -3);+2.5 → 3。这条是参考实现对「取整」的承诺。
|
||||
const buf = encodeBinaryFrame(0, uniform(-2.5 / 32767, 2.5 / 32767, -0.5 / 32767, 1));
|
||||
expect(buf.readInt16LE(8)).toBe(-2);
|
||||
expect(buf.readInt16LE(10)).toBe(3);
|
||||
expect(buf.readInt16LE(12)).toBe(0);
|
||||
expect(Math.round(-2.5)).toBe(-2);
|
||||
expect(Math.round(-0.5)).toBe(-0);
|
||||
});
|
||||
|
||||
it('置信度:128/255 是低置信度阈值,0 与 255 两端都保留', () => {
|
||||
const buf = encodeBinaryFrame(0, uniform(0, 0, 0, 128 / 255));
|
||||
const conf = buf.readUInt8(8 + 6);
|
||||
expect(conf).toBe(128);
|
||||
expect(decodeBinaryFrame(buf).points[0][3]).toBeCloseTo(128 / 255, 12);
|
||||
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 0)).readUInt8(14)).toBe(0);
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 1)).readUInt8(14)).toBe(255);
|
||||
// 越界置信度被夹紧(而非回绕)
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, 2)).readUInt8(14)).toBe(255);
|
||||
expect(encodeBinaryFrame(0, uniform(0, 0, 0, -1)).readUInt8(14)).toBe(0);
|
||||
});
|
||||
|
||||
it('解码拒绝长度 / magic / 版本不合法的帧', () => {
|
||||
expect(() => decodeBinaryFrame(Buffer.alloc(BINARY_FRAME_BYTES - 1))).toThrow(/239/);
|
||||
const badMagic = Buffer.alloc(BINARY_FRAME_BYTES);
|
||||
badMagic.writeUInt16LE(0x1234, 0);
|
||||
expect(() => decodeBinaryFrame(badMagic)).toThrow(/magic/);
|
||||
const badVersion = Buffer.alloc(BINARY_FRAME_BYTES);
|
||||
badVersion.writeUInt16LE(MAGIC, 0);
|
||||
badVersion.writeUInt8(0x02, 2);
|
||||
expect(() => decodeBinaryFrame(badVersion)).toThrow(/版本/);
|
||||
});
|
||||
|
||||
it('编解码往返在量化误差内稳定(含随机输入)', () => {
|
||||
for (let seed = 0; seed < 20; seed += 1) {
|
||||
const points: Point[] = Array.from({ length: KEYPOINT_COUNT }, (_, i) => {
|
||||
const v = (n: number) => Math.sin(seed * 13.7 + i * 1.9 + n) * 0.9;
|
||||
return [v(0), v(1), v(2), (Math.cos(seed + i) + 1) / 2] as Point;
|
||||
});
|
||||
const decoded = decodeBinaryFrame(encodeBinaryFrame(seed, points));
|
||||
for (let i = 0; i < KEYPOINT_COUNT; i += 1) {
|
||||
for (let c = 0; c < 3; c += 1) {
|
||||
// 量化步长 1/32767 ≈ 3.05e-5,允许半个步长
|
||||
expect(Math.abs(decoded.points[i][c] - points[i][c])).toBeLessThanOrEqual(1.6e-5);
|
||||
}
|
||||
expect(Math.abs(decoded.points[i][3] - points[i][3])).toBeLessThanOrEqual(1 / 255 + 1e-9);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe('模型可用性(usable)', () => {
|
||||
const glb = model({
|
||||
format: ModelFormat.Glb,
|
||||
vrmVersion: null,
|
||||
stats: { humanoidMapped: 0, humanoidTotal: 22 } as Record<string, unknown>,
|
||||
stats: { humanoidMapped: 0, humanoidTotal: 22, drivable: false } as Record<string, unknown>,
|
||||
});
|
||||
expect(isUsableModel(glb)).toBe(false);
|
||||
expect(unusableReason(glb)).toBe('humanoid 不达标(0/22),未产出 VRM');
|
||||
@@ -58,6 +58,40 @@ describe('模型可用性(usable)', () => {
|
||||
expect(dto.unusableReason).toContain('0/22');
|
||||
});
|
||||
|
||||
it('GLB 但 **humanoid 已达标** → 不能归因成「humanoid 不达标」(归因准确)', () => {
|
||||
// 直接上传的 .glb:骨架合规(drivable=true)、只是没有 VRM 容器。
|
||||
// 早期实现会写成「humanoid 不达标(19/22)」,把审核引向错误的修复方向。
|
||||
const glb = model({
|
||||
format: ModelFormat.Glb,
|
||||
vrmVersion: null,
|
||||
stats: {
|
||||
humanoidMapped: 19,
|
||||
humanoidTotal: 22,
|
||||
drivable: true,
|
||||
missingRequiredBones: [],
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
const reason = unusableReason(glb);
|
||||
expect(reason).not.toContain('humanoid 不达标');
|
||||
expect(reason).toContain('19/22');
|
||||
expect(reason).toContain('已达标');
|
||||
expect(reason).toContain('未产出 VRM');
|
||||
});
|
||||
|
||||
it('GLB 且 humanoid 未达标(drivable=false)→ 仍归因于 humanoid', () => {
|
||||
const glb = model({
|
||||
format: ModelFormat.Glb,
|
||||
vrmVersion: null,
|
||||
stats: {
|
||||
humanoidMapped: 12,
|
||||
humanoidTotal: 22,
|
||||
drivable: false,
|
||||
missingRequiredBones: ['leftHand'],
|
||||
} as Record<string, unknown>,
|
||||
});
|
||||
expect(unusableReason(glb)).toBe('humanoid 不达标(12/22),未产出 VRM');
|
||||
});
|
||||
|
||||
it('GLB 且没有 stats → 给出通用原因(不抛错)', () => {
|
||||
const glb = model({ format: ModelFormat.Glb, vrmVersion: null, stats: null });
|
||||
expect(unusableReason(glb)).toContain('未产出 VRM');
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 真实样例素材的端到端集成测试(**opt-in**)。
|
||||
*
|
||||
* 为什么单独一个文件:`model-archive.spec.ts` / `mmd-vmd.spec.ts` 用的都是
|
||||
* 合成的 PMX / GLB / VMD 字节与假 Assimp 夹具,能覆盖解析边界,但证明不了
|
||||
* 「**真的** MMD 素材能出片」——而 ①「所有模型 → VRM」与 ②「动作内部标准 +
|
||||
* 骨骼重绑定」的验收标准恰恰是真实素材。
|
||||
*
|
||||
* 门禁:找不到样例目录或 Assimp 时整个 describe 跳过,所以在没有素材的开发机
|
||||
* 与 CI 上 `npm run test` 依旧全绿;素材与 Assimp 齐备时会真跑一遍。
|
||||
*
|
||||
* OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=/usr/bin/assimp npm test
|
||||
*
|
||||
* 样例不随仓库分发(体积 + 版权),因此这里只断言**结构性不变量**,不断言
|
||||
* 模型的具体骨骼名或贴图内容。
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseGlbChunks } from '../src/common/glb-chunks';
|
||||
import { resolveAssimpBinary } from '../src/common/assimp';
|
||||
import { ANGLE_NAMES, KEYPOINT_COUNT, KEYPOINT_FORMAT, MP } from '../src/preprocess/mediapipe33';
|
||||
import { LEG_IK, MMD_SKELETON } from '../src/preprocess/mmd-skeleton';
|
||||
import { vmdToWorldFrames } from '../src/preprocess/mmd-pose';
|
||||
import { serializeFramesNdjson, parseFramesNdjson } from '../src/preprocess/keypoint-codec';
|
||||
import { worldFramesToKeypointFrames } from '../src/preprocess/retarget';
|
||||
import { VMD_FPS, parseVmd, trackHasPositionMotion } from '../src/preprocess/vmd-parser';
|
||||
import { convertModelArchive } from '../src/models/model-convert';
|
||||
import { inspectModelFile } from '../src/models/model-inspector';
|
||||
import { renderMotionThumbnail } from '../src/motions/motion-thumbnail';
|
||||
|
||||
/* ------------------------------------------------------------------ 样例定位 */
|
||||
|
||||
/** 仓库根目录下的样例目录(web-server/test/../../.. = 仓库根) */
|
||||
const SAMPLES_DIR =
|
||||
process.env.OPENDANCE_SAMPLES_DIR ?? join(__dirname, '..', '..', '..', '示例文件');
|
||||
|
||||
const MODEL_SMALL = '模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip';
|
||||
const MODEL_LARGE = '【奥黛塔】_by_原神.zip';
|
||||
const MOTION_VMD = '【动作】霜雪千年.vmd';
|
||||
|
||||
const samplePath = (name: string): string => join(SAMPLES_DIR, name);
|
||||
const hasSample = (name: string): boolean => existsSync(samplePath(name));
|
||||
// 与 configuration.ts 的默认值保持一致(ASSIMP_BIN 未设时按 PATH 找 `assimp`)。
|
||||
// 注意不能传 undefined:resolveAssimpBinary 会对入参调 `.includes()`。
|
||||
const assimp = resolveAssimpBinary(process.env.ASSIMP_BIN ?? 'assimp');
|
||||
|
||||
const MODELS_READY = hasSample(MODEL_SMALL) && hasSample(MODEL_LARGE) && assimp.found;
|
||||
const VMD_READY = hasSample(MOTION_VMD);
|
||||
|
||||
/** 只在需要时报出的跳过原因,方便排障 */
|
||||
if (!MODELS_READY) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[real-samples] 跳过模型转换用例:样例目录 ${SAMPLES_DIR} 缺失文件或未找到 Assimp(${assimp.searched.join(' -> ')})`,
|
||||
);
|
||||
}
|
||||
if (!VMD_READY) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[real-samples] 跳过 VMD 用例:缺少 ${samplePath(MOTION_VMD)}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 访问器字节级读取工具 */
|
||||
|
||||
const COMPONENTS: Record<number, { size: number; get: (v: DataView, o: number) => number }> = {
|
||||
5120: { size: 1, get: (v, o) => v.getInt8(o) },
|
||||
5121: { size: 1, get: (v, o) => v.getUint8(o) },
|
||||
5122: { size: 2, get: (v, o) => v.getInt16(o, true) },
|
||||
5123: { size: 2, get: (v, o) => v.getUint16(o, true) },
|
||||
5125: { size: 4, get: (v, o) => v.getUint32(o, true) },
|
||||
5126: { size: 4, get: (v, o) => v.getFloat32(o, true) },
|
||||
};
|
||||
|
||||
const COMPONENT_COUNT: Record<string, number> = {
|
||||
SCALAR: 1,
|
||||
VEC2: 2,
|
||||
VEC3: 3,
|
||||
VEC4: 4,
|
||||
MAT4: 16,
|
||||
};
|
||||
|
||||
interface GltfDocument {
|
||||
json: Record<string, any>;
|
||||
bin: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐元素读出访问器覆盖的**真实** min/max。
|
||||
* 必须按 `byteStride` 走(Assimp 会给交错缓冲写 byteStride,忽略它会把
|
||||
* JOINTS_0 之类的 u16 数据读成乱码)。
|
||||
*/
|
||||
function actualBounds(gltf: GltfDocument, accessorIndex: number) {
|
||||
const a = gltf.json.accessors[accessorIndex];
|
||||
const view = gltf.json.bufferViews[a.bufferView];
|
||||
const component = COMPONENTS[a.componentType];
|
||||
const width = COMPONENT_COUNT[a.type];
|
||||
const stride = a.byteStride ?? component.size * width;
|
||||
const base = gltf.bin.byteOffset + (view.byteOffset ?? 0) + (a.byteOffset ?? 0);
|
||||
const dv = new DataView(gltf.bin.buffer, gltf.bin.byteOffset, gltf.bin.byteLength);
|
||||
|
||||
const min = new Array<number>(width).fill(Infinity);
|
||||
const max = new Array<number>(width).fill(-Infinity);
|
||||
for (let e = 0; e < a.count; e += 1) {
|
||||
for (let c = 0; c < width; c += 1) {
|
||||
const value = component.get(dv, base + e * stride + c * component.size);
|
||||
if (value < min[c]) min[c] = value;
|
||||
if (value > max[c]) max[c] = value;
|
||||
}
|
||||
}
|
||||
return { min, max, accessor: a, component, width, stride, base };
|
||||
}
|
||||
|
||||
/** POSITION 访问器的语义标记 = 4(ARRAY_BUFFER)且被某个 primitive 引用为 POSITION */
|
||||
function positionAccessors(gltf: GltfDocument): number[] {
|
||||
const found = new Set<number>();
|
||||
for (const mesh of gltf.json.meshes ?? []) {
|
||||
for (const primitive of mesh.primitives ?? []) {
|
||||
const index = primitive.attributes?.POSITION;
|
||||
if (typeof index === 'number') found.add(index);
|
||||
}
|
||||
}
|
||||
return [...found].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 所有 JOINTS_0 访问器里出现的最大骨骼下标 */
|
||||
function maxJointIndex(gltf: GltfDocument): number {
|
||||
let worst = -1;
|
||||
for (const mesh of gltf.json.meshes ?? []) {
|
||||
for (const primitive of mesh.primitives ?? []) {
|
||||
const index = primitive.attributes?.JOINTS_0;
|
||||
if (typeof index !== 'number') continue;
|
||||
const a = gltf.json.accessors[index];
|
||||
const view = gltf.json.bufferViews[a.bufferView];
|
||||
const component = COMPONENTS[a.componentType];
|
||||
const width = COMPONENT_COUNT[a.type];
|
||||
const stride = a.byteStride ?? component.size * width;
|
||||
const base = gltf.bin.byteOffset + (view.byteOffset ?? 0) + (a.byteOffset ?? 0);
|
||||
const dv = new DataView(gltf.bin.buffer, gltf.bin.byteOffset, gltf.bin.byteLength);
|
||||
for (let e = 0; e < a.count; e += 1) {
|
||||
for (let c = 0; c < width; c += 1) {
|
||||
const value = component.get(dv, base + e * stride + c * component.size);
|
||||
if (value > worst) worst = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- 模型 → VRM */
|
||||
|
||||
describe.skipIf(!MODELS_READY)('真实样例:PMX 压缩包 → VRM(T-WEB-003/T-WEB-011)', () => {
|
||||
/** 转换结果按样例名缓存,两个 describe 之间不重复跑 Assimp */
|
||||
const cache = new Map<string, { converted: any; gltf: GltfDocument }>();
|
||||
|
||||
async function convert(name: string) {
|
||||
const cached = cache.get(name);
|
||||
if (cached) return cached;
|
||||
const archive = readFileSync(samplePath(name));
|
||||
const converted = await convertModelArchive(archive, {
|
||||
assimpBin: assimp.path,
|
||||
timeoutMs: 600_000,
|
||||
preferFormat: 'vrm',
|
||||
});
|
||||
const { json, bin } = parseGlbChunks<Record<string, any>>(converted.buffer);
|
||||
expect(bin, 'VRM 必须内嵌 BIN 块(贴图与顶点数据都在里面)').not.toBeNull();
|
||||
const entry = { converted, gltf: { json, bin: bin as Buffer } };
|
||||
cache.set(name, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 两个真实模型的公共不变量 */
|
||||
async function assertVrmInvariants(name: string) {
|
||||
const { converted, gltf } = await convert(name);
|
||||
const { json } = gltf;
|
||||
|
||||
expect(converted.format).toBe('vrm');
|
||||
expect(json.asset?.version).toBe('2.0');
|
||||
expect(json.extensions?.VRMC_vrm?.specVersion).toBe('1.0');
|
||||
// 元信息里必须写清楚源文件与处理摘要,运营要能据此判断为什么「不可用」
|
||||
expect(converted.sourceModelPath.toLowerCase()).toMatch(/\.pmx$/);
|
||||
expect(converted.note.length).toBeGreaterThan(0);
|
||||
|
||||
// 贴图必须全部内嵌:外链贴图一旦离开原目录就是坏图
|
||||
const images = json.images ?? [];
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
expect(images.filter((i: any) => i.bufferView !== undefined)).toHaveLength(images.length);
|
||||
expect(images.filter((i: any) => typeof i.uri === 'string')).toHaveLength(0);
|
||||
|
||||
// MMD 专属的材质扩展不能泄漏到标准 PBR 产物里(客户端不认这些键)
|
||||
const mmdOnly = [
|
||||
'KHR_materials_specular',
|
||||
'KHR_materials_volume',
|
||||
'KHR_materials_transmission',
|
||||
'KHR_materials_ior',
|
||||
'KHR_materials_emissive_strength',
|
||||
];
|
||||
const leftovers = new Set<string>();
|
||||
for (const material of json.materials ?? []) {
|
||||
for (const key of Object.keys(material.extensions ?? {})) {
|
||||
if (mmdOnly.includes(key)) leftovers.add(key);
|
||||
}
|
||||
}
|
||||
expect([...leftovers]).toEqual([]);
|
||||
|
||||
// 顶点不能被写成 NaN / Inf —— 一个坏顶点就能让客户端整个网格消失
|
||||
for (const index of positionAccessors(gltf)) {
|
||||
const { min, max } = actualBounds(gltf, index);
|
||||
for (const v of [...min, ...max]) expect(Number.isFinite(v)).toBe(true);
|
||||
expect(max[0]).toBeGreaterThan(min[0]);
|
||||
expect(max[1]).toBeGreaterThan(min[1]);
|
||||
expect(max[2]).toBeGreaterThan(min[2]);
|
||||
|
||||
// POSITION 的 min/max 是 glTF 规范**唯一强制**要求填写的,且必须等于真实
|
||||
// 包围盒:客户端拿它做视锥剔除与 AABB,填错会导致模型被整体剔除。
|
||||
const declared = json.accessors[index];
|
||||
expect(declared.min).toHaveLength(3);
|
||||
expect(declared.max).toHaveLength(3);
|
||||
for (let c = 0; c < 3; c += 1) {
|
||||
expect(declared.min[c]).toBeCloseTo(min[c], 4);
|
||||
expect(declared.max[c]).toBeCloseTo(max[c], 4);
|
||||
}
|
||||
}
|
||||
|
||||
// 蒙皮下标必须落在 skin.joints 范围内,否则客户端会取到越界矩阵
|
||||
for (const skin of json.skins ?? []) {
|
||||
expect(maxJointIndex(gltf)).toBeLessThan(skin.joints.length);
|
||||
}
|
||||
|
||||
// 分析器(审核界面看到的统计)与产物本身一致
|
||||
return { converted, gltf };
|
||||
}
|
||||
|
||||
it(
|
||||
'小体积 PMX(初音未来,1 MB)转出可驱动的 VRM',
|
||||
async () => {
|
||||
const { converted, gltf } = await assertVrmInvariants(MODEL_SMALL);
|
||||
const { json } = gltf;
|
||||
|
||||
expect(json.materials?.length ?? 0).toBeGreaterThanOrEqual(1);
|
||||
expect(json.skins?.length ?? 0).toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(converted.note).toContain('VRM');
|
||||
expect(converted.buffer.length).toBeGreaterThan(100_000);
|
||||
},
|
||||
600_000,
|
||||
);
|
||||
|
||||
it(
|
||||
'大体积 PMX(原神奥黛塔,28 MB / 18 贴图 / 36 双面材质)保留全部资源',
|
||||
async () => {
|
||||
const { converted, gltf } = await assertVrmInvariants(MODEL_LARGE);
|
||||
const { json } = gltf;
|
||||
|
||||
const images = json.images ?? [];
|
||||
const materials = json.materials ?? [];
|
||||
expect(images.length).toBeGreaterThanOrEqual(10);
|
||||
expect(materials.length).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// MMD 的双面标志必须在转换时补回 GLB(否则裙摆/头发穿帮)
|
||||
const doubleSided = materials.filter((m: any) => m.doubleSided === true).length;
|
||||
expect(doubleSided).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// 产物不能比源压缩包还小一个量级(贴图丢了会)
|
||||
expect(converted.buffer.length).toBeGreaterThan(5_000_000);
|
||||
},
|
||||
900_000,
|
||||
);
|
||||
|
||||
it(
|
||||
'REAL-SAMPLE humanoid 覆盖与可驱动性(真实骨架分析)',
|
||||
async () => {
|
||||
for (const name of [MODEL_SMALL, MODEL_LARGE]) {
|
||||
const { converted } = await convert(name);
|
||||
const { writeFileSync, mkdtempSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-real-'));
|
||||
const file = join(dir, 'model.glb');
|
||||
writeFileSync(file, converted.buffer);
|
||||
|
||||
const stats = await inspectModelFile(file, converted.buffer.length);
|
||||
expect(stats.container).toBe('vrm');
|
||||
expect(stats.vrmVersion).toMatch(/^1\.0/);
|
||||
expect(stats.humanoidTotal).toBe(22);
|
||||
// 真实 MMD 素材的 humanoid 覆盖率下限:实测初音 19/22、奥黛塔 21/22
|
||||
expect(stats.humanoidMapped).toBeGreaterThanOrEqual(19);
|
||||
// 33 点驱动所需的 16 根必须齐全
|
||||
expect(stats.missingRequiredBones).toEqual([]);
|
||||
expect(stats.drivable).toBe(true);
|
||||
expect(stats.triangles).toBeGreaterThan(1_000);
|
||||
expect(stats.textures).toBe(stats.images);
|
||||
expect(stats.warnings.filter((w) => w.includes('无法'))).toEqual([]);
|
||||
}
|
||||
},
|
||||
900_000,
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- VMD → 动作内部标准 */
|
||||
|
||||
describe.skipIf(!VMD_READY)('真实样例:VMD → 动作内部标准 + 骨骼重绑定(T-WEB-006)', () => {
|
||||
const vmd = () => parseVmd(readFileSync(samplePath(MOTION_VMD)));
|
||||
|
||||
it('解析真实 VMD:标准骨架 31 根全命中,双侧腿部 IK 链完整', () => {
|
||||
const motion = vmd();
|
||||
|
||||
expect(motion.maxFrame).toBeGreaterThan(1000);
|
||||
expect(motion.tracks.size).toBeGreaterThanOrEqual(31);
|
||||
|
||||
const standard = MMD_SKELETON.map((bone) => bone.name);
|
||||
expect(standard).toHaveLength(31);
|
||||
const missing = standard.filter((name) => !motion.tracks.has(name));
|
||||
expect(missing).toEqual([]);
|
||||
|
||||
for (const side of ['左', '右'] as const) {
|
||||
const ik = LEG_IK[side];
|
||||
expect(motion.tracks.has(ik.ik), `${side}足IK`).toBe(true);
|
||||
expect(motion.tracks.has(ik.knee), `${side}ひざ`).toBe(true);
|
||||
expect(motion.tracks.has(ik.ankle), `${side}足首`).toBe(true);
|
||||
expect(motion.tracks.has(ik.toe), `${side}つま先`).toBe(true);
|
||||
}
|
||||
|
||||
// 真实舞蹈数据里躯干有位移、腿部由 IK 目标驱动
|
||||
expect(trackHasPositionMotion(motion.tracks.get('センター'))).toBe(true);
|
||||
expect(trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik))).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it('骨骼重绑定:15 个直接关节命中真实骨骼(置信度 1.0),其余按层级推算(0.5)', () => {
|
||||
const motion = vmd();
|
||||
const { frames } = vmdToWorldFrames(motion, { fps: VMD_FPS });
|
||||
expect(frames).toHaveLength(motion.maxFrame + 1);
|
||||
|
||||
// vmdToWorldFrames 把 MMD 骨骼名改写成 joint-map 的候选名(重绑定核心)
|
||||
const keys = [...frames[0].keys()].sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
'head',
|
||||
'leftarm',
|
||||
'leftfoot',
|
||||
'leftforearm',
|
||||
'lefthand',
|
||||
'leftleg',
|
||||
'lefttoebase',
|
||||
'leftupleg',
|
||||
'rightarm',
|
||||
'rightfoot',
|
||||
'rightforearm',
|
||||
'righthand',
|
||||
'rightleg',
|
||||
'righttoebase',
|
||||
'rightupleg',
|
||||
].sort(),
|
||||
);
|
||||
// 逐元素断言在 2901 帧 × 33 点上跑会拖满 vitest 的超时,因此这里先聚合
|
||||
// 违规项、最后只 assert 一次;失败信息仍然带得出具体帧与下标。
|
||||
const violations: string[] = [];
|
||||
for (const [f, frame] of frames.entries()) {
|
||||
for (const key of keys) {
|
||||
const p = frame.get(key) as [number, number, number];
|
||||
if (!p.every((v) => Number.isFinite(v))) violations.push(`帧${f} ${key} 非有限`);
|
||||
}
|
||||
}
|
||||
expect(violations.slice(0, 5)).toEqual([]);
|
||||
|
||||
const keypoints = worldFramesToKeypointFrames(frames, VMD_FPS);
|
||||
expect(keypoints).toHaveLength(frames.length);
|
||||
|
||||
// 显式标注 number[]:MP 是 as const,直接从它推出的数组是字面量联合,
|
||||
// 会让 `DIRECT.includes(index)` 这类按 number 查询的调用过不了类型检查。
|
||||
const DIRECT: number[] = [MP.NOSE, MP.LEFT_SHOULDER, MP.RIGHT_SHOULDER, MP.LEFT_ELBOW, MP.RIGHT_ELBOW,
|
||||
MP.LEFT_WRIST, MP.RIGHT_WRIST, MP.LEFT_HIP, MP.RIGHT_HIP, MP.LEFT_KNEE, MP.RIGHT_KNEE,
|
||||
MP.LEFT_ANKLE, MP.RIGHT_ANKLE, MP.LEFT_FOOT_INDEX, MP.RIGHT_FOOT_INDEX];
|
||||
|
||||
const confidence = new Map<number, Set<number>>();
|
||||
for (const [f, frame] of keypoints.entries()) {
|
||||
if (frame.points.length !== KEYPOINT_COUNT) violations.push(`帧${f} 点数 ${frame.points.length}`);
|
||||
if (frame.angles.length !== ANGLE_NAMES.length) violations.push(`帧${f} 角度数 ${frame.angles.length}`);
|
||||
frame.points.forEach((point, index) => {
|
||||
const [x, y, z, c] = point;
|
||||
if (![x, y, z].every(Number.isFinite)) violations.push(`帧${f} 点${index} 非有限`);
|
||||
if (!(c === 0 || c === 0.5 || c === 1)) violations.push(`帧${f} 点${index} 置信度 ${c}`);
|
||||
// 骨盆归零后投影到图像空间,两个方向都必须落在 [0,1]
|
||||
if (Math.abs(x) > 1.0001 || Math.abs(y) > 1.0001) violations.push(`帧${f} 点${index} 越界 ${x},${y}`);
|
||||
const set = confidence.get(index) ?? new Set<number>();
|
||||
set.add(c);
|
||||
confidence.set(index, set);
|
||||
});
|
||||
for (const angle of frame.angles) {
|
||||
// 关节角按契约存 cos 值,必须有限且落在 [-1,1]
|
||||
if (!Number.isFinite(angle) || Math.abs(angle) > 1.0001) violations.push(`帧${f} 角度 ${angle}`);
|
||||
}
|
||||
}
|
||||
expect(violations.slice(0, 5)).toEqual([]);
|
||||
|
||||
// 直接绑定的索引必须**只能**是置信度 1.0,绝不退化成推算值
|
||||
for (const index of DIRECT) expect([...confidence.get(index)!]).toEqual([1]);
|
||||
// 其余 18 个点全部由层级推算,且没有任何点彻底缺失
|
||||
const derived = [...confidence.keys()].filter((i) => !DIRECT.includes(i));
|
||||
expect(derived).toHaveLength(KEYPOINT_COUNT - DIRECT.length);
|
||||
for (const index of derived) expect([...confidence.get(index)!]).toEqual([0.5]);
|
||||
}, 300_000);
|
||||
|
||||
it('内部标准序列化:NDJSON 往返一致、缩略图有效、动作幅度非退化', () => {
|
||||
const motion = vmd();
|
||||
const { frames } = vmdToWorldFrames(motion, { fps: VMD_FPS });
|
||||
const keypoints = worldFramesToKeypointFrames(frames, VMD_FPS);
|
||||
|
||||
const ndjson = serializeFramesNdjson(keypoints);
|
||||
expect(ndjson.endsWith('\n')).toBe(true);
|
||||
const roundTrip = parseFramesNdjson(ndjson);
|
||||
expect(roundTrip).toHaveLength(keypoints.length);
|
||||
expect(roundTrip[0].points).toHaveLength(KEYPOINT_COUNT);
|
||||
expect(roundTrip.every((f, i) => f.frame === keypoints[i].frame)).toBe(true);
|
||||
expect(KEYPOINT_FORMAT).toBe('mediapipe33@0.1.0');
|
||||
|
||||
const thumbnail = renderMotionThumbnail(keypoints, { size: 256 });
|
||||
expect(thumbnail).not.toBeNull();
|
||||
expect(thumbnail!.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a');
|
||||
|
||||
// 动作幅度:手腕必须真的在动,鼻子相对稳定
|
||||
const spread = (index: number) => {
|
||||
const xs = keypoints.map((f) => f.points[index][0]);
|
||||
return Math.max(...xs) - Math.min(...xs);
|
||||
};
|
||||
expect(spread(MP.LEFT_WRIST)).toBeGreaterThan(0.1);
|
||||
expect(spread(MP.RIGHT_WRIST)).toBeGreaterThan(0.1);
|
||||
expect(spread(MP.LEFT_ANKLE)).toBeGreaterThan(0.05);
|
||||
expect(spread(MP.NOSE)).toBeLessThan(spread(MP.LEFT_WRIST));
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { AssetKind, PublishStatus } from '../src/common/enums';
|
||||
import { ENTITIES, Scene } from '../src/entities';
|
||||
import { Asset } from '../src/entities/asset.entity';
|
||||
import { CatalogService } from '../src/catalog/catalog.service';
|
||||
import { ScenesService } from '../src/scenes/scenes.service';
|
||||
import { StorageService } from '../src/storage/storage.service';
|
||||
|
||||
/**
|
||||
* **带真实数据库**的端到端验证(库内跑的是 sql.js,不需要外部 MySQL)。
|
||||
*
|
||||
* 这一层补上单元测试覆盖不到的两件事:
|
||||
*
|
||||
* 1. **实体注册**:`Scene` 必须真的在 DataSource 的 metadata 里 —— 生产事故就是
|
||||
* `src/entities/index.ts` 的 `ENTITIES` 漏了 `Scene`,导致启动正常、但第一次查它
|
||||
* 就抛 `No metadata for "Scene" was found.`,进而把 `catalog/export` 一起打挂。
|
||||
* 2. **上传落盘链路**:走**真实的** `StorageService`(multer diskStorage 的落盘形状:
|
||||
* 文件对象只有 `path`、没有 `buffer`)→ 归档 → 落库 → `published()` → `catalog/export`。
|
||||
*
|
||||
* 断言用的是真实 SQL(TypeORM 建表 + 插入 + 查询),不是 mock。
|
||||
*/
|
||||
|
||||
/**
|
||||
* sqljs 兼容垫片:SQLite 本身对列类型名非常宽松(只有 5 种 affinity),但 TypeORM 的
|
||||
* sqlite 驱动会按 `normalizeType` 的结果去校验 `column.type`,于是 MySQL 专有写法
|
||||
* (`char(64)` / `mediumtext`)会被判"不支持"。这里把它们映射成等价的 sqlite 类型,
|
||||
* 让**真实的实体**(Asset / Scene)能在内存库里建表 —— 只影响本测试进程。
|
||||
*/
|
||||
function installSqljsTypeShim(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { AbstractSqliteDriver } = require('typeorm/driver/sqlite-abstract/AbstractSqliteDriver');
|
||||
const aliases: Record<string, string> = {
|
||||
char: 'varchar',
|
||||
mediumtext: 'text',
|
||||
longtext: 'text',
|
||||
tinytext: 'text',
|
||||
enum: 'varchar',
|
||||
};
|
||||
const original = AbstractSqliteDriver.prototype.normalizeType;
|
||||
AbstractSqliteDriver.prototype.normalizeType = function patched(column: {
|
||||
type?: unknown;
|
||||
isPrimary?: boolean;
|
||||
isGenerated?: boolean;
|
||||
}) {
|
||||
const raw = typeof column?.type === 'string' ? column.type.toLowerCase() : '';
|
||||
// SQLite 的 AUTOINCREMENT 只允许出现在 `INTEGER PRIMARY KEY` 上,
|
||||
// 而本项目用的是 `bigint` 自增主键(MySQL 习惯)——测试里映射成 integer。
|
||||
if (raw === 'bigint' && column?.isPrimary && column?.isGenerated) return 'integer';
|
||||
if (aliases[raw]) return aliases[raw];
|
||||
return original.call(this, column);
|
||||
};
|
||||
}
|
||||
installSqljsTypeShim();
|
||||
|
||||
const storageRoot = mkdtempSync(join(tmpdir(), 'opendance-e2e-'));
|
||||
const uploadDir = mkdtempSync(join(tmpdir(), 'opendance-upload-'));
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(storageRoot, { recursive: true, force: true });
|
||||
rmSync(uploadDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tinyGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0', generator: 'opendance e2e' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ name: 'Floor_col' }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [{ count: 3 }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write('glTF', 0, 'ascii');
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||||
const chunkHeader = Buffer.alloc(8);
|
||||
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||
return Buffer.concat([header, chunkHeader, padded]);
|
||||
}
|
||||
|
||||
/** 模拟 multer 的 diskStorage 产物:只落盘、不带 buffer */
|
||||
function diskUpload(name: string) {
|
||||
const path = join(uploadDir, `${Date.now()}-${Math.random().toString(16).slice(2)}-${name}`);
|
||||
writeFileSync(path, tinyGlb());
|
||||
return { originalname: name, mimetype: 'model/gltf-binary', path, size: tinyGlb().length };
|
||||
}
|
||||
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: 'e2e 来源',
|
||||
sourceUrl: 'https://example.com/e2e',
|
||||
license: 'MIT',
|
||||
author: 'e2e',
|
||||
};
|
||||
|
||||
const CONFIG = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -3.5, z: 0 },
|
||||
wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' },
|
||||
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
camera: { mode: 'orbit', distance: 6, height: 2 },
|
||||
};
|
||||
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataSource = new DataSource({
|
||||
type: 'sqljs',
|
||||
entities: ENTITIES,
|
||||
synchronize: true,
|
||||
dropSchema: true,
|
||||
logging: false,
|
||||
});
|
||||
await dataSource.initialize();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await dataSource?.destroy();
|
||||
});
|
||||
|
||||
describe('实体注册(生产事故的根因)', () => {
|
||||
it('Scene 有 metadata 且 getRepository 不抛错', () => {
|
||||
expect(dataSource.hasMetadata(Scene)).toBe(true);
|
||||
expect(() => dataSource.getRepository(Scene)).not.toThrow();
|
||||
});
|
||||
|
||||
it('反向验证:ENTITIES 漏掉 Scene 时会复现生产上的报错原文', async () => {
|
||||
const broken = new DataSource({
|
||||
type: 'sqljs',
|
||||
// 故意漏掉 Scene —— 复现 src/entities/index.ts 曾经的写法
|
||||
entities: ENTITIES.filter((e) => e !== Scene),
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
});
|
||||
await broken.initialize();
|
||||
try {
|
||||
expect(broken.hasMetadata(Scene)).toBe(false);
|
||||
// 关键:**getRepository 不会抛**(TypeORM 0.3 是惰性的)——这正是"启动完全正常"
|
||||
// 的原因;错误要到**第一次真正查询**时才暴露,与生产现象完全一致。
|
||||
expect(() => broken.getRepository(Scene)).not.toThrow();
|
||||
await expect(broken.getRepository(Scene).find()).rejects.toThrow(
|
||||
/No metadata for "Scene" was found/,
|
||||
);
|
||||
} finally {
|
||||
await broken.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('场景上传 → 目录导出(真实数据库 + 真实落盘)', () => {
|
||||
it('diskStorage 形状的上传:归档、落库、published()、catalog/export 全部打通', async () => {
|
||||
const storage = new StorageService(
|
||||
{ storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
dataSource.getRepository(Asset),
|
||||
);
|
||||
await storage.onModuleInit();
|
||||
|
||||
const scenes = new ScenesService(
|
||||
dataSource.getRepository(Scene),
|
||||
storage,
|
||||
{ assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
);
|
||||
|
||||
// 1) 上传(直接导入 .glb)
|
||||
const upload = diskUpload('stage.glb');
|
||||
const scene = await scenes.create({ name: 'e2e 舞台', config: JSON.stringify(CONFIG), ...SOURCE }, upload as never);
|
||||
expect(scene.id).toBeGreaterThan(0);
|
||||
expect(scene.status).toBe(PublishStatus.Pending);
|
||||
expect(String(scene.conversionNote)).toContain('直接导入');
|
||||
expect(scene.config?.physics.gravity.y).toBeCloseTo(-3.5);
|
||||
expect(scene.config?.physics.wind.gustAmplitude).toBeCloseTo(4);
|
||||
expect(scene.config?.camera.mode).toBe('orbit');
|
||||
|
||||
// 2) 归档:GLB 已进 storage,临时目录里不再残留该文件
|
||||
const assetRows = await dataSource.getRepository(Asset).find({ where: { kind: AssetKind.Scene } });
|
||||
expect(assetRows.length).toBe(1);
|
||||
expect(assetRows[0].storedPath).toMatch(/\.glb$/);
|
||||
const tmpLeft = readdirSync(join(storageRoot, 'tmp'));
|
||||
expect(tmpLeft).not.toContain(upload.path.slice(upload.path.lastIndexOf('/') + 1));
|
||||
|
||||
// 3) 未发布时 published() 为空(不上架)
|
||||
expect(await scenes.published()).toEqual([]);
|
||||
|
||||
// 4) 发布后 published() 能取到,且物理参数完整
|
||||
await dataSource.getRepository(Scene).update({ id: scene.id }, { status: PublishStatus.Published });
|
||||
const published = await scenes.published();
|
||||
expect(published.length).toBe(1);
|
||||
expect(published[0].config?.physics.ground.friction).toBeCloseTo(0.8);
|
||||
expect(published[0].glbUrl).toMatch(/^\/static\//);
|
||||
|
||||
// 5) 目录导出带上场景,且指纹随之变化
|
||||
const repository = (rows: unknown[]) => ({ find: async () => rows });
|
||||
const catalog = new CatalogService(
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
repository([]) as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows } as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows } as never,
|
||||
{ toDtos: async (rows: unknown[]) => rows, resolveDefaultModelId: async () => null } as never,
|
||||
scenes,
|
||||
{ contractVersions: { 'rest-api': '0.5.0' } } as never,
|
||||
);
|
||||
|
||||
const bundle = await catalog.exportBundle();
|
||||
expect(bundle.scenes.map((s) => s.name)).toContain('e2e 舞台');
|
||||
expect(bundle.scenes[0].config?.physics.gravity.y).toBeCloseTo(-3.5);
|
||||
|
||||
const version = await catalog.version();
|
||||
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// 6) 场景子系统报告为可用(不再降级)
|
||||
expect(scenes.diagnostics()).toEqual({ available: true, error: null });
|
||||
|
||||
// 7) 删除场景:行被软删除,published() 不再返回
|
||||
await scenes.remove(scene.id);
|
||||
expect(await scenes.published()).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it('越界/非法参数在真实落库时也被收敛(不会把非法物理下发到客户端)', async () => {
|
||||
const storage = new StorageService(
|
||||
{ storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
dataSource.getRepository(Asset),
|
||||
);
|
||||
await storage.onModuleInit();
|
||||
const scenes = new ScenesService(
|
||||
dataSource.getRepository(Scene),
|
||||
storage,
|
||||
{ assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never,
|
||||
);
|
||||
|
||||
const scene = await scenes.create(
|
||||
{
|
||||
name: 'e2e 越界场景',
|
||||
config: JSON.stringify({
|
||||
physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' },
|
||||
camera: { mode: '无此模式' },
|
||||
}),
|
||||
...SOURCE,
|
||||
},
|
||||
diskUpload('clamped.glb') as never,
|
||||
);
|
||||
expect(scene.config?.physics.gravity.y).toBeCloseTo(-200);
|
||||
expect(scene.config?.physics.ground.friction).toBeCloseTo(1);
|
||||
expect(scene.config?.physics.colliders).toBe('auto');
|
||||
expect(scene.config?.camera.mode).toBe('follow');
|
||||
|
||||
// 落库后重新读出来仍是收敛后的值(transformer 往返正常)
|
||||
const reloaded = await scenes.findOne(scene.id);
|
||||
expect(reloaded.config?.physics.gravity.y).toBeCloseTo(-200);
|
||||
await scenes.remove(scene.id);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AssetKind } from '../src/common/enums';
|
||||
import { StorageService } from '../src/storage/storage.service';
|
||||
|
||||
/**
|
||||
* 上传落盘的两条通路都必须能处理「数据库里已有记录,但文件已经不在了」。
|
||||
*
|
||||
* 实测缺陷(T-WEB-017 期间发现):`persistTempFile`(动作 / 模型 / 场景上传走它)
|
||||
* 在去重命中时直接 `safeUnlink(tempPath)` 把刚上传的**唯一副本**删掉并返回旧记录;
|
||||
* 一旦记录指向的路径不存在(换过 `STORAGE_DIR`、只恢复了数据库、或手工删过文件),
|
||||
* 上传仍然返回 201,但后续动作预处理 500(`ENOENT ... .vmd`)、模型分析与缩略图
|
||||
* 静默失败。`persistBuffer` 早已有兜底,`persistTempFile` 漏了 —— 本用例把它钉住。
|
||||
*/
|
||||
|
||||
let root: string;
|
||||
let assets: Array<Record<string, unknown>>;
|
||||
|
||||
function buildService(): StorageService {
|
||||
const repo = {
|
||||
findOne: vi.fn(async ({ where }: { where: { sha256?: string } }) =>
|
||||
assets.find((a) => a.sha256 === where.sha256) ?? null),
|
||||
create: (entity: Record<string, unknown>) => entity,
|
||||
save: vi.fn(async (entity: Record<string, unknown>) => {
|
||||
const saved = { id: assets.length + 1, ...entity };
|
||||
assets.push(saved);
|
||||
return saved;
|
||||
}),
|
||||
};
|
||||
// StorageService 的构造函数只依赖配置与仓储,这里用最小替身
|
||||
return new StorageService({ storageDir: root } as never, repo as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'opendance-storage-'));
|
||||
assets = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('上传落盘:去重与文件缺失兜底', () => {
|
||||
it('首次上传:写入文件并登记记录', async () => {
|
||||
const service = buildService();
|
||||
const temp = join(root, 'upload.tmp');
|
||||
writeFileSync(temp, 'vmd-bytes');
|
||||
|
||||
const stored = await service.persistTempFile(AssetKind.Motion, temp, 'a.vmd', 'application/octet-stream');
|
||||
expect(existsSync(service.absolutePath(stored.asset))).toBe(true);
|
||||
expect(readFileSync(service.absolutePath(stored.asset), 'utf8')).toBe('vmd-bytes');
|
||||
expect(existsSync(temp)).toBe(false); // 临时文件已被移走
|
||||
});
|
||||
|
||||
it('去重命中且文件仍在:丢弃临时文件、复用记录', async () => {
|
||||
const service = buildService();
|
||||
const first = join(root, 'a.tmp');
|
||||
writeFileSync(first, 'same-bytes');
|
||||
const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream');
|
||||
|
||||
const second = join(root, 'b.tmp');
|
||||
writeFileSync(second, 'same-bytes');
|
||||
const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream');
|
||||
|
||||
expect(again.asset.id).toBe(stored.asset.id);
|
||||
expect(existsSync(second)).toBe(false);
|
||||
expect(assets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('去重命中但**文件已丢失**:按原路径补写,而不是删掉唯一副本', async () => {
|
||||
const service = buildService();
|
||||
const first = join(root, 'a.tmp');
|
||||
writeFileSync(first, 'lost-bytes');
|
||||
const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream');
|
||||
const absolute = service.absolutePath(stored.asset);
|
||||
|
||||
// 模拟「换过 STORAGE_DIR / 只恢复了数据库」:记录还在,文件没了
|
||||
rmSync(absolute, { force: true });
|
||||
expect(existsSync(absolute)).toBe(false);
|
||||
|
||||
const second = join(root, 'b.tmp');
|
||||
writeFileSync(second, 'lost-bytes');
|
||||
const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream');
|
||||
|
||||
expect(again.asset.id).toBe(stored.asset.id);
|
||||
expect(existsSync(absolute)).toBe(true); // 文件被补写回来
|
||||
expect(readFileSync(absolute, 'utf8')).toBe('lost-bytes');
|
||||
expect(existsSync(second)).toBe(false); // 临时文件已转移
|
||||
});
|
||||
|
||||
it('persistBuffer 的内存通路同样兜底(回归保护)', async () => {
|
||||
const service = buildService();
|
||||
const content = Buffer.from('generated-bytes');
|
||||
const stored = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson');
|
||||
const absolute = service.absolutePath(stored.asset);
|
||||
rmSync(absolute, { force: true });
|
||||
|
||||
const again = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson');
|
||||
expect(again.asset.id).toBe(stored.asset.id);
|
||||
expect(existsSync(absolute)).toBe(true);
|
||||
});
|
||||
|
||||
it('空文件被拒绝(不会登记成一条空资产)', async () => {
|
||||
const service = buildService();
|
||||
const temp = join(root, 'empty.tmp');
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(temp, '');
|
||||
await expect(
|
||||
service.persistTempFile(AssetKind.Motion, temp, 'empty.vmd', 'application/octet-stream'),
|
||||
).rejects.toThrow(/空/);
|
||||
expect(assets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user