feat(common): 协议 v5 帧头新增光标字段 cursor

4b 光标覆盖层的协议地基:VideoFrameHeader 尾部追加 Option<(i16,i16)>,
携带本帧发送时刻的远端光标物理像素坐标(None=隐藏/离屏/未实现三态折叠)。
解决「看不见远端鼠标」——相对模式下用户不知道指针在哪、轻点会落在哪。

- 字段追加改变 bincode 线格式,PROTO_VERSION 4→5,v4 旧端由握手显式拒绝(既有机制)
- v4 守卫按 v3 先例降为下界锚点,最新一代用精确等号守卫
- 新增 3 测试:Option 往返(含 i16 边界与负值)、尾部追加不扰动既有字段、
  None 紧凑性(Some 比 None 恰多 4 字节,证明光标未被固定宽度白占)
- agent/agent-core/viewer-core 构造点补占位 cursor: None(真实采样下一步接入),
  保证本提交全 workspace 可编译
This commit is contained in:
郑博文 2026-11-02 10:11:23 +08:00
parent e96502f888
commit 98b4f03cff
4 changed files with 72 additions and 5 deletions

View File

@ -189,6 +189,8 @@ fn encode_send(
ts_ms,
seq,
keyframe: h264_contains_idr(&payload),
// v5 光标字段:占位 None,本 crate 的真实采样在下一步接入
cursor: None,
};
let frame = encode_video_frame(&header, &payload);
if tx.send(Some(frame.clone())).is_err() {

View File

@ -372,6 +372,8 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc
ts_ms: start.elapsed().as_millis() as u64,
seq,
keyframe: $kf,
// v5 光标字段:占位 None,真实采样(GetCursorInfo)在下一步接入
cursor: None,
};
let payload_vec: Vec<u8> = $payload;
let frame = encode_video_frame(&header, &payload_vec);

View File

@ -22,7 +22,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// - v4:新增 `InputEvent::MouseMoveRel`(相对鼠标,单位=被控端桌面物理像素)。
/// bincode 按位置编码枚举判别序号,新增变体会改变其后变体的编号,
/// 故同样必须三端同步;旧端由 `Auth` 握手显式拒绝。
pub const PROTO_VERSION: u16 = 4;
/// - v5:`VideoFrameHeader` 尾部新增 `cursor`(本帧发送时刻的远端光标位置)。
/// 字段追加同样改变线格式(旧端会在反序列化时少读/多读而错位),故必须三端同步;
/// 旧端由 `Auth` 握手显式拒绝。语义见 [`VideoFrameHeader::cursor`]。
pub const PROTO_VERSION: u16 = 5;
/// 控制通道消息(双向)
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -132,6 +135,15 @@ pub fn clamp_text_units(units: Vec<u16>) -> (Vec<u16>, bool) {
/// 用于丢帧检测与弱网统计;注意它统计的是"发送序",不是"显示序"
/// - `keyframe`:本帧是否 IDR。解码端(尤其 MediaCodec)需要它决定是否重置解码器,
/// 不必再自己扫 NAL 起始码判断
/// - `cursor`(v5):本帧**发送时刻**的远端光标位置,被采集显示器内的**物理像素**
/// ((0,0)=采集画面左上角,与 `width`/`height` 同尺度同原点)。
/// `None` = 不可用,含三种情况:指针被隐藏(系统未显示光标)、指针在采集显示器
/// **之外**(多显示器负坐标区等——采集画面里没有它,主控端画了也是错的)、
/// 或被控端平台未实现光标采样。
/// 用途:主控端据此在画面上标记指针位置(「看不见远端鼠标」修复);它与 `ts_ms`
/// 同刻、同帧到达,不存在标记与画面的相对错位。
/// 为什么 `i16`:采集原点固定 (0,0),现实桌面尺寸远小于 32767;被控端已把
/// 越界值折叠为 `None`,到达值恒在 `[0, width)×[0, height)` 内。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoFrameHeader {
pub width: u32,
@ -139,6 +151,7 @@ pub struct VideoFrameHeader {
pub ts_ms: u64,
pub seq: u64,
pub keyframe: bool,
pub cursor: Option<(i16, i16)>,
}
async fn read_u32le<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<u32> {
@ -279,6 +292,7 @@ mod tests {
ts_ms: 12345,
seq,
keyframe,
cursor: Some((640, 480)),
}
}
@ -347,15 +361,63 @@ mod tests {
// ---- v4 相对鼠标 ----
/// 版本守卫:v4 是相对鼠标的登记点。
/// 版本守卫:v4 是相对鼠标的登记点。v5 之后此断言随版本推进为下界,
/// 与 v3 守卫同规(保留它是「任何一次版本变更都必须显式经过这里」的锚点)。
#[test]
fn proto_version_is_four_after_rel_move() {
assert_eq!(
PROTO_VERSION, 4,
"新增 InputEvent::MouseMoveRel 后必须递增到 4"
assert!(
PROTO_VERSION >= 4,
"MouseMoveRel 自 v4 起存在,版本不得回退(当前 {PROTO_VERSION})"
);
}
/// 版本守卫:v5 是光标字段的登记点(最新一代用精确等号)。
#[test]
fn proto_version_is_five_after_cursor_field() {
assert_eq!(
PROTO_VERSION, 5,
"新增 VideoFrameHeader::cursor 后必须递增到 5"
);
}
/// 光标字段必须原样往返,含 `None`(隐藏/离屏/未实现三态都折叠为它)与负值边界。
/// `None` 是本变体的核心语义:缺它主控端就无法区分「指针在 (0,0)」与「没有指针」。
#[test]
fn cursor_field_roundtrips_option_and_i16() {
for c in [
None,
Some((0i16, 0i16)),
Some((1919, 1079)),
Some((-32768, 32767)),
Some((i16::MIN, i16::MAX)),
] {
let mut h = hdr(1, false);
h.cursor = c;
let payload = b"\x00\x00\x00\x01\x65x";
let bytes = encode_video_frame(&h, payload);
let hlen = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
let back: VideoFrameHeader = bincode::deserialize(&bytes[8..8 + hlen]).unwrap();
assert_eq!(back.cursor, c, "光标字段往返失真: {c:?}");
}
}
/// cursor 是**追加在尾部**的字段:既有字段(width/height/ts/seq/keyframe)的
/// 解码不受影响,且 `None` 变体让帧头显著变短(证明它没被塞进固定宽度里白占字节)。
#[test]
fn cursor_is_trailing_field_and_none_is_compact() {
let mut with = hdr(9, true);
with.cursor = Some((10, 20));
let mut without = hdr(9, true);
without.cursor = None;
let wb = bincode::serialize(&with).unwrap();
let wob = bincode::serialize(&without).unwrap();
// Some((i16,i16)) 比 None 多 4 字节(两个 i16),1 字节 tag 两边都有
assert_eq!(wb.len(), wob.len() + 4, "光标坐标应为 2×i16 定长附加");
// 前缀不变:去掉尾部光标字段,其余字节的解码结果完全一致(追加语义)
let h: VideoFrameHeader = bincode::deserialize(&wb).unwrap();
assert_eq!((h.width, h.height, h.seq, h.keyframe), (1920, 1080, 9, true));
}
/// 相对位移必须原样往返,**包含负值**(向左/向上移动)。
/// 负值是本变体的核心语义:绝对坐标是无符号的,相对增量必须有符号。
#[test]

View File

@ -678,6 +678,7 @@ mod tests {
ts_ms: 42,
seq: 7,
keyframe: true,
cursor: None,
};
let payload = vec![0u8; 16];
let f: Frame = (h.clone(), payload.clone()).into();