remote/viewer/src/main.rs
dev-team 7dbb04da17 feat(hud): B1 基线指标——抖动/帧间隔p95/丢包率/解码失败计数 + 测试脚本
- common: Pong 扩展 lost/sent(agent 发送方向 QUIC 计数,经交叉评审项)
- agent: Pong 附带 conn.stats().path 丢包统计
- viewer: Stats 加 GapRing(64)/jitter EMA/dec_fail;HUD 新增 JIT/P95/LOSS/DECFAIL 四行,% 字形
- tools/bench.md: 三场景基线测试脚本
2026-10-23 09:47:09 +08:00

684 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 主控端 Viewer:QUIC 连接被控端 → 解压帧 → minifb 渲染;采集键鼠上行。
//! HUD:右上角半透明白底参数面板(FPS / RTT / 端到端延迟 / 码率),延迟绿-黄-红分级。
mod keymap;
use anyhow::{bail, Result};
use common::{read_control, write_control, ControlMsg, InputEvent, MouseButton};
use minifb::{Key, MouseMode, Window, WindowOptions};
use openh264::formats::YUVSource;
use rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::collections::HashSet;
use std::net::ToSocketAddrs;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
/// MVP:跳过服务端证书校验(自签名)。正式版固定公钥/账号绑定。
#[derive(Debug)]
struct SkipVerify;
impl ServerCertVerifier for SkipVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
fn parse_args() -> Result<(String, u16, String)> {
let mut addr = "127.0.0.1".to_string();
let mut port = 47800u16;
let mut code = "123456".to_string();
let a: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < a.len() {
match a[i].as_str() {
"--addr" => addr = a[i + 1].clone(),
"--port" => port = a[i + 1].parse()?,
"--code" => code = a[i + 1].clone(),
_ => {}
}
i += 2;
}
Ok((addr, port, code))
}
static T0: OnceLock<Instant> = OnceLock::new();
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 数据源,全部原子量,跨任务共享)
struct Stats {
/// 最近 RTT ms(0=未知)
rtt: AtomicU64,
/// 被控时钟 - 主控时钟 偏移估计 ms(EMA 平滑)
offset: AtomicI64,
offset_valid: AtomicBool,
/// 端到端延迟估计 ms(采集→网络→接收,EMA 平滑)
e2e: AtomicU64,
e2e_valid: AtomicBool,
/// 累计收帧数 / 字节数
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<()> {
let (addr, port, code) = parse_args()?;
T0.set(Instant::now()).ok();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(run(addr, port, code))
}
async fn run(addr: String, port: u16, code: String) -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let tls = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipVerify))
.with_no_client_auth();
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls).try_into()?;
// 接收窗口放大:视频是下行大流量方向,默认 512KB 流窗口会死锁吞吐
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));
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
client_config.transport_config(Arc::new(transport));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
let sock_addr = (addr.as_str(), port)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("resolve failed: {addr}"))?;
let conn = endpoint
.connect(sock_addr, "localhost")?
.await
.map_err(|e| anyhow::anyhow!("connect {addr}:{port} failed: {e}"))?;
println!("[viewer] connected to {addr}:{port}");
let (mut send, mut recv) = conn.open_bi().await?;
write_control(&mut send, &ControlMsg::Auth { code }).await?;
match read_control(&mut recv).await? {
ControlMsg::AuthOk { width, height } => {
println!("[viewer] auth ok, remote desktop {width}x{height}");
// 流 2:键鼠输入 + Ping/Pong 独立通道,不与视频流竞争
let (isend, irecv) = conn.open_bi().await?;
session(isend, irecv, recv, width, height)
}
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
m => bail!("unexpected message: {m:?}"),
}
}
fn session(
send: quinn::SendStream,
mut irecv: quinn::RecvStream,
mut recv: quinn::RecvStream,
width: u32,
height: u32,
) -> Result<()> {
let (w, h) = (width as usize, height as usize);
let stats = Arc::new(Stats::default());
// 读任务(流 1):H.264 解码 → RGB → mpsc;同时统计端到端延迟与流量
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
{
let stats = stats.clone();
let mut decoder = openh264::decoder::Decoder::new()
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
tokio::spawn(async move {
loop {
match common::read_video_frame(&mut recv).await {
Ok((hdr, payload)) => {
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 e =
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
- hdr.ts_ms as i64;
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 + e) / 5
} else {
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();
let mut rgb = vec![0u8; dw * dh * 3];
yuv.write_rgb8(&mut rgb);
if frame_tx.send(rgb).is_err() {
break;
}
}
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()); // 空帧占位唤醒渲染循环
}
}
}
Err(e) => {
eprintln!("[viewer] stream closed: {e}");
break;
}
}
}
});
}
// 读任务(流 2):Pong → RTT 与时钟偏移估计
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_control(&mut irecv).await {
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);
let sm = if stats.offset_valid.load(Ordering::Relaxed) {
(old * 3 + est) / 4
} else {
est
};
stats.offset.store(sm, Ordering::Relaxed);
stats.offset_valid.store(true, Ordering::Relaxed);
}
Ok(_) => {}
Err(_) => break,
}
}
});
}
// 写任务(流 2):输入 / Ping
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
tokio::spawn(async move {
let mut send = send;
while let Some(b) = msg_rx.recv().await {
if send.write_all(&b).await.is_err() {
break;
}
}
let _ = send.finish();
});
let push = |tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>, m: ControlMsg| {
let _ = tx.send(common::encode_control(&m));
};
let mut window = Window::new(
"mvp-remote viewer (Esc 退出)",
w,
h,
WindowOptions {
resize: false,
..Default::default()
},
)?;
let mut buffer = vec![0u32; w * h];
let mut prev_keys: HashSet<Key> = HashSet::new();
let mut prev_btns = [false; 3];
let mut last_pos: Option<(u16, u16)> = None;
let mut dirty = false;
let mut last_ping = Instant::now() - Duration::from_secs(1);
// HUD 每秒刷新的统计基线
let mut hud_last = Instant::now();
let mut hud_frames = 0u64;
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) {
break;
}
// 0) 每 500ms 发一次 Ping(RTT/时钟偏移测量)
if last_ping.elapsed() >= Duration::from_millis(500) {
last_ping = Instant::now();
push(&msg_tx, ControlMsg::Ping(now_ms()));
}
// 1) 渲染最新帧(只保留最新,丢弃积压);空帧=解码异常,请求关键帧
loop {
match frame_rx.try_recv() {
Ok(raw) => {
if raw.is_empty() {
push(&msg_tx, ControlMsg::KeyFrameReq);
} else if raw.len() == w * h * 3 {
blit(&mut buffer, &raw);
dirty = true;
}
}
Err(_) => break,
}
}
// 2) 键盘
let keys: HashSet<Key> = window.get_keys().into_iter().collect();
for k in keys.difference(&prev_keys) {
if let Some((sc, ext)) = keymap::map(*k) {
push(
&msg_tx,
ControlMsg::Input(InputEvent::KeyDown {
scancode: sc,
extended: ext,
}),
);
}
}
for k in prev_keys.difference(&keys) {
if let Some((sc, ext)) = keymap::map(*k) {
push(
&msg_tx,
ControlMsg::Input(InputEvent::KeyUp {
scancode: sc,
extended: ext,
}),
);
}
}
prev_keys = keys;
// 3) 鼠标移动(窗口坐标 → 虚拟桌面归一化 0..65535;位置变化才发送)
if let Some((x, y)) = window.get_mouse_pos(MouseMode::Discard) {
let nx = ((x / w as f32) * 65535.0).clamp(0.0, 65535.0) as u16;
let ny = ((y / h as f32) * 65535.0).clamp(0.0, 65535.0) as u16;
if last_pos != Some((nx, ny)) {
last_pos = Some((nx, ny));
push(&msg_tx, ControlMsg::Input(InputEvent::MouseMove { x: nx, y: ny }));
}
}
// 4) 鼠标按键(边沿触发)
for (i, (btn, mfb)) in BTN_MAP.iter().enumerate() {
let down = window.get_mouse_down(*mfb);
if down != prev_btns[i] {
prev_btns[i] = down;
push(
&msg_tx,
ControlMsg::Input(if down {
InputEvent::MouseDown(*btn)
} else {
InputEvent::MouseUp(*btn)
}),
);
}
}
// 5) 滚轮
if let Some((_, dy)) = window.get_scroll_wheel() {
if dy.abs() >= 1.0 {
push(
&msg_tx,
ControlMsg::Input(InputEvent::Wheel { dy: dy as i32 * 120 }),
);
}
}
// 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);
hud_fps = f.saturating_sub(hud_frames);
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,
&[
("FPS", &format!("{hud_fps}"), lat_color(hud_fps >= 25, hud_fps >= 15)),
("RTT", &format!("{rtt}ms"), lat_color(rtt < 50 && rtt > 0, rtt < 100)),
(
"E2E",
&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),
],
);
window.update_with_buffer(&buffer, w, h)?;
dirty = false;
} else {
window.update();
std::thread::sleep(Duration::from_millis(4));
}
}
println!("[viewer] closed");
Ok(())
}
const BTN_MAP: [(MouseButton, minifb::MouseButton); 3] = [
(MouseButton::Left, minifb::MouseButton::Left),
(MouseButton::Right, minifb::MouseButton::Right),
(MouseButton::Middle, minifb::MouseButton::Middle),
];
// ---------- HUD ----------
const C_GREEN: u32 = 0x0A8F3C;
const C_AMBER: u32 = 0xC07000;
const C_RED: u32 = 0xC02222;
const C_GRAY: u32 = 0x3A3A3A;
fn lat_color(good: bool, mid: bool) -> u32 {
if good {
C_GREEN
} else if mid {
C_AMBER
} else {
C_RED
}
}
/// 5x7 点阵字体(列优先,bit0=顶行)。覆盖 HUD 用到的字符。
fn glyph(c: u8) -> [u8; 5] {
match c {
b'0' => [0x3E, 0x51, 0x49, 0x45, 0x3E],
b'1' => [0x00, 0x42, 0x7F, 0x40, 0x00],
b'2' => [0x42, 0x61, 0x51, 0x49, 0x46],
b'3' => [0x21, 0x41, 0x45, 0x4B, 0x31],
b'4' => [0x18, 0x14, 0x12, 0x7F, 0x10],
b'5' => [0x27, 0x45, 0x45, 0x45, 0x39],
b'6' => [0x3C, 0x4A, 0x49, 0x49, 0x30],
b'7' => [0x01, 0x71, 0x09, 0x05, 0x03],
b'8' => [0x36, 0x49, 0x49, 0x49, 0x36],
b'9' => [0x06, 0x49, 0x49, 0x29, 0x1E],
b'A' => [0x7E, 0x11, 0x11, 0x11, 0x7E],
b'B' => [0x7F, 0x49, 0x49, 0x49, 0x36],
b'C' => [0x3E, 0x41, 0x41, 0x41, 0x22],
b'D' => [0x7F, 0x41, 0x41, 0x22, 0x1C],
b'E' => [0x7F, 0x49, 0x49, 0x49, 0x41],
b'F' => [0x7F, 0x09, 0x09, 0x09, 0x01],
b'G' => [0x3E, 0x41, 0x49, 0x49, 0x7A],
b'H' => [0x7F, 0x08, 0x08, 0x08, 0x7F],
b'I' => [0x00, 0x41, 0x7F, 0x41, 0x00],
b'J' => [0x20, 0x40, 0x41, 0x3F, 0x01],
b'K' => [0x7F, 0x08, 0x14, 0x22, 0x41],
b'L' => [0x7F, 0x40, 0x40, 0x40, 0x40],
b'M' => [0x7F, 0x02, 0x0C, 0x02, 0x7F],
b'N' => [0x7F, 0x04, 0x08, 0x10, 0x7F],
b'O' => [0x3E, 0x41, 0x41, 0x41, 0x3E],
b'P' => [0x7F, 0x09, 0x09, 0x09, 0x06],
b'Q' => [0x3E, 0x41, 0x51, 0x21, 0x5E],
b'R' => [0x7F, 0x09, 0x19, 0x29, 0x46],
b'S' => [0x46, 0x49, 0x49, 0x49, 0x31],
b'T' => [0x01, 0x01, 0x7F, 0x01, 0x01],
b'U' => [0x3F, 0x40, 0x40, 0x40, 0x3F],
b'V' => [0x1F, 0x20, 0x40, 0x20, 0x1F],
b'W' => [0x7F, 0x20, 0x18, 0x20, 0x7F],
b'X' => [0x63, 0x14, 0x08, 0x14, 0x63],
b'Y' => [0x03, 0x04, 0x78, 0x04, 0x03],
b'Z' => [0x61, 0x51, 0x49, 0x45, 0x43],
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],
}
}
/// 在 (x,y) 处以 scale 倍率画一行文字
fn draw_text(
buffer: &mut [u32],
w: usize,
mut x: usize,
y: usize,
s: &str,
color: u32,
scale: usize,
) {
let h = buffer.len() / w.max(1);
for ch in s.bytes() {
let g = glyph(ch.to_ascii_uppercase());
for (col, bits) in g.iter().enumerate() {
for row in 0..7 {
if bits & (1 << row) != 0 {
for sy in 0..scale {
for sx in 0..scale {
let px = x + col * scale + sx;
let py = y + row * scale + sy;
if px < w && py < h {
buffer[py * w + px] = color;
}
}
}
}
}
}
x += 6 * scale;
}
}
/// 右上角半透明白底 HUD:lines = [(标签, 值, 颜色)]
fn draw_hud(buffer: &mut [u32], w: usize, lines: &[(&str, &str, u32)]) {
const SCALE: usize = 2;
const PAD: usize = 10;
const MARGIN: usize = 12;
const LINE: usize = 8 * SCALE;
let h = buffer.len() / w.max(1);
let line_w = |l: &(&str, &str, u32)| (l.0.len() + 2 + l.1.len()) * 6 * SCALE;
let box_w = lines.iter().map(line_w).max().unwrap_or(0) + PAD * 2;
let box_h = lines.len() * LINE + PAD * 2;
let x0 = w.saturating_sub(box_w + MARGIN);
let y0 = MARGIN;
// 半透明白底:px' = px*0.35 + 255*0.65
for by in y0..(y0 + box_h).min(h) {
for bx in x0..(x0 + box_w).min(w) {
let p = &mut buffer[by * w + bx];
let v = *p;
let r = (v >> 16) & 0xFF;
let g = (v >> 8) & 0xFF;
let b = v & 0xFF;
let mix = |c: u32| c * 89 / 255 + 166;
*p = (mix(r) << 16) | (mix(g) << 8) | mix(b);
}
}
for (i, (label, value, color)) in lines.iter().enumerate() {
let mut x = x0 + PAD;
let y = y0 + PAD + i * LINE;
draw_text(buffer, w, x, y, label, C_GRAY, SCALE);
x += (label.len() + 2) * 6 * SCALE;
draw_text(buffer, w, x, y, value, *color, SCALE);
}
}
/// RGB888 字节(openh264 解码输出)→ minifb 0RGB u32 缓冲
fn blit(buffer: &mut [u32], raw: &[u8]) {
for (px, chunk) in buffer.iter_mut().zip(raw.chunks_exact(3)) {
*px = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
}
}