feat(agent): v5 光标采样接入帧头(GetCursorInfo + 折叠规则 + dump 观测列)
采集端把远端光标位置填进每帧帧头,供主控端画标记(4b 解决看不见远端鼠标)。 - fold_cursor 纯函数承载全部折叠判定:隐藏(CURSOR_SHOWING 未置位)/ 越出采集画面 [0,w)x[0,h)(副屏负坐标区,Q5=A)/超 i16 范围(防截断成 看似合法的错坐标)→ None;坐标语义单一来源在被控端 - sample_cursor 只负责 Win32 读数(cbSize 契约注释)与失败/越界留痕 (会话期各只打一条日志,可见后果但不逐帧刷屏) - 采样点在 emit! 宏(唯一发送入口):新帧/静止 500ms 保活重发/强制 IDR 三条路径天然全覆盖,帧头 cursor 与 ts_ms 同刻 - 前提:agent 的 per-monitor DPI 感知(3b/D2)保证 ptScreenPos 是物理像素 - agent crate 首批单测 4 例(界内含原点/隐藏/五类越界/i16 上界), mutation 反向验证(>= 改 > 后越界用例确实红) - dump_stream 帧表加光标列,Windows 端实测(本机同回环): 界内 Some(1000,600) 精确、副屏 -960 折叠 None、None->Some(300,250) 恢复 全部 PASS;期间发现有人操控真鼠标,探针已改静置门控+读回自校验设计
This commit is contained in:
parent
98b4f03cff
commit
b1ac32175c
@ -114,6 +114,14 @@ fn arg(name: &str) -> Option<String> {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v5 观测列:帧头光标字段的稳定文本形态(proof 脚本按 `cursor=(x,y)` / `cursor=None` 解析)
|
||||||
|
fn cursor_cell(c: &Option<(i16, i16)>) -> String {
|
||||||
|
match c {
|
||||||
|
Some((x, y)) => format!("cursor=({x},{y})"),
|
||||||
|
None => "cursor=None".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
rustls::crypto::ring::default_provider()
|
rustls::crypto::ring::default_provider()
|
||||||
@ -184,8 +192,8 @@ async fn main() -> Result<()> {
|
|||||||
// 累积裸码流,用于 --save
|
// 累积裸码流,用于 --save
|
||||||
let mut saved: Vec<u8> = Vec::new();
|
let mut saved: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
println!("\n序号 seq kf 载荷字节 NAL 结构 解码结果");
|
println!("\n序号 seq kf 载荷字节 NAL 结构 光标 解码结果");
|
||||||
println!("{}", "-".repeat(72));
|
println!("{}", "-".repeat(86));
|
||||||
|
|
||||||
for i in 0..total {
|
for i in 0..total {
|
||||||
let (hdr, payload) = match tokio::time::timeout(
|
let (hdr, payload) = match tokio::time::timeout(
|
||||||
@ -221,11 +229,12 @@ async fn main() -> Result<()> {
|
|||||||
// 模拟主控端积压跳帧:读出但不解码
|
// 模拟主控端积压跳帧:读出但不解码
|
||||||
if Some(i) == skip_at {
|
if Some(i) == skip_at {
|
||||||
println!(
|
println!(
|
||||||
"[{i:2}] {:3} {:<5} {:8} {:<14} ⏭ 跳过解码(模拟积压跳帧)",
|
"[{i:2}] {:3} {:<5} {:8} {:<14} {:14} ⏭ 跳过解码(模拟积压跳帧)",
|
||||||
hdr.seq,
|
hdr.seq,
|
||||||
hdr.keyframe,
|
hdr.keyframe,
|
||||||
payload.len(),
|
payload.len(),
|
||||||
describe(&types)
|
describe(&types),
|
||||||
|
cursor_cell(&hdr.cursor)
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -237,11 +246,12 @@ async fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"[{i:2}] {:3} {:<5} {:8} {:<14} {}",
|
"[{i:2}] {:3} {:<5} {:8} {:<14} {:14} {}",
|
||||||
hdr.seq,
|
hdr.seq,
|
||||||
hdr.keyframe,
|
hdr.keyframe,
|
||||||
payload.len(),
|
payload.len(),
|
||||||
describe(&types),
|
describe(&types),
|
||||||
|
cursor_cell(&hdr.cursor),
|
||||||
res
|
res
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ use windows::Win32::Graphics::Dxgi::{
|
|||||||
IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT,
|
IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT,
|
||||||
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
|
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
|
||||||
};
|
};
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{GetCursorInfo, CURSORINFO, CURSOR_SHOWING};
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@ -50,6 +51,31 @@ use std::time::{Duration, Instant};
|
|||||||
/// 又不会因过密的整帧内编码抬高码率。
|
/// 又不会因过密的整帧内编码抬高码率。
|
||||||
pub const INTRA_PERIOD_FRAMES: u32 = 60;
|
pub const INTRA_PERIOD_FRAMES: u32 = 60;
|
||||||
|
|
||||||
|
/// v5:光标可见性 + 采集边界折叠的**纯逻辑**(无 Win32 调用,可单测)。
|
||||||
|
///
|
||||||
|
/// 输入是 `GetCursorInfo` 的原始读数与采集画面尺寸;输出协议 `cursor` 字段语义
|
||||||
|
/// (见 `common::VideoFrameHeader::cursor`):
|
||||||
|
/// - 未 `CURSOR_SHOWING`(指针隐藏或被触控抑制)→ None
|
||||||
|
/// - 越出 `[0,w)×[0,h)`(副屏负坐标区、主屏外右侧等)→ None
|
||||||
|
/// ——采集画面里没有它,主控端画了位置也是错的(4b/Q5=A:语义单一来源在被控端)
|
||||||
|
/// - 越出 i16 正范围 → None 且第二返回 `true`(调用方打日志):协议用 i16 省字节,
|
||||||
|
/// 依赖「现实桌面 << 32767」前提;前提破了就折叠,**绝不截断出一个看起来合法的错坐标**
|
||||||
|
///
|
||||||
|
/// 独立成函数的原因:这套判定是 4b 折叠规则的全部智力内容,而 Win32 读数在
|
||||||
|
/// 有人用真鼠标的机器上不可复现——逻辑必须与采样解耦地被测试锁死。
|
||||||
|
fn fold_cursor(showing: bool, x: i32, y: i32, w: i32, h: i32) -> (Option<(i16, i16)>, bool) {
|
||||||
|
if !showing {
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
if x < 0 || y < 0 || x >= w || y >= h {
|
||||||
|
return (None, false);
|
||||||
|
}
|
||||||
|
if x > i16::MAX as i32 || y > i16::MAX as i32 {
|
||||||
|
return (None, true);
|
||||||
|
}
|
||||||
|
(Some((x as i16, y as i16)), false)
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Duplication {
|
pub struct Duplication {
|
||||||
pub device: ID3D11Device,
|
pub device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
@ -182,6 +208,42 @@ impl Duplication {
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v5:采样当前远端光标位置(**采集显示器内**的物理像素,(0,0)=画面左上角)。
|
||||||
|
///
|
||||||
|
/// 折叠规则(隐藏/离屏/i16 越界 → None)的唯一真源是 [`fold_cursor`]——
|
||||||
|
/// 那里是可单测的纯逻辑;本函数只负责 Win32 读数与失败留痕。
|
||||||
|
///
|
||||||
|
/// `GetCursorInfo` 失败 → None + 会话期只打一条日志(可见后果,但不逐帧刷屏)。
|
||||||
|
///
|
||||||
|
/// 前提:进程必须 per-monitor DPI 感知(`main.rs::init_dpi_awareness`,3b/D2 教训),
|
||||||
|
/// 否则 `ptScreenPos` 是逻辑坐标,与采集像素不同尺度。
|
||||||
|
pub fn sample_cursor(&self) -> Option<(i16, i16)> {
|
||||||
|
static LOGGED_FAIL: AtomicBool = AtomicBool::new(false);
|
||||||
|
static LOGGED_OVERFLOW: AtomicBool = AtomicBool::new(false);
|
||||||
|
unsafe {
|
||||||
|
let mut ci = CURSORINFO::default();
|
||||||
|
// Win32 契约:调用方必须先填 cbSize,否则 API 直接拒绝(MSDN GetCursorInfo)
|
||||||
|
ci.cbSize = std::mem::size_of::<CURSORINFO>() as u32;
|
||||||
|
if GetCursorInfo(&mut ci).is_err() {
|
||||||
|
if !LOGGED_FAIL.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[capture] GetCursorInfo 失败:帧头 cursor 将恒为 None");
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (pos, overflow) = fold_cursor(
|
||||||
|
ci.flags.0 & CURSOR_SHOWING.0 != 0,
|
||||||
|
ci.ptScreenPos.x,
|
||||||
|
ci.ptScreenPos.y,
|
||||||
|
self.width as i32,
|
||||||
|
self.height as i32,
|
||||||
|
);
|
||||||
|
if overflow && !LOGGED_OVERFLOW.swap(true, Ordering::Relaxed) {
|
||||||
|
eprintln!("[capture] 光标坐标超出 i16 协议范围,已折叠为 None(桌面尺寸异常?)");
|
||||||
|
}
|
||||||
|
pos
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。
|
/// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。
|
||||||
@ -364,6 +426,10 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
|
|
||||||
// `$kf` 由调用点显式给出:NVENC 路径知道 `force_key`,软编路径同理。
|
// `$kf` 由调用点显式给出:NVENC 路径知道 `force_key`,软编路径同理。
|
||||||
// 不要在这里猜——首帧和 KeyFrameReq 都会产生 IDR,猜错会让解码端错过重同步点。
|
// 不要在这里猜——首帧和 KeyFrameReq 都会产生 IDR,猜错会让解码端错过重同步点。
|
||||||
|
//
|
||||||
|
// `cursor` 在**这里**采样(不在循环顶部):帧头语义是「本帧发送时刻的位置」。
|
||||||
|
// emit! 是唯一的发送入口,静止保活重发也走它(500ms 分支),所以新帧 /
|
||||||
|
// 静止重发 / 强制 IDR 三种发送路径天然全覆盖,无需各自补采样。
|
||||||
macro_rules! emit {
|
macro_rules! emit {
|
||||||
($payload:expr, $kf:expr) => {{
|
($payload:expr, $kf:expr) => {{
|
||||||
let header = VideoFrameHeader {
|
let header = VideoFrameHeader {
|
||||||
@ -372,8 +438,7 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
ts_ms: start.elapsed().as_millis() as u64,
|
ts_ms: start.elapsed().as_millis() as u64,
|
||||||
seq,
|
seq,
|
||||||
keyframe: $kf,
|
keyframe: $kf,
|
||||||
// v5 光标字段:占位 None,真实采样(GetCursorInfo)在下一步接入
|
cursor: dup.sample_cursor(),
|
||||||
cursor: None,
|
|
||||||
};
|
};
|
||||||
let payload_vec: Vec<u8> = $payload;
|
let payload_vec: Vec<u8> = $payload;
|
||||||
let frame = encode_video_frame(&header, &payload_vec);
|
let frame = encode_video_frame(&header, &payload_vec);
|
||||||
@ -587,3 +652,61 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
|
|||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::fold_cursor;
|
||||||
|
|
||||||
|
const W: i32 = 1920;
|
||||||
|
const H: i32 = 1080;
|
||||||
|
|
||||||
|
/// 界内可见 → 原样返回。中心与 (0,0) 都要覆盖:(0,0) 与 None 在协议里是两种语义,
|
||||||
|
/// 若实现把「左上角」误折叠成 None,光标到左上角就「消失」了(这是最容易写错的一条)。
|
||||||
|
#[test]
|
||||||
|
fn inside_bounds_pass_through_including_origin() {
|
||||||
|
assert_eq!(fold_cursor(true, 960, 540, W, H), (Some((960, 540)), false));
|
||||||
|
assert_eq!(fold_cursor(true, 0, 0, W, H), (Some((0, 0)), false));
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, W - 1, H - 1, W, H),
|
||||||
|
(Some((1919, 1079)), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏/触控抑制(CURSOR_SHOWING 未置位)→ None,**即使坐标界内**。
|
||||||
|
/// 顺序很关键:坐标合法但不可见时也不能画标记。
|
||||||
|
#[test]
|
||||||
|
fn hidden_cursor_folds_to_none_even_when_in_bounds() {
|
||||||
|
assert_eq!(fold_cursor(false, 100, 100, W, H), (None, false));
|
||||||
|
assert_eq!(fold_cursor(false, 0, 0, W, H), (None, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 越出采集画面 → None:负坐标(副屏在左,本机实测 -1920..-1)、
|
||||||
|
/// 右/下边界外、以及**恰好在边界上**(坐标 w 对应第 w+1 列,画面里没有)。
|
||||||
|
#[test]
|
||||||
|
fn out_of_capture_bounds_folds_to_none() {
|
||||||
|
assert_eq!(fold_cursor(true, -960, 500, W, H), (None, false)); // 副屏(负 X)
|
||||||
|
assert_eq!(fold_cursor(true, 100, -1, W, H), (None, false)); // 负 Y
|
||||||
|
assert_eq!(fold_cursor(true, W, 500, W, H), (None, false)); // 右边界外一列
|
||||||
|
assert_eq!(fold_cursor(true, 500, H, W, H), (None, false)); // 下边界外一行
|
||||||
|
assert_eq!(fold_cursor(true, 2610, 1300, W, H), (None, false)); // 主屏外右侧
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 越出 i16 正范围 → None + 第二返回 true(调用方据此打日志)。
|
||||||
|
/// 注意:常规采集尺寸(≤1920)下越界 x 永远先被边界检查拦下,i16 分支不可达——
|
||||||
|
/// 必须用超宽采集(假想带鱼屏)才能测到;它证明「界内但超 i16」会被折叠
|
||||||
|
/// 而**不是截断成看似合法的错坐标**。i16::MAX 恰好合法,不误报。
|
||||||
|
#[test]
|
||||||
|
fn beyond_i16_folds_with_overflow_flag() {
|
||||||
|
let huge_w = 40_000; // 假想的超宽采集,越过 i16::MAX
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, i16::MAX as i32, 0, huge_w, H),
|
||||||
|
(Some((i16::MAX, 0)), false),
|
||||||
|
"i16::MAX 恰好界内合法,不该误报 overflow"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fold_cursor(true, 33_000, 10, huge_w, H),
|
||||||
|
(None, true),
|
||||||
|
"界内但超 i16 → overflow=true 的 None,而非截断成错误坐标"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user