desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)

把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层:

- desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按
  发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。
- desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、
  每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。
- protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来——
  装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选"
  不需要改固件。

几个改对了的旧问题:

- 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位
  会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。
- 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出
  几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。
- 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了"
  会被读成"密码错了"。现在要求盐与摘要都在。
- 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。
  现在先收尾上一段再开新的,并且重名往后排号。
- 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面**
  不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。

测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用
ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
2026-10-14 19:14:16 +08:00
parent eafa112959
commit a89d7ae5fb
50 changed files with 15615 additions and 11 deletions
+5287
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
[package]
name = "cam-desktop"
version = "0.1.0"
edition = "2021"
description = "ESP32 图传与装甲板系统的桌面端"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# `protocol-asset` 打开自定义资源协议,录制出来的 mp4 才能在界面里直接回放
# (前端用 convertFileSrc 把它变成一个 <video> 能播的 URL)。
tauri = { version = "2", features = ["protocol-asset"] }
# 只用来"在访达里显示"与"用系统播放器打开"。所有调用都走我们自己的 Rust 命令,
# 所以前端不需要装 @tauri-apps/plugin-opener,也不需要给它开权限。
tauri-plugin-opener = "2"
# 非界面部分:UDP 接收/重组、装甲板、配置、密码、槽位分配。它自己带 64 个测试。
cam-core = { path = "../core" }
# 线路格式与固件共享。
protocol = { path = "../../protocol" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
anyhow = "1"
# 回环通道的随机 token。
rand = "0.9"
# 本地 WebSocket 视频通道。用 tokio-tungstenite 而不是 Tauri 的自定义协议:
# 二进制消息不经 JSON/IPC 序列化,前端拿到的就是原始 JPEG 字节,可以直接交给
# 浏览器的原生 JPEG 解码器(createImageBitmap)。
tokio = { version = "1", features = [
"rt-multi-thread",
"net",
"sync",
"io-util",
"macros",
"time",
] }
tokio-tungstenite = "0.30"
futures-util = "0.3"
# 截图要 PNG(相机出的 JPEG 不带 alpha、也可能被播放器当照片处理),所以要解码再编码。
# demo 模式也用它合成假画面。
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
# 文件名里的时间戳:现场看目录时要能一眼看出是哪一局录的。
chrono = "0.4"
[profile.release]
opt-level = 2
+8
View File
@@ -0,0 +1,8 @@
//! Tauri 的构建脚本。
//!
//! 它做两件事:把 `tauri.conf.json` 读进来生成上下文,以及(在 Windows 上)挂上清单。
//! 桌面端不需要自定义资源编译,所以这里没有 `tauri_build::Attributes`。
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "窗口与事件这些核心能力。业务命令(绑定、解锁、截图、录制)都是本应用自己的 #[tauri::command],不在权限系统里,所以这里没有它们的条目。",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+503
View File
@@ -0,0 +1,503 @@
//! 界面能调用的命令。
//!
//! # 每个会改东西的命令都过密码闸门
//!
//! 界面里有一个解锁对话框,但那只是入口:这些函数才是执行点。所以每个改变配置的命令
//! 都显式调用 [`AppState::require_unlocked`],而 `presented` 允许在同一次调用里带上
//! 密码(前端在解锁后立刻改绑定时就是这样)。
//!
//! # 为什么错误是 `String`
//!
//! Tauri 把 `Err` 序列化给前端,而 `anyhow::Error` 不实现 `Serialize`。这些错误是给
//! **现场的人**看的("密码不对"、"还没有收到这一路的画面"),本来就该是给人读的句子,
//! 所以直接传字符串,同时用 `log::warn!` 留下带上下文的版本。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::{AppHandle, State};
use cam_core::config::Role;
use cam_core::snapshot::{ConfigView, Snapshot};
use cam_core::HudConfig;
use crate::recording::{list_media, MediaFileDto};
use crate::server::StreamInfo;
use crate::state::AppState;
/// 还没有测到帧率时,告诉 ffmpeg 的默认输入帧率。
///
/// 传感器在这个配置下实测 22 fps,但写死 22 是在撒谎——真正的问题不是"猜得准不准",
/// 而是这个数会决定回放速度,所以它一律写进日志与 [`cam_core::snapshot::RecordingDto`]。
const FALLBACK_INPUT_FPS: f32 = 20.0;
type Shared<'a> = State<'a, Arc<AppState>>;
// ---------------------------------------------------------------------------
// 只读
// ---------------------------------------------------------------------------
/// 视频通道的地址与 token。前端启动时拉一次。
#[tauri::command]
pub fn stream_info(state: Shared<'_>) -> StreamInfo {
state.stream_info()
}
/// 当前完整状态。
#[tauri::command]
pub fn get_snapshot(state: Shared<'_>) -> Snapshot {
state.snapshot()
}
/// 配置里可以给界面看的那部分(不含密码哈希)。用户端第一次启动用它判断"要不要设密码"。
#[tauri::command]
pub fn get_config(state: Shared<'_>) -> ConfigView {
ConfigView::from(&state.config())
}
/// 录制目录里的 mp4 与 png,新的在前。
#[tauri::command]
pub fn list_media_files(state: Shared<'_>) -> Vec<MediaFileDto> {
list_media(&state.config().recordings_dir())
}
/// 配置文件的路径。现场排查时要能告诉人"去看这个文件"。
#[tauri::command]
pub fn config_path(state: Shared<'_>) -> String {
state.config_path.display().to_string()
}
// ---------------------------------------------------------------------------
// 密码
// ---------------------------------------------------------------------------
/// 校验密码并把本次运行标记为已解锁。
#[tauri::command]
pub fn unlock(password: String, state: Shared<'_>) -> Result<bool, String> {
state
.unlock(&password)
.map_err(|err| format!("存下来的密码哈希读不出来:{err}"))
}
/// 手动重新上锁(并顺手把发现列表关掉)。
#[tauri::command]
pub fn lock(state: Shared<'_>) {
state.lock();
}
/// 首次设置密码,或修改密码。
#[tauri::command]
pub fn set_password(
current: Option<String>,
fresh: String,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state
.set_password(current.as_deref(), &fresh)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 设置面板打开/关闭带来的发现列表。打开需要已解锁。
#[tauri::command]
pub fn set_discovery_open(open: bool, state: Shared<'_>) -> Result<(), String> {
state.set_discovery_open(open)
}
// ---------------------------------------------------------------------------
// 绑定与身份
// ---------------------------------------------------------------------------
/// 绑定(或解除绑定)一块图传板。
#[tauri::command]
pub fn bind_camera(
camera_id: Option<u32>,
password: Option<String>,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state.require_unlocked(password.as_deref())?;
state
.update_config(move |config| config.camera_id = camera_id)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 绑定(或解除绑定)一块装甲板主板。
#[tauri::command]
pub fn bind_armor(
armor_id: Option<u32>,
password: Option<String>,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state.require_unlocked(password.as_deref())?;
state
.update_config(move |config| config.armor_id = armor_id)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 自己属于哪一边(0/1)。改错队号会让血条显示对手的血量,所以也要密码。
#[tauri::command]
pub fn set_team(
team: u8,
password: Option<String>,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state.require_unlocked(password.as_deref())?;
if team > 1 {
return Err("队伍号只能是 0(队伍一)或 1(队伍二)".into());
}
state
.update_config(move |config| config.team = team)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 切换身份。
///
/// **离开用户端要密码**:否则解锁界面就形同虚设——切到裁判端就能看两路画面、还能随便
/// 改配置。反过来(裁判端 → 用户端)不需要:裁判端本来就没有密码。
#[tauri::command]
pub fn set_role(
role: Role,
password: Option<String>,
state: Shared<'_>,
) -> Result<ConfigView, String> {
let current = state.config().role;
if current.needs_password() && current != role {
state.require_unlocked(password.as_deref())?;
}
state
.update_config(move |config| {
config.role = role;
// 记下"人选过了":下次启动直接进这个身份,不再问。
config.role_chosen = true;
})
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 左右镜像。摄像头装反了才需要,不是判罚相关的东西,所以不设密码。
#[tauri::command]
pub fn set_mirror(mirror: bool, state: Shared<'_>) -> Result<ConfigView, String> {
state
.update_config(move |config| config.mirror = mirror)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 保存 HUD 设置(血条、瞄准器、网格、命中反馈)。
#[tauri::command]
pub fn save_hud(hud: HudConfig, state: Shared<'_>) -> Result<ConfigView, String> {
state
.update_config(move |config| config.hud = hud)
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 裁判端两个槽位的手动指定。
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub fn set_referee_slots(
camera_a: Option<u32>,
camera_b: Option<u32>,
armor_a: Option<u32>,
armor_b: Option<u32>,
manual: bool,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state
.update_config(move |config| {
config.referee.slot_a = camera_a;
config.referee.slot_b = camera_b;
config.referee.armor_a = armor_a;
config.referee.armor_b = armor_b;
config.referee.manual_slots = manual;
})
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
/// 录制目录与 ffmpeg 路径。
#[tauri::command]
pub fn set_recording_options(
dir: Option<String>,
ffmpeg: Option<String>,
crf: Option<u8>,
preset: Option<String>,
state: Shared<'_>,
) -> Result<ConfigView, String> {
state
.update_config(move |config| {
config.recording.dir = dir.filter(|d| !d.trim().is_empty());
if let Some(ffmpeg) = ffmpeg.filter(|f| !f.trim().is_empty()) {
config.recording.ffmpeg = ffmpeg;
}
if let Some(crf) = crf {
config.recording.crf = crf;
}
if let Some(preset) = preset.filter(|p| !p.trim().is_empty()) {
config.recording.preset = preset;
}
})
.map_err(|err| format!("{err}"))?;
Ok(ConfigView::from(&state.config()))
}
// ---------------------------------------------------------------------------
// 截图与录制
// ---------------------------------------------------------------------------
/// 把某一路最新的一帧存成 PNG。
#[tauri::command]
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
let frame = state
.latest_frames()
.latest(camera)
.ok_or_else(|| format!("还没有收到 0x{camera:08X} 的画面,暂时截不了"))?;
let config = state.config();
let dir = config.recordings_dir().join("shots");
std::fs::create_dir_all(&dir).map_err(|err| format!("建不了截图目录 {dir:?}:{err}"))?;
let path = dir.join(format!(
"shot-{camera:08X}-{}.png",
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
));
let image = image::load_from_memory_with_format(&frame.data, image::ImageFormat::Jpeg)
.map_err(|err| format!("这一帧不是能解码的 JPEG:{err}"))?;
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|err| format!("写不了 {}:{err}", path.display()))?;
log::info!(
"screenshot of 0x{camera:08X} (frame {}) -> {}",
frame.frame_id,
path.display()
);
Ok(path.display().to_string())
}
/// 开始录制若干路。
#[tauri::command]
pub fn start_recording(
cameras: Vec<u32>,
state: Shared<'_>,
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
if cameras.is_empty() {
return Err("没有指定要录哪一路".into());
}
let config = state.config();
let dir = config.recordings_dir();
let mut started = Vec::new();
for camera in cameras {
// 帧率:配置里指定了就用它,否则用实测值。两者都没有时用保守默认值并记日志
// ——猜错的表现是回放速度不对,而那个数会写进 DTO 与日志。
let fps = if config.recording.fps > 0.0 {
config.recording.fps
} else {
match state.fps_of(camera) {
Some(measured) if measured > 1.0 => measured,
_ => {
log::warn!(
"0x{camera:08X} 还没有测到帧率,录制先按 {FALLBACK_INPUT_FPS} fps 走;\
回放速度可能不准,可以改用 [recording] fps 明确指定"
);
FALLBACK_INPUT_FPS
}
}
};
let dto = state
.recordings
.start(
camera,
&dir,
&config.recording.ffmpeg,
fps,
config.recording.crf,
&config.recording.preset,
)
.map_err(|err| format!("起不了录制:{err:#}"))?;
started.push(dto);
}
Ok(started)
}
/// 停止录制。`cameras` 为空表示全部停止。
#[tauri::command]
pub fn stop_recording(
cameras: Vec<u32>,
state: Shared<'_>,
) -> Result<Vec<cam_core::snapshot::RecordingDto>, String> {
let stopped = if cameras.is_empty() {
state.recordings.stop_all()
} else {
cameras
.into_iter()
.filter_map(|camera| state.recordings.stop(camera))
.collect()
};
Ok(stopped)
}
/// 在访达/资源管理器里显示一个文件。
///
/// 只允许指向录制目录里的东西:这是一个"用系统程序打开路径"的命令,把它做成任意路径
/// 都能传的通用工具没有任何好处。
#[tauri::command]
pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
let dir = state.config().recordings_dir();
let target = PathBuf::from(&path);
if !is_within(&dir, &target) {
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
}
if !target.exists() {
return Err(format!("{} 不存在了", target.display()));
}
tauri_plugin_opener::reveal_item_in_dir(&target)
.map_err(|err| format!("打开 {path} 失败:{err}"))
}
/// `child` 在 `parent` 里面吗。
///
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
fn is_within(parent: &Path, child: &Path) -> bool {
let Ok(parent) = parent.canonicalize() else {
return false;
};
match child.canonicalize() {
Ok(child) => child.starts_with(parent),
// 还不存在(比如刚被删掉)时退回文本比较,但仍然要求是父目录下的相对路径。
Err(_) => child.parent().is_some_and(|p| {
p.canonicalize()
.map(|p| p.starts_with(&parent))
.unwrap_or(false)
}),
}
}
// ---------------------------------------------------------------------------
// 退出与清空
// ---------------------------------------------------------------------------
/// 清空配置:把文件挪到 `.bak`,然后回到"第一次启动"的状态。
///
/// 不直接删的原因是现场排查:一个人说"我重置过了还是这样"时,能看一眼旧文件是最快的
/// 结论。
#[tauri::command]
pub fn clear_config(password: Option<String>, state: Shared<'_>) -> Result<String, String> {
state.require_unlocked(password.as_deref())?;
let path = state.config_path.clone();
if path.exists() {
let backup = PathBuf::from(format!("{}.bak", path.display()));
std::fs::rename(&path, &backup)
.map_err(|err| format!("挪走 {} 失败:{err}", path.display()))?;
log::warn!("配置已重置,旧文件留在 {}", backup.display());
}
state.lock();
Ok(format!(
"配置已重置(旧文件备份在 {})。请重启程序。",
path.display()
))
}
/// 退出程序。用户端要密码——现场"随手关掉"也是一种需要挡住的误操作。
#[tauri::command]
pub fn quit_app(
password: Option<String>,
app: AppHandle,
state: Shared<'_>,
) -> Result<(), String> {
state.require_unlocked(password.as_deref())?;
// 先把录制收尾,否则 ffmpeg 会留下一个没有索引的 mp4。
let stopped = state.recordings.stop_all();
for dto in stopped {
log::info!(
"退出前收尾录制 0x{:08X}:{} 帧 -> {:?}",
dto.camera,
dto.frames_written,
dto.path
);
}
app.exit(0);
Ok(())
}
/// 主动推一次快照。绑定改动之后前端不必等 200 ms。
#[tauri::command]
pub fn refresh(state: Shared<'_>) {
state.push_snapshot();
}
#[cfg(test)]
mod tests {
use super::is_within;
use std::path::PathBuf;
#[test]
fn a_sibling_directory_with_a_shared_prefix_is_not_inside() {
let dir = std::env::temp_dir().join(format!("cam-reveal-{}", std::process::id()));
std::fs::create_dir_all(dir.join("rec")).unwrap();
std::fs::create_dir_all(dir.join("rec-evil")).unwrap();
let inside = dir.join("rec").join("a.mp4");
std::fs::write(&inside, b"x").unwrap();
let outside = dir.join("rec-evil").join("b.mp4");
std::fs::write(&outside, b"x").unwrap();
assert!(is_within(&dir.join("rec"), &inside));
assert!(
!is_within(&dir.join("rec"), &outside),
"`/a/rec-evil` 以 `/a/rec` 为前缀,但它不在里面"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_absolute_path_outside_is_refused() {
let dir = std::env::temp_dir().join(format!("cam-reveal2-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(!is_within(&dir, &PathBuf::from("/etc/hosts")));
let _ = std::fs::remove_dir_all(&dir);
}
}
+376
View File
@@ -0,0 +1,376 @@
//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。
//!
//! # 为什么不是"假界面"
//!
//! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从
//! [`cam_core::receiver::Receiver::datagram`] 与
//! [`cam_core::armor::ArmorHealthStore::datagram`] 进去。也就是说它跑的是同一条真实链路:
//!
//! ```text
//! 合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
//! 合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
//! ```
//!
//! 于是演示模式顺带成了这条链路的端到端验证:分片、重组、发现、统计、掉血派生、
//! 两道闸门(用户端只收自己那一块)全都在跑真代码。截图、录制也因此**不需要特殊分支**。
//!
//! # 它绝不能被误读成真实链路
//!
//! 只在 `--demo` / `CAM_DEMO=1` 下启动,而且 [`cam_core::snapshot::Snapshot::demo`] 为
//! true,界面必须显示"演示模式"标记。板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出
//! 是人造的形状。
//!
//! # 线程
//!
//! 三个:两路画面各一个(各 15fps 合成 JPEG),加一个广播装甲板血量的。它们活到进程
//! 结束、不需要退出条件——和真实的接收线程一样。
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
use std::time::Duration;
use protocol::{fragment_frame, ArmorHealth, MsgType};
use crate::state::{
AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B,
};
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
const WIDTH: u32 = 640;
const HEIGHT: u32 = 480;
/// 合成帧率。实机是 22fps;15 够看出流畅度,又把 CPU 留给真正要验证的界面。
const FPS: u64 = 15;
/// 装甲板广播周期,与固件一致(200 ms)。
const ARMOR_PERIOD: Duration = Duration::from_millis(200);
/// 演示里一次"命中"打掉多少血。故意取一组差异大的值,好看出闪红强度是不是跟着伤害走。
const DAMAGE_CYCLE: [u16; 4] = [3, 7, 12, 25];
pub fn spawn(state: Arc<AppState>) {
log::warn!("演示模式:画面与血量都是本地合成的,不是场上的板子");
for (camera, team) in [(DEMO_CAMERA_A, 0u8), (DEMO_CAMERA_B, 1u8)] {
spawn_camera(Arc::clone(&state), camera, team);
}
spawn_armor(state);
}
/// 一路合成画面:周期广播 Hello(让发现列表有东西),并按 15fps 推分片。
fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
let _ = std::thread::Builder::new()
.name(format!("demo-cam-{camera:08X}"))
.spawn(move || {
let from = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(127, 0, 0, 1),
// 端口只是让日志/发现里的地址看起来像回事。
41000 + team as u16,
));
let hello = protocol::Hello {
sender_id: camera,
firmware: 0x0DE0,
};
let frame_interval = Duration::from_micros(1_000_000 / FPS);
let mut frame_id: u32 = 0;
let mut last_hello = std::time::Instant::now() - Duration::from_secs(10);
loop {
let started = std::time::Instant::now();
// 板子每 2 秒广播一次在线状态,与固件一致。
if last_hello.elapsed() >= Duration::from_secs(2) {
state
.receiver
.datagram(&hello.encode(MsgType::Hello), from);
last_hello = std::time::Instant::now();
}
let jpeg = render(camera, team, frame_id);
for fragment in fragment_frame(camera, frame_id, &jpeg, WIDTH as u16, HEIGHT as u16)
{
state.receiver.datagram(&fragment.encode(), from);
}
frame_id = frame_id.wrapping_add(1);
// 按目标周期睡,而不是固定 sleep:合成一张 640x480 的 JPEG 要几毫秒,
// 不扣掉这段时间的话实际帧率会明显低于 15,演示看起来像在卡。
if let Some(rest) = frame_interval.checked_sub(started.elapsed()) {
std::thread::sleep(rest);
}
}
});
}
/// 两块装甲板主板:各自按脚本掉血、打空、重置。
fn spawn_armor(state: Arc<AppState>) {
let _ = std::thread::Builder::new()
.name("demo-armor".into())
.spawn(move || {
let boards = [(DEMO_ARMOR_A, 0u8, 41050u16), (DEMO_ARMOR_B, 1u8, 41051)];
let mut hp = [100u16, 100u16];
// 两块板子的掉血节奏错开,界面上两边的血量不会同时变。
let mut ticks = [0u32, 7];
let mut hits = [0usize, 1];
let mut dead_for = [0u32, 0];
loop {
for (slot, (board, team, port)) in boards.iter().enumerate() {
ticks[slot] += 1;
if hp[slot] == 0 {
// 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。
dead_for[slot] += 1;
if dead_for[slot] >= 10 {
hp[slot] = 100;
dead_for[slot] = 0;
ticks[slot] = 0;
log::info!("demo: 0x{board:08X} 重置为满血(长按 BOOT 的等价动作)");
}
} else if ticks[slot] % 15 == 0 {
// 约 3 秒一次。
let damage = DAMAGE_CYCLE[hits[slot] % DAMAGE_CYCLE.len()];
hits[slot] += 1;
hp[slot] = hp[slot].saturating_sub(damage);
}
let from = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(127, 0, 0, 1),
*port,
));
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
for index in 0..4u8 {
let health = ArmorHealth {
team: *team,
index,
hp: hp[slot],
};
state.armor.datagram(&health.encode_from(*board), from);
}
}
std::thread::sleep(ARMOR_PERIOD);
}
});
}
/// 合成一张 PNG/JPEG 用的画面。
///
/// 内容刻意是为**验收**挑的:
///
/// - 两路的底色不同(青 / 橙),一眼能分辨左右两格有没有串;
/// - 正中有一组同心圆 + 十字,用来核对瞄准器的偏移校准(偏移为 0 时应该正好套住圆心);
/// - 每 40 px 一条淡网格,用来核对缩放与平移;
/// - 左上角 16 个方块是二进制帧号,截图里能看出画面在推进;
/// - 一个随时间走 Lissajous 的实心圆,用来判断两路是不是同一时刻的画面。
fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
// 底色:竖直渐变。队伍一偏青、队伍二偏橙。
//
// 直接铺一块裸缓冲再交给 `from_raw`,而不是逐像素 `put_pixel`:一帧 30 万像素,
// 每像素一次带边界检查的写入在 15fps 下是白白烧掉的 CPU。
let tint: [i16; 3] = if team == 0 { [0, 46, 40] } else { [58, 30, 0] };
let mut raw = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
for y in 0..HEIGHT {
let shade = (y as i16 * 60 / HEIGHT as i16) as i16;
let row = &mut raw[(y * WIDTH * 3) as usize..((y + 1) * WIDTH * 3) as usize];
for pixel in row.chunks_exact_mut(3) {
pixel[0] = (18 + tint[0] / 3 + shade) as u8;
pixel[1] = (18 + tint[1] / 3 + shade) as u8;
pixel[2] = (22 + tint[2] / 3 + shade) as u8;
}
}
let mut img = image::RgbImage::from_raw(WIDTH, HEIGHT, raw)
.expect("the buffer is exactly WIDTH * HEIGHT * 3 bytes");
let (cx, cy) = (WIDTH as i32 / 2, HEIGHT as i32 / 2);
let t = frame_id as f32 / FPS as f32;
// 淡网格:每 40 px。缩放/平移时这组线是最直观的参照。
for x in (0..WIDTH as i32).step_by(40) {
for y in 0..HEIGHT as i32 {
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
*pixel = brighten(*pixel, 12);
}
}
}
for y in (0..HEIGHT as i32).step_by(40) {
for x in 0..WIDTH as i32 {
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
*pixel = brighten(*pixel, 12);
}
}
}
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160]));
arm(&mut img, cx - 90, cy, cx - 40, cy, image::Rgb([120, 120, 130]));
arm(&mut img, cx + 40, cy, cx + 90, cy, image::Rgb([120, 120, 130]));
arm(&mut img, cx, cy - 90, cx, cy - 40, image::Rgb([120, 120, 130]));
arm(&mut img, cx, cy + 40, cx, cy + 90, image::Rgb([120, 120, 130]));
// 二进制帧号:16 个方块,最左边是低位。
for bit in 0..16i32 {
let on = (frame_id >> bit) & 1 == 1;
let x = 12 + bit * 14;
let color = if on {
image::Rgb([120, 255, 160])
} else {
image::Rgb([52, 52, 60])
};
fill_rect(&mut img, x, 12, 10, 10, color);
}
// Lissajous 圆点:两路同一个 t 下位置不同,用来分辨"这两格是不是同一块板子"。
let phase = if team == 0 { 0.0 } else { 1.7 };
let dot_x = cx + (250.0 * (t * 0.9 + phase).sin()) as i32;
let dot_y = cy + (150.0 * (t * 1.3 + phase).cos()) as i32;
disc(&mut img, dot_x, dot_y, 26, image::Rgb([255, 210, 90]));
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80);
fill_rect(&mut img, bar, HEIGHT as i32 - 24, 60, 8, image::Rgb([90, 200, 255]));
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
fill_rect(
&mut img,
WIDTH as i32 - 40 - corner * 24,
12,
18,
18,
if corner == 0 {
image::Rgb([120, 255, 160])
} else {
image::Rgb([255, 140, 120])
},
);
let mut out = Vec::new();
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78);
encoder
.encode_image(&img)
.expect("a synthetic RGB image always encodes");
out
}
fn brighten(pixel: image::Rgb<u8>, by: u8) -> image::Rgb<u8> {
image::Rgb([
pixel[0].saturating_add(by),
pixel[1].saturating_add(by),
pixel[2].saturating_add(by),
])
}
fn put(img: &mut image::RgbImage, x: i32, y: i32, color: image::Rgb<u8>) {
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
return;
}
img.put_pixel(x as u32, y as u32, color);
}
fn fill_rect(img: &mut image::RgbImage, x: i32, y: i32, w: i32, h: i32, color: image::Rgb<u8>) {
for dy in 0..h {
for dx in 0..w {
put(img, x + dx, y + dy, color);
}
}
}
fn disc(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy <= radius * radius {
put(img, cx + dx, cy + dy, color);
}
}
}
}
/// 细圆环(只画一圈,不填充)。
fn ring(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
let steps = 720;
for step in 0..steps {
let angle = step as f32 * std::f32::consts::TAU / steps as f32;
put(
img,
cx + (radius as f32 * angle.cos()) as i32,
cy + (radius as f32 * angle.sin()) as i32,
color,
);
}
}
/// 一像素宽的线段(只用来画靶心的四臂,够用就行)。
fn arm(img: &mut image::RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, color: image::Rgb<u8>) {
let steps = (x1 - x0).abs().max((y1 - y0).abs()).max(1);
for step in 0..=steps {
let f = step as f32 / steps as f32;
put(
img,
x0 + ((x1 - x0) as f32 * f) as i32,
y0 + ((y1 - y0) as f32 * f) as i32,
color,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_synthetic_frame_is_a_decodable_jpeg_of_the_right_size() {
let jpeg = render(DEMO_CAMERA_A, 0, 42);
assert!(jpeg.starts_with(&[0xFF, 0xD8]), "JPEG SOI");
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
.expect("decodes");
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
}
#[test]
fn the_two_cameras_look_different() {
// 两路画面必须能被一眼分辨,否则"左右两格是不是同一块板子"就看不出来。
let a = render(DEMO_CAMERA_A, 0, 10);
let b = render(DEMO_CAMERA_B, 1, 10);
assert_ne!(a, b);
}
#[test]
fn consecutive_frames_differ() {
// 画面必须真的在动,否则截图看不出链路是活的。
assert_ne!(render(DEMO_CAMERA_A, 0, 1), render(DEMO_CAMERA_A, 0, 2));
}
#[test]
fn a_frame_is_big_enough_to_be_a_realistic_load() {
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
let jpeg = render(DEMO_CAMERA_A, 0, 1);
assert!(jpeg.len() > 3_000, "suspiciously small: {} bytes", jpeg.len());
assert!(
jpeg.len() < 200_000,
"suspiciously large: {} bytes",
jpeg.len()
);
}
}
+342
View File
@@ -0,0 +1,342 @@
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
//!
//! 一个进程、两套页面:
//!
//! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。
//! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
//!
//! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、
//! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。
//!
//! # 命令行
//!
//! ```text
//! --role <team|referee> 身份。默认看配置文件,没有配置文件时是 referee
//! --config <路径> 配置文件位置
//! --demo 演示模式:本地合成画面与血量(标了"演示模式")
//! --list 打印发现的摄像头与装甲板后退出
//! -h, --help 显示这段帮助
//! ```
//!
//! **未知参数只记一条日志,不报错**:macOS 从访达启动 .app 时会塞进来一个
//! `-psn_0_123456`,把它当错误处理的表现是"双击图标什么都没发生"。
mod commands;
mod demo;
mod recording;
mod server;
mod state;
use std::error::Error;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use cam_core::config::{self, Config, Role};
use tauri::{Manager, RunEvent, WindowEvent};
use state::AppState;
const USAGE: &str = "\
用法: cam-desktop [选项]
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
--config <路径> 配置文件位置
--demo 演示模式:画面与血量都是本地合成的
--list 打印发现的摄像头与装甲板后退出
-h, --help 显示这段帮助
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
用户端的绑定改动需要密码;裁判端不需要。
环境变量 CAM_DEMO=1 与 --demo 等价。
";
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(err) = run() {
log::error!("{err}");
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
let path = config::default_config_path().with_extension("startup-error.txt");
let _ = std::fs::write(&path, format!("{err}\n"));
eprintln!("{USAGE}");
std::process::exit(1);
}
}
/// 命令行参数。全部可选——什么都不给时按配置文件走。
#[derive(Debug, Default)]
struct Args {
role: Option<Role>,
config: Option<PathBuf>,
demo: bool,
list: bool,
help: bool,
}
impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Self {
let mut args = Args {
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
..Default::default()
};
let mut it = argv.peekable();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => args.help = true,
"--demo" => args.demo = true,
"--list" => args.list = true,
"--role" => {
args.role = it.next().and_then(|v| Role::parse(&v));
}
"--config" => {
args.config = it.next().map(PathBuf::from);
}
other => {
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
// 但报错的代价(窗口起不来)远大于收益。
log::debug!("ignoring unknown argument {other:?}");
}
}
}
args
}
}
fn run() -> Result<(), Box<dyn Error>> {
let args = Args::parse(std::env::args().skip(1));
if args.help {
print!("{USAGE}");
return Ok(());
}
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
let (mut config, notes) = config::load_with_migration(&config_path)?;
// 命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当
// 另一台用,而不动文件。
if let Some(role) = args.role {
config.role = role;
}
for note in &notes {
log::info!("{note}");
}
log::info!(
"role={} team={} camera={:?} armor={:?} mirror={} demo={} config={}",
config.role.as_str(),
config.team,
config.camera_id,
config.armor_id,
config.mirror,
args.demo,
config_path.display()
);
let (state, video_socket, armor_socket) =
AppState::new(config_path, config, notes, args.demo)?;
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
if let Some(socket) = video_socket {
let receiver = Arc::clone(&state.receiver);
std::thread::Builder::new()
.name("udp-reader".into())
.spawn(move || receiver.run(socket))?;
}
if let Some(socket) = armor_socket {
state.armor.spawn(socket);
}
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
state.install_sinks();
if args.demo {
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
demo::spawn(Arc::clone(&state));
}
// --list:等一个广播周期,把发现到的东西打出来。
//
// 旧的 Slint 客户端在 release 的用户端直接拒绝这个选项(它会列出场上所有板子的 id
// 与 IP)。现在这条限制落在别处:用户端的发现列表只在**解锁后的设置面板**里放开,
// 而这里的输出只进日志,不进界面。
if args.list {
std::thread::sleep(Duration::from_secs(5));
println!("摄像头:");
for camera in state.snapshot().cameras {
println!(
" {} {} {}",
camera.id_text,
camera.addr,
if camera.online { "在线" } else { "离线" }
);
}
println!("装甲板:");
for board in state.snapshot().armor_boards {
println!(
" {} {} 队伍{} {} 血",
board.id_text,
board.addr,
board.team + 1,
board.hp
);
}
state.recordings.stop_all();
_ = state;
return Ok(());
}
let listener_state = Arc::clone(&state);
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(Arc::clone(&state))
.invoke_handler(tauri::generate_handler![
commands::stream_info,
commands::get_snapshot,
commands::get_config,
commands::list_media_files,
commands::config_path,
commands::unlock,
commands::lock,
commands::set_password,
commands::set_discovery_open,
commands::bind_camera,
commands::bind_armor,
commands::set_team,
commands::set_role,
commands::set_mirror,
commands::save_hud,
commands::set_referee_slots,
commands::set_recording_options,
commands::screenshot,
commands::start_recording,
commands::stop_recording,
commands::reveal,
commands::clear_config,
commands::quit_app,
commands::refresh,
])
.setup(move |_app| {
listener_state.spawn_background();
Ok(())
})
// 用户端未解锁时拦住"关窗口"与"退出"。
//
// 这是需求里明确要的(退出/关闭也要密码),所以这里把 close 拦下来并让前端弹
// 解锁框。拦不住的路径仍然存在——`kill -9`、强退、拔电源——所以它同样是"防
// 随手误操作",不是安全边界。
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let state = window.state::<Arc<AppState>>();
if state.config().role.needs_password() && !state.is_unlocked() {
api.prevent_close();
state.push(server::WireMsg::ConfirmQuit);
}
}
})
.build(tauri::generate_context!())?;
app.run(|app, event| {
if let RunEvent::ExitRequested { api, .. } = &event {
let state = app.state::<Arc<AppState>>();
if state.config().role.needs_password() && !state.is_unlocked() {
api.prevent_exit();
state.push(server::WireMsg::ConfirmQuit);
return;
}
}
if let RunEvent::Exit = event {
// 收尾录制:ffmpeg 需要把索引写完,否则留下一个放不了的 mp4。
let state = app.state::<Arc<AppState>>();
let stopped = state.recordings.stop_all();
for dto in stopped {
log::info!(
"退出:0x{:08X} 收了 {} 帧 -> {:?}",
dto.camera,
dto.frames_written,
dto.path
);
}
}
});
Ok(())
}
/// 让 [`Config`] 在日志里可读。
///
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
/// 什么样"是这个二进制自己的事。
#[allow(dead_code)]
fn describe(config: &Config) -> String {
format!(
"role={} team={} camera={:?} armor={:?}",
config.role.as_str(),
config.team,
config.camera_id,
config.armor_id
)
}
#[cfg(test)]
mod tests {
use super::Args;
fn args(list: &[&str]) -> Args {
Args::parse(list.iter().map(|s| s.to_string()))
}
#[test]
fn defaults_are_all_optional() {
let parsed = args(&[]);
assert!(parsed.role.is_none(), "the config file decides");
assert!(!parsed.demo);
assert!(!parsed.help);
}
#[test]
fn role_and_config_are_read() {
let parsed = args(&["--role", "team", "--config", "/tmp/x.toml"]);
assert_eq!(parsed.role, Some(cam_core::Role::Team));
assert_eq!(parsed.config.unwrap().to_string_lossy(), "/tmp/x.toml");
}
#[test]
fn a_mac_app_launch_argument_does_not_break_startup() {
// 从访达双击 .app 时系统会塞进来这个。把它当错误处理的表现是"双击没反应"。
let parsed = args(&["-psn_0_123456"]);
assert!(parsed.role.is_none());
assert!(!parsed.help);
}
#[test]
fn a_bad_role_value_falls_back_to_the_config_rather_than_dying() {
let parsed = args(&["--role", "teem"]);
assert!(parsed.role.is_none(), "typo must not kill the window");
}
#[test]
fn demo_can_be_turned_on_from_the_command_line() {
assert!(args(&["--demo"]).demo);
}
}
+744
View File
@@ -0,0 +1,744 @@
//! 录制为 mp4,以及录制文件的清点。
//!
//! # 为什么用 ffmpeg 子进程而不是 Rust 编码器
//!
//! 两路 640x480@22fps 的 H.264 编码在 Rust 侧要引一个大而重的编码器绑定;而现场机器上
//! 已经有 ffmpeg(`/opt/homebrew/bin/ffmpeg`),它是硬编码/多线程都调好的。代价是多一个
//! **外部依赖**——所以 [Recorder::start] 在 ffmpeg 起不来时返回一个明确的错误,而不是
//! 让界面看起来"在录",其实什么都没写。
//!
//! # 为什么帧率要显式告诉 ffmpeg
//!
//! `image2pipe` 里的 JPEG 序列**没有时间戳**:一串帧在 ffmpeg 看来就是"以固定帧率播放
//! 的图片"。所以必须由我们给出这个数,而它只能来自实测([`crate::state`] 里的帧率窗口)。
//! 猜错的表现是回放快放或慢放——而且看不出来是哪里错了,所以在 DTO 与文档里都写明了。
//!
//! # 为什么经由一个线程和一条有界队列
//!
//! 喂帧发生在 UDP 接收线程里,而**接收线程绝不能被写盘拖慢**:那一天的表现是链路上
//! 本不存在的丢帧。所以中间隔一条长度 64 的队列:写不进去就把帧丢掉并计数,丢帧数会
//! 出现在界面上——少了半秒却不说,会看起来像相机的问题。
use std::collections::HashMap;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result};
use cam_core::snapshot::{id_text, RecordingDto};
use protocol::Frame;
/// 写入线程的队列长度。
///
/// 22fps 下这是约 3 秒的缓冲:够吸收一次写盘的抖动,又不至于在 ffmpeg 卡死时把内存
/// 堆起来。
const QUEUE: usize = 64;
/// Unix 毫秒。
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// 一次录制。
pub struct Recorder {
camera: u32,
path: PathBuf,
started_ms: u64,
input_fps: f32,
/// `None` 表示已经停止喂帧(队列那头的线程会随之收尾)。
tx: Option<SyncSender<Arc<Frame>>>,
frames_written: Arc<AtomicU64>,
frames_dropped: Arc<AtomicU64>,
error: Arc<Mutex<Option<String>>>,
finish: Option<thread::JoinHandle<()>>,
}
impl Recorder {
/// 启动一路录制。
///
/// `fps` 是喂给 ffmpeg 的输入帧率,调用方负责给出实测值。
pub fn start(
camera: u32,
dir: &Path,
ffmpeg: &str,
fps: f32,
crf: u8,
preset: &str,
) -> Result<Self> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the recording directory {}", dir.display()))?;
// 文件名里带秒级时间戳,但**必须**再查一次有没有同名——同一秒里停一次再开一次
// 是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 `-y` 会把上一段直接覆盖掉,
// 没有任何提示。现场丢掉的那段往往正好是要复盘的那一段。
let path = unique_take_path(dir, camera);
let mut child = Command::new(ffmpeg)
.args(["-hide_banner", "-loglevel", "error", "-y"])
// 输入:一串 JPEG。-framerate 必须在 -i 之前,它是 image2 解复用器的选项。
.args(["-f", "image2pipe", "-c:v", "mjpeg"])
.args(["-framerate", &format!("{fps:.4}")])
.args(["-i", "pipe:0"])
.args(["-an"])
.args(["-c:v", "libx264", "-preset", preset])
.args(["-crf", &crf.to_string()])
// yuv420p + faststart:前者让文件在任何播放器里都能放,后者把索引挪到文件头,
// 于是分享出去的 mp4 可以边下边播。
.args(["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.with_context(|| {
format!(
"could not start ffmpeg ({ffmpeg}); 用 [recording] ffmpeg 指定它的路径"
)
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("ffmpeg was started without a stdin pipe"))?;
let (tx, rx) = sync_channel::<Arc<Frame>>(QUEUE);
let frames_written = Arc::new(AtomicU64::new(0));
let frames_dropped = Arc::new(AtomicU64::new(0));
let error = Arc::new(Mutex::new(None));
let finish = {
let frames_written = Arc::clone(&frames_written);
let error = Arc::clone(&error);
let path = path.clone();
thread::Builder::new()
.name(format!("rec-writer-{}", id_text(camera)))
.spawn(move || {
if let Err(err) = pump(&rx, stdin, &mut child, &frames_written) {
log::error!("recording {} stopped: {err:#}", path.display());
*error.lock().expect("rec error mutex poisoned") =
Some(format!("{err:#}"));
}
})?
};
log::info!(
"recording 0x{camera:08X} -> {} ({fps:.1} fps in, crf {crf}, {preset})",
path.display()
);
Ok(Self {
camera,
path,
started_ms: now_ms(),
input_fps: fps,
tx: Some(tx),
frames_written,
frames_dropped,
error,
finish: Some(finish),
})
}
/// 喂一帧。写不进去就计一次丢帧,不阻塞。
pub fn feed(&self, frame: &Arc<Frame>) {
let Some(tx) = self.tx.as_ref() else {
return;
};
match tx.try_send(Arc::clone(frame)) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
self.frames_dropped.fetch_add(1, Ordering::Relaxed);
}
// 写入线程已经结束(ffmpeg 退出):由 `error` 记录原因,这里不再重复报。
Err(TrySendError::Disconnected(_)) => {}
}
}
pub fn frames_written(&self) -> u64 {
self.frames_written.load(Ordering::Relaxed)
}
pub fn frames_dropped(&self) -> u64 {
self.frames_dropped.load(Ordering::Relaxed)
}
pub fn error(&self) -> Option<String> {
self.error.lock().expect("rec error mutex poisoned").clone()
}
/// 停止并等 ffmpeg 把文件收尾(`+faststart` 要重写一遍索引,所以这一步不是瞬时的)。
pub fn stop(mut self) -> RecordingDto {
// 丢掉发送端 ⇒ 写入线程的 recv 返回 Err ⇒ 它关掉 stdin 并 wait 子进程。
drop(self.tx.take());
if let Some(handle) = self.finish.take() {
if handle.join().is_err() {
log::warn!("the recording writer thread panicked");
}
}
let mut dto = self.dto();
dto.active = false;
if let Some(error) = dto.error.as_deref() {
log::warn!("recording 0x{:08X} finished with an error: {error}", self.camera);
} else {
log::info!(
"recording 0x{:08X} stopped: {} frames -> {}",
self.camera,
dto.frames_written,
self.path.display()
);
}
dto
}
pub fn dto(&self) -> RecordingDto {
RecordingDto {
camera: self.camera,
camera_text: id_text(self.camera),
active: self.tx.is_some(),
path: Some(self.path.display().to_string()),
input_fps: self.input_fps,
frames_written: self.frames_written(),
frames_dropped: self.frames_dropped(),
started_ms: Some(self.started_ms),
error: self.error(),
}
}
}
/// 给一段新录制挑一个不会被占用的文件名。
///
/// `rec-00000001-2026-10-14_19-12-05.mp4`,同秒重名时往后排 `-2`、`-3`。
fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
let base = format!(
"rec-{}-{}",
id_text(camera).trim_start_matches("0x"),
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
);
let mut candidate = dir.join(format!("{base}.mp4"));
for n in 2..1000 {
if !candidate.exists() {
break;
}
candidate = dir.join(format!("{base}-{n}.mp4"));
}
candidate
}
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
fn pump(
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
mut stdin: ChildStdin,
child: &mut Child,
frames_written: &AtomicU64,
) -> Result<()> {
while let Ok(frame) = rx.recv() {
if let Err(err) = stdin.write_all(&frame.data) {
// 管道断了通常意味着 ffmpeg 已经退出(参数不对、磁盘满)。把它的 stderr
// 读出来拼进错误里:只说"Broken pipe"对现场毫无帮助。
let reason = child_stderr(child);
return Err(anyhow!(
"writing to ffmpeg failed: {err}{}",
reason.map(|r| format!(";ffmpeg 说:{r}")).unwrap_or_default()
));
}
frames_written.fetch_add(1, Ordering::Relaxed);
}
// 关掉 stdin 是让 ffmpeg 知道"没有更多帧了"的唯一方式。
drop(stdin);
match child.wait() {
Ok(status) if status.success() => Ok(()),
Ok(status) => {
let reason = child_stderr(child);
Err(anyhow!(
"ffmpeg exited with {status}{}",
reason.map(|r| format!(":{r}")).unwrap_or_default()
))
}
Err(err) => Err(anyhow!("could not wait for ffmpeg: {err}")),
}
}
/// 读一下子进程的 stderr(已经读完则返回 `None`)。
fn child_stderr(child: &mut Child) -> Option<String> {
use std::io::Read;
let mut buf = String::new();
let stderr = child.stderr.as_mut()?;
// 管道是非阻塞读的话这里会直接返回;否则 ffmpeg 已经退出,读得到 EOF。
match stderr.read_to_string(&mut buf) {
Ok(0) => None,
Ok(_) => Some(buf.trim().to_string()),
Err(err) if err.kind() == ErrorKind::WouldBlock => None,
Err(_) => None,
}
}
/// 所有正在进行的录制。
#[derive(Default)]
pub struct RecordingSet {
inner: Mutex<HashMap<u32, Recorder>>,
}
impl RecordingSet {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn start(
&self,
camera: u32,
dir: &Path,
ffmpeg: &str,
fps: f32,
crf: u8,
preset: &str,
) -> Result<RecordingDto> {
// **先停掉同一路已有的那次录制,再开新的。**
//
// 顺序不是随便定的:文件名唯一性靠"这个路径已经被占用了"来判断,而 ffmpeg 的输出
// 文件是它启动之后才创建的。先开新的话,新的一段会挑到与上一段相同的名字,然后被
// 它的 `-y` 覆盖掉——而上一段刚要收尾。先停就能保证上一个文件已经落地,`exists()`
// 于是是可信的。
let previous = self.inner.lock().expect("recording mutex poisoned").remove(&camera);
if let Some(previous) = previous {
log::warn!("0x{camera:08X} was already recording; the previous take is being closed");
drop(previous.stop());
}
let recorder = Recorder::start(camera, dir, ffmpeg, fps, crf, preset)?;
let dto = recorder.dto();
self.inner
.lock()
.expect("recording mutex poisoned")
.insert(camera, recorder);
Ok(dto)
}
pub fn stop(&self, camera: u32) -> Option<RecordingDto> {
let recorder = self
.inner
.lock()
.expect("recording mutex poisoned")
.remove(&camera)?;
Some(recorder.stop())
}
pub fn stop_all(&self) -> Vec<RecordingDto> {
let recorders: Vec<Recorder> = self
.inner
.lock()
.expect("recording mutex poisoned")
.drain()
.map(|(_, recorder)| recorder)
.collect();
recorders.into_iter().map(Recorder::stop).collect()
}
/// 把一帧喂给对应那一路的录制器。在 UDP 接收线程里被调用,所以必须快。
pub fn feed(&self, frame: &Arc<Frame>) {
// 先快速判断有没有在录,避免每帧都抢锁。
if self.inner.lock().expect("recording mutex poisoned").is_empty() {
return;
}
let inner = self.inner.lock().expect("recording mutex poisoned");
if let Some(recorder) = inner.get(&frame.sender_id) {
recorder.feed(frame);
}
}
pub fn active(&self) -> Vec<RecordingDto> {
let inner = self.inner.lock().expect("recording mutex poisoned");
let mut list: Vec<RecordingDto> = inner.values().map(Recorder::dto).collect();
list.sort_by_key(|dto| dto.camera);
list
}
}
/// 一段已经录好的文件或截图。
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaFileDto {
pub name: String,
pub path: String,
pub kind: String,
pub bytes: u64,
pub modified_ms: u64,
}
/// 列出目录里的 mp4 与 png,新的在前。
///
/// 只认这两种后缀:目录是给人看的,不该因为里面放了个 `.DS_Store` 就报错或显示出来。
pub fn list_media(dir: &Path) -> Vec<MediaFileDto> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
let kind = match ext.to_ascii_lowercase().as_str() {
"mp4" => "video",
"png" => "image",
_ => continue,
};
let Ok(meta) = entry.metadata() else {
continue;
};
out.push(MediaFileDto {
name: path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
path: path.display().to_string(),
kind: kind.to_string(),
bytes: meta.len(),
modified_ms: meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
});
}
out.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
out
}
#[cfg(test)]
mod tests {
use super::*;
use protocol::fragment_frame;
use std::time::Duration;
/// 合成一张能被 ffmpeg 解出来的 JPEG。
fn jpeg(width: u32, height: u32, seed: u8) -> Vec<u8> {
let mut img = image::RgbImage::new(width, height);
for (x, y, pixel) in img.enumerate_pixels_mut() {
*pixel = image::Rgb([
(x as u8).wrapping_add(seed),
(y as u8).wrapping_add(seed),
seed,
]);
}
let mut out = Vec::new();
let mut encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80);
encoder.encode_image(&img).expect("encodes");
out
}
fn frame(camera: u32, id: u32) -> Arc<Frame> {
Arc::new(Frame {
sender_id: camera,
frame_id: id,
width: 64,
height: 48,
data: jpeg(64, 48, id as u8),
})
}
fn has_ffmpeg() -> bool {
Command::new("ffmpeg")
.arg("-version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cam-desktop-rec-{tag}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates the temp dir");
dir
}
#[test]
fn a_missing_ffmpeg_is_an_error_not_a_silent_no_op() {
let dir = temp_dir("no-ffmpeg");
// 不用 expect_err:那要求 Ok 那一侧实现 Debug,而 Recorder 持有着管道与线程,
// 给它加 Debug 只是为了写测试,不划算。
let err = match Recorder::start(1, &dir, "definitely-not-ffmpeg", 20.0, 20, "veryfast") {
Ok(_) => panic!("must refuse to pretend it is recording"),
Err(err) => err,
};
assert!(
err.to_string().contains("ffmpeg"),
"the message must name the missing tool: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recording_produces_a_playable_mp4() {
// 这一条真的会起 ffmpeg 并写出一个文件,然后用 ffprobe 读回它的帧数与分辨率。
// 只用"文件存在且非空"来断言是不够的:ffmpeg 参数写错时也会留下一个空的
// 容器,而那正是最难在现场发现的一种失败。
if !has_ffmpeg() {
eprintln!("skipping: no ffmpeg on this machine");
return;
}
let dir = temp_dir("mp4");
let set = RecordingSet::new();
set.start(0xAAAA, &dir, "ffmpeg", 20.0, 23, "veryfast")
.expect("starts");
for i in 0..20 {
set.feed(&frame(0xAAAA, i));
}
// 让写入线程把队列排空再停。
for _ in 0..50 {
thread::sleep(Duration::from_millis(10));
if set.active()[0].frames_written >= 20 {
break;
}
}
let dto = set.stop(0xAAAA).expect("was recording");
assert_eq!(dto.frames_written, 20, "all 20 frames reached ffmpeg");
assert_eq!(dto.frames_dropped, 0);
assert!(dto.error.is_none(), "ffmpeg said: {:?}", dto.error);
let path = dto.path.expect("has a path");
let meta = std::fs::metadata(&path).expect("the file exists");
assert!(meta.len() > 0, "an empty container is a failure, not a take");
if let Some((frames, width, height)) = ffprobe_frames(&path) {
assert_eq!(frames, 20, "ffmpeg wrote every frame we fed it");
assert_eq!((width, height), (64, 48));
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn feeding_after_stop_does_not_panic() {
if !has_ffmpeg() {
return;
}
let dir = temp_dir("after-stop");
let set = RecordingSet::new();
set.start(0xBBBB, &dir, "ffmpeg", 20.0, 23, "veryfast")
.unwrap();
let held = frame(0xBBBB, 0);
set.stop(0xBBBB);
// 停止之后接收线程可能还有一帧在手。
set.feed(&held);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn starting_twice_closes_the_first_take_instead_of_leaking_it() {
if !has_ffmpeg() {
return;
}
let dir = temp_dir("twice");
let set = RecordingSet::new();
// 第一段:喂几帧并等它们真的落到 ffmpeg。写盘是异步的,不等到位的话这次录制会是
// 零帧,而零帧时 ffmpeg 连输出文件都不会创建——那测的就不是同一件事了。
set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
for i in 0..8 {
set.feed(&frame(1, i));
}
wait_for_frames(&set, 1, 8);
// 第二段:这里会把第一段收尾,并另取一个文件名。
let second = set.start(1, &dir, "ffmpeg", 20.0, 23, "veryfast").unwrap();
for i in 10..18 {
set.feed(&frame(1, i));
}
wait_for_frames(&set, 1, 8);
assert_eq!(set.active().len(), 1, "只有一路在录");
assert_eq!(set.stop_all().len(), 1);
let files = list_media(&dir);
assert_eq!(files.len(), 2, "两段各自留下一个文件:{files:?}");
assert!(
files.iter().all(|file| file.bytes > 0),
"两段都得有内容,空容器不算一次录制:{files:?}"
);
assert!(second.path.is_some(), "第二段也应该有自己的路径");
let _ = std::fs::remove_dir_all(&dir);
}
/// 等到某一路写进了至少 `want` 帧,或者超时。
fn wait_for_frames(set: &RecordingSet, camera: u32, want: u64) {
for _ in 0..100 {
let written = set
.active()
.iter()
.find(|dto| dto.camera == camera)
.map(|dto| dto.frames_written)
.unwrap_or(0);
if written >= want {
return;
}
thread::sleep(Duration::from_millis(10));
}
panic!("0x{camera:08X} 没有把 {want} 帧写进 ffmpeg");
}
#[test]
fn two_takes_in_the_same_second_do_not_overwrite_each_other() {
// 同一秒里停一次再开一次是现场很自然的操作("这局重录")。重名的后果是 ffmpeg 的
// `-y` 静默覆盖上一段——而丢掉的那段往往正好是要复盘的那段。
let dir = temp_dir("unique");
let first = unique_take_path(&dir, 0xAAAA);
std::fs::write(&first, b"").unwrap();
let second = unique_take_path(&dir, 0xAAAA);
assert_ne!(first, second);
assert!(
second.to_string_lossy().ends_with("-2.mp4"),
"got {}",
second.display()
);
std::fs::write(&second, b"").unwrap();
let third = unique_take_path(&dir, 0xAAAA);
assert!(third.to_string_lossy().ends_with("-3.mp4"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn media_listing_ignores_anything_that_is_not_a_take() {
let dir = temp_dir("listing");
std::fs::write(dir.join("a.mp4"), b"x").unwrap();
std::fs::write(dir.join("b.PNG"), b"x").unwrap();
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
std::fs::write(dir.join(".DS_Store"), b"x").unwrap();
let files = list_media(&dir);
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names.len(), 2, "got {names:?}");
assert!(names.contains(&"a.mp4"));
assert!(names.contains(&"b.PNG"), "后缀大小写不该决定它是不是一张截图");
assert_eq!(files[0].kind, "video");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn listing_a_missing_directory_is_empty_not_an_error() {
assert!(list_media(Path::new("/nonexistent/recordings")).is_empty());
}
#[test]
fn the_file_name_says_which_camera_and_when() {
// 现场翻目录时要能一眼看出是哪一路、哪一局。
assert!(id_text(0x3D4D_8A9A).ends_with("3D4D8A9A"));
}
/// 用 ffprobe 读回帧数与分辨率。ffprobe 不在就返回 `None`(跳过那几条断言)。
fn ffprobe_frames(path: &str) -> Option<(i64, u32, u32)> {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=nb_read_frames,width,height",
"-of",
"default=noprint_wrappers=1",
path,
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut frames = None;
let mut width = None;
let mut height = None;
for line in text.lines() {
if let Some((key, value)) = line.split_once('=') {
match key {
"nb_read_frames" => frames = value.parse().ok(),
"width" => width = value.parse().ok(),
"height" => height = value.parse().ok(),
_ => {}
}
}
}
Some((frames?, width?, height?))
}
#[test]
fn fragmented_frames_are_not_what_the_recorder_wants() {
// 录制器收的是**重组之后**的整帧(sink 传进来的就是),不是分片。这里把这个
// 约定钉下来:如果哪天有人在接收路径上提前喂分片,测试会立刻说明原因。
let frags = fragment_frame(1, 1, &jpeg(64, 48, 1), 64, 48);
assert!(frags.len() == 1, "one small JPEG is one fragment");
let whole = frame(1, 1);
assert!(whole.data.len() > 2, "a real JPEG, not a fragment header");
}
}
+369
View File
@@ -0,0 +1,369 @@
//! 界面与后端之间的视频通道:一个只监听回环地址的 WebSocket。
//!
//! # 为什么不是 Tauri 的 IPC
//!
//! 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要被序列化成
//! JSON(base64 再涨 1/3),再在 JS 侧解回来——CPU 花在没人需要的编解码上,延迟也白
//! 涨一截。WebSocket 的二进制帧把原始 JPEG 字节直接交给前端,浏览器用原生解码器
//! (`createImageBitmap`)解,**后端连 JPEG 都不用解**。
//!
//! # 为什么要有 token
//!
//! 端口是随机分配的,但同机器上任何进程都能连回环地址。这条通道里有实时画面,给它一
//! 个每次启动随机生成的 token(前端从 `stream_info` 命令拿到),至少让"另一个本地程序
//! 顺手连上来看画面"变成一件需要先读到那个 token 的事。
//!
//! 说清楚它的边界:这**不是**防对手的措施——对手要看画面得先在自己的机器上跑这套客户
//! 端,那道闸门在 [`cam_core::receiver::Receiver`] 里。这里的 token 只防止本机上的顺手
//! 连接。
//!
//! # 二进制帧的布局
//!
//! ```text
//! 0 4 8 10 12 16
//! +--------------+--------------+------+------+--------------+---------+
//! | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
//! +--------------+--------------+------+------+--------------+---------+
//! ```
//!
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
use tokio_tungstenite::tungstenite::http::StatusCode;
use tokio_tungstenite::tungstenite::{Bytes, Message};
use cam_core::snapshot::{HitDto, Snapshot};
/// 二进制帧头长度。
pub const FRAME_HEADER_LEN: usize = 16;
/// 广播通道的容量。
///
/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要
/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算,
/// 256 个槽位约 5.6 MB 上限。
pub const CHANNEL_CAPACITY: usize = 256;
/// 向界面推送的消息。
pub enum ServerMsg {
/// 一帧原始 JPEG(已经拼好 16 字节头)。
///
/// 载荷用 `Bytes` 而不是 `Arc<Vec<u8>>`:它本身就是引用计数的缓冲,克隆是加一次
/// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。
Binary(Bytes),
/// JSON 文本。
Text(String),
}
/// JSON 文本消息的信封。
///
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
/// 消息各写一次 `JSON.parse` 的错误处理。
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "t", rename_all = "camelCase")]
pub enum WireMsg {
/// 完整状态快照,5 Hz。
Snapshot(Box<Snapshot>),
/// 一次掉血,立即推。
Hit(HitDto),
/// 后端拦截了一次"关闭/退出"(用户端还没解锁)。前端据此弹出解锁对话框。
ConfirmQuit,
/// 一条说明(迁移、录制报错等),前端显示成一行提示。
///
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
Note { message: String },
}
/// 前端连接这条通道需要的信息。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamInfo {
/// `ws://127.0.0.1:<port>/?token=...`
pub url: String,
pub port: u16,
/// 当前连着的界面数。用于诊断"画面不动"到底是后端没推还是前端没连。
pub clients: usize,
}
/// 把一张帧编码成二进制消息。
pub fn encode_frame(frame: &protocol::Frame) -> Vec<u8> {
let mut out = Vec::with_capacity(FRAME_HEADER_LEN + frame.data.len());
out.extend_from_slice(&frame.sender_id.to_le_bytes());
out.extend_from_slice(&frame.frame_id.to_le_bytes());
out.extend_from_slice(&frame.width.to_le_bytes());
out.extend_from_slice(&frame.height.to_le_bytes());
out.extend_from_slice(&(frame.data.len() as u32).to_le_bytes());
out.extend_from_slice(&frame.data);
out
}
/// 从二进制消息里解出帧头。前后端对这段字节布局的约定由测试盯着。
#[cfg(test)]
pub fn decode_frame_header(message: &[u8]) -> Option<(u32, u32, u16, u16, usize)> {
if message.len() < FRAME_HEADER_LEN {
return None;
}
let sender_id = u32::from_le_bytes(message[0..4].try_into().ok()?);
let frame_id = u32::from_le_bytes(message[4..8].try_into().ok()?);
let width = u16::from_le_bytes(message[8..10].try_into().ok()?);
let height = u16::from_le_bytes(message[10..12].try_into().ok()?);
let len = u32::from_le_bytes(message[12..16].try_into().ok()?) as usize;
if message.len() < FRAME_HEADER_LEN + len {
return None;
}
Some((sender_id, frame_id, width, height, len))
}
/// 绑一个随机端口的回环 listener。
///
/// 随机端口而不是固定端口:同一台机器上同时开用户端和裁判端时不会撞车。
pub async fn bind() -> std::io::Result<(TcpListener, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let port = listener.local_addr()?.port();
Ok((listener, port))
}
/// 接受循环。每个连接一个任务,活到连接断开。
pub async fn serve(
listener: TcpListener,
token: String,
tx: broadcast::Sender<Arc<ServerMsg>>,
clients: Arc<AtomicUsize>,
) {
loop {
let (stream, peer) = match listener.accept().await {
Ok(pair) => pair,
Err(err) => {
log::warn!("stream: accept failed: {err}");
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
};
let tx = tx.clone();
let token = token.clone();
let clients = Arc::clone(&clients);
tauri::async_runtime::spawn(async move {
if let Err(err) = handle_client(stream, peer, &token, tx, clients).await {
log::debug!("stream: client {peer} ended: {err}");
}
});
}
}
async fn handle_client(
stream: TcpStream,
peer: std::net::SocketAddr,
token: &str,
tx: broadcast::Sender<Arc<ServerMsg>>,
clients: Arc<AtomicUsize>,
) -> anyhow::Result<()> {
stream.set_nodelay(true).ok();
let expected = token.to_string();
let callback = move |req: &Request, res: Response| -> Result<Response, ErrorResponse> {
let query = req.uri().query().unwrap_or("");
let presented = query
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(key, _)| *key == "token")
.map(|(_, value)| value.to_string());
match presented {
Some(value) if constant_time_eq(&value, &expected) => Ok(res),
_ => {
log::warn!("stream: refusing {peer}: wrong or missing token");
let mut response = ErrorResponse::new(Some("bad token".into()));
*response.status_mut() = StatusCode::FORBIDDEN;
Err(response)
}
}
};
let ws = tokio_tungstenite::accept_hdr_async(stream, callback).await?;
let (mut sink, mut incoming) = ws.split();
let mut rx = tx.subscribe();
let mut keepalive = tokio::time::interval(Duration::from_secs(20));
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
log::info!("stream: interface connected from {peer} ({count} connected)");
// 连上就先推一条提示,前端能据此在界面上显示"通道已接通"。
let hello = serde_json::to_string(&WireMsg::Note {
message: "video channel ready".into(),
})
.unwrap_or_else(|_| "{}".into());
let _ = sink.send(Message::text(hello)).await;
let result = loop {
tokio::select! {
message = rx.recv() => match message {
Ok(msg) => {
let out = match &*msg {
ServerMsg::Binary(bytes) => Message::Binary(bytes.clone()),
ServerMsg::Text(text) => Message::text(text.clone()),
};
if let Err(err) = sink.send(out).await {
break Err(anyhow::anyhow!("send failed: {err}"));
}
}
// 接收慢,积压被挤掉了。对视频这是正常且期望的:跳过旧帧,继续送新的。
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::debug!("stream: {peer} lagged {skipped} messages; skipping ahead");
}
Err(broadcast::error::RecvError::Closed) => {
break Ok(());
}
},
_ = keepalive.tick() => {
// WKWebView 侧的连接可能在休眠后静默失效。ping 让这种情况变成一次明确的
// 发送失败,而不是一个永远不刷新的窗口。
if let Err(err) = sink.send(Message::Ping(Bytes::new())).await {
break Err(anyhow::anyhow!("keepalive failed: {err}"));
}
}
incoming_msg = incoming.next() => match incoming_msg {
None => break Ok(()),
Some(Err(err)) => break Err(anyhow::anyhow!("receive failed: {err}")),
// 这条通道是单向的:前端发来的东西(pong 除外)一律忽略。
Some(Ok(_)) => {}
},
}
};
let count = clients.fetch_sub(1, Ordering::Relaxed) - 1;
log::info!("stream: interface disconnected ({count} connected)");
result
}
/// 定长比较,避免 token 校验泄露时间信息。
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
/// 生成一个随机 token。
pub fn random_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 24];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(sender: u32, id: u32, len: usize) -> protocol::Frame {
protocol::Frame {
sender_id: sender,
frame_id: id,
width: 640,
height: 480,
data: vec![0xAB; len],
}
}
#[test]
fn the_binary_header_roundtrips() {
let encoded = encode_frame(&frame(0x3D4D_8A9A, 4242, 22_000));
assert_eq!(encoded.len(), FRAME_HEADER_LEN + 22_000);
assert_eq!(
decode_frame_header(&encoded),
Some((0x3D4D_8A9A, 4242, 640, 480, 22_000))
);
}
#[test]
fn a_truncated_message_is_refused_rather_than_read_past_the_end() {
let mut encoded = encode_frame(&frame(1, 1, 100));
encoded.truncate(FRAME_HEADER_LEN + 40);
// 头里声明 100 字节,实际只有 40:宁可返回 None,也不要让前端拿到一段不完整的
// JPEG(那会解码失败,或者更糟——解出一张半截的图)。
assert_eq!(decode_frame_header(&encoded), None);
assert_eq!(decode_frame_header(&[0u8; 4]), None);
}
#[test]
fn the_token_comparison_is_exact() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
assert!(!constant_time_eq("", "a"));
assert!(constant_time_eq("", ""));
}
#[test]
fn tokens_differ_between_runs() {
assert_ne!(random_token(), random_token());
assert_eq!(random_token().len(), 48);
}
#[test]
fn the_wire_envelope_carries_the_message_kind() {
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
board: 1,
team: 0,
index: 2,
from: 100,
to: 97,
damage: 3,
at_ms: 1,
}))
.unwrap();
assert!(json.contains(r#""t":"hit""#), "got {json}");
assert!(json.contains(r#""damage":3"#), "got {json}");
}
#[test]
fn a_unit_variant_is_just_a_tag() {
let json = serde_json::to_string(&WireMsg::ConfirmQuit).unwrap();
assert_eq!(json, r#"{"t":"confirmQuit"}"#);
}
#[test]
fn a_note_is_tagged_too() {
let json = serde_json::to_string(&WireMsg::Note {
message: "hi".into(),
})
.unwrap();
assert_eq!(json, r#"{"t":"note","message":"hi"}"#);
}
}
+635
View File
@@ -0,0 +1,635 @@
//! 进程内的共享状态。
//!
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
//!
//! - **推**:接收线程拿到帧就交给 sink,sink 直接发进广播通道(不阻塞、不排队);
//! 装甲板上报推进一个 [`HealthTracker`],派生出掉血事件就立刻推一条。
//! - **拉**:一个 5 Hz 的循环把当前状态拼成一份 [`Snapshot`] 推出去。界面不需要任何
//! 轮询命令,它只是渲染收到的那份状态。
//!
//! # 密码闸门的执行点在这里,不在界面里
//!
//! 所有会改绑定的命令都过 [`AppState::require_unlocked`]。界面上有一个解锁对话框,但那
//! **只是入口**:即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。这和
//! 旧客户端把防作弊放在**接收端**而不是界面里是同一条原则。
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use cam_core::armor::{ArmorHealthStore, HealthTracker};
use cam_core::config::{Config, Role};
use cam_core::password;
use cam_core::receiver::{Discovery, LatestFrames, Receiver};
use cam_core::ArmorSource;
use cam_core::slots::SlotAllocator;
use cam_core::snapshot::{
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
};
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
use tokio::sync::broadcast;
use crate::recording::{now_ms, RecordingSet};
use crate::server::{self, ServerMsg, WireMsg};
/// 状态快照的推送频率。
///
/// 5 Hz:血量心跳是 200 ms,所以这个频率刚好不漏掉一次掉血;而再快也只是让界面做
/// 无意义的重绘。
pub const SNAPSHOT_INTERVAL: Duration = Duration::from_millis(200);
/// 界面端是否产出技术读数。
///
/// 由**编译模式**决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开
/// 一次就泄露了。裁判端不受影响——它是中立方,双方遥测对判罚有用。
pub const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
pub struct AppState {
pub config_path: PathBuf,
config: Mutex<Config>,
/// 会话内解锁(见模块文档)。
unlocked: AtomicBool,
/// 设置面板是否打开,决定发现列表放不放开。
discovery_open: AtomicBool,
pub receiver: Arc<Receiver>,
pub armor: Arc<ArmorHealthStore>,
discovery: Arc<Discovery>,
latest: Arc<LatestFrames>,
/// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。
fps: Mutex<HashMap<u32, FpsWindow>>,
slots: Mutex<SlotAllocator>,
pub recordings: Arc<RecordingSet>,
tx: broadcast::Sender<Arc<ServerMsg>>,
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
///
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
/// 连不上的界面。
listener: Mutex<Option<tokio::net::TcpListener>>,
pub port: u16,
token: String,
clients: Arc<AtomicUsize>,
pub demo: bool,
notes: Mutex<Vec<String>>,
started: Instant,
}
impl AppState {
/// 建好状态,并绑好两个 UDP 端口。
///
/// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该
/// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有
/// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。
pub fn new(
config_path: PathBuf,
config: Config,
mut notes: Vec<String>,
demo: bool,
) -> Result<(Arc<Self>, Option<std::net::UdpSocket>, Option<std::net::UdpSocket>)> {
let role = config.role;
let bound = if role == Role::Team { config.camera_id } else { None };
// 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。
//
// 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现
// 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比
// 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。
let (receiver, video_socket) = match Receiver::start(
protocol::BROADCAST_ADDR,
role.to_protocol(),
bound,
) {
Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)),
Err(err) => {
log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT);
notes.push(format!(
"绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \
(旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。",
protocol::DEFAULT_PORT
));
(
Arc::new(Receiver::new(
protocol::BROADCAST_ADDR,
role.to_protocol(),
bound,
)),
None,
)
}
};
let discovery = receiver.discovery();
let latest = receiver.latest_frames();
// 装甲板:两端都要收。用户端要显示自己的血条,裁判端要显示双方。
let (armor, armor_socket) = match ArmorHealthStore::start() {
Ok((store, socket)) => (store, Some(socket)),
Err(err) => {
log::warn!(
"armor: could not listen on UDP {}: {err}; health stays a placeholder",
protocol::ARMOR_PORT
);
(Arc::new(ArmorHealthStore::new()), None)
}
};
let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY);
let (listener, port) = tauri::async_runtime::block_on(server::bind())
.context("绑不上本地 WebSocket 端口(回环地址)")?;
let state = Arc::new(Self {
config_path,
config: Mutex::new(config),
unlocked: AtomicBool::new(false),
discovery_open: AtomicBool::new(false),
receiver,
armor,
discovery,
latest,
fps: Mutex::new(HashMap::new()),
slots: Mutex::new(SlotAllocator::new()),
recordings: RecordingSet::new(),
tx,
listener: Mutex::new(Some(listener)),
port,
token: server::random_token(),
clients: Arc::new(AtomicUsize::new(0)),
demo,
notes: Mutex::new(notes),
started: Instant::now(),
});
state.apply_gates();
Ok((state, video_socket, armor_socket))
}
/// 让视频通道开始工作:把帧与装甲板上报接到广播通道上。
///
/// 必须在 `Arc<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
pub fn install_sinks(self: &Arc<Self>) {
let tx = self.tx.clone();
let recordings = Arc::clone(&self.recordings);
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
self.receiver
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
recordings.feed(&frame);
let bytes = server::encode_frame(&frame);
let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into())));
}));
let tx = self.tx.clone();
let tracker = Arc::new(Mutex::new(HealthTracker::new()));
// 装甲板的 sink:把"现在多少血"派生为"刚刚掉了几点",立刻推给界面。
self.armor.set_sink(Arc::new(move |report| {
let hit = tracker
.lock()
.expect("health tracker mutex poisoned")
.observe(&report);
let Some(hit) = hit else {
return;
};
log::info!(
"armor: board 0x{:08X} took {} damage ({} -> {})",
hit.board,
hit.damage,
hit.from,
hit.to
);
let dto = HitDto {
board: hit.board,
team: hit.team,
index: hit.index,
from: hit.from,
to: hit.to,
damage: hit.damage,
at_ms: now_ms(),
};
let _ = tx.send(Arc::new(ServerMsg::Text(
serde_json::to_string(&WireMsg::Hit(dto)).unwrap_or_default(),
)));
}));
}
/// 起 WebSocket 服务与快照循环。
pub fn spawn_background(self: &Arc<Self>) {
let Some(listener) = self
.listener
.lock()
.expect("listener mutex poisoned")
.take()
else {
log::warn!("spawn_background called twice; the stream server is already running");
return;
};
let state = Arc::clone(self);
let token = self.token.clone();
let tx = self.tx.clone();
let clients = Arc::clone(&self.clients);
tauri::async_runtime::spawn(async move {
server::serve(listener, token, tx, clients).await;
});
let state_for_loop = Arc::clone(&state);
tauri::async_runtime::spawn(async move {
let mut ticker = tokio::time::interval(SNAPSHOT_INTERVAL);
loop {
ticker.tick().await;
state_for_loop.tick();
state_for_loop.push_snapshot();
}
});
}
// -----------------------------------------------------------------------
// 配置
// -----------------------------------------------------------------------
pub fn config(&self) -> Config {
self.config.lock().expect("config mutex poisoned").clone()
}
/// 改配置、落盘、重新套用闸门。
pub fn update_config(&self, change: impl FnOnce(&mut Config)) -> Result<Config> {
let updated = {
let mut config = self.config.lock().expect("config mutex poisoned");
change(&mut config);
// Guard 上不能直接调 `normalized()`(它取走 self)。克隆一份再归一化:配置
// 只有几百字节,而这里每次都是人点一下才发生。
config.clone().normalized()
};
updated
.save(&self.config_path)
.context("写入配置文件失败;改动只在本次运行内有效")?;
*self.config.lock().expect("config mutex poisoned") = updated.clone();
self.apply_gates();
Ok(updated)
}
/// 按当前配置重新设置两道闸门。
///
/// 每一次可能改变身份、绑定或发现开合的改动之后都要调用它——把这件事收在一个函数
/// 里,是为了不让"某个命令忘了重新套用闸门"这种漏洞有时间长出来。
pub fn apply_gates(&self) {
let config = self.config();
let role = config.role;
self.receiver.set_role(role.to_protocol());
// 第一道闸门:帧。用户端只看绑定的那一块;裁判端看全部。
let limit = match role {
Role::Team => config.camera_id,
Role::Referee => None,
};
self.receiver.restrict_to(limit);
// 第二道闸门:发现列表。只有用户端在**解锁后**打开设置面板时才放开;裁判端
// 本来就要看到全场。
let open = match role {
Role::Referee => true,
Role::Team => self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed),
};
self.receiver.set_discovery_all(open);
if !open {
self.discovery_open.store(false, Ordering::Relaxed);
}
}
// -----------------------------------------------------------------------
// 密码闸门
// -----------------------------------------------------------------------
pub fn is_unlocked(&self) -> bool {
// 裁判端没有密码(见 Role::needs_password),所以它永远是"已解锁"。
!self.config().role.needs_password() || self.unlocked.load(Ordering::Relaxed)
}
/// 校验一次密码,成功就把会话标记为已解锁。
pub fn unlock(&self, presented: &str) -> Result<bool> {
let stored = self.config().password;
let Some(stored) = stored else {
// 还没设过密码:任何输入都不该被当成"通过"。
return Ok(false);
};
let ok = password::verify_password(presented, &stored)?;
if ok {
self.unlocked.store(true, Ordering::Relaxed);
log::info!("密码校验通过;本次运行内不再询问");
} else {
log::warn!("密码校验失败");
}
Ok(ok)
}
/// 手动重新上锁。
pub fn lock(&self) {
self.unlocked.store(false, Ordering::Relaxed);
self.discovery_open.store(false, Ordering::Relaxed);
self.apply_gates();
}
/// 改绑之前的守门人。
///
/// `presented` 允许调用方在同一次调用里带上密码(界面上就是这样:解锁对话框收起后
/// 用户立刻做改动),但会话已经解锁时不需要它。
pub fn require_unlocked(&self, presented: Option<&str>) -> Result<(), String> {
if self.is_unlocked() {
return Ok(());
}
if let Some(presented) = presented {
match self.unlock(presented) {
Ok(true) => return Ok(()),
Ok(false) => {
return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into())
}
Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")),
}
}
Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into())
}
/// 打开/关闭设置面板带来的发现列表。
pub fn set_discovery_open(&self, open: bool) -> Result<(), String> {
if open {
self.require_unlocked(None)?;
}
self.discovery_open.store(open, Ordering::Relaxed);
self.apply_gates();
Ok(())
}
/// 设置或修改密码。用户端第一次启动必须设。
pub fn set_password(&self, current: Option<&str>, fresh: &str) -> Result<()> {
let existing = self.config().password;
if let Some(existing) = existing {
// 已有密码:必须能证明知道旧的(或者在本次运行里已经解锁过)。
let trusted = self.unlocked.load(Ordering::Relaxed)
|| current
.map(|current| password::verify_password(current, &existing).unwrap_or(false))
.unwrap_or(false);
if !trusted {
anyhow::bail!("修改密码需要先输入当前密码");
}
}
let hash = password::hash_password(fresh)?;
self.update_config(move |config| config.password = Some(hash))?;
self.unlocked.store(true, Ordering::Relaxed);
Ok(())
}
// -----------------------------------------------------------------------
// 推送
// -----------------------------------------------------------------------
pub fn push(&self, msg: WireMsg) {
let text = match serde_json::to_string(&msg) {
Ok(text) => text,
Err(err) => {
log::error!("could not serialise a message for the interface: {err}");
return;
}
};
// 没有连接时发送会失败,这是正常情况(界面还没起来,或者刚刷新)。
let _ = self.tx.send(Arc::new(ServerMsg::Text(text)));
}
pub fn push_snapshot(&self) {
self.push(WireMsg::Snapshot(Box::new(self.snapshot())));
}
pub fn note(&self, message: impl Into<String>) {
let message = message.into();
log::info!("{message}");
self.notes
.lock()
.expect("notes mutex poisoned")
.push(message.clone());
self.push(WireMsg::Note { message });
}
pub fn stream_info(&self) -> server::StreamInfo {
server::StreamInfo {
url: format!("ws://127.0.0.1:{}/?token={}", self.port, self.token),
port: self.port,
clients: self.clients.load(Ordering::Relaxed),
}
}
pub fn latest_frames(&self) -> Arc<LatestFrames> {
Arc::clone(&self.latest)
}
// -----------------------------------------------------------------------
// 每 200ms 推进一次
// -----------------------------------------------------------------------
/// 推进帧率窗口与槽位分配。
pub fn tick(&self) {
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
let online_set: HashSet<u32> = online.iter().copied().collect();
{
let mut fps = self.fps.lock().expect("fps mutex poisoned");
let now = Instant::now();
// 掉线的摄像头把窗口丢掉。留着的话,它回来时累计帧数已经涨了一大截,下一
// 次结算会报出一个几百 fps 的假数字。
fps.retain(|id, _| online_set.contains(id));
for id in &online {
let stats = self.discovery.stats(*id);
fps.entry(*id)
.or_insert_with(|| FpsWindow::seeded(DEFAULT_WINDOW, now, stats.frames_shown))
.observe(stats.frames_shown, now);
}
}
let config = self.config();
let mut slots = self.slots.lock().expect("slots mutex poisoned");
match config.role {
Role::Referee => {
if config.referee.manual_slots {
// 手动:以配置为准。现场有烧错固件的板子时,人需要能压过自动分配。
slots.force(0, config.referee.slot_a);
slots.force(1, config.referee.slot_b);
} else {
slots.assign(&online);
}
}
// 用户端只有一个"槽位":它绑定的那一块。
Role::Team => {
slots.force(0, config.camera_id);
slots.force(1, None);
}
}
}
fn diagnostics(&self) -> bool {
self.config().role == Role::Referee || TEAM_DIAGNOSTICS
}
/// 某块摄像头的帧率(用于录制时告诉 ffmpeg 输入帧率)。
pub fn fps_of(&self, camera: u32) -> Option<f32> {
let fps = self.fps.lock().expect("fps mutex poisoned");
let window = fps.get(&camera)?;
if window.has_reading() {
Some(window.fps())
} else {
None
}
}
// -----------------------------------------------------------------------
// 快照
// -----------------------------------------------------------------------
pub fn snapshot(&self) -> Snapshot {
let config = self.config();
let diagnostics = self.diagnostics();
let cameras: Vec<CameraDto> = self
.discovery
.cameras()
.iter()
.map(CameraDto::from)
.collect();
let stats: Vec<CameraStatsDto> = self
.discovery
.cameras()
.iter()
.map(|camera| {
let stats = self.discovery.stats(camera.id);
let fps = self.fps_of(camera.id).unwrap_or(0.0);
CameraStatsDto::build(diagnostics, camera.id, camera.is_online(), &stats, fps)
})
.collect();
let armor_boards: Vec<ArmorBoardDto> =
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
let health = match config.role {
// 用户端只显示自己那一边。
Role::Team => vec![self.health_for(config.team)],
Role::Referee => vec![self.health_for(0), self.health_for(1)],
};
Snapshot {
role: config.role,
team: config.team,
mirrored: config.mirror,
demo: self.demo,
diagnostics,
unlocked: self.is_unlocked(),
discovery_open: self.discovery_open.load(Ordering::Relaxed),
uptime_ms: self.started.elapsed().as_millis() as u64,
clients: self.clients.load(Ordering::Relaxed),
config: ConfigView::from(&config),
cameras,
armor_boards,
stats,
slots: SlotsDto {
camera_a: slots[0],
camera_b: slots[1],
armor_a: self.assigned_armor(&config, 0),
armor_b: self.assigned_armor(&config, 1),
manual: config.referee.manual_slots,
},
health,
recordings: self.recordings.active(),
notes: self.notes.lock().expect("notes mutex poisoned").clone(),
}
}
/// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
let explicit = if team == 0 {
config.referee.armor_a
} else {
config.referee.armor_b
};
if config.referee.manual_slots {
return explicit;
}
explicit.or_else(|| {
self.armor
.boards()
.iter()
.find(|board| board.team == team && board.is_online())
.map(|board| board.id)
})
}
/// 一边的血量。
///
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
fn health_for(&self, team: u8) -> TeamHealthDto {
let config = self.config();
let low = config.hud.health.low_threshold;
let board = match config.role {
Role::Team => config
.armor_id
.or_else(|| self.assigned_armor(&config, team)),
Role::Referee => self.assigned_armor(&config, team),
};
if let Some(board) = board {
return match self.armor.health_of_board(board) {
Some(hp) => TeamHealthDto::present(team, Some(board), [hp; 4], low),
None => TeamHealthDto::absent(team, Some(board)),
};
}
match self.armor.health(team) {
Some(hps) => TeamHealthDto::present(team, None, hps, low),
None => TeamHealthDto::absent(team, None),
}
}
}
/// 演示模式下合成的板子 id。
///
/// 取成合法十六进制、又不与真实 MAC 派生值相像的形状:截图里一眼能看出这是演示。
pub const DEMO_CAMERA_A: u32 = 0x0DE0_0001;
pub const DEMO_CAMERA_B: u32 = 0x0DE0_0002;
pub const DEMO_ARMOR_A: u32 = 0x0A20_0001;
pub const DEMO_ARMOR_B: u32 = 0x0A20_0002;
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "cam-desktop",
"version": "0.1.0",
"identifier": "org.camdesktop.client",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "cam-desktop",
"width": 1440,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"maximized": true,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": [
"$HOME/Movies/cam-desktop/**",
"$HOME/Videos/cam-desktop/**",
"$APPDATA/**",
"$TEMP/**"
]
}
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"],
"category": "Utility",
"shortDescription": "ESP32 图传与装甲板桌面端",
"longDescription": "用户端:一路画面 + 血条与瞄准器。裁判端:两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}