//! 主控端 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 { Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _certificate: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> Result { Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _certificate: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> Result { Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { 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 = 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 = OnceLock::new(); fn now_ms() -> u64 { T0.get().unwrap().elapsed().as_millis() as u64 } /// 帧到达间隔环形缓冲(取 p95 用) struct GapRing { last: Option, 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, } 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) { 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 { let g = self.gaps.lock().ok()?; if g.filled < 8 { return None; } let mut v: Vec = 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)>>> = 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::>(); 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>, 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 = 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 = 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; } } }