Files
opendance-web/contracts/data-models.md
T
zijunchiang 53bf182476 feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
2026-09-15 20:37:53 +08:00

163 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# contracts/data-models.md — 数据模型契约 v0.4.0
Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。
字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。
## 1. 通用约定
- 所有资源返回 `id: number`(BIGINT,超出 2^53 前不会发生)。
- 所有时间为 ISO 8601 UTC 字符串,例:`2026-09-14T10:00:00.000Z`。
- 列表接口统一分页响应:
```json
{ "items": [], "total": 0, "page": 1, "pageSize": 20 }
```
- 错误响应统一:
```json
{ "statusCode": 400, "message": "…", "error": "Bad Request", "details": {} }
```
- **来源(出处)五元组**是必填项,出现在 `songs` / `motions` / `models` 三处:
| 字段 | 类型 | 说明 |
|---|---|---|
| `sourceType` | enum | 见 1.1 |
| `sourceName` | string | 来源名称,如「官方曲库」「作者名」 |
| `sourceUrl` | string | 来源链接,必须为 http(s) |
| `license` | string | 许可标识,如 `CC-BY-4.0`、`MIT`、`proprietary`、`unknown` |
| `author` | string | 作者 / 权利人 |
### 1.1 `SourceType` 枚举
```
original 原创
licensed 已获授权
cc Creative Commons
public_domain 公共领域
user_upload 用户上传(需审核)
other 其他(须在 sourceName 说明)
```
### 1.2 `PublishStatus` 枚举
```
draft 草稿,仅管理端可见
pending 待审核
published 已发布,客户端可见
rejected 已驳回
```
## 2. `Song`
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | number | 读 | |
| `title` | string | ✅ | 曲名,≤200 |
| `artist` | string | ✅ | 演唱者,≤200 |
| `album` | string | | 专辑,≤200 |
| `language` | string | | `zh` / `en` / `ja` / `ko` / `instrumental` |
| `durationMs` | number | | 时长毫秒 |
| `bpm` | number | | 节拍 |
| `difficulty` | number | | 1..5 |
| `tags` | string[] | | |
| `coverUrl` | string\|null | | 封面访问地址 |
| `audioUrl` | string\|null | | 音频访问地址 |
| `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | 默认 `draft` |
| `createdAt` / `updatedAt` | string | 读 | |
## 3. `Motion`(动作数据)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | number | 读 | |
| `songId` | number\|null | | 关联曲目 |
| `name` | string | ✅ | 动作名称,≤200 |
| `format` | enum | ✅ | `bvh` / `vmd` / `fbx` / `gltf` / `glb` / `keypoints-json` |
| `fileUrl` | string | 读 | 原始动作文件 |
| `fileSize` | number | 读 | 字节 |
| `fps` | number\|null | | 原始帧率 |
| `frameCount` | number\|null | | 原始帧数 |
| `durationMs` | number\|null | | |
| `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` |
| `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或 .npz 的 JSON 封装) |
| `keypointFrames` | number\|null | 读 | 关键点帧数 |
| `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` |
| `preprocessError` | string\|null | 读 | |
| `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | |
| `createdAt` / `updatedAt` | string | 读 | |
## 4. `Model`(角色模型,VRM 优先)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | number | 读 | |
| `name` | string | ✅ | 模型名称,≤200 |
| `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md §1.1 |
| `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null |
| `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** |
| `fileUrl` | string | 读 | |
| `fileSize` | number | 读 | |
| `thumbnailUrl` | string\|null | 读 | |
| `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null |
| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 —— **每次转换都会变**,仅作记录 |
| `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null |
| `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md §1.1 |
| `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | |
| `createdAt` / `updatedAt` | string | 读 | |
### 4.1 默认模型选择规则(冻结)
服务端 `GET /api/catalog/export` 的 `defaultModelId` 按以下顺序解析:
```
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的
DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型,
换库/重建库后仍然有效;没有压缩包时退回按产物 sha256,最后按名称兜底。
⚠ 不能用产物 sha256 当主键:实测 Assimp 的 GLB 导出不是字节级确定的,
同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 有十几字节浮动)
2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1
3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个
4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个
5. 无可用模型 → 客户端使用内置占位模型
```
第 0/1 步是 v0.3.0 新增:上传 MMD 模型压缩包并转换成功后,服务端会把该模型
`is_primary=1` + `status=published`,并把身份写进源码,使它成为开箱默认模型。
`isPrimary` 通过 `POST /api/models/:id/primary` 设置,服务端保证互斥(同一事务内先清零)。
## 5. `ReviewRecord`(审核流水)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | number | |
| `targetType` | enum | `song` / `motion` / `model` |
| `targetId` | number | |
| `action` | enum | `submit` / `approve` / `reject` / `withdraw` |
| `fromStatus` / `toStatus` | `PublishStatus` | |
| `reviewer` | string\|null | 审核人标识 |
| `comment` | string\|null | 审核意见 |
| `createdAt` | string | |
## 6. `CatalogBundle`(客户端一次性拉取,`GET /api/catalog/export`)
```json
{
"contractVersion": { "rest-api": "0.1.0", "data-models": "0.1.0", "skeleton-topology": "0.1.0", "websocket-keypoints": "0.1.0" },
"generatedAt": "2026-09-14T10:00:00.000Z",
"songs": [],
"motions": [],
"models": [],
"defaultModelId": 3
}
```
- 仅包含 `status = 'published'` 的记录。
- `defaultModelId` 由 4.1 的规则解析得出,客户端无需自行实现优先级。