commit 9b8fc6be96c539ca0600685f42f9c646c53e9071 Author: zijunchiang Date: Mon Sep 14 19:00:55 2026 +0800 chore(contracts): 冻结 v0.1.0 跨端契约(REST/WebSocket/数据模型/拓扑/格式/数据库) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba7a864 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# 依赖与构建产物 +node_modules/ +dist/ +build/ +coverage/ +*.tsbuildinfo + +# 环境变量:只提交样例 +.env +.env.* +!.env.example + +# 上传存储与本地数据(锚定到具体路径,避免误伤 web-server/src/storage 源码目录) +/web-server/storage/ +/web-server/data/ +/web-server/tmp/ + +# 发布产物 +dist-release/ +*.tar.gz +*.zip + +# 日志 +*.log +npm-debug.log* + +# 编辑器 / 系统 +.idea/ +.vscode/ +*.swp +.DS_Store +Thumbs.db diff --git a/contracts/VERSION b/contracts/VERSION new file mode 100644 index 0000000..66b5603 --- /dev/null +++ b/contracts/VERSION @@ -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 diff --git a/contracts/data-models.md b/contracts/data-models.md new file mode 100644 index 0000000..9acd409 --- /dev/null +++ b/contracts/data-models.md @@ -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 的规则解析得出,客户端无需自行实现优先级。 diff --git a/contracts/database.md b/contracts/database.md new file mode 100644 index 0000000..a3596c4 --- /dev/null +++ b/contracts/database.md @@ -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` 方法) +- 迁移文件名格式:`-.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` 记录 diff --git a/contracts/model-formats.md b/contracts/model-formats.md new file mode 100644 index 0000000..c4af693 --- /dev/null +++ b/contracts/model-formats.md @@ -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 预览 diff --git a/contracts/open-source-credits.md b/contracts/open-source-credits.md new file mode 100644 index 0000000..4a6d166 --- /dev/null +++ b/contracts/open-source-credits.md @@ -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 +- 其余项目版权归各自作者所有 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml new file mode 100644 index 0000000..61493fd --- /dev/null +++ b/contracts/rest-api.yaml @@ -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://: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 } diff --git a/contracts/skeleton-topology.md b/contracts/skeleton-topology.md new file mode 100644 index 0000000..c4ba13c --- /dev/null +++ b/contracts/skeleton-topology.md @@ -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`)在插值阶段由相邻帧线性补全,不参与代价计算。 diff --git a/contracts/websocket-keypoints.md b/contracts/websocket-keypoints.md new file mode 100644 index 0000000..4930921 --- /dev/null +++ b/contracts/websocket-keypoints.md @@ -0,0 +1,131 @@ +# contracts/websocket-keypoints.md — 关键点传输契约 v0.1.0 + +Android 辅助 App(发送端)→ Windows 客户端(接收端)。 +服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。 + +## 1. 端点 + +``` +ws://: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` 对应本契约。