界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
483 lines
28 KiB
Markdown
483 lines
28 KiB
Markdown
# desktop
|
||
|
||
桌面端。**两种身份,两套页面,一个进程。**
|
||
|
||
| 身份 | 画面 | 血量 | 装甲板 | 绑定 |
|
||
|---|---|---|---|---|
|
||
| 用户端(`team`) | 一路 | **双方**(左上角自己 / 右上角对手) | 自己那块 | **要,改动需密码** |
|
||
| 裁判端(`referee`) | 两路并排 | 双方(各自贴画面角上,**队名可改**) | 发现列表 + 槽位 | 不需要 |
|
||
|
||
界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。**它不是把旧客户端套进
|
||
一个窗口**:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。
|
||
|
||
```text
|
||
desktop/
|
||
core/ cam-core:不含任何界面与 Tauri 的东西(80 个测试)
|
||
src-tauri/ Tauri 后端:推流、命令、录制(30 个测试)
|
||
src/ React 前端:两套页面 + HUD
|
||
src/lib/ IPC 封装、WebSocket 客户端、状态存储、格式化
|
||
src/components/ 视频窗格、血条、胜负横幅、瞄准器、装甲板表、队名编辑、录制面板、设置抽屉
|
||
src/pages/ 用户端 / 裁判端
|
||
```
|
||
|
||
## 快速开始
|
||
|
||
```sh
|
||
cd desktop
|
||
pnpm install
|
||
pnpm app # = tauri dev:起 vite + 编 Rust + 开窗口
|
||
pnpm build # 只构建前端(tsc --noEmit && vite build)
|
||
pnpm tauri build # 出 .app 与 .dmg
|
||
```
|
||
|
||
下面两条是 Rust 侧,用它验证不依赖窗口的部分:
|
||
|
||
```sh
|
||
cd desktop/core && cargo test # 80
|
||
cd desktop/src-tauri && cargo test # 30,其中几条真的会起 ffmpeg
|
||
```
|
||
|
||
### 演示模式:没有板子也能把整条链路跑起来
|
||
|
||
```sh
|
||
cd desktop
|
||
CAM_DEMO=1 pnpm app
|
||
```
|
||
|
||
它**不伪造数据给界面**,而是合成**协议数据报**,从 `Receiver::datagram` 与
|
||
`ArmorHealthStore::datagram` 进去。也就是说演示模式跑的是同一条真实路径:
|
||
|
||
```text
|
||
合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||
```
|
||
|
||
分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。
|
||
界面上会有一条 **演示模式** 标记,板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出是人造
|
||
的形状——**别把它当成现场数据**。
|
||
|
||
### 命令行与环境变量
|
||
|
||
```text
|
||
--role <team|referee> 身份。默认看配置文件
|
||
--config <路径> 配置文件位置
|
||
--demo 演示模式
|
||
--list 打印发现的摄像头与装甲板后退出
|
||
-h, --help 帮助
|
||
|
||
CAM_DEMO=1 等价于 --demo
|
||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||
```
|
||
|
||
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||
(`tauri dev -- -- --demo`),写在一行环境变量里不会有人记错。
|
||
|
||
**未知参数只记一条 debug 日志,不报错。** macOS 从访达双击 .app 时系统会塞进来一个
|
||
`-psn_0_123456`;把它当错误处理的表现是"双击图标什么都没发生"。
|
||
|
||
## 为什么换成 Tauri,代价是什么
|
||
|
||
旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是**界面这一侧的生产力**:血条、
|
||
瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是
|
||
几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心
|
||
在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。
|
||
|
||
代价说清楚:
|
||
|
||
- **多一层**。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
|
||
- **要联网装依赖**(前端 npm、Rust crates)。
|
||
- **画面要过一条本地通道**才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器,
|
||
路径更短。
|
||
|
||
换来的另一样东西是**可测性**:`core/` 不认识界面,所以重组、发现、帧率窗口、槽位分配、
|
||
配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。
|
||
|
||
## 视频怎么进 WebView
|
||
|
||
**一个只监听回环地址的 WebSocket,推二进制 JPEG。** 界面从 `stream_info` 命令拿到
|
||
`ws://127.0.0.1:<随机端口>/?token=...`。
|
||
|
||
### 为什么不是 Tauri 的 IPC
|
||
|
||
两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨
|
||
1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把
|
||
原始字节直接交给前端,浏览器用**原生解码器**解,**后端连 JPEG 都不用解**。
|
||
|
||
### 二进制帧的布局
|
||
|
||
```text
|
||
0 4 8 10 12 16
|
||
+--------------+--------------+------+------+--------------+---------+
|
||
| sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
|
||
+--------------+--------------+------+------+--------------+---------+
|
||
```
|
||
|
||
小端,16 字节头。前后端对这段布局的约定由一个测试盯着(`server.rs` 与 `stream.ts`
|
||
两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。
|
||
|
||
### 前端侧的两个陷阱
|
||
|
||
**解码是异步的,所以帧号要看着用。** `createImageBitmap` 返回 Promise,两个相邻帧的解码
|
||
可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔
|
||
往回跳一帧,看起来像"卡了一下",极难归因。
|
||
|
||
**`ImageBitmap` 必须显式 `close()`。** 它持有解码后的像素,不吃 GC 的账。每收到新帧就
|
||
替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。
|
||
|
||
## 防作弊:两道闸门,不是一个
|
||
|
||
对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。
|
||
|
||
**关键是这件事做在接收端,不是界面里。** 广播的 Hello 里就有 MAC 派生的 `sender_id` 与
|
||
IP,藏起来毫无意义;数据也可能已经收下并解码了,`.slint`/CSS 只控制显示。
|
||
|
||
| 闸门 | 管什么 | 什么时候开 |
|
||
|---|---|---|
|
||
| `only_sender` | 帧的重组与投递 | 用户端**始终**是绑定的那一块 |
|
||
| `discovery_all` | 是否记录**其它**板子的 Hello | 只在密码解锁后的设置界面里打开 |
|
||
|
||
旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑
|
||
只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但**画面不进
|
||
来**——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。
|
||
|
||
四种情况不限制:裁判端(中立方)、debug 构建、用户端还没绑定、`--camera-id` 显式覆盖。
|
||
|
||
### 遥测按编译模式门控
|
||
|
||
`TEAM_DIAGNOSTICS = cfg!(debug_assertions)`。用户端 release 下**不产出**帧率/丢帧这些
|
||
数字——不是"界面藏起来",是 DTO 里就不存在(`CameraStatsDto::build` 直接把它们置零)。
|
||
由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。
|
||
|
||
### 这挡不住什么
|
||
|
||
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后可以自己发一个
|
||
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住
|
||
需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就
|
||
失配。
|
||
|
||
回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要
|
||
先读到那个 token 的事。
|
||
|
||
## 密码:它挡的是谁
|
||
|
||
必须说清楚,否则会被当成一把它并不是的锁。
|
||
|
||
- **挡得住**:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观
|
||
的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会
|
||
要求先解锁。**退出/关闭程序**同样要密码,因为"随手关掉"也是一种要挡的误操作。
|
||
- **挡不住**:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希
|
||
或者直接改源码都行。真要封住得回固件加配对 token。
|
||
|
||
所以它的定位是**防误触与防随手改**,不是密码学意义上的对抗。
|
||
|
||
实现上:
|
||
|
||
- Argon2id(`argon2` crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希
|
||
一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有
|
||
保护。
|
||
- 首次启动**强制**设置密码(`Config::needs_password_setup`),最短 4 位。长度下限只要
|
||
4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏
|
||
手机查一遍。
|
||
- 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
|
||
- **裁判端不要密码**:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改
|
||
槽位、开录。
|
||
- **离开用户端要密码**:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。
|
||
|
||
执行点在 Rust 侧:每个改变配置的命令都过 `AppState::require_unlocked`。界面上的解锁
|
||
对话框只是入口——即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。
|
||
|
||
**故意没有 `--camera-id` 这类命令行覆盖。** 旧客户端有一个,用来"临时把一台机器当另一台
|
||
用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改
|
||
(要密码),或者用 `--config` 指一份另外的配置文件。
|
||
|
||
忘了密码:删掉配置文件里的 `password` 一行(或整个文件)。旧客户端的配置会在首次启动时
|
||
导入并备份成 `.bak`。
|
||
|
||
## 用户端 HUD
|
||
|
||
### 血条:两个上角,平行四边形,一条分 5 格
|
||
|
||
**画面左上角是自己,右上角是对手**——两条同时看得见。血量是这一屏上最需要对照的两个数,
|
||
放在画面之外意味着视线要离开取景。条本身是斜的(平行四边形),里面分成 **5 格**,一格
|
||
20 点血:格子少而大,隔着几米扫一眼也能数出"还剩几格",比读一条连续的长度或一个百分比快。
|
||
少了的那部分从**靠中心的一侧**开始灭,于是两条血一起减少时视觉上往两边退。
|
||
|
||
(条挂在绝对定位的一层上,`pointer-events: none`——那一层盖着画面,而画面要能滚轮缩放、
|
||
指针拖动。同理,倍率与复位徽标从画面**上**沿挪到了下沿,上沿两个角归血条。)
|
||
|
||
宽度、是否显示数字/队名、低血阈值与脉动都可配;阈值以内的格子提前染上警示色,所以"危险区
|
||
在哪"是一直看得见的,而不是等真的低血了才第一次出现。
|
||
|
||
**未接入时显示占位说明而不是 0**:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经
|
||
没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。
|
||
|
||
血量归零**不写"已击毁/损毁"这类话**:归零这件事由图传中央的胜负横幅表达(见下),条上只留
|
||
一个 0 与灭掉的格子。
|
||
|
||
### 胜负横幅
|
||
|
||
一方归零之后,**图传中央**显示结论:赢的一边是「胜利」,输的一边是「击败」,两边同时归零是
|
||
「平局」(那时候任何一边显示"胜利"都是错的)。用词带上对手的名字,裁判端两格因此也能直接
|
||
看出是哪一边赢了。
|
||
|
||
- 结论由 Rust 侧算(`snapshot::resolve_outcomes`,有单测):**输只需要看自己**——自己归零就是
|
||
输,哪怕对手那块板子掉线;**赢要看两边**——对手归零且自己还有血,否则一块从没上报过血量的
|
||
自家主板会让人"凭空胜利"。
|
||
- 它一直显示到血量被重置(长按 BOOT / 下一局开始),不做几秒淡出:淡出之后屏幕上就再没有
|
||
"这局结束了"的痕迹,截图、回放与现场的争议都无从对照。
|
||
- 这是唯一放在画面正中的东西。命中反馈那一套刻意避开正中(见下),因为**胜负不是那种信息**:
|
||
它是一局的结束,此刻不再需要瞄准。
|
||
|
||
### 瞄准器
|
||
|
||
画在 canvas 上(不是叠一层 DOM),因为**它必须跟着画面走**:它标的是相机光轴上的一个点。
|
||
用 DOM 摆一个 `<img>` 再叠一层 `transform` 的话,"图像落在哪、瞄准点落在哪"要算两遍,而
|
||
两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换
|
||
只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。
|
||
|
||
尺寸与缩放的关系也是刻意分开的:
|
||
|
||
| | 跟着缩放走吗 | 为什么 |
|
||
|---|---|---|
|
||
| 位置 | **是** | 它指的是画面里的一个点 |
|
||
| 大小 | 否 | 像真瞄具一样,放大之后准星覆盖画面里更小的一块 |
|
||
| 网格 | 是 | 它标的是画面里的比例,用来估距离 |
|
||
|
||
**偏移校准存的是画面比例,不是像素。** 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素
|
||
偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。
|
||
|
||
### 命中反馈
|
||
|
||
主板**不报**"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的
|
||
(`HealthTracker`):
|
||
|
||
- 血量减少 ⇒ 一次命中,伤害是两个读数之差;
|
||
- 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
|
||
- 第一次见到一块板子不产生命中——没有基线,此时 `hp = 0` 只是"已经没血了"。
|
||
|
||
按**板子**记基线而不是按 `(板子, index)`:主板一次广播四条、`hp` 相同,按四条各记一份
|
||
基线会让一次掉血被报成四次命中。
|
||
|
||
已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次
|
||
打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。
|
||
|
||
反馈有**两样**:整屏闪红、准星旁的伤害数字。**大号数字不放在画面正中**——第一版放了,
|
||
结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。
|
||
|
||
**没有"画面抖动"了。** 原来有第三样:被打中时整幅图(连瞄准器一起)晃一下。它去掉了,
|
||
理由有两条:
|
||
|
||
- 抖动恰好发生在最需要看清画面的那一下;
|
||
- 它把**画面**动了,而操作手看到画面动时的第一反应是"车在晃",不是"我被打中了"——一个
|
||
只有几百毫秒的信号,代价是让人怀疑一个不存在的问题。
|
||
|
||
`HitFeedbackConfig::shake` 这个字段留着但不再生效:配置文件是 `deny_unknown_fields` 的,
|
||
删字段会让所有写过 `shake = true` 的配置**解析失败**,而配置解析失败等于程序起不来。设置
|
||
面板里也没有这个开关了。
|
||
|
||
## 裁判端
|
||
|
||
### 队名可以直接改
|
||
|
||
两格画面各自下面有一个队名输入框(`TeamNameEditor`),回车或点到别处生效,Esc 取消。
|
||
名字是**这一场比赛**的属性,所以它放在裁判盯着的地方,而不是设置面板深处的某一节;改完
|
||
立刻反映到血条、装甲板表与"等待图传"的占位文字上。
|
||
|
||
规范化的三件事统一在 Rust 侧做(`config::sanitize_team_name`,有单测):合并空白、按**字符**
|
||
截断到 12 个字(按字节截断会切出半个字)、留空回落到「队伍一 / 队伍二」。超过长度在输入框里
|
||
就限制了,比提交后被悄悄截掉诚实。
|
||
|
||
命令是 `set_team_name(team, name)`——**一次只改一边**。界面本来可以传一整个两条的数组,但那样
|
||
每次改名都是"读两条、改一条、写两条",而读来的那两条来自最多 200 ms 前的快照,紧接着改另一边
|
||
就会把上一次的改动覆盖回去。**同样过密码闸门**:裁判端本来就没有密码(`Role::needs_password`
|
||
为假),所以对现场没有影响;而给一个不设闸门的"写配置"入口,等于在闸门上开一条旁路——今天
|
||
改的是名字,明天有人顺手把绑定也挪进来。
|
||
|
||
名字在快照里以 `teamNames`(两条,下标就是队伍号)推给界面,两边各自的 `TeamHealthDto.name`
|
||
也是从这里来的:界面各处自己拼 `team === 0 ? "队伍一" : "队伍二"` 会在改名之后留下一半旧名字,
|
||
而且不会报错。
|
||
|
||
### 两格等宽
|
||
|
||
`flex: 1 1 0` + `min-width: 0`,两格的宽度只由容器决定。
|
||
|
||
旧客户端在这里翻过车:`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按 stretch
|
||
分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要
|
||
十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。
|
||
|
||
### 槽位分配:修掉了一个真 bug
|
||
|
||
规则:
|
||
|
||
1. 还在线的槽位不换人;
|
||
2. **候选里排除已经被另一个槽位占着的 id**;
|
||
3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
|
||
4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。
|
||
|
||
规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是**队伍一的板子一离线,它的
|
||
槽位就会去拿队伍二那块,两格显示同一块板子**——看起来像图传串了,现场的排查方向会是网线、
|
||
AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。
|
||
|
||
有烧错固件的板子(`team` 字段是反的)时可以在设置里改成手动指定槽位。
|
||
|
||
### 装甲板发现列表
|
||
|
||
场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个
|
||
槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队
|
||
伍号烧反了"和"只有一条上报在发"。
|
||
|
||
**这不需要改固件**:主板一直用 `encode_from(sender_id)` 把自己的 MAC 派生 id 打进帧头,
|
||
只是旧的解码路径把它丢了。现在 `ArmorHealth::decode_from` 把它交出来。
|
||
|
||
### 截图与录制
|
||
|
||
- 截图存 PNG(`shots/` 下),文件名带板子 id 与毫秒时间戳。
|
||
- 录制存 mp4,走 ffmpeg 子进程:`-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0`
|
||
喂帧,x264 编码,`+faststart`。
|
||
- **帧率要显式告诉 ffmpeg**。`image2pipe` 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来
|
||
就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放
|
||
时第一个要看的就是它。
|
||
- 喂帧经由一条长度 64 的队列与一个写盘线程。**接收线程绝不能被写盘拖慢**:那一天的表现是
|
||
链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
|
||
- 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的
|
||
`-y` 把上一段**静默覆盖**掉。所以先收尾上一段再开新的,重名往后排 `-2`、`-3`。
|
||
|
||
界面内回放走 Tauri 的资源协议,只对 `tauri.conf.json` 里 `assetProtocol.scope` 白名单的
|
||
目录生效。默认录制目录在名单里;改到别处时 `<video>` 会加载失败,界面上给出"用系统播放器
|
||
打开"的退路——而不是让人以为录下来的文件坏了。
|
||
|
||
## 配置
|
||
|
||
`~/.config/cam-desktop/config.toml`,`--config` 可改。
|
||
|
||
```toml
|
||
role = "team"
|
||
roleChosen = true
|
||
team = 0
|
||
teamNames = ["队伍一", "队伍二"]
|
||
cameraId = "0x0DE00001"
|
||
armorId = "0x0A200001"
|
||
mirror = false
|
||
password = "$argon2id$v=19$m=19456,t=2,p=1$..."
|
||
|
||
[recording]
|
||
dir = "/Users/you/Movies/cam-desktop"
|
||
ffmpeg = "ffmpeg"
|
||
crf = 20
|
||
preset = "veryfast"
|
||
|
||
[hud.crosshair]
|
||
style = "cross-dot"
|
||
size = 0.05
|
||
offsetX = 0.0
|
||
```
|
||
|
||
几个决定:
|
||
|
||
- **手写解析换成 serde + toml。** 旧客户端的配置只有四个标量字段,手写换来零依赖是划算
|
||
的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
|
||
- **未知键名仍然报错**(`deny_unknown_fields`)。打错一个键名却"看起来生效了",在现场是
|
||
要浪费时间的。
|
||
- **字段名统一 camelCase**,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮,
|
||
但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置
|
||
看起来"改了没生效"。
|
||
- **原子写**:先写同目录临时文件再 `rename`。配置文件里有密码哈希,写一半崩掉留下的截断
|
||
文件会让"解锁"永久失败。
|
||
- **id 写成十六进制字符串**(`"0x3D4D8A9A"`),与界面、日志、抓包里看到的完全一致。
|
||
- `teamNames` 在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的两队
|
||
叫什么"是比赛本身的属性,不是裁判端这个身份的属性。
|
||
- `roleChosen` 单独一位:`role` 的默认值是裁判端,所以"role == referee"既可能是人选了裁判
|
||
端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。
|
||
|
||
旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 `.bak`(不删:现场
|
||
出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用**默认**路径时才做这件事——
|
||
`--config` 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。
|
||
|
||
## 浏览器预览(仅开发)
|
||
|
||
Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面
|
||
里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由
|
||
`src/lib/dev-preview.ts` 合成。
|
||
|
||
```sh
|
||
pnpm dev # 用户端
|
||
open 'http://127.0.0.1:1420/?preview=referee' # 裁判端(密码 1234)
|
||
```
|
||
|
||
两重门保证它进不了正式构建:`import.meta.env.DEV` 为真**且**没有 `__TAURI_INTERNALS__`,
|
||
并且调用点是 `await import(...)`——条件为假时整个模块不会被打进产物。
|
||
|
||
## 测试
|
||
|
||
```sh
|
||
cd desktop/core && cargo test # 80:重组、发现、两道闸门、帧率窗口、槽位、配置、密码、胜负判定
|
||
cd desktop/src-tauri && cargo test # 30:帧头布局、录制、截图、演示画面、命令行
|
||
cd protocol && cargo test # 15
|
||
```
|
||
|
||
三条值得单独说的:
|
||
|
||
- **录制**那条真的起 ffmpeg、喂 20 帧、停,然后用 `ffprobe` 读回帧数与分辨率。只断言"文件
|
||
存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的
|
||
一种失败。
|
||
- **截图**那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
|
||
- **帧率窗口**那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下"
|
||
改回错误算法。
|
||
|
||
CSP 的验证不靠"看一眼没报错",而是靠**前端能不能连上后端**:前端的第一步是先 `invoke`
|
||
拿通道地址,再连 WebSocket。所以快照里的 `clients` 从 0 变成 1 同时证明了四件事——JS 加载
|
||
成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在
|
||
0。手动核对的办法:
|
||
|
||
```sh
|
||
RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
|
||
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1
|
||
```
|
||
|
||
## 线程与数据流
|
||
|
||
| 线程 | 做什么 | 为什么单独 |
|
||
|---|---|---|
|
||
| `udp-reader` | 收包、解析、按发送方重组 | 不能让解码/写盘挤进数据报之间 |
|
||
| `armor-reader` | 收 5601 上的血量广播 | 与视频是两个端口、两种节奏 |
|
||
| `rec-writer-<id>` | 把队列里的帧写进 ffmpeg 的 stdin | 接收线程绝不能被写盘拖慢 |
|
||
| `demo-*`(仅演示) | 合成协议数据报 | 走同一条真实路径 |
|
||
| tokio 任务 | WebSocket 服务、5 Hz 快照 | 异步 IO |
|
||
|
||
跨线程只有两样东西:`Arc` 与广播通道。帧在接收线程里编码一次(16 字节头 + JPEG),
|
||
所有界面共用同一份 `Bytes`。
|
||
|
||
## 已知限制与没做的
|
||
|
||
- **同一队有两块装甲板在广播时**,`health(team)` 是"最后写入者优先",四个格子可能混着两块
|
||
板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到;
|
||
日志里会警告一次,消歧办法是绑定具体的板子 id(`health_of_board`)。
|
||
- **录制帧率**是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与
|
||
DTO,所以至少能解释清楚。
|
||
- **`csp` 是一条收紧过的白名单**,但它是"够用"而不是"最小":`style-src` 带
|
||
`'unsafe-inline'`(React 的内联 `style` 属性需要它),`connect-src` 放开了整个
|
||
`ws://127.0.0.1:*`(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端"
|
||
——见下面"测试"一节最后一条。
|
||
- **证书与公证**没做,`.dmg` 只在本机可用。
|
||
- 前端没有自动化测试。界面这一侧的验证手段是浏览器预览 + 真机运行;逻辑都在 `core/` 里
|
||
有测试。
|
||
|
||
## 排查
|
||
|
||
| 现象 | 原因 |
|
||
|---|---|
|
||
| 起不来,提示绑不上 UDP 5600 | 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 `~/.config/cam-desktop/startup-error.txt` |
|
||
| 窗口起来了但没画面 | 设置里看"摄像头"下拉:绑定的是哪一块、在不在线 |
|
||
| 状态栏一直"已连接,等待画面…" | 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次) |
|
||
| 队伍端下拉里只有一块板子 | 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选 |
|
||
| 右上角那条血显示"装甲板未接入" | 对手那块主板没在 5601 上报血量(或不在线)。显示的是说明而不是 0——0 会被当成"对手已经没血" |
|
||
| 改了队名,某一处还是旧名字 | 不该发生:名字只有一个来源(快照里的 `teamNames`,血条直接用 `name`)。看到的话说明有一处自己拼了"队伍一/队伍二",那是个 bug |
|
||
| 输了/赢了之后画面中央一直有字 | 刻意如此:它显示到血量被重置(长按 BOOT)为止,不做几秒淡出。想清掉就重置装甲板 |
|
||
| 被打中时画面不动,只有闪红 | 刻意如此:抖动去掉了(见"命中反馈")。旧配置里的 `shake = true` 读得进来但不生效 |
|
||
| 画面不动 | 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。`RUST_LOG=debug` 会打印完整通道地址,可以用 `wscat` 连上去看 |
|
||
| 回放失败 | 录制目录不在资源协议白名单里。用"用系统播放器打开" |
|
||
| 录不了 | ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录 |
|
||
| 两块画面串了 | 检查是不是手动指定了槽位,或者两块板子烧了同一个 `ARMOR_TEAM` |
|
||
| macOS 上 WebKit 报 `could not create directory .../WebKit/...` | 运行环境禁止写 `~/Library/WebKit`(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响 |
|
||
| `libc` 找不到 `MAP_FAILED` | `desktop/.cargo/config.toml` 的 target 没改成宿主三元组 |
|