把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
229 lines
8.4 KiB
Rust
229 lines
8.4 KiB
Rust
//! 帧率统计。
|
||
//!
|
||
//! # 必须按"帧数除以墙钟",不是"帧间隔的平均"
|
||
//!
|
||
//! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。
|
||
//!
|
||
//! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧
|
||
//! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏——
|
||
//! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场
|
||
//! 会以为还有性能余量,然后花时间去查一个不存在的问题。
|
||
//!
|
||
//! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印),
|
||
//! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
|
||
//! 现在它的正确性由一个直接对着数字写的测试盯着。
|
||
//!
|
||
//! # 时钟是可注入的
|
||
//!
|
||
//! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用
|
||
//! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// 帧率统计窗口。默认 2 秒。
|
||
pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2);
|
||
|
||
/// 某一路的帧率窗口。
|
||
#[derive(Debug, Clone)]
|
||
pub struct FpsWindow {
|
||
window: Duration,
|
||
started: Instant,
|
||
/// 上一次结算时的累计帧数。
|
||
frames: u64,
|
||
/// 已经结算过几个窗口。
|
||
///
|
||
/// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]),
|
||
/// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个
|
||
/// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。
|
||
completed: u32,
|
||
fps: f32,
|
||
}
|
||
|
||
impl Default for FpsWindow {
|
||
fn default() -> Self {
|
||
Self::new(DEFAULT_WINDOW)
|
||
}
|
||
}
|
||
|
||
impl FpsWindow {
|
||
pub fn new(window: Duration) -> Self {
|
||
Self::starting_at(window, Instant::now())
|
||
}
|
||
|
||
/// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。
|
||
///
|
||
/// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的
|
||
/// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会
|
||
/// 报出几百 fps 的假数字。
|
||
pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self {
|
||
Self {
|
||
window,
|
||
started,
|
||
frames: frames_shown,
|
||
completed: 0,
|
||
fps: 0.0,
|
||
}
|
||
}
|
||
|
||
/// 窗口从指定的时刻开始,基线为 0。测试用。
|
||
pub fn starting_at(window: Duration, started: Instant) -> Self {
|
||
Self::seeded(window, started, 0)
|
||
}
|
||
|
||
/// 最近一次结算出的帧率。窗口还没满时是 0。
|
||
pub fn fps(&self) -> f32 {
|
||
self.fps
|
||
}
|
||
|
||
/// 窗口是否已经结算过至少一次。
|
||
///
|
||
/// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子
|
||
/// 卡死了"。
|
||
pub fn has_reading(&self) -> bool {
|
||
self.completed > 0
|
||
}
|
||
|
||
/// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。
|
||
///
|
||
/// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点:
|
||
/// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。
|
||
pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option<f32> {
|
||
let elapsed = now.saturating_duration_since(self.started);
|
||
|
||
if elapsed < self.window {
|
||
return None;
|
||
}
|
||
|
||
self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32();
|
||
self.frames = frames_shown;
|
||
self.started = now;
|
||
self.completed += 1;
|
||
|
||
Some(self.fps)
|
||
}
|
||
|
||
/// 换了一路画面:窗口重开。
|
||
///
|
||
/// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来
|
||
/// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条
|
||
/// 规则现在收在这里。
|
||
pub fn reset(&mut self, now: Instant) {
|
||
self.started = now;
|
||
self.frames = 0;
|
||
self.completed = 0;
|
||
self.fps = 0.0;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rate_is_frames_over_wallclock() {
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
// 2 秒里到了 44 帧 = 22 fps。
|
||
let fps = window
|
||
.observe(44, t0 + Duration::from_secs(2))
|
||
.expect("the window is full");
|
||
|
||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_half_full_window_reports_nothing() {
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None);
|
||
assert_eq!(window.fps(), 0.0);
|
||
assert!(!window.has_reading());
|
||
}
|
||
|
||
#[test]
|
||
fn a_stalled_stream_reports_zero_not_a_stale_number() {
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
window.observe(44, t0 + Duration::from_secs(2));
|
||
// 板子不出帧了:累计值不变,下一个窗口必须报 0。
|
||
let fps = window
|
||
.observe(44, t0 + Duration::from_secs(4))
|
||
.expect("second window");
|
||
|
||
assert_eq!(fps, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn averaging_frame_gaps_is_the_wrong_answer() {
|
||
// 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。
|
||
//
|
||
// 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里
|
||
// 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧
|
||
// (30fps 上下),而墙钟给出的才是真实的 22fps。
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
// 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。
|
||
let fps = window
|
||
.observe(44, t0 + Duration::from_secs(2))
|
||
.expect("window full");
|
||
|
||
assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}");
|
||
assert!(
|
||
fps < 25.0,
|
||
"a frame-gap average would have reported ~30fps here"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_completed_window_is_not_a_reading_yet_until_it_is_full() {
|
||
// 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000);
|
||
|
||
assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None);
|
||
assert!(!window.has_reading());
|
||
|
||
// 2 秒后:这 2 秒里到了 44 帧。
|
||
let fps = window
|
||
.observe(9_044, t0 + Duration::from_secs(2))
|
||
.expect("window full");
|
||
|
||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||
}
|
||
|
||
#[test]
|
||
fn resetting_clears_the_cumulative_count() {
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
window.observe(1000, t0 + Duration::from_secs(2));
|
||
// 换了一路画面:累计值从 0 重新开始。
|
||
window.reset(t0 + Duration::from_secs(2));
|
||
|
||
let fps = window
|
||
.observe(44, t0 + Duration::from_secs(4))
|
||
.expect("window full");
|
||
|
||
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_counter_that_goes_backwards_does_not_underflow() {
|
||
// 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用
|
||
// u64 回绕算出一个天文数字。
|
||
let t0 = Instant::now();
|
||
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
|
||
|
||
window.observe(500, t0 + Duration::from_secs(2));
|
||
let fps = window
|
||
.observe(10, t0 + Duration::from_secs(4))
|
||
.expect("window full");
|
||
|
||
assert_eq!(fps, 0.0);
|
||
}
|
||
}
|