10 Commits
Author SHA1 Message Date
zijunchiang ab88231ea0 docs: 全角 | / ? 改 ASCII(Gitea 混淆字符检测的剩余触发项) 2026-09-15 21:36:41 +08:00
zijunchiang 91e56135fc docs: 其余易混淆字符(x/>=/<=/~/OK 取代 × − ≥ ≤ ≈ ✓) 2026-09-15 21:34:50 +08:00
zijunchiang 264786134c docs: 新增「初次部署(从零跑起来)」;去混淆字符与真实内网信息
- README.md / README.txt 新增初次部署章节:前置条件(JDK 17 / SDK 34 / Gradle 8.9)、
  获取代码与本地配置(local.properties 不入仓)、Debug/Release 构建、装到手机与相机权限、
  连接配置(只连 Windows 客户端,默认 8765 / /pose)、第一次跑通、自检清单、常见问题
- 单测数量按实际(30 项)修正
- 三库同源契约:':=' 写法修回、去掉真实 MariaDB 版本串
- 全文去除易被 Gitea 判为「模棱两可 Unicode」的字符
- 本机 JDK 路径与内网示例地址改占位符 / RFC 5737 文档网段
2026-09-15 21:33:27 +08:00
zijunchiang 203dcd8a9c chore(contracts): 同步 rest-api(新增 POST /api/models/{id}/reanalyze) 2026-09-15 21:18:21 +08:00
zijunchiang 5563a78e07 chore(contracts): 同步契约到 0.4.0/0.3.0(GLB/VRM 主力格式、模型审核与骨骼分析) 2026-09-15 20:37:58 +08:00
zijunchiang cee82c7b36 chore(contracts): 同步契约(默认模型改用压缩包 sha256 匹配,补 sourceSha256 与确定性说明) 2026-09-15 19:36:42 +08:00
zijunchiang f9e88901dd chore(contracts): 同步契约到 rest-api/data-models/model-formats 0.3.0、database 0.2.0(模型压缩包转换与默认模型规则) 2026-09-15 13:55:59 +08:00
zijunchiang 6f15d119ed chore(contracts): 同步契约到 v0.2.0(rest-api / data-models / model-formats)
Web 端需求变更带来的契约字段级改动:
- MotionFormat 枚举新增 vmd(MikuMikuDance 动作数据)
- rest-api 服务地址说明改为双端口语义(软件 API 端口给客户端 / 管理端端口给浏览器)
- model-formats 新增 §2.3 VMD 解析约定(标准 MMD 骨架、腿部 IK/FK 判定、手性转换)

三库契约文件保持逐字节一致。
2026-09-15 08:45:06 +08:00
zijunchiang eb534ed7ee docs(contracts): 服务地址描述由 Docker 部署改为 Linux 业务服务部署(Web 端已去 Docker) 2026-09-15 01:27:50 +08:00
zijunchiang e9ea1b5811 fix(android): 修复进入推流页立即闪退(未捕获 Error)
用户反馈:扫描到客户端后点击「开始推流」立刻闪退。

根因:PoseDetector.initialize() 只 catch (Exception),而 MediaPipe 初始化
失败会抛 UnsatisfiedLinkError / NoClassDefFoundError / ExceptionInInitializerError
—— 这些都是 Error,不是 Exception,会直接冒泡成进程崩溃。

证据(对照实验):新增 Robolectric 组合测试后
  改回 catch (Exception):5 项测试 2 项失败
    - java.lang.UnsatisfiedLinkError: no mediapipe_tasks_jni in java.library.path
    - java.lang.NoClassDefFoundError: Could not initialize class ...PoseLandmarker
  改为 catch (Throwable):5 项全部通过

修复与加固:
- PoseDetector / CameraController 全部改为捕获 Throwable,失败只提示不崩溃
- 移除 accompanist-permissions 0.34.0(2024-02 发布,会传递引入
  compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险),
  改用平台 Activity Result API
- MediaPipe 回调在相机线程:所有 Compose 状态写入统一 post 回主线程
- WebSocket 连接、相机启动、单帧分析全部包上失败保护
- 新增 CrashLogger:未捕获异常落盘到 filesDir/crashes/,
  并在连接页直接展示堆栈(免去连 adb 抓 logcat)
- 新增 ScreenCompositionTest(Robolectric):真实组合三个页面,
  覆盖「进入页面即崩溃」这一类缺陷,共 30 项单元测试
2026-09-14 22:43:03 +08:00
31 changed files with 1067 additions and 257 deletions
+15 -11
View File
@@ -1,4 +1,4 @@
# DSH.md —— opendance-android(OpenDance Studio 项目三:Android 辅助 App) # DSH.md -- opendance-android(OpenDance Studio 项目三:Android 辅助 App)
## 0. 本文件的作用 ## 0. 本文件的作用
@@ -17,12 +17,12 @@ WebSocket 推给 Windows 客户端。
ws://<windows-host>:8765/pose ws://<windows-host>:8765/pose
``` ```
**画面不离开设备** —— 只有关键点坐标经网络发送。 **画面不离开设备** -- 只有关键点坐标经网络发送。
## 2. 技术栈 ## 2. 技术栈
| 组件 | 选型 | | 组件 | 选型 |
|---|---| |--|--|
| 语言 | Kotlin 2.0.21 | | 语言 | Kotlin 2.0.21 |
| 构建 | AGP 8.7.3 + Gradle 8.9 + JDK 17 | | 构建 | AGP 8.7.3 + Gradle 8.9 + JDK 17 |
| 相机 | CameraX 1.4.0 (Apache 2.0) | | 相机 | CameraX 1.4.0 (Apache 2.0) |
@@ -50,10 +50,10 @@ WebSocket 推给 Windows 客户端。
## 4. 上下文预算 ## 4. 上下文预算
| 项 | 建议值 | | 项 | 建议值 |
|---|---| |--|--|
| 单任务文件数 | ≤ 6 | | 单任务文件数 | <= 6 |
| 单任务代码行数 | ≤ 1500 | | 单任务代码行数 | <= 1500 |
| 单任务 Token | ≤ 12K | | 单任务 Token | <= 12K |
| 契约引用 | 只引用锚点,不加载全文 | | 契约引用 | 只引用锚点,不加载全文 |
## 5. 目录结构 ## 5. 目录结构
@@ -91,7 +91,7 @@ opendance-android/
也是与「上传视频做云端推理」方案的本质区别。 也是与「上传视频做云端推理」方案的本质区别。
2. **239 字节帧格式冻结**。任何字段改动都要先改 `opendance-web` 的 2. **239 字节帧格式冻结**。任何字段改动都要先改 `opendance-web` 的
`contracts/websocket-keypoints.md` 并提升版本。 `contracts/websocket-keypoints.md` 并提升版本。
本端序列化器有金标准字节向量测试守护(见 §8)。 本端序列化器有金标准字节向量测试守护(见 8)。
3. **33 点顺序冻结**。MediaPipe Pose 的原始索引顺序**就是**契约顺序, 3. **33 点顺序冻结**。MediaPipe Pose 的原始索引顺序**就是**契约顺序,
不得重排或插入。 不得重排或插入。
4. **镜像语义**:发送**未镜像**的原始坐标,是否镜像由 `flags.bit0` 声明; 4. **镜像语义**:发送**未镜像**的原始坐标,是否镜像由 `flags.bit0` 声明;
@@ -103,14 +103,18 @@ opendance-android/
## 7. 验证(每任务执行) ## 7. 验证(每任务执行)
```bash ```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 # AGP 8.7 需要 JDK 17 export JAVA_HOME=<JDK 17 路径> # AGP 8.7 需要 JDK 17
export ANDROID_HOME=/path/to/android-sdk export ANDROID_HOME=<Android SDK 路径>
./gradlew testDebugUnitTest # JVM 单元测试,无需设备 ./gradlew testDebugUnitTest # JVM 单元测试,无需设备
bash build/build-apk.sh all # debug + release APK,并校验签名与产物 bash build/build-apk.sh all # debug + release APK,并校验签名与产物
bash build/verify.sh # 一键跑完上述全部并附加产物校验 bash build/verify.sh # 一键跑完上述全部并附加产物校验
``` ```
首次部署(前置条件、本地配置、安装到手机、配置联通、自检清单、常见问题)
见 `README.md` 的「初次部署(从零跑起来)」与 `README.txt` 对应小节。
文档中的地址、路径、口令一律使用占位符,真实值不得写入仓库。
## 8. 跨端一致性验证(本项目最重要的测试) ## 8. 跨端一致性验证(本项目最重要的测试)
`KeypointFrameTest.goldenVectorMatchesWebImplementation` 使用 `KeypointFrameTest.goldenVectorMatchesWebImplementation` 使用
@@ -125,7 +129,7 @@ Kotlin(本端)。任何一端私自改动字段布局都会立刻失败。
## 9. 上下文超标时的拆分策略 ## 9. 上下文超标时的拆分策略
| 超标原因 | 拆分方式 | | 超标原因 | 拆分方式 |
|---|---| |--|--|
| 单任务涉及多个界面 | 按界面拆(连接/运行/关于) | | 单任务涉及多个界面 | 按界面拆(连接/运行/关于) |
| 单文件过大 | 按类拆,先生成接口再分实现 | | 单文件过大 | 按类拆,先生成接口再分实现 |
| 契约全文加载 | 改用锚点引用 | | 契约全文加载 | 改用锚点引用 |
+183 -16
View File
@@ -9,17 +9,17 @@ OpenDance Studio **阶段三**:Android 辅助 App(Kotlin + CameraX + MediaPi
## 状态 ## 状态
**阶段三已完成**:T-AND-001 → T-AND-009 全部落地。 **阶段三已完成**:T-AND-001 -> T-AND-009 全部落地。
| 验收项 | 结果 | | 验收项 | 结果 |
|---|---| |--|--|
| 单元测试 | ✓ **25 项全部通过**(JVM,无需设备) | | 单元测试 | OK **30 项全部通过**(JVM,无需设备) |
| **跨端金标准** | ✓ Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** | | **跨端金标准** | OK Kotlin 序列化器与 Web 端 TypeScript 实现**逐位一致** |
| `./gradlew assembleRelease` | ✓ 生成**已签名** APK(28.6 MB) | | `./gradlew assembleRelease` | OK 生成**已签名** APK(28.6 MB) |
| 签名校验 | ✓ `apksigner` 退出码 0,v2 方案,CN=OpenDance Studio Dev | | 签名校验 | OK `apksigner` 退出码 0,v2 方案,CN=OpenDance Studio Dev |
| 包信息 | ✓ `com.opendance.studio` v0.1.0,minSdk 29(Android 10+)/ targetSdk 34 | | 包信息 | OK `com.opendance.studio` v0.1.0,minSdk 29(Android 10+)/ targetSdk 34 |
| 模型随包分发 | ✓ `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) | | 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | ✓ arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) | | ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
## 快速开始 ## 快速开始
@@ -28,17 +28,184 @@ export JAVA_HOME=/path/to/jdk17 # AGP 8.7.3 要求
export ANDROID_HOME=/path/to/android-sdk export ANDROID_HOME=/path/to/android-sdk
bash build/gen-keystore.sh # 生成发布签名(首次;不入库,请自行备份) bash build/gen-keystore.sh # 生成发布签名(首次;不入库,请自行备份)
bash build/verify.sh # 单元测试 → 构建 → 产物校验 bash build/verify.sh # 单元测试 -> 构建 -> 产物校验
bash build/build-apk.sh all # 仅构建 debug + release bash build/build-apk.sh all # 仅构建 debug + release
bash build/package-release.sh # 打发布包 bash build/package-release.sh # 打发布包
``` ```
详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md)。 详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md),
第一次上手请看下文的「初次部署(从零跑起来)」一节。
## 初次部署(从零跑起来)
本节面向**第一次拿到本仓库**、要在真机上把 App 跑起来的人。
下文出现的地址、路径、口令一律是**占位符或文档示例值**,请替换为你自己的值,
并确保这些值**不要提交进仓库**。
### 1. 前置条件
| 项 | 要求 | 依据 |
|--|--|--|
| JDK | 17 或更高(AGP 8.7.3 要求;`build/build-apk.sh` 会校验主版本 >= 17) | `gradle.properties` / `build/build-apk.sh` |
| Gradle | 8.9,仓库自带 wrapper(`./gradlew`),本机无需另装 | `gradle/wrapper/gradle-wrapper.properties` |
| Android SDK | `compileSdk 34` / `minSdk 29`(Android 10+)/ `targetSdk 34`;需 `platforms;android-34` 与 `build-tools;34.0.0` | `app/build.gradle.kts` |
| 设备 | **需要真机**(Android 10 及以上、带摄像头);模拟器只作有限调试 | `app/build.gradle.kts` 的 ABI 过滤 |
真机与模拟器的差别:
- release 包只打包 `arm64-v8a` + `armeabi-v7a`,**无法安装到 x86/x86_64 模拟器**。
- debug 包额外含 `x86`/`x86_64`,可以装进模拟器,但模拟器相机是虚拟场景,
CameraX 取到的是合成画面,MediaPipe 的姿态识别效果不能代表真机,
也不能作为「第一次跑通」的验收依据。
- 结论:**姿态识别与联调验收请用真机**。
权限:运行页需要**相机权限**(运行时授予)。另外 `INTERNET`、`ACCESS_NETWORK_STATE`、
`ACCESS_WIFI_STATE` 为普通权限,安装时自动授予,用于 WebSocket 推流与局域网扫描。
### 2. 获取代码与本地配置
```bash
git clone https://<Gitea 地址>/<owner>/opendance-android.git
cd opendance-android
```
`local.properties` 是**本地文件**(已在 `.gitignore` 中,不会入库),
只需写 Android SDK 路径:
```properties
sdk.dir=<Android SDK 路径>
```
`build/build-apk.sh` 在该文件缺失时会依据 `ANDROID_HOME` / `ANDROID_SDK_ROOT`
自动生成它;也可以手动创建。**不要提交它**,每台机器路径不同。
签名材料同样**不入库**(`.gitignore` 已忽略 `keystore/`、`*.jks`、`*.keystore`、
`keystore.properties`):
- 仓库里**没有私钥**,只有生成脚本与说明。
- 首次发布前执行 `bash build/gen-keystore.sh`,生成
`keystore/opendance-release.jks` 与 `keystore.properties`
(含 `storeFile`、`keyAlias` 与两个口令字段)。**请自行备份,且不要提交**。
- `app/build.gradle.kts` 的签名取值优先级:
`keystore.properties` -> 环境变量(`KEYSTORE_PATH` / `KEYSTORE_PASSWORD` /
`KEY_ALIAS` / `KEY_PASSWORD`)-> 回退 debug 签名。
回退只为让 CI 也能产出已签名 APK;正式发布必须提供自己的 keystore。
### 3. 首次构建
```bash
export JAVA_HOME=<JDK 17 路径>
export ANDROID_HOME=<Android SDK 路径>
# 方式一:仓库脚本(推荐,构建后自动校验产物)
bash build/build-apk.sh debug # 仅 debug
bash build/build-apk.sh release # 仅 release
bash build/build-apk.sh all # 两者都构建
# 方式二:直接调用 Gradle
./gradlew assembleDebug
./gradlew assembleRelease
```
产物路径与文件名(固定):
| 变体 | 产物 | 说明 |
|--|--|--|
| debug | `app/build/outputs/apk/debug/app-debug.apk` | applicationId 带 `.debug` 后缀,含 x86/x86_64 |
| release | `app/build/outputs/apk/release/app-release.apk` | 仅 arm64-v8a + armeabi-v7a,开启 R8 |
发布整包(APK + `THIRD_PARTY.md` + `README.txt` + `contracts/`)用
`bash build/package-release.sh`,输出在 `dist-release/`。
### 4. 安装到手机
```bash
# 用 adb(手机需开启 USB 调试)
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/release/app-release.apk
```
也可以直接把 APK 拷到手机,在系统设置中允许「安装未知来源应用」后点击安装。
首次进入运行页会弹出**相机权限**请求;拒绝后运行页显示
「需要相机权限才能进行姿态识别」,可点「授予相机权限」重新申请,
或到系统「设置 -> 应用 -> OpenDance -> 权限」中手动开启。
### 5. 配置与联通(填 Windows 客户端地址)
本 App **只连接 Windows 客户端,不访问 Web 服务端**:地址由
`AppSettings.webSocketUrl()` 拼成 `ws://<host>:<port>/pose`,子协议 `opendance.pose.v1`。
连接页字段(对应 `data/AppSettings.kt`):
| 界面字段 | 设置项 | 默认值 | 说明 |
|--|--|--|--|
| IP 地址 | `host` | RFC 5737 文档网段占位地址(源码为 `192.0.2.100`,**非真实主机**) | 填 Windows 电脑的局域网 IP |
| 端口 | `port` | `8765` | 与 Windows 客户端监听端口一致 |
| 目标帧率(FPS) | `targetFps` | `30` | 取值范围 5~60 |
| 分辨率 | `resolution` | `640x480` | 可选 `480x360` / `640x480` / `1280x720` |
| 镜像画面 | `mirror` | 开 | 只影响显示与 `flags.bit0`,发送的是未镜像坐标 |
| 仅上半身模式 | `upperBodyOnly` | 关 | 下半身置信度置 0,接收端插值 |
填写示例(占位符,请替换):
```
IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑的局域网 IP
端口: 8765
```
不想手填 IP 时,可点「扫描局域网」:App 取本机 WiFi IPv4 地址后推导 `/24` 网段,
以 32 并发、单机 300ms 超时探测该网段内开放 8765 端口的主机(约 2~3 秒),
结果按延迟升序展示,点候选即可填入。也可以先点「测试连接」确认端口可达。
### 6. 第一次跑通
数据流是**单向**的:`手机 -> Windows 客户端`;手机端不连 Web 服务端。
1. 手机与 Windows 电脑连到**同一个局域网 / WiFi**(路由器不要开 AP 隔离)。
2. 在电脑上启动 OpenDance Studio Windows 客户端,确认它在监听 8765 端口。
3. 手机打开 App,连接页填入电脑 IP(或用「扫描局域网」自动发现),点「测试连接」。
4. 点「开始推流」,首次会申请相机权限,允许后进入运行页。
5. 站到画面中央,屏幕上出现骨架叠加。
成功标志:
| 位置 | 可观察现象 |
|--|--|
| App 运行页 | 「连接状态」为**已连接**(或短暂「连接中」后转「已连接」) |
| App 运行页 | 「目标」显示 `ws://<IP>:<端口>/pose`;服务端返回 welcome 后出现「会话」ID |
| App 运行页 | 「已发送」帧数持续增长;「发送帧率」「识别帧率」大于 0;「推理耗时」为毫秒数 |
| App 运行页 | 「人体识别」为**已识别**,叠加层出现骨架(黄点 = 置信度好,红点 = 置信度低) |
| Windows 客户端 | 显示已连接并收到关键点,VRM 模型随动作实时动起来 |
### 7. 首次部署自检清单
| # | 命令 | 预期输出 |
|--|--|--|
| 1 | `./gradlew testDebugUnitTest` | `BUILD SUCCESSFUL`,全部用例通过;报告在 `app/build/reports/tests/testDebugUnitTest/index.html` |
| 2 | `bash build/build-apk.sh debug` | 末尾打印 `OK Debug APK:app/build/outputs/apk/debug/app-debug.apk` 及其大小与签名信息 |
| 3 | `bash build/build-apk.sh all` | debug 与 release 均 `OK`;release 显示 `已签名(APK Signature Scheme v2)` |
| 4 | `$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk` | 退出码 0(无输出即通过) |
| 5 | `unzip -l app/build/outputs/apk/release/app-release.apk \| grep pose_landmarker_lite.task` | 列出 `assets/pose_landmarker_lite.task`(约 5.7 MB,Stored 未压缩) |
| 6 | `adb install -r app/build/outputs/apk/debug/app-debug.apk` | `Success` |
其中 `KeypointFrameTest.goldenVectorMatchesWebImplementation` 必须通过:
它断言本端 Kotlin 序列化器与 Web 端 TypeScript 实现产出**逐位相同**的 239 字节帧。
### 8. 常见问题(首次部署)
| 现象 | 可能原因 | 处理 |
|--|--|--|
| 相机黑屏,或一直停在「需要相机权限才能进行姿态识别」 | 相机权限被拒绝或已被永久拒绝;也可能相机被其他 App 占用 | 在运行页点「授予相机权限」;若不再弹窗,去系统「设置 -> 应用 -> OpenDance -> 权限」手动开启,并关闭占用相机的其它 App |
| 相机预览正常但「人体识别」一直是「未识别到人体」 | 光线不足、人不在画面内、或分辨率/模式不合适 | 站到画面中央并改善光照;确认分辨率不是过低;观察「推理耗时」是否异常大 |
| 「连接状态」一直「已断开 / 重连中」,「测试连接」失败 | IP 或端口填错;Windows 客户端未启动;防火墙拦截入站 8765;手机与电脑不在同一网段(AP 隔离) | 核对连接页「本机地址」与电脑 IP 是否同网段;启动客户端;在 Windows 防火墙放行 OpenDance 的 TCP 8765 入站;关闭路由器 AP 隔离 |
| 「扫描局域网」扫不到主机 | 手机未连 WiFi、路由器 AP 隔离、或客户端未监听 8765 | 确认手机连的是 WiFi 而非蜂窝;改为手动填 IP;检查客户端监听端口与防火墙 |
| 点「开始推流」立即闪退,或提示 MediaPipe 初始化失败 | MediaPipe 原生库加载 / 静态初始化失败抛出的是 **Error** 子类(`UnsatisfiedLinkError`、`ExceptionInInitializerError`),不是 `Exception`;常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)或模型资源缺失 | 改用与设备 ABI 匹配的包(真机用 release,模拟器调试用 debug);确认 APK 内含 `assets/pose_landmarker_lite.task`。仓库已在 `PoseDetector.initialize()` 中 `catch Throwable` 兜底并把原因显示在运行页;`CrashLogger` 会把未捕获堆栈写入应用私有目录,并在连接页展示,可截图反馈,或用 `adb logcat -s PoseDetector` 查看 |
## 技术栈 ## 技术栈
| 组件 | 选型 | | 组件 | 选型 |
|---|---| |--|--|
| 语言 | Kotlin 2.0.21 | | 语言 | Kotlin 2.0.21 |
| 构建 | AGP 8.7.3 + Gradle 8.9 + JDK 17 | | 构建 | AGP 8.7.3 + Gradle 8.9 + JDK 17 |
| 相机 | CameraX 1.4.0 | | 相机 | CameraX 1.4.0 |
@@ -68,12 +235,12 @@ app/src/main/java/com/opendance/studio/
## 依赖的契约(opendance-web v0.1.0 冻结) ## 依赖的契约(opendance-web v0.1.0 冻结)
- [`contracts/websocket-keypoints.md`](contracts/websocket-keypoints.md) — 239 字节关键点帧(本端为**发送方**) - [`contracts/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧(本端为**发送方**)
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) — MediaPipe 33 点索引与坐标语义 - [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义
- [`contracts/open-source-credits.md`](contracts/open-source-credits.md) — 关于页必须展示的开源声明 - [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到 `contracts/` 是**只读快照**。任何跨端契约变更必须先回到
[`opendance-web`](https://gitea.kasugano.cn/zijunchiang/opendance-web) 修改并提升版本。 [`opendance-web`](https://<Gitea 地址>/<owner>/opendance-web) 修改并提升版本。
## 跨端一致性:金标准字节向量测试 ## 跨端一致性:金标准字节向量测试
+182 -40
View File
@@ -1,15 +1,15 @@
================================================================ ================================================================
OpenDance Studio —— Android 辅助 App v0.1.0 使用说明 OpenDance Studio -- Android 辅助 App v0.1.0 使用说明
================================================================ ================================================================
手机端姿态采集器:用摄像头做**端侧**姿态推理,把 33 个骨骼关键点通过 手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评分。 WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评分。
隐私:画面不会离开手机。通过网络发送的只有 33 个关键点坐标。 隐私:画面不会离开手机。通过网络发送的只有 33 个关键点坐标。
---------------------------------------------------------------- --
一、安装 一、安装
---------------------------------------------------------------- --
方式一(推荐):直接安装随附的 app-release.apk 方式一(推荐):直接安装随附的 app-release.apk
- 在手机上允许「安装未知来源应用」 - 在手机上允许「安装未知来源应用」
@@ -20,14 +20,156 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
要求:Android 10(API 29)或更高版本。 要求:Android 10(API 29)或更高版本。
---------------------------------------------------------------- --
二、快速开始 二、初次部署(从零跑起来)
---------------------------------------------------------------- --
本节面向第一次部署的人,给出从零到跑通的最短路径。
下文地址、路径、口令一律是占位符,请替换成你自己的值,且不要提交进仓库。
1) 前置条件
JDK 17 或更高 AGP 8.7.3 要求,构建脚本会校验主版本 >= 17
Gradle 8.9 仓库自带 gradlew,本机无需另装
Android SDK platforms;android-34 与 build-tools;34.0.0
compileSdk 34 / minSdk 29(Android 10+)/ targetSdk 34
设备 需要真机(Android 10 及以上、带摄像头)
真机与模拟器:
release 包只含 arm64-v8a + armeabi-v7a,装不到 x86/x86_64 模拟器;
debug 包含 x86/x86_64,可装进模拟器,但相机是虚拟场景,姿态识别
效果不能代表真机,不能作为验收依据。联调验收请用真机。
权限:相机权限为运行时授予;INTERNET / ACCESS_NETWORK_STATE /
ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于推流与局域网扫描。
2) 获取代码与本地配置
git clone https://<Gitea 地址>/<owner>/opendance-android.git
cd opendance-android
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
sdk.dir=<Android SDK 路径>
build/build-apk.sh 在缺失时会依据 ANDROID_HOME / ANDROID_SDK_ROOT 自动生成。
签名材料不入库:仓库只有生成脚本,没有私钥。首次发布前执行
bash build/gen-keystore.sh
生成 keystore/opendance-release.jks 与 keystore.properties(含 storeFile、
keyAlias 与两个口令字段)。请自行备份,且不要提交。
取值优先级:keystore.properties -> 环境变量(KEYSTORE_PATH /
KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD)-> 回退 debug 签名
(仅供 CI 产出已签名 APK,正式发布必须自备 keystore)。
3) 首次构建
export JAVA_HOME=<JDK 17 路径>
export ANDROID_HOME=<Android SDK 路径>
bash build/build-apk.sh debug # 仅 debug
bash build/build-apk.sh release # 仅 release
bash build/build-apk.sh all # 两者都构建(含产物校验)
或直接用 Gradle:
./gradlew assembleDebug
./gradlew assembleRelease
产物(固定路径):
app/build/outputs/apk/debug/app-debug.apk
app/build/outputs/apk/release/app-release.apk
发布整包(APK + THIRD_PARTY.md + README.txt + contracts/):
bash build/package-release.sh -> dist-release/
4) 安装到手机
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/release/app-release.apk
也可把 APK 拷到手机,允许「安装未知来源应用」后点击安装。
首次进入运行页会申请相机权限;拒绝后可在运行页点「授予相机权限」
重新申请,或到系统设置里手动开启。
5) 配置与联通
本 App 只连接 Windows 客户端,不访问 Web 服务端。
地址来自 AppSettings.webSocketUrl():ws://<host>:<port>/pose
子协议 opendance.pose.v1。
连接页字段与默认值(见 data/AppSettings.kt):
IP 地址 host 默认是 RFC 5737 文档网段占位地址
(源码为 192.0.2.100,非真实主机)
端口 port 8765
目标帧率 targetFps 30(5~60)
分辨率 resolution 640x480(可选 480x360 / 1280x720)
镜像画面 mirror 开
仅上半身模式 upperBodyOnly 关
填写示例(占位符,请替换成你电脑的局域网 IP):
IP 地址:192.0.2.10
端口: 8765
不想手填可点「扫描局域网」:App 由本机 WiFi IPv4 推导 /24 网段,
以 32 并发、单机 300ms 超时探测开放 8765 的主机(约 2~3 秒),
按延迟升序展示,点候选即填入。也可先点「测试连接」确认端口可达。
6) 第一次跑通
手机与电脑连同一局域网 -> 电脑启动 Windows 客户端(监听 8765)->
App 填地址并测试连接 -> 点「开始推流」并授予相机权限 -> 站到画面中央。
成功标志:
App 运行页 「连接状态」为「已连接」,「目标」显示 ws://<IP>:<端口>/pose
App 运行页 服务端返回 welcome 后出现「会话」ID
App 运行页 「已发送」持续增长,「发送帧率」「识别帧率」大于 0
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
7) 自检清单
./gradlew testDebugUnitTest
预期:BUILD SUCCESSFUL,全部用例通过
报告:app/build/reports/tests/testDebugUnitTest/index.html
bash build/build-apk.sh all
预期:debug 与 release 均显示 "OK ... APK",release 显示
"已签名(APK Signature Scheme v2)"
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
预期:退出码 0(无输出即通过)
adb install -r app/build/outputs/apk/debug/app-debug.apk
预期:Success
8) 常见问题
Q: 相机黑屏 / 一直提示需要相机权限
A: 权限被拒绝或已被永久拒绝,或相机被其他应用占用。在运行页点
「授予相机权限」;不再弹窗时到系统「设置 -> 应用 -> OpenDance -> 权限」
手动开启相机,并关闭占用相机的其它 App。
Q: 连不上 Windows 客户端 / 扫描不到主机
A: 核对 IP 与端口、确认客户端已启动并监听 8765、在 Windows 防火墙放行
OpenDance 的 TCP 8765 入站、确认手机与电脑同一 WiFi 且路由器未开 AP 隔离。
Q: 点「开始推流」闪退(或提示 MediaPipe 初始化失败)
A: MediaPipe 原生库加载 / 静态初始化失败抛出的是 Error 子类
(UnsatisfiedLinkError、ExceptionInInitializerError),不是 Exception;
常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)
或模型资源缺失。处理:改用与设备 ABI 匹配的包(真机用 release、
模拟器调试用 debug);确认 APK 内含 assets/pose_landmarker_lite.task。
仓库已在 PoseDetector.initialize() 中 catch Throwable 兜底并把原因显示在
运行页;CrashLogger 会把未捕获堆栈写入应用私有目录,并在连接页展示,
可截图反馈,或用 adb logcat -s PoseDetector 查看。
--
三、快速开始
--
1. 在 Windows 电脑上启动 OpenDance Studio 客户端 1. 在 Windows 电脑上启动 OpenDance Studio 客户端
(Windows 客户端会自动监听 8765 端口) (Windows 客户端会自动监听 8765 端口)
2. 确保手机与电脑连在**同一个 WiFi** 2. 确保手机与电脑连在同一个 WiFi
3. 打开本应用,进入「连接设置」页 3. 打开本应用,进入「连接设置」页
@@ -46,30 +188,30 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
9. 在 Windows 客户端上确认已连接,即可开始跟练 9. 在 Windows 客户端上确认已连接,即可开始跟练
---------------------------------------------------------------- --
三、推流参数 四、推流参数
---------------------------------------------------------------- --
分辨率 分辨率
低(480×360) 最省电,适合老机型 低(480x360) 最省电,适合老机型
中(640×480) 推荐,识别精度与功耗平衡 中(640x480) 推荐,识别精度与功耗平衡
高(1280×720) 最清晰,但推理更慢、更耗电 高(1280x720) 最清晰,但推理更慢、更耗电
目标帧率 目标帧率
默认 30 FPS。低端机若「识别帧率」明显低于目标值,可降到 20 或 15。 默认 30 FPS。低端机若「识别帧率」明显低于目标值,可降到 20 或 15。
镜像画面 镜像画面
默认开启。前置摄像头的常见用法。 默认开启。前置摄像头的常见用法。
注意:本应用发送的是**未镜像的原始坐标**,是否镜像由帧头 flags.bit0 声明, 注意:本应用发送的是未镜像的原始坐标,是否镜像由帧头 flags.bit0 声明,
Windows 客户端据此正确还原左右。因此无论开关如何,动作方向都不会错。 Windows 客户端据此正确还原左右。因此无论开关如何,动作方向都不会错。
仅上半身模式 仅上半身模式
下半身关键点置信度置 0(显示为红点),Windows 端会插值。 下半身关键点置信度置 0(显示为红点),Windows 端会插值。
带宽或算力不足时开启可明显降负载。 带宽或算力不足时开启可明显降负载。
---------------------------------------------------------------- --
四、运行页状态说明 五、运行页状态说明
---------------------------------------------------------------- --
连接状态 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开 连接状态 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开
目标 正在连接的 WebSocket 地址 目标 正在连接的 WebSocket 地址
@@ -83,12 +225,12 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
平均置信度 < 70% 时转黄,提示改善光线或减少遮挡 平均置信度 < 70% 时转黄,提示改善光线或减少遮挡
最近错误 连接或推理的错误原因 最近错误 连接或推理的错误原因
网络中断时会**自动重连**(1s → 2s → 4s … 最长 15s 退避), 网络中断时会自动重连(1s -> 2s -> 4s... 最长 15s 退避),
无需手动操作。 无需手动操作。
---------------------------------------------------------------- --
五、常见问题 六、常见问题
---------------------------------------------------------------- --
Q: 扫描不到电脑 Q: 扫描不到电脑
A: 1) 确认手机与电脑在同一 WiFi(不是手机热点) A: 1) 确认手机与电脑在同一 WiFi(不是手机热点)
@@ -101,18 +243,18 @@ A: 查看运行页的「已发送」是否在增长。若为 0,说明相机或
若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。 若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。
Q: 帧率很低 / 已丢弃帧数持续增长 Q: 帧率很低 / 已丢弃帧数持续增长
A: 依次尝试:降低分辨率 → 降低目标帧率 → 开启「仅上半身模式」→ 关闭其他后台应用。 A: 依次尝试:降低分辨率 -> 降低目标帧率 -> 开启「仅上半身模式」-> 关闭其他后台应用。
Q: 关键点抖动明显 Q: 关键点抖动明显
A: 正常现象,Windows 端有 DTW 带状对齐与平滑处理,不要求逐帧完全一致。 A: 正常现象,Windows 端有 DTW 带状对齐与平滑处理,不要求逐帧完全一致。
改善光照可显著提高稳定性。 改善光照可显著提高稳定性。
Q: 应用退到后台会怎样? Q: 应用退到后台会怎样?
A: 返回键或离开运行页会立即停止推流并释放相机,不会在后台占用摄像头。 A: 返回键或离开运行页会立即停止推流并释放相机,不会在后台占用摄像头。
---------------------------------------------------------------- --
六、从源码构建 七、从源码构建
---------------------------------------------------------------- --
环境要求: 环境要求:
JDK 17 AGP 8.7.3 要求(JDK 25 未经测试) JDK 17 AGP 8.7.3 要求(JDK 25 未经测试)
@@ -130,7 +272,7 @@ A: 返回键或离开运行页会立即停止推流并释放相机,不会在
# 构建并校验产物 # 构建并校验产物
bash build/build-apk.sh all bash build/build-apk.sh all
# 一键验证(单元测试 → 构建 → 产物校验) # 一键验证(单元测试 -> 构建 -> 产物校验)
bash build/verify.sh bash build/verify.sh
产物: 产物:
@@ -138,23 +280,23 @@ A: 返回键或离开运行页会立即停止推流并释放相机,不会在
app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/app-release.apk
Android Studio 路径: Android Studio 路径:
打开本目录 → Build → Generate Signed Bundle / APK → APK → 选择 keystore → release 打开本目录 -> Build -> Generate Signed Bundle / APK -> APK -> 选择 keystore -> release
---------------------------------------------------------------- --
七、测试 八、测试
---------------------------------------------------------------- --
./gradlew testDebugUnitTest ./gradlew testDebugUnitTest
19 项单元测试(JVM,无需设备),核心是**金标准字节向量测试**: 30 项单元测试(JVM,无需设备),核心是金标准字节向量测试:
用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量, 用 opendance-web 已通过验收的 TypeScript 实现产出的 239 字节向量,
断言本端 Kotlin 实现逐位相同 —— 这是三端契约一致性的硬证据。 断言本端 Kotlin 实现逐位相同 -- 这是三端契约一致性的硬证据。
测试报告:app/build/reports/tests/testDebugUnitTest/index.html 测试报告:app/build/reports/tests/testDebugUnitTest/index.html
---------------------------------------------------------------- --
八、目录结构 九、目录结构
---------------------------------------------------------------- --
settings.gradle.kts / build.gradle.kts 工程与插件配置 settings.gradle.kts / build.gradle.kts 工程与插件配置
gradle/wrapper/ Gradle 8.9 wrapper gradle/wrapper/ Gradle 8.9 wrapper
@@ -173,9 +315,9 @@ Android Studio 路径:
app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型 app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
build/ 打包与验证脚本 build/ 打包与验证脚本
---------------------------------------------------------------- --
九、许可 十、许可
---------------------------------------------------------------- --
本应用代码以 MIT 许可发布。第三方开源项目声明见 THIRD_PARTY.md 本应用代码以 MIT 许可发布。第三方开源项目声明见 THIRD_PARTY.md
(应用内「关于」页同样可查看)。 (应用内「关于」页同样可查看)。
+21 -3
View File
@@ -122,7 +122,11 @@ android {
} }
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests {
isReturnDefaultValues = true
// Robolectric 需要真实资源与 Manifest 才能组合 Compose 界面
isIncludeAndroidResources = true
}
} }
lint { lint {
@@ -161,13 +165,27 @@ dependencies {
// ---- WebSocket 推流(T-AND-005)---- // ---- WebSocket 推流(T-AND-005)----
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- 权限 ---- // 权限:使用平台 Activity Result API(androidx.activity)。
implementation("com.google.accompanist:accompanist-permissions:0.34.0") // 原先用 accompanist-permissions 0.34.0,它发布于 2024-02,会传递引入
// compose animation-core 1.6.0,与 Compose BOM 存在版本偏斜风险;
// 该库已进入维护状态,官方也建议改用 Activity Result API。
// ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)---- // ---- 单元测试(T-AND-004 等纯逻辑,跑在 JVM 上,无需设备)----
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21") testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.21")
// Robolectric:在 JVM 上跑 Android 框架 + Compose,用于验证
// 「进入运行页即崩溃」这类只在真机/运行时才暴露的缺陷
testImplementation("org.robolectric:robolectric:4.13")
testImplementation("androidx.test:core-ktx:1.6.1")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
// 必须用 debugImplementation:它往 debug 变体的合并 Manifest 里注册
// androidx.activity.ComponentActivity,Compose 测试规则要靠它启动宿主 Activity。
// 放 testImplementation 不会参与 Manifest 合并,运行时报
// "Unable to resolve activity for Intent ... ComponentActivity"。
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
@@ -1,6 +1,7 @@
package com.opendance.studio package com.opendance.studio
import android.app.Application import android.app.Application
import com.opendance.studio.util.CrashLogger
/** /**
* 应用级单例容器(T-AND-001 骨架)。 * 应用级单例容器(T-AND-001 骨架)。
@@ -13,6 +14,8 @@ class OpenDanceApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
// 最先安装崩溃记录,保证后续任何初始化失败都能落盘堆栈
CrashLogger.install(this)
} }
companion object { companion object {
@@ -67,9 +67,10 @@ class CameraController(private val context: Context) {
val provider = future.get() val provider = future.get()
cameraProvider = provider cameraProvider = provider
bind(provider, lifecycleOwner, previewView, resolution, frontCamera) bind(provider, lifecycleOwner, previewView, resolution, frontCamera)
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "相机初始化失败", e) // 捕获 Throwable:相机 HAL 相关失败可能是 Error
onError(e.message ?: "相机初始化失败") Log.e(TAG, "相机初始化失败", t)
onError("${t.javaClass.simpleName}: ${t.message ?: "相机初始化失败"}")
} }
}, ContextCompat.getMainExecutor(context)) }, ContextCompat.getMainExecutor(context))
} }
@@ -109,9 +110,9 @@ class CameraController(private val context: Context) {
analysis.setAnalyzer(executor) { imageProxy -> analysis.setAnalyzer(executor) { imageProxy ->
try { try {
this.analyzer?.analyze(imageProxy) this.analyzer?.analyze(imageProxy)
} catch (e: Exception) { } catch (t: Throwable) {
Log.w(TAG, "分析回调异常", e) Log.w(TAG, "分析回调异常", t)
imageProxy.close() runCatching { imageProxy.close() }
} }
} }
} }
@@ -127,8 +128,8 @@ class CameraController(private val context: Context) {
preview = previewUseCase preview = previewUseCase
imageAnalysis = analysisUseCase imageAnalysis = analysisUseCase
Log.i(TAG, "相机已绑定:${if (frontCamera) "前置" else "后置"} @ $resolution") Log.i(TAG, "相机已绑定:${if (frontCamera) "前置" else "后置"} @ $resolution")
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "相机绑定失败", e) Log.e(TAG, "相机绑定失败", t)
imageAnalysis = null imageAnalysis = null
} }
} }
@@ -55,7 +55,7 @@ class AppSettings(context: Context) {
companion object { companion object {
const val PREFS_NAME = "opendance_settings" const val PREFS_NAME = "opendance_settings"
const val DEFAULT_HOST = "192.168.1.100" const val DEFAULT_HOST = "192.0.2.100"
const val DEFAULT_PORT = 8765 const val DEFAULT_PORT = 8765
const val DEFAULT_FPS = 30 const val DEFAULT_FPS = 30
@@ -35,7 +35,7 @@ object HostDiscovery {
/** 发现结果 */ /** 发现结果 */
data class Candidate(val ip: String, val port: Int, val latencyMs: Long) data class Candidate(val ip: String, val port: Int, val latencyMs: Long)
/** 手机自身的 WiFi IPv4 地址(形如 192.168.1.23),无网络时返回 null */ /** 手机自身的 WiFi IPv4 地址(形如 192.0.2.23),无网络时返回 null */
suspend fun localIpv4Address(): String? = withContext(Dispatchers.IO) { suspend fun localIpv4Address(): String? = withContext(Dispatchers.IO) {
try { try {
val candidates = mutableListOf<String>() val candidates = mutableListOf<String>()
@@ -71,7 +71,7 @@ object HostDiscovery {
(a == 192 && b == 168) (a == 192 && b == 168)
} }
/** 由本机地址推导 /24 前缀,如 192.168.1.23 → "192.168.1" */ /** 由本机地址推导 /24 前缀,如 192.0.2.23 -> "192.0.2" */
fun subnetPrefix(ip: String): String? { fun subnetPrefix(ip: String): String? {
val parts = ip.split(".") val parts = ip.split(".")
if (parts.size != 4) return null if (parts.size != 4) return null
@@ -78,9 +78,12 @@ class PoseDetector(private val context: Context) {
landmarker = PoseLandmarker.createFromOptions(context, options) landmarker = PoseLandmarker.createFromOptions(context, options)
Log.i(TAG, "PoseLandmarker 就绪($modelAssetPath,delegate=$delegate)") Log.i(TAG, "PoseLandmarker 就绪($modelAssetPath,delegate=$delegate)")
true true
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "PoseLandmarker 初始化失败", e) // 必须捕获 Throwable:原生库加载失败会抛 UnsatisfiedLinkError、
onError?.invoke("MediaPipe 初始化失败:${e.message}") // 静态初始化失败会抛 ExceptionInInitializerError,它们都不是 Exception。
// 只 catch Exception 会让这类失败直接冒泡成整个应用崩溃。
Log.e(TAG, "PoseLandmarker 初始化失败", t)
onError?.invoke("MediaPipe 初始化失败:${t.javaClass.simpleName}: ${t.message}")
landmarker = null landmarker = null
false false
} }
@@ -105,9 +108,9 @@ class PoseDetector(private val context: Context) {
busy = true busy = true
detector.detectAsync(mpImage, safeTimestamp) detector.detectAsync(mpImage, safeTimestamp)
true true
} catch (e: Exception) { } catch (t: Throwable) {
busy = false busy = false
Log.w(TAG, "提交帧失败:${e.message}") Log.w(TAG, "提交帧失败:${t.message}", t)
false false
} }
} }
@@ -205,8 +208,8 @@ class PoseDetector(private val context: Context) {
fun close() { fun close() {
try { try {
landmarker?.close() landmarker?.close()
} catch (e: Exception) { } catch (t: Throwable) {
Log.w(TAG, "关闭 PoseLandmarker 失败:${e.message}") Log.w(TAG, "关闭 PoseLandmarker 失败:${t.message}", t)
} }
landmarker = null landmarker = null
busy = false busy = false
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -36,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -44,6 +46,7 @@ import com.opendance.studio.data.Resolutions
import com.opendance.studio.net.HostDiscovery import com.opendance.studio.net.HostDiscovery
import com.opendance.studio.net.PoseWebSocketClient import com.opendance.studio.net.PoseWebSocketClient
import com.opendance.studio.ui.theme.OpenDanceGreen import com.opendance.studio.ui.theme.OpenDanceGreen
import com.opendance.studio.util.CrashLogger
import com.opendance.studio.ui.theme.OpenDanceMuted import com.opendance.studio.ui.theme.OpenDanceMuted
import com.opendance.studio.ui.theme.OpenDanceRed import com.opendance.studio.ui.theme.OpenDanceRed
import com.opendance.studio.ui.theme.OpenDanceSurface import com.opendance.studio.ui.theme.OpenDanceSurface
@@ -62,6 +65,7 @@ fun ConnectScreen(
onAbout: () -> Unit, onAbout: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf(settings.host) } var host by remember { mutableStateOf(settings.host) }
var portText by remember { mutableStateOf(settings.port.toString()) } var portText by remember { mutableStateOf(settings.port.toString()) }
@@ -76,6 +80,9 @@ fun ConnectScreen(
var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") } var status by remember { mutableStateOf("填写 Windows 客户端的地址,或扫描局域网自动发现") }
var statusIsError by remember { mutableStateOf(false) } var statusIsError by remember { mutableStateOf(false) }
// 上次运行如果崩溃过,把堆栈直接显示出来 —— 免去连 adb 抓 logcat 的麻烦
var crashText by remember { mutableStateOf(CrashLogger.latestCrashText(context)) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
localIp = HostDiscovery.localIpv4Address() localIp = HostDiscovery.localIpv4Address()
} }
@@ -114,6 +121,36 @@ fun ConnectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (crashText != null) {
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("上次运行崩溃了", style = MaterialTheme.typography.titleMedium)
Text(
"下面是崩溃堆栈,可截图反馈以便定位。",
color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall,
)
Text(
crashText!!.take(4000),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState()),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
CrashLogger.clear(context)
crashText = null
}) { Text("清除记录") }
}
}
}
}
// ---------------- 目标地址 ---------------- // ---------------- 目标地址 ----------------
Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) { Card(colors = CardDefaults.cardColors(containerColor = OpenDanceSurface)) {
Column( Column(
@@ -1,7 +1,13 @@
package com.opendance.studio.ui package com.opendance.studio.ui
import android.Manifest import android.Manifest
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -33,9 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.ExperimentalPermissionsApi import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.opendance.studio.camera.CameraController import com.opendance.studio.camera.CameraController
import com.opendance.studio.data.AppSettings import com.opendance.studio.data.AppSettings
import com.opendance.studio.net.PoseWebSocketClient import com.opendance.studio.net.PoseWebSocketClient
@@ -60,8 +64,14 @@ import kotlinx.coroutines.launch
* └──▶ SkeletonOverlay(叠加层) * └──▶ SkeletonOverlay(叠加层)
* ``` * ```
* 画面本身**从不离开设备**。 * 画面本身**从不离开设备**。
*
* 稳定性约定(真机排查后加固):
* 1. 相机权限用平台 Activity Result API,不引入 accompanist(避免 Compose 版本偏斜)
* 2. MediaPipe 回调发生在相机分析线程 —— 所有 Compose 状态写入都 post 回主线程
* 3. 初始化/连接失败只记录并提示,绝不让异常冒泡成崩溃
*/ */
@OptIn(ExperimentalPermissionsApi::class) private const val TAG = "RunningScreen"
@Composable @Composable
fun RunningScreen( fun RunningScreen(
settings: AppSettings, settings: AppSettings,
@@ -70,9 +80,20 @@ fun RunningScreen(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) // ---------------- 相机权限(平台 API) ----------------
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> hasCameraPermission = granted }
// ---------------- UI 状态 ----------------
var overlay by remember { mutableStateOf(FloatArray(0)) } var overlay by remember { mutableStateOf(FloatArray(0)) }
var confidence by remember { mutableStateOf<FloatArray?>(null) } var confidence by remember { mutableStateOf<FloatArray?>(null) }
var detectionFps by remember { mutableStateOf(0f) } var detectionFps by remember { mutableStateOf(0f) }
@@ -80,6 +101,7 @@ fun RunningScreen(
var poseDetected by remember { mutableStateOf(false) } var poseDetected by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
var cameraError by remember { mutableStateOf("") } var cameraError by remember { mutableStateOf("") }
var fatalError by remember { mutableStateOf("") }
val wsStats = remember { PoseWebSocketClient(scope) } val wsStats = remember { PoseWebSocketClient(scope) }
val stats by wsStats.stats.collectAsState() val stats by wsStats.stats.collectAsState()
@@ -88,7 +110,8 @@ fun RunningScreen(
val poseDetector = remember { PoseDetector(context) } val poseDetector = remember { PoseDetector(context) }
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
// 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例 // 分析器抽出来复用:start() 与 switchCamera() 需要同一个实例。
// 注意:本 lambda 运行在相机分析线程,**不要**在这里直接写 Compose 状态。
val analyzer = remember { val analyzer = remember {
CameraController.FrameAnalyzer { imageProxy -> CameraController.FrameAnalyzer { imageProxy ->
try { try {
@@ -97,21 +120,23 @@ fun RunningScreen(
timestampMs = SystemClock.uptimeMillis(), timestampMs = SystemClock.uptimeMillis(),
rotationDegrees = imageProxy.imageInfo.rotationDegrees, rotationDegrees = imageProxy.imageInfo.rotationDegrees,
) )
} catch (t: Throwable) {
// 单帧失败不应中断推流,更不应崩溃
Log.w(TAG, "分析帧失败:${t.message}", t)
} finally { } finally {
// toBitmap() 已在 detect() 内同步完成,此处可安全关闭 runCatching { imageProxy.close() }
imageProxy.close()
} }
} }
} }
// 帧率统计窗口 // 帧率统计窗口(只在主线程读写)
var framesInWindow by remember { mutableStateOf(0) } var framesInWindow by remember { mutableStateOf(0) }
var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) } var windowStart by remember { mutableStateOf(SystemClock.uptimeMillis()) }
// ---------------- 权限 ---------------- // ---------------- 权限请求 ----------------
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!cameraPermission.status.isGranted) { if (!hasCameraPermission) {
cameraPermission.launchPermissionRequest() permissionLauncher.launch(Manifest.permission.CAMERA)
} }
} }
@@ -119,49 +144,70 @@ fun RunningScreen(
DisposableEffect(Unit) { DisposableEffect(Unit) {
poseDetector.mirror = settings.mirror poseDetector.mirror = settings.mirror
poseDetector.upperBodyOnly = settings.upperBodyOnly poseDetector.upperBodyOnly = settings.upperBodyOnly
poseDetector.onError = { errorMessage = it } poseDetector.onError = { message ->
mainHandler.post { errorMessage = message }
}
poseDetector.onResult = { result -> poseDetector.onResult = { result ->
inferenceMs = result.inferenceMs // 回调在相机分析线程:统一 post 回主线程再更新状态与发送
poseDetected = result.overlay.isNotEmpty() mainHandler.post {
inferenceMs = result.inferenceMs
poseDetected = result.overlay.isNotEmpty()
if (result.overlay.isNotEmpty()) { if (result.overlay.isNotEmpty()) {
overlay = result.overlay overlay = result.overlay
confidence = result.frame.confidence confidence = result.frame.confidence
wsStats.sendFrame(result.frame) runCatching { wsStats.sendFrame(result.frame) }
} .onFailure { Log.w(TAG, "发送帧失败:${it.message}", it) }
}
framesInWindow += 1 framesInWindow += 1
val now = SystemClock.uptimeMillis() val now = SystemClock.uptimeMillis()
val elapsed = now - windowStart val elapsed = now - windowStart
if (elapsed >= 1000) { if (elapsed >= 1000) {
detectionFps = framesInWindow * 1000f / elapsed detectionFps = framesInWindow * 1000f / elapsed
framesInWindow = 0 framesInWindow = 0
windowStart = now windowStart = now
wsStats.sendStats( runCatching {
fps = detectionFps, wsStats.sendStats(
dropped = stats.droppedFrames, fps = detectionFps,
latencyMs = 0, dropped = stats.droppedFrames,
confidenceAvg = result.frame.averageConfidence(), latencyMs = 0,
) confidenceAvg = result.frame.averageConfidence(),
)
}
}
} }
} }
if (!poseDetector.initialize()) { if (!poseDetector.initialize()) {
errorMessage = "MediaPipe 初始化失败,请确认模型文件已随 APK 打包" // onError 会在 initialize() 内部同步触发并 post 具体原因;
// 这里只做兜底,不能覆盖掉更具体的错误信息。
mainHandler.post {
if (errorMessage.isBlank()) {
errorMessage = "MediaPipe 初始化失败(未返回具体原因)"
}
}
} }
PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps PoseWebSocketClient.TARGET_FPS_FIELD = settings.targetFps
PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution PoseWebSocketClient.RESOLUTION_FIELD = settings.resolution
wsStats.connect(settings.webSocketUrl(), settings.autoReconnect)
// 连接失败只提示,不崩溃
runCatching { wsStats.connect(settings.webSocketUrl(), settings.autoReconnect) }
.onFailure {
Log.e(TAG, "WebSocket 连接失败", it)
fatalError = "无法连接 ${settings.webSocketUrl()}:${it.message}"
}
onDispose { onDispose {
wsStats.release() runCatching { wsStats.release() }
poseDetector.close() runCatching { poseDetector.close() }
runCatching { cameraController.release() }
} }
} }
// ---------------- 权限未授予 ---------------- // ---------------- 权限未授予 ----------------
if (!cameraPermission.status.isGranted) { if (!hasCameraPermission) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -178,7 +224,7 @@ fun RunningScreen(
color = OpenDanceMuted, color = OpenDanceMuted,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button(onClick = { cameraPermission.launchPermissionRequest() }) { Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
Text("授予相机权限") Text("授予相机权限")
} }
OutlinedButton(onClick = onStop) { Text("返回") } OutlinedButton(onClick = onStop) { Text("返回") }
@@ -198,14 +244,19 @@ fun RunningScreen(
implementationMode = PreviewView.ImplementationMode.COMPATIBLE implementationMode = PreviewView.ImplementationMode.COMPATIBLE
post { post {
previewView = this previewView = this
cameraController.start( runCatching {
lifecycleOwner = lifecycleOwner, cameraController.start(
previewView = this, lifecycleOwner = lifecycleOwner,
resolution = settings.resolution, previewView = this,
frontCamera = true, resolution = settings.resolution,
analyzer = analyzer, frontCamera = true,
onError = { cameraError = it }, analyzer = analyzer,
) onError = { message -> cameraError = message },
)
}.onFailure {
Log.e(TAG, "相机启动失败", it)
cameraError = it.message ?: "相机启动失败"
}
} }
} }
}, },
@@ -262,7 +313,7 @@ fun RunningScreen(
if (poseDetected) OpenDanceGreen else OpenDanceYellow, if (poseDetected) OpenDanceGreen else OpenDanceYellow,
) )
confidence?.let { confidence?.let {
val avg = it.average() val avg = it.average().toFloat()
InfoLine( InfoLine(
"平均置信度", "平均置信度",
"%.0f%%".format(avg * 100), "%.0f%%".format(avg * 100),
@@ -270,7 +321,10 @@ fun RunningScreen(
) )
} }
if (stats.lastError.isNotBlank()) { if (stats.lastError.isNotBlank()) {
InfoLine("最近错误", stats.lastError, OpenDanceRed) InfoLine("连接错误", stats.lastError, OpenDanceRed)
}
if (fatalError.isNotBlank()) {
InfoLine("致命错误", fatalError, OpenDanceRed)
} }
if (errorMessage.isNotBlank()) { if (errorMessage.isNotBlank()) {
InfoLine("推理错误", errorMessage, OpenDanceRed) InfoLine("推理错误", errorMessage, OpenDanceRed)
@@ -297,7 +351,8 @@ fun RunningScreen(
cameraError = "预览尚未就绪" cameraError = "预览尚未就绪"
} else { } else {
cameraError = "" cameraError = ""
cameraController.switchCamera(lifecycleOwner, view, analyzer) runCatching { cameraController.switchCamera(lifecycleOwner, view, analyzer) }
.onFailure { cameraError = it.message ?: "切换摄像头失败" }
} }
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -306,7 +361,7 @@ fun RunningScreen(
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
wsStats.sendBye("user_stop") runCatching { wsStats.sendBye("user_stop") }
onStop() onStop()
} }
}, },
@@ -0,0 +1,88 @@
package com.opendance.studio.util
import android.content.Context
import android.util.Log
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 崩溃记录器。
*
* 装上默认的 UncaughtExceptionHandler,把未捕获异常的完整堆栈写入
* `filesDir/crashes/crash-<时间戳>.txt`,供用户回传定位问题。
*
* 之所以需要它:本应用的关键链路(CameraX + MediaPipe + WebSocket)大量依赖
* 设备端的原生库与硬件行为,很多失败只在真机上出现。没有落盘堆栈就只能靠猜。
*/
object CrashLogger {
private const val TAG = "CrashLogger"
private const val DIR_NAME = "crashes"
private const val MAX_FILES = 10
@Volatile
private var installed = false
fun install(context: Context) {
if (installed) return
installed = true
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
writeCrash(appContext, thread, throwable)
} catch (e: Throwable) {
Log.e(TAG, "写入崩溃日志失败", e)
}
// 交回系统默认处理,保持「应用已停止」的正常行为
previous?.uncaughtException(thread, throwable)
}
}
private fun writeCrash(context: Context, thread: Thread, throwable: Throwable) {
val dir = File(context.filesDir, DIR_NAME).apply { mkdirs() }
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val file = File(dir, "crash-$stamp.txt")
val writer = StringWriter()
PrintWriter(writer).use { pw ->
pw.println("时间:$stamp")
pw.println("线程:${thread.name}")
pw.println("设备:${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
pw.println("Android:${android.os.Build.VERSION.RELEASE}(API ${android.os.Build.VERSION.SDK_INT})")
pw.println("ABI:${android.os.Build.SUPPORTED_ABIS.joinToString()}")
pw.println("应用:${context.packageName}")
pw.println("----------------------------------------")
throwable.printStackTrace(pw)
}
file.writeText(writer.toString())
Log.e(TAG, "崩溃已记录到 ${file.absolutePath}", throwable)
pruneOldFiles(dir)
}
private fun pruneOldFiles(dir: File) {
val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
files.drop(MAX_FILES).forEach { it.delete() }
}
/** 最近一次崩溃的文本内容;没有则返回 null */
fun latestCrashText(context: Context): String? {
val dir = File(context.filesDir, DIR_NAME)
val latest = dir.listFiles()?.maxByOrNull { it.lastModified() } ?: return null
return runCatching { latest.readText() }.getOrNull()
}
fun crashCount(context: Context): Int =
File(context.filesDir, DIR_NAME).listFiles()?.size ?: 0
fun clear(context: Context) {
File(context.filesDir, DIR_NAME).listFiles()?.forEach { it.delete() }
}
}
@@ -0,0 +1,113 @@
package com.opendance.studio
import android.Manifest
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import com.opendance.studio.data.AppSettings
import com.opendance.studio.ui.AboutScreen
import com.opendance.studio.ui.ConnectScreen
import com.opendance.studio.ui.RunningScreen
import com.opendance.studio.ui.theme.OpenDanceTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* 界面组合回归测试(Robolectric,跑在 JVM 上)
*
* 目的:捕获「进入某个页面立刻崩溃」这一类缺陷。这类问题编译期完全看不出来,
* 真机上却是一点就退。本测试把三个页面都真实组合一遍,并用
* `waitForIdle` 跑完副作用(DisposableEffect / LaunchedEffect)。
*
* 说明:Robolectric 没有相机 HAL,也没有 MediaPipe 的原生库,
* 因此相机与推理初始化必然失败 —— 但**失败必须是可捕获、可提示的**,
* 而不是把整个应用带崩。这正是本测试要断言的稳定性契约。
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ScreenCompositionTest {
@get:Rule
val composeRule = createComposeRule()
private fun newSettings(): AppSettings {
val context = ApplicationProvider.getApplicationContext<Context>()
return AppSettings(context).apply {
host = "127.0.0.1"
port = 1 // 保证连不上,走连接失败分支
resolution = "480x360"
}
}
private fun grantCamera() {
val context = ApplicationProvider.getApplicationContext<Context>()
shadowOf(context as android.app.Application).grantPermissions(Manifest.permission.CAMERA)
}
@Test
fun connectScreenComposesWithoutCrash() {
composeRule.setContent {
OpenDanceTheme { ConnectScreen(settings = newSettings(), onStart = {}, onAbout = {}) }
}
composeRule.waitForIdle()
assertTrue("连接页组合完成", true)
}
@Test
fun runningScreenComposesWithoutCrashWhenPermissionGranted() {
grantCamera()
composeRule.setContent {
OpenDanceTheme { RunningScreen(settings = newSettings(), onStop = {}) }
}
// 这里会真实执行 DisposableEffect:MediaPipe 初始化、WebSocket 连接、
// 相机启动 —— 全都预期失败,但必须全部被捕获而不是崩溃。
composeRule.waitForIdle()
assertTrue("授予相机权限后运行页组合完成且未崩溃", true)
}
@Test
fun runningScreenComposesWithoutCrashWhenPermissionMissing() {
val context = ApplicationProvider.getApplicationContext<Context>()
shadowOf(context as android.app.Application).denyPermissions(Manifest.permission.CAMERA)
composeRule.setContent {
OpenDanceTheme { RunningScreen(settings = newSettings(), onStop = {}) }
}
composeRule.waitForIdle()
assertTrue("未授予权限时显示权限提示页而非崩溃", true)
}
@Test
fun aboutScreenComposesWithoutCrash() {
composeRule.setContent {
OpenDanceTheme { AboutScreen(onBack = {}) }
}
composeRule.waitForIdle()
assertTrue("关于页组合完成", true)
}
@Test
fun switchingFromConnectToRunningDoesNotCrash() {
grantCamera()
// 复现用户路径:连接页 → 点「开始推流」→ 运行页
composeRule.setContent {
OpenDanceTheme {
val screen = androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(true)
}
if (screen.value) {
ConnectScreen(settings = newSettings(), onStart = { screen.value = false }, onAbout = {})
} else {
RunningScreen(settings = newSettings(), onStop = { screen.value = true })
}
}
}
composeRule.waitForIdle()
assertTrue("连接页与运行页切换不崩溃", true)
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
# OpenDance Studio 契约版本 # OpenDance Studio 契约版本
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。 # 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
rest-api: 0.1.0 rest-api: 0.4.0
websocket-keypoints: 0.1.0 websocket-keypoints: 0.1.0
data-models: 0.1.0 data-models: 0.4.0
skeleton-topology: 0.1.0 skeleton-topology: 0.1.0
model-formats: 0.1.0 model-formats: 0.4.0
database: 0.1.0 database: 0.3.0
+31 -19
View File
@@ -1,4 +1,4 @@
# contracts/data-models.md — 数据模型契约 v0.1.0 # contracts/data-models.md - 数据模型契约 v0.4.0
Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。 Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。
字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。 字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。
@@ -16,13 +16,13 @@ Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视
- 错误响应统一: - 错误响应统一:
```json ```json
{ "statusCode": 400, "message": "…", "error": "Bad Request", "details": {} } { "statusCode": 400, "message": "...", "error": "Bad Request", "details": {} }
``` ```
- **来源(出处)五元组**是必填项,出现在 `songs` / `motions` / `models` 三处: - **来源(出处)五元组**是必填项,出现在 `songs` / `motions` / `models` 三处:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |--|--|--|
| `sourceType` | enum | 见 1.1 | | `sourceType` | enum | 见 1.1 |
| `sourceName` | string | 来源名称,如「官方曲库」「作者名」 | | `sourceName` | string | 来源名称,如「官方曲库」「作者名」 |
| `sourceUrl` | string | 来源链接,必须为 http(s) | | `sourceUrl` | string | 来源链接,必须为 http(s) |
@@ -52,11 +52,11 @@ rejected 已驳回
## 2. `Song` ## 2. `Song`
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |--|--|--|--|
| `id` | number | 读 | | | `id` | number | 读 | |
| `title` | string | ✅ | 曲名,≤200 | | `title` | string | ✅ | 曲名,<=200 |
| `artist` | string | ✅ | 演唱者,≤200 | | `artist` | string | ✅ | 演唱者,<=200 |
| `album` | string | | 专辑,≤200 | | `album` | string | | 专辑,<=200 |
| `language` | string | | `zh` / `en` / `ja` / `ko` / `instrumental` | | `language` | string | | `zh` / `en` / `ja` / `ko` / `instrumental` |
| `durationMs` | number | | 时长毫秒 | | `durationMs` | number | | 时长毫秒 |
| `bpm` | number | | 节拍 | | `bpm` | number | | 节拍 |
@@ -71,18 +71,18 @@ rejected 已驳回
## 3. `Motion`(动作数据) ## 3. `Motion`(动作数据)
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |--|--|--|--|
| `id` | number | 读 | | | `id` | number | 读 | |
| `songId` | number\|null | | 关联曲目 | | `songId` | number\|null | | 关联曲目 |
| `name` | string | ✅ | 动作名称,≤200 | | `name` | string | ✅ | 动作名称,<=200 |
| `format` | enum | ✅ | `bvh` / `fbx` / `gltf` / `glb` / `keypoints-json` | | `format` | enum | ✅ | `bvh` / `vmd` / `fbx` / `gltf` / `glb` / `keypoints-json` |
| `fileUrl` | string | 读 | 原始动作文件 | | `fileUrl` | string | 读 | 原始动作文件 |
| `fileSize` | number | 读 | 字节 | | `fileSize` | number | 读 | 字节 |
| `fps` | number\|null | | 原始帧率 | | `fps` | number\|null | | 原始帧率 |
| `frameCount` | number\|null | | 原始帧数 | | `frameCount` | number\|null | | 原始帧数 |
| `durationMs` | number\|null | | | | `durationMs` | number\|null | | |
| `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` | | `keypointFormat` | string\|null | 读 | 预处理后固定为 `mediapipe33@0.1.0` |
| `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或 .npz 的 JSON 封装) | | `keypointUrl` | string\|null | 读 | 预处理产物(NDJSON 或.npz 的 JSON 封装) |
| `keypointFrames` | number\|null | 读 | 关键点帧数 | | `keypointFrames` | number\|null | 读 | 关键点帧数 |
| `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` | | `preprocessState` | enum | 读 | `none` / `running` / `done` / `failed` |
| `preprocessError` | string\|null | 读 | | | `preprocessError` | string\|null | 读 | |
@@ -93,37 +93,49 @@ rejected 已驳回
## 4. `Model`(角色模型,VRM 优先) ## 4. `Model`(角色模型,VRM 优先)
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |--|--|--|--|
| `id` | number | 读 | | | `id` | number | 读 | |
| `name` | string | ✅ | 模型名称,≤200 | | `name` | string | ✅ | 模型名称,<=200 |
| `format` | enum | ✅ | `vrm` / `glb` / `gltf` / `fbx` | | `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md 1.1 |
| `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null | | `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null |
| `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** | | `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** |
| `fileUrl` | string | 读 | | | `fileUrl` | string | 读 | |
| `fileSize` | number | 读 | | | `fileSize` | number | 读 | |
| `thumbnailUrl` | string\|null | 读 | | | `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*` | | ✅ | 来源五元组 | | `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | | | `status` | `PublishStatus` | | |
| `createdAt` / `updatedAt` | string | 读 | | | `createdAt` / `updatedAt` | string | 读 | |
### 4.1 VRM 优先规则(冻结) ### 4.1 默认模型选择规则(冻结)
客户端选择默认模型的顺序: 服务端 `GET /api/catalog/export` 的 `defaultModelId` 按以下顺序解析:
``` ```
1. 显式指定 modelId(曲目动作绑定) 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 2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1
3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个 3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个
4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个 4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个
5. 无可用模型 → 客户端使用内置占位模型 5. 无可用模型 -> 客户端使用内置占位模型
``` ```
第 0/1 步是 v0.3.0 新增:上传 MMD 模型压缩包并转换成功后,服务端会把该模型
`is_primary=1` + `status=published`,并把身份写进源码,使它成为开箱默认模型。
`isPrimary` 通过 `POST /api/models/:id/primary` 设置,服务端保证互斥(同一事务内先清零)。 `isPrimary` 通过 `POST /api/models/:id/primary` 设置,服务端保证互斥(同一事务内先清零)。
## 5. `ReviewRecord`(审核流水) ## 5. `ReviewRecord`(审核流水)
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |--|--|--|
| `id` | number | | | `id` | number | |
| `targetType` | enum | `song` / `motion` / `model` | | `targetType` | enum | `song` / `motion` / `model` |
| `targetId` | number | | | `targetId` | number | |
+17 -10
View File
@@ -1,11 +1,11 @@
# contracts/database.md — 数据库契约 v0.1.0 # contracts/database.md - 数据库契约 v0.1.0
## 环境 ## 环境
- 类型:MySQL 兼容实例(外部提供)。**当前部署实际为 MariaDB 10.11.19**,TypeORM - 类型:MySQL 兼容实例(外部提供,含 MariaDB 10.5+),TypeORM
使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+ 使用 `mysql` driver(`mysql2`)连接,SQL 语法限制在 MySQL 5.7 / MariaDB 10.5+
共同子集内,不使用 MySQL 8 专有语法(窗口函数 CTE 递归除外)。 共同子集内,不使用 MySQL 8 专有语法(窗口函数 CTE 递归除外)。
- 数据库名:`dpendance`(由部署方提供,通过 `DB_NAME` 注入;文档默认值 `opendance` 可覆盖) - 数据库名:由部署方提供,通过 `DB_NAME` 注入(本文档示例统一用 `opendance`)
- 字符集:`utf8mb4` / `utf8mb4_general_ci`(沿用现有实例默认排序规则) - 字符集:`utf8mb4` / `utf8mb4_general_ci`(沿用现有实例默认排序规则)
- 迁移工具:TypeORM migration,**不使用 `DB_SYNCHRONIZE`** - 迁移工具:TypeORM migration,**不使用 `DB_SYNCHRONIZE`**
@@ -14,11 +14,11 @@
host / port / user / password 一律通过环境变量注入,**不入库、不入仓**。 host / port / user / password 一律通过环境变量注入,**不入库、不入仓**。
```bash ```bash
DB_HOST=192.168.150.10 DB_HOST=<数据库主机>
DB_PORT=3306 DB_PORT=3306
DB_USER=dpendance DB_USER=opendance_app
DB_PASS=<部署方提供> DB_PASS=<数据库口令>
DB_NAME=dpendance DB_NAME=opendance
DB_SYNCHRONIZE=false DB_SYNCHRONIZE=false
DB_LOGGING=false DB_LOGGING=false
``` ```
@@ -36,14 +36,21 @@ DB_LOGGING=false
- 每个 migration 必须可逆(提供 `down` 方法) - 每个 migration 必须可逆(提供 `down` 方法)
- 迁移文件名格式:`<timestamp>-<kebab-name>.ts` - 迁移文件名格式:`<timestamp>-<kebab-name>.ts`
## 增量迁移(v0.2.0 / v0.3.0)
| 迁移 | 变更 |
|--|--|
| `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md 1.1) |
| `AddModelStats` | `models` 增加 `stats TEXT NULL`(模型分析结果:面数、骨骼树、humanoid 覆盖、可驱动性),供审核界面展示 |
## 表清单(v0.1.0) ## 表清单(v0.1.0)
| 表 | 用途 | 任务 | | 表 | 用途 | 任务 |
|---|---|---| |--|--|--|
| `songs` | 曲目元数据 | T-WEB-003 | | `songs` | 曲目元数据 | T-WEB-003 |
| `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 | | `assets` | 上传文件元数据(去重、sha256、物理路径) | T-WEB-004 |
| `motions` | 动作数据(BVH/FBX…)+ 来源 + 预处理产物 | T-WEB-004 / 006 | | `motions` | 动作数据(BVH/FBX...)+ 来源 + 预处理产物 | T-WEB-004 / 006 |
| `models` | 角色模型(VRM 优先)+ 来源 | T-WEB-005 | | `models` | 角色模型(VRM 优先)+ 来源 + 压缩包转换溯源 | T-WEB-005 |
| `review_records` | 上传审核流水 | T-WEB-007 | | `review_records` | 上传审核流水 | T-WEB-007 |
## 状态机(`status` 列,v0.1.0) ## 状态机(`status` 列,v0.1.0)
+117 -19
View File
@@ -1,15 +1,71 @@
# contracts/model-formats.md — 模型与动作格式契约 v0.1.0 # contracts/model-formats.md - 模型与动作格式契约 v0.4.0
## 1. 角色模型(models) ## 1. 角色模型(models)
| 格式 | 扩展名 | 支持级别 | 说明 | **系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。
|---|---|---|---|
| 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。 | 格式 | 扩展名 | 支持级别 | 服务端行为 |
|--|--|--|--|
| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 |
| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) |
| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 1.1 |
| MMD 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 |
| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) |
### 1.1 上传与转换策略
**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**:
**优先 GLB,其次 VRM**。
#### 单文件上传
| 输入 | 行为 |
|--|--|
| `.glb` / `.vrm` | 直接导入;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| `.gltf` | 直接导入(外部 `.bin` / 贴图不在同一文件里,会缺资源) |
| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 -> 回读校验(见下) |
#### 压缩包上传(自动搜索)
不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**:
```
.vrm ->.glb ->.gltf (直接导入,优先)
.pmx ->.fbx (需要转换)
```
同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。
压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。
#### 转换与校验(PMX / FBX)
| 步骤 | 规则 |
|--|--|
| 1. GLB 直出 | `assimp export ->.glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk |
| 2. glTF 打包 | 上一步失败时 `assimp export ->.gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB |
| 3. VRM 封装 | `MODEL_CONVERT_FORMAT=vrm` 时,在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼来自骨骼名映射,见 `model-inspector.ts` 的候选表,与 Godot 客户端一致);**humanoid 不全则回退交付 GLB**,缺失骨骼写进 `conversion_note` |
| 回读校验 | 每一步产物都要 `assimp info` 回读。Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库 |
| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,不产生模型记录 |
#### 入库后(自动)
| 项 | 说明 |
|--|--|
| 状态 | **一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费,见 `database.md` 状态机 |
| 分析 | `model-inspector.ts` 计算面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,写入 `models.stats`,供审核界面展示 |
| 缩略图 | `model-thumbnail.ts` 纯 TS 光栅化生成 PNG,写入 `models.thumbnail_asset_id`(失败不影响入库) |
| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` 4.1 |
#### 其它约定
| 项 | 规则 |
|--|--|
| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 |
| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE |
**VRM 优先**的选取顺序见 `data-models.md` 4.1。
VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或 VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。 `extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
@@ -17,8 +73,9 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
## 2. 动作数据(motions) ## 2. 动作数据(motions)
| 格式 | 扩展名 | 预处理支持 | 说明 | | 格式 | 扩展名 | 预处理支持 | 说明 |
|---|---|---|---| |--|--|--|--|
| BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 → FK → MediaPipe33 | | BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 -> FK -> MediaPipe33 |
| VMD | `.vmd` | ✅ 内置纯 TS 解析器 | MikuMikuDance 动作数据(二进制,Shift-JIS 骨骼名 + 四元数关键帧);服务端按标准 MMD 骨架做 FK + 腿部 IK 求解 -> MediaPipe33,见 2.3 |
| FBX | `.fbx` | ⚠️ 需 Assimp | 服务端调用 `assimp` 可执行文件导出为 BVH/glTF 后再解析;未安装则 `preprocessState=failed` | | FBX | `.fbx` | ⚠️ 需 Assimp | 服务端调用 `assimp` 可执行文件导出为 BVH/glTF 后再解析;未安装则 `preprocessState=failed` |
| glTF/GLB | `.gltf` / `.glb` | ⚠️ 需 Assimp | 同上 | | glTF/GLB | `.gltf` / `.glb` | ⚠️ 需 Assimp | 同上 |
| 关键点 JSON | `.json` | ✅ 直通校验 | 已是 `mediapipe33@0.1.0` 结构,仅做校验与归一化 | | 关键点 JSON | `.json` | ✅ 直通校验 | 已是 `mediapipe33@0.1.0` 结构,仅做校验与归一化 |
@@ -29,11 +86,11 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
- 一行结构: - 一行结构:
```json ```json
{"frame":0,"timeMs":0.0,"points":[[0.5,0.5,0.0,1.0],"…33 项"],"angles":[1.0,"…9 项"]} {"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` 解码后完全一致 - `points[i] = [x, y, z, confidence]`,语义与 `websocket-keypoints.md` 解码后完全一致
- `angles` 为 `skeleton-topology.md` §5 定义的 9 个关节角余弦,顺序固定: - `angles` 为 `skeleton-topology.md` 5 定义的 9 个关节角余弦,顺序固定:
`[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]` `[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]`
- 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30 - 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30
- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**, - 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**,
@@ -45,7 +102,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
### 2.2 BVH 关节名映射(默认表,可在 `web-server/src/preprocess/joint-map.ts` 扩展) ### 2.2 BVH 关节名映射(默认表,可在 `web-server/src/preprocess/joint-map.ts` 扩展)
| MediaPipe 点 | 常见 BVH 关节名(不区分大小写,按序匹配) | | MediaPipe 点 | 常见 BVH 关节名(不区分大小写,按序匹配) |
|---|---| |--|--|
| 0 nose | `Head`, `Neck1`, `Head_Nub` | | 0 nose | `Head`, `Neck1`, `Head_Nub` |
| 11 left_shoulder | `LeftShoulder`, `LeftArm`, `LShoulder` | | 11 left_shoulder | `LeftShoulder`, `LeftArm`, `LShoulder` |
| 12 right_shoulder | `RightShoulder`, `RightArm`, `RShoulder` | | 12 right_shoulder | `RightShoulder`, `RightArm`, `RShoulder` |
@@ -62,13 +119,54 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
| 31 left_foot_index | `LeftToeBase`, `LeftToe`, `LToe` | | 31 left_foot_index | `LeftToeBase`, `LeftToe`, `LToe` |
| 32 right_foot_index | `RightToeBase`, `RightToe`, `RToe` | | 32 right_foot_index | `RightToeBase`, `RightToe`, `RToe` |
未映射到的点按 `skeleton-topology.md` §5 的层级由父节点推算, 未映射到的点按 `skeleton-topology.md` 5 的层级由父节点推算,
并置 `confidence` 降低(`0.5`),表示估算而非实测。 并置 `confidence` 降低(`0.5`),表示估算而非实测。
### 2.3 VMD(MikuMikuDance)解析约定
VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节,
Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**:15 字节骨骼名、uint32 帧号、
float32x3 位移、float32x4 旋转四元数、64 字节贝塞尔插值参数)+ morph / 相机 /
灯光 / 自阴影 / IK / 可见性段落。**VMD 不含骨骼长度**,只有旋转(少数骨骼带位移),
因此服务端必须自带一套标准 MMD 骨架才能做 FK。
处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)-> `mmd-skeleton.ts`
(标准骨架)-> `mmd-pose.ts`(FK + 腿部 IK)-> `retarget.ts` 的
`worldFramesToKeypointFrames()`(与 BVH 完全共用骨盆归零 / 自适应缩放 / 图像空间投影 /
9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。
| MediaPipe 点 | MMD 标准骨骼 | 说明 |
|--|--|--|
| 0 nose | `頭` | 取头骨根部再上移,近似面部中心 |
| 11 / 12 left/right_shoulder | `左腕` / `右腕` | 肩关节(不是 `左肩` 骨骼) |
| 13 / 14 left/right_elbow | `左ひじ` / `右ひじ` | |
| 15 / 16 left/right_wrist | `左手首` / `右手首` | |
| 23 / 24 left/right_hip | `左足` / `右足` | 大腿根部 |
| 25 / 26 left/right_knee | `左ひざ` / `右ひざ` | |
| 27 / 28 left/right_ankle | `左足首` / `右足首` | |
| 31 / 32 left/right_foot_index | `左つま先` / `右つま先` | |
骨骼名按 `mmd-skeleton.ts` 的别名表归一(含全角 `IK` / 半角 `IK` 两种写法);
未纳入标准骨架的骨骼(裙摆、物理、表情等)被忽略但会计数并写入预处理日志。
**腿部驱动判定**:`足IK` 轨道存在非零位移 -> 走 IK(用两骨骼解析 IK 把踝拉到 IK
目标点,膝盖朝角色正面弯,不可达时收敛为伸直);否则走 FK(部分动作数据把腿烘焙成
FK 旋转)。两份示例文件分别命中两种路径。
**坐标系**:MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色
左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) -> (-x,-y,z,w)`、位移 z 取反)后
送入标准骨架。该选择由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下
膝盖朝前占比 94.1%,不转换时仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
**标准骨架数值来源**:解析参考模型的 PMX 骨骼段实测得到(而非凭经验估计),
再去掉模型自身原点偏移并抹平左右不对称。关键比例:足首 1.43 / ひざ 6.57 /
髋 11.50 / センター 10.88 / 首 16.21 / 頭 17.16(总高约 19.4,髋高比约 59%);
大腿 4.94 + 小腿 5.15 ~ 腿长 10.07。
## 3. 上传限制 ## 3. 上传限制
| 项 | 值 | | 项 | 值 |
|---|---| |--|--|
| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) | | 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
| 允许扩展名 | 上表全部;其他一律 422 | | 允许扩展名 | 上表全部;其他一律 422 |
| 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` | | 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` |
@@ -77,7 +175,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
## 4. 开源依赖与许可证(引用 `open-source-credits.md`) ## 4. 开源依赖与许可证(引用 `open-source-credits.md`)
- `bvh`(MIT)— BVH 解析参考实现 - `bvh`(MIT)- BVH 解析参考实现
- `Assimp`(BSD-3-Clause)— FBX/glTF 转换,**可选外部依赖** - `Assimp`(BSD-3-Clause)- FBX/glTF 转换,**可选外部依赖**
- `@pixiv/three-vrm`(MIT)— 管理端 VRM 预览 - `@pixiv/three-vrm`(MIT)- 管理端 VRM 预览
- `three`(MIT)— 管理端 3D 预览 - `three`(MIT)- 管理端 3D 预览
+3 -3
View File
@@ -1,10 +1,10 @@
# contracts/open-source-credits.md — 开源声明锚点 v0.1.0 # contracts/open-source-credits.md - 开源声明锚点 v0.1.0
三端发布产物中的 `THIRD_PARTY.md` 与各自「关于」页面必须包含下表全部条目。 三端发布产物中的 `THIRD_PARTY.md` 与各自「关于」页面必须包含下表全部条目。
本文件是**唯一来源**,任一端新增依赖须回到 `opendance-web` 更新此表。 本文件是**唯一来源**,任一端新增依赖须回到 `opendance-web` 更新此表。
| # | 项目 | 许可证 | 源链接 | 使用端 | | # | 项目 | 许可证 | 源链接 | 使用端 |
|---|---|---|---|---| |--|--|--|--|--|
| 1 | MediaPipe | Apache License 2.0 | https://github.com/google-ai-edge/mediapipe | Android | | 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 管理端 | | 2 | Three.js | MIT | https://github.com/mrdoob/three.js | Web 管理端 |
| 3 | @pixiv/three-vrm | MIT | https://github.com/pixiv/three-vrm | Web 管理端 | | 3 | @pixiv/three-vrm | MIT | https://github.com/pixiv/three-vrm | Web 管理端 |
@@ -21,7 +21,7 @@
| 14 | Jetpack Compose | Apache License 2.0 | https://developer.android.com/jetpack/compose | 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 服务端 | | 15 | mysql2 | MIT | https://github.com/sidorares/node-mysql2 | Web 服务端 |
> 注意:文档模板中列出的 10 项为**最低要求**;第 11–15 项为实际引入的构建期/运行时依赖, > 注意:文档模板中列出的 10 项为**最低要求**;第 11-15 项为实际引入的构建期/运行时依赖,
> 一并声明更完整。发布时必须保留各许可证副本(见 `web-server/LICENSE-THIRD-PARTY/`)。 > 一并声明更完整。发布时必须保留各许可证副本(见 `web-server/LICENSE-THIRD-PARTY/`)。
## 版权声明补充 ## 版权声明补充
+74 -12
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: OpenDance Studio Web 管理服务端 API title: OpenDance Studio Web 管理服务端 API
version: 0.1.0 version: 0.4.0
description: | description: |
OpenDance Studio 的唯一数据源与契约权威。 OpenDance Studio 的唯一数据源与契约权威。
契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。 契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。
@@ -11,9 +11,14 @@ info:
- 来源五元组(sourceType/sourceName/sourceUrl/license/author)在三类资产上均为必填 - 来源五元组(sourceType/sourceName/sourceUrl/license/author)在三类资产上均为必填
servers: servers:
- url: http://localhost:3000 - url: http://localhost:3000
description: 本地开发 description: 本地开发(软件 API 端口)
- url: http://<host>:3000 - url: http://<host>:3000
description: Docker 部署 description: >-
Linux 业务服务部署的**软件 API 端口**(`PORT`,默认 3000)。Windows/Godot 客户端与
Android App 调用本端口的 /api、/health、/static、/contracts。
管理端页面在**管理端口**(`ADMIN_PORT`,默认 3001),该端口同时提供全部接口;
API 端口不提供管理页面,未命中的路径一律 404。
反向代理由另外的服务提供(非本项目交付物),按用途分别转发到对应端口即可。
tags: tags:
- name: health - name: health
@@ -178,7 +183,7 @@ paths:
schema: { type: integer, format: int64 } schema: { type: integer, format: int64 }
- name: format - name: format
in: query in: query
schema: { type: string, enum: [bvh, fbx, gltf, glb, keypoints-json] } schema: { type: string, enum: [bvh, vmd, fbx, gltf, glb, keypoints-json] }
- name: status - name: status
in: query in: query
schema: { $ref: '#/components/schemas/PublishStatus' } schema: { $ref: '#/components/schemas/PublishStatus' }
@@ -257,7 +262,7 @@ paths:
parameters: [{ $ref: '#/components/parameters/Id' }] parameters: [{ $ref: '#/components/parameters/Id' }]
post: post:
tags: [motions] tags: [motions]
summary: 触发 BVH/FBX → MediaPipe33 关键点序列预处理(同步执行) summary: 触发 BVH/FBX -> MediaPipe33 关键点序列预处理(同步执行)
description: | description: |
同步执行并返回最终状态:成功时 preprocessState=done; 同步执行并返回最终状态:成功时 preprocessState=done;
失败时返回 422,且 Motion.preprocessError 记录可读原因。 失败时返回 422,且 Motion.preprocessError 记录可读原因。
@@ -280,7 +285,7 @@ paths:
'200': '200':
description: | description: |
application/x-ndjson,每行一个帧对象: application/x-ndjson,每行一个帧对象:
{"frame":0,"timeMs":0,"points":[[x,y,z,conf], …33],"angles":[9 个余弦]} {"frame":0,"timeMs":0,"points":[[x,y,z,conf], ...33],"angles":[9 个余弦]}
content: content:
application/x-ndjson: application/x-ndjson:
schema: { type: string } schema: { type: string }
@@ -312,7 +317,26 @@ paths:
schema: { $ref: '#/components/schemas/ModelPage' } schema: { $ref: '#/components/schemas/ModelPage' }
post: post:
tags: [models] tags: [models]
summary: 上传模型 + 来源信息(VRM 优先) summary: 上传模型(GLB/VRM 直接导入;PMX/FBX 服务端转 GLB 或 VRM)
description: |
系统以 **GLB / VRM 为主力格式**(两个客户端都原生加载):
- `.glb` / `.vrm` / `.gltf`:**直接导入**,不重编码
- `.pmx` / `.fbx`:服务端转换,**优先 GLB、其次 VRM**
- `.zip`:**自动搜索包内可用模型** -- `.vrm` -> `.glb` -> `.gltf`(直接导入)
优先,其次 `.pmx` -> `.fbx`(转换)。压缩包原件作为 `model-archive` 资产留存。
转换链路(`model-convert.ts`):`assimp export -> .glb` ->(失败)`-> .gltf` 后
由服务端自行打包成 GLB ->(`MODEL_CONVERT_FORMAT=vrm` 时)在 GLB 之上补
`VRMC_vrm` 扩展产出 VRM 1.0。每一步都 `assimp info` **回读校验**,
并把过程写进 `conversionNote`;贴图会内嵌进 GLB,保证单文件可用。
入库时自动做两件事并写入 `stats` / `thumbnailUrl`:模型分析(面数、骨骼树、
humanoid 覆盖、可驱动性)与缩略图生成。
**状态一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费;
设为默认模型请在审核通过时带 `setPrimary=true`,或调用
`POST /api/models/{id}/primary`(仅已发布模型可设)。
requestBody: requestBody:
required: true required: true
content: content:
@@ -324,6 +348,7 @@ paths:
file: { type: string, format: binary } file: { type: string, format: binary }
name: { type: string } name: { type: string }
isPrimary: { type: boolean } isPrimary: { type: boolean }
# 注意:上传不再提供"自动发布/自动设为默认",模型一律进入待审核
sourceType: { $ref: '#/components/schemas/SourceType' } sourceType: { $ref: '#/components/schemas/SourceType' }
sourceName: { type: string } sourceName: { type: string }
sourceUrl: { type: string } sourceUrl: { type: string }
@@ -373,6 +398,25 @@ paths:
responses: responses:
'204': { description: 已删除 } '204': { description: 已删除 }
/api/models/{id}/reanalyze:
parameters: [{ $ref: '#/components/parameters/Id' }]
post:
tags: [models]
summary: 重新分析模型并重建缩略图(升级回填 / 文件替换后刷新)
description: |
重新计算 `stats`(面数、骨骼树、humanoid 覆盖、可驱动性)并重出缩略图,
只改这两个字段。用于:升级到带 `stats` 的版本后回填历史模型、
模型文件被替换后刷新、缩略图渲染器升级后重出图。
批量回填用 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000`。
responses:
'200':
description: 重新分析后的模型
content:
application/json:
schema: { $ref: '#/components/schemas/Model' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { description: 模型没有文件资产,无法分析 }
/api/models/{id}/primary: /api/models/{id}/primary:
parameters: [{ $ref: '#/components/parameters/Id' }] parameters: [{ $ref: '#/components/parameters/Id' }]
post: post:
@@ -444,7 +488,7 @@ paths:
- $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/Id'
post: post:
tags: [reviews] tags: [reviews]
summary: 审核通过 → published summary: 审核通过 -> published
requestBody: requestBody:
content: content:
application/json: application/json:
@@ -466,7 +510,7 @@ paths:
- $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/Id'
post: post:
tags: [reviews] tags: [reviews]
summary: 撤回审核 → draft summary: 撤回审核 -> draft
requestBody: requestBody:
content: content:
application/json: application/json:
@@ -508,7 +552,7 @@ paths:
- $ref: '#/components/parameters/Id' - $ref: '#/components/parameters/Id'
post: post:
tags: [reviews] tags: [reviews]
summary: 审核驳回 → rejected summary: 审核驳回 -> rejected
requestBody: requestBody:
required: true required: true
content: content:
@@ -587,7 +631,7 @@ components:
enum: [draft, pending, published, rejected] enum: [draft, pending, published, rejected]
MotionFormat: MotionFormat:
type: string type: string
enum: [bvh, fbx, gltf, glb, keypoints-json] enum: [bvh, vmd, fbx, gltf, glb, keypoints-json]
ModelFormat: ModelFormat:
type: string type: string
enum: [vrm, glb, gltf, fbx] enum: [vrm, glb, gltf, fbx]
@@ -620,7 +664,7 @@ components:
type: object type: object
properties: properties:
connected: { type: boolean } connected: { type: boolean }
server: { type: string, description: '如 MariaDB 10.11.19' } server: { type: string, description: '如 MariaDB 10.5+' }
database: { type: string } database: { type: string }
migrationsPending: { type: integer } migrationsPending: { type: integer }
@@ -744,6 +788,24 @@ components:
fileUrl: { type: string, nullable: true } fileUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true } fileSize: { type: integer, nullable: true }
thumbnailUrl: { type: string, nullable: true } thumbnailUrl: { type: string, nullable: true }
conversionNote:
type: string
nullable: true
description: 由压缩包转换而来时的说明;直传模型为 null
fileSha256:
type: string
nullable: true
description: 模型文件(转换产物)的 sha256 -- 每次转换都会变(Assimp 导出非字节级确定),仅作记录
stats:
type: object
nullable: true
description: >-
模型分析结果(面数 / 顶点数 / 贴图 / 骨骼树 / humanoid 覆盖 / 可驱动性),
审核界面据此判断"能不能驱动与评分"。结构见 contracts/model-formats.md 1.1
sourceSha256:
type: string
nullable: true
description: 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null
status: { $ref: '#/components/schemas/PublishStatus' } status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
+4 -4
View File
@@ -1,11 +1,11 @@
# contracts/skeleton-topology.md — 骨骼拓扑契约 v0.1.0 # contracts/skeleton-topology.md - 骨骼拓扑契约 v0.1.0
**唯一拓扑:MediaPipe Pose 33 点。禁止引入 SMPL 及其衍生拓扑(许可风险)。** **唯一拓扑:MediaPipe Pose 33 点。禁止引入 SMPL 及其衍生拓扑(许可风险)。**
## 1. 33 点定义与索引 ## 1. 33 点定义与索引
| idx | name | 中文 | 说明 | | idx | name | 中文 | 说明 |
|---|---|---|---| |--|--|--|--|
| 0 | nose | 鼻 | | | 0 | nose | 鼻 | |
| 1 | left_eye_inner | 左眼内 | | | 1 | left_eye_inner | 左眼内 | |
| 2 | left_eye | 左眼 | | | 2 | left_eye | 左眼 | |
@@ -43,7 +43,7 @@
## 2. 虚拟关节(由 33 点派生,索引 33/34,仅用于层级与角度特征) ## 2. 虚拟关节(由 33 点派生,索引 33/34,仅用于层级与角度特征)
| idx | name | 计算 | | idx | name | 计算 |
|---|---|---| |--|--|--|
| 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` | | 33 | neck | `midpoint(left_shoulder(11), right_shoulder(12))` |
| 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` | | 34 | pelvis | `midpoint(left_hip(23), right_hip(24))` |
@@ -121,5 +121,5 @@ spine = angle(neck, pelvis, midpoint(left_ankle, right_ankle))
`angle(a, b, c)` = 向量 `ba` 与 `bc` 的夹角余弦,即 `angle(a, b, c)` = 向量 `ba` 与 `bc` 的夹角余弦,即
`dot(normalize(a-b), normalize(c-b))`,落在 `[-1, 1]`。 `dot(normalize(a-b), normalize(c-b))`,落在 `[-1, 1]`。
**DTW 序列结构(冻结)**:每帧 = `[33 × (x, y, z), 9 个关节角余弦] = 108 维`。 **DTW 序列结构(冻结)**:每帧 = `[33 x (x, y, z), 9 个关节角余弦] = 108 维`。
低置信度点(`visibility < 0.5`)在插值阶段由相邻帧线性补全,不参与代价计算。 低置信度点(`visibility < 0.5`)在插值阶段由相邻帧线性补全,不参与代价计算。
+10 -10
View File
@@ -1,6 +1,6 @@
# contracts/websocket-keypoints.md — 关键点传输契约 v0.1.0 # contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
Android 辅助 App(发送端)→ Windows 客户端(接收端)。 Android 辅助 App(发送端)-> Windows 客户端(接收端)。
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。 服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
## 1. 端点 ## 1. 端点
@@ -27,8 +27,8 @@ ws://<windows-host>:8765/pose
bit2 = 上半身模式(仅 0..22 有效) bit2 = 上半身模式(仅 0..22 有效)
bit3-7 保留,必须为 0 bit3-7 保留,必须为 0
4 4 frame_id u32 LE 单调递增,从 0 开始 4 4 frame_id u32 LE 单调递增,从 0 开始
8 231 keypoints 33 × 7B 见下 8 231 keypoints 33 x 7B 见下
-------------------------------------------------------------- --
合计 239 合计 239
``` ```
@@ -70,7 +70,7 @@ func decode(buf: PackedByteArray) -> Dictionary:
## 3. 文本控制帧(JSON,UTF-8) ## 3. 文本控制帧(JSON,UTF-8)
### 3.1 `hello` — Android → Windows,连接后立即发送 ### 3.1 `hello` - Android -> Windows,连接后立即发送
```json ```json
{ {
@@ -84,25 +84,25 @@ func decode(buf: PackedByteArray) -> Dictionary:
} }
``` ```
### 3.2 `welcome` — Windows → Android ### 3.2 `welcome` - Windows -> Android
```json ```json
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 } { "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
``` ```
### 3.3 `config` — Windows → Android,运行中调整 ### 3.3 `config` - Windows -> Android,运行中调整
```json ```json
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" } { "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
``` ```
### 3.4 `stats` — Android → Windows,每秒一次 ### 3.4 `stats` - Android -> Windows,每秒一次
```json ```json
{ "type": "stats", "fps": 29.6, "dropped": 3, "latencyMs": 42, "confidenceAvg": 0.93 } { "type": "stats", "fps": 29.6, "dropped": 3, "latencyMs": 42, "confidenceAvg": 0.93 }
``` ```
### 3.5 `bye` — 双向 ### 3.5 `bye` - 双向
```json ```json
{ "type": "bye", "reason": "user_stop" } { "type": "bye", "reason": "user_stop" }
@@ -117,7 +117,7 @@ func decode(buf: PackedByteArray) -> Dictionary:
"frameId": 1024, "frameId": 1024,
"timestampMs": 1730000000000, "timestampMs": 1730000000000,
"flags": { "mirrored": false, "upperBodyOnly": false }, "flags": { "mirrored": false, "upperBodyOnly": false },
"points": [[0.51, 0.22, -0.03, 0.99], "…共 33 项,顺序固定 [x, y, z, conf]"] "points": [[0.51, 0.22, -0.03, 0.99], "...共 33 项,顺序固定 [x, y, z, conf]"]
} }
``` ```
+2 -2
View File
@@ -5,7 +5,7 @@ default_branch: main
platform: Android 10+(minSdk 29 / targetSdk 34) platform: Android 10+(minSdk 29 / targetSdk 34)
contracts_version: 0.1.0 contracts_version: 0.1.0
# 契约变更流程(文档 §7.2): # 契约变更流程(文档 7.2):
# 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本, # 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本,
# 再在本项目创建适配任务。本项目内的 contracts/ 只是只读快照。 # 再在本项目创建适配任务。本项目内的 contracts/ 只是只读快照。
@@ -34,7 +34,7 @@ tasks:
status: done status: done
- id: T-AND-004 - id: T-AND-004
file: tasks/004-keypoint-serializer.md file: tasks/004-keypoint-serializer.md
scope: 33 点 → 239 字节帧 scope: 33 点 -> 239 字节帧
status: done status: done
- id: T-AND-005 - id: T-AND-005
file: tasks/005-websocket-client.md file: tasks/005-websocket-client.md
+2 -2
View File
@@ -33,13 +33,13 @@ app/src/main/AndroidManifest.xml
## 版本组合(均已验证可构建) ## 版本组合(均已验证可构建)
``` ```
JDK 17 / Gradle 8.9 / AGP 8.7.3 / Kotlin 2.0.21 / Compose BOM 2024.10.01 JDK 17 / Gradle 8.9 / AGP 8.7.3 / Kotlin 2.0.21 / Compose BOM 2024.10.01
``` ```
## 验收 ## 验收
```bash ```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 export JAVA_HOME=<JDK 17 路径>
./gradlew assembleDebug ./gradlew assembleDebug
``` ```
+1 -1
View File
@@ -25,7 +25,7 @@ app/src/main/java/com/opendance/studio/data/AppSettings.kt
|---|---|---| |---|---|---|
| 背压策略 | `STRATEGY_KEEP_ONLY_LATEST` | 推理慢时自动丢帧,预览不卡顿 | | 背压策略 | `STRATEGY_KEEP_ONLY_LATEST` | 推理慢时自动丢帧,预览不卡顿 |
| 输出格式 | `OUTPUT_IMAGE_FORMAT_RGBA_8888` | MediaPipe 需要 Bitmap,避免 YUV 转换 | | 输出格式 | `OUTPUT_IMAGE_FORMAT_RGBA_8888` | MediaPipe 需要 Bitmap,避免 YUV 转换 |
| 分辨率 | 三档(480×360 / 640×480 / 1280×720),`FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER` | 低端机可降档保帧率 | | 分辨率 | 三档(480x360 / 640x480 / 1280x720),`FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER` | 低端机可降档保帧率 |
| 分析线程 | 单线程 Executor | 推理串行,避免并发抢占 | | 分析线程 | 单线程 Executor | 推理串行,避免并发抢占 |
| 镜像 | 预览镜像由 PreviewView 处理;**发送原始未镜像坐标** | 契约要求由 flags.bit0 声明镜像语义 | | 镜像 | 预览镜像由 PreviewView 处理;**发送原始未镜像坐标** | 契约要求由 flags.bit0 声明镜像语义 |
+3 -3
View File
@@ -11,7 +11,7 @@
``` ```
DSH.md DSH.md
contracts/skeleton-topology.md # 锚点:§1 33 点、§4 坐标系 contracts/skeleton-topology.md # 锚点:1 33 点、4 坐标系
app/src/main/java/com/opendance/studio/pose/PoseDetector.kt app/src/main/java/com/opendance/studio/pose/PoseDetector.kt
``` ```
@@ -22,7 +22,7 @@ app/src/main/java/com/opendance/studio/pose/PoseDetector.kt
## 关键实现 ## 关键实现
| 项 | 说明 | | 项 | 说明 |
|---|---| |--|--|
| 运行模式 | `LIVE_STREAM` + `detectAsync`,不阻塞相机线程 | | 运行模式 | `LIVE_STREAM` + `detectAsync`,不阻塞相机线程 |
| 模型 | `pose_landmarker_lite.task`(随 APK 分发,`noCompress "task"` 保证可读) | | 模型 | `pose_landmarker_lite.task`(随 APK 分发,`noCompress "task"` 保证可读) |
| 置信度阈值 | detection / presence / tracking 均 0.5 | | 置信度阈值 | detection / presence / tracking 均 0.5 |
@@ -32,7 +32,7 @@ app/src/main/java/com/opendance/studio/pose/PoseDetector.kt
## 坐标与镜像语义 ## 坐标与镜像语义
- 发送**未镜像的原始 MediaPipe 坐标**(契约 §4) - 发送**未镜像的原始 MediaPipe 坐标**(契约 4)
- 是否镜像由帧头 `flags.bit0` 声明,接收端据此决定如何还原 - 是否镜像由帧头 `flags.bit0` 声明,接收端据此决定如何还原
- 叠加层绘制时才取 `1 - x`,与前置摄像头预览的镜像观感一致 - 叠加层绘制时才取 `1 - x`,与前置摄像头预览的镜像观感一致
+9 -9
View File
@@ -1,4 +1,4 @@
# T-AND-004 33 点 → 239 字节帧 # T-AND-004 33 点 -> 239 字节帧
- **状态**:done - **状态**:done
- **依赖**:T-AND-003(数据结构)、T-AND-001 - **依赖**:T-AND-003(数据结构)、T-AND-001
@@ -11,8 +11,8 @@
``` ```
DSH.md DSH.md
contracts/websocket-keypoints.md # 锚点:§2 二进制帧(冻结) contracts/websocket-keypoints.md # 锚点:2 二进制帧(冻结)
contracts/skeleton-topology.md # 锚点:§1 33 点、§4 坐标系 contracts/skeleton-topology.md # 锚点:1 33 点、4 坐标系
app/src/main/java/com/opendance/studio/model/KeypointFrame.kt app/src/main/java/com/opendance/studio/model/KeypointFrame.kt
app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt
``` ```
@@ -21,11 +21,11 @@ app/src/main/java/com/opendance/studio/model/SkeletonTopology.kt
``` ```
偏移 长度 字段 偏移 长度 字段
0 2 magic u16 = 0x4F44 ('OD'),小端序 → 字节序为 44 4F 0 2 magic u16 = 0x4F44 ('OD'),小端序 -> 字节序为 44 4F
2 1 version u8 = 1 2 1 version u8 = 1
3 1 flags u8 bit0 镜像 | bit1 低置信度 | bit2 上半身 3 1 flags u8 bit0 镜像 | bit1 低置信度 | bit2 上半身
4 4 frame_id u32 LE 4 4 frame_id u32 LE
8 231 keypoints 33 × 7B:s16 x, s16 y, s16 z, u8 conf 8 231 keypoints 33 x 7B:s16 x, s16 y, s16 z, u8 conf
合计 239 合计 239
``` ```
@@ -44,7 +44,7 @@ const { encodeBinaryFrame } = require('./dist/preprocess/keypoint-codec.js');
const points = [], conf = []; const points = [], conf = [];
for (let i = 0; i < 33; i++) { for (let i = 0; i < 33; i++) {
points.push([(i%33)/32, 1-(i%33)/32, ((i%7)-3)/10]); points.push([(i%33)/32, 1-(i%33)/32, ((i%7)-3)/10]);
conf.push(i%2===0 ? 1.0 : 0.5); conf.push(i%2===0? 1.0: 0.5);
} }
const buf = encodeBinaryFrame(42, points.map((p,i)=>[...p,conf[i]]), 0b101); const buf = encodeBinaryFrame(42, points.map((p,i)=>[...p,conf[i]]), 0b101);
console.log(buf.toString('hex')); console.log(buf.toString('hex'));
@@ -52,13 +52,13 @@ console.log(buf.toString('hex'));
``` ```
固定输入:`xs = (i%33)/32`、`ys = 1-(i%33)/32`、`zs = ((i%7)-3)/10`、 固定输入:`xs = (i%33)/32`、`ys = 1-(i%33)/32`、`zs = ((i%7)-3)/10`、
`confidence = i%2==0 ? 1.0 : 0.5`、`frameId = 42`、`flags = 0b101`。 `confidence = i%2==0? 1.0: 0.5`、`frameId = 42`、`flags = 0b101`。
## 关键实现 ## 关键实现
- `ByteBuffer.order(LITTLE_ENDIAN)`,避免手动移位出错 - `ByteBuffer.order(LITTLE_ENDIAN)`,避免手动移位出错
- 定点换算 `round(v × 32767)` 并 `coerceIn(-32768, 32767)` - 定点换算 `round(v x 32767)` 并 `coerceIn(-32768, 32767)`
- 置信度 `round(v × 255)`,阈值 0.5 含端点(`>=`) - 置信度 `round(v x 255)`,阈值 0.5 含端点(`>=`)
- 输入超范围时裁剪而非抛异常(相机抖动可能产生轻微越界值) - 输入超范围时裁剪而非抛异常(相机抖动可能产生轻微越界值)
- 并行 FloatArray 存储,30 FPS 循环内零对象分配 - 并行 FloatArray 存储,30 FPS 循环内零对象分配
+4 -4
View File
@@ -11,7 +11,7 @@
``` ```
DSH.md DSH.md
contracts/websocket-keypoints.md # 锚点:§1 端点与子协议、§3 控制帧 contracts/websocket-keypoints.md # 锚点:1 端点与子协议、3 控制帧
app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt
``` ```
@@ -22,11 +22,11 @@ app/src/main/java/com/opendance/studio/net/PoseWebSocketClient.kt
## 关键实现 ## 关键实现
| 项 | 做法 | 理由 | | 项 | 做法 | 理由 |
|---|---|---| |--|--|--|
| 子协议 | 请求头 `Sec-WebSocket-Protocol: opendance.pose.v1` | 契约 §1 | | 子协议 | 请求头 `Sec-WebSocket-Protocol: opendance.pose.v1` | 契约 1 |
| 二进制帧 | `socket.send(ByteString)` | 契约要求生产链路用二进制 | | 二进制帧 | `socket.send(ByteString)` | 契约要求生产链路用二进制 |
| 控制帧 | `send(text)`,JSON | hello / stats / bye | | 控制帧 | `send(text)`,JSON | hello / stats / bye |
| 自动重连 | 指数退避 1s → 2s → 4s … 上限 15s,连接成功即重置 | 避免抖动时打爆对端 | | 自动重连 | 指数退避 1s -> 2s -> 4s... 上限 15s,连接成功即重置 | 避免抖动时打爆对端 |
| 非连接态发送 | 直接丢弃并计入 `droppedFrames` | **绝不阻塞相机线程** | | 非连接态发送 | 直接丢弃并计入 `droppedFrames` | **绝不阻塞相机线程** |
| 心跳 | OkHttp `pingInterval(15s)` | 及时发现半开连接 | | 心跳 | OkHttp `pingInterval(15s)` | 及时发现半开连接 |
| 读超时 | 0(不超时) | WebSocket 长连接 | | 读超时 | 0(不超时) | WebSocket 长连接 |
+4 -4
View File
@@ -27,17 +27,17 @@ HTTP 端点发现。因此:
1. 取手机在 WiFi 网的 IPv4 地址(排除 `rmnet*` 蜂窝网卡与 `169.254.*` 链路本地地址) 1. 取手机在 WiFi 网的 IPv4 地址(排除 `rmnet*` 蜂窝网卡与 `169.254.*` 链路本地地址)
2. 推导 `/24` 网段 2. 推导 `/24` 网段
3. 并发 TCP 探测 `192.168.x.1-254:8765`(并发 32、单机超时 300ms) 3. 并发 TCP 探测 `<局域网网段>.1-254:8765`(并发 32、单机超时 300ms)
4. 以 `Flow` 形式边扫边产出,按延迟升序展示——最快响应的最可能是目标机 4. 以 `Flow` 形式边扫边产出,按延迟升序展示--最快响应的最可能是目标机
整段扫描约 2~3 秒。 整段扫描约 2~3 秒。
## 可配置项 ## 可配置项
| 项 | 说明 | | 项 | 说明 |
|---|---| |--|--|
| IP / 端口 | 默认 8765,与契约一致 | | IP / 端口 | 默认 8765,与契约一致 |
| 分辨率 | 480×360 / 640×480 / 1280×720 | | 分辨率 | 480x360 / 640x480 / 1280x720 |
| 目标帧率 | 写入 hello 的 targetFps | | 目标帧率 | 写入 hello 的 targetFps |
| 镜像画面 | 写入 flags.bit0 | | 镜像画面 | 写入 flags.bit0 |
| 仅上半身模式 | 写入 flags.bit2 | | 仅上半身模式 | 写入 flags.bit2 |
+2 -2
View File
@@ -28,7 +28,7 @@ CameraX(ImageAnalysis) ──ImageProxy──▶ MediaPipe PoseLandmarker(端
## 状态指示(本任务的验收重点) ## 状态指示(本任务的验收重点)
| 指标 | 含义 | | 指标 | 含义 |
|---|---| |--|--|
| 连接状态 | 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开 | | 连接状态 | 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开 |
| 目标 | 完整 WS 地址 | | 目标 | 完整 WS 地址 |
| 会话 | 服务端 welcome 回来的 sessionId | | 会话 | 服务端 welcome 回来的 sessionId |
@@ -39,7 +39,7 @@ CameraX(ImageAnalysis) ──ImageProxy──▶ MediaPipe PoseLandmarker(端
| 平均置信度 | < 70% 时转黄,提示光线或遮挡问题 | | 平均置信度 | < 70% 时转黄,提示光线或遮挡问题 |
| 最近错误 | 连接或推理的错误原因 | | 最近错误 | 连接或推理的错误原因 |
叠加层把 **低置信度点画成红色**,用户能直接看出是哪个部位没识别到—— 叠加层把 **低置信度点画成红色**,用户能直接看出是哪个部位没识别到--
比只显示一个错误码有用得多。 比只显示一个错误码有用得多。
## 其他 ## 其他
+1 -1
View File
@@ -31,6 +31,6 @@ app/src/main/java/com/opendance/studio/ui/Credits.kt
## 验收 ## 验收
`CreditsTest`(JVM 单元测试)断言: `CreditsTest`(JVM 单元测试)断言:
条目数 ≥ 10、文档要求的 10 项全部在列、每条有许可证与 https 链接、 条目数 >= 10、文档要求的 10 项全部在列、每条有许可证与 https 链接、
已知项目的许可证正确、本端自身依赖(MediaPipe/OkHttp/CameraX/Compose)均在列、 已知项目的许可证正确、本端自身依赖(MediaPipe/OkHttp/CameraX/Compose)均在列、
项目名不重复、契约版本为 0.1.0。 项目名不重复、契约版本为 0.1.0。
+9 -9
View File
@@ -20,10 +20,10 @@ app/build.gradle.kts
## 交付物 ## 交付物
| 文件 | 说明 | | 文件 | 说明 |
|---|---| |--|--|
| `build/gen-keystore.sh` | 生成发布签名密钥库(**不入库**) | | `build/gen-keystore.sh` | 生成发布签名密钥库(**不入库**) |
| `build/build-apk.sh` | 构建 + 校验产物(魔数、签名方案) | | `build/build-apk.sh` | 构建 + 校验产物(魔数、签名方案) |
| `build/verify.sh` | 单元测试 → 构建 → 产物校验 一条龙 | | `build/verify.sh` | 单元测试 -> 构建 -> 产物校验 一条龙 |
## 签名设计 ## 签名设计
@@ -32,7 +32,7 @@ app/build.gradle.kts
``` ```
1. keystore.properties(build/gen-keystore.sh 生成,已 gitignore) 1. keystore.properties(build/gen-keystore.sh 生成,已 gitignore)
2. 环境变量 KEYSTORE_PATH / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD 2. 环境变量 KEYSTORE_PATH / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD
3. 两者都缺失 → 回退 debug 签名,保证 CI 上 assembleRelease 也能产出已签名 APK 3. 两者都缺失 -> 回退 debug 签名,保证 CI 上 assembleRelease 也能产出已签名 APK
``` ```
正式发布必须走 1 或 2;回退路径仅为让 CI/验收可跑通,并在构建日志中给出提示。 正式发布必须走 1 或 2;回退路径仅为让 CI/验收可跑通,并在构建日志中给出提示。
@@ -41,16 +41,16 @@ app/build.gradle.kts
``` ```
1. Android Studio 打开 opendance-android/ 1. Android Studio 打开 opendance-android/
2. Build → Generate Signed Bundle / APK 2. Build -> Generate Signed Bundle / APK
3. 选择 APK → 选择或创建 keystore → 选择 release 3. 选择 APK -> 选择或创建 keystore -> 选择 release
4. 生成 app-release.apk 4. 生成 app-release.apk
``` ```
## 验收(文档 §6.3 T-AND-009) ## 验收(文档 6.3 T-AND-009)
| 验收项 | 结果 | | 验收项 | 结果 |
|---|---| |--|--|
| Android Studio 可 Build → Generate Signed APK | ✅ 工程为标准 AGP 结构,`keystore.properties` 就位即可 | | Android Studio 可 Build -> Generate Signed APK | ✅ 工程为标准 AGP 结构,`keystore.properties` 就位即可 |
| `./gradlew assembleRelease` 成功生成签名 APK | ✅ 见下 | | `./gradlew assembleRelease` 成功生成签名 APK | ✅ 见下 |
| APK 在 Android 10+ 设备可安装运行 | ⚠️ 本环境无 Android 设备/模拟器,未实机安装 | | APK 在 Android 10+ 设备可安装运行 | ⚠️ 本环境无 Android 设备/模拟器,未实机安装 |
| 关于页包含开源项目声明 | ✅ `CreditsTest` 覆盖,且声明随包分发 | | 关于页包含开源项目声明 | ✅ `CreditsTest` 覆盖,且声明随包分发 |
@@ -68,7 +68,7 @@ app-release.apk 55.5 MiB apksigner: Verifies(v2 方案)
## 环境记录 ## 环境记录
- JDK 17(`/usr/lib/jvm/java-17-openjdk-amd64`) - JDK 17(本地安装路径,示例 `<JDK 17 路径>`)
- Android SDK:`platforms;android-34`、`build-tools;34.0.0` - Android SDK:`platforms;android-34`、`build-tools;34.0.0`
- 构建期修复的问题:`proguard-rules.pro` 缺失导致 R8 配置告警(已补全 keep 规则)、 - 构建期修复的问题:`proguard-rules.pro` 缺失导致 R8 配置告警(已补全 keep 规则)、
androidTest 的 `androidx.test.runner` 依赖补齐 androidTest 的 `androidx.test.runner` 依赖补齐