2026-10-29 06:14:27 -04:00
|
|
|
|
//! viewer-core 的桌面 headless 验证工具(阶段 1 验收用,不依赖 Android)。
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 证明三件事,逐条打印:
|
|
|
|
|
|
//! 1. viewer-core 能完成 QUIC 连接 + 协议 v2 认证握手;
|
|
|
|
|
|
//! 2. 帧流按契约交付(seq 单调递增、keyframe 标志与码流实际 NAL 一致);
|
|
|
|
|
|
//! 3. 收到的码流能被 openh264 解出画面(即:Android 侧 MediaCodec 将拿到同样
|
|
|
|
|
|
//! 合法的 Annex-B——解码器不同,但码流合法性在这里就证明了)。
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! 用法:
|
|
|
|
|
|
//! 终端1: target\release\agent.exe --port 47900 --code t
|
|
|
|
|
|
//! 终端2: cargo run -p viewer-core --bin headless_check -- --port 47900 --code t --frames 60
|
|
|
|
|
|
|
|
|
|
|
|
use anyhow::Result;
|
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
use viewer_core::{ConnectConfig, Session};
|
|
|
|
|
|
|
|
|
|
|
|
fn arg(name: &str) -> Option<String> {
|
|
|
|
|
|
let a: Vec<String> = std::env::args().collect();
|
|
|
|
|
|
a.iter()
|
|
|
|
|
|
.position(|x| x == name)
|
|
|
|
|
|
.and_then(|i| a.get(i + 1))
|
|
|
|
|
|
.cloned()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 与 dump_stream 同款:扫 Annex-B 起始码拿 NAL 类型,交叉校验 keyframe 标志
|
|
|
|
|
|
fn has_idr(payload: &[u8]) -> bool {
|
|
|
|
|
|
let mut i = 0usize;
|
|
|
|
|
|
while i + 3 < payload.len() {
|
|
|
|
|
|
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
|
|
|
|
|
4
|
|
|
|
|
|
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
|
|
|
|
|
3
|
|
|
|
|
|
} else {
|
|
|
|
|
|
i += 1;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
match payload.get(i + sc) {
|
|
|
|
|
|
Some(&b) if b & 0x1F == 5 => return true,
|
|
|
|
|
|
Some(_) => i += sc + 1,
|
|
|
|
|
|
None => break,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
|
async fn main() -> Result<()> {
|
|
|
|
|
|
let port: u16 = arg("--port")
|
|
|
|
|
|
.map(|s| s.parse())
|
|
|
|
|
|
.transpose()?
|
|
|
|
|
|
.unwrap_or(47800);
|
|
|
|
|
|
let code = arg("--code").unwrap_or_else(|| "123456".into());
|
2026-10-29 07:18:11 -04:00
|
|
|
|
// --host:默认 127.0.0.1;填局域网 IP 可顺带验证防火墙入站路径
|
|
|
|
|
|
let host = arg("--host").unwrap_or_else(|| "127.0.0.1".into());
|
2026-10-29 06:14:27 -04:00
|
|
|
|
let frames_wanted: u64 = arg("--frames")
|
|
|
|
|
|
.map(|s| s.parse())
|
|
|
|
|
|
.transpose()?
|
|
|
|
|
|
.unwrap_or(60);
|
|
|
|
|
|
|
2026-10-29 07:18:11 -04:00
|
|
|
|
let mut session = Session::connect(ConnectConfig::direct(&host, port, code)).await?;
|
|
|
|
|
|
println!("[check] 连接成功 host={host} peer={}", session.peer_addr());
|
2026-10-29 06:14:27 -04:00
|
|
|
|
|
|
|
|
|
|
let mut decoder = openh264::decoder::Decoder::new()?;
|
|
|
|
|
|
let mut last_seq: Option<u64> = None;
|
|
|
|
|
|
let mut got = 0u64;
|
|
|
|
|
|
let mut seq_violations = 0u64;
|
|
|
|
|
|
let mut kf_mismatches = 0u64;
|
|
|
|
|
|
let mut decoded = 0u64;
|
|
|
|
|
|
let mut decode_failed_before_idr = 0u64;
|
|
|
|
|
|
let mut awaiting_idr = true; // 与 Android 侧相同的状态机
|
|
|
|
|
|
let mut rtt_shown = false;
|
|
|
|
|
|
|
|
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
|
|
|
|
|
while got < frames_wanted && std::time::Instant::now() < deadline {
|
|
|
|
|
|
let Some(f) = tokio::time::timeout(Duration::from_secs(10), session.next_frame())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
else {
|
|
|
|
|
|
break;
|
|
|
|
|
|
};
|
|
|
|
|
|
got += 1;
|
|
|
|
|
|
|
|
|
|
|
|
// 契约 2a:seq 单调递增
|
|
|
|
|
|
if let Some(prev) = last_seq {
|
|
|
|
|
|
if f.seq <= prev {
|
|
|
|
|
|
seq_violations += 1;
|
|
|
|
|
|
println!("[check] ⚠️ seq 回退: {prev} -> {}", f.seq);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
last_seq = Some(f.seq);
|
|
|
|
|
|
|
|
|
|
|
|
// 契约 2b:keyframe 标志与码流一致
|
|
|
|
|
|
let real_kf = has_idr(&f.payload);
|
|
|
|
|
|
if f.keyframe != real_kf {
|
|
|
|
|
|
kf_mismatches += 1;
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"[check] ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
|
|
|
|
|
|
f.keyframe, real_kf, f.seq
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 契约 3:与 Android 相同的状态机下解码必须成功
|
|
|
|
|
|
if awaiting_idr && !f.keyframe {
|
|
|
|
|
|
continue; // 等 IDR,P 帧不喂(这是 R-3 教训的实现)
|
|
|
|
|
|
}
|
|
|
|
|
|
if f.keyframe {
|
|
|
|
|
|
awaiting_idr = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
match decoder.decode(&f.payload) {
|
|
|
|
|
|
Ok(_) => decoded += 1,
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
decode_failed_before_idr += 1;
|
|
|
|
|
|
println!("[check] ❌ seq={} 解码失败: {e}", f.seq);
|
|
|
|
|
|
awaiting_idr = true;
|
|
|
|
|
|
session.request_keyframe();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RTT 出现即记录一次
|
|
|
|
|
|
if !rtt_shown {
|
|
|
|
|
|
let s = session.stats();
|
|
|
|
|
|
if s.rtt_ms > 0 {
|
|
|
|
|
|
println!("[check] RTT={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
|
|
|
|
|
rtt_shown = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let s = session.stats();
|
|
|
|
|
|
println!("\n========== 阶段 1 headless 验收 ==========");
|
|
|
|
|
|
println!("收到帧: {got}(目标 {frames_wanted})");
|
|
|
|
|
|
println!("seq 违例: {seq_violations}");
|
|
|
|
|
|
println!("keyframe 标志不符: {kf_mismatches}");
|
|
|
|
|
|
println!("解码成功: {decoded} / 解码失败: {decode_failed_before_idr}");
|
|
|
|
|
|
println!("统计: rtt={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
|
|
|
|
|
|
|
|
|
|
|
let ok = got >= frames_wanted.min(30)
|
|
|
|
|
|
&& seq_violations == 0
|
|
|
|
|
|
&& kf_mismatches == 0
|
|
|
|
|
|
&& decoded > 0
|
|
|
|
|
|
&& decode_failed_before_idr == 0;
|
|
|
|
|
|
println!(
|
|
|
|
|
|
"结论: {}",
|
|
|
|
|
|
if ok { "✅ viewer-core 收帧链路通过" } else { "❌ 存在未满足项" }
|
|
|
|
|
|
);
|
|
|
|
|
|
session.close().await;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|