remote/viewer/src/main.rs

711 lines
27 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 → 单槽 latest-wins;同时统计端到端延迟与流量
// 帧格式 (src_w, src_h, rgb);(0,0,_) = 解码失败占位,触发 KeyFrameReq
// 单槽而非无界队列:渲染跟不上 60fps 到达率时旧帧直接被覆盖,绝不积压
//(无界 mpsc 在 NVENC 60fps 下会无限积压 → 延迟线性增长 + 渲染 drain 时
// 长时间阻塞窗口消息泵 → 窗口假死、HUD 停刷新)
let latest: Arc<std::sync::Mutex<Option<(u32, u32, Vec<u8>)>>> =
Arc::new(std::sync::Mutex::new(None));
{
let stats = stats.clone();
let latest = latest.clone();
let mut decoder = openh264::decoder::Decoder::new()
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
tokio::spawn(async move {
let mut size_logged = false;
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 (dw, dh) = (dw as u32, dh as u32);
if !size_logged {
size_logged = true;
eprintln!(
"[viewer] decoded frame {dw}x{dh} (window {}x{})",
hdr.width, hdr.height
);
}
let mut rgb = vec![0u8; dw as usize * dh as usize * 3];
yuv.write_rgb8(&mut rgb);
// latest-wins:直接覆盖旧帧,无积压
if let Ok(mut slot) = latest.lock() {
*slot = Some((dw, dh, rgb));
}
}
Ok(None) => {}
Err(e) => {
// 解码异常:请求被控端强制关键帧
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
eprintln!("[viewer] decode failed: {e}, request keyframe");
if let Ok(mut slot) = latest.lock() {
*slot = Some((0, 0, 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));
};
// 入会立即请求 IDR:避免从 GOP 中间接入收到裸 P 帧解码失败导致启动黑屏
push(&msg_tx, ControlMsg::KeyFrameReq);
let mut window = Window::new(
"mvp-remote v0.7-LowLatency (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) 渲染最新帧(单槽 latest-wins:每迭代只取最新一帧,天然防积压)
if let Ok(mut slot) = latest.lock() {
if let Some((sw, sh, raw)) = slot.take() {
if raw.is_empty() {
push(&msg_tx, ControlMsg::KeyFrameReq);
} else if sw > 0 && sh > 0 {
blit(&mut buffer, w, h, &raw, sw as usize, sh as usize);
dirty = true;
}
}
}
// 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:指标每秒刷新一次;每秒至少强制呈现一次(视频冻结/黑屏时 HUD 依然可见)
let mut need_present = dirty;
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();
need_present = true; // HUD 面板每秒至少重绘一次
} else {
hud_loss = f64::NAN;
hud_jit = 0;
hud_p95 = None;
hud_dec = 0;
}
if need_present {
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 缓冲。
/// 支持源尺寸与窗口不同(解码器可能对齐到 16 倍数,如 1080→1088):
/// 逐行拷贝重叠区域,源小于窗口时右侧/下方留黑。
fn blit(buffer: &mut [u32], w: usize, h: usize, raw: &[u8], dw: usize, dh: usize) {
let cw = w.min(dw);
let ch = h.min(dh);
for row in 0..ch {
let src = &raw[row * dw * 3..row * dw * 3 + cw * 3];
let dst = &mut buffer[row * w..row * w + cw];
for (px, chunk) in dst.iter_mut().zip(src.chunks_exact(3)) {
*px = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
}
}
}