chore(contracts): 引入 opendance-web v0.1.0 契约只读快照
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Godot 生成的导入缓存与元数据
|
||||||
|
.godot/
|
||||||
|
*.translation
|
||||||
|
|
||||||
|
# 导出与发布产物
|
||||||
|
dist-release/
|
||||||
|
build/windows/
|
||||||
|
build/linux/
|
||||||
|
build/macos/
|
||||||
|
*.exe
|
||||||
|
*.pck
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# 本机临时素材(真实 VRM 样例等,版权原因不入库)
|
||||||
|
_scratch/
|
||||||
|
user/
|
||||||
|
|
||||||
|
# 编辑器 / 系统
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# OpenDance Studio 契约版本
|
||||||
|
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||||
|
rest-api: 0.1.0
|
||||||
|
websocket-keypoints: 0.1.0
|
||||||
|
data-models: 0.1.0
|
||||||
|
skeleton-topology: 0.1.0
|
||||||
|
model-formats: 0.1.0
|
||||||
|
database: 0.1.0
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# contracts/data-models.md — 数据模型契约 v0.1.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` / `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 | ✅ | `vrm` / `glb` / `gltf` / `fbx` |
|
||||||
|
| `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null |
|
||||||
|
| `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** |
|
||||||
|
| `fileUrl` | string | 读 | |
|
||||||
|
| `fileSize` | number | 读 | |
|
||||||
|
| `thumbnailUrl` | string\|null | 读 | |
|
||||||
|
| `source*` | | ✅ | 来源五元组 |
|
||||||
|
| `status` | `PublishStatus` | | |
|
||||||
|
| `createdAt` / `updatedAt` | string | 读 | |
|
||||||
|
|
||||||
|
### 4.1 VRM 优先规则(冻结)
|
||||||
|
|
||||||
|
客户端选择默认模型的顺序:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 显式指定 modelId(曲目动作绑定)
|
||||||
|
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. 无可用模型 → 客户端使用内置占位模型
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 的规则解析得出,客户端无需自行实现优先级。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# contracts/database.md — 数据库契约 v0.1.0
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
- 类型:MySQL 兼容实例(外部提供)。**当前部署实际为 MariaDB 10.11.19**,TypeORM
|
||||||
|
使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+
|
||||||
|
共同子集内,不使用 MySQL 8 专有语法(窗口函数 CTE 递归除外)。
|
||||||
|
- 数据库名:`dpendance`(由部署方提供,通过 `DB_NAME` 注入;文档默认值 `opendance` 可覆盖)
|
||||||
|
- 字符集:`utf8mb4` / `utf8mb4_general_ci`(沿用现有实例默认排序规则)
|
||||||
|
- 迁移工具:TypeORM migration,**不使用 `DB_SYNCHRONIZE`**
|
||||||
|
|
||||||
|
## 连接信息(由部署方提供)
|
||||||
|
|
||||||
|
host / port / user / password 一律通过环境变量注入,**不入库、不入仓**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=192.168.150.10
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=dpendance
|
||||||
|
DB_PASS=<部署方提供>
|
||||||
|
DB_NAME=dpendance
|
||||||
|
DB_SYNCHRONIZE=false
|
||||||
|
DB_LOGGING=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- 所有表名使用 `snake_case`,复数形式(`songs` / `motions` / `models`)
|
||||||
|
- 主键统一为 `BIGINT AUTO_INCREMENT`,字段名 `id`
|
||||||
|
- 时间字段统一 `DATETIME`,`created_at` / `updated_at`,`updated_at` 由应用层维护
|
||||||
|
- 软删除统一使用 `deleted_at DATETIME NULL`(`@DeleteDateColumn`)
|
||||||
|
- 布尔字段使用 `TINYINT(1)`
|
||||||
|
- 来源(出处)字段为 5 元组,任何资产入库必须齐备:
|
||||||
|
`source_type` / `source_name` / `source_url` / `license` / `author`
|
||||||
|
- 不使用 `DB_SYNCHRONIZE`,所有表结构变更走 migration
|
||||||
|
- 每个 migration 必须可逆(提供 `down` 方法)
|
||||||
|
- 迁移文件名格式:`<timestamp>-<kebab-name>.ts`
|
||||||
|
|
||||||
|
## 表清单(v0.1.0)
|
||||||
|
|
||||||
|
| 表 | 用途 | 任务 |
|
||||||
|
|---|---|---|
|
||||||
|
| `songs` | 曲目元数据 | T-WEB-003 |
|
||||||
|
| `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 |
|
||||||
|
| `motions` | 动作数据(BVH/FBX…)+ 来源 + 预处理产物 | T-WEB-004 / 006 |
|
||||||
|
| `models` | 角色模型(VRM 优先)+ 来源 | T-WEB-005 |
|
||||||
|
| `review_records` | 上传审核流水 | T-WEB-007 |
|
||||||
|
|
||||||
|
## 状态机(`status` 列,v0.1.0)
|
||||||
|
|
||||||
|
```
|
||||||
|
draft ──submit──▶ pending ──approve──▶ published
|
||||||
|
│
|
||||||
|
└──reject──▶ rejected ──submit──▶ pending
|
||||||
|
```
|
||||||
|
|
||||||
|
- `published` 才可被 Windows 客户端 / Android App 消费
|
||||||
|
- `/api/catalog/export` 只导出 `published` 记录
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# contracts/model-formats.md — 模型与动作格式契约 v0.1.0
|
||||||
|
|
||||||
|
## 1. 角色模型(models)
|
||||||
|
|
||||||
|
| 格式 | 扩展名 | 支持级别 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| VRM | `.vrm` | **首选(P0)** | glTF 2.0 二进制 + VRM 扩展;Windows 端 godot-vrm 直接加载 |
|
||||||
|
| glTF Binary | `.glb` | P1 | 需含骨骼(skin);无骨骼则仅作静态展示 |
|
||||||
|
| glTF | `.gltf` (+ `.bin`, 贴图) | P2 | 多文件,管理端需打包为 zip 上传 |
|
||||||
|
| FBX | `.fbx` | P2 | 需 Assimp 转换,管理端仅归档,不保证重定向质量 |
|
||||||
|
|
||||||
|
**VRM 优先**的选取顺序见 `data-models.md` §4.1。
|
||||||
|
|
||||||
|
VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
|
||||||
|
`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
|
||||||
|
|
||||||
|
## 2. 动作数据(motions)
|
||||||
|
|
||||||
|
| 格式 | 扩展名 | 预处理支持 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 → FK → MediaPipe33 |
|
||||||
|
| FBX | `.fbx` | ⚠️ 需 Assimp | 服务端调用 `assimp` 可执行文件导出为 BVH/glTF 后再解析;未安装则 `preprocessState=failed` |
|
||||||
|
| glTF/GLB | `.gltf` / `.glb` | ⚠️ 需 Assimp | 同上 |
|
||||||
|
| 关键点 JSON | `.json` | ✅ 直通校验 | 已是 `mediapipe33@0.1.0` 结构,仅做校验与归一化 |
|
||||||
|
|
||||||
|
### 2.1 预处理产物(冻结)
|
||||||
|
|
||||||
|
- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM
|
||||||
|
- 一行结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"frame":0,"timeMs":0.0,"points":[[0.5,0.5,0.0,1.0],"…33 项"],"angles":[1.0,"…9 项"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `points[i] = [x, y, z, confidence]`,语义与 `websocket-keypoints.md` 解码后完全一致
|
||||||
|
- `angles` 为 `skeleton-topology.md` §5 定义的 9 个关节角余弦,顺序固定:
|
||||||
|
`[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]`
|
||||||
|
- 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30
|
||||||
|
- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**,
|
||||||
|
再投影到 MediaPipe 归一化图像空间:
|
||||||
|
`x_img = 0.5 + x_world * scale`,`y_img = 0.5 - y_world * scale`,`z_img = -z_world * scale`,
|
||||||
|
`scale = 0.35 / max(|x|,|y|,|z|)`(按整段动作的最大范围自适应,保证不出画)
|
||||||
|
- 缺失关节(BVH 中不存在对应骨骼):`confidence = 0.0`,坐标沿用最近可用父节点
|
||||||
|
|
||||||
|
### 2.2 BVH 关节名映射(默认表,可在 `web-server/src/preprocess/joint-map.ts` 扩展)
|
||||||
|
|
||||||
|
| MediaPipe 点 | 常见 BVH 关节名(不区分大小写,按序匹配) |
|
||||||
|
|---|---|
|
||||||
|
| 0 nose | `Head`, `Neck1`, `Head_Nub` |
|
||||||
|
| 11 left_shoulder | `LeftShoulder`, `LeftArm`, `LShoulder` |
|
||||||
|
| 12 right_shoulder | `RightShoulder`, `RightArm`, `RShoulder` |
|
||||||
|
| 13 left_elbow | `LeftForeArm`, `LeftElbow`, `LElbow` |
|
||||||
|
| 14 right_elbow | `RightForeArm`, `RightElbow`, `RElbow` |
|
||||||
|
| 15 left_wrist | `LeftHand`, `LeftWrist`, `LHand` |
|
||||||
|
| 16 right_wrist | `RightHand`, `RightWrist`, `RHand` |
|
||||||
|
| 23 left_hip | `LeftUpLeg`, `LeftHip`, `LHipJoint` |
|
||||||
|
| 24 right_hip | `RightUpLeg`, `RightHip`, `RHipJoint` |
|
||||||
|
| 25 left_knee | `LeftLeg`, `LeftKnee`, `LKnee` |
|
||||||
|
| 26 right_knee | `RightLeg`, `RightKnee`, `RKnee` |
|
||||||
|
| 27 left_ankle | `LeftFoot`, `LeftAnkle`, `LAnkle` |
|
||||||
|
| 28 right_ankle | `RightFoot`, `RightAnkle`, `RAnkle` |
|
||||||
|
| 31 left_foot_index | `LeftToeBase`, `LeftToe`, `LToe` |
|
||||||
|
| 32 right_foot_index | `RightToeBase`, `RightToe`, `RToe` |
|
||||||
|
|
||||||
|
未映射到的点按 `skeleton-topology.md` §5 的层级由父节点推算,
|
||||||
|
并置 `confidence` 降低(`0.5`),表示估算而非实测。
|
||||||
|
|
||||||
|
## 3. 上传限制
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
|
||||||
|
| 允许扩展名 | 上表全部;其他一律 422 |
|
||||||
|
| 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` |
|
||||||
|
| 去重 | 相同 sha256 复用同一 `assets` 记录,不重复落盘 |
|
||||||
|
| 文件名 | 服务端不使用原始文件名做路径,只存 `assets.original_name` |
|
||||||
|
|
||||||
|
## 4. 开源依赖与许可证(引用 `open-source-credits.md`)
|
||||||
|
|
||||||
|
- `bvh`(MIT)— BVH 解析参考实现
|
||||||
|
- `Assimp`(BSD-3-Clause)— FBX/glTF 转换,**可选外部依赖**
|
||||||
|
- `@pixiv/three-vrm`(MIT)— 管理端 VRM 预览
|
||||||
|
- `three`(MIT)— 管理端 3D 预览
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# contracts/open-source-credits.md — 开源声明锚点 v0.1.0
|
||||||
|
|
||||||
|
三端发布产物中的 `THIRD_PARTY.md` 与各自「关于」页面必须包含下表全部条目。
|
||||||
|
本文件是**唯一来源**,任一端新增依赖须回到 `opendance-web` 更新此表。
|
||||||
|
|
||||||
|
| # | 项目 | 许可证 | 源链接 | 使用端 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | MediaPipe | Apache License 2.0 | https://github.com/google-ai-edge/mediapipe | Android |
|
||||||
|
| 2 | Three.js | MIT | https://github.com/mrdoob/three.js | Web 管理端 |
|
||||||
|
| 3 | @pixiv/three-vrm | MIT | https://github.com/pixiv/three-vrm | Web 管理端 |
|
||||||
|
| 4 | Assimp | BSD-3-Clause | https://github.com/assimp/assimp | Web 服务端 / Windows |
|
||||||
|
| 5 | Godot Engine | MIT | https://github.com/godotengine/godot | Windows |
|
||||||
|
| 6 | godot-vrm | MIT | https://github.com/V-Sekai/godot-vrm | Windows |
|
||||||
|
| 7 | NestJS | MIT | https://github.com/nestjs/nest | Web 服务端 |
|
||||||
|
| 8 | TypeORM | MIT | https://github.com/typeorm/typeorm | Web 服务端 |
|
||||||
|
| 9 | OkHttp | Apache License 2.0 | https://github.com/square/okhttp | Android |
|
||||||
|
| 10 | bvh | MIT | https://github.com/aimoonchen/bvh | Web 服务端 / Android |
|
||||||
|
| 11 | React | MIT | https://github.com/facebook/react | Web 管理端 |
|
||||||
|
| 12 | Vite | MIT | https://github.com/vitejs/vite | Web 管理端 |
|
||||||
|
| 13 | CameraX | Apache License 2.0 | https://developer.android.com/jetpack/androidx/releases/camera | Android |
|
||||||
|
| 14 | Jetpack Compose | Apache License 2.0 | https://developer.android.com/jetpack/compose | Android |
|
||||||
|
| 15 | mysql2 | MIT | https://github.com/sidorares/node-mysql2 | Web 服务端 |
|
||||||
|
|
||||||
|
> 注意:文档模板中列出的 10 项为**最低要求**;第 11–15 项为实际引入的构建期/运行时依赖,
|
||||||
|
> 一并声明更完整。发布时必须保留各许可证副本(见 `web-server/LICENSE-THIRD-PARTY/`)。
|
||||||
|
|
||||||
|
## 版权声明补充
|
||||||
|
|
||||||
|
- MediaPipe: Copyright 2023 Google LLC
|
||||||
|
- Godot Engine: Copyright Godot Engine contributors
|
||||||
|
- 其余项目版权归各自作者所有
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: OpenDance Studio Web 管理服务端 API
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
OpenDance Studio 的唯一数据源与契约权威。
|
||||||
|
契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。
|
||||||
|
|
||||||
|
- 所有 JSON 字段 camelCase
|
||||||
|
- 列表接口统一 { items, total, page, pageSize }
|
||||||
|
- 来源五元组(sourceType/sourceName/sourceUrl/license/author)在三类资产上均为必填
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:3000
|
||||||
|
description: 本地开发
|
||||||
|
- url: http://<host>:3000
|
||||||
|
description: Docker 部署
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: health
|
||||||
|
- name: songs
|
||||||
|
- name: motions
|
||||||
|
- name: models
|
||||||
|
- name: reviews
|
||||||
|
- name: catalog
|
||||||
|
- name: assets
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
tags: [health]
|
||||||
|
summary: 健康检查(含数据库连通性)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 服务与数据库均正常
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Health'
|
||||||
|
'503':
|
||||||
|
description: 数据库不可用
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Health'
|
||||||
|
|
||||||
|
/api/songs:
|
||||||
|
get:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 曲目列表
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/PageSize'
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
description: 按 title/artist/album 模糊匹配
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
- name: artist
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 分页曲目
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SongPage'
|
||||||
|
post:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 新建曲目
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SongCreate'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 已创建
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Song' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
|
||||||
|
/api/songs/{id}:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
get:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 曲目详情
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 曲目
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Song' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
patch:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 更新曲目
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/SongUpdate' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 更新后的曲目
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Song' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
delete:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 删除曲目(软删除)
|
||||||
|
responses:
|
||||||
|
'204': { description: 已删除 }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
|
/api/songs/{id}/audio:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
post:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 上传曲目音频
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file]
|
||||||
|
properties:
|
||||||
|
file: { type: string, format: binary }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 上传后的曲目
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Song' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
|
||||||
|
/api/songs/{id}/cover:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
post:
|
||||||
|
tags: [songs]
|
||||||
|
summary: 上传曲目封面
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file]
|
||||||
|
properties:
|
||||||
|
file: { type: string, format: binary }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 上传后的曲目
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Song' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
|
||||||
|
/api/motions:
|
||||||
|
get:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 动作列表
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/PageSize'
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
- name: songId
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, format: int64 }
|
||||||
|
- name: format
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [bvh, fbx, gltf, glb, keypoints-json] }
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 分页动作
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/MotionPage' }
|
||||||
|
post:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 上传动作文件 + 来源信息
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
|
||||||
|
properties:
|
||||||
|
file: { type: string, format: binary }
|
||||||
|
name: { type: string }
|
||||||
|
songId: { type: integer, format: int64 }
|
||||||
|
fps: { type: number }
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string }
|
||||||
|
sourceUrl: { type: string }
|
||||||
|
license: { type: string }
|
||||||
|
author: { type: string }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
autoPreprocess: { type: boolean, default: false }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 已创建
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Motion' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'422':
|
||||||
|
description: 文件格式不支持或无法解析
|
||||||
|
|
||||||
|
/api/motions/{id}:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
get:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 动作详情
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 动作
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Motion' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
patch:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 更新动作元数据
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/MotionUpdate' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 更新后的动作
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Motion' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
delete:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 删除动作(软删除)
|
||||||
|
responses:
|
||||||
|
'204': { description: 已删除 }
|
||||||
|
|
||||||
|
/api/motions/{id}/preprocess:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
post:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 触发 BVH/FBX → MediaPipe33 关键点序列预处理(同步执行)
|
||||||
|
description: |
|
||||||
|
同步执行并返回最终状态:成功时 preprocessState=done;
|
||||||
|
失败时返回 422,且 Motion.preprocessError 记录可读原因。
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 预处理完成
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Motion' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'422':
|
||||||
|
description: 预处理失败,reason 见 preprocessError
|
||||||
|
|
||||||
|
/api/motions/{id}/keypoints:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
get:
|
||||||
|
tags: [motions]
|
||||||
|
summary: 下载关键点序列(NDJSON,每行一帧)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: |
|
||||||
|
application/x-ndjson,每行一个帧对象:
|
||||||
|
{"frame":0,"timeMs":0,"points":[[x,y,z,conf], …33],"angles":[9 个余弦]}
|
||||||
|
content:
|
||||||
|
application/x-ndjson:
|
||||||
|
schema: { type: string }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
'409':
|
||||||
|
description: 尚未完成预处理
|
||||||
|
|
||||||
|
/api/models:
|
||||||
|
get:
|
||||||
|
tags: [models]
|
||||||
|
summary: 模型列表
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/PageSize'
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
- name: format
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [vrm, glb, gltf, fbx] }
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 分页模型
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ModelPage' }
|
||||||
|
post:
|
||||||
|
tags: [models]
|
||||||
|
summary: 上传模型 + 来源信息(VRM 优先)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file, name, sourceType, sourceName, sourceUrl, license, author]
|
||||||
|
properties:
|
||||||
|
file: { type: string, format: binary }
|
||||||
|
name: { type: string }
|
||||||
|
isPrimary: { type: boolean }
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string }
|
||||||
|
sourceUrl: { type: string }
|
||||||
|
license: { type: string }
|
||||||
|
author: { type: string }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: 已创建
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Model' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'422':
|
||||||
|
description: 不支持的模型格式
|
||||||
|
|
||||||
|
/api/models/{id}:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
get:
|
||||||
|
tags: [models]
|
||||||
|
summary: 模型详情
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 模型
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Model' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
patch:
|
||||||
|
tags: [models]
|
||||||
|
summary: 更新模型元数据
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ModelUpdate' }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 更新后的模型
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Model' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
delete:
|
||||||
|
tags: [models]
|
||||||
|
summary: 删除模型(软删除)
|
||||||
|
responses:
|
||||||
|
'204': { description: 已删除 }
|
||||||
|
|
||||||
|
/api/models/{id}/primary:
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
post:
|
||||||
|
tags: [models]
|
||||||
|
summary: 设为全局默认模型(互斥,同事务清零其余)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 设置后的模型
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Model' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
|
/api/reviews:
|
||||||
|
get:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 审核流水 / 待审列表
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: query
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- name: targetId
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, format: int64 }
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
description: 传入时改为返回该状态的待审资产列表(合并三类)
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/PageSize'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 审核流水或待审资产
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ReviewPage'
|
||||||
|
- $ref: '#/components/schemas/PendingPage'
|
||||||
|
|
||||||
|
/api/reviews/{targetType}/{id}/submit:
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
post:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 提交审核
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reviewer: { type: string }
|
||||||
|
comment: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: 状态已流转 }
|
||||||
|
'409': { description: 当前状态不允许该操作 }
|
||||||
|
|
||||||
|
/api/reviews/{targetType}/{id}/approve:
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
post:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 审核通过 → published
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reviewer: { type: string }
|
||||||
|
comment: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: 状态已流转 }
|
||||||
|
'409': { description: 当前状态不允许该操作 }
|
||||||
|
|
||||||
|
/api/reviews/{targetType}/{id}/withdraw:
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
post:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 撤回审核 → draft
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reviewer: { type: string }
|
||||||
|
comment: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: 状态已流转 }
|
||||||
|
'409': { description: 当前状态不允许该操作 }
|
||||||
|
|
||||||
|
/api/reviews/{targetType}/{id}/history:
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
get:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 单个资产的审核历史
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 审核流水(按时间倒序)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/ReviewRecord' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
|
/api/reviews/{targetType}/{id}/reject:
|
||||||
|
parameters:
|
||||||
|
- name: targetType
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [song, motion, model] }
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
post:
|
||||||
|
tags: [reviews]
|
||||||
|
summary: 审核驳回 → rejected
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [comment]
|
||||||
|
properties:
|
||||||
|
reviewer: { type: string }
|
||||||
|
comment: { type: string }
|
||||||
|
responses:
|
||||||
|
'200': { description: 状态已流转 }
|
||||||
|
'400': { description: 驳回必须填写理由 }
|
||||||
|
'409': { description: 当前状态不允许该操作 }
|
||||||
|
|
||||||
|
/api/catalog/export:
|
||||||
|
get:
|
||||||
|
tags: [catalog]
|
||||||
|
summary: 客户端一次性拉取已发布目录
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: CatalogBundle
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/CatalogBundle' }
|
||||||
|
|
||||||
|
/api/catalog/version:
|
||||||
|
get:
|
||||||
|
tags: [catalog]
|
||||||
|
summary: 契约版本与目录指纹(客户端增量同步用)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 版本信息
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
contractVersion: { type: object, additionalProperties: { type: string } }
|
||||||
|
revision: { type: string, description: 目录内容 sha256,变化即需重新拉取 }
|
||||||
|
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Id:
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer, format: int64 }
|
||||||
|
Page:
|
||||||
|
name: page
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, minimum: 1, default: 1 }
|
||||||
|
PageSize:
|
||||||
|
name: pageSize
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
|
||||||
|
|
||||||
|
responses:
|
||||||
|
BadRequest:
|
||||||
|
description: 参数校验失败(含来源五元组缺失)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ApiError' }
|
||||||
|
NotFound:
|
||||||
|
description: 资源不存在
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ApiError' }
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
SourceType:
|
||||||
|
type: string
|
||||||
|
enum: [original, licensed, cc, public_domain, user_upload, other]
|
||||||
|
PublishStatus:
|
||||||
|
type: string
|
||||||
|
enum: [draft, pending, published, rejected]
|
||||||
|
MotionFormat:
|
||||||
|
type: string
|
||||||
|
enum: [bvh, fbx, gltf, glb, keypoints-json]
|
||||||
|
ModelFormat:
|
||||||
|
type: string
|
||||||
|
enum: [vrm, glb, gltf, fbx]
|
||||||
|
|
||||||
|
ApiError:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
statusCode: { type: integer }
|
||||||
|
message: { oneOf: [{ type: string }, { type: array, items: { type: string } }] }
|
||||||
|
error: { type: string }
|
||||||
|
|
||||||
|
SourceFields:
|
||||||
|
type: object
|
||||||
|
required: [sourceType, sourceName, sourceUrl, license, author]
|
||||||
|
properties:
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string, maxLength: 200 }
|
||||||
|
sourceUrl: { type: string, maxLength: 500, description: 必须为 http(s) URL }
|
||||||
|
license: { type: string, maxLength: 100 }
|
||||||
|
author: { type: string, maxLength: 200 }
|
||||||
|
|
||||||
|
Health:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status: { type: string, enum: [ok, degraded] }
|
||||||
|
uptimeSec: { type: number }
|
||||||
|
version: { type: string }
|
||||||
|
contractVersion: { type: object, additionalProperties: { type: string } }
|
||||||
|
database:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
connected: { type: boolean }
|
||||||
|
server: { type: string, description: '如 MariaDB 10.11.19' }
|
||||||
|
database: { type: string }
|
||||||
|
migrationsPending: { type: integer }
|
||||||
|
|
||||||
|
Song:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SourceFields'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
title: { type: string }
|
||||||
|
artist: { type: string }
|
||||||
|
album: { type: string, nullable: true }
|
||||||
|
language: { type: string, nullable: true }
|
||||||
|
durationMs: { type: integer, nullable: true }
|
||||||
|
bpm: { type: number, nullable: true }
|
||||||
|
difficulty: { type: integer, nullable: true, minimum: 1, maximum: 5 }
|
||||||
|
tags: { type: array, items: { type: string } }
|
||||||
|
coverUrl: { type: string, nullable: true }
|
||||||
|
audioUrl: { type: string, nullable: true }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
|
||||||
|
SongCreate:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SourceFields'
|
||||||
|
- type: object
|
||||||
|
required: [title, artist]
|
||||||
|
properties:
|
||||||
|
title: { type: string, maxLength: 200 }
|
||||||
|
artist: { type: string, maxLength: 200 }
|
||||||
|
album: { type: string, maxLength: 200 }
|
||||||
|
language: { type: string }
|
||||||
|
durationMs: { type: integer }
|
||||||
|
bpm: { type: number }
|
||||||
|
difficulty: { type: integer, minimum: 1, maximum: 5 }
|
||||||
|
tags: { type: array, items: { type: string } }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
|
||||||
|
SongUpdate:
|
||||||
|
type: object
|
||||||
|
description: 所有字段可选;提供 source* 时必须五元组同时提供
|
||||||
|
properties:
|
||||||
|
title: { type: string }
|
||||||
|
artist: { type: string }
|
||||||
|
album: { type: string }
|
||||||
|
language: { type: string }
|
||||||
|
durationMs: { type: integer }
|
||||||
|
bpm: { type: number }
|
||||||
|
difficulty: { type: integer, minimum: 1, maximum: 5 }
|
||||||
|
tags: { type: array, items: { type: string } }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string }
|
||||||
|
sourceUrl: { type: string }
|
||||||
|
license: { type: string }
|
||||||
|
author: { type: string }
|
||||||
|
|
||||||
|
SongPage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||||
|
total: { type: integer }
|
||||||
|
page: { type: integer }
|
||||||
|
pageSize: { type: integer }
|
||||||
|
|
||||||
|
Motion:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SourceFields'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
songId: { type: integer, format: int64, nullable: true }
|
||||||
|
name: { type: string }
|
||||||
|
format: { $ref: '#/components/schemas/MotionFormat' }
|
||||||
|
fileUrl: { type: string, nullable: true }
|
||||||
|
fileSize: { type: integer, nullable: true }
|
||||||
|
fps: { type: number, nullable: true }
|
||||||
|
frameCount: { type: integer, nullable: true }
|
||||||
|
durationMs: { type: integer, nullable: true }
|
||||||
|
keypointFormat: { type: string, nullable: true }
|
||||||
|
keypointUrl: { type: string, nullable: true }
|
||||||
|
keypointFrames: { type: integer, nullable: true }
|
||||||
|
preprocessState: { type: string, enum: [none, running, done, failed] }
|
||||||
|
preprocessError: { type: string, nullable: true }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
|
||||||
|
MotionUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
songId: { type: integer, format: int64, nullable: true }
|
||||||
|
fps: { type: number }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string }
|
||||||
|
sourceUrl: { type: string }
|
||||||
|
license: { type: string }
|
||||||
|
author: { type: string }
|
||||||
|
|
||||||
|
MotionPage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||||
|
total: { type: integer }
|
||||||
|
page: { type: integer }
|
||||||
|
pageSize: { type: integer }
|
||||||
|
|
||||||
|
Model:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SourceFields'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
name: { type: string }
|
||||||
|
format: { $ref: '#/components/schemas/ModelFormat' }
|
||||||
|
vrmVersion: { type: string, nullable: true }
|
||||||
|
isPrimary: { type: boolean }
|
||||||
|
fileUrl: { type: string, nullable: true }
|
||||||
|
fileSize: { type: integer, nullable: true }
|
||||||
|
thumbnailUrl: { type: string, nullable: true }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
|
||||||
|
ModelUpdate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
sourceType: { $ref: '#/components/schemas/SourceType' }
|
||||||
|
sourceName: { type: string }
|
||||||
|
sourceUrl: { type: string }
|
||||||
|
license: { type: string }
|
||||||
|
author: { type: string }
|
||||||
|
|
||||||
|
ModelPage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
||||||
|
total: { type: integer }
|
||||||
|
page: { type: integer }
|
||||||
|
pageSize: { type: integer }
|
||||||
|
|
||||||
|
ReviewRecord:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: integer, format: int64 }
|
||||||
|
targetType: { type: string, enum: [song, motion, model] }
|
||||||
|
targetId: { type: integer, format: int64 }
|
||||||
|
action: { type: string, enum: [submit, approve, reject, withdraw] }
|
||||||
|
fromStatus: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
toStatus: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
reviewer: { type: string, nullable: true }
|
||||||
|
comment: { type: string, nullable: true }
|
||||||
|
createdAt: { type: string, format: date-time }
|
||||||
|
|
||||||
|
ReviewPage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items: { type: array, items: { $ref: '#/components/schemas/ReviewRecord' } }
|
||||||
|
total: { type: integer }
|
||||||
|
page: { type: integer }
|
||||||
|
pageSize: { type: integer }
|
||||||
|
|
||||||
|
PendingPage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
targetType: { type: string, enum: [song, motion, model] }
|
||||||
|
targetId: { type: integer, format: int64 }
|
||||||
|
name: { type: string }
|
||||||
|
status: { $ref: '#/components/schemas/PublishStatus' }
|
||||||
|
source: { $ref: '#/components/schemas/SourceFields' }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
total: { type: integer }
|
||||||
|
page: { type: integer }
|
||||||
|
pageSize: { type: integer }
|
||||||
|
|
||||||
|
CatalogBundle:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
contractVersion: { type: object, additionalProperties: { type: string } }
|
||||||
|
generatedAt: { type: string, format: date-time }
|
||||||
|
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
|
||||||
|
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
|
||||||
|
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
|
||||||
|
defaultModelId: { type: integer, format: int64, nullable: true }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# contracts/skeleton-topology.md — 骨骼拓扑契约 v0.1.0
|
||||||
|
|
||||||
|
**唯一拓扑:MediaPipe Pose 33 点。禁止引入 SMPL 及其衍生拓扑(许可风险)。**
|
||||||
|
|
||||||
|
## 1. 33 点定义与索引
|
||||||
|
|
||||||
|
| idx | name | 中文 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | nose | 鼻 | |
|
||||||
|
| 1 | left_eye_inner | 左眼内 | |
|
||||||
|
| 2 | left_eye | 左眼 | |
|
||||||
|
| 3 | left_eye_outer | 左眼外 | |
|
||||||
|
| 4 | right_eye_inner | 右眼内 | |
|
||||||
|
| 5 | right_eye | 右眼 | |
|
||||||
|
| 6 | right_eye_outer | 右眼外 | |
|
||||||
|
| 7 | left_ear | 左耳 | |
|
||||||
|
| 8 | right_ear | 右耳 | |
|
||||||
|
| 9 | mouth_left | 左嘴角 | |
|
||||||
|
| 10 | mouth_right | 右嘴角 | |
|
||||||
|
| 11 | left_shoulder | 左肩 | |
|
||||||
|
| 12 | right_shoulder | 右肩 | |
|
||||||
|
| 13 | left_elbow | 左肘 | |
|
||||||
|
| 14 | right_elbow | 右肘 | |
|
||||||
|
| 15 | left_wrist | 左腕 | |
|
||||||
|
| 16 | right_wrist | 右腕 | |
|
||||||
|
| 17 | left_pinky | 左小指 | |
|
||||||
|
| 18 | right_pinky | 右小指 | |
|
||||||
|
| 19 | left_index | 左食指 | |
|
||||||
|
| 20 | right_index | 右食指 | |
|
||||||
|
| 21 | left_thumb | 左拇指 | |
|
||||||
|
| 22 | right_thumb | 右拇指 | |
|
||||||
|
| 23 | left_hip | 左髋 | |
|
||||||
|
| 24 | right_hip | 右髋 | |
|
||||||
|
| 25 | left_knee | 左膝 | |
|
||||||
|
| 26 | right_knee | 右膝 | |
|
||||||
|
| 27 | left_ankle | 左踝 | |
|
||||||
|
| 28 | right_ankle | 右踝 | |
|
||||||
|
| 29 | left_heel | 左足跟 | |
|
||||||
|
| 30 | right_heel | 右足跟 | |
|
||||||
|
| 31 | left_foot_index | 左足尖 | |
|
||||||
|
| 32 | right_foot_index | 右足尖 | |
|
||||||
|
|
||||||
|
## 2. 虚拟关节(由 33 点派生,索引 33/34,仅用于层级与角度特征)
|
||||||
|
|
||||||
|
| idx | name | 计算 |
|
||||||
|
|---|---|---|
|
||||||
|
| 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` |
|
||||||
|
| 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` |
|
||||||
|
|
||||||
|
## 3. 父节点表(`parent[i]`,-1 表示世界根)
|
||||||
|
|
||||||
|
对称树,便于骨骼重定向与关节角计算:
|
||||||
|
|
||||||
|
```
|
||||||
|
34 pelvis -> -1 (world root)
|
||||||
|
33 neck -> 34
|
||||||
|
23 left_hip -> 34
|
||||||
|
24 right_hip -> 34
|
||||||
|
11 left_shoulder -> 33
|
||||||
|
12 right_shoulder -> 33
|
||||||
|
0 nose -> 33
|
||||||
|
1 left_eye_inner -> 0
|
||||||
|
2 left_eye -> 0
|
||||||
|
3 left_eye_outer -> 0
|
||||||
|
4 right_eye_inner -> 0
|
||||||
|
5 right_eye -> 0
|
||||||
|
6 right_eye_outer -> 0
|
||||||
|
7 left_ear -> 3
|
||||||
|
8 right_ear -> 6
|
||||||
|
9 mouth_left -> 0
|
||||||
|
10 mouth_right -> 0
|
||||||
|
25 left_knee -> 23
|
||||||
|
26 right_knee -> 24
|
||||||
|
27 left_ankle -> 25
|
||||||
|
28 right_ankle -> 26
|
||||||
|
29 left_heel -> 27
|
||||||
|
30 right_heel -> 28
|
||||||
|
31 left_foot_index -> 29
|
||||||
|
32 right_foot_index -> 30
|
||||||
|
13 left_elbow -> 11
|
||||||
|
14 right_elbow -> 12
|
||||||
|
15 left_wrist -> 13
|
||||||
|
16 right_wrist -> 14
|
||||||
|
17 left_pinky -> 15
|
||||||
|
18 right_pinky -> 16
|
||||||
|
19 left_index -> 15
|
||||||
|
20 right_index -> 16
|
||||||
|
21 left_thumb -> 15
|
||||||
|
22 right_thumb -> 16
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 坐标系
|
||||||
|
|
||||||
|
- **归一化图像坐标**:`x, y ∈ [0, 1]`,原点左上角,`y` 向下;`x` 相对图像宽,`y` 相对图像高。
|
||||||
|
- **深度**:`z` 与 `x` 同尺度,原点位于髋中心,**负值朝向相机**。
|
||||||
|
- **镜像**:Android 前置摄像头默认输出镜像画面;是否镜像由帧头 `flags.bit0` 声明,
|
||||||
|
接收端据此决定是否翻转 `x`(`x' = 1 - x`)。
|
||||||
|
- **置信度**:`visibility ∈ [0, 1]`,落盘/传输为 `uint8`(`round(v * 255)`)。
|
||||||
|
归一化比例统一使用 `32767`(见 `websocket-keypoints.md`)。
|
||||||
|
|
||||||
|
## 5. 派生特征(DTW 输入,Windows 端实现,服务端预处理产物同构)
|
||||||
|
|
||||||
|
服务端 T-WEB-006 预处理产出的关键点序列与 Android 端输出的帧**结构完全一致**,
|
||||||
|
因此 Windows 端 DTW 引擎只有一条输入通路。
|
||||||
|
|
||||||
|
关节角特征(弧度,余弦值便于插值):
|
||||||
|
|
||||||
|
```
|
||||||
|
shoulder_L = angle(left_elbow, left_shoulder, left_hip)
|
||||||
|
shoulder_R = angle(right_elbow, right_shoulder, right_hip)
|
||||||
|
elbow_L = angle(left_shoulder, left_elbow, left_wrist)
|
||||||
|
elbow_R = angle(right_shoulder, right_elbow, right_wrist)
|
||||||
|
hip_L = angle(left_shoulder, left_hip, left_knee)
|
||||||
|
hip_R = angle(right_shoulder, right_hip, right_knee)
|
||||||
|
knee_L = angle(left_hip, left_knee, left_ankle)
|
||||||
|
knee_R = angle(right_hip, right_knee, right_ankle)
|
||||||
|
neck = angle(nose, neck, pelvis)
|
||||||
|
spine = angle(neck, pelvis, midpoint(left_ankle, right_ankle))
|
||||||
|
```
|
||||||
|
|
||||||
|
`angle(a, b, c)` = 向量 `ba` 与 `bc` 的夹角余弦,即
|
||||||
|
`dot(normalize(a-b), normalize(c-b))`,落在 `[-1, 1]`。
|
||||||
|
|
||||||
|
**DTW 序列结构(冻结)**:每帧 = `[33 × (x, y, z), 9 个关节角余弦] = 108 维`。
|
||||||
|
低置信度点(`visibility < 0.5`)在插值阶段由相邻帧线性补全,不参与代价计算。
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# contracts/websocket-keypoints.md — 关键点传输契约 v0.1.0
|
||||||
|
|
||||||
|
Android 辅助 App(发送端)→ Windows 客户端(接收端)。
|
||||||
|
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
|
||||||
|
|
||||||
|
## 1. 端点
|
||||||
|
|
||||||
|
```
|
||||||
|
ws://<windows-host>:8765/pose
|
||||||
|
```
|
||||||
|
|
||||||
|
- Windows 客户端监听,Android App 主动连接。
|
||||||
|
- 子协议:`opendance.pose.v1`(`Sec-WebSocket-Protocol`)
|
||||||
|
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
|
||||||
|
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
|
||||||
|
|
||||||
|
## 2. 二进制帧:239 字节(冻结)
|
||||||
|
|
||||||
|
**小端序(little-endian)**。
|
||||||
|
|
||||||
|
```
|
||||||
|
偏移 长度 字段 类型 说明
|
||||||
|
0 2 magic u16 LE 固定 0x4F44(ASCII "OD")
|
||||||
|
2 1 version u8 固定 0x01
|
||||||
|
3 1 flags u8 bit0 = 镜像画面(mirrored)
|
||||||
|
bit1 = 本帧含低置信度点(<0.5)
|
||||||
|
bit2 = 上半身模式(仅 0..22 有效)
|
||||||
|
bit3-7 保留,必须为 0
|
||||||
|
4 4 frame_id u32 LE 单调递增,从 0 开始
|
||||||
|
8 231 keypoints 33 × 7B 见下
|
||||||
|
--------------------------------------------------------------
|
||||||
|
合计 239
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单个关键点(7 字节,`i` 为 0..32)
|
||||||
|
|
||||||
|
```
|
||||||
|
偏移 长度 字段 类型 换算
|
||||||
|
+0 2 x i16 LE x_norm = x / 32767.0
|
||||||
|
+2 2 y i16 LE y_norm = y / 32767.0
|
||||||
|
+4 2 z i16 LE z_norm = z / 32767.0
|
||||||
|
+6 1 conf u8 visibility = conf / 255.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- 点索引与坐标语义见 `skeleton-topology.md`。
|
||||||
|
- 编码:`x = clamp(round(x_norm * 32767), -32768, 32767)`,`y`、`z` 同理。
|
||||||
|
- 低置信度点(`conf < 128`)坐标无意义,接收端必须插值,不得直接使用。
|
||||||
|
- `i16` 为有符号数:`0x8000` 表示 `-32768`。
|
||||||
|
|
||||||
|
### 参考实现(GDScript 风格伪码)
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func decode(buf: PackedByteArray) -> Dictionary:
|
||||||
|
assert(buf.size() == 239)
|
||||||
|
var magic := buf.decode_u16(0)
|
||||||
|
assert(magic == 0x4F44 and buf.decode_u8(2) == 1)
|
||||||
|
var flags := buf.decode_u8(3)
|
||||||
|
var frame_id := buf.decode_u32(4)
|
||||||
|
var pts := []
|
||||||
|
for i in 33:
|
||||||
|
var o := 8 + i * 7
|
||||||
|
pts.append({
|
||||||
|
"x": float(buf.decode_s16(o)) / 32767.0,
|
||||||
|
"y": float(buf.decode_s16(o + 2)) / 32767.0,
|
||||||
|
"z": float(buf.decode_s16(o + 4)) / 32767.0,
|
||||||
|
"conf": float(buf.decode_u8(o + 6)) / 255.0,
|
||||||
|
})
|
||||||
|
return {"flags": flags, "frame_id": frame_id, "points": pts}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 文本控制帧(JSON,UTF-8)
|
||||||
|
|
||||||
|
### 3.1 `hello` — Android → Windows,连接后立即发送
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "hello",
|
||||||
|
"protocol": "opendance.pose.v1",
|
||||||
|
"device": "Pixel 8",
|
||||||
|
"appVersion": "0.1.0",
|
||||||
|
"cameraFacing": "front",
|
||||||
|
"targetFps": 30,
|
||||||
|
"resolution": "640x480"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `welcome` — Windows → Android
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 `config` — Windows → Android,运行中调整
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 `stats` — Android → Windows,每秒一次
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "stats", "fps": 29.6, "dropped": 3, "latencyMs": 42, "confidenceAvg": 0.93 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 `bye` — 双向
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "bye", "reason": "user_stop" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. JSON 调试帧(可选,仅开发期)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pose",
|
||||||
|
"protocol": "opendance.pose.v1",
|
||||||
|
"frameId": 1024,
|
||||||
|
"timestampMs": 1730000000000,
|
||||||
|
"flags": { "mirrored": false, "upperBodyOnly": false },
|
||||||
|
"points": [[0.51, 0.22, -0.03, 0.99], "…共 33 项,顺序固定 [x, y, z, conf]"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
|
||||||
|
|
||||||
|
## 5. 兼容性规则
|
||||||
|
|
||||||
|
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
|
||||||
|
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
|
||||||
|
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
|
||||||
|
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
|
||||||
Reference in New Issue
Block a user