2026-10-22 21:17:55 -04:00
|
|
|
|
//! 主控端 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 21:47:09 -04:00
|
|
|
|
/// 帧到达间隔环形缓冲(取 p95 用)
|
|
|
|
|
|
struct GapRing {
|
|
|
|
|
|
last: Option<Instant>,
|
|
|
|
|
|
ring: [u32; 64],
|
|
|
|
|
|
idx: usize,
|
|
|
|
|
|
filled: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 21:17:55 -04:00
|
|
|
|
/// 会话统计(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,
|
2026-10-22 21:47:09 -04:00
|
|
|
|
/// 抖动:相邻帧 |Δ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()
|
|
|
|
|
|
}
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
|
2026-10-22 22:31:30 -04:00
|
|
|
|
// 读任务(流 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));
|
2026-10-22 21:17:55 -04:00
|
|
|
|
{
|
|
|
|
|
|
let stats = stats.clone();
|
2026-10-22 22:31:30 -04:00
|
|
|
|
let latest = latest.clone();
|
2026-10-22 21:17:55 -04:00
|
|
|
|
let mut decoder = openh264::decoder::Decoder::new()
|
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
|
|
|
|
|
|
tokio::spawn(async move {
|
2026-10-22 22:31:30 -04:00
|
|
|
|
let mut size_logged = false;
|
2026-10-22 21:17:55 -04:00
|
|
|
|
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 平滑)
|
2026-10-22 21:47:09 -04:00
|
|
|
|
let mut est = None;
|
2026-10-22 21:17:55 -04:00
|
|
|
|
if stats.offset_valid.load(Ordering::Relaxed) {
|
2026-10-22 21:47:09 -04:00
|
|
|
|
let e =
|
2026-10-22 21:17:55 -04:00
|
|
|
|
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
|
|
|
|
|
|
- hdr.ts_ms as i64;
|
2026-10-22 21:47:09 -04:00
|
|
|
|
if (0..5000).contains(&e) {
|
2026-10-22 21:17:55 -04:00
|
|
|
|
let old = stats.e2e.load(Ordering::Relaxed) as i64;
|
|
|
|
|
|
let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
|
2026-10-22 21:47:09 -04:00
|
|
|
|
(old * 4 + e) / 5
|
2026-10-22 21:17:55 -04:00
|
|
|
|
} else {
|
2026-10-22 21:47:09 -04:00
|
|
|
|
e
|
2026-10-22 21:17:55 -04:00
|
|
|
|
};
|
|
|
|
|
|
stats.e2e.store(sm as u64, Ordering::Relaxed);
|
|
|
|
|
|
stats.e2e_valid.store(true, Ordering::Relaxed);
|
2026-10-22 21:47:09 -04:00
|
|
|
|
est = Some(sm);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-22 21:47:09 -04:00
|
|
|
|
stats.on_frame_arrival(est);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
match decoder.decode(&payload) {
|
|
|
|
|
|
Ok(Some(yuv)) => {
|
|
|
|
|
|
let (dw, dh) = yuv.dimensions();
|
2026-10-22 22:31:30 -04:00
|
|
|
|
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];
|
2026-10-22 21:17:55 -04:00
|
|
|
|
yuv.write_rgb8(&mut rgb);
|
2026-10-22 22:31:30 -04:00
|
|
|
|
// latest-wins:直接覆盖旧帧,无积压
|
|
|
|
|
|
if let Ok(mut slot) = latest.lock() {
|
|
|
|
|
|
*slot = Some((dw, dh, rgb));
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(None) => {}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
// 解码异常:请求被控端强制关键帧
|
2026-10-22 21:47:09 -04:00
|
|
|
|
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
2026-10-22 22:31:30 -04:00
|
|
|
|
if let Ok(mut slot) = latest.lock() {
|
|
|
|
|
|
*slot = Some((0, 0, Vec::new()));
|
|
|
|
|
|
}
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-10-22 21:47:09 -04:00
|
|
|
|
Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => {
|
2026-10-22 21:17:55 -04:00
|
|
|
|
let r = now_ms();
|
|
|
|
|
|
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed);
|
2026-10-22 21:47:09 -04:00
|
|
|
|
stats.agent_lost.store(lost, Ordering::Relaxed);
|
|
|
|
|
|
stats.agent_sent.store(sent, Ordering::Relaxed);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
// 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));
|
|
|
|
|
|
};
|
2026-10-23 01:12:27 -04:00
|
|
|
|
// 入会立即请求 IDR:避免从 GOP 中间接入收到裸 P 帧解码失败导致启动黑屏
|
|
|
|
|
|
push(&msg_tx, ControlMsg::KeyFrameReq);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
|
|
|
|
|
|
let mut window = Window::new(
|
2026-10-23 00:48:16 -04:00
|
|
|
|
"mvp-remote v0.7-LowLatency (Esc 退出)",
|
2026-10-22 21:17:55 -04:00
|
|
|
|
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;
|
2026-10-22 21:47:09 -04:00
|
|
|
|
let mut hud_lost = 0u64;
|
|
|
|
|
|
let mut hud_sent = 0u64;
|
2026-10-22 21:17:55 -04:00
|
|
|
|
|
|
|
|
|
|
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()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 22:31:30 -04:00
|
|
|
|
// 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;
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 }),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 22:45:46 -04:00
|
|
|
|
// 6) HUD:指标每秒刷新一次;每秒至少强制呈现一次(视频冻结/黑屏时 HUD 依然可见)
|
|
|
|
|
|
let mut need_present = dirty;
|
2026-10-22 21:47:09 -04:00
|
|
|
|
let (hud_loss, hud_jit, hud_p95, hud_dec);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
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;
|
2026-10-22 21:47:09 -04:00
|
|
|
|
// 丢包率 = Δ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);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
hud_last = Instant::now();
|
2026-10-22 22:45:46 -04:00
|
|
|
|
need_present = true; // HUD 面板每秒至少重绘一次
|
2026-10-22 21:47:09 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
hud_loss = f64::NAN;
|
|
|
|
|
|
hud_jit = 0;
|
|
|
|
|
|
hud_p95 = None;
|
|
|
|
|
|
hud_dec = 0;
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
2026-10-22 22:45:46 -04:00
|
|
|
|
if need_present {
|
2026-10-22 21:17:55 -04:00
|
|
|
|
let rtt = stats.rtt.load(Ordering::Relaxed);
|
|
|
|
|
|
let e2e = stats.e2e.load(Ordering::Relaxed);
|
|
|
|
|
|
let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed);
|
2026-10-22 21:47:09 -04:00
|
|
|
|
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);
|
2026-10-22 21:17:55 -04:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-10-22 21:47:09 -04:00
|
|
|
|
("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 }),
|
2026-10-22 21:17:55 -04:00
|
|
|
|
("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],
|
2026-10-22 21:47:09 -04:00
|
|
|
|
b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11],
|
2026-10-22 21:17:55 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-22 22:31:30 -04:00
|
|
|
|
/// 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;
|
|
|
|
|
|
}
|
2026-10-22 21:17:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|