Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c45aff3f | ||
|
|
e7472fa12f | ||
|
|
881341be3a | ||
|
|
7dbb04da17 | ||
|
|
95049b6642 |
No files matched your search
@@ -0,0 +1,117 @@
|
||||
# B 侧设计文档(主控端 + 协议 + 网络)
|
||||
|
||||
> 负责人:成员 B | 主责 crate:`viewer/`、`common/` | 配合:A(agent/)
|
||||
> 按里程碑推进,每个里程碑独立 feature 分支,合入即打 tag。
|
||||
|
||||
---
|
||||
|
||||
## B1 · 联调基线(1-2 天,和 A 合作完成)
|
||||
|
||||
目标:拿到 H.264 版双机实测数据,作为一切优化的对照基线。
|
||||
|
||||
1. **HUD 扩展**:新增 丢包率(估算)/抖动(jitter)/帧间隔 p95 三项读数
|
||||
- 抖动 = 相邻帧 E2E 差值的绝对值 EMA;帧间隔 p95 用环形缓冲取 95 分位;
|
||||
- 码流异常计数(解码失败次数)也上 HUD,验证关键帧自愈是否生效。
|
||||
2. **测试脚本** `tools/bench.md`:固定三个场景各测 60s —— 静止桌面 / 播放视频 / 拖动窗口,记录 HUD 截图。
|
||||
3. **产出**:基线数据表填入本文件末尾。
|
||||
|
||||
## B2 · 码率自适应(3-4 天)
|
||||
|
||||
目标:弱网自动降码率保流畅,好网缓慢升码率提画质。
|
||||
|
||||
### 协议扩展(common/)
|
||||
|
||||
```rust
|
||||
/// B2 新增控制消息(流 2)
|
||||
RateReport {
|
||||
loss_permille: u16, // 接收端估算丢包率 ‰
|
||||
rtt_ms: u32, // 最近 RTT
|
||||
e2e_ms: u32, // 平滑后端到端延迟
|
||||
}
|
||||
```
|
||||
|
||||
viewer 每 2s 汇总一次发送(流 2 复用,无新增握手)。
|
||||
|
||||
### 控制算法(先简后精,AIMD 风格)
|
||||
|
||||
| 网络状态(连续 3 个周期) | 动作 |
|
||||
|---|---|
|
||||
| 丢包 > 8% 或 E2E > 400ms | 码率 ×0.6(下限 1Mbps),同时通知编码器降帧率到 20fps |
|
||||
| 丢包 3-8% | 码率 ×0.9 |
|
||||
| 丢包 < 1% 且 E2E < 150ms 持续 10s | 码率 +500Kbps 步进上探(上限 15Mbps) |
|
||||
|
||||
### agent 侧配合点(给 A 的接口需求)
|
||||
|
||||
- 需要运行时改码率的入口:优先查 openh264 crate 是否暴露 `SetOption(RC_PARAM)`;
|
||||
- 若 crate 不支持 → 方案 B:保留 encoder 实例但按新码率重建(重建成本 ~50ms,降码率不频繁,可接受);
|
||||
- 降级到极限(1Mbps 仍丢包 >15%)→ 主动降分辨率 1080p→720p(A 实现,协议传 `ResChange` 通知,viewer 重配解码器与窗口尺寸)。
|
||||
|
||||
## B3 · P2P 打洞 + 信令 + 中继(1.5-2 周,核心里程碑)
|
||||
|
||||
目标:跨公网可用。三层结构:
|
||||
|
||||
```
|
||||
┌─────────┐ ①注册/交换地址(WebSocket) ┌──────────┐
|
||||
│ viewer │ ───────────────────────────→ │ 信令服务器 │
|
||||
└────┬────┘ └──────────┘
|
||||
│ ②拿到对方候选地址(IP:port×N) ↑
|
||||
│ ③a 直连(打洞) ──────────────────── agent 注册
|
||||
│ ③b 失败 → 走 TURN 中继
|
||||
```
|
||||
|
||||
### 信令服务器(B 主建,A 参与评审)
|
||||
|
||||
- 最小实现:单二进制 Rust + WebSocket(tokio-tungstenite),无数据库;
|
||||
- 消息:`Register{agent_id}` / `Connect{agent_id}` / `Answer{candidates, auth_code_hint}`;
|
||||
- agent 开机注册自身 ID(设备码),viewer 输对方 ID 发起;
|
||||
- 中立服务器,不碰媒体数据,带宽成本 ≈ 0。
|
||||
|
||||
### 候选地址与打洞
|
||||
|
||||
1. agent 侧收集候选:本机局域网地址、路由器公网映射(STUN 探测,自建 coturn 的 stun 或公共 stun);
|
||||
2. 双方经信令交换候选后**同时**向对方发探测包(UDP 打洞,锥形 NAT 成功率高;对称 NAT 基本打不通);
|
||||
3. 打通 → 直连(QUIC 直接到该地址);打不通 → TURN 中继兜底。
|
||||
|
||||
### 中继方案(务实路线)
|
||||
|
||||
QUIC 本身没有标准 TURN 集成,两条路:
|
||||
- **路线一(推荐先做)**:自建中继服务 `relay` —— viewer 把 QUIC 包封装转发给 relay,relay 转给 agent(本质是应用层 TURN,复用信令服务器的部署);
|
||||
- **路线二(二期)**:coturn + 手写 ICE-lite,仅在路线一性能不足时投入。
|
||||
|
||||
### 安全(随 B3 一起做,替代现在的跳过校验)
|
||||
|
||||
- 预共享验证码升级为 **PAKE(SPAKE2)**:验证码不落服务器、不落网络,双方推导会话密钥;
|
||||
- 信令只传地址候选,媒体端到端加密保持 QUIC TLS 不变。
|
||||
|
||||
## B4 · 多屏 + 文件传输(各 2-3 天,穿插做)
|
||||
|
||||
**多屏**:
|
||||
- 协议:`ScreenList{screens: Vec<{id, w, h}>}`(AuthOk 里带)、`SelectScreen{id}`;
|
||||
- agent 枚举 DXGI outputs(A 实现),viewer 加屏幕切换下拉;
|
||||
- 第二期再做"多屏同显"。
|
||||
|
||||
**文件传输**:
|
||||
- 首选走已有 QUIC 连接开独立双向流(不引入 aria2 依赖,部署简单);
|
||||
- 协议:`FileOffer{name, size}` → `FileAccept` → 原始分块流(1MB 块 + 校验);
|
||||
- viewer 做拖拽发送 + 保存对话框。
|
||||
|
||||
---
|
||||
|
||||
## 里程碑与分支规划
|
||||
|
||||
| 里程碑 | 分支 | 预估 | 验收标准 |
|
||||
|---|---|---|---|
|
||||
| B1 联调基线 | feature/hud-baseline | 1-2 天 | 三场景基线数据入库 |
|
||||
| B2 码率自适应 | feature/rate-adapt | 3-4 天 | 模拟弱网(clumsy/丢包工具)下自动降码率,画面不断流 |
|
||||
| B3 打洞+中继 | feature/p2p-relay | 1.5-2 周 | 两台跨公网机器通过 ID 互联;打洞失败自动走中继 |
|
||||
| B4 多屏/文件 | feature/screens-files | 3-5 天 | 多屏切换、拖拽传文件可用 |
|
||||
|
||||
顺序建议:B1 → B2 → B3 → B4(B3 是价值最大项,B4 可穿插)。
|
||||
|
||||
## 基线数据表(B1 完成后填写)
|
||||
|
||||
| 场景 | FPS | RTT | E2E | 码率 | 丢包 | 抖动 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 静止桌面 | | | | | | |
|
||||
| 视频播放 | | | | | | |
|
||||
| 拖动窗口 | | | | | | |
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 编码基准:1080p 噪声帧(最恶劣压缩工况)对比单线程与 4 线程 openh264。
|
||||
//! 运行:cargo run --release -p agent --bin encbench
|
||||
|
||||
use openh264::encoder::{
|
||||
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
||||
};
|
||||
use openh264::formats::{BgraSliceU8, YUVBuffer};
|
||||
use openh264::OpenH264API;
|
||||
use std::time::Instant;
|
||||
|
||||
fn bench(threads: u16, frames: usize) -> f64 {
|
||||
let mut enc = Encoder::with_api_config(
|
||||
OpenH264API::from_source(),
|
||||
EncoderConfig::new()
|
||||
.bitrate(BitRate::from_bps(5_000_000))
|
||||
.max_frame_rate(FrameRate::from_hz(30.0))
|
||||
.rate_control_mode(RateControlMode::Bitrate)
|
||||
.usage_type(UsageType::ScreenContentRealTime)
|
||||
.intra_frame_period(IntraFramePeriod::from_num_frames(300))
|
||||
.num_threads(threads),
|
||||
)
|
||||
.expect("encoder");
|
||||
|
||||
// 预生成 10 张不同噪声帧轮换(保证每帧 P 帧都真实编码,避免运动估计空转)
|
||||
let mut frames_in = Vec::with_capacity(10);
|
||||
for f in 0..10u32 {
|
||||
let mut frame = vec![0u8; 1920 * 1080 * 4];
|
||||
let mut seed = 12345u32.wrapping_add(f * 7919);
|
||||
for px in frame.chunks_exact_mut(4) {
|
||||
seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
|
||||
px.copy_from_slice(&seed.to_le_bytes());
|
||||
}
|
||||
frames_in.push(YUVBuffer::from_bgra8_source(BgraSliceU8::new(&frame, (1920, 1080))));
|
||||
}
|
||||
|
||||
// 预热 5 帧(首帧 IDR、线程池初始化)
|
||||
for (i, y) in frames_in.iter().enumerate().take(5) {
|
||||
let _ = enc.encode(y).unwrap_or_else(|_| panic!("frame {i}"));
|
||||
}
|
||||
let t = Instant::now();
|
||||
for (i, y) in frames_in.iter().cycle().take(frames).enumerate() {
|
||||
let _ = enc.encode(y).unwrap_or_else(|_| panic!("frame {i}"));
|
||||
}
|
||||
t.elapsed().as_millis() as f64 / frames as f64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("1080p 噪声帧编码基准(含 BGRA→YUV 转换)");
|
||||
let t = Instant::now();
|
||||
let _ = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
||||
&vec![7u8; 1920 * 1080 * 4],
|
||||
(1920, 1080),
|
||||
));
|
||||
println!("BGRA→YUV 转换: {}ms/帧", t.elapsed().as_millis());
|
||||
let s = bench(1, 20);
|
||||
println!("单线程: {s:.1}ms/帧 → {}fps", (1000.0 / s) as u32);
|
||||
let m = bench(4, 20);
|
||||
println!("4 线程: {m:.1}ms/帧 → {}fps", (1000.0 / m) as u32);
|
||||
}
|
||||
+92
-37
@@ -143,6 +143,23 @@ impl Duplication {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// 在映射窗口内执行回调(避免 8MB 中间拷贝)。回调参数 = (像素数据, 行距)。
|
||||
/// 映射内存仅在回调返回前有效。
|
||||
pub fn with_mapped<R>(&mut self, f: impl FnOnce(&[u8], usize) -> R) -> Result<R> {
|
||||
unsafe {
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
self.context
|
||||
.Map(&self.staging_res, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
|
||||
.map_err(|e| anyhow!("Map staging failed: {e}"))?;
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let pitch = mapped.RowPitch as usize;
|
||||
let src = std::slice::from_raw_parts(mapped.pData as *const u8, pitch * h);
|
||||
let r = f(src, pitch);
|
||||
self.context.Unmap(&self.staging_res, 0);
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -169,15 +186,17 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>) {
|
||||
dup.width, dup.height
|
||||
);
|
||||
|
||||
// H.264 编码器:屏幕内容实时档 + 码率控制(对标 UU:3.2Mbps@60fps)
|
||||
// H.264 编码器:屏幕内容实时档 + 码率控制 + 4 线程(单线程 1080p 仅 ~20fps,
|
||||
// 多线程把编码压进 33ms 预算实现稳定 30fps)
|
||||
let mut encoder = match Encoder::with_api_config(
|
||||
OpenH264API::from_source(),
|
||||
EncoderConfig::new()
|
||||
.bitrate(BitRate::from_bps(5_000_000))
|
||||
.max_frame_rate(FrameRate::from_hz(30.0))
|
||||
.max_frame_rate(FrameRate::from_hz(60.0))
|
||||
.rate_control_mode(RateControlMode::Bitrate)
|
||||
.usage_type(UsageType::ScreenContentRealTime)
|
||||
.intra_frame_period(IntraFramePeriod::from_num_frames(300)),
|
||||
.intra_frame_period(IntraFramePeriod::from_num_frames(300))
|
||||
.num_threads(4),
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
@@ -188,50 +207,86 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>) {
|
||||
|
||||
let mut last_frame: Option<Vec<u8>> = None;
|
||||
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
||||
let min_frame = Duration::from_millis(33); // 30fps 上限
|
||||
// 编码实测 ~5ms/帧(1080p 屏幕内容实时档),60fps 只需 16.7ms 预算,
|
||||
// 够用 → 帧率上限提到 60(对标 UU 60fps;带宽由 RC 压在 5Mbps 内)
|
||||
let min_frame = Duration::from_millis(16); // 60fps 上限
|
||||
let mut scratch: Vec<u8> = Vec::new(); // 行距不连续时的复用缓冲
|
||||
// 编码耗时统计(每 120 帧输出一次均值,ms)
|
||||
let mut stat_frames = 0u64;
|
||||
let mut stat_convert_ms = 0u64;
|
||||
let mut stat_encode_ms = 0u64;
|
||||
loop {
|
||||
let iter_start = Instant::now();
|
||||
match dup.acquire(80) {
|
||||
Ok(true) => match dup.copy_frame() {
|
||||
Ok(raw) => {
|
||||
// 主控端请求关键帧(解码异常恢复)
|
||||
if keyreq.swap(false, Ordering::Relaxed) {
|
||||
encoder.force_intra_frame();
|
||||
Ok(true) => {
|
||||
// 主控端请求关键帧(解码异常恢复)
|
||||
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
||||
if force_key {
|
||||
encoder.force_intra_frame();
|
||||
}
|
||||
// 映射窗口内直接做 BGRA→YUV(行距连续时零拷贝,否则复用 scratch)
|
||||
let conv_start = Instant::now();
|
||||
let dims = (dup.width as usize, dup.height as usize);
|
||||
let conv = dup.with_mapped(|src, pitch| {
|
||||
if pitch == dims.0 * 4 {
|
||||
YUVBuffer::from_bgra8_source(BgraSliceU8::new(src, dims))
|
||||
} else {
|
||||
scratch.resize(dims.0 * dims.1 * 4, 0);
|
||||
for (row, dst) in scratch.chunks_exact_mut(dims.0 * 4).enumerate() {
|
||||
dst.copy_from_slice(
|
||||
&src[row * pitch..row * pitch + dims.0 * 4],
|
||||
);
|
||||
}
|
||||
YUVBuffer::from_bgra8_source(BgraSliceU8::new(&scratch, dims))
|
||||
}
|
||||
let header = VideoFrameHeader {
|
||||
width: dup.width,
|
||||
height: dup.height,
|
||||
ts_ms: start.elapsed().as_millis() as u64,
|
||||
};
|
||||
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
||||
&raw,
|
||||
(dup.width as usize, dup.height as usize),
|
||||
));
|
||||
match encoder.encode(&yuv) {
|
||||
Ok(ebs) => {
|
||||
let payload = ebs.to_vec();
|
||||
let frame = encode_video_frame(&header, &payload);
|
||||
if tx.send(Some(frame.clone())).is_err() {
|
||||
return; // 会话结束
|
||||
});
|
||||
match conv {
|
||||
Ok(yuv) => {
|
||||
stat_convert_ms += conv_start.elapsed().as_millis() as u64;
|
||||
let enc_start = Instant::now();
|
||||
let r = encoder.encode(&yuv);
|
||||
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
||||
match r {
|
||||
Ok(ebs) => {
|
||||
let payload = ebs.to_vec();
|
||||
let header = VideoFrameHeader {
|
||||
width: dup.width,
|
||||
height: dup.height,
|
||||
ts_ms: start.elapsed().as_millis() as u64,
|
||||
};
|
||||
let frame = encode_video_frame(&header, &payload);
|
||||
if tx.send(Some(frame.clone())).is_err() {
|
||||
return; // 会话结束
|
||||
}
|
||||
last_frame = Some(frame);
|
||||
last_sent = Instant::now();
|
||||
stat_frames += 1;
|
||||
if stat_frames % 120 == 0 {
|
||||
eprintln!(
|
||||
"[capture] avg convert {}ms, encode {}ms/frame (last 120)",
|
||||
stat_convert_ms / 120,
|
||||
stat_encode_ms / 120
|
||||
);
|
||||
stat_convert_ms = 0;
|
||||
stat_encode_ms = 0;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[capture] encode failed: {e}");
|
||||
}
|
||||
last_frame = Some(frame);
|
||||
last_sent = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[capture] encode failed: {e}");
|
||||
}
|
||||
}
|
||||
// 帧间隔节流到 ~30fps
|
||||
let el = iter_start.elapsed();
|
||||
if el < min_frame {
|
||||
std::thread::sleep(min_frame - el);
|
||||
Err(e) => {
|
||||
eprintln!("[capture] frame convert failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[capture] frame copy failed: {e}");
|
||||
break; // 重建 duplication
|
||||
// 帧间隔节流到 ~30fps
|
||||
let el = iter_start.elapsed();
|
||||
if el < min_frame {
|
||||
std::thread::sleep(min_frame - el);
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(false) => {
|
||||
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
|
||||
if last_sent.elapsed() > Duration::from_millis(500) {
|
||||
|
||||
+13
-5
@@ -43,12 +43,12 @@ async fn main() -> Result<()> {
|
||||
|
||||
// 传输参数调优:
|
||||
// - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口)
|
||||
// - 发送缓冲上限 2MB:QUIC 内部排队有界(约 1 帧多),配合上游 latest-wins 丢帧
|
||||
// 把端到端排队延迟压在 ~0.4s 内
|
||||
// - 发送缓冲上限 512KB:QUIC 内部排队 ≈ 3 帧 @5Mbps,E2E 延迟天花板 ~150ms;
|
||||
// 配合上游 latest-wins 丢帧,超量部分在进 QUIC 前就被丢弃
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||
transport.send_window(2 * 1024 * 1024);
|
||||
transport.send_window(512 * 1024);
|
||||
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
||||
server_config.transport_config(Arc::new(transport));
|
||||
|
||||
@@ -93,8 +93,10 @@ async fn main() -> Result<()> {
|
||||
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// 读循环(流 2):输入事件 + Ping
|
||||
// 读循环(流 2):输入事件 + Ping;Pong 附带视频下行方向的 QUIC 丢包统计
|
||||
// (conn.stats().path 统计的是本端发送方向,agent 的发送方向即视频下行)
|
||||
let keyreq_reader = keyreq.clone();
|
||||
let conn_stats = conn.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
loop {
|
||||
match read_control(&mut irecv).await {
|
||||
@@ -104,7 +106,13 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
Ok(ControlMsg::Ping(t)) => {
|
||||
let agent_ts = t0.elapsed().as_millis() as u64;
|
||||
let _ = ctl_tx.send(ControlMsg::Pong { t, agent_ts });
|
||||
let p = conn_stats.stats().path;
|
||||
let _ = ctl_tx.send(ControlMsg::Pong {
|
||||
t,
|
||||
agent_ts,
|
||||
lost: p.lost_packets,
|
||||
sent: p.sent_packets,
|
||||
});
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ pub enum ControlMsg {
|
||||
Ping(u64),
|
||||
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
|
||||
/// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟
|
||||
Pong { t: u64, agent_ts: u64 },
|
||||
/// lost/sent = agent 发送方向(视频下行)的 QUIC 丢包/发包累计计数(conn.stats().path)
|
||||
Pong { t: u64, agent_ts: u64, lost: u64, sent: u64 },
|
||||
Input(InputEvent),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 双机测试基线脚本(B1)
|
||||
|
||||
每个场景测试 60 秒,记录 HUD 读数填入《B侧设计文档.md》末尾的基线数据表。
|
||||
|
||||
## 准备
|
||||
|
||||
- 两端都用最新 release:`agent.exe` + `viewer.exe`(同目录带 3 个 mingw DLL)
|
||||
- 被控机:`agent.exe --port 47800 --code 123456`
|
||||
- 主控机:`viewer.exe --addr <被控机IP> --port 47800 --code 123456`
|
||||
- 测试前确认:被控机亮屏有桌面;同一网络(记下是有线还是 WiFi 频段)
|
||||
|
||||
## 场景
|
||||
|
||||
| # | 场景 | 被控机操作 | 观察点 |
|
||||
|---|---|---|---|
|
||||
| 1 | 静止桌面 | 打开一个空白文档,不动 60s | FPS 应低(静止检测),E2E/JIT 应绿 |
|
||||
| 2 | 视频播放 | 全屏播放任意 1080p 视频 | 码率峰值、FPS、丢包(LOSS 行) |
|
||||
| 3 | 拖动窗口 | 持续快速拖动一个大窗口 60s | 最恶劣工况:E2E/P95/JIT 是否变红、DECFAIL 是否增长 |
|
||||
|
||||
## 记录方法
|
||||
|
||||
每个场景进行中截一张 HUD 图(Win+Shift+S 框选右上角),或肉眼逐行抄录:
|
||||
|
||||
```
|
||||
FPS= RTT= E2E= JIT= P95= LOSS= DECFAIL= RATE=
|
||||
```
|
||||
|
||||
## 附加记录
|
||||
|
||||
- 网络环境:有线 / WiFi 5GHz / WiFi 2.4GHz,信号强度
|
||||
- 被控机 CPU 占用(任务管理器,agent.exe 一行)
|
||||
- 编码器类型:当前 openh264 软编(A 侧换 NVENC 后需重测一轮对比)
|
||||
|
||||
## 注意
|
||||
|
||||
- LOSS 显示 `--` 表示第一个 Pong 还没回来或 Δsent=0(静止场景发送少,属正常)
|
||||
- DECFAIL 在连接建立初期可能有 1-2 次(等第一个关键帧),后续不应增长;持续增长 = 关键帧自愈有问题,报给 A
|
||||
+135
-6
@@ -81,8 +81,15 @@ fn now_ms() -> u64 {
|
||||
T0.get().unwrap().elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// 帧到达间隔环形缓冲(取 p95 用)
|
||||
struct GapRing {
|
||||
last: Option<Instant>,
|
||||
ring: [u32; 64],
|
||||
idx: usize,
|
||||
filled: usize,
|
||||
}
|
||||
|
||||
/// 会话统计(HUD 数据源,全部原子量,跨任务共享)
|
||||
#[derive(Default)]
|
||||
struct Stats {
|
||||
/// 最近 RTT ms(0=未知)
|
||||
rtt: AtomicU64,
|
||||
@@ -95,6 +102,80 @@ struct Stats {
|
||||
/// 累计收帧数 / 字节数
|
||||
frames: AtomicU64,
|
||||
bytes: AtomicU64,
|
||||
/// 抖动:相邻帧 |ΔE2E| 的 EMA(ms×16 定点存储避免浮点原子)
|
||||
jitter_x16: AtomicU64,
|
||||
prev_e2e: AtomicI64,
|
||||
/// agent 发送侧(视频下行)QUIC 丢包/发包累计(最近一次 Pong)
|
||||
agent_lost: AtomicU64,
|
||||
agent_sent: AtomicU64,
|
||||
/// H.264 解码失败计数(关键帧自愈验证指标)
|
||||
dec_fail: AtomicU64,
|
||||
/// 帧到达间隔环形缓冲
|
||||
gaps: std::sync::Mutex<GapRing>,
|
||||
}
|
||||
|
||||
impl Default for Stats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rtt: AtomicU64::new(0),
|
||||
offset: AtomicI64::new(0),
|
||||
offset_valid: AtomicBool::new(false),
|
||||
e2e: AtomicU64::new(0),
|
||||
e2e_valid: AtomicBool::new(false),
|
||||
frames: AtomicU64::new(0),
|
||||
bytes: AtomicU64::new(0),
|
||||
jitter_x16: AtomicU64::new(0),
|
||||
prev_e2e: AtomicI64::new(-1),
|
||||
agent_lost: AtomicU64::new(0),
|
||||
agent_sent: AtomicU64::new(0),
|
||||
dec_fail: AtomicU64::new(0),
|
||||
gaps: std::sync::Mutex::new(GapRing {
|
||||
last: None,
|
||||
ring: [0; 64],
|
||||
idx: 0,
|
||||
filled: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
/// 记录一次帧到达:更新间隔环形缓冲与抖动 EMA
|
||||
fn on_frame_arrival(&self, est: Option<i64>) {
|
||||
let now = Instant::now();
|
||||
if let Ok(mut g) = self.gaps.lock() {
|
||||
if let Some(t) = g.last {
|
||||
let gap = (now - t).as_millis() as u32;
|
||||
let i = g.idx;
|
||||
g.ring[i] = gap;
|
||||
g.idx = (i + 1) % g.ring.len();
|
||||
g.filled = (g.filled + 1).min(g.ring.len());
|
||||
}
|
||||
g.last = Some(now);
|
||||
}
|
||||
// 抖动 = |本帧E2E - 上帧E2E| 的 EMA(RFC3550 风格,÷16 衰减)
|
||||
if let Some(e) = est {
|
||||
let prev = self.prev_e2e.load(Ordering::Relaxed);
|
||||
self.prev_e2e.store(e, Ordering::Relaxed);
|
||||
if prev >= 0 {
|
||||
let d = (e - prev).unsigned_abs() as u64;
|
||||
let old = self.jitter_x16.load(Ordering::Relaxed);
|
||||
let sm = if old == 0 { d * 16 } else { old + d * 16 / 16 - old / 16 };
|
||||
self.jitter_x16.store(sm, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 帧间隔 p95(ms),样本不足返回 None
|
||||
fn gap_p95(&self) -> Option<u32> {
|
||||
let g = self.gaps.lock().ok()?;
|
||||
if g.filled < 8 {
|
||||
return None;
|
||||
}
|
||||
let mut v: Vec<u32> = g.ring[..g.filled].to_vec();
|
||||
v.sort_unstable();
|
||||
v.get(v.len() * 95 / 100).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -173,21 +254,24 @@ fn session(
|
||||
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
|
||||
stats.frames.fetch_add(1, Ordering::Relaxed);
|
||||
// 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑)
|
||||
let mut est = None;
|
||||
if stats.offset_valid.load(Ordering::Relaxed) {
|
||||
let est =
|
||||
let e =
|
||||
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
|
||||
- hdr.ts_ms as i64;
|
||||
if (0..5000).contains(&est) {
|
||||
if (0..5000).contains(&e) {
|
||||
let old = stats.e2e.load(Ordering::Relaxed) as i64;
|
||||
let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
|
||||
(old * 4 + est) / 5
|
||||
(old * 4 + e) / 5
|
||||
} else {
|
||||
est
|
||||
e
|
||||
};
|
||||
stats.e2e.store(sm as u64, Ordering::Relaxed);
|
||||
stats.e2e_valid.store(true, Ordering::Relaxed);
|
||||
est = Some(sm);
|
||||
}
|
||||
}
|
||||
stats.on_frame_arrival(est);
|
||||
match decoder.decode(&payload) {
|
||||
Ok(Some(yuv)) => {
|
||||
let (dw, dh) = yuv.dimensions();
|
||||
@@ -200,6 +284,7 @@ fn session(
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
// 解码异常:请求被控端强制关键帧
|
||||
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
||||
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
||||
let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环
|
||||
}
|
||||
@@ -220,9 +305,11 @@ fn session(
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match read_control(&mut irecv).await {
|
||||
Ok(ControlMsg::Pong { t, agent_ts }) => {
|
||||
Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => {
|
||||
let r = now_ms();
|
||||
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed);
|
||||
stats.agent_lost.store(lost, Ordering::Relaxed);
|
||||
stats.agent_sent.store(sent, Ordering::Relaxed);
|
||||
// agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA
|
||||
let est = agent_ts as i64 - ((t + r) / 2) as i64;
|
||||
let old = stats.offset.load(Ordering::Relaxed);
|
||||
@@ -278,6 +365,8 @@ fn session(
|
||||
let mut hud_bytes = 0u64;
|
||||
let mut hud_fps = 0u64;
|
||||
let mut hud_mbps = 0f64;
|
||||
let mut hud_lost = 0u64;
|
||||
let mut hud_sent = 0u64;
|
||||
|
||||
loop {
|
||||
if !window.is_open() || window.is_key_down(Key::Escape) {
|
||||
@@ -368,6 +457,7 @@ fn session(
|
||||
}
|
||||
|
||||
// 6) HUD:每秒刷新一次指标,画在右上角
|
||||
let (hud_loss, hud_jit, hud_p95, hud_dec);
|
||||
if hud_last.elapsed() >= Duration::from_secs(1) {
|
||||
let f = stats.frames.load(Ordering::Relaxed);
|
||||
let b = stats.bytes.load(Ordering::Relaxed);
|
||||
@@ -375,12 +465,46 @@ fn session(
|
||||
hud_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6;
|
||||
hud_frames = f;
|
||||
hud_bytes = b;
|
||||
// 丢包率 = Δlost / Δsent(agent 发送方向,随 Pong 同步)
|
||||
let l = stats.agent_lost.load(Ordering::Relaxed);
|
||||
let s = stats.agent_sent.load(Ordering::Relaxed);
|
||||
hud_loss = if s > hud_sent {
|
||||
((l - hud_lost) as f64 / (s - hud_sent) as f64 * 100.0).min(99.9)
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
hud_lost = l;
|
||||
hud_sent = s;
|
||||
hud_jit = stats.jitter_x16.load(Ordering::Relaxed) / 16;
|
||||
hud_p95 = stats.gap_p95();
|
||||
hud_dec = stats.dec_fail.load(Ordering::Relaxed);
|
||||
hud_last = Instant::now();
|
||||
} else {
|
||||
hud_loss = f64::NAN;
|
||||
hud_jit = 0;
|
||||
hud_p95 = None;
|
||||
hud_dec = 0;
|
||||
}
|
||||
if dirty {
|
||||
let rtt = stats.rtt.load(Ordering::Relaxed);
|
||||
let e2e = stats.e2e.load(Ordering::Relaxed);
|
||||
let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed);
|
||||
let loss_str = if hud_loss.is_nan() {
|
||||
"--".to_string()
|
||||
} else if hud_loss < 0.0 {
|
||||
"--".to_string()
|
||||
} else {
|
||||
format!("{hud_loss:.1}%")
|
||||
};
|
||||
let loss_c = if hud_loss.is_nan() || hud_loss < 0.0 {
|
||||
C_GRAY
|
||||
} else {
|
||||
lat_color(hud_loss < 1.0, hud_loss < 5.0)
|
||||
};
|
||||
let jit_c = lat_color(hud_jit < 10, hud_jit < 30);
|
||||
let p95_c = hud_p95
|
||||
.map(|p| lat_color(p < 60, p < 150))
|
||||
.unwrap_or(C_GRAY);
|
||||
draw_hud(
|
||||
&mut buffer,
|
||||
w,
|
||||
@@ -392,6 +516,10 @@ fn session(
|
||||
&if e2e_ok { format!("{e2e}ms") } else { "--".into() },
|
||||
lat_color(e2e < 100, e2e < 200),
|
||||
),
|
||||
("JIT", &format!("{hud_jit}ms"), jit_c),
|
||||
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
|
||||
("LOSS", &loss_str, loss_c),
|
||||
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
|
||||
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
||||
("RES", &format!("{w}x{h}"), C_GRAY),
|
||||
],
|
||||
@@ -473,6 +601,7 @@ fn glyph(c: u8) -> [u8; 5] {
|
||||
b':' => [0x00, 0x36, 0x36, 0x00, 0x00],
|
||||
b'.' => [0x00, 0x60, 0x60, 0x00, 0x00],
|
||||
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
|
||||
b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11],
|
||||
b' ' => [0x00; 5],
|
||||
_ => [0x00; 5],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user