Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab88231ea0 | ||
|
|
91e56135fc | ||
|
|
264786134c | ||
|
|
203dcd8a9c | ||
|
|
5563a78e07 | ||
|
|
cee82c7b36 | ||
|
|
f9e88901dd | ||
|
|
6f15d119ed | ||
|
|
eb534ed7ee | ||
|
|
e9ea1b5811 |
@@ -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. 上下文超标时的拆分策略
|
||||||
|
|
||||||
| 超标原因 | 拆分方式 |
|
| 超标原因 | 拆分方式 |
|
||||||
|---|---|
|
|--|--|
|
||||||
| 单任务涉及多个界面 | 按界面拆(连接/运行/关于) |
|
| 单任务涉及多个界面 | 按界面拆(连接/运行/关于) |
|
||||||
| 单文件过大 | 按类拆,先生成接口再分实现 |
|
| 单文件过大 | 按类拆,先生成接口再分实现 |
|
||||||
| 契约全文加载 | 改用锚点引用 |
|
| 契约全文加载 | 改用锚点引用 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 预览
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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`)在插值阶段由相邻帧线性补全,不参与代价计算。
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 声明镜像语义 |
|
||||||
|
|
||||||
|
|||||||
@@ -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`,与前置摄像头预览的镜像观感一致
|
||||||
|
|
||||||
|
|||||||
@@ -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 循环内零对象分配
|
||||||
|
|
||||||
|
|||||||
@@ -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 长连接 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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% 时转黄,提示光线或遮挡问题 |
|
||||||
| 最近错误 | 连接或推理的错误原因 |
|
| 最近错误 | 连接或推理的错误原因 |
|
||||||
|
|
||||||
叠加层把 **低置信度点画成红色**,用户能直接看出是哪个部位没识别到——
|
叠加层把 **低置信度点画成红色**,用户能直接看出是哪个部位没识别到--
|
||||||
比只显示一个错误码有用得多。
|
比只显示一个错误码有用得多。
|
||||||
|
|
||||||
## 其他
|
## 其他
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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` 依赖补齐
|
||||||
|
|||||||
Reference in New Issue
Block a user