Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3738bd91b9 | ||
|
|
50073b97cf | ||
|
|
f2e231c0b2 | ||
|
|
584b7eaca9 |
@@ -10,7 +10,29 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
|
||||
|
||||
## 状态
|
||||
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.5.0**(契约 `rest-api 0.4.0`、`data-models 0.6.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.3.0`)。
|
||||
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.1**(契约 `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.1 修订(**契约补齐 + 文档补齐**,契约 `websocket-keypoints` 0.1.0 -> 0.2.0):
|
||||
|
||||
1. **补齐 `rest-api.yaml` 里漏写的场景结构**:`catalog/export` 自 v0.7.0 起就返回 `scenes[]`,`data-models.md` 7 也已冻结,但 `CatalogBundle` 漏了这个字段、`components` 里也没有 `Scene`/`SceneConfig`。本次只补规格、**不改线格式**,故六个契约版本号不变(rest-api 仍 0.5.0);
|
||||
2. **契约 `websocket-keypoints` -> 0.2.0**(T-WEB-016,与 Android `T-AND-011` 的"两端一起改"配套):新增 1.1 **保活**(接收端必须声明 `silentTimeoutMs`/`heartbeatIntervalMs`,且**心跳 < 静默超时的一半**)、3.1 `hello.deviceId`、3.2/3.3 welcome/config 携带保活字段、3.6 应用层 `ping`/`pong`、5 **设备去重**、7 **关闭码与重连策略**、7.1 退避与 ±20% 抖动、8 **连接状态机**(两端同名)。子协议名与二进制帧 `version` 均不变,**向后兼容**;
|
||||
3. **`deploy/proxy/README.md` 新增第九节**:托管 Gitea 的 IIS 站点附件上传上限(与一~五节的 Web 服务反代是**两个不同站点**)。实测 30.5 MB / 40 MB 返回 HTTP 413,根因是 IIS 默认 `maxAllowedContentLength = 30,000,000` 字节;给出放宽到 256 MB 的 IIS/ARR/Gitea `app.ini`/nginx 四处配置与复验命令 —— 这正是 Windows 端 v0.1.0 被拆成 `zip.part0/1/2` 的根因;
|
||||
4. **补齐 `tasks/011` ~ `tasks/016`**:此前 T-WEB-011~015 已在代码中落地但缺少任务说明书与 `project.yaml` 条目,本次一并补上(T-WEB-011 版本号展示 / 012 VRM 优先 / 013 动作标准与骨骼重绑定 / 014 动作预览 / 015 场景管理 / 016 契约 0.2.0);
|
||||
5. 规模 **18 文件 / 246 项**测试,`contracts:check` 通过、`check-routes` 35 条路由一致。
|
||||
|
||||
v0.7.0 修订(**管理端场景页**,补齐场景端到端可用):
|
||||
|
||||
1. **管理端「场景管理」页**(`/scenes`):列表(40px 缩略图 + **物理摘要**「重力 -9.8 | 风 5.0m/s + 阵风 2.5m/s@0.5Hz」+ 状态)、上传表单(**GLB 文件 + 名称 + 来源五元组 + 物理参数**:重力、风基础向量、阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光)、详情面板(复用 3D 预览 + **参数可视化编辑并保存**);
|
||||
2. 新增 `physicsSummary()` 纯函数与场景页样式(两栏自适应);路由 `/scenes` + 侧栏「场景管理」入口;
|
||||
3. 新增 2 项测试;规模 **19 文件 / 245 项**。
|
||||
|
||||
v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0):
|
||||
|
||||
1. **新增场景管理**(T-WEB-015):场景**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM);上传 `.glb/.gltf` 直接导入,`.zip/.pmx/.fbx` 由服务端转 GLB;
|
||||
2. **场景物理参数**(`SceneConfig` 冻结在契约 data-models 7.1):重力向量、**风向动态**(基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源(auto/mesh/none)、环境光/雾、出生点(≤16)、相机;**服务端只存参数、不跑物理**(仿真在客户端 Godot);
|
||||
3. **入库即收敛**:越界夹紧、非法枚举回退默认值,保证客户端拿到的参数一定可解释;`scenes.config`/`stats` 用 MEDIUMTEXT;
|
||||
4. **端点**:`GET/POST /api/scenes`、`GET/PATCH/DELETE /api/scenes/{id}`(共 35 条路由,`check-routes` 通过);审核复用 `/api/reviews/scene/:id/approve|reject`(上传一律 `pending`);`catalog/export` 增 `scenes[]` 且指纹纳入场景变更;
|
||||
5. 缩略图复用模型光栅化器 + 分析复用模型检查器;新增 6 项测试;规模 **19 文件 / 243 项**。
|
||||
|
||||
v0.5.0 修订(**动作预览**,契约 data-models -> 0.6.0):
|
||||
|
||||
@@ -101,15 +123,15 @@ v0.2.0 主要变更:
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.5.0/opendance-web-v0.5.0.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.5.0/SHA256SUMS
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.1/opendance-web-v0.7.1.tar.gz
|
||||
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.1/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
```
|
||||
|
||||
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.5.0.tar.gz && cd opendance-web-v0.5.0
|
||||
tar -xzf opendance-web-v0.7.1.tar.gz && cd opendance-web-v0.7.1
|
||||
bash deploy/init-env.sh
|
||||
```
|
||||
|
||||
@@ -179,8 +201,8 @@ FLUSH PRIVILEGES;
|
||||
### 5. 部署步骤
|
||||
|
||||
```bash
|
||||
tar -xzf opendance-web-v0.5.0.tar.gz
|
||||
cd opendance-web-v0.5.0
|
||||
tar -xzf opendance-web-v0.7.1.tar.gz
|
||||
cd opendance-web-v0.7.1
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
```
|
||||
@@ -353,12 +375,12 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 237 项单元测试 / 18 个文件(VMD 31、模型压缩包 27、
|
||||
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、MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+41
-7
@@ -1,5 +1,5 @@
|
||||
================================================================
|
||||
OpenDance Studio -- Web 管理服务端 v0.5.0 使用说明
|
||||
OpenDance Studio -- Web 管理服务端 v0.7.1 使用说明
|
||||
================================================================
|
||||
|
||||
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
|
||||
@@ -8,6 +8,40 @@ OpenDance Studio -- Web 管理服务端 v0.5.0 使用说明
|
||||
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
|
||||
连接信息通过 .env 注入。
|
||||
|
||||
v0.7.1 修订 - 契约补齐 + 文档补齐(契约 websocket-keypoints 0.1.0 -> 0.2.0)
|
||||
1. 补齐 rest-api.yaml 里漏写的场景结构(CatalogBundle.scenes[] + Scene +
|
||||
SceneConfig): catalog/export 自 v0.7.0 起就返回 scenes[],规格漏写会导致
|
||||
两端对字段边界的理解不一致。只补规格、不改线格式,六个契约版本号不变。
|
||||
2. 契约 websocket-keypoints -> 0.2.0(与 Android T-AND-011 配套):
|
||||
1.1 保活(接收端声明 silentTimeoutMs/heartbeatIntervalMs,心跳必须小于
|
||||
静默超时的一半)、3.1 hello.deviceId、3.6 应用层 ping/pong、
|
||||
5 设备去重、7 关闭码与重连策略、7.1 退避与 ±20% 抖动、8 连接状态机。
|
||||
子协议名与帧 version 均不变,向后兼容。
|
||||
3. deploy/proxy/README.md 新增第九节: 托管 Gitea 的 IIS 站点附件上传上限
|
||||
(与一~五节的 Web 服务反代是两个不同站点)。实测 30.5/40 MB 返回 HTTP 413,
|
||||
根因是 IIS 默认 maxAllowedContentLength = 30,000,000 字节;给出放宽到
|
||||
256 MB 的配置与复验命令。这是 Windows 端 v0.1.0 被拆成 zip.part0/1/2 的根因。
|
||||
4. 补齐 tasks/011 ~ 016 与 project.yaml 条目(此前已落地但缺任务说明书)。
|
||||
5. 规模 18 文件 / 246 项测试; contracts:check 通过, 35 条路由一致。
|
||||
|
||||
v0.7.0 修订 - 管理端场景页(补齐场景端到端可用)
|
||||
1. 新增「场景管理」页(/scenes): 列表(40px 缩略图 + 物理摘要 + 状态)、
|
||||
上传(GLB 文件 + 名称 + 来源五元组 + 物理参数: 重力/风/阵风/地面/碰撞体/环境光)、
|
||||
详情(复用 3D 预览 + 参数可视化编辑并保存)。
|
||||
2. 新增 physicsSummary() 纯函数、场景页样式与侧栏入口。
|
||||
3. 新增 2 项测试; 规模 19 文件 / 245 项。
|
||||
|
||||
v0.6.0 修订 - 场景管理(契约 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
|
||||
1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入,
|
||||
.zip/.pmx/.fbx 由服务端转 GLB。
|
||||
2. 场景物理参数(SceneConfig, 契约 data-models 7.1): 重力向量、风向动态
|
||||
(基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源、
|
||||
环境光/雾、出生点(<=16)、相机; 服务端只存参数不跑物理(仿真在客户端)。
|
||||
3. 入库即收敛: 越界夹紧、非法枚举回退默认; scenes.config/stats 用 MEDIUMTEXT。
|
||||
4. 端点: GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}(共 35 条路由);
|
||||
审核复用 /api/reviews/scene/:id/approve|reject; catalog/export 增 scenes[]。
|
||||
5. 新增 6 项测试; 规模 19 文件 / 243 项。
|
||||
|
||||
v0.5.0 修订 - 动作预览(契约 data-models -> 0.6.0)
|
||||
1. 动作骨架缩略图(服务端): motion-thumbnail.ts 把关键点序列画成正视图骨架 PNG,
|
||||
随 /preprocess 生成并落库(motions.thumbnail_asset_id, 迁移 AddMotionThumbnail),
|
||||
@@ -125,11 +159,11 @@ v0.2.1 修订
|
||||
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
|
||||
本项目不会创建数据库本身
|
||||
3. 取得发布包并校验(地址用你们自己的制品库替换)
|
||||
curl -LO https://<制品库地址>/opendance-web-v0.5.0.tar.gz
|
||||
curl -LO https://<制品库地址>/opendance-web-v0.7.1.tar.gz
|
||||
curl -LO https://<制品库地址>/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS # 期望输出 "OK"
|
||||
4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
|
||||
tar -xzf opendance-web-v0.5.0.tar.gz && cd opendance-web-v0.5.0
|
||||
tar -xzf opendance-web-v0.7.1.tar.gz && cd opendance-web-v0.7.1
|
||||
bash deploy/init-env.sh
|
||||
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
|
||||
bash deploy/install.sh
|
||||
@@ -187,8 +221,8 @@ v0.2.1 修订
|
||||
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
|
||||
|
||||
部署步骤
|
||||
tar -xzf opendance-web-v0.5.0.tar.gz
|
||||
cd opendance-web-v0.5.0
|
||||
tar -xzf opendance-web-v0.7.1.tar.gz
|
||||
cd opendance-web-v0.7.1
|
||||
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
|
||||
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
|
||||
|
||||
@@ -363,12 +397,12 @@ deploy/.env 初始化引导(deploy/init-env.sh)
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 237 项单元测试 / 18 个文件(VMD 31、模型压缩包 24、
|
||||
npm run test # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
|
||||
# 模型检查器 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)
|
||||
# MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
# OpenDance Studio 契约版本
|
||||
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||
rest-api: 0.4.0
|
||||
websocket-keypoints: 0.1.0
|
||||
data-models: 0.6.0
|
||||
rest-api: 0.5.0
|
||||
websocket-keypoints: 0.2.0
|
||||
data-models: 0.7.0
|
||||
skeleton-topology: 0.2.0
|
||||
model-formats: 0.5.0
|
||||
database: 0.3.0
|
||||
database: 0.4.0
|
||||
|
||||
@@ -83,7 +83,7 @@ rejected 已驳回
|
||||
| `durationMs` | number\|null | | |
|
||||
| `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` |
|
||||
| `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或.npz 的 JSON 封装) |
|
||||
| `thumbnailUrl` | string\|null | 读 | 骨架缩略图(`/preprocess` 时生成,动作管理列表展示;未生成时为 null)(v0.6.0) |
|
||||
| `thumbnailUrl` | string\|null | 读 | 骨架缩略图(`/preprocess` 时生成,动作管理列表展示;未生成时为 null)(v0.7.0) |
|
||||
| `keypointFrames` | number\|null | 读 | 关键点帧数 |
|
||||
| `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` |
|
||||
| `preprocessError` | string\|null | 读 | |
|
||||
@@ -163,3 +163,48 @@ rejected 已驳回
|
||||
|
||||
- 仅包含 `status = 'published'` 的记录。
|
||||
- `defaultModelId` 由 4.1 的规则解析得出,客户端无需自行实现优先级。
|
||||
|
||||
---
|
||||
|
||||
## 7. `Scene`(场景,GLB 为主)(v0.7.0)
|
||||
|
||||
场景与模型的区别:**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM),
|
||||
并携带**物理参数**(服务端只存参数、不跑物理;仿真在客户端)。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--|--|--|--|
|
||||
| `id` | number | 读 | |
|
||||
| `name` | string | ✅ | 场景名称,<=200 |
|
||||
| `glbUrl` | string\|null | 读 | 场景 GLB 下载地址 |
|
||||
| `thumbnailUrl` | string\|null | 读 | 缩略图(服务端离线渲染) |
|
||||
| `fileSize` | number\|null | 读 | |
|
||||
| `fileSha256` | string\|null | 读 | |
|
||||
| `conversionNote` | string\|null | 读 | 由 `.zip`/`.pmx`/`.fbx` 转换而来时的说明 |
|
||||
| `config` | `SceneConfig` | ✅ | 物理 / 环境 / 出生点 / 相机,见 7.1 |
|
||||
| `stats` | object\|null | 读 | 场景 GLB 分析(面数 / 网格 / 材质 / warnings) |
|
||||
| `sourceType` `sourceName` `sourceUrl` `license` `author` | string | ✅ | 来源五元组 |
|
||||
| `status` | enum | 读 | 上传一律 `pending`,经审核后 `published` |
|
||||
|
||||
### 7.1 `SceneConfig`(冻结)
|
||||
|
||||
```yaml
|
||||
physics:
|
||||
gravity: { x: 0, y: -9.8, z: 0 } # 米/秒^2,各分量 ±200
|
||||
wind:
|
||||
base: { x: 0, y: 0, z: 0 } # 基础风向量(米/秒,±100)
|
||||
gustAmplitude: 0 # 阵风幅度 0~100
|
||||
gustFrequency: 0.2 # 阵风频率 Hz,0.01~5
|
||||
gustDirection: { x: 1, y: 0, z: 0 } # 阵风方向(±1)
|
||||
space: world | local # 风的作用坐标系
|
||||
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 } # 0~1 / 0~1 / 0~89 度
|
||||
colliders: auto | mesh | none # auto=按 GLB 命名约定,mesh=三角网
|
||||
environment:
|
||||
ambientColor: "#8fb4ff" # #RRGGBB
|
||||
ambientIntensity: 1 # 0~4
|
||||
fogDensity: 0 # 0~1(0=关闭)
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }] # 最多 16 个
|
||||
camera: { mode: follow | fixed | orbit, distance: 4.5, height: 1.6 }
|
||||
```
|
||||
|
||||
服务端入库时对每项做**边界收敛**(越界夹紧、非法枚举回退默认值),
|
||||
保证客户端拿到的参数一定可解释;`catalog/export` 的 `scenes[]` 直接下发该结构。
|
||||
|
||||
+22
-1
@@ -36,7 +36,7 @@ DB_LOGGING=false
|
||||
- 每个 migration 必须可逆(提供 `down` 方法)
|
||||
- 迁移文件名格式:`<timestamp>-<kebab-name>.ts`
|
||||
|
||||
## 增量迁移(v0.2.0 / v0.3.0)
|
||||
## 增量迁移(v0.2.0 / v0.4.0)
|
||||
|
||||
| 迁移 | 变更 |
|
||||
|--|--|
|
||||
@@ -63,3 +63,24 @@ draft ──submit──▶ pending ──approve──▶ published
|
||||
|
||||
- `published` 才可被 Windows 客户端 / Android App 消费
|
||||
- `/api/catalog/export` 只导出 `published` 记录
|
||||
|
||||
---
|
||||
|
||||
## 场景表 `scenes`(v0.4.0 / T-WEB-015)
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|--|--|--|
|
||||
| `id` | bigint PK | |
|
||||
| `name` | varchar(200) | 场景名称 |
|
||||
| `glb_asset_id` | bigint NULL | 场景 GLB(以 GLB 为主,不转 VRM) |
|
||||
| `thumbnail_asset_id` | bigint NULL | 缩略图 |
|
||||
| `config` | **mediumtext** NULL | `SceneConfig` JSON(物理 / 环境 / 出生点 / 相机) |
|
||||
| `stats` | **mediumtext** NULL | 场景 GLB 分析结果(复用模型检查器) |
|
||||
| `file_size` / `file_sha256` / `conversion_note` | - | 与 models 同义 |
|
||||
| `source_type` `source_name` `source_url` `license` `author` | - | 来源五元组(必填) |
|
||||
| `status` | varchar(16) | 上传一律 `pending`,审核后 `published`/`rejected` |
|
||||
| `created_at` `updated_at` `deleted_at` | datetime | 软删除 |
|
||||
|
||||
* `config` / `stats` 用 **MEDIUMTEXT**(TEXT 的 64KB 上限在 `models.stats` 上踩过坑);
|
||||
* 迁移:`AddScenes1789650000000`(可逆,`down` 为 `DROP TABLE scenes`);
|
||||
* 状态机与审核复用既有约定(审核走 `/api/reviews/scene/:id/approve|reject`)。
|
||||
|
||||
+157
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: OpenDance Studio Web 管理服务端 API
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
description: |
|
||||
OpenDance Studio 的唯一数据源与契约权威。
|
||||
契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。
|
||||
@@ -594,6 +594,70 @@ paths:
|
||||
contractVersion: { type: object, additionalProperties: { type: string } }
|
||||
revision: { type: string, description: 目录内容 sha256,变化即需重新拉取 }
|
||||
|
||||
/api/scenes:
|
||||
get:
|
||||
tags: [scenes]
|
||||
summary: 场景列表(v0.5.0)
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Page'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- name: q
|
||||
in: query
|
||||
schema: { type: string }
|
||||
description: 按 name/author 模糊匹配
|
||||
- name: status
|
||||
in: query
|
||||
schema: { type: string, enum: [draft, pending, published, rejected] }
|
||||
responses:
|
||||
'200':
|
||||
description: 分页结果 { items, total, page, pageSize }
|
||||
post:
|
||||
tags: [scenes]
|
||||
summary: 上传场景(multipart:file + name + 来源五元组 + 可选 config)
|
||||
description: |
|
||||
`file` 以 **.glb 为主**(直接导入,不重编码);`.gltf` 直接导入;
|
||||
`.zip` / `.pmx` / `.fbx` 由服务端转换为 GLB(场景不做 humanoid 重绑定、不转 VRM)。
|
||||
`config` 为 JSON 字符串(或平铺字段 gravity / wind / environment / spawnPoints / camera),
|
||||
含物理参数:重力向量、风向动态(基础风 + 阵风)、地面摩擦/弹性/坡度、碰撞体来源。
|
||||
服务端**只存参数、不跑物理**(仿真在客户端)。上传一律 status=pending。
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
|
||||
properties:
|
||||
file: { type: string, format: binary }
|
||||
name: { type: string }
|
||||
config: { type: string, description: SceneConfig 的 JSON 字符串 }
|
||||
sourceType: { type: string }
|
||||
sourceName: { type: string }
|
||||
sourceUrl: { type: string }
|
||||
license: { type: string }
|
||||
author: { type: string }
|
||||
responses:
|
||||
'201':
|
||||
description: 已入库(pending)
|
||||
/api/scenes/{id}:
|
||||
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||
get:
|
||||
tags: [scenes]
|
||||
summary: 场景详情
|
||||
responses:
|
||||
'200': { description: Scene }
|
||||
'404': { description: 不存在 }
|
||||
patch:
|
||||
tags: [scenes]
|
||||
summary: 更新场景名称 / 物理参数(状态变更走 /api/reviews)
|
||||
responses:
|
||||
'200': { description: Scene }
|
||||
'400': { description: 参数非法或试图直接改状态 }
|
||||
delete:
|
||||
tags: [scenes]
|
||||
summary: 删除场景(软删除)
|
||||
responses:
|
||||
'204': { description: 已删除 }
|
||||
|
||||
components:
|
||||
parameters:
|
||||
Id:
|
||||
@@ -876,4 +940,96 @@ components:
|
||||
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
||||
scenes:
|
||||
type: array
|
||||
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
|
||||
items: { $ref: '#/components/schemas/Scene' }
|
||||
defaultModelId: { type: integer, format: int64, nullable: true }
|
||||
|
||||
Scene:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SourceFields'
|
||||
- type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
name: { type: string }
|
||||
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
|
||||
thumbnailUrl: { type: string, nullable: true }
|
||||
fileSize: { type: integer, nullable: true }
|
||||
fileSha256: { type: string, nullable: true }
|
||||
conversionNote:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
|
||||
config:
|
||||
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
|
||||
nullable: true
|
||||
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
|
||||
stats:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
|
||||
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
|
||||
SceneConfig:
|
||||
type: object
|
||||
description: >-
|
||||
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
|
||||
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
|
||||
properties:
|
||||
physics:
|
||||
type: object
|
||||
properties:
|
||||
gravity:
|
||||
type: object
|
||||
description: 米/秒^2,各分量 ±200
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
wind:
|
||||
type: object
|
||||
properties:
|
||||
base: { $ref: '#/components/schemas/Vec3' }
|
||||
gustAmplitude: { type: number, description: 0~100 }
|
||||
gustFrequency: { type: number, description: Hz,0.01~5 }
|
||||
gustDirection: { $ref: '#/components/schemas/Vec3' }
|
||||
space: { type: string, enum: [world, local] }
|
||||
ground:
|
||||
type: object
|
||||
properties:
|
||||
friction: { type: number, description: 0~1 }
|
||||
restitution: { type: number, description: 0~1 }
|
||||
slopeLimit: { type: number, description: 0~89 度 }
|
||||
colliders: { type: string, enum: [auto, mesh, none] }
|
||||
environment:
|
||||
type: object
|
||||
properties:
|
||||
ambientColor: { type: string, description: '#RRGGBB' }
|
||||
ambientIntensity: { type: number, description: 0~4 }
|
||||
fogDensity: { type: number, description: 0~1,0=关闭 }
|
||||
spawnPoints:
|
||||
type: array
|
||||
maxItems: 16
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
|
||||
camera:
|
||||
type: object
|
||||
properties:
|
||||
mode: { type: string, enum: [follow, fixed, orbit] }
|
||||
distance: { type: number, description: 0.5~50 }
|
||||
height: { type: number, description: 0~20 }
|
||||
|
||||
Vec3:
|
||||
type: object
|
||||
properties:
|
||||
x: { type: number }
|
||||
y: { type: number }
|
||||
z: { type: number }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
|
||||
# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0
|
||||
|
||||
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
|
||||
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
||||
@@ -14,6 +14,22 @@ ws://<windows-host>:8765/pose
|
||||
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
||||
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
|
||||
|
||||
### 1.1 保活(v0.2.0 新增,向后兼容)
|
||||
|
||||
接收端有**静默超时**:超过 `silentTimeoutMs` 没有收到任何数据帧或文本帧即断开
|
||||
(`close(1001, "timeout")`)。为此:
|
||||
|
||||
- 接收端必须在 `welcome`(以及需要变更时的 `config`)中声明
|
||||
`silentTimeoutMs` 与期望的 `heartbeatIntervalMs`;
|
||||
- 发送端按 `heartbeatIntervalMs` 周期发送 `ping`(见 §3.6),
|
||||
且 **`heartbeatIntervalMs` 必须小于 `silentTimeoutMs` 的一半**;
|
||||
- 发送端每帧关键点与每秒 `stats` 同样算作活动,`ping` 只是兜底,
|
||||
保证「画面里没有人 / 相机暂停」时连接不会被误判掉线。
|
||||
|
||||
> 子协议名与二进制帧 `version` 均**不变**:本版本只**新增可选**控制字段与
|
||||
> 一种新的控制类型,遵循 §5 的兼容规则;仅实现 v0.1.0 的一端仍可正常互通
|
||||
> (它不发 `ping`、不读 `silentTimeoutMs`,但每帧与每秒 stats 已足够保活)。
|
||||
|
||||
## 2. 二进制帧:239 字节(冻结)
|
||||
|
||||
**小端序(little-endian)**。
|
||||
@@ -76,26 +92,54 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{
|
||||
"type": "hello",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"deviceId": "a1b2c3d4e5f60718",
|
||||
"device": "Pixel 8",
|
||||
"appVersion": "0.1.0",
|
||||
"appVersion": "0.2.0",
|
||||
"cameraFacing": "front",
|
||||
"targetFps": 30,
|
||||
"resolution": "640x480"
|
||||
}
|
||||
```
|
||||
|
||||
- `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识
|
||||
(建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。
|
||||
接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接,
|
||||
设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。
|
||||
未提供 `deviceId` 时接收端退回按连接计数。
|
||||
|
||||
### 3.2 `welcome` - Windows -> Android
|
||||
|
||||
```json
|
||||
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
|
||||
{
|
||||
"type": "welcome",
|
||||
"protocol": "opendance.pose.v1",
|
||||
"sessionId": "uuid",
|
||||
"expectedFps": 30,
|
||||
"silentTimeoutMs": 15000,
|
||||
"heartbeatIntervalMs": 5000
|
||||
}
|
||||
```
|
||||
|
||||
- `silentTimeoutMs` / `heartbeatIntervalMs`(v0.2.0 新增):见 §1.1。
|
||||
发送端若未读到这两个字段,应使用保守默认值(超时 5000、心跳 1000)。
|
||||
|
||||
### 3.3 `config` - Windows -> Android,运行中调整
|
||||
|
||||
```json
|
||||
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
|
||||
{
|
||||
"type": "config",
|
||||
"mirror": true,
|
||||
"targetFps": 30,
|
||||
"upperBodyOnly": false,
|
||||
"resolution": "640x480",
|
||||
"silentTimeoutMs": 15000,
|
||||
"heartbeatIntervalMs": 5000
|
||||
}
|
||||
```
|
||||
|
||||
- `silentTimeoutMs` / `heartbeatIntervalMs` 为 v0.2.0 新增的可选字段,
|
||||
接收端在运行中调整保活参数时一并下发。
|
||||
|
||||
### 3.4 `stats` - Android -> Windows,每秒一次
|
||||
|
||||
```json
|
||||
@@ -108,6 +152,31 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
{ "type": "bye", "reason": "user_stop" }
|
||||
```
|
||||
|
||||
`bye` 表示**主动**结束:发出方在发送后立即关闭连接,接收方据此关闭且
|
||||
**不得**自动重连(见 §6)。
|
||||
|
||||
### 3.6 `ping` / `pong` - 保活(v0.2.0 新增)
|
||||
|
||||
Android -> Windows:
|
||||
|
||||
```json
|
||||
{ "type": "ping", "sentAtMs": 1730000000000 }
|
||||
```
|
||||
|
||||
Windows -> Android(可选回复,用于 RTT):
|
||||
|
||||
```json
|
||||
{ "type": "pong", "sentAtMs": 1730000000000, "serverAtMs": 1730000000042 }
|
||||
```
|
||||
|
||||
- `sentAtMs` 为发送方的本地毫秒时间戳,原样回传;发送端用
|
||||
`now - sentAtMs` 估算 RTT。
|
||||
- 接收端收到任何一帧(二进制或文本)都算活动,`ping` 无需额外应答即可保活;
|
||||
`pong` 只用于诊断。
|
||||
- **不要**依赖 WebSocket 协议层的 ping 帧(`Sec-WebSocket-Ping`):它由底层
|
||||
库自动处理,**不会**暴露给应用层,因此不能被接收端的静默超时逻辑看到。
|
||||
这也是必须定义应用层 `ping` 的原因。
|
||||
|
||||
## 4. JSON 调试帧(可选,仅开发期)
|
||||
|
||||
```json
|
||||
@@ -123,9 +192,68 @@ func decode(buf: PackedByteArray) -> Dictionary:
|
||||
|
||||
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
||||
|
||||
## 5. 兼容性规则
|
||||
## 5. 设备去重(v0.2.0 新增)
|
||||
|
||||
接收端按 §3.1 的 `deviceId` 去重:
|
||||
|
||||
| 情况 | 行为 |
|
||||
|--|--|
|
||||
| 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 |
|
||||
| `deviceId` 缺失或为空 | 不做去重,按连接计数 |
|
||||
| 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 |
|
||||
|
||||
这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让
|
||||
「已连接 N 台」抖动。
|
||||
|
||||
## 6. 兼容性规则
|
||||
|
||||
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
||||
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
||||
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
||||
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
|
||||
|
||||
## 7. 关闭码与重连策略(v0.2.0 新增,两端必须一致)
|
||||
|
||||
| 关闭码 / 情形 | 含义 | 发送端是否自动重连 |
|
||||
|--|--|--|
|
||||
| `1000` 正常关闭 | 任一方主动结束(含收到 `bye`) | **否** |
|
||||
| `1001` going away | 接收端主动关闭(服务重启、`stop()`、静默超时踢人) | **否**(见下方说明) |
|
||||
| `1002` 协议错误 | 子协议 / 协议版本不匹配 | **否**(重连也不会成功) |
|
||||
| 连接失败 / 超时 / `1006` 异常断开 | 网络抖动、对端进程退出、Wi-Fi 切换 | **是** |
|
||||
| 客户端主动 `disconnect()` | 用户点「结束推流」 | **否** |
|
||||
|
||||
- 静默超时由接收端发 `close(1001, "timeout")`;正常情况下发送端按 §1.1 保活,
|
||||
**不应**触发它。若仍频繁触发,属于保活参数或实现缺陷,需按 §7 排查,
|
||||
而不是靠重连掩盖。
|
||||
- 接收端因「同一 `deviceId` 被新连接替换」关闭旧连接时用 `1000`,
|
||||
旧连接不重连。
|
||||
|
||||
### 7.1 退避参数(冻结)
|
||||
|
||||
```
|
||||
第 n 次重连等待 = min(500ms * 2^(n-1), 15000ms) * (1 ± 20% 抖动)
|
||||
即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶),每次叠加 ±20% 抖动
|
||||
连接成功后计数清零
|
||||
```
|
||||
|
||||
## 8. 连接状态机(v0.2.0 新增,两端同名,便于对照日志)
|
||||
|
||||
```
|
||||
idle → resolving → connecting → handshaking → streaming → degraded
|
||||
↑ │
|
||||
└──────── reconnecting ←───────────┘
|
||||
stopped ← 主动停止 / 1000 / 1001 / 1002(终态,不再重连)
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 进入条件(发送端) |
|
||||
|--|--|--|
|
||||
| `idle` | 尚未开始连接 | 初始状态 |
|
||||
| `resolving` | 解析主机地址(DNS / 局域网发现) | `connect()` 且主机名非 IP 字面量 |
|
||||
| `connecting` | TCP + WebSocket 升级中 | 已发起请求 |
|
||||
| `handshaking` | 已连接,等待 `welcome` | `onOpen`,已发送 `hello` |
|
||||
| `streaming` | 正常推流 | 收到 `welcome` 或已成功发送首帧 |
|
||||
| `degraded` | 仍连接但异常(丢弃帧增长 / 发送失败) | `send` 失败或持续丢帧 |
|
||||
| `reconnecting` | 退避等待中 | 非主动断开 |
|
||||
| `stopped` | 已停止(终态) | 主动停止 / 1000 / 1001 / 1002 |
|
||||
|
||||
接收端(Windows)在日志与状态面板中使用同名状态描述每个客户端连接。
|
||||
|
||||
@@ -204,6 +204,7 @@ curl -i http://<反代地址>/api/catalog/version
|
||||
| 客户端接口全 404 | 把客户端的转发规则指到了管理端口之外,或误把 `/api` 前缀重写掉;客户端应转发到软件 API 端口 |
|
||||
| 管理页面 404 | 把浏览器的转发规则指到了**软件 API 端口**(该端口不提供管理页面,这是有意设计);改指 `ADMIN_PORT` |
|
||||
| 两个端口都转发到同一条规则 | 若确实想合并两个端口,设置 `ADMIN_PORT=PORT` 走单端口模式,而不是让反代去猜 |
|
||||
| **Gitea 的 Release 附件上传 413** | 这是**另一台站点**(托管 Gitea 的 IIS),本服务的 `UPLOAD_MAX_BYTES` 管不到它;见第九节 |
|
||||
|
||||
## 八、还没有反代时:直连后端怎么访问
|
||||
|
||||
@@ -226,3 +227,91 @@ bash deploy/check-access.sh /opt/opendance # 指定安装目录
|
||||
脚本会打印 `.env` 中的 `HOST`/`PORT`、实际监听地址(重点看是否只监听
|
||||
`127.0.0.1`)、回环与对外 IP 两次自测结果、80/443 上有没有反代、
|
||||
firewalld / ufw / iptables 的放行情况,并直接给出修复命令。
|
||||
|
||||
---
|
||||
|
||||
## 九、Gitea 站点的附件上传上限(Windows 客户端整包必须放开)
|
||||
|
||||
> 本节与第一~五节是**两个不同的站点**:前者是本 Web 服务的反代,后者是托管
|
||||
> **Gitea**(源码仓库 + Release 附件)的站点。Windows 客户端的发布整包走 Gitea,
|
||||
> 因此**必须**单独放开 Gitea 站点的上限。
|
||||
|
||||
### 9.1 实测结论
|
||||
|
||||
| 上传体积 | 结果 |
|
||||
|--|--|
|
||||
| 14 MB | 通过(HTTP 201) |
|
||||
| 30.5 MB | **失败 HTTP 413**(IIS「请求实体过大」页面) |
|
||||
| 40 MB | **失败 HTTP 413** |
|
||||
|
||||
→ 默认上限就是 **IIS 的 `maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)**。
|
||||
Windows 客户端整包约 38 MB(`OpenDance.exe` ≈104 MB 未压缩 + `OpenDance.pck`),
|
||||
早先因此被拆成 `zip.part0/1/2`(每卷 13 MiB)才传上去 —— 用户拿到的是
|
||||
**无法直接解压的碎片**,必须自己拼接,属于交付缺陷。
|
||||
|
||||
### 9.2 正确做法:放开 Gitea 站点上限到 256 MB
|
||||
|
||||
1. **IIS**(托管 Gitea 的站点根目录 `web.config`):
|
||||
|
||||
```xml
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<!-- 256 MiB;默认 30,000,000 字节,Windows 整包会被 413 掉 -->
|
||||
<requestLimits maxAllowedContentLength="268435456" />
|
||||
</requestFiltering>
|
||||
</security>
|
||||
<serverRuntime uploadReadAheadSize="268435456" />
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
```
|
||||
|
||||
`uploadReadAheadSize` 必须一起改(SSL 场景下默认仅 48 KB,会中途失败)。
|
||||
|
||||
2. **ARR**(服务器级,不是站点级)在 `applicationHost.config` 中同步放宽请求体上限:
|
||||
|
||||
```xml
|
||||
<system.webServer>
|
||||
<proxy>
|
||||
<forwarding />
|
||||
</proxy>
|
||||
</system.webServer>
|
||||
<webProxy enabled="true" timeout="00:05:00" />
|
||||
<serverRuntime uploadReadAheadSize="268435456" />
|
||||
```
|
||||
|
||||
并在 IIS 管理器 → Application Request Routing Cache → Server Proxy Settings
|
||||
中确认请求体上限(`maxRequestBytes`)≥ `268435456`。
|
||||
|
||||
3. **Gitea 自身** `app.ini`:
|
||||
|
||||
```ini
|
||||
[attachment]
|
||||
; 默认通常为 4~32 MB,必须一起放大,否则 IIS 放行了 Gitea 仍会拒绝
|
||||
MAX_SIZE = 256
|
||||
```
|
||||
|
||||
4. **若 Gitea 前面还有 nginx**(IIS 之外或之内):
|
||||
|
||||
```nginx
|
||||
client_max_body_size 256m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
```
|
||||
|
||||
5. 改完执行 `iisreset /noforce`(或只重启站点),再按下节复验。
|
||||
|
||||
### 9.3 复验方法
|
||||
|
||||
```bash
|
||||
# 造一个 40 MB 文件上传到 Gitea 的附件接口,预期 HTTP 201(不再是 413)
|
||||
head -c 40000000 /dev/urandom > /tmp/probe.bin
|
||||
curl -i -X POST \
|
||||
-H "Authorization: token <token>" \
|
||||
-F "file=@/tmp/probe.bin" \
|
||||
"https://<Gitea 地址>/api/v1/repos/<owner>/<repo>/releases/<id>/assets?name=probe.bin"
|
||||
```
|
||||
|
||||
在放开上限之前,**不要**用分卷绕过:Windows 端 `build/package-release.sh`
|
||||
会拒绝生成 `.partN`,超限时直接告警并要求先放宽本节的配置。
|
||||
|
||||
+34
-4
@@ -1,8 +1,14 @@
|
||||
project: opendance-web-server
|
||||
version: 0.5.0
|
||||
version: 0.7.1
|
||||
repository: opendance-web
|
||||
default_branch: main
|
||||
contracts_version: 0.4.0
|
||||
contracts_version:
|
||||
rest-api: 0.5.0
|
||||
websocket-keypoints: 0.2.0
|
||||
data-models: 0.7.0
|
||||
skeleton-topology: 0.2.0
|
||||
model-formats: 0.5.0
|
||||
database: 0.4.0
|
||||
|
||||
# 契约变更流程(文档 7.2):
|
||||
# 任何跨端契约变更必须先在本项目修改 contracts/ 并提升版本,
|
||||
@@ -58,15 +64,39 @@ tasks:
|
||||
file: tasks/010-deploy.md
|
||||
scope: 部署脚本(双端口:软件 API 3000 + 管理端 3001,不部署 MySQL)+ deploy/.env 初始化引导(init-env.sh,HOST 默认 0.0.0.0)
|
||||
status: done
|
||||
- id: T-WEB-011
|
||||
file: tasks/011-version-display.md
|
||||
scope: 管理端左下角展示当前版本号(唯一来源 package.json)
|
||||
status: done
|
||||
- id: T-WEB-012
|
||||
file: tasks/012-vrm-first.md
|
||||
scope: 模型主力格式全面转为 VRM(非 VRM 入库标记不可用,不进已发布目录)
|
||||
status: done
|
||||
- id: T-WEB-013
|
||||
file: tasks/013-bone-rebinding.md
|
||||
scope: 动作内部标准(33 点)+ 模型骨骼按名重绑定(含 MMD/中文名)
|
||||
status: done
|
||||
- id: T-WEB-014
|
||||
file: tasks/014-motion-preview.md
|
||||
scope: 动作管理预览(离线骨架缩略图 + 管理端 3D 回放)
|
||||
status: done
|
||||
- id: T-WEB-015
|
||||
file: tasks/015-scene-management.md
|
||||
scope: 场景管理(GLB + 重力/风/物理参数,服务端只存参数)
|
||||
status: done
|
||||
- id: T-WEB-016
|
||||
file: tasks/016-websocket-contract-020.md
|
||||
scope: 契约 websocket-keypoints 0.2.0(保活 / 设备去重 / 状态机,与 T-AND-011 对齐)
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-web-v0.5.0.tar.gz
|
||||
artifact: opendance-web-v0.7.1.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
|
||||
- admin/ # React 管理端构建产物
|
||||
- deploy/ # install.sh / init-env.sh / systemd / openrc / initd / ctl.sh / .env.example(PORT、ADMIN_PORT、HOST)
|
||||
- deploy/proxy/ # 反向代理说明(nginx / IIS,仅供参考不部署)
|
||||
- contracts/ # 当前版本契约快照(data-models 0.6.0)
|
||||
- contracts/ # 当前版本契约快照(rest-api 0.5.0 / websocket-keypoints 0.2.0 / data-models 0.7.0 / database 0.4.0)
|
||||
- THIRD_PARTY.md # 开源声明
|
||||
- README.txt # 使用说明
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 打包发布产物:opendance-web-v0.5.0.tar.gz
|
||||
# 打包发布产物:opendance-web-v0.7.0.tar.gz
|
||||
#
|
||||
# 产物结构(Linux 业务服务部署,不使用 Docker、不部署反向代理):
|
||||
# web-server/ NestJS 源码 + 构建产物
|
||||
@@ -21,7 +21,20 @@ set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
VERSION="${1:-0.5.0}"
|
||||
# 版本号唯一来源:web-server/package.json 的 version(与 GET /health 的 version 同源,
|
||||
# 也是管理端左下角展示的那个值)。不传参数时自动读取,并与 project.yaml 交叉校验。
|
||||
PKG_VERSION="$(node -p "require('./web-server/package.json').version" 2>/dev/null || true)"
|
||||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||||
VERSION="${1:-$PKG_VERSION}"
|
||||
VERSION="${VERSION:-0.7.1}"
|
||||
if [ -n "$PKG_VERSION" ] && [ "$VERSION" != "$PKG_VERSION" ]; then
|
||||
echo "[error] 版本号不一致:参数=$VERSION,web-server/package.json=$PKG_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$YAML_VERSION" ] && [ "$VERSION" != "$YAML_VERSION" ]; then
|
||||
echo "[error] 版本号不一致:参数=$VERSION,project.yaml=$YAML_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
NAME="opendance-web-v${VERSION}"
|
||||
OUT_DIR="$REPO_ROOT/dist-release"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
@@ -169,7 +182,15 @@ if [ "$fail" -ne 0 ]; then
|
||||
die "产物完整性校验失败,已删除有问题的压缩包"
|
||||
fi
|
||||
|
||||
# ---- SHA256SUMS(裸文件名,下载目录内 sha256sum -c 直接可用) ----
|
||||
( cd "$OUT_DIR" && sha256sum "$NAME.tar.gz" > SHA256SUMS )
|
||||
log "完成:$OUT_DIR/$NAME.tar.gz"
|
||||
log "已生成 $OUT_DIR/SHA256SUMS"
|
||||
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
|
||||
log "sha256 自校验:OK"
|
||||
else
|
||||
die "sha256 自校验失败"
|
||||
fi
|
||||
echo
|
||||
echo "产物内容:"
|
||||
# 先把清单读入变量,避免 tar 因管道提前关闭(SIGPIPE)而误报 "write error"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# T-WEB-011 管理端展示当前版本号
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-010
|
||||
- **文档**: v1.6 §11(版本号展示)
|
||||
|
||||
## 目标
|
||||
|
||||
管理端页面**左下角**展示当前运行版本 `vX.Y.Z`,且与 `GET /health` 的 `version`
|
||||
同源(文档 §11.1 的硬性要求:单一来源、可核对)。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 唯一来源 | `web-server/package.json` 的 `version`(同时是 `/health` 的 `version`) |
|
||||
| 注入方式 | Vite 构建期 `define: { __APP_VERSION__: <package.json version> }` |
|
||||
| 展示位置 | `admin/src/components/Layout.tsx` 侧边栏底部 `APP_VERSION_LABEL` |
|
||||
| 禁止重复 | `admin/src/lib/version.ts` 是唯一读取点,界面不出现版本号字面量 |
|
||||
|
||||
`/health` 的 `version` 取 `process.env.npm_package_version`(由 npm 脚本注入),
|
||||
与构建期注入管理端的值来自同一个 `package.json`,因此**不可能不一致**。
|
||||
|
||||
## 验收(文档 §11.3)
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 管理端任一页面左下角可见 `vX.Y.Z` | ✅ 侧边栏底部固定显示 |
|
||||
| 与 `GET /health` 的 `version` 相同 | ✅ 同源于 `web-server/package.json` |
|
||||
| 与产物文件名一致 | ✅ `opendance-web-vX.Y.Z.tar.gz`;打包脚本读取同一个 package.json |
|
||||
@@ -0,0 +1,32 @@
|
||||
# T-WEB-012 模型主力格式:全面转为 VRM
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-005 / T-WEB-007
|
||||
- **文档**: v1.6 §4.9
|
||||
- **契约**: `model-formats 0.5.0`、`data-models 0.7.0`
|
||||
|
||||
## 目标
|
||||
|
||||
所有入库模型统一转成 **VRM**(glTF 2.0 容器 + humanoid 扩展),
|
||||
客户端只消费 VRM,避免三种格式各自的骨骼命名/坐标系差异在客户端叠加。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 转换默认值 | `MODEL_CONVERT_FORMAT` 默认 `vrm`(`.env.example` 同步) |
|
||||
| 直传 | `.vrm` / `.glb` 已经是 glTF 容器:直接入库、**不重编码** |
|
||||
| 需转换 | `.pmx` / `.fbx` / `.zip` 由 Assimp 转 GLB,再补齐 VRM 扩展 |
|
||||
| 可用性判定 | `isUsableModel()`:`format === 'vrm'` 才可用 |
|
||||
| 不可用模型 | **入库但标记为「不可用」**,`unusableReason()` 说明原因;不进已发布目录 |
|
||||
| 目录过滤 | `catalog/export` 只下发 `format = vrm` 的模型;`catalog/version` 指纹同样只算可用的 |
|
||||
| 默认模型 | `resolveDefaultModelId()` 只在可用模型里挑 |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 上传 PMX/FBX/zip 后产出 VRM | ✅ Assimp → GLB → VRM 扩展补齐;实测 MMD 模型(初音未来 / 奥黛塔)转换成功 |
|
||||
| 非 VRM 模型不出现在客户端目录 | ✅ `catalog/export` 过滤 + 指纹过滤;`test/model-usability.spec.ts` 覆盖 |
|
||||
| 不可用模型有明确原因 | ✅ DTO 增 `usable` / `unusableReason` 字段 |
|
||||
| 三端契约一致 | ✅ `data-models 0.7.0` / `model-formats 0.5.0` 快照三端逐字节一致 |
|
||||
@@ -0,0 +1,42 @@
|
||||
# T-WEB-013 动作内部标准与模型骨骼重绑定
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-006 / T-WEB-012
|
||||
- **文档**: v1.6 §4.10
|
||||
- **契约**: `skeleton-topology 0.2.0`、`data-models 0.7.0`
|
||||
|
||||
## 目标
|
||||
|
||||
1. **动作内部标准**:所有动作(BVH / VMD / FBX / glTF)统一预处理成
|
||||
MediaPipe 33 点关键点序列(`mediapipe33@0.1.0`),客户端只认这一种;
|
||||
2. **模型骨骼重绑定**:任意命名风格的模型都能被关键点驱动 —— 按**名字**映射到
|
||||
canonical humanoid 骨骼,而不是要求模型符合某一种命名规范。
|
||||
|
||||
## 方案
|
||||
|
||||
### 动作内部标准
|
||||
|
||||
`preprocess.service.ts` 按扩展名分派解析器,统一产出 NDJSON 关键点序列 +
|
||||
9 个关节角特征(108 维),与 Android 端推流结构完全同构 —— 因此客户端
|
||||
DTW 的两侧输入是同一套特征。
|
||||
|
||||
### 骨骼重绑定
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 归一化 | `normalizeBoneName()`:保留 CJK 与假名(**不能再只留 `a-z0-9`**),全角 ASCII 折叠(`足IK` → `足ik`) |
|
||||
| 候选表 | `HUMANOID_CANDIDATES` 覆盖 VRM/VRoid、Mixamo、MMD 日文名、中文常见名 |
|
||||
| 生成而非手写 | Godot 端 `src/model/humanoid_map.gd` 的 CANDIDATES **由服务端表生成**,并有跨端一致性测试 |
|
||||
| 覆盖度 | `coverage()` / `missing_bones()` 暴露给审核界面与客户端 |
|
||||
|
||||
> **踩坑记录**:第一版 `normalizeBoneName()` 只保留 `[a-z0-9]`,把 CJK 名字
|
||||
> 整个抹成空串,导致 MMD 模型 humanoid 覆盖 **0/22**。修复后实测
|
||||
> 奥黛塔 **21/22**、初音未来 **19/22**,两者 `drivable = true` 且 VRM 正常产出。
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| MMD(日文骨骼名)模型可驱动 | ✅ 实测 21/22、19/22 覆盖;客户端可正常驱动 |
|
||||
| Godot 端候选表与服务端一致 | ✅ 一致性测试(跨仓库生成 + 校验) |
|
||||
| 动作统一为 33 点 | ✅ `keypoint_format = mediapipe33@0.1.0`;Android 端推流同构 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# T-WEB-014 动作管理:预览功能
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-004 / T-WEB-006
|
||||
- **文档**: v1.6 §4.11(重要)
|
||||
- **契约**: `data-models 0.6.0`(新增 `thumbnailUrl`)
|
||||
|
||||
## 目标
|
||||
|
||||
审核动作时能**直接看到动作长什么样**,而不是只看文件名与帧数 ——
|
||||
动作是否可用、是否与曲目匹配,靠肉眼判断最快。
|
||||
|
||||
## 方案
|
||||
|
||||
| 项 | 做法 |
|
||||
|--|--|
|
||||
| 服务端缩略图 | `motions/motion-thumbnail.ts` 用关键点序列离线渲染骨架 PNG(前端视角的"动作剪影"),零原生依赖 |
|
||||
| 入库时机 | 预处理 `run()` 末尾生成(**非致命**:失败不阻塞预处理完成,只记录) |
|
||||
| 契约 | `data-models 0.6.0` 给 Motion 增加 `thumbnailUrl`;迁移 `1789600000000-AddMotionThumbnail` |
|
||||
| 管理端预览 | `admin/src/components/MotionPreview.tsx`:3D 骨架回放(Three.js),可拖动时间轴逐帧查看 |
|
||||
| 依赖数据 | `admin/src/lib/keypoint-sequence.ts` 解析 NDJSON,`skeleton-topology.ts` 提供父节点表 |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 动作列表显示缩略图 | ✅ 离线渲染的骨架 PNG |
|
||||
| 可逐帧回放查看动作 | ✅ `MotionPreview` 时间轴 + 3D 骨架 |
|
||||
| 缩略图生成失败不影响预处理 | ✅ 非致命分支;错误写入日志 |
|
||||
| 契约三端一致 | ✅ `data-models 0.6.0` → 后续 0.7.0;快照逐字节一致 |
|
||||
@@ -0,0 +1,48 @@
|
||||
# T-WEB-015 场景管理(新增模块)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-007(审核流程)
|
||||
- **文档**: v1.6 §4.12
|
||||
- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`database 0.4.0`
|
||||
|
||||
## 目标
|
||||
|
||||
新增**场景**这一类资源:客户端跟练时的 3D 环境(GLB)+ 物理参数
|
||||
(重力 / 风 / 地面 / 碰撞体 / 环境 / 出生点 / 相机)。
|
||||
服务端**只存参数、不跑物理**,仿真在客户端(Windows 端用 Godot 内置物理)。
|
||||
|
||||
## 与模型的区别
|
||||
|
||||
| 项 | 模型 | 场景 |
|
||||
|--|--|--|
|
||||
| 主力格式 | **VRM**(humanoid 重绑定) | **GLB**(不做重绑定、不转 VRM) |
|
||||
| 转换 | PMX/FBX/zip → GLB → VRM | `.glb`/`.gltf` 直接导入;`.zip`/`.pmx`/`.fbx` → GLB |
|
||||
| 附加数据 | 骨骼 / 材质 / 缩略图 | **物理参数** `SceneConfig` |
|
||||
|
||||
## 交付物
|
||||
|
||||
| 文件 | 说明 |
|
||||
|--|--|
|
||||
| `contracts/data-models.md` §7 | `Scene` 与冻结的 `SceneConfig` 结构 |
|
||||
| `contracts/rest-api.yaml` | `/api/scenes` 增删改查 + 审核复用 `/api/reviews/scene/:id/approve\|reject` + `catalog/export.scenes[]` |
|
||||
| `migrations/1789650000000-AddScenes.ts` | `scenes` 表;`config` / `stats` 用 **MEDIUMTEXT** |
|
||||
| `src/scenes/scene-config.ts` | `normalizeSceneConfig()`:缺项默认、越界夹紧、非法枚举回退,**永不抛错** |
|
||||
| `src/scenes/{scenes.service,scene.serializer,scenes.controller,scenes.module}.ts` | 业务实现(上传一律 `pending`;删除时清理源文件/GLB/缩略图) |
|
||||
| `src/models/model-inspector.ts` | 场景复用模型检查器做 GLB 分析 |
|
||||
| `src/models/model-thumbnail.ts` | 场景复用模型光栅化器出缩略图 |
|
||||
| `admin/src/pages/Scenes.tsx` | 列表 / 上传 / 审核 / 删除 / 物理参数编辑(编辑期即按同一规则夹紧) |
|
||||
| `admin/src/lib/scene-physics.ts` | 与后端同规则的收敛(保存前就不会产生非法枚举) |
|
||||
|
||||
> **MEDIUMTEXT 的原因**:`models.stats` 曾在 TEXT(65,535 字节)上溢出
|
||||
> (实测 70,980 字节 → MySQL 1406 → 500)。场景的 `stats` 与 `config` 同样可能很大,
|
||||
> 因此**一开始就用 MEDIUMTEXT**,并保留 `shrinkStatsForStorage()` 兜底。
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| 场景可上传(GLB 直传 / zip 自动解包 / PMX 转换) | ✅ 与模型同一套转换管线 |
|
||||
| 物理参数可在管理端可视化编辑 | ✅ 重力 / 风 / 摩擦 / 弹性 / 碰撞体 / 环境 / 出生点 / 相机 |
|
||||
| 非法参数不会入库 | ✅ 前后端同规则收敛;`test/scene-config.spec.ts` 9 项边界用例 |
|
||||
| 客户端能拿到场景 | ✅ `catalog/export.scenes[]`;目录指纹纳入场景变更 |
|
||||
| 路由与契约一致 | ✅ `check-routes` 共 **35** 条 |
|
||||
@@ -0,0 +1,48 @@
|
||||
# T-WEB-016 契约提升:websocket-keypoints 0.2.0(保活 / 去重 / 状态机)
|
||||
|
||||
- **状态**: done
|
||||
- **依赖**: T-WEB-003(本项目为契约源)
|
||||
- **文档**: v1.6 §6.5 / §7.2
|
||||
- **触发**: Android 端 T-AND-011「连接状态异常、频繁重连」要求**两端一起改**
|
||||
|
||||
## 背景
|
||||
|
||||
按文档 §7.2,任何跨端契约变更必须**先在本项目修改 `contracts/` 并提升版本**,
|
||||
再向另两端建迁移任务。本次 Android 端排查出"频繁重连"的根因在协议层:
|
||||
接收端有静默超时,但**契约里没有声明超时值,也没有定义任何应用层保活消息**。
|
||||
|
||||
## 变更内容(均为**向后兼容**:子协议名与二进制帧 `version` 均不变)
|
||||
|
||||
| 章节 | 新增 | 为什么必须写进契约 |
|
||||
|--|--|--|
|
||||
| §1.1 保活 | 接收端必须在 `welcome`(及需要变更时的 `config`)中声明 `silentTimeoutMs` 与 `heartbeatIntervalMs`;且**心跳必须小于静默超时的一半** | 客户端原先只能用 OkHttp 的**协议层** ping,而它由底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命 |
|
||||
| §3.1 | `hello.deviceId`(该安装实例的稳定标识,**不使用任何硬件序列号**) | 没有它,一次闪断重连会被接收端显示成两台设备 |
|
||||
| §3.2 / §3.3 | `welcome` / `config` 携带上述保活字段 | 让客户端按服务端节奏保活,而不是各写一套魔数 |
|
||||
| §3.6 | 应用层 `ping` / `pong`(回显 `sentAtMs`,`pong` 另带 `serverAtMs` 供算 RTT) | 协议层 ping 对应用层不可见,必须自定义 |
|
||||
| §5 | 设备去重规则:同一 `deviceId` 只保留最新连接,旧连接以 `1000` 关闭 | 用 `1000` 是刻意的:对端据 §7 不会重连,否则会形成"挤掉-重连-再挤掉"的循环 |
|
||||
| §7 | 关闭码与重连策略:`1000` / `1001` / `1002` 与主动停止**不重连**;其余重连 | 原实现"只要能重连就重连",服务端重启或协议不匹配时也会疯狂重连 |
|
||||
| §7.1 | 退避参数冻结:`min(500ms * 2^(n-1), 15000ms)` + **±20% 抖动** | 避免同网段多台设备同时重连打尖峰 |
|
||||
| §8 | 连接状态机 `idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped` | 两端同名,日志可直接对照 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
* 子协议 `opendance.pose.v1` 与二进制帧 `version = 0x01` **均不变**;
|
||||
* 只**新增可选**控制字段与一种新控制类型;
|
||||
* 仅实现 v0.1.0 的一端仍可正常互通(它不发 `ping`、不读 `silentTimeoutMs`,
|
||||
但每帧关键点 + 每秒 `stats` 已足够保活)。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|--|--|
|
||||
| `contracts/websocket-keypoints.md` | 上文全部章节 |
|
||||
| `contracts/VERSION` | `websocket-keypoints: 0.1.0` → `0.2.0` |
|
||||
| `web-server/src/config/contract-versions.ts` | 编译期常量同步为 0.2.0(`check-contracts.mjs` 强制) |
|
||||
|
||||
## 验收
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--|--|
|
||||
| `npm run contracts:check` 通过 | ✅ 六项契约版本一致,35 条路由与契约一致 |
|
||||
| 三端快照逐字节一致 | ✅ windows / android 的 `contracts/` md5 与 web 完全相同 |
|
||||
| 另两端的迁移任务已建立 | ✅ Windows `T-WIN-015`、Android `T-AND-011` 均已落地并引用本契约 |
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "opendance-admin",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { ModelsPage } from './pages/Models';
|
||||
import { ScenesPage } from './pages/Scenes';
|
||||
import { MotionsPage } from './pages/Motions';
|
||||
import { ReviewsPage } from './pages/Reviews';
|
||||
import { SongsPage } from './pages/Songs';
|
||||
@@ -14,6 +15,7 @@ export default function App() {
|
||||
<Route path="songs" element={<SongsPage />} />
|
||||
<Route path="models" element={<ModelsPage />} />
|
||||
<Route path="motions" element={<MotionsPage />} />
|
||||
<Route path="scenes" element={<ScenesPage />} />
|
||||
<Route path="reviews" element={<ReviewsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiUpload } from './client';
|
||||
import type {
|
||||
Scene,
|
||||
SceneConfig,
|
||||
ScenePage,
|
||||
SceneUpdate,
|
||||
ContractVersion,
|
||||
Health,
|
||||
Model,
|
||||
@@ -12,6 +16,7 @@ import type {
|
||||
MotionUpdate,
|
||||
PendingPage,
|
||||
PublishStatus,
|
||||
SourceType,
|
||||
ReviewActionBody,
|
||||
ReviewPage,
|
||||
Song,
|
||||
@@ -233,3 +238,54 @@ export function getHealth(): Promise<Health> {
|
||||
export function getCatalogVersion(): Promise<ContractVersion> {
|
||||
return apiGet<ContractVersion>('/api/catalog/version');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 场景(T-WEB-015) */
|
||||
|
||||
export function listScenes(
|
||||
params: ListParams & { q?: string; status?: PublishStatus | '' },
|
||||
): Promise<ScenePage> {
|
||||
return apiGet<ScenePage>('/api/scenes', {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
status: params.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function getScene(id: number): Promise<Scene> {
|
||||
return apiGet<Scene>(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
export interface SceneUploadInput {
|
||||
file: File;
|
||||
name: string;
|
||||
/** 物理/环境/出生点/相机;序列化成 JSON 字符串随 multipart 提交 */
|
||||
config: SceneConfig;
|
||||
sourceType: SourceType;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
/** 上传场景:multipart(file + name + config + 来源五元组) */
|
||||
export function uploadScene(input: SceneUploadInput): Promise<Scene> {
|
||||
const form = new FormData();
|
||||
form.append('file', input.file);
|
||||
form.append('name', input.name);
|
||||
form.append('config', JSON.stringify(input.config));
|
||||
form.append('sourceType', input.sourceType);
|
||||
form.append('sourceName', input.sourceName);
|
||||
form.append('sourceUrl', input.sourceUrl);
|
||||
form.append('license', input.license);
|
||||
form.append('author', input.author);
|
||||
return apiUpload<Scene>('/api/scenes', form);
|
||||
}
|
||||
|
||||
export function updateScene(id: number, payload: SceneUpdate): Promise<Scene> {
|
||||
return apiPatch<Scene>(`/api/scenes/${id}`, payload);
|
||||
}
|
||||
|
||||
export function deleteScene(id: number): Promise<void> {
|
||||
return apiDelete(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
@@ -303,3 +303,59 @@ export interface ApiErrorBody {
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 场景(v0.7.0 / T-WEB-015) */
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface SceneWind {
|
||||
base: Vec3;
|
||||
gustAmplitude: number;
|
||||
gustFrequency: number;
|
||||
gustDirection: Vec3;
|
||||
space: 'world' | 'local';
|
||||
}
|
||||
|
||||
export interface SceneConfig {
|
||||
physics: {
|
||||
gravity: Vec3;
|
||||
wind: SceneWind;
|
||||
ground: { friction: number; restitution: number; slopeLimit: number };
|
||||
colliders: 'auto' | 'mesh' | 'none';
|
||||
};
|
||||
environment: { ambientColor: string; ambientIntensity: number; fogDensity: number };
|
||||
spawnPoints: Array<{ x: number; y: number; z: number; yaw: number }>;
|
||||
camera: { mode: 'follow' | 'fixed' | 'orbit'; distance: number; height: number };
|
||||
}
|
||||
|
||||
/** 场景:以 GLB 为主(不做 humanoid 重绑定、不转 VRM)+ 物理参数 */
|
||||
export interface Scene extends SourceFields {
|
||||
id: number;
|
||||
name: string;
|
||||
glbUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
fileSize: number | null;
|
||||
fileSha256: string | null;
|
||||
conversionNote: string | null;
|
||||
config: SceneConfig | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
status: PublishStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ScenePage {
|
||||
items: Scene[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SceneUpdate {
|
||||
name?: string;
|
||||
config?: SceneConfig;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 场景物理参数的展示与默认值(T-WEB-015)。
|
||||
*
|
||||
* 放在 `.ts` 里而不是页面组件内:管理端的纯逻辑要与页面解耦,便于在服务端的
|
||||
* vitest 里直接断言(`.tsx` 需要 jsx 编译选项,服务端 tsconfig 不带)。
|
||||
* 默认值必须与契约 `data-models.md` 7.1 / 服务端 `scene-config.ts` 保持一致。
|
||||
*/
|
||||
import type { SceneConfig } from '../api/types';
|
||||
|
||||
export const DEFAULT_SCENE_CONFIG: SceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -9.8, z: 0 },
|
||||
wind: {
|
||||
base: { x: 0, y: 0, z: 0 },
|
||||
gustAmplitude: 0,
|
||||
gustFrequency: 0.2,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
environment: { ambientColor: '#8fb4ff', ambientIntensity: 1, fogDensity: 0 },
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||
camera: { mode: 'follow', distance: 4.5, height: 1.6 },
|
||||
};
|
||||
|
||||
/**
|
||||
* 列表里的物理摘要:一眼看出重力与风(服务端只存参数、仿真在客户端,
|
||||
* 因此列表必须把这两个最关键的物理量显示出来)。
|
||||
*/
|
||||
export function physicsSummary(config: SceneConfig | null): string {
|
||||
if (!config) return '—';
|
||||
const { gravity, wind } = config.physics;
|
||||
const windSpeed = Math.hypot(wind.base.x, wind.base.y, wind.base.z);
|
||||
const gust =
|
||||
wind.gustAmplitude > 0
|
||||
? ` + 阵风 ${wind.gustAmplitude.toFixed(1)}m/s@${wind.gustFrequency}Hz`
|
||||
: '';
|
||||
return `重力 ${gravity.y.toFixed(1)} | 风 ${windSpeed.toFixed(1)}m/s${gust}`;
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* 场景管理页(T-WEB-015)。
|
||||
*
|
||||
* 与模型/动作页的差异:场景**以 GLB 为主**且携带**物理参数**,因此本页有三块:
|
||||
* 1. 列表:缩略图 + 物理摘要(重力 / 风)+ 状态;
|
||||
* 2. 上传:GLB 文件 + 名称 + 来源五元组 + 物理参数(重力、风向动态、地面、碰撞体、环境);
|
||||
* 3. 详情:复用管理端 3D 预览(GLB/glTF/VRM 同一加载器)+ 参数可视化(可编辑后保存)。
|
||||
*
|
||||
* 服务端只存参数、不跑物理;这里的"物理"只是**参数编辑与展示**。
|
||||
*/
|
||||
import { lazy, Suspense, useState, type FormEvent } from 'react';
|
||||
|
||||
import { deleteScene, listScenes, updateScene, uploadScene } from '../api/endpoints';
|
||||
import { errorMessage } from '../api/client';
|
||||
import type { PublishStatus, Scene, SceneConfig, SourceType } from '../api/types';
|
||||
import { SOURCE_TYPES } from '../api/types';
|
||||
import { formatBytes, formatDateTime } from '../lib/format';
|
||||
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../lib/scene-physics';
|
||||
import { SOURCE_TYPE_LABELS, STATUS_OPTIONS } from '../lib/labels';
|
||||
import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import { Empty, ErrorBox, Loading, StatusBadge } from '../components/ui';
|
||||
|
||||
const VrmPreview = lazy(async () => {
|
||||
const module = await import('../components/VrmPreview');
|
||||
return { default: module.VrmPreview };
|
||||
});
|
||||
|
||||
interface SceneFormState {
|
||||
name: string;
|
||||
sourceType: string;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
gravityY: string;
|
||||
windX: string;
|
||||
windY: string;
|
||||
windZ: string;
|
||||
gustAmplitude: string;
|
||||
gustFrequency: string;
|
||||
gustDirectionX: string;
|
||||
groundFriction: string;
|
||||
groundSlopeLimit: string;
|
||||
colliders: 'auto' | 'mesh' | 'none';
|
||||
ambientColor: string;
|
||||
ambientIntensity: string;
|
||||
cameraMode: 'follow' | 'fixed' | 'orbit';
|
||||
cameraDistance: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: SceneFormState = {
|
||||
name: '',
|
||||
sourceType: '',
|
||||
sourceName: '',
|
||||
sourceUrl: '',
|
||||
license: '',
|
||||
author: '',
|
||||
gravityY: '-9.8',
|
||||
windX: '0',
|
||||
windY: '0',
|
||||
windZ: '0',
|
||||
gustAmplitude: '0',
|
||||
gustFrequency: '0.2',
|
||||
gustDirectionX: '1',
|
||||
groundFriction: '0.6',
|
||||
groundSlopeLimit: '45',
|
||||
colliders: 'auto',
|
||||
ambientColor: '#8fb4ff',
|
||||
ambientIntensity: '1',
|
||||
cameraMode: 'follow',
|
||||
cameraDistance: '4.5',
|
||||
};
|
||||
|
||||
function numberOf(value: string, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 表单 -> 契约 SceneConfig(越界由服务端再收敛一次) */
|
||||
function toConfig(form: SceneFormState): SceneConfig {
|
||||
return {
|
||||
physics: {
|
||||
gravity: { x: 0, y: numberOf(form.gravityY, -9.8), z: 0 },
|
||||
wind: {
|
||||
base: {
|
||||
x: numberOf(form.windX, 0),
|
||||
y: numberOf(form.windY, 0),
|
||||
z: numberOf(form.windZ, 0),
|
||||
},
|
||||
gustAmplitude: numberOf(form.gustAmplitude, 0),
|
||||
gustFrequency: numberOf(form.gustFrequency, 0.2),
|
||||
gustDirection: { x: numberOf(form.gustDirectionX, 1), y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: {
|
||||
friction: numberOf(form.groundFriction, 0.6),
|
||||
restitution: 0,
|
||||
slopeLimit: numberOf(form.groundSlopeLimit, 45),
|
||||
},
|
||||
colliders: form.colliders,
|
||||
},
|
||||
environment: {
|
||||
ambientColor: /^#[0-9a-fA-F]{6}$/.test(form.ambientColor) ? form.ambientColor : '#8fb4ff',
|
||||
ambientIntensity: numberOf(form.ambientIntensity, 1),
|
||||
fogDensity: 0,
|
||||
},
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||
camera: {
|
||||
mode: form.cameraMode,
|
||||
distance: numberOf(form.cameraDistance, 4.5),
|
||||
height: 1.6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{hint ? <em className="muted small"> {hint}</em> : null}
|
||||
</span>
|
||||
{children}
|
||||
{error ? <span className="field-error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneUploadForm({ onSaved }: { onSaved: () => void }): JSX.Element {
|
||||
const [form, setForm] = useState<SceneFormState>(EMPTY_FORM);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const patch = (next: Partial<SceneFormState>): void => setForm((previous) => ({ ...previous, ...next }));
|
||||
|
||||
const submit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const nextErrors: FieldErrors = {};
|
||||
requireText(form.name, 'name', '名称', nextErrors);
|
||||
validateSourceFields(
|
||||
{ ...form, sourceType: form.sourceType as SourceType | '' },
|
||||
nextErrors,
|
||||
);
|
||||
setErrors(nextErrors);
|
||||
if (hasErrors(nextErrors) || !file) return;
|
||||
setSaving(true);
|
||||
setServerError(null);
|
||||
try {
|
||||
await uploadScene({
|
||||
file,
|
||||
name: form.name.trim(),
|
||||
config: toConfig(form),
|
||||
sourceType: form.sourceType as (typeof SOURCE_TYPES)[number],
|
||||
sourceName: form.sourceName.trim(),
|
||||
sourceUrl: form.sourceUrl.trim(),
|
||||
license: form.license.trim(),
|
||||
author: form.author.trim(),
|
||||
});
|
||||
setForm(EMPTY_FORM);
|
||||
setFile(null);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
setServerError(errorMessage(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panel-body" onSubmit={submit}>
|
||||
<div className="form-grid">
|
||||
<Field label="场景文件 file" hint=".glb 为主(.gltf/.zip/.fbx/.pmx 服务端转 GLB)" error={errors.file}>
|
||||
<input type="file" accept=".glb,.gltf,.zip,.fbx,.pmx" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</Field>
|
||||
<Field label="名称 name" error={errors.name}>
|
||||
<input value={form.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<h4 className="form-section">物理参数(服务端只存参数,仿真在客户端)</h4>
|
||||
<div className="form-grid">
|
||||
<Field label="重力 Y (m/s²)" hint="默认 -9.8">
|
||||
<input value={form.gravityY} onChange={(event) => patch({ gravityY: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="碰撞体 colliders">
|
||||
<select value={form.colliders} onChange={(event) => patch({ colliders: event.target.value as SceneFormState['colliders'] })}>
|
||||
<option value="auto">auto(按 GLB 命名约定)</option>
|
||||
<option value="mesh">mesh(三角网)</option>
|
||||
<option value="none">none(不使用)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="风 基础向量 X/Y/Z (m/s)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.windX} onChange={(event) => patch({ windX: event.target.value })} />
|
||||
<input value={form.windY} onChange={(event) => patch({ windY: event.target.value })} />
|
||||
<input value={form.windZ} onChange={(event) => patch({ windZ: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="阵风幅度 (m/s) / 频率 (Hz)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.gustAmplitude} onChange={(event) => patch({ gustAmplitude: event.target.value })} />
|
||||
<input value={form.gustFrequency} onChange={(event) => patch({ gustFrequency: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="地面摩擦 / 坡度上限(度)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.groundFriction} onChange={(event) => patch({ groundFriction: event.target.value })} />
|
||||
<input value={form.groundSlopeLimit} onChange={(event) => patch({ groundSlopeLimit: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="环境光颜色 / 强度">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.ambientColor} onChange={(event) => patch({ ambientColor: event.target.value })} />
|
||||
<input value={form.ambientIntensity} onChange={(event) => patch({ ambientIntensity: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<h4 className="form-section">来源五元组(必填)</h4>
|
||||
<div className="form-grid">
|
||||
<Field label="来源类型 sourceType" error={errors.sourceType}>
|
||||
<select value={form.sourceType} onChange={(event) => patch({ sourceType: event.target.value })}>
|
||||
<option value="">请选择</option>
|
||||
{SOURCE_TYPES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{SOURCE_TYPE_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="来源名称 sourceName" error={errors.sourceName}>
|
||||
<input value={form.sourceName} onChange={(event) => patch({ sourceName: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="来源链接 sourceUrl" error={errors.sourceUrl}>
|
||||
<input value={form.sourceUrl} onChange={(event) => patch({ sourceUrl: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="许可 license" error={errors.license}>
|
||||
<input value={form.license} onChange={(event) => patch({ license: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="作者 author" error={errors.author}>
|
||||
<input value={form.author} onChange={(event) => patch({ author: event.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{serverError ? <ErrorBox message={serverError} /> : null}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? '上传中…' : '上传场景(待审核)'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** 详情:3D 预览 + 参数编辑 */
|
||||
function SceneDetail({ scene, onChanged }: { scene: Scene; onChanged: () => void }): JSX.Element {
|
||||
const initial = scene.config ?? DEFAULT_SCENE_CONFIG;
|
||||
const [form, setForm] = useState<SceneFormState>({
|
||||
...EMPTY_FORM,
|
||||
name: scene.name,
|
||||
gravityY: String(initial.physics.gravity.y),
|
||||
windX: String(initial.physics.wind.base.x),
|
||||
windY: String(initial.physics.wind.base.y),
|
||||
windZ: String(initial.physics.wind.base.z),
|
||||
gustAmplitude: String(initial.physics.wind.gustAmplitude),
|
||||
gustFrequency: String(initial.physics.wind.gustFrequency),
|
||||
gustDirectionX: String(initial.physics.wind.gustDirection.x),
|
||||
groundFriction: String(initial.physics.ground.friction),
|
||||
groundSlopeLimit: String(initial.physics.ground.slopeLimit),
|
||||
colliders: initial.physics.colliders,
|
||||
ambientColor: initial.environment.ambientColor,
|
||||
ambientIntensity: String(initial.environment.ambientIntensity),
|
||||
cameraMode: initial.camera.mode,
|
||||
cameraDistance: String(initial.camera.distance),
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await updateScene(scene.id, { name: form.name.trim(), config: toConfig(form) });
|
||||
setMessage('已保存');
|
||||
onChanged();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scene-detail">
|
||||
<div className="scene-preview">
|
||||
{scene.glbUrl ? (
|
||||
<Suspense fallback={<Loading text="正在加载 3D 预览…" />}>
|
||||
<VrmPreview fileUrl={scene.glbUrl} name={scene.name} height={320} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Empty text="该场景没有 GLB 文件" />
|
||||
)}
|
||||
</div>
|
||||
<div className="scene-params">
|
||||
<div className="kv-grid">
|
||||
<div className="kv">
|
||||
<span className="muted">文件</span>
|
||||
<code>{formatBytes(scene.fileSize)}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">物理摘要</span>
|
||||
<code>{physicsSummary(scene.config)}</code>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="muted">转换说明</span>
|
||||
<code className="break">{scene.conversionNote ?? '—'}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Field label="名称">
|
||||
<input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="重力 Y (m/s²)">
|
||||
<input value={form.gravityY} onChange={(event) => setForm({ ...form, gravityY: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="风 X/Y/Z (m/s)">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.windX} onChange={(event) => setForm({ ...form, windX: event.target.value })} />
|
||||
<input value={form.windY} onChange={(event) => setForm({ ...form, windY: event.target.value })} />
|
||||
<input value={form.windZ} onChange={(event) => setForm({ ...form, windZ: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="阵风幅度 / 频率">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.gustAmplitude} onChange={(event) => setForm({ ...form, gustAmplitude: event.target.value })} />
|
||||
<input value={form.gustFrequency} onChange={(event) => setForm({ ...form, gustFrequency: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="地面摩擦 / 坡度上限">
|
||||
<div className="inline-inputs">
|
||||
<input value={form.groundFriction} onChange={(event) => setForm({ ...form, groundFriction: event.target.value })} />
|
||||
<input value={form.groundSlopeLimit} onChange={(event) => setForm({ ...form, groundSlopeLimit: event.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="碰撞体">
|
||||
<select
|
||||
value={form.colliders}
|
||||
onChange={(event) => setForm({ ...form, colliders: event.target.value as SceneFormState['colliders'] })}
|
||||
>
|
||||
<option value="auto">auto</option>
|
||||
<option value="mesh">mesh</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{message ? <p className="muted small">{message}</p> : null}
|
||||
{error ? <ErrorBox message={error} /> : null}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={save}>
|
||||
{saving ? '保存中…' : '保存参数'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenesPage(): JSX.Element {
|
||||
const [q, setQ] = useState('');
|
||||
const [status, setStatus] = useState<PublishStatus | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [detailId, setDetailId] = useState<number | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const state = useAsync(() => listScenes({ q, status, page, pageSize: 20 }), [q, status, page]);
|
||||
const items = state.data?.items ?? [];
|
||||
const detail = items.find((item) => item.id === detailId) ?? null;
|
||||
|
||||
const remove = async (scene: Scene): Promise<void> => {
|
||||
setBusyId(scene.id);
|
||||
try {
|
||||
await deleteScene(scene.id);
|
||||
state.reload();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1>场景管理</h1>
|
||||
<p className="muted">
|
||||
场景以 <strong>GLB</strong> 为主(不做 humanoid 重绑定、不转 VRM),并携带物理参数:
|
||||
重力、风向动态、地面与碰撞体。服务端只存参数,仿真在 Windows 客户端。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setUploadOpen((value) => !value)}>
|
||||
{uploadOpen ? '收起上传' : '上传场景'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{uploadOpen ? (
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>上传场景</h2>
|
||||
<span className="muted">POST /api/scenes(multipart)</span>
|
||||
</header>
|
||||
<SceneUploadForm
|
||||
onSaved={() => {
|
||||
setUploadOpen(false);
|
||||
state.reload();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="panel">
|
||||
<header className="panel-head">
|
||||
<h2>场景列表</h2>
|
||||
<div className="filters">
|
||||
<input
|
||||
placeholder="搜索名称 / 作者"
|
||||
value={q}
|
||||
onChange={(event) => {
|
||||
setQ(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value as PublishStatus | '');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.error ? <ErrorBox message={state.error} onRetry={state.reload} /> : null}
|
||||
{state.loading && !state.data ? <Loading text="正在加载场景…" /> : null}
|
||||
{!state.loading && !state.error && items.length === 0 ? (
|
||||
<Empty text="还没有场景,点击右上角「上传场景」(.glb 为主)。" />
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>预览</th>
|
||||
<th>名称</th>
|
||||
<th>物理参数</th>
|
||||
<th>体积</th>
|
||||
<th>状态</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((scene) => (
|
||||
<tr key={scene.id}>
|
||||
<td>
|
||||
{scene.thumbnailUrl ? (
|
||||
<img
|
||||
className="thumb-40 thumb-clickable"
|
||||
src={scene.thumbnailUrl}
|
||||
alt={`${scene.name} 缩略图`}
|
||||
title="点击查看详情与参数"
|
||||
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||
/>
|
||||
) : (
|
||||
<span className="muted small">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-title">{scene.name}</div>
|
||||
<div className="muted small">
|
||||
#{scene.id} · {formatDateTime(scene.updatedAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono small">{physicsSummary(scene.config)}</td>
|
||||
<td className="mono">{formatBytes(scene.fileSize)}</td>
|
||||
<td>
|
||||
<StatusBadge status={scene.status} />
|
||||
</td>
|
||||
<td className="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
onClick={() => setDetailId(detailId === scene.id ? null : scene.id)}
|
||||
>
|
||||
{detailId === scene.id ? '收起详情' : '详情 / 预览'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={busyId === scene.id}
|
||||
onClick={() => remove(scene)}
|
||||
title="DELETE /api/scenes/:id(软删除)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<div className="panel-body scene-detail-panel">
|
||||
<header className="panel-head">
|
||||
<h3>场景详情 #{detail.id}</h3>
|
||||
<span className="muted">参数变更走 PATCH /api/scenes/:id;状态变更走审核页</span>
|
||||
</header>
|
||||
<SceneDetail scene={detail} onChanged={state.reload} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1539,3 +1539,56 @@ textarea {
|
||||
font-size: 12px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* ---- 场景管理(T-WEB-015) ---- */
|
||||
.scene-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.scene-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-detail-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.scene-preview {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: #181a20;
|
||||
}
|
||||
|
||||
.inline-inputs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inline-inputs input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opendance-web-server",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.1",
|
||||
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ModelsModule } from './models/models.module';
|
||||
import { MotionsModule } from './motions/motions.module';
|
||||
import { PreprocessModule } from './preprocess/preprocess.module';
|
||||
import { ReviewsModule } from './reviews/reviews.module';
|
||||
import { ScenesModule } from './scenes/scenes.module';
|
||||
import { SongsModule } from './songs/songs.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@@ -39,6 +40,7 @@ import { StorageModule } from './storage/storage.module';
|
||||
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
ScenesModule,
|
||||
SongsModule,
|
||||
MotionsModule,
|
||||
ModelsModule,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ModelsService } from '../models/models.service';
|
||||
import { MotionDto } from '../motions/motion.serializer';
|
||||
import { MotionsService } from '../motions/motions.service';
|
||||
import { SongDto } from '../songs/song.serializer';
|
||||
import { ScenesService } from '../scenes/scenes.service';
|
||||
import type { SceneDto } from '../scenes/scene.serializer';
|
||||
import { SongsService } from '../songs/songs.service';
|
||||
|
||||
export interface CatalogBundle {
|
||||
@@ -20,6 +22,8 @@ export interface CatalogBundle {
|
||||
songs: SongDto[];
|
||||
motions: MotionDto[];
|
||||
models: ModelDto[];
|
||||
/** 已发布场景(含物理参数);契约 data-models 0.7.0 */
|
||||
scenes: SceneDto[];
|
||||
defaultModelId: number | null;
|
||||
}
|
||||
|
||||
@@ -36,6 +40,7 @@ export class CatalogService {
|
||||
private readonly songsService: SongsService,
|
||||
private readonly motionsService: MotionsService,
|
||||
private readonly modelsService: ModelsService,
|
||||
private readonly scenesService: ScenesService,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@@ -50,10 +55,11 @@ export class CatalogService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const [songDtos, motionDtos, modelDtos, defaultModelId] = await Promise.all([
|
||||
const [songDtos, motionDtos, modelDtos, sceneDtos, defaultModelId] = await Promise.all([
|
||||
this.songsService.toDtos(songs),
|
||||
this.motionsService.toDtos(motions),
|
||||
this.modelsService.toDtos(models),
|
||||
this.scenesService.published(),
|
||||
this.modelsService.resolveDefaultModelId(),
|
||||
]);
|
||||
|
||||
@@ -63,6 +69,7 @@ export class CatalogService {
|
||||
songs: songDtos,
|
||||
motions: motionDtos,
|
||||
models: modelDtos,
|
||||
scenes: sceneDtos,
|
||||
defaultModelId,
|
||||
};
|
||||
}
|
||||
@@ -72,7 +79,7 @@ export class CatalogService {
|
||||
* 指纹只覆盖影响客户端行为的字段。
|
||||
*/
|
||||
async version(): Promise<{ contractVersion: Record<string, string>; revision: string; generatedAt: string }> {
|
||||
const [songs, motions, models] = await Promise.all([
|
||||
const [songs, motions, models, scenes] = await Promise.all([
|
||||
this.songs.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
||||
this.motions.find({ where: { status: PublishStatus.Published }, order: { id: 'ASC' } }),
|
||||
// v0.5.0:只交付**已产出 VRM** 的模型(usable),未产出 VRM 的不进入客户端目录
|
||||
@@ -80,6 +87,7 @@ export class CatalogService {
|
||||
where: { status: PublishStatus.Published, format: ModelFormat.Vrm },
|
||||
order: { id: 'ASC' },
|
||||
}),
|
||||
this.scenesService.published(),
|
||||
]);
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
@@ -94,6 +102,7 @@ export class CatalogService {
|
||||
m.preprocessState,
|
||||
]),
|
||||
models: models.map((m) => [m.id, m.name, m.format, m.isPrimary, m.updatedAt.toISOString(), m.fileAssetId]),
|
||||
scenes: scenes.map((s) => [s.id, s.name, s.updatedAt, s.glbUrl, s.status]),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -60,6 +60,8 @@ export enum TargetType {
|
||||
Song = 'song',
|
||||
Motion = 'motion',
|
||||
Model = 'model',
|
||||
/** 场景(T-WEB-015) */
|
||||
Scene = 'scene',
|
||||
}
|
||||
|
||||
export enum AssetKind {
|
||||
@@ -72,6 +74,10 @@ export enum AssetKind {
|
||||
Thumbnail = 'thumbnail',
|
||||
/** 动作关键点序列的骨架缩略图(T-WEB-014) */
|
||||
MotionThumbnail = 'motion-thumbnail',
|
||||
/** 场景 GLB(T-WEB-015) */
|
||||
Scene = 'scene',
|
||||
/** 场景缩略图 */
|
||||
SceneThumbnail = 'scene-thumbnail',
|
||||
Keypoints = 'keypoints',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { join } from 'node:path';
|
||||
* 未设置该变量时回落到这里的常量)。
|
||||
*/
|
||||
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'rest-api': '0.4.0',
|
||||
'websocket-keypoints': '0.1.0',
|
||||
'data-models': '0.6.0',
|
||||
'rest-api': '0.5.0',
|
||||
'websocket-keypoints': '0.2.0',
|
||||
'data-models': '0.7.0',
|
||||
'skeleton-topology': '0.2.0',
|
||||
'model-formats': '0.5.0',
|
||||
database: '0.3.0',
|
||||
database: '0.4.0',
|
||||
});
|
||||
|
||||
/** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { bigintNumber } from '../common/transformers';
|
||||
import { PublishStatus, SourceType } from '../common/enums';
|
||||
import type { SceneConfig } from '../scenes/scene-config';
|
||||
|
||||
/** JSON 文本列(MEDIUMTEXT):TEXT 的 64KB 上限在模型 stats 上已经踩过坑 */
|
||||
const jsonText = {
|
||||
to: (value: unknown): string | null => (value === null || value === undefined ? null : JSON.stringify(value)),
|
||||
from: (value: string | null): unknown => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 场景 —— 契约 data-models.md §7(T-WEB-015)。
|
||||
*
|
||||
* **以 GLB 为主**(与模型区分:场景不做 humanoid 重绑定、不转 VRM);
|
||||
* `config` 存物理/环境/出生点/相机参数(重力、风向动态等),
|
||||
* **服务端只存参数不跑物理**,仿真在客户端(Godot 内置物理)。
|
||||
*/
|
||||
@Entity('scenes')
|
||||
@Index('idx_scenes_status', ['status'])
|
||||
export class Scene {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
/** 场景 GLB 资产 */
|
||||
@Column({ name: 'glb_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
|
||||
glbAssetId!: number | null;
|
||||
|
||||
@Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber })
|
||||
thumbnailAssetId!: number | null;
|
||||
|
||||
/** 物理 / 环境 / 出生点 / 相机(见 scenes/scene-config.ts) */
|
||||
@Column({ name: 'config', type: 'mediumtext', nullable: true, transformer: jsonText })
|
||||
config!: SceneConfig | null;
|
||||
|
||||
/** 场景 GLB 的分析结果(面数 / 网格 / 材质 / warnings,复用模型检查器) */
|
||||
@Column({ name: 'stats', type: 'mediumtext', nullable: true, transformer: jsonText })
|
||||
stats!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'file_size', type: 'bigint', nullable: true, transformer: bigintNumber })
|
||||
fileSize!: number | null;
|
||||
|
||||
@Column({ name: 'file_sha256', type: 'varchar', length: 64, nullable: true })
|
||||
fileSha256!: string | null;
|
||||
|
||||
/** 由 `.zip` / `.pmx` / `.fbx` 转换而来时的说明 */
|
||||
@Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true })
|
||||
conversionNote!: string | null;
|
||||
|
||||
// ---- 来源五元组(必填)----
|
||||
@Column({ name: 'source_type', type: 'varchar', length: 32 })
|
||||
sourceType!: SourceType;
|
||||
|
||||
@Column({ name: 'source_name', type: 'varchar', length: 200 })
|
||||
sourceName!: string;
|
||||
|
||||
@Column({ name: 'source_url', type: 'varchar', length: 500 })
|
||||
sourceUrl!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
license!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
author!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 16, default: PublishStatus.Pending })
|
||||
status!: PublishStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', type: 'datetime', nullable: true })
|
||||
deletedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* 场景管理(T-WEB-015,契约 database 0.4.0 / data-models 0.7.0)。
|
||||
*
|
||||
* 场景以 **GLB 为主**:`glb_asset_id` 指向存储里的场景文件;
|
||||
* `config` 存物理参数(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机;
|
||||
* `stats` 复用模型检查器的分析结果。服务端**只存参数、不跑物理**。
|
||||
*/
|
||||
export class AddScenes1789650000000 implements MigrationInterface {
|
||||
name = 'AddScenes1789650000000'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`scenes\` (
|
||||
\`id\` bigint NOT NULL AUTO_INCREMENT,
|
||||
\`name\` varchar(200) NOT NULL,
|
||||
\`glb_asset_id\` bigint NULL,
|
||||
\`thumbnail_asset_id\` bigint NULL,
|
||||
\`config\` mediumtext NULL,
|
||||
\`stats\` mediumtext NULL,
|
||||
\`file_size\` bigint NULL,
|
||||
\`file_sha256\` varchar(64) NULL,
|
||||
\`conversion_note\` varchar(500) NULL,
|
||||
\`source_type\` varchar(32) NOT NULL,
|
||||
\`source_name\` varchar(200) NOT NULL,
|
||||
\`source_url\` varchar(500) NOT NULL,
|
||||
\`license\` varchar(100) NOT NULL,
|
||||
\`author\` varchar(200) NOT NULL,
|
||||
\`status\` varchar(16) NOT NULL DEFAULT 'pending',
|
||||
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
\`deleted_at\` datetime NULL,
|
||||
INDEX \`idx_scenes_status\` (\`status\`),
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE \`scenes\``);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ModelsModule } from '../models/models.module';
|
||||
@@ -9,7 +10,7 @@ import { ReviewsController } from './reviews.controller';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel]), ModelsModule],
|
||||
imports: [TypeOrmModule.forFeature([Scene, ReviewRecord, Song, Motion, CharacterModel]), ModelsModule],
|
||||
controllers: [ReviewsController],
|
||||
providers: [ReviewsService],
|
||||
exports: [ReviewsService],
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Paged, toPaged } from '../common/pagination';
|
||||
import { CharacterModel } from '../entities/model.entity';
|
||||
import { Motion } from '../entities/motion.entity';
|
||||
import { ReviewRecord } from '../entities/review-record.entity';
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import { Song } from '../entities/song.entity';
|
||||
import { QueryReviewDto, ReviewActionDto } from './review.dto';
|
||||
|
||||
@@ -68,6 +69,7 @@ export class ReviewsService {
|
||||
@InjectRepository(Song) private readonly songs: Repository<Song>,
|
||||
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
|
||||
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
|
||||
@InjectRepository(Scene) private readonly scenes: Repository<Scene>,
|
||||
private readonly modelsService: ModelsService,
|
||||
) {}
|
||||
|
||||
@@ -84,15 +86,17 @@ export class ReviewsService {
|
||||
async listByStatus(status: PublishStatus, query: QueryReviewDto): Promise<Paged<PendingItemDto>> {
|
||||
const rows: PendingItemDto[] = [];
|
||||
|
||||
const [songs, motions, models] = await Promise.all([
|
||||
const [songs, motions, models, scenes] = await Promise.all([
|
||||
this.songs.find({ where: { status } }),
|
||||
this.motions.find({ where: { status } }),
|
||||
this.models.find({ where: { status } }),
|
||||
this.scenes.find({ where: { status } }),
|
||||
]);
|
||||
|
||||
rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title)));
|
||||
rows.push(...motions.map((m) => toPending(TargetType.Motion, m, m.name)));
|
||||
rows.push(...models.map((m) => toPending(TargetType.Model, m, m.name)));
|
||||
rows.push(...scenes.map((s) => toPending(TargetType.Scene, s, s.name)));
|
||||
|
||||
rows.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||
const total = rows.length;
|
||||
@@ -133,6 +137,14 @@ export class ReviewsService {
|
||||
save = () => this.motions.save(motion);
|
||||
break;
|
||||
}
|
||||
case TargetType.Scene: {
|
||||
const scene = await this.scenes.findOne({ where: { id } });
|
||||
if (!scene) throw new NotFoundException(`场景 #${id} 不存在`);
|
||||
entity = scene;
|
||||
name = scene.name;
|
||||
save = () => this.scenes.save(scene);
|
||||
break;
|
||||
}
|
||||
case TargetType.Model: {
|
||||
const model = await this.models.findOne({ where: { id } });
|
||||
if (!model) throw new NotFoundException(`模型 #${id} 不存在`);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 场景物理与环境的配置定义(T-WEB-015)。
|
||||
*
|
||||
* 职责划分:**服务端只存参数、不跑物理**;仿真在客户端(Windows 端用 Godot 内置物理:
|
||||
* RigidBody3D / Area3D 风场 / PhysicsServer3D)。因此这里只做
|
||||
* "结构校验 + 默认值 + 边界收敛",保证下发给客户端的参数一定可解释。
|
||||
*/
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/** 风:基础风 + 阵风(正弦叠加) */
|
||||
export interface WindConfig {
|
||||
base: Vec3;
|
||||
/** 阵风幅度(米/秒,0 表示无阵风;上限 100) */
|
||||
gustAmplitude: number;
|
||||
/** 阵风频率(Hz,0.01~5) */
|
||||
gustFrequency: number;
|
||||
/** 阵风方向(上限 ±1) */
|
||||
gustDirection: Vec3;
|
||||
/** 世界坐标 or 场景局部坐标 */
|
||||
space: 'world' | 'local';
|
||||
}
|
||||
|
||||
export interface GroundConfig {
|
||||
/** 摩擦系数 0~1 */
|
||||
friction: number;
|
||||
/** 弹性 0~1 */
|
||||
restitution: number;
|
||||
/** 可行走坡度上限(度)0~89 */
|
||||
slopeLimit: number;
|
||||
}
|
||||
|
||||
export interface EnvironmentConfig {
|
||||
/** 环境光颜色(#RRGGBB) */
|
||||
ambientColor: string;
|
||||
/** 环境光强度 0~4 */
|
||||
ambientIntensity: number;
|
||||
/** 雾浓度 0~1(0 = 关闭) */
|
||||
fogDensity: number;
|
||||
}
|
||||
|
||||
export interface SpawnPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
/** 朝向(度,绕 Y 轴) */
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
mode: 'follow' | 'fixed' | 'orbit';
|
||||
distance: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SceneConfig {
|
||||
physics: {
|
||||
/** 重力向量(米/秒^2),默认 (0,-9.8,0) */
|
||||
gravity: Vec3;
|
||||
wind: WindConfig;
|
||||
ground: GroundConfig;
|
||||
/** 碰撞体来源:auto(按 GLB 命名约定)| mesh(三角网)| none */
|
||||
colliders: 'auto' | 'mesh' | 'none';
|
||||
};
|
||||
environment: EnvironmentConfig;
|
||||
spawnPoints: SpawnPoint[];
|
||||
camera: CameraConfig;
|
||||
}
|
||||
|
||||
export const SCENE_CONFIG_DEFAULTS: SceneConfig = {
|
||||
physics: {
|
||||
gravity: { x: 0, y: -9.8, z: 0 },
|
||||
wind: {
|
||||
base: { x: 0, y: 0, z: 0 },
|
||||
gustAmplitude: 0,
|
||||
gustFrequency: 0.2,
|
||||
gustDirection: { x: 1, y: 0, z: 0 },
|
||||
space: 'world',
|
||||
},
|
||||
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 },
|
||||
colliders: 'auto',
|
||||
},
|
||||
environment: { ambientColor: '#8fb4ff', ambientIntensity: 1, fogDensity: 0 },
|
||||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||||
camera: { mode: 'follow', distance: 4.5, height: 1.6 },
|
||||
};
|
||||
|
||||
function num(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function vec3(value: unknown, fallback: Vec3, limit = 1000): Vec3 {
|
||||
const raw = (value ?? {}) as Partial<Vec3>;
|
||||
return {
|
||||
x: num(raw.x, fallback.x, -limit, limit),
|
||||
y: num(raw.y, fallback.y, -limit, limit),
|
||||
z: num(raw.z, fallback.z, -limit, limit),
|
||||
};
|
||||
}
|
||||
|
||||
function hexColor(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意输入收敛成合法 `SceneConfig`:缺项用默认值、越界夹紧、非法枚举回退。
|
||||
* **永不抛错** —— 保证客户端拿到的参数一定可解释。
|
||||
*/
|
||||
export function normalizeSceneConfig(input: unknown): SceneConfig {
|
||||
const raw = (input ?? {}) as Partial<SceneConfig>;
|
||||
const physics = (raw.physics ?? {}) as Partial<SceneConfig['physics']>;
|
||||
const wind = (physics.wind ?? {}) as Partial<WindConfig>;
|
||||
const ground = (physics.ground ?? {}) as Partial<GroundConfig>;
|
||||
const environment = (raw.environment ?? {}) as Partial<EnvironmentConfig>;
|
||||
const camera = (raw.camera ?? {}) as Partial<CameraConfig>;
|
||||
const defaults = SCENE_CONFIG_DEFAULTS;
|
||||
const spawnPoints = Array.isArray(raw.spawnPoints) && raw.spawnPoints.length > 0
|
||||
? raw.spawnPoints
|
||||
: defaults.spawnPoints;
|
||||
|
||||
return {
|
||||
physics: {
|
||||
gravity: vec3(physics.gravity, defaults.physics.gravity, 200),
|
||||
wind: {
|
||||
base: vec3(wind.base, defaults.physics.wind.base, 100),
|
||||
gustAmplitude: num(wind.gustAmplitude, defaults.physics.wind.gustAmplitude, 0, 100),
|
||||
gustFrequency: num(wind.gustFrequency, defaults.physics.wind.gustFrequency, 0.01, 5),
|
||||
gustDirection: vec3(wind.gustDirection, defaults.physics.wind.gustDirection, 1),
|
||||
space: wind.space === 'local' ? 'local' : 'world',
|
||||
},
|
||||
ground: {
|
||||
friction: num(ground.friction, defaults.physics.ground.friction, 0, 1),
|
||||
restitution: num(ground.restitution, defaults.physics.ground.restitution, 0, 1),
|
||||
slopeLimit: num(ground.slopeLimit, defaults.physics.ground.slopeLimit, 0, 89),
|
||||
},
|
||||
colliders: physics.colliders === 'mesh' || physics.colliders === 'none' ? physics.colliders : 'auto',
|
||||
},
|
||||
environment: {
|
||||
ambientColor: hexColor(environment.ambientColor, defaults.environment.ambientColor),
|
||||
ambientIntensity: num(environment.ambientIntensity, defaults.environment.ambientIntensity, 0, 4),
|
||||
fogDensity: num(environment.fogDensity, defaults.environment.fogDensity, 0, 1),
|
||||
},
|
||||
spawnPoints: spawnPoints.slice(0, 16).map((point) => {
|
||||
const item = (point ?? {}) as Partial<SpawnPoint>;
|
||||
return {
|
||||
x: num(item.x, 0, -1000, 1000),
|
||||
y: num(item.y, 0, -1000, 1000),
|
||||
z: num(item.z, 0, -1000, 1000),
|
||||
yaw: num(item.yaw, 0, -360, 360),
|
||||
};
|
||||
}),
|
||||
camera: {
|
||||
mode: camera.mode === 'fixed' || camera.mode === 'orbit' ? camera.mode : defaults.camera.mode,
|
||||
distance: num(camera.distance, defaults.camera.distance, 0.5, 50),
|
||||
height: num(camera.height, defaults.camera.height, 0, 20),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PublishStatus, SourceType } from '../common/enums';
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import type { SceneConfig } from './scene-config';
|
||||
|
||||
/** 契约 data-models.md §7 的对外表示 */
|
||||
export interface SceneDto {
|
||||
id: number;
|
||||
name: string;
|
||||
glbUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
fileSize: number | null;
|
||||
fileSha256: string | null;
|
||||
conversionNote: string | null;
|
||||
/** 物理 / 环境 / 出生点 / 相机(服务端只存参数,仿真在客户端) */
|
||||
config: SceneConfig | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
sourceType: SourceType;
|
||||
sourceName: string;
|
||||
sourceUrl: string;
|
||||
license: string;
|
||||
author: string;
|
||||
status: PublishStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SceneUrlLookup {
|
||||
glbUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
export function toSceneDto(scene: Scene, urls: SceneUrlLookup): SceneDto {
|
||||
return {
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
glbUrl: urls.glbUrl,
|
||||
thumbnailUrl: urls.thumbnailUrl,
|
||||
fileSize: scene.fileSize ?? null,
|
||||
fileSha256: scene.fileSha256 ?? null,
|
||||
conversionNote: scene.conversionNote ?? null,
|
||||
config: scene.config ?? null,
|
||||
stats: scene.stats ?? null,
|
||||
sourceType: scene.sourceType,
|
||||
sourceName: scene.sourceName,
|
||||
sourceUrl: scene.sourceUrl,
|
||||
license: scene.license,
|
||||
author: scene.author,
|
||||
status: scene.status,
|
||||
createdAt: scene.createdAt.toISOString(),
|
||||
updatedAt: scene.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ScenesService, type UploadedSceneFile } from './scenes.service';
|
||||
import type { SceneDto } from './scene.serializer';
|
||||
|
||||
/** 场景管理(T-WEB-015):契约 rest-api.yaml /api/scenes */
|
||||
@Controller('api/scenes')
|
||||
export class ScenesController {
|
||||
constructor(private readonly scenes: ScenesService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('q') q?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
): Promise<{ items: SceneDto[]; total: number; page: number; pageSize: number }> {
|
||||
return this.scenes.list({
|
||||
q,
|
||||
status,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 上传场景:multipart,`file`(.glb 为主)+ 名称 + 来源五元组 + 可选 config(JSON) */
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
create(
|
||||
@Body() body: Record<string, unknown>,
|
||||
@UploadedFile() file: UploadedSceneFile | undefined,
|
||||
): Promise<SceneDto> {
|
||||
return this.scenes.create(body, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id', ParseIntPipe) id: number): Promise<SceneDto> {
|
||||
return this.scenes.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: Record<string, unknown>,
|
||||
): Promise<SceneDto> {
|
||||
return this.scenes.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
await this.scenes.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { ScenesController } from './scenes.controller';
|
||||
import { ScenesService } from './scenes.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Scene]), StorageModule],
|
||||
controllers: [ScenesController],
|
||||
providers: [ScenesService],
|
||||
exports: [ScenesService],
|
||||
})
|
||||
export class ScenesModule {}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetKind, ModelFormat, PublishStatus } from '../common/enums';
|
||||
import { assertSourceTupleComplete, type SourceTuple } from '../common/source.dto';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
import { Scene } from '../entities/scene.entity';
|
||||
import { convertModelArchive, convertModelFile } from '../models/model-convert';
|
||||
import { inspectModelFile } from '../models/model-inspector';
|
||||
import { renderModelThumbnail } from '../models/model-thumbnail';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS, type SceneConfig } from './scene-config';
|
||||
import { type SceneDto, toSceneDto } from './scene.serializer';
|
||||
|
||||
export interface UploadedSceneFile {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
size: number;
|
||||
mimetype?: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_DIRECT = new Set(['.glb', '.gltf']);
|
||||
|
||||
function extOf(name: string): string {
|
||||
const index = name.lastIndexOf('.');
|
||||
return index >= 0 ? name.slice(index).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景管理(T-WEB-015)。
|
||||
*
|
||||
* 与模型管理的差异(契约 model-formats 1.1 / data-models §7):
|
||||
* * 场景**以 GLB 为主**,不做 humanoid 重绑定、不转 VRM;
|
||||
* * 场景携带**物理参数**(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机;
|
||||
* * 服务端**只存参数、不跑物理**:仿真在客户端(Godot 内置物理)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScenesService {
|
||||
private readonly logger = new Logger(ScenesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Scene) private readonly scenes: Repository<Scene>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async create(body: Record<string, unknown>, file: UploadedSceneFile | undefined): Promise<SceneDto> {
|
||||
if (!file || !file.buffer || file.buffer.length === 0) {
|
||||
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
|
||||
}
|
||||
const name = typeof body.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : '';
|
||||
if (name.length === 0) throw new BadRequestException('场景名称 name 必填');
|
||||
const tuple = assertSourceTupleComplete({
|
||||
sourceType: body.sourceType as SourceTuple['sourceType'],
|
||||
sourceName: body.sourceName as string,
|
||||
sourceUrl: body.sourceUrl as string,
|
||||
license: body.license as string,
|
||||
author: body.author as string,
|
||||
});
|
||||
if (!tuple) {
|
||||
throw new BadRequestException('上传场景必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
|
||||
}
|
||||
|
||||
const parsed = this.parseConfigInput(body);
|
||||
const ext = extOf(file.originalname || '');
|
||||
let buffer: Buffer;
|
||||
let conversionNote: string;
|
||||
|
||||
if (SUPPORTED_DIRECT.has(ext)) {
|
||||
// 场景主力格式:GLB 直接导入(不重编码)
|
||||
buffer = file.buffer;
|
||||
conversionNote = `直接导入(${ext.slice(1).toUpperCase()},未做重编码)`;
|
||||
} else {
|
||||
// 其余格式统一转 GLB(场景不做 VRM)
|
||||
try {
|
||||
const converted =
|
||||
ext === '.zip'
|
||||
? await convertModelArchive(file.buffer, {
|
||||
assimpBin: this.config.assimpBin,
|
||||
timeoutMs: this.config.modelConvertTimeoutMs,
|
||||
preferFormat: 'glb',
|
||||
})
|
||||
: await convertModelFile(file.buffer, file.originalname, {
|
||||
assimpBin: this.config.assimpBin,
|
||||
timeoutMs: this.config.modelConvertTimeoutMs,
|
||||
preferFormat: 'glb',
|
||||
});
|
||||
buffer = converted.buffer;
|
||||
conversionNote = converted.note.slice(0, 500);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`场景转换失败(${ext || '未知扩展名'}):${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await this.storage.persistBuffer(
|
||||
AssetKind.Scene,
|
||||
buffer,
|
||||
file.originalname || `scene-${Date.now()}.glb`,
|
||||
'model/gltf-binary',
|
||||
);
|
||||
|
||||
const absolutePath = this.storage.absolutePath(stored.asset);
|
||||
const stats = await this.inspectSafely(absolutePath, buffer.length, name);
|
||||
const thumbnailAssetId = await this.thumbnailSafely(absolutePath);
|
||||
|
||||
const scene = this.scenes.create({
|
||||
name,
|
||||
glbAssetId: stored.asset.id,
|
||||
thumbnailAssetId,
|
||||
config: parsed,
|
||||
stats: stats as unknown as Record<string, unknown>,
|
||||
fileSize: stored.asset.size,
|
||||
fileSha256: stored.asset.sha256,
|
||||
conversionNote,
|
||||
sourceType: tuple.sourceType as Scene['sourceType'],
|
||||
sourceName: tuple.sourceName,
|
||||
sourceUrl: tuple.sourceUrl,
|
||||
license: tuple.license,
|
||||
author: tuple.author,
|
||||
// 与模型/动作一致:上传一律待审核
|
||||
status: PublishStatus.Pending,
|
||||
});
|
||||
const saved = await this.scenes.save(scene);
|
||||
this.logger.log(
|
||||
`场景已入库待审:#${saved.id} ${name}(${file.originalname},${conversionNote})` +
|
||||
` 重力=(${parsed.physics.gravity.x},${parsed.physics.gravity.y},${parsed.physics.gravity.z})`,
|
||||
);
|
||||
const [dto] = await this.toDtos([saved]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 场景配置:既接受 `config` JSON 字符串(multipart 常用),也接受平铺字段 */
|
||||
private parseConfigInput(body: Record<string, unknown>): SceneConfig {
|
||||
const raw = body.config;
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) {
|
||||
try {
|
||||
return normalizeSceneConfig(JSON.parse(raw));
|
||||
} catch {
|
||||
throw new BadRequestException('config 不是合法的 JSON');
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object') return normalizeSceneConfig(raw);
|
||||
if (body.gravity || body.wind || body.environment || body.spawnPoints || body.camera) {
|
||||
return normalizeSceneConfig({
|
||||
physics: { gravity: body.gravity, wind: body.wind },
|
||||
environment: body.environment,
|
||||
spawnPoints: body.spawnPoints,
|
||||
camera: body.camera,
|
||||
});
|
||||
}
|
||||
return SCENE_CONFIG_DEFAULTS;
|
||||
}
|
||||
|
||||
private async inspectSafely(
|
||||
absolutePath: string,
|
||||
fileSize: number,
|
||||
name: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return (await inspectModelFile(absolutePath, fileSize)) as unknown as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
this.logger.warn(`场景「${name}」分析失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async thumbnailSafely(absolutePath: string): Promise<number | null> {
|
||||
try {
|
||||
const rendered = await renderModelThumbnail(absolutePath, { size: 512 });
|
||||
if (!rendered) return null;
|
||||
const stored = await this.storage.persistBuffer(
|
||||
AssetKind.SceneThumbnail,
|
||||
rendered.png,
|
||||
`scene-thumb-${Date.now()}.png`,
|
||||
'image/png',
|
||||
);
|
||||
return stored.asset.id;
|
||||
} catch (error) {
|
||||
this.logger.warn(`场景缩略图生成失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: {
|
||||
q?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ items: SceneDto[]; total: number; page: number; pageSize: number }> {
|
||||
const page = Math.max(1, Math.floor(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize ?? 20)));
|
||||
const qb = this.scenes.createQueryBuilder('s');
|
||||
if (query.q) {
|
||||
qb.andWhere('(s.name LIKE :q OR s.author LIKE :q)', { q: `%${query.q}%` });
|
||||
}
|
||||
if (query.status) qb.andWhere('s.status = :status', { status: query.status });
|
||||
qb.orderBy('s.id', 'DESC').skip((page - 1) * pageSize).take(pageSize);
|
||||
const [rows, total] = await qb.getManyAndCount();
|
||||
return { items: await this.toDtos(rows), total, page, pageSize };
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<SceneDto> {
|
||||
const scene = await this.getEntity(id);
|
||||
const [dto] = await this.toDtos([scene]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
async update(id: number, body: Record<string, unknown>): Promise<SceneDto> {
|
||||
const scene = await this.getEntity(id);
|
||||
if (typeof body.name === 'string' && body.name.trim().length > 0) scene.name = body.name.trim();
|
||||
if (body.config !== undefined || body.gravity || body.wind || body.environment) {
|
||||
scene.config = this.parseConfigInput(body);
|
||||
}
|
||||
if (typeof body.status === 'string' && body.status !== scene.status) {
|
||||
throw new BadRequestException('状态变更请走 /api/reviews 接口(上传一律待审核)');
|
||||
}
|
||||
const saved = await this.scenes.save(scene);
|
||||
const [dto] = await this.toDtos([saved]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 软删除(与歌曲一致:保留数据,客户端目录按 status 过滤) */
|
||||
async remove(id: number): Promise<void> {
|
||||
const scene = await this.getEntity(id);
|
||||
await this.scenes.softRemove(scene);
|
||||
}
|
||||
|
||||
async getEntity(id: number): Promise<Scene> {
|
||||
const scene = await this.scenes.findOne({ where: { id } });
|
||||
if (!scene) throw new NotFoundException(`场景 #${id} 不存在`);
|
||||
return scene;
|
||||
}
|
||||
|
||||
async toDtos(rows: Scene[]): Promise<SceneDto[]> {
|
||||
const assets = await this.storage.findMany(
|
||||
rows.flatMap((row) => [row.glbAssetId, row.thumbnailAssetId]),
|
||||
);
|
||||
return rows.map((row) => {
|
||||
const glb = row.glbAssetId ? assets.get(row.glbAssetId) : undefined;
|
||||
const thumbnail = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined;
|
||||
if (glb && !existsSync(this.storage.absolutePath(glb))) {
|
||||
this.logger.warn(`场景 #${row.id} 的 GLB 在存储中不存在:${this.storage.absolutePath(glb)}`);
|
||||
}
|
||||
return toSceneDto(row, {
|
||||
glbUrl: glb ? this.storage.url(glb) : null,
|
||||
thumbnailUrl: thumbnail ? this.storage.url(thumbnail) : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 供 catalog 使用:已发布场景(含物理参数) */
|
||||
async published(): Promise<SceneDto[]> {
|
||||
const rows = await this.scenes.find({
|
||||
where: { status: PublishStatus.Published },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
return this.toDtos(rows);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ describe('目录导出只含可用模型', () => {
|
||||
};
|
||||
const songsService = { toDtos: vi.fn().mockResolvedValue([]) };
|
||||
const motionsService = { toDtos: vi.fn().mockResolvedValue([]) };
|
||||
const scenesService = { published: vi.fn().mockResolvedValue([]) };
|
||||
const modelsService = {
|
||||
toDtos: vi.fn().mockImplementation((rows: CharacterModel[]) =>
|
||||
rows.map((row) => toModelDto(row, { fileUrl: '/f', thumbnailUrl: null })),
|
||||
@@ -90,6 +91,7 @@ describe('目录导出只含可用模型', () => {
|
||||
songsService as never,
|
||||
motionsService as never,
|
||||
modelsService as never,
|
||||
scenesService as never,
|
||||
config as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
||||
import { DEFAULT_SCENE_CONFIG, physicsSummary } from '../admin/src/lib/scene-physics';
|
||||
import { toSceneDto } from '../src/scenes/scene.serializer';
|
||||
import { PublishStatus, SourceType } from '../src/common/enums';
|
||||
import type { Scene } from '../src/entities/scene.entity';
|
||||
|
||||
/**
|
||||
* 场景物理参数(T-WEB-015)。
|
||||
*
|
||||
* 服务端**只存参数、不跑物理**,所以这里锁两件事:
|
||||
* 1. 越界/非法输入一律被收敛成可解释的值(客户端拿到就一定能用);
|
||||
* 2. 缺省即 `SCENE_CONFIG_DEFAULTS`(重力 -9.8 等),且序列化结构稳定。
|
||||
*/
|
||||
describe('SceneConfig 归一化', () => {
|
||||
it('空输入 -> 默认值(重力 -9.8、无风、跟随相机)', () => {
|
||||
const config = normalizeSceneConfig(undefined);
|
||||
expect(config).toEqual(SCENE_CONFIG_DEFAULTS);
|
||||
expect(config.physics.gravity).toEqual({ x: 0, y: -9.8, z: 0 });
|
||||
expect(config.physics.wind.gustAmplitude).toBe(0);
|
||||
expect(config.camera.mode).toBe('follow');
|
||||
});
|
||||
|
||||
it('越界数值被夹紧(重力 ±200、摩擦 0~1、频率 0.01~5、雾 0~1)', () => {
|
||||
const config = normalizeSceneConfig({
|
||||
physics: {
|
||||
gravity: { x: 9999, y: -9999, z: 0 },
|
||||
wind: { base: { x: 500, y: 0, z: 0 }, gustAmplitude: 999, gustFrequency: 99 },
|
||||
ground: { friction: 5, restitution: -1, slopeLimit: 120 },
|
||||
},
|
||||
environment: { ambientIntensity: 99, fogDensity: 9 },
|
||||
camera: { distance: 999, height: -5 },
|
||||
});
|
||||
expect(config.physics.gravity.x).toBe(200);
|
||||
expect(config.physics.gravity.y).toBe(-200);
|
||||
expect(config.physics.wind.base.x).toBe(100);
|
||||
expect(config.physics.wind.gustAmplitude).toBe(100);
|
||||
expect(config.physics.wind.gustFrequency).toBe(5);
|
||||
expect(config.physics.ground.friction).toBe(1);
|
||||
expect(config.physics.ground.restitution).toBe(0);
|
||||
expect(config.physics.ground.slopeLimit).toBe(89);
|
||||
expect(config.environment.ambientIntensity).toBe(4);
|
||||
expect(config.environment.fogDensity).toBe(1);
|
||||
expect(config.camera.distance).toBe(50);
|
||||
expect(config.camera.height).toBe(0);
|
||||
});
|
||||
|
||||
it('非法枚举回退默认;风坐标系只认 local/world', () => {
|
||||
const config = normalizeSceneConfig({
|
||||
physics: { colliders: 'banana', wind: { space: 'space' } },
|
||||
camera: { mode: 'fly' },
|
||||
environment: { ambientColor: 'red' },
|
||||
});
|
||||
expect(config.physics.colliders).toBe('auto');
|
||||
expect(config.physics.wind.space).toBe('world');
|
||||
expect(config.camera.mode).toBe('follow');
|
||||
expect(config.environment.ambientColor).toBe(SCENE_CONFIG_DEFAULTS.environment.ambientColor);
|
||||
|
||||
const local = normalizeSceneConfig({ physics: { colliders: 'mesh', wind: { space: 'local' } } });
|
||||
expect(local.physics.colliders).toBe('mesh');
|
||||
expect(local.physics.wind.space).toBe('local');
|
||||
});
|
||||
|
||||
it('出生点最多 16 个,坐标与朝向被夹紧', () => {
|
||||
const many = Array.from({ length: 40 }, () => ({ x: 1e9, y: 0, z: 0, yaw: 1e9 }));
|
||||
const config = normalizeSceneConfig({ spawnPoints: many });
|
||||
expect(config.spawnPoints.length).toBe(16);
|
||||
expect(config.spawnPoints[0].x).toBe(1000);
|
||||
expect(config.spawnPoints[0].yaw).toBe(360);
|
||||
});
|
||||
|
||||
it('合法 #RRGGBB 颜色被接受并小写化', () => {
|
||||
const config = normalizeSceneConfig({ environment: { ambientColor: '#AABBCC' } });
|
||||
expect(config.environment.ambientColor).toBe('#aabbcc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SceneDto 序列化', () => {
|
||||
it('带出 config 与来源五元组;URL 由调用方注入', () => {
|
||||
const scene = {
|
||||
id: 3,
|
||||
name: '黄昏广场',
|
||||
glbAssetId: 11,
|
||||
thumbnailAssetId: 12,
|
||||
config: normalizeSceneConfig({ physics: { gravity: { x: 0, y: -3.5, z: 0 } } }),
|
||||
stats: { triangles: 1234 },
|
||||
fileSize: 456,
|
||||
fileSha256: 'abc',
|
||||
conversionNote: '直接导入(GLB,未做重编码)',
|
||||
sourceType: SourceType.Other,
|
||||
sourceName: 'x',
|
||||
sourceUrl: 'y',
|
||||
license: 'z',
|
||||
author: 'a',
|
||||
status: PublishStatus.Pending,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00Z'),
|
||||
} as unknown as Scene;
|
||||
const dto = toSceneDto(scene, { glbUrl: '/static/scene/x.glb', thumbnailUrl: '/static/scene/x.png' });
|
||||
expect(dto.id).toBe(3);
|
||||
expect(dto.glbUrl).toBe('/static/scene/x.glb');
|
||||
expect(dto.config?.physics.gravity.y).toBe(-3.5);
|
||||
expect(dto.stats).toEqual({ triangles: 1234 });
|
||||
expect(dto.status).toBe(PublishStatus.Pending);
|
||||
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('场景列表的物理摘要(管理端)', () => {
|
||||
it('展示重力与风速,并在有阵风时标注幅度/频率', () => {
|
||||
const calm = physicsSummary(normalizeSceneConfig(undefined));
|
||||
expect(calm).toContain('重力 -9.8');
|
||||
expect(calm).toContain('风 0.0m/s');
|
||||
expect(calm).not.toContain('阵风');
|
||||
|
||||
const windy = physicsSummary(
|
||||
normalizeSceneConfig({
|
||||
physics: {
|
||||
wind: {
|
||||
base: { x: 3, y: 0, z: 4 },
|
||||
gustAmplitude: 2.5,
|
||||
gustFrequency: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(windy).toContain('风 5.0m/s');
|
||||
expect(windy).toContain('阵风 2.5m/s@0.5Hz');
|
||||
});
|
||||
|
||||
it('没有 config 时显示占位符(不抛错)', () => {
|
||||
expect(physicsSummary(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('管理端默认值与服务端一致(契约 7.1 单一真源)', () => {
|
||||
expect(DEFAULT_SCENE_CONFIG).toEqual(SCENE_CONFIG_DEFAULTS);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user