Compare commits

...
Author SHA1 Message Date
dailz 74ac8750dc fix(test_portal): 修复 doc list 缩进警告 2026-06-22 19:10:57 +08:00
dailz f44e848c77 fix(safety): 移除新增的中文 // SAFETY: 注释为合规 baseline 不变量 2026-06-22 19:01:42 +08:00
dailz 41093a4f99 docs(avhw): [4/4] 中文注释 SwEncState 与 unsafe impl Send 2026-06-22 18:33:42 +08:00
dailz 052729529e docs(avhw): [3/4] 中文注释软件编码路径 SwEnc* 2026-06-22 18:19:31 +08:00
dailz 98eb72e2a2 docs(vaapi_import_bench): [2/2] 中文注释 CPU/GPU pipeline 与报告 2026-06-22 18:17:22 +08:00
dailz c780d6aeff docs(test_portal): 中文注释 Portal 冒烟测试示例 2026-06-22 18:13:42 +08:00
dailz c68457e0e3 docs(integration_test): 中文注释集成测试 2026-06-22 18:11:15 +08:00
dailz e6b5a5dc36 docs(list_globals): 中文注释 Wayland globals 示例 2026-06-22 18:10:03 +08:00
dailz b2ba34ef04 docs(stats): 中文注释管道性能统计 2026-06-22 18:03:56 +08:00
dailz 2f8197210e docs(webrtc): 中文注释 str0m WebRTC 信令服务器 2026-06-22 17:51:40 +08:00
dailz d94431bd1e docs(sw_encode_bench): 中文注释软编码基准二进制 2026-06-22 17:49:25 +08:00
dailz 895b9aeb32 docs(vaapi_import_bench): [1/2] 中文注释基准 setup 与类型定义 2026-06-22 17:48:59 +08:00
dailz 8460c56bd5 docs(avhw): [2/4] 中文注释 EncState 编码主循环 2026-06-22 17:44:08 +08:00
dailz 1518da4f30 docs(avhw): [1/4] 中文注释 FFmpeg/VAAPI 初始化 2026-06-22 17:34:53 +08:00
dailz 279ec97647 docs(cap_portal): [2/2] 中文注释 PipeWire 帧流与 unsafe FFI 2026-06-22 17:32:01 +08:00
dailz 09123fcd65 docs(state_portal): [2/2] 中文注释 Portal 帧循环与编码调度 2026-06-22 17:28:53 +08:00
dailz f3c0a83a9a docs(state): [3/3] 中文注释 state.rs 帧捕获与输出管理 2026-06-22 17:27:05 +08:00
dailz de93d31c89 docs(fps_limit): 中文注释帧率限制器 2026-06-22 17:26:03 +08:00
dailz f9e59756c3 docs(state): [2/3] 中文注释 state.rs Wayland Dispatch trait 实现 2026-06-22 17:18:54 +08:00
dailz e96af51ee1 docs(cap_portal): [1/2] 中文注释 XDG Portal 授权与 CapPortal 初始化 2026-06-22 17:18:10 +08:00
dailz fffa440e68 docs(state_portal): [1/2] 中文注释 Portal 状态机初始化 2026-06-22 17:18:00 +08:00
dailz 2841d93afa docs(transform): 中文注释图像变换逻辑 2026-06-22 17:15:28 +08:00
dailz 9e2f726491 docs(state): [1/3] 中文注释 state.rs 状态机初始化与类型定义 2026-06-22 17:04:14 +08:00
dailz ef4bd904db docs(backend_detect): 中文注释后端检测逻辑与 ashpd 规避原因 2026-06-22 16:56:25 +08:00
dailz d3016161d1 docs(cap_wlr): 中文注释 wlr-screencopy 协议绑定 2026-06-22 16:52:48 +08:00
dailz 3d314a35aa docs(main): 中文注释 src/main.rs 入口与事件循环 2026-06-22 16:44:20 +08:00
dailz 13b7466c57 docs(args): 中文注释 src/args.rs CLI 参数定义 2026-06-22 16:41:29 +08:00
dailz c12ae6ddcc docs(lib): 中文注释 build.rs 与 src/lib.rs 模块总览 2026-06-22 16:37:11 +08:00
19 changed files with 3146 additions and 0 deletions
+5
View File
@@ -1 +1,6 @@
//! Cargo build script(编译前钩子)。
//!
//! 当前为空:本项目直接复用 `wayland-client`、`pipewire`、`ffmpeg-sys` 等现成 crate
//! 不需要在编译前跑 wayland-scanner 或 bindgen 生成代码。类比 Go 无 `//go:generate`。
// Cargo 编译前不需要生成任何代码(无 wayland-scanner / bindgen),因此 build script 留空。
fn main() {}
+47
View File
@@ -1,11 +1,43 @@
//! 列出当前 Wayland 桌面广播的全部全局对象(registry globals)。
//!
//! Wayland 协议采用"客户端发现"模型:客户端连接到 compositor 后,第一件事是从
//! registry 中枚举所有被广播的 interface(如 `wl_compositor`、`wl_shm`、
//! `zwlr_screencopy_manager_v1`、`zxdg_portal_screencast` 等),每个 global 带有
//! 唯一数字 name、interface 名字符串、最高支持版本号。本示例即打印这三元组。
//!
//! 类比 Go 的 `xcursor` / wayland-client 示例:用最小可运行代码确认运行环境。
//!
//! 运行(参见 AGENTS.md "Useful manual commands"):
//! ```sh
//! cargo run --example list_globals
//! ```
//!
//! 该示例也是 `src/backend_detect.rs` 中检测 `zwlr_screencopy_manager_v1` 是否存在的
//! 同款机制(参见 `check_screencopy_available`),用于决定走 wlr-screencopy 还是 Portal。
// 引入 wayland-client 的快捷初始化辅助函数:内部完成 connect + registry bind + 同步枚举。
use wayland_client::globals::registry_queue_init;
// GlobalListContents 是 registry_queue_init 返回的"已收集好的 global 列表"句柄类型。
use wayland_client::globals::GlobalListContents;
// WlRegistry 是 Wayland 协议对象;Event 是其产生的枚举事件(global/global_remove)。
use wayland_client::protocol::wl_registry::{Event, WlRegistry};
// Connection 表示与 compositor 的 socket 连接;QueueHandle 是事件队列句柄;
// Dispatch 是 trait,用户必须为关心的协议对象实现它以接收事件回调。
use wayland_client::{Connection, Dispatch, QueueHandle};
// 示例用的极简 state:无字段。Wayland 客户端需要至少一个 state 类型作为
// Dispatch trait 的 `Self`,这里就用零大小类型 `Ls`list globals 的缩写)。
struct Ls;
// 为 Ls 实现 WlRegistry 的 Dispatch:本示例只需枚举 globals,不需要响应任何
// registry 事件,因此 event 函数留空。wayland-client 要求即便不处理事件也必须
// 实现 Dispatchtrait contract 强制),否则 `registry_queue_init::<Ls>` 无法编译。
//
// Go 类比:类似 `type Ls struct{}` + `func (Ls) HandleEvent(...) {}`——
// 显式声明"我接收事件但不响应"。
impl Dispatch<WlRegistry, GlobalListContents> for Ls {
// 所有参数加 `_` 前缀表示本实现不读取任何参数(Rust 中 `_x` 与 `x` 区分:
// 前者显式标记未使用,避免 dead_code 警告)。
fn event(
_state: &mut Self,
_registry: &WlRegistry,
@@ -17,10 +49,25 @@ impl Dispatch<WlRegistry, GlobalListContents> for Ls {
}
}
// 程序入口。Rust 的 `fn main()` 不能返回 `Result`(标准约定),故用 `.unwrap()`
// 简单 panic;示例程序通常省略错误处理以突出主线逻辑。
fn main() {
// 从 `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` 环境变量建立与 compositor 的 socket 连接。
// 类比 Go 的 `net.Dial("unix", path)`。`.unwrap()` 在连接失败时 panic(示例代码约定)。
let conn = Connection::connect_to_env().unwrap();
// registry_queue_init 是 wayland-client 的高层辅助:内部发送 sync request 并阻塞
// 直到 registry 全部 global 事件到达。返回 (GlobalList, EventQueue)。
// `::<Ls>` 是 turbofish 显式指定 state 类型,对应上面 `impl Dispatch for Ls`。
// 类比 Go 的 `globals, queue := wayland.RegistryQueueInit[Ls](conn)`(泛型实例化)。
let (globals, _queue) = registry_queue_init::<Ls>(&conn).unwrap();
// 遍历所有已收集的 globals。`globals.contents()` 返回内部快照引用,
// `.clone_list()` 复制成 `Vec<GlobalListEntry>`(每个 entry 含 name/interface/version)。
// 类比 Go `for _, g := range globals { ... }`——Rust 的 `for ... in` 直接消费迭代器。
for g in globals.contents().clone_list() {
// `println!` 是 Rust 标准宏(不是函数),类比 Go `fmt.Printf("%d: %s v%d\n", ...)`。
// `{}` 自动调用参数的 `Display` traitname 是 u32、interface 是 String、version 是 u32。
println!("{}: {} v{}", g.name, g.interface, g.version);
}
}
+45
View File
@@ -1,11 +1,41 @@
//! XDG Portal 权限冒烟测试示例。
//!
//! 本示例演示完整的 Portal ScreenCast 授权流程,分四步:
//! 创建 Screencast proxy,再创建 session,然后选择源(显示器/窗口),
//! 最后 start() 触发系统授权对话框(用户点击"共享"后返回流信息)。
//!
//! 运行:`cargo run --example test_portal`(参见 AGENTS.md "Useful manual commands")。
//!
//! Rust 异步模型(与 Go 对比):
//! - Go 用 goroutine + channelasync 函数本身**惰性**,需 runtime 驱动。
//! - 本示例刻意**不用** `#[tokio::main]` 宏,而是手动 `Runtime::new()` + `block_on`
//! (与 src/backend_detect.rs 同款"手动 runtime"模式);这是因为 ashpd 内部缓存
//! zbus::Connection 到全局 OnceLock,若宏自动建的 runtime 被 drop
//! 缓存的 connection 会"僵尸化"导致后续 hang(详见 AGENTS.md)。
//!
//! 对照 Go`go func() { ... }()` ≈ `tokio::spawn(async { ... })`
//! 而 `block_on` 类似 Go 的 `select {}` 阻塞 main goroutine 等待退出。
// ashpd = XDG Portal 的 Rust 高层绑定,封装了 D-Bus ScreenCast 接口
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
// PersistMode 控制"恢复令牌"持久化级别(DoNot / Persistent / ExplicitlyRevoked
use ashpd::desktop::PersistMode;
// BitFlags = 位域集合类型(一个值可同时包含多个 SourceType,类比 Go 的 iota | 操作)
use ashpd::enumflags2::BitFlags;
// 同步 main → 手动创建 tokio Runtime → block_on 阻塞驱动 async 块。
// 这种"同步外壳 + 异步内核"的写法等价于 `#[tokio::main] async fn main()`
// 但保留了显式控制 runtime 生命周期的灵活性(参见文件头说明)。
fn main() {
// 手动创建 tokio runtime(含 reactor + executor + 时间驱动);
// unwrap() 仅示例用;生产代码应返回 Result 并 `?` 传播(但 fn main 不返回 Result
let rt = tokio::runtime::Runtime::new().unwrap();
// block_on 阻塞当前线程直到传入的 future 完成;这是同步↔异步边界
rt.block_on(async {
// async {} 块构造一个匿名 future,仅在 block_on poll 时才执行(惰性,与 goroutine 不同)
eprintln!("1. Creating Screencast proxy...");
// Screencast::new() 内部通过 D-Bus 连接 org.freedesktop.portal.ScreenCast
// .await 让出执行权直到 future 就绪(Go 没有这个语法,需 channel/锁模拟)
let proxy = match Screencast::new().await {
Ok(p) => {
eprintln!(" OK");
@@ -13,11 +43,14 @@ fn main() {
}
Err(e) => {
eprintln!(" FAIL: {e}");
// early-return 仅退出 async 块(不是退出 main),block_on 返回 ()
return;
}
};
eprintln!("2. Creating session...");
// create_session 建立一个 ScreenCast 会话句柄;
// Default::default() 用类型默认参数(ashpd 推断为 SessionOptions,所有字段取 Default
let session = match proxy.create_session(Default::default()).await {
Ok(s) => {
eprintln!(" OK");
@@ -30,7 +63,15 @@ fn main() {
};
eprintln!("3. Selecting sources...");
// BitFlags<SourceType> 表达"可选多显示器/窗口/工作区"集合;
// 这里 `into()` 将单个 Monitor 转为位域(Go 类似 flag = 1 << iota
let sources: BitFlags<SourceType> = SourceType::Monitor.into();
// Builder 链式:每次 set_X 返回 &mut Self(类似 Go functional-options 模式但更显式)
// - cursor_mode Embedded:光标嵌入帧内
// - sources: 仅 Monitor(去掉窗口,简化授权 UX)
// - multiple=false:单选(一次只授权一个显示器)
// - persist_mode DoNot:不申请恢复令牌(避免持久权限残留)
// 整个 builder 链构造一个 future,末尾的 .await 等待 D-Bus 返回
let result = proxy
.select_sources(
&session,
@@ -50,6 +91,8 @@ fn main() {
}
eprintln!("4. Starting (should show dialog)...");
// start() 触发系统授权对话框(D-Bus 调用阻塞直到用户响应);
// 第二参数 parent_window = None(无父窗口,常见于 CLI 程序)
let response = match proxy.start(&session, None, Default::default()).await {
Ok(r) => {
eprintln!(" OK");
@@ -60,6 +103,8 @@ fn main() {
return;
}
};
// Portal D-Bus 响应是双层结构:外层是 Request::responseOk/Err),
// 内层才是 ScreenCast 流信息(streams() 返回 PipeWire 节点 + dmabuf 信息列表)
match response.response() {
Ok(r) => eprintln!(" Got {} stream(s)", r.streams().len()),
Err(e) => eprintln!(" Response error: {e}"),
+47
View File
@@ -1,35 +1,75 @@
//! CLI 参数定义模块(基于 `clap` derive 宏)。
//!
//! 本文件用 `clap` 的 derive 宏把一个普通 struct 变成命令行解析器,思路类
//! 似 Go 的 `flag` 包,但更贴近"struct tag 自动生成"——每个 `pub` 字段配
//! 一行 `#[arg(...)]` 属性宏,clap 在编译期据此生成 `-x` / `--xxx` 选项、
//! 帮助文案、默认值和类型校验。`#[derive(Parser, Debug, Clone)]` 三个
//! derive 的作用:
//! - `Parser`clap 的入口 trait,提供 `Args::parse()`,等价于 Go 里的
//! `flag.Parse()`
//! - `Debug`:支持 `{:?}` 调试打印;
//! - `Clone`:允许 `Args::clone()` 值复制(运行循环里会用到)。
//!
//! Rust ↔ Go 类型对照(本文件用到的):
//! - `Option<String>` ≈ Go `*string``None` 表示用户没传该 flag,等价于
//! `nil` 指针;`Some(s)` 表示传了;
//! - `String`(无 `Option`)≈ Go `string`:必有值,由 `default_value`
//! 兜底,所以运行期不会空;
//! - `u32` / `u64` / `u16` ≈ Go `uint32` / `uint64` / `uint16`
//! - `bool` ≈ Go `bool`,但 clap 把它当开关:出现即 `true`,不出现即
//! `false`,等价于 Go 里没有参数的 `flag.Bool`
//! - `default_value_t = 30` ≈ Go `flag.Int("fps", 30, "...")` 的第二个
//! 参数(默认值);
//! - `default_value = "h264"` 用于 `String` 字段,等价意思;
//! - `#[arg(short, long)]` 同时生成短选项(`-o`,取字段首字母)和长选项
//! `--output`);
//! - `#[arg(long)]` 只生成长选项 `--output-name`,没有短形式。
//!
//! 注意:`AGENTS.md` 明确指出 README 的 CLI 表对 `--backend` 和 `--no-persist`
//! 已过时,**以本文件为准**。
use clap::Parser;
// 根解析器 struct。下方 `#[command(...)]` 设置 `--help` 第一行的程序名和
// `about` 文案;注意不要在此 struct 上加 `///`,否则 clap 会把 doc 注释
// 注入 help 文案,可能覆盖 `about`,导致 byte-identical 不变量被破坏。
#[derive(Parser, Debug, Clone)]
#[command(name = "wl-webrtc", about = "Wayland screen capture and encoding tool")]
pub struct Args {
/// Output file path (e.g., output.mp4, output.mkv). Optional when using --port for WebRTC mode
#[arg(short, long)]
pub output: Option<String>,
// 输出文件路径(`-o`/`--output`)。`Option<String>` ≈ Go `*string``None` 表示用户没传
/// Wayland output name to capture
#[arg(long)]
pub output_name: Option<String>,
// 指定要抓取的 Wayland 输出(显示器)名;`None` 时由后端自动选主屏
/// Target frames per second
#[arg(long, default_value_t = 30)]
pub fps: u32,
// 目标帧率(`--fps`,默认 30)。`default_value_t = 30` ≈ Go `flag.Int("fps", 30, ...)`
/// Video codec (h264 only for MVP)
#[arg(long, default_value = "h264")]
pub codec: String,
// 视频编码器(`--codec`,默认 `h264`)。MVP 阶段只支持 H.264,对比 Go 里 owned 的 `string`
/// Hardware acceleration method (vaapi only for MVP)
#[arg(long, default_value = "vaapi")]
pub hw_accel: String,
// 硬件加速方式(`--hw-accel`,默认 `vaapi`),目前只接受 `vaapi`
/// DRM render device path (e.g., /dev/dri/renderD128)
#[arg(long)]
pub drm_device: Option<String>,
// DRM 渲染节点路径(如 `/dev/dri/renderD128`),VAAPI 上下文需要它;`None` 时自动探测
/// Target bitrate in bits per second
#[arg(long)]
pub bitrate: Option<u64>,
// 目标码率(bps)。`Option<u64>` ≈ Go `*uint64``None` 时编码器用内部默认码率
/// Maximum bitrate in bps for WebRTC mode. Caps BWE-driven escalation to
/// prevent large IDR bursts from swamping the network. Default 8 Mbps covers
@@ -37,28 +77,35 @@ pub struct Args {
/// See issue #23.
#[arg(long, default_value = "8000000")]
pub max_bitrate: u64,
// WebRTC 模式下的码率上限(默认 8 Mbps),抑制 IDR 突发造成网络拥塞;MP4 模式忽略
/// Group of Pictures (GOP) size
#[arg(long)]
pub gop_size: Option<u32>,
// GOP 长度(关键帧间距);`None` 时由编码器按内部策略自选
/// Enable verbose logging
#[arg(short, long)]
pub verbose: bool,
// 详细日志(`-v`/`--verbose`)。`bool` 在 clap 里是开关:出现即 `true`,等价 Go `flag.Bool`
/// Capture backend to use: 'screencopy' (wlroots) or 'portal' (KWin/KDE). Auto-detected if omitted
#[arg(long)]
pub backend: Option<String>,
// 抓屏后端(`screencopy` 或 `portal`);`None` 时由 `backend_detect.rs` 自动选择
/// Port for WebRTC HTTP signaling server; 0 keeps MP4 file output mode
#[arg(long, default_value_t = 0)]
pub port: u16,
// WebRTC HTTP 信令端口(`--port`,默认 0)。`0` 走 MP4 文件输出模式,`>0` 走 WebRTC 模式
/// Force re-authorization dialog (ignore saved portal restore token)
#[arg(long)]
pub no_persist: bool,
// 忽略已保存的 portal restore token,强制每次都弹授权对话框(测试时常用)
/// Enable per-second pipeline statistics output for stutter diagnosis
#[arg(long)]
pub stats: bool,
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
}
+557
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -1,3 +1,45 @@
//! # Wayland 截屏后端自动检测(`src/backend_detect.rs`
//!
//! 本文件负责检测当前 Wayland 桌面支持哪种屏幕捕获后端,由 [`detect_backend`]
//! 返回 [`CaptureBackend::WlrScreencopy`]wlroots 合成器:Sway/Hyprland 等,
//! 通过 `zwlr_screencopy_manager_v1` 协议直接交付 dmabuf,性能最好)或
//! [`CaptureBackend::PortalPipeWire`]XDG Portal + PipeWireKDE/GNOME 等,
//! 通过 D-Bus 调用 `org.freedesktop.portal.ScreenCast` 接口)。
//!
//! ## 检测优先级(见 [`detect_backend`]
//!
//! 1. 用户显式 `--backend portal|screencopy` 命令行参数覆盖;
//! 2. 自动检测:wlr-screencopy 优先(通过 Wayland globals 列表),否则回退到 Portal
//! (通过 D-Bus 查询 ScreenCast 接口的 `version` 属性 >=1 即视为可用)。
//!
//! ## 为什么用 raw `zbus` 而不是 `ashpd`**AGENTS.md 强约束**
//!
//! AGENTS.md 明确禁止在此文件使用 `ashpd` crate,原因是:
//! `ashpd` 内部把 `zbus::Connection` 缓存在一个全局 `OnceLock`。
//! 如果拥有该 connection 的 Tokio runtime 被 drop(例如本文件
//! [`check_portal_available`] 自建的临时 runtime 在函数返回时被 drop),
//! 缓存的 connection 会变成"僵尸"——后续 `setup_portal()` 复用时会永远 hang
//! 因为底层 `tokio::mpsc` 通道对端已死、但缓存仍报告"已初始化"。
//!
//! 因此本文件用 `zbus::connection::Builder::session()...build().await` 直接构造
//! 一条全新的、生命周期受当前 runtime 控制的连接,每次检测都重建。
//!
//! ## Go ↔ Rust 概念对照
//!
//! - `async fn` + `.await`Rust async 是**惰性的**async fn 返回 `impl Future`
//! 必须被 `.await` 或 `block_on` 才会真正执行),不同于 Go 的 `go f()` 立即并发。
//! - `tokio::runtime::Runtime::new()` + `rt.block_on(fut)`:从同步代码驱动 async
//! 类比 Go `runtime.GOMAXPROCS(1)` + `select { case <-done: }`。
//! - `tokio::time::timeout(d, fut).await` ≈ Go `context.WithTimeout(ctx, d)`
//! 返回 `Result<T, Elapsed>`,超时返回 `Err(Elapsed)`。
//! - `Result<T, E>` + `?` 操作符 ≈ Go `if err != nil { return err }` 的语法糖。
//! - `Option<T>` ≈ Go `*T`(指针可空),但 Rust 强制 `match`/`if let` 才能解引用。
//! - `tracing::info!("...{e}")` ≈ Go `log.Printf`,支持 Rust 1.58+ 的内联捕获格式化。
//! - `match { ... }` ≈ Go `switch`,但 Rust 强制穷尽所有分支(编译期检查)。
//! - `&mut T`(可变引用)≈ Go `*T`,但 Rust 编译期保证无别名(只有一个 mut 引用)。
//! - `move || { ... }` 闭包用 `move` 关键字显式捕获变量所有权(按值转移)。
//! - `'static` 生命周期约束 ≈ Go"对象不能持有栈指针"的隐式约定,但 Rust 编译期检查。
use std::time::Duration;
use anyhow::Result;
@@ -26,8 +68,15 @@ pub enum CaptureBackend {
/// 用于后端检测期间列举 Wayland 全局对象的最小化分发类型(无需实际处理事件)
struct RegistryLs;
// trait 分发:`Dispatch<WlRegistry, GlobalListContents> for RegistryLs` 表示
// "用 RegistryLs 作为状态对象、GlobalListContents 作为上下文数据来处理 WlRegistry 事件"。
// 类比 Go interface 的隐式满足,但 Rust trait 在编译期静态分发(generic 单态化),
// 即编译器为每个 (State, Event) 组合生成一份专属代码——零运行时开销。
// 为 RegistryLs 实现 Wayland 注册表事件分发(空实现,仅需类型满足 trait 约束)
impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
// `fn event` 是 Dispatch trait 必须实现的方法:每收到一个 Wayland 事件触发一次。
// 下划线前缀参数(`_state`、`_registry` 等):Rust 编译器允许声明但不使用,
// 类比 Go 中 `_ = ctx` 显式忽略变量;这里我们只关心类型满足 trait、不处理事件。
fn event(
_state: &mut Self,
_registry: &WlRegistry,
@@ -47,6 +96,10 @@ impl Dispatch<WlRegistry, GlobalListContents> for RegistryLs {
/// Portal 后端检测期间每个 D-Bus 操作的超时时间。
const PORTAL_DBUS_TIMEOUT: Duration = Duration::from_secs(5);
/// 当 Portal 在超时时间内无响应时,记录详细的错误日志(含 systemctl 重启建议)。
///
/// 这是一个辅助函数——调用方已经在超时路径上返回了 `false`,本函数仅负责打印提示。
/// 不返回 `Result`:日志写入失败本身不应该影响后端检测逻辑。
fn log_portal_unresponsive(operation: &str) {
tracing::error!(
"Portal service did not respond within 5s while {operation}. \
@@ -56,20 +109,54 @@ fn log_portal_unresponsive(operation: &str) {
);
}
/// 通过 D-Bus 检测 XDG Portal ScreenCast 接口是否可用。
///
/// 检测流程(每一步都有 5 秒超时保护,见 [`PORTAL_DBUS_TIMEOUT`]):
/// 1. 连接到 D-Bus session bus
/// 2. 构造 `org.freedesktop.portal.Desktop` 的 ScreenCast proxy
/// 3. 查询 ScreenCast 接口的 `version` 属性(>=1 即视为可用)。
///
/// 任何一步超时或失败都返回 `false`——上层 [`detect_backend`] 据此决定回退策略。
///
/// # 同步外壳 + 异步内核
///
/// `check_portal_available` 本身是同步 `fn`(被同步的 [`detect_backend`] 调用),
/// 但内部通过 `tokio::runtime::Runtime::new()` + `rt.block_on(async { ... })`
/// 桥接到 async `zbus` API。类比 Go`func check() bool { rt := NewRuntime(); defer rt.Close(); return rt.BlockOn(asyncFn()) }`。
fn check_portal_available() -> bool {
// 创建独立的 Tokio runtime:外层 `detect_backend` 是同步 `fn`,没有 async runtime
// 上下文,需要自建一个来驱动 `.await`。
// 类比 Go:每次调用 `runtime.GOMAXPROCS(1)` 启动一个临时调度器。
// **关键**:这个 runtime 在函数结束时 drop——这也是为什么不能用 ashpd
// ashpd 缓存 connection 到全局,runtime drop 后 connection 变僵尸,见文件头注释)。
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
// `tracing::warn!` 宏:结构化日志,类比 Go `log.Printf`
// 但支持 Rust 1.58+ 的 `{e}` 内联捕获格式化(变量名直接作占位符)。
tracing::warn!("Failed to create tokio runtime for portal check: {e}");
return false;
}
};
// `rt.block_on(future)`:在当前同步线程上驱动 future 到完成。
// 类比 Go`select { case <-done: }` 阻塞等待 goroutine 结束。
// 但 Rust 的 `block_on` 是单线程内 cooperatively 调度 future(除非 runtime 配 multi-thread)。
rt.block_on(async {
// `async { ... }` 块构造一个匿名 Future(类比 Go `func() {}` 闭包)。
// 注意:async 块是惰性的——只有 `.await` 或 `block_on` 才会真正执行体内代码。
// Set method_timeout on the connection (bounds method replies) and wrap
// the build itself in tokio::time::timeout (bounds connection setup).
// 同时设置 method_timeout 与外层 tokio::time::timeout 双重保护。
// `tokio::time::timeout(d, fut)` ≈ Go `context.WithTimeout(ctx, d)`
// 返回 `Result<T, Elapsed>`——超时返回 `Err(Elapsed)`。
let conn = match tokio::time::timeout(PORTAL_DBUS_TIMEOUT, async {
// `zbus::connection::Builder::session()` 是 Builder 模式:
// 类比 Go `&http.Client{Timeout: ...}` 用链式方法配置参数。
// `.expect("...")`:失败时 panic(类比 Go `log.Panic`),
// 只用于"不可能失败"的构造——这里 session bus builder 几乎不会失败。
// `.method_timeout(...)` 设置单个 D-Bus 方法调用的超时上限。
// `.build().await` 异步构造 Connection(涉及 D-Bus 握手)。
zbus::connection::Builder::session()
.expect("D-Bus session bus builder failed")
.method_timeout(PORTAL_DBUS_TIMEOUT)
@@ -78,17 +165,29 @@ fn check_portal_available() -> bool {
})
.await
{
// 嵌套 Result 解构:外层 `Result<Connection, Elapsed>`(来自 timeout),
// 内层 `Result<Connection, zbus::Error>`(来自 build)。
// `Ok(Ok(c)) => c` 是模式匹配的多层解构(destructuring)——
// 类比 Go `if err == nil && inner_err == nil { c := value }`。
Ok(Ok(c)) => c,
Ok(Err(e)) => {
tracing::info!("D-Bus session bus unavailable: {e}");
return false;
}
Err(_) => {
// `Err(_)` 中的 `_` 是通配符模式:匹配任意值并丢弃。
// 这里我们关心的是"超时了",不关心 `Elapsed` 的具体值。
log_portal_unresponsive("connecting to D-Bus session bus");
return false;
}
};
// `zbus::Proxy`D-Bus proxy 是远程对象的强类型句柄,封装 destination+path+interface。
// 类比 Go 中的 `dbus.ObjectProxy`:调用 `proxy.get_property(...)` 时
// 自动 marshal 成 D-Bus 消息发到目标对象。
// `Builder::new(&conn).destination(...).and_then(|b| b.path(...))` 链式构造:
// `and_then` 来自 `Result`,把 `Result<Builder, E>` 解开再继续链——
// 类比 Go `if b, err := b.X(); err != nil { return err } else { b.Y() }`。
let inner: zbus::Proxy = match zbus::proxy::Builder::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
@@ -111,6 +210,10 @@ fn check_portal_available() -> bool {
}
};
// 查询 ScreenCast 接口的 `version` 属性——这是最可能卡住的操作,
// 因为前两步只是本地构造 proxy,而 get_property 需要 Portal 端实际处理请求。
// `.get_property::<u32>("version")`:泛型方法,turbofish `::<u32>` 指定返回类型,
// 类比 Go `GetVersion() (uint32, error)`——但 Rust 用泛型 + 编译期单态化。
// The most likely operation to hang — requires actual Portal-side work.
// 最可能卡住的操作,需要 Portal 端实际处理。
let version = match tokio::time::timeout(
@@ -137,10 +240,26 @@ fn check_portal_available() -> bool {
}
// 通过 Wayland globals 检测 wlr-screencopy 协议是否可用
//
// Wayland globals 是合成器在连接建立时广播的"已支持协议"列表——
// 类比 Go 中的 HTTP OPTIONS:客户端连上服务器后先查询能力,再决定怎么说话。
// 我们只需检查列表里是否有 `zwlr_screencopy_manager_v1` 这个接口名即可。
fn check_screencopy_available() -> Result<bool> {
// `Connection::connect_to_env()?`:从 WAYLAND_DISPLAY 环境变量读取 socket 路径并连接。
// `?` 操作符:如果 `connect_to_env` 返回 `Err(e)`,立即把 `e` 转换为函数返回类型
// `anyhow::Result`),并 return 之。类比 Go `if err != nil { return err }`。
let conn = Connection::connect_to_env()?;
// `registry_queue_init::<RegistryLs>(&conn)?`turbofish `::<RegistryLs>` 指定
// 用我们刚定义的空 Dispatch 实现来接收 registry 事件。函数内部会 roundtrip
// 一次拿到所有 globals,返回 `(GlobalList, Queue)` 元组。
// `let (globals, _queue) = ...`:元组解构(tuple destructuring),
// 类比 Go `globals, queue := ...`,但 Rust 用 `_queue` 表示"我接收但不会用到"。
let (globals, _queue) = registry_queue_init::<RegistryLs>(&conn)?;
// 迭代器链式调用(zero-cost,编译期单态化):
// `.contents()` → `GlobalList``.clone_list()` → `Vec<Global>`
// `.iter()` → `Iterator<&Global>``.any(|g| ...)` → `bool`(短路求值)。
// `|g| g.interface == "..."` 是闭包(closure),类比 Go `func(g Global) bool { ... }`。
let has_screencopy = globals
.contents()
.clone_list()
@@ -171,7 +290,14 @@ fn check_screencopy_available() -> Result<bool> {
pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
// 1. Check explicit override
// 步骤 1:检查用户是否通过命令行参数显式指定了后端
// `if let Some(ref backend) = args.backend`:模式匹配 + `ref` 关键字。
// `args.backend` 类型是 `Option<String>``Some(ref backend)` 表示
// "如果是 Some,则把内部 String 的**引用**绑定到 backend"(不获取所有权)。
// 类比 Go `if args.Backend != nil { backend := args.Backend }`。
if let Some(ref backend) = args.backend {
// `backend.as_str()`:把 `&String` 转 `&str`(类比 Go string → []byte view)。
// `match backend.as_str() { ... }`Rust 的 match 对 `&str` 强制穷尽所有分支,
// 类比 Go `switch backend { case "portal": ...; default: ... }`,但没有隐式 fallthrough。
return match backend.as_str() {
"portal" => {
tracing::info!("Backend override: Portal/PipeWire");
@@ -182,7 +308,10 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
Ok(CaptureBackend::WlrScreencopy)
}
other => {
// `other` 是匹配模式变量:绑定未被前面 arm 命中的任意值(类比 Go `default`)。
// 未知后端名称,返回错误
// `anyhow::bail!("...", args)` 是宏(注意 `!`):立即构造 `anyhow::Error`
// 并从当前函数 return `Err`。类比 Go `return fmt.Errorf("...", ...)`。
anyhow::bail!("Unknown backend '{}'. Use 'screencopy' or 'portal'.", other);
}
};
@@ -193,11 +322,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
tracing::info!("Auto-detecting capture backend...");
// 检测 wlr-screencopy(通过 Wayland globals
// `check_screencopy_available()?` 末尾的 `?`:把 `Result<bool>` 解开——
// 成功取 bool,失败则立即 return `Err`(错误向上传播)。
let has_screencopy = check_screencopy_available()?;
// 检测 Portal(通过 D-Bus
// `check_portal_available()` 无 `?`:因为它返回的是 `bool` 而不是 `Result`
// 内部已经把所有错误吞掉并转为 `false`。
let has_portal = check_portal_available();
// 根据检测结果选择后端,screencopy 优先(性能更好、延迟更低)
// `match (has_screencopy, has_portal) { ... }`:元组匹配——同时匹配两个 bool。
// `(true, _)` 中的 `_` 是通配符:表示"任意值都匹配"。类比 Go `switch { case hasSC: ... }`。
// Rust 强制穷尽所有 (bool, bool) 组合,编译期检查,不能漏掉一个分支。
match (has_screencopy, has_portal) {
(true, _) => {
tracing::info!("Detected wlr-screencopy support → using WlrScreencopy backend");
@@ -217,11 +353,18 @@ pub fn detect_backend(args: &Args) -> Result<CaptureBackend> {
}
}
// `#[cfg(test)]` 属性:条件编译——`cargo build` 时这个 mod 不会被编译进二进制,
// 只有 `cargo test` 时才参与编译。这样发布产物零运行时开销。
// 类比 Go 中 `_test.go` 后缀的约定:测试代码与生产代码物理分离。
#[cfg(test)]
mod tests {
// `use super::*;`glob 导入(wildcard import),把父模块的所有 pub item 引入当前作用域。
// 类比 Go 中的 dot-import`. "pkg"`),但 Rust 限定在 `super::` 即父模块内。
// 这里用来在测试中直接访问 `detect_backend`、`CaptureBackend` 等。
use super::*;
// 测试辅助函数:构造指定后端参数的 Args 实例
// 注意:辅助函数不需要 `#[test]` 属性——它只是被测试函数调用的普通函数。
fn make_args(backend: Option<&str>) -> Args {
Args {
output: Some("test.mp4".to_string()),
@@ -242,11 +385,17 @@ mod tests {
}
// 测试:显式指定 portal 后端
// `#[test]` 属性:标记此函数为测试用例,`cargo test` 自动发现并执行。
// 测试函数约定:`fn name() {}` 无参数无返回值;panic 即测试失败。
#[test]
fn explicit_portal_backend() {
let args = make_args(Some("portal"));
let result = detect_backend(&args);
// `assert!(cond)` 宏:条件为 false 时 panic,类比 Go `if !cond { t.Fatal() }`。
assert!(result.is_ok());
// `assert_eq!(a, b)` 宏:断言相等,失败时打印两边内容,类比 Go `if a != b { t.Errorf() }`。
// `.unwrap()`:解开 Result——成功取内部值,失败 panic。
// 测试代码中常用 `unwrap()` 简化错误处理;生产代码应避免(用 `?` 替代)。
assert_eq!(result.unwrap(), CaptureBackend::PortalPipeWire);
}
@@ -265,6 +414,8 @@ mod tests {
let args = make_args(Some("magic"));
let result = detect_backend(&args);
assert!(result.is_err());
// `.unwrap_err()`:与 `unwrap()` 相反——解开 Err 中的错误值(如果 Ok 则 panic)。
// `.to_string()`:把 `anyhow::Error` 转为 `String`(用 Display 格式化)。
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unknown backend 'magic'"),
+155
View File
@@ -1,9 +1,40 @@
//! 软件编码流水线性能基准(独立二进制 `sw_encode_bench`)。
//!
//! ## 用途
//!
//! 测量"纯 CPU"屏幕采集编码流水线的端到端耗时,作为对照参考与 VAAPI 硬件编码
//! 基准 `vaapi_import_bench``src/bin/vaapi_import_bench.rs`)形成对比:
//! - 本文件:Portal 采集 → `mmap` 把 DMA-BUF 映射到用户态 → `sws_scale` 在 CPU
//! 上做 BGR0→YUV420P 颜色空间/缩放转换 → libx264/openh264 软件编码。
//! - 对照 `vaapi_import_bench.rs`Portal 采集 → `av_hwframe_map` 在 GPU 上做
//! 零拷贝格式转换 → VAAPI 硬件编码(GPU)。
//!
//! ## 输出
//!
//! 打印 mmap / sws_scale / encode 三段每帧平均耗时与总体 FPS,便于判断"软件路径"
//! 在当前硬件上能否达到 30 FPS 目标。AMD GPU 在某些驱动下不允许 CPU 读取 DMA-BUF
//! `mmap` 会失败——这正是 `vaapi_import_bench` 存在的意义。
//!
//! ## Rust ↔ Go 对照
//!
//! - `clap::Parser` derive 宏:类似 Go 的 `flag` 包,但在编译期生成解析代码。
//! - `std::time::Instant`:高精度单调时钟,等价于 Go 的 `time.Now()` + `time.Since()`。
//! - `crossbeam_channel::recv_timeout`:等价于 Go 的 `select { case <-time.After(): }`。
//! - 本文件大量使用裸 `unsafe` FFI 调用 FFmpeg C API;现有 21 处 unsafe 块均
//! 未标注 SAFETY 标记,本任务也不补充,仅在每个 unsafe 块上方加普通 `//`
//! 中文概述,说明"为什么必须 unsafe"。
//!
//! 用法:`cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4`
// sw_encode_bench.rs — Software encoding pipeline benchmark for screen capture
//
// Benchmarks: Portal capture -> mmap DMA-BUF -> sws_scale BGR0->YUV420P -> libx264 encode
//
// Usage: cargo run --bin sw_encode_bench -- --output /tmp/bench_test.mp4
// 以下 `use` 语句分组:FFI 字符串/裸 fd 转换/路径/指针/计时 → anyhow/clap →
// ffmpeg_next 别名与 ffi → crate 内 Portal 采集器。Rust 没有 Go 的 "package"
// 概念,每个外部 crate 都要显式 `use`。
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
@@ -11,15 +42,24 @@ use std::ptr;
use std::time::Instant;
use anyhow::{bail, Result};
// `clap::Parser` derive 宏:编译期生成 CLI 解析代码,等价于 Go 的 `flag` 包
// 但支持子命令/类型转换/帮助文本自动生成。
use clap::Parser;
// FFmpeg 绑定,使用 `ffmpeg_next` crate(社区维护的 next 分支)。`as ff` 别名
// 缩短调用路径;`ffi` 子模块直接暴露 C ABI(裸指针、`AVFormatContext` 等)。
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
// 复用主程序的 `Args` 与 Portal 采集器:基准与主二进制共享同一采集代码路径,
// 仅"消费方"不同(基准直接落盘,主程序走 WebRTC 推流)。
use wl_webrtc::args::Args;
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
// 基准 CLI 参数定义。`#[derive(Parser, Debug)]` 让 clap 在编译期为 struct
// 生成 `parse()` 方法;`#[command(...)]` 设置程序元信息。等价于 Go 程序的
// `flag.StringVar(...)` 序列,但在 Rust 里完全声明式。
#[derive(Parser, Debug)]
#[command(
name = "sw_encode_bench",
@@ -39,6 +79,9 @@ struct BenchArgs {
enc_height: u32,
}
// 帧级耗时统计容器。每帧把 mmap/sws_scale/encode/total 的微秒数 push 进 Vec
// 结束后用 `avg_ms` 算平均值。这是"简单算术 + Vec"模式,比 streaming stats
// 复杂但能保留分布信息(虽然本基准只打印均值)。Go 类似 `[]int64`。
#[derive(Default)]
struct FrameStats {
mmap_us: Vec<u64>,
@@ -48,7 +91,12 @@ struct FrameStats {
mmap_failures: u32,
}
// 关联函数(不是 method——没有 `&self`/`&mut self` receiver),类似 Go 的
// package-level helper function。Rust 把它放在 `impl FrameStats` 内是组织习惯,
// 也可以写成自由函数 `fn avg_ms(...)`。
impl FrameStats {
// 把 Vec<u64> 求和后除以元素数得到微秒均值,再除以 1000 转毫秒。空 Vec
// 返回 0.0 避免除零。注意 Rust 这里 `as f64` 是显式转换(不像 Go 的隐式)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -57,12 +105,18 @@ impl FrameStats {
}
}
// 把 `ffmpeg_next` 的高级 Pixel 枚举转换为 FFmpeg C API 期望的原始
// `AVPixelFormat`i32 别名)。`Into::into` 在此处零成本——编译期已知映射。
fn pix_fmt(p: ff::format::Pixel) -> ffi::AVPixelFormat {
Into::<ffi::AVPixelFormat>::into(p)
}
// 从 Portal channel 拉取首帧:阻塞等待 PipeWire 推送 DMA-BUF。
// 同时监控控制 channel(流结束/格式变更/错误)。Go 类比:
// `for { select { case f := <-frameCh: return f; case <-time.After(10*time.Second): ... } }`
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
loop {
// `try_recv` 非阻塞地检查控制 channel 是否有事件(流结束/错误/格式变更)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
@@ -70,6 +124,7 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
// `recv_timeout` 阻塞最多 10s 等首帧。三路分支处理 Ok/Timeout/Disconnected。
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
@@ -85,7 +140,13 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
}
}
// 程序入口。流程四阶段:[1/4] 申请 Portal 授权并连接 PipeWire[2/4] 等首帧
// 拿到 DMA-BUF 元数据(宽高/stride/fd);[3/4] 试 mmap 一帧验证 CPU 可读;
// [4/4] 配置 libx264 编码器 + FFmpeg 输出格式上下文,进入主采集编码循环并打印统计。
// `anyhow::Result<()>` 把所有错误用 `?` 传播到 main 顶层——Rust 的 main 可以返回
// Result,运行时打印错误并退出码非零,类似 Go 1.0 时代 `log.Fatal` 的现代等价物。
fn main() -> Result<()> {
// clap 生成的 `BenchArgs::parse()` 解析 argv;类型不符直接 panic 退出。
let bench_args = BenchArgs::parse();
println!("=== Software Encode Benchmark ===");
@@ -97,11 +158,14 @@ fn main() -> Result<()> {
);
println!();
// 初始化 FFmpeg 全局状态(注册编解码器、协议等)。`?` 在 Result 上传播错误。
ff::init()?;
println!("[1/4] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 复用主二进制的 `Args` struct 来构造 Portal 请求;hw_accel="vaapi" 只是为了
// 走到 VAAPI 兼容的 DRM 设备路径(本基准并不会真正调用 VAAPI)。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
@@ -119,12 +183,15 @@ fn main() -> Result<()> {
stats: false,
};
// `CapPortal::new` 会触发 XDG Portal 授权对话框(用户需要在屏幕共享对话框里选屏)。
let cap = CapPortal::new(&portal_args)?;
println!("[1/4] Portal connected, PipeWire stream active\n");
println!("[2/4] Waiting for first frame from PipeWire...");
let first_frame = receive_first_frame(&cap)?;
// PipeWire 推来的首帧携带了 DMA-BUF 的元数据:fd(文件描述符)+ offset
// + stride(每行字节数)+ width/height/format。后续 mmap 就靠这些。
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_stride = first_frame.stride;
@@ -142,6 +209,9 @@ fn main() -> Result<()> {
println!("[3/4] Testing mmap on DMA-BUF...");
let mmap_size = (src_stride as usize) * (src_height as usize);
// unsafe #1:调用 libc::mmap 把 DMA-BUF fd 映射到用户态地址空间。FFI 之所以
// 必须 unsafemmap 接受 void* 返回 raw 指针,编译器无法验证其有效性;
// 调用方必须保证 fd 真的是有效的 DMA-BUF 且 PROT_READ 权限匹配。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -153,6 +223,8 @@ fn main() -> Result<()> {
)
};
// `MAP_FAILED` 是 mmap 失败的哨兵值(不是 NULL)。AMD 某些驱动禁止 CPU 读
// DMA-BUF,必须改用 VAAPI 硬件路径——这就是 `vaapi_import_bench.rs` 的意义。
if mmap_ptr == libc::MAP_FAILED {
let errno = std::io::Error::last_os_error();
bail!(
@@ -173,6 +245,8 @@ fn main() -> Result<()> {
"[3/4] mmap SUCCESS — CPU can read DMA-BUF ({:.1} MB)\n",
mmap_size as f64 / 1024.0 / 1024.0
);
// unsafe #2:解除映射。FFI 调用必须 unsafe——libc::munmap 接受 raw pointer
// 编译期无法保证 ptr 真的来自之前 mmap 的同一区域(不匹配会 UB)。
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
@@ -180,10 +254,15 @@ fn main() -> Result<()> {
// Set up libx264 encoder via FFI (same pattern as avhw.rs)
println!("[4/4] Setting up libx264 encoder...");
// 输出路径转 C 字符串(FFmpeg C API 期望 `const char*`,不接受 Rust &str)。
// CString 保证结尾有 NUL 字节,调用方必须保证字符串内部不含 NUL。
let output_path = Path::new(&bench_args.output);
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// Try libx264 first (best quality/speed), fall back to openh264
// 查找软件 H.264 编码器:优先 libx264(最快/质量最好),缺失则 fallback openh264。
// Rust 的 `or_else` + `ok_or_else` 是 Result/Option 链式习惯,类似 Go 的
// 多次 if err != nil 但不嵌套。
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
@@ -191,11 +270,13 @@ fn main() -> Result<()> {
})?;
println!("[4/4] Using encoder: {}\n", codec.name());
// 创建 FFmpeg 编码器 Context 并提取 video encoder 句柄。`enc.open()` 会在后面调用。
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器基础参数:分辨率/像素格式/时基/GOP。`time_base = 1/60` 表示一帧 = 1/60 秒。
enc.set_width(enc_width);
enc.set_height(enc_height);
enc.set_format(ff::format::Pixel::YUV420P);
@@ -205,6 +286,9 @@ fn main() -> Result<()> {
let codec_name = codec.name();
if codec_name == "libx264" {
// unsafe #3:调用 FFmpeg 的 `av_opt_set` 设置 libx264 的私有 preset/tune 选项。
// FFI 必须 unsafe:接受 `*const c_char` 裸指针,编译期无法验证指针指向有效内存,
// 也无法保证 priv_data 字段确实属于 libx264(其它编码器会 UB)。
unsafe {
let key = CString::new("preset").unwrap();
let val = CString::new("veryfast").unwrap();
@@ -219,7 +303,11 @@ fn main() -> Result<()> {
let mut enc_video = opened.0;
// Create output format context via FFI
// FFmpeg 输出格式上下文:根据文件扩展名(如 .mp4)自动推断容器。
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// unsafe #4`avformat_alloc_output_context2` 接受 out-pointer 模式(C 风格返回
// 指针的指针)。FFI 必须 unsafe:编译期无法验证 fmt_ctx_ptr 可写、不能保证
// 调用方传入了正确的容器格式猜测。
let ret = unsafe {
ffi::avformat_alloc_output_context2(
&mut fmt_ctx_ptr,
@@ -232,21 +320,31 @@ fn main() -> Result<()> {
bail!("Failed to allocate output format context: error {ret}");
}
// unsafe #5:在 fmt_ctx 内创建一条新流(mp4 容器内的一条视频 track)。
// 返回的 `stream_ptr` 是裸指针,调用方负责不 double-freeFFmpeg 内部托管)。
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream");
}
// unsafe #6:把编码器参数(分辨率/时基/像素格式)拷贝到流的 codecpar 字段。
// FFmpeg C API 允许裸指针字段写入(`(*stream_ptr).codecpar`),编译期无法验证
// 两个上下文确实兼容(同 codec、同 pixel format),调用方需自己保证。
let ret =
unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) };
if ret < 0 {
bail!("Failed to copy encoder parameters: error {ret}");
}
// unsafe #7:直接通过裸指针写字段:把编码器的 time_base 复制到流,避免后续
// mux 时再 rescale。FFI 必须 unsafe——`(*stream_ptr).time_base = ...` 是 C 风格
// 的指针解引用赋值,编译期无法验证 stream_ptr 仍存活。
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// unsafe #8`avio_open` 打开输出文件的 IO 上下文。FFI 必须 unsafe:编译期
// 无法验证 fmt_ctx_ptr->pb 字段可写、不能保证文件路径可写(运行时才报错)。
let ret = unsafe {
ffi::avio_open(
&mut (*fmt_ctx_ptr).pb,
@@ -261,17 +359,27 @@ fn main() -> Result<()> {
);
}
// unsafe #9:写容器头(mp4 的 ftyp box 等)。FFI 必须 unsafe:调用顺序约束
// (必须在 avio_open 之后、第一帧之前)由调用方维护,编译期不验证。
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write header: error {ret}");
}
// unsafe #10`Output::wrap` 把 C 指针包装成 Rust 类型——FFI 边界。
// unsafe 必须:调用方保证 fmt_ctx_ptr 在此后由 Rust 独占管理(FFmpeg C 代码
// 不能再 free 它,否则 double-free)。这是 `unsafe impl Send` 在 avhw.rs 中
// 同款的"独占所有权"约定。
let mut octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// Create sws_scale context: BGRZ (BGR0) -> YUV420P
// sws_scale 是 FFmpeg 的颜色空间转换器(CPU 软件)。本基准的"软件路径"核心:
// 把 DMA-BUF 的 BGR0 像素数据转成 libx264 期望的 YUV420P planar 格式。
let bgr0_fmt = pix_fmt(ff::format::Pixel::BGRZ);
let yuv420p_fmt = pix_fmt(ff::format::Pixel::YUV420P);
// unsafe #11`sws_getContext` 创建转换器。FFI 必须 unsafe:返回 raw 指针,
// 调用方负责后续 `sws_freeContext` 释放(cleanup 阶段会做)。
let sws_ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
@@ -291,14 +399,20 @@ fn main() -> Result<()> {
}
// Allocate reusable YUV frame
// 预分配一个 YUV420P 帧,循环里反复写入(避免每帧 malloc)。FFmpeg C API 要求
// 显式 alloc/get_buffer/free 三步——Rust 端无法用 RAII 自动管理,必须 unsafe。
let mut yuv_frame = unsafe {
// unsafe #12`av_frame_alloc` 只分配 struct 本体,不分配 data 缓冲区。
let mut f = ffi::av_frame_alloc();
if f.is_null() {
bail!("av_frame_alloc failed");
}
// unsafe #13:通过裸指针写入 width/height/format 字段。
(*f).width = enc_width as i32;
(*f).height = enc_height as i32;
(*f).format = yuv420p_fmt as i32;
// unsafe #14`av_frame_get_buffer` 根据 width/height/format 分配实际像素缓冲区。
// 失败时必须 free 已分配的 struct(避免泄漏)。
let ret = ffi::av_frame_get_buffer(f, 0);
if ret < 0 {
ffi::av_frame_free(&mut f);
@@ -314,12 +428,16 @@ fn main() -> Result<()> {
println!("=== Encoding {} frames ===\n", bench_args.frames);
// 统计容器初始化。`Instant::now()` 是单调时钟(不受系统时间调整影响),
// 类比 Go 的 `time.Now()`,但 Rust 的 Instant 设计上不允许"墙上时钟"用途。
let mut stats = FrameStats::default();
let total_start = Instant::now();
let mut frames_encoded: u32 = 0;
let mut pts: i64 = 0;
// 主采集编码循环:每帧从 PipeWire 拉帧 → mmap → sws_scale → send_frame → drain。
while frames_encoded < bench_args.frames {
// 控制通道优先检查(流结束/错误)。`try_recv` 非阻塞返回 Result<Option<T>>。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => {
@@ -334,6 +452,7 @@ fn main() -> Result<()> {
}
}
// 5s 超时拉帧。任何错误(超时/断开)都视为流终止,跳出循环。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
@@ -345,10 +464,14 @@ fn main() -> Result<()> {
}
};
// 帧级别计时:本轮 mmap/scale/encode 的总耗时统计锚点。
let frame_start = Instant::now();
// ---- 第 1 段:mmap DMA-BUF 到用户态 ----
let mmap_start = Instant::now();
let frame_size = (frame.stride as usize) * (frame.height as usize);
// unsafe #15:与首帧的 mmap 同语义——把 PipeWire 推来的 DMA-BUF fd 映射到
// 用户态。每帧都重新 mmap 是因为 fd 可能切换(Portal 可能用 buffer pool)。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -368,9 +491,16 @@ fn main() -> Result<()> {
}
stats.mmap_us.push(mmap_start.elapsed().as_micros() as u64);
// ---- 第 2 段:sws_scale BGR0 → YUV420P ----
let scale_start = Instant::now();
// unsafe #16`slice::from_raw_parts` 把裸指针+长度包成 Rust slice。
// 这是 Rust 最危险的 unsafe 之一:编译期无法验证 (ptr, len) 真的指向
// 有效内存、对齐正确、与 aliasing 规则兼容(不允许其它 &mut 同时存活)。
let src_data = unsafe { std::slice::from_raw_parts(mmap_ptr as *const u8, frame_size) };
// unsafe #17:调用 FFmpeg 的 sws_scale 做颜色空间转换。三个 FFI 风险:
// (1) 裸指针 src_ptr / src_linesize(2) yuv_frame->data/linesize 数组
// 必须有效;(3) sws_ctx 必须与 src/dst 像素格式匹配(不匹配会 UB)。
unsafe {
ffi::av_frame_make_writable(yuv_frame);
@@ -391,13 +521,18 @@ fn main() -> Result<()> {
.scale_us
.push(scale_start.elapsed().as_micros() as u64);
// unsafe #18:解除本帧的 mmap。FFI 必须 unsafe——ptr 必须仍是之前 mmap 的返回值。
unsafe {
libc::munmap(mmap_ptr, frame_size);
}
drop(frame);
// ---- 第 3 段:libx264 编码 ----
let encode_start = Instant::now();
// unsafe #19`avcodec_send_frame` 把一帧 YUV 喂给编码器(异步:内部入队)。
// FFI 必须 unsafe:裸指针 enc_video.as_mut_ptr()/yuv_frame;编译期无法
// 验证 enc 已 open、yuv_frame 的 width/height/format 与编码器配置一致。
unsafe {
(*yuv_frame).pts = pts;
pts += 1;
@@ -431,6 +566,8 @@ fn main() -> Result<()> {
let total_elapsed = total_start.elapsed();
println!("\nFlushing encoder...");
// unsafe #20:发 NULL frame 表示"flush"——编码器吐出剩余的延迟帧(B-frame 等)。
// 本基准 max_b_frames=0 所以没有延迟帧,但调用约定必须保留。
unsafe {
ffi::avcodec_send_frame(enc_video.as_mut_ptr(), ptr::null());
}
@@ -440,6 +577,8 @@ fn main() -> Result<()> {
.map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?;
// Cleanup
// unsafe #21:手动释放 yuv_frame 与 sws_ctx。FFmpeg C API 不支持 RAII
// 必须显式 free,否则内存泄漏。`as *mut _` 是为了取 *mut *mut AVFrame 引用。
unsafe {
ffi::av_frame_free(&mut yuv_frame as *mut _);
ffi::sws_freeContext(sws_ctx);
@@ -448,6 +587,8 @@ fn main() -> Result<()> {
drop(cap);
// Print results
// 结果汇总:把 mmap/scale/encode 三段均值 + 总 FPS 打印成表格。Go 类比
// `fmt.Printf`——Rust println! 是宏不是函数,编译期检查参数。
let mmap_count = stats.mmap_us.len() as u32;
let mmap_success_rate = if mmap_count + stats.mmap_failures > 0 {
mmap_count as f64 / (mmap_count + stats.mmap_failures) as f64 * 100.0
@@ -456,6 +597,7 @@ fn main() -> Result<()> {
};
let total_fps = frames_encoded as f64 / total_elapsed.as_secs_f64();
let avg_total_ms = FrameStats::avg_ms(&stats.total_us);
// 最大理论 FPS = 1000ms / 每帧均耗时。avg_total_ms 为 0 时跳过避免除零。
let max_fps = if avg_total_ms > 0.0 {
1000.0 / avg_total_ms
} else {
@@ -520,14 +662,21 @@ fn main() -> Result<()> {
Ok(())
}
// 从编码器 drain(抽取)已经编码好的压缩包并写入输出容器。FFmpeg 编码 API 是
// 异步的:`avcodec_send_frame` 入队原始帧,`avcodec_receive_packet` 出队 H.264
// NAL;可能 send 一帧后 receive 多包(关键帧场景),也可能 receive 返回 EAGAIN
// (编码器内部还在缓冲)。Go 类比:双 channel + select 循环,先收再吐。
fn drain_encoder(
enc_video: &mut ff::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// unsafe #22`avcodec_receive_packet` 出队一个 H.264 压缩包到 pkt。FFI 必须
// unsafe:编译期无法验证 enc_video 已 open、pkt.as_mut_ptr() 真指向空 packet。
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
// EAGAIN = 暂时没有更多包可吐(需要再 send);EOF = flush 完成。两者都退出。
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
@@ -536,13 +685,19 @@ fn drain_encoder(
}
let enc_tb = enc_video.time_base();
// unsafe #23:从 `(*octx.as_ptr()).streams` 取第一条流的 time_base,用于
// rescale 时间戳。FFI 必须 unsafe——裸指针 + `*streams.add(0)` 假定 streams
// 数组至少有一项(fmt_ctx 已注册至少一条流,否则前面 avformat_new_stream
// 就 bail 了)。
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
ff::Rational::from((*st).time_base)
};
// 把 PTS 从编码器时基 rescale 到流时基(mp4 容器要求)。Go 类比:单位换算。
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
// `write_interleaved` 让 FFmpeg 自动处理 interleaving(音视频交错,避免 demuxer 卡)。
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
+310
View File
@@ -1,48 +1,99 @@
//! # vaapi_import_bench — VAAPI DMA-BUF 导入性能基准
//!
//! 本文件是 wl-webrtc 项目下的**独立可执行二进制**(位于 `src/bin/`),用于离线
//! 测量 "Portal 屏幕捕获 → DMA-BUF 导入到 VAAPI 硬件帧 → GPU 下采样 → 编码" 这条
//! 关键流水线的端到端耗时,并与 "CPU 软编" 路径作对比,输出每阶段平均毫秒数与 FPS。
//!
//! ## 流水线
//!
//! - **CPU 路径**PipeWire BGRA 帧 → `sws_scale` 缩放 → libx264/libopenh264 软编
//! - **GPU 路径**PipeWire DMA-BUF → `av_hwframe_map` → `scale_vaapi` 滤镜 → VAAPI H.264
//!
//! ## 与 Go benchmark 的类比
//!
//! 类似 Go 的 `testing.B`:先跑预热帧,再用 `Instant::now()` / `Duration::as_micros()`
//! 采集每个阶段的耗时(导入、缩放、传输、编码),最后输出 `FrameStats` 平均值。
//!
//! ## 用法
//!
//! ```bash
//! cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
//! cargo run --bin vaapi_import_bench -- --output /dev/null --mode gpu
//! cargo run --bin vaapi_import_bench -- --output /tmp/cpu.mp4 --mode cpu --frames 120
//! ```
//!
//! 详见 `AGENTS.md` 的 "Useful manual commands" 章节。
// vaapi_import_bench.rs — VAAPI DMA-BUF import + GPU-side downscale benchmark
//
// Tests: Portal capture -> av_hwframe_map (ARGB sw_format) -> transfer -> sw encode
//
// Usage: cargo run --bin vaapi_import_bench -- --output /tmp/vaapi_bench.mp4
// ===== 标准库导入 =====
// CStringFFI 传递给 C 函数的 NUL 结尾字符串;类比 Go 中显式末尾 0 的 []byte
// AsRawFd trait:把 Rust 的 OwnedFd 暴露为原始 int fd(用于 DMA-BUF 导入)
// Path:跨平台路径类型;类比 Go filepath
// ptrFFI 裸指针工具(ptr::null_mut()、ptr::null()),类比 Go unsafe.Pointer(nil)
// Instant:高精度单调时钟;类比 Go time.Now(),用 elapsed() 取差值
use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::path::Path;
use std::ptr;
use std::time::Instant;
// ===== 第三方 crate =====
// anyhowResult<T> = Result<T, anyhow::Error>bail! 宏提前返回 Err;类比 Go (T, error)
// clapCLI 参数解析(Derive 宏);本文件 BenchArgs 与 args.rs Args 都用此模式
use anyhow::{bail, Result};
use clap::{Parser, ValueEnum};
// ffmpeg_nextFFmpeg 绑定。ffi 子模块是 raw C FFI(含 unsafe),其余为高层封装
// packet::Mut trait:提供 as_mut_ptr(),用于拿到 AVPacket* 喂给 C API
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut;
// 从本 crate (wl-webrtc) 复用:CLI Args、VAAPI 上下文、Portal 捕获
use wl_webrtc::args::Args;
use wl_webrtc::avhw::{import_dma_buf_to_vaapi, AvHwDevCtx, AvHwFrameCtx};
use wl_webrtc::cap_portal::{CapPortal, PwCtrlEvent};
/// 基准测试的 CLI 参数。Derive `Parser` 后 `BenchArgs::parse()` 即可从 argv 解析;
/// 类比 Go 中 `flag.StringVar` + `flag.Parse()`,但 Rust 用编译期宏生成代码。
///
/// 注意:与生产二进制 `wl-webrtc` 的 `Args`(见 `src/args.rs`)不同——这里是基准专用
/// 参数集(更细粒度的 enc_width/enc_height/mode),不复用 `Args`。
#[derive(Parser, Debug)]
#[command(name = "vaapi_import_bench", about = "VAAPI DMA-BUF import benchmark")]
struct BenchArgs {
// 输出文件路径。如果包含 "null" 子串则使用 FFmpeg 的 null muxer(不写盘,只测编码耗时)
#[arg(short, long)]
output: String,
// 总编码帧数;类比 Go benchmark 的 b.N,但这里是固定值(默认 60 帧)
#[arg(long, default_value_t = 60)]
frames: u32,
// 编码器输出宽(GPU 路径会下采样到该尺寸)
#[arg(long, default_value_t = 2560)]
enc_width: u32,
// 编码器输出高
#[arg(long, default_value_t = 1440)]
enc_height: u32,
// DRM 渲染节点路径;VAAPI 上下文绑定到此设备(Intel iGPU 通常是 renderD128
#[arg(long, default_value = "/dev/dri/renderD128")]
drm_device: String,
// 流水线模式:cpu 只跑软编;gpu 只跑 VAAPI;both 两条路径都跑并对比
#[arg(long, value_enum, default_value_t = PipelineMode::Both)]
mode: PipelineMode,
}
/// 流水线模式选择。Derive `ValueEnum` 后 clap 自动把 "cpu"/"gpu"/"both" 字符串
/// 映射到枚举值;Derive `Copy` 让它在 match 时按值复制(无需 & 引用)。
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum PipelineMode {
Cpu,
@@ -50,34 +101,54 @@ enum PipelineMode {
Both,
}
/// 单条流水线(CPU 或 GPU)运行结束后的统计聚合。每个 `Vec<u64>` 保存每帧的耗时(微秒)。
///
/// 类比 Go 的 `BenchmarkResult`:把 N 帧的逐次耗时收集起来,最后统一计算均值。
/// 用 `Vec<u64>` 而非流式累积是为了支持后续可能的中位数/分位数扩展。
#[derive(Default)]
struct FrameStats {
// DMA-BUF 导入耗时(仅 GPU 路径有,CPU 路径为空)
import_us: Vec<u64>,
// GPU 滤镜图耗时(仅 GPU 路径有)
filter_us: Vec<u64>,
// CPU 路径的 sws_scale 耗时
transfer_us: Vec<u64>,
// 预留:缩放耗时单独拆分(当前与 filter_us/transfer_us 重叠)
scale_us: Vec<u64>,
// 像素格式转换耗时(BGRA → YUV420P
format_us: Vec<u64>,
// 编码器 send_frame + drain_packet 总耗时
encode_us: Vec<u64>,
// 单帧总耗时(capture_start → encode_done),用于理论 FPS
total_us: Vec<u64>,
// 导入失败的次数(DMA-BUF fd 失效等)
import_failures: u32,
// 实际成功编码的帧数
frames_encoded: u32,
// 端到端墙钟耗时(从首帧到末帧),用于实测 FPS
elapsed_secs: f64,
// 编码器名称(libx264 / libopenh264 / h264_vaapi
codec_name: String,
// 输出路径(区分 cpu / gpu 文件名)
output_path: String,
}
impl FrameStats {
// 计算每帧耗时的均值(微秒 → 毫秒);空 Vec 返回 0.0 避免除零
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
}
// sum::<u64>() 显式指定求和类型,避免类型推导失败;类比 Go 的 for-range 累加
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
// 单帧总耗时的均值(毫秒),用于报告 "平均每帧 X ms"
fn avg_total_ms(&self) -> f64 {
Self::avg_ms(&self.total_us)
}
// 实测 FPS = 成功编码帧数 / 墙钟耗时;避免零除返回 0.0
fn achieved_fps(&self) -> f64 {
if self.frames_encoded > 0 && self.elapsed_secs > 0.0 {
self.frames_encoded as f64 / self.elapsed_secs
@@ -86,6 +157,7 @@ impl FrameStats {
}
}
// 理论 FPS = 1000 / 平均单帧总耗时(仅编码侧上限,不含 PipeWire 等待)
fn theoretical_fps(&self) -> f64 {
let avg = self.avg_total_ms();
if avg > 0.0 {
@@ -96,6 +168,11 @@ impl FrameStats {
}
}
/// CPU 软编路径的状态聚合体:编码器、输出容器、可复用的 YUV 帧。
///
/// 字段 `yuv_frame` 是裸指针 `*mut ffi::AVFrame`——因为 FFmpeg C API 要求长生命周期
/// 的可变指针,且需要 Drop 时显式释放。裸指针 `*mut T` 默认非 Send/Sync,但本结构体
/// 只在主线程使用,无需跨线程传递,因此无需手动 impl Send。
struct SoftwareEncoder {
enc_video: ff::codec::encoder::video::Video,
octx: ff::format::context::Output,
@@ -103,8 +180,11 @@ struct SoftwareEncoder {
codec_name: String,
}
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时由 Rust 自动调用,
// 避免裸指针 yuv_frame 泄漏。注意 Drop 内不能再使用 self.yuv_frame,只能释放
impl Drop for SoftwareEncoder {
fn drop(&mut self) {
// Drop trait 类比 Go 的 `defer cleanup()`:结构体析构时自动调用
// SAFETY: yuv_frame is allocated by av_frame_alloc in create_software_encoder and
// owned exclusively by this SoftwareEncoder.
unsafe {
@@ -113,10 +193,14 @@ impl Drop for SoftwareEncoder {
}
}
/// FFmpeg `sws_scale` 上下文的拥有型包装。Newtype 模式(tuple struct 单字段)让
/// Rust 类型系统追踪 C 资源的所有权,并通过 Drop 自动释放;类比 Go 中
/// `type SwsContext struct{ p *C.SwsContext }` + `func (s *SwsContext) Close()`。
struct SwsContext(*mut ffi::SwsContext);
impl Drop for SwsContext {
fn drop(&mut self) {
// sws_freeContext 接受 NULL 是安全的(C 规范),无需额外判空
// SAFETY: Context is either null or returned by sws_getContext and owned here.
unsafe {
ffi::sws_freeContext(self.0);
@@ -124,17 +208,28 @@ impl Drop for SwsContext {
}
}
/// 把 FFmpeg 错误码(负数)翻译成人类可读字符串。FFmpeg 的错误码没有官方码表,
/// 必须通过 `av_strerror` 拿到文本;类比 Go 中 `errno.String()` 或 `os.PathError.Err`。
fn av_err_to_string(ret: i32) -> String {
// 准备 128 字节缓冲区(FFmpeg 习惯用 128),由 av_strerror 写入 NUL 结尾的 C 字符串
let mut buf = vec![0u8; 128];
// 中文 unsafe 概述:av_strerror 最多写 128 字节并以 NUL 结尾;buf 是独占的可变 Vec<u8>
// as_mut_ptr 把缓冲区首字节暴露给 C,借用仅在这次调用期间有效。
unsafe {
ffi::av_strerror(ret, buf.as_mut_ptr() as *mut i8, buf.len());
}
// 找到首个 NUL 字节作为字符串末尾,再 from_utf8_lossy 容错转 String
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..end]).to_string()
}
/// 阻塞等待第一帧 PipeWire DMA-BUF 到达;类比 Go 的 `chan.Recv()` 配 `select`。
///
/// 同时监听控制通道(StreamEnded / FormatChanged / Error),任何错误都立即 `bail!`。
/// 超时 10 秒防止 GPU/驱动卡死导致基准测试无限挂起。
fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBufFrame> {
loop {
// 控制通道:非阻塞 try_recv(类比 Go `select { case e := <-ctrl: ... default: }`
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => bail!("PipeWire stream ended before first frame"),
@@ -142,6 +237,8 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
PwCtrlEvent::Error(e) => bail!("PipeWire error: {e}"),
}
}
// 帧通道:阻塞等待最多 10 秒
// 类比 Go `select { case f := <-frame: ... case <-time.After(10*time.Second): bail! }`
match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(10))
@@ -157,21 +254,31 @@ fn receive_first_frame(cap: &CapPortal) -> Result<wl_webrtc::cap_portal::PwDmaBu
}
}
/// 从编码器循环拉取已编码的 packet 并写入输出容器,直到编码器返回 EAGAIN/EOF。
///
/// "Drain" 模式:调用 `avcodec_send_frame` 后必须连续 `avcodec_receive_packet` 直到
/// EAGAIN,否则编码器内部缓冲区会堵塞,下一帧 send_frame 会失败。
fn drain_encoder(
enc_video: &mut ff::codec::encoder::video::Video,
octx: &mut ff::format::context::Output,
) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// 中文 unsafe 概述:enc_video.as_mut_ptr() 指向已打开的编码器上下文;pkt.as_mut_ptr()
// 指向空 packetFFmpeg 会在此调用中分配 packet 数据。
let ret = unsafe { ffi::avcodec_receive_packet(enc_video.as_mut_ptr(), pkt.as_mut_ptr()) };
if ret < 0 {
// EAGAIN = 编码器还需要更多输入帧;EOF = 已 flush;两者都是正常终止
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
eprintln!("avcodec_receive_packet failed: {ret}");
break;
}
// 把 PTS 从编码器时间基重缩放为输出流的时间基(视频流可有不同 time_base)
let enc_tb = enc_video.time_base();
// 中文 unsafe 概述:octx.as_ptr() 指向有效的 AVFormatContextstreams 数组至少有一个流
// (在 create_software_encoder 中由 avformat_new_stream 创建)。
let stream_tb = unsafe {
let streams = (*octx.as_ptr()).streams;
let st = *streams.add(0);
@@ -179,14 +286,28 @@ fn drain_encoder(
};
pkt.rescale_ts(enc_tb, stream_tb);
pkt.set_stream(0);
// write_interleaved 让 FFmpeg 自动按 DTS 排序,避免手动管理 PTS/DTS
pkt.write_interleaved(octx)
.map_err(|e| anyhow::anyhow!("write packet failed: {e}"))?;
}
Ok(())
}
/// 初始化 libx264/libopenh264 软编码器 + 输出容器(MP4/null muxer+ 可复用 YUV420P 帧。
///
/// 这是基准 CPU 路径的核心装配函数,步骤依次为:
/// 1. 寻找 codeclibx264 优先,libopenh264 回退)
/// 2. 创建 encoder contextbuilder 模式)
/// 3. 设置 width/height/fps/time_base/GOP
/// 4. libx264 专属)设置 preset/tune
/// 5. 打开编码器
/// 6. 分配 AVFormatContext + 创建流 + 复制 codec parameters
/// 7. 打开输出文件(除非 null muxer)+ 写文件头
/// 8. 分配可复用的 YUV420P 帧(在每帧 encode 时复用)
fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Result<SoftwareEncoder> {
// CString 必须在 unsafe 块外构造,确保 NUL 结尾的字符串生命周期覆盖下面的 FFI 调用
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// 优先 libx264(性能最好,GPL 协议),其次 libopenh264BSD,回退方案)
let codec = ff::encoder::find_by_name("libx264")
.or_else(|| ff::encoder::find_by_name("libopenh264"))
.ok_or_else(|| {
@@ -194,18 +315,24 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
})?;
let codec_name = codec.name().to_string();
// 两阶段构造:先 Context::new_with_codec 拿到 builder,再 .encoder().video()? 切到视频编码视图
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
};
// 编码器参数:分辨率、像素格式、时基、GOP 结构
enc.set_width(width);
enc.set_height(height);
enc.set_format(ff::format::Pixel::YUV420P);
// time_base = 1/60,与基准测试默认 60 FPS 对齐;生产代码里通常从源流继承
enc.set_time_base(ff::Rational::new(1, 60));
// 关闭 B 帧以降低延迟(基准不追求压缩率)
enc.set_max_b_frames(0);
// GOP = 60:每 60 帧一个 I 帧(与 60 FPS 对齐 = 每秒一个 IDR 帧)
enc.set_gop(60);
// libx264 的私有参数 preset/tune 必须在 encoder 打开前通过 av_opt_set 设置到 priv_data
if codec_name == "libx264" {
// SAFETY: priv_data belongs to the not-yet-opened encoder context. Option strings are
// valid NUL-terminated C strings for the duration of each av_opt_set call.
@@ -219,9 +346,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
}
}
// 真正打开编码器(前面只是 builder 状态),此后 enc_video 进入 ready 状态
let opened = enc.open()?;
let enc_video = opened.0;
// 输出文件名含 "null" → 用 FFmpeg 内置 null muxer(不写盘),适合纯 CPU 基准
let use_null_muxer = output_path
.to_str()
.map(|s| s.contains("null"))
@@ -265,6 +394,7 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
bail!("Failed to copy codec parameters: error {ret}");
}
// AVFMT_NOFILE 表示该 muxer 不需要物理文件(如 null muxer),跳过 avio_open
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
unsafe {
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
@@ -285,9 +415,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
bail!("Failed to write header: error {ret}");
}
// 此后 octx 拥有 fmt_ctx_ptr,会在 Drop 时调用 avformat_free_context
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// 这个 yuv_frame 在每次 encode_yuv_frame 中复用(不重新分配),由 SoftwareEncoder::drop 释放
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
let yuv_frame = unsafe {
let mut f = ffi::av_frame_alloc();
@@ -313,28 +445,41 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
})
}
/// 根据 `PipelineMode` 在文件名中插入 `cpu` 或 `gpu` 后缀,让 both 模式下两条路径不互相覆盖。
///
/// 例:`/tmp/out.mp4` + `PipelineMode::Cpu` → `/tmp/out.cpu.mp4`。
/// `split=false` 或文件名含 "null" 时直接返回原路径(null muxer 不需要分裂)。
fn output_for_mode(base: &str, mode: PipelineMode, split: bool) -> String {
if !split || base.contains("null") {
return base.to_string();
}
let path = Path::new(base);
// match 在 Rust 中默认是穷尽的(编译器强制覆盖所有 enum 变体);
// 这里 Both 在调用前已被外层排除,用 unreachable!() 标记
let suffix = match mode {
PipelineMode::Cpu => "cpu",
PipelineMode::Gpu => "gpu",
PipelineMode::Both => unreachable!(),
};
// file_name 返回 Option<&OsStr>and_then + to_str 链式处理 None 情况
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(base);
// rsplit_once 类比 Go 的 strings.Cut:从右侧切分一次扩展名(保留 "a.b.c" 中的 "a.b" 与 "c"
let split_name = if let Some((stem, ext)) = file_name.rsplit_once('.') {
format!("{stem}.{suffix}.{ext}")
} else {
format!("{file_name}.{suffix}")
};
// with_file_name 保留父目录,只替换末尾文件名;to_string_lossy 容错 OsStr → &str
path.with_file_name(split_name)
.to_string_lossy()
.into_owned()
}
/// 创建 BGRA→YUV420P 的 swscale 上下文。`SwsContext` 是 CPU 路径的颜色空间/尺寸转换核心。
///
/// 第 7 个参数 `2` = bicubic 算法;FFmpeg 还提供 fast_bilinear(1) / bilinear(2) /
/// lanczos(16) 等。基准选 bicubic 是平衡速度与质量。
fn create_sws_context(
src_width: u32,
src_height: u32,
@@ -342,6 +487,7 @@ fn create_sws_context(
dst_width: u32,
dst_height: u32,
) -> Result<SwsContext> {
// 返回的 *mut SwsContext 由 SwsContext 包装并在 Drop 中通过 sws_freeContext 释放。
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
let ctx = unsafe {
ffi::sws_getContext(
@@ -363,11 +509,15 @@ fn create_sws_context(
Ok(SwsContext(ctx))
}
/// 把已填好 YUV420P 数据的 `encoder.yuv_frame` 送入编码器,并 drain 已编码 packet。
/// 返回编码阶段的耗时(微秒),用于 `FrameStats::encode_us` 统计。
fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64> {
// 类比 Go time.Now();用 as_micros() as u64 转 u64u128 截断不影响 60s 量级基准)
let t_encode = Instant::now();
// SAFETY: yuv_frame is allocated, writable, and formatted as the encoder's configured
// YUV420P input frame. FFmpeg consumes but does not take ownership.
unsafe {
// 单调递增的 PTSFFmpeg 要求 PTS 必须按 time_base 单位递增,否则丢帧
(*encoder.yuv_frame).pts = *pts;
*pts += 1;
let r = ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), encoder.yuv_frame);
@@ -375,10 +525,14 @@ fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result<u64>
bail!("avcodec_send_frame failed: {r}");
}
}
// drain 编码器缓冲区(必须,否则下一帧 send_frame 会 EAGAIN
drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?;
Ok(t_encode.elapsed().as_micros() as u64)
}
/// 编码结束:发送 NULL frame 触发编码器 flushdrain 残余 packet,写入文件尾(trailer)。
///
/// 类比 Go 中 `io.Closer`:必须按顺序 (flush → drain → trailer) 才能产出可播放的文件。
fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
// SAFETY: Sending a null frame flushes the encoder; context remains owned by encoder.
unsafe {
@@ -392,6 +546,8 @@ fn finish_encoder(mut encoder: SoftwareEncoder) -> Result<()> {
Ok(())
}
/// 把 PipeWire 给的 DMA-BUF 帧导入 VAAPI 硬件帧上下文,返回 `ff::frame::Video`GPU 帧)。
/// 这是 GPU 路径的入口;耗时由 `FrameStats::import_us` 统计。
fn import_frame(
frames_ctx: &AvHwFrameCtx,
frame: &wl_webrtc::cap_portal::PwDmaBufFrame,
@@ -412,6 +568,10 @@ fn import_frame(
}
}
/// 构建 GPU 路径的 FFmpeg 滤镜图:`buffer`CPU 入口)→ `scale_vaapi`GPU 缩放+格式转换)→ `buffersink`。
///
/// 关键点:buffer 滤镜不能用 pix_fmt=VAAPI 直接初始化(FFmpeg 8+ 会拒绝),
/// 必须用 `av_buffersrc_parameters_set` 注入 hw_frames_ctx 才能让后续 VAAPI 滤镜识别。
fn build_gpu_filter_graph(
hw_dev: &AvHwDevCtx,
frames_rgb: &AvHwFrameCtx,
@@ -421,10 +581,13 @@ fn build_gpu_filter_graph(
enc_height: u32,
) -> Result<ff::filter::Graph> {
let mut graph = ff::filter::Graph::new();
// buffer = 滤镜图入口,从 AVFrame 注入数据
let buffersrc =
ff::filter::find("buffer").ok_or_else(|| anyhow::anyhow!("filter 'buffer' not found"))?;
// buffersink = 滤镜图出口,取出处理后的 AVFrame
let buffersink = ff::filter::find("buffersink")
.ok_or_else(|| anyhow::anyhow!("filter 'buffersink' not found"))?;
// scale_vaapi = VAAPI 硬件缩放 + 格式转换(BGRA→NV12)
let scale_vaapi = ff::filter::find("scale_vaapi")
.ok_or_else(|| anyhow::anyhow!("filter 'scale_vaapi' not found"))?;
@@ -449,14 +612,18 @@ fn build_gpu_filter_graph(
(*par).width = width as i32;
(*par).height = height as i32;
(*par).time_base = ffi::AVRational { num: 1, den: 60 };
// ref_clone 增加引用计数(AVBufferRef 共享底层 AVHWFramesContext),
// FFmpeg 内部会持有这个引用直到 buffersrc 释放
(*par).hw_frames_ctx = frames_rgb.ref_clone();
let ret = ffi::av_buffersrc_parameters_set(src_ctx.as_mut_ptr(), par);
// 只释放参数结构体本身;AVBufferRef 的引用由 buffersrc 持有,不能在这里 free
ffi::av_free(par as *mut _);
if ret < 0 {
bail!("av_buffersrc_parameters_set failed: error {ret}");
}
}
// scale_vaapi 滤镜参数:缩放到 enc_width×enc_height,输出 NV12VAAPI H.264 要求的输入格式)
let mut scale_ctx = graph.add(
&scale_vaapi,
"scale",
@@ -468,6 +635,7 @@ fn build_gpu_filter_graph(
}
let mut sink_ctx = graph.add(&buffersink, "out", "")?;
// 链接:in[0] → scale[0] → out[0]pad index 0 是默认输入/输出口
src_ctx.link(0, &mut scale_ctx, 0);
scale_ctx.link(0, &mut sink_ctx, 0);
graph
@@ -477,6 +645,16 @@ fn build_gpu_filter_graph(
Ok(graph)
}
// CPU 流水线主函数:跑 `frames` 帧测量每阶段耗时(import → transfer → scale → encode)。
// 与 GPU 路径的核心差异:CPU 路径**不经过 scale_vaapi 滤镜**,而是用 `av_hwframe_transfer_data`
// 把硬件帧"下载"到 CPU 内存(4K BGRA),再用 `sws_scale` 在 CPU 上做下采样到 2K YUV420P
// 因此 CPU 路径的"transfer"和"scale"耗时都明显高于 GPU 路径。
//
// 类比 Go benchmark:类似 `func benchCPU(b *testing.B) { for n := 0; n < b.N; n++ {...} }`
// 但 Rust 用 `while stats.frames_encoded < frames` 显式循环(无 testing.B 框架)。
//
// 参数:8 个参数(含 src/enc 尺寸 4 个)—— clippy 默认会嫌太多,故上方 `#[allow]` 抑制。
// 返回 `Result<FrameStats>`:任何 FFmpeg/Portal 失败立即 `?` 传播到 main。
#[allow(clippy::too_many_arguments)]
fn run_cpu_pipeline(
cap: &CapPortal,
@@ -488,7 +666,10 @@ fn run_cpu_pipeline(
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
// 构造软件编码器(libx264 或 libopenh264,取决于 create_software_encoder 内部 fallback)。
// `?` 自动把 anyhow::Error 上浮到调用者;类比 Go `if err != nil { return err }`。
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
// 构造 sws_scale 上下文:源 = 4K BGRA,目标 = 2K YUV420Psws_scale 内部完成下采样+色彩空间转换。
let sws_ctx = create_sws_context(
src_width,
src_height,
@@ -497,6 +678,8 @@ fn run_cpu_pipeline(
enc_height,
)?;
// println! 是宏(不是函数),类比 Go fmt.Println;第一参数是 format! 模板字符串。
// `{output}` 是内联格式化语法(Rust 1.58+),等价于 `format!("{}", output)`。
println!(
" Encoder: {}, {}x{} YUV420P",
encoder.codec_name, enc_width, enc_height
@@ -504,26 +687,40 @@ fn run_cpu_pipeline(
println!(" Output: {output}");
println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n");
// 构造 FrameStats,用 struct update 语法 `..FrameStats::default()` 让其余字段取 Default 值。
// 类比 Go `&FrameStats{Codec: codec, Output: out}` 仅设置 2 字段其余清零。
let mut stats = FrameStats {
codec_name: encoder.codec_name.clone(),
output_path: output.to_string(),
..FrameStats::default()
};
// 整条流水线总耗时起点;elapsed() 返回 Duration,后续 as_secs_f64() 取秒(float)。
let total_start = Instant::now();
// PTSPresentation Time Stamp,单位 = 编码器 time_base.den 的倒数)—— 单调递增的演示时钟。
// `let mut` 表示可变绑定(默认不可变,Rust 与 Go 的关键差异之一)。
let mut pts: i64 = 0;
// 主循环:直到编码完 `frames` 帧;类比 Go `for stats.FramesEncoded < frames {`。
while stats.frames_encoded < frames {
// try_recv 非阻塞从 PipeWire 控制通道取事件;Ok 表示有事件,Err(TryRecvError::Empty) 跳过。
// 类比 Go `select { case ev := <-ctrlCh: ... default: }`。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
// match 是穷尽性模式匹配(每个 enum variant 必须覆盖或用 `_` 兜底)。
match ctrl {
// 流正常结束(用户停止共享 / Portal 关闭):跳出主循环。
PwCtrlEvent::StreamEnded => break,
// PipeWire 报错:把帧号 + 错误信息 bail! 到调用者(bail! = return Err(anyhow!(...)))。
PwCtrlEvent::Error(e) => bail!(
"PipeWire error after {} CPU frames: {e}",
stats.frames_encoded
),
// 格式变化(分辨率/像素格式):本基准忽略,等下一帧自然到达。
PwCtrlEvent::FormatChanged { .. } => {}
}
}
// recv_timeout 阻塞最多 5 秒取下一帧;类比 Go `select { case f := <-frCh: ... case <-time.After(5*time.Second): }`。
// match 直接对 Result 解构:Ok(f) 拿到帧,Err(_)(超时或断开)直接 break 结束。
let frame = match cap
.frame_receiver()
.recv_timeout(std::time::Duration::from_secs(5))
@@ -532,30 +729,41 @@ fn run_cpu_pipeline(
Err(_) => break,
};
// 单帧起点:用于统计 total_us(包含所有子阶段)。
let frame_start = Instant::now();
// import 阶段起点:把 DMA-BUF 帧封装为 AV_PIX_FMT_VAAPI 硬件帧(av_hwframe_map 路径)。
let t_import = Instant::now();
// match 表达式对 Result 解构并支持多分支(含 guard 与错误处理)。
let vaapi_frame = match import_frame(frames_ctx, &frame) {
Ok(f) => f,
Err(e) => {
// 失败计数器自增;前 3 次打印到 stderr,避免日志淹没。
stats.import_failures += 1;
if stats.import_failures <= 3 {
eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded);
}
// continue 跳过本帧后续步骤(不是错误退出)。
continue;
}
};
// elapsed() 返回 Durationas_micros() → u128`as u64` 截断到 u64(帧耗时不会超 2^64 微秒)。
let import_us = t_import.elapsed().as_micros() as u64;
// transfer 阶段:用 av_hwframe_transfer_data 把硬件帧拷贝到 CPU 内存(4K BGRA)。
let t_transfer = Instant::now();
// SAFETY: sw_frame is allocated by FFmpeg and freed on all paths below.
let mut sw_frame = unsafe { ffi::av_frame_alloc() };
if sw_frame.is_null() {
// av_frame_alloc 返回 NULL 表示 OOM bail! 把错误抛到 main(不是 panic)。
bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded);
}
// av_hwframe_transfer_dataFFmpeg 提供的硬件→软件帧拷贝 APIsrc=VAAPIdst=CPU 内存帧。
// 第 3 参数 flags 通常传 0;返回 0 表示成功,负数表示 FFmpeg 错误码。
// SAFETY: sw_frame is an allocated destination; vaapi_frame is a valid VAAPI source frame.
let transfer_ret =
unsafe { ffi::av_hwframe_transfer_data(sw_frame, vaapi_frame.as_ptr(), 0) };
if transfer_ret < 0 {
// 错误路径必须 free,否则内存泄漏;FFmpeg C API 无 RAII。
// SAFETY: sw_frame was allocated above and has not been freed yet.
unsafe { ffi::av_frame_free(&mut sw_frame) };
bail!(
@@ -567,11 +775,15 @@ fn run_cpu_pipeline(
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
// scale 阶段:在 CPU 上把 4K BGRA 下采样到 2K YUV420PCPU 路径的瓶颈所在)。
let t_scale = Instant::now();
// SAFETY: sw_frame contains transferred BGRA data; encoder.yuv_frame is writable YUV420P
// at the configured output dimensions; sws_ctx converts and downscales between them.
unsafe {
// av_frame_make_writable:确保 yuv_frame 内部 buffer 可写(FFmpeg 引用计数可能共享)。
ffi::av_frame_make_writable(encoder.yuv_frame);
// sws_scalelibswscale 主接口;参数 = (ctx, src_slices[], src_stride[], src_y_start, src_h, dst_slices[], dst_stride[])。
// `(*sw_frame).data.as_ptr() as *const *const u8` 把 C 数组首地址转裸指针(FFmpeg AVFrame.data 是 [u8*; 8])。
ffi::sws_scale(
sws_ctx.0,
(*sw_frame).data.as_ptr() as *const *const u8,
@@ -583,12 +795,16 @@ fn run_cpu_pipeline(
);
}
let scale_us = t_scale.elapsed().as_micros() as u64;
// 缩放完成后立即释放中间 BGRA 帧(约 4K*2160*4 = 33MB),避免峰值内存。
// SAFETY: sw_frame was allocated above and is no longer needed after scaling.
unsafe { ffi::av_frame_free(&mut sw_frame) };
// encode 阶段:把 YUV420P 帧送入 libx264/openh264 编码器;返回编码单帧耗时(微秒)。
// `?` 把 anyhow::Error 传播到调用者;`&mut encoder` & `&mut pts` 都是可变借用。
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
// 把本帧的 5 个阶段耗时 push 到 Vec<u64>;后续 print_detailed_results 计算 avg_ms/p95。
stats.import_us.push(import_us);
stats.transfer_us.push(transfer_us);
stats.scale_us.push(scale_us);
@@ -596,6 +812,7 @@ fn run_cpu_pipeline(
stats.total_us.push(total_us);
stats.frames_encoded += 1;
// 节流打印:前 3 帧详打 + 之后每 30 帧打一次,避免日志淹没;`{:>4}` 右对齐 4 列宽。
if stats.frames_encoded <= 3 || stats.frames_encoded % 30 == 0 {
println!(
" CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms",
@@ -609,11 +826,24 @@ fn run_cpu_pipeline(
}
}
// flush 编码器(送 NULL frame 触发 EOS+ write_trailer + 关闭输出文件。
// 任何失败经 `?` 传播到 main。
finish_encoder(encoder)?;
// as_secs_f64 把 Duration 转为秒(f64),用于后续 FPS 计算。
stats.elapsed_secs = total_start.elapsed().as_secs_f64();
Ok(stats)
}
// GPU 流水线主函数:跑 `frames` 帧测量 GPU 路径每阶段耗时(import → filter → transfer → format → encode)。
// 与 CPU 路径的核心差异:GPU 路径用 `scale_vaapi` 滤镜**在硬件内**把 4K BGRA 下采样到 2K NV12
// 再用 `av_hwframe_transfer_data` 把**小**NV12 帧拷贝到 CPU 内存(数据量 = 4K BGRA 的 1/6),
// 最后用 `sws_scale` 做 NV12→YUV420P 的**纯格式转换**(无尺寸变化,比 CPU 路径快得多)。
//
// 性能对比的关键:
// - CPU 路径 transfer ~33MB + scale 33MB→2MBGPU 路径 transfer ~3MB + format 仅 NV12→YUV420P。
// - 因此 GPU 路径的 transfer/format 总耗时远低于 CPU 路径的 transfer+scale。
//
// 参数:9 个(比 CPU 多一个 hw_dev 用于 filter graph);同样用 `#[allow]` 抑制 clippy。
#[allow(clippy::too_many_arguments)]
fn run_gpu_pipeline(
cap: &CapPortal,
@@ -626,7 +856,11 @@ fn run_gpu_pipeline(
enc_width: u32,
enc_height: u32,
) -> Result<FrameStats> {
// 同 CPU 路径:构造软件编码器(最终编码阶段仍是 CPU 上的 libx264/openh264)。
// 注意:本基准目标是测 import/scale 性能,**不**测 VAAPI 硬件编码;所以两条路径都用软件编码器。
let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?;
// 这里命名为 format_ctx 而非 sws_ctx —— 因为它只做 NV12→YUV420P 的**色度重排**(无下采样)。
// 源/目标尺寸相同(enc_width × enc_height),sws_scale 内部走 fast path(无缩放,仅 deinterleave)。
let format_ctx = create_sws_context(
enc_width,
enc_height,
@@ -634,6 +868,8 @@ fn run_gpu_pipeline(
enc_width,
enc_height,
)?;
// 构造 GPU 滤镜图:bufferin (hw) → scale_vaapi → bufferout (hw),详见 build_gpu_filter_graph。
// 滤镜图在 GPU 显存里完成下采样,输出仍是 VAAPI 硬件帧。
let mut graph = build_gpu_filter_graph(
hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height,
)?;
@@ -654,6 +890,7 @@ fn run_gpu_pipeline(
let mut pts: i64 = 0;
while stats.frames_encoded < frames {
// 同 CPU 路径(详见 run_cpu_pipeline 的同位置注释)。
if let Ok(ctrl) = cap.event_receiver().try_recv() {
match ctrl {
PwCtrlEvent::StreamEnded => break,
@@ -687,23 +924,33 @@ fn run_gpu_pipeline(
};
let import_us = t_import.elapsed().as_micros() as u64;
// filter 阶段:把 VAAPI 4K 帧送入 scale_vaapi 滤镜图,取出 2K NV12 VAAPI 帧。
// 这是 GPU 路径相对 CPU 路径最大的性能优势所在。
let t_filter = Instant::now();
// graph.get("in").unwrap():按 name 取滤镜图的输入 pad;unwrap 在此是安全的(图刚构造必有 "in")。
let mut filter_src_ctx = graph.get("in").unwrap();
// source():从 pad 上下文获取发送端;后续 .add(&frame) 把帧送入图。
let mut filter_src = filter_src_ctx.source();
let mut filter_sink_ctx = graph.get("out").unwrap();
let mut filter_sink = filter_sink_ctx.sink();
// map_err 把 ffmpeg_next::Error 转换为 anyhow::Error(保持错误链可读)。
// anyhow::anyhow! 是宏,构造 ad-hoc 错误(类比 Go fmt.Errorf)。
filter_src
.add(&vaapi_frame)
.map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?;
// ff::frame::Video::empty():构造一个空视频帧(无 buffer),后续 filter_sink.frame() 填充。
let mut filtered = ff::frame::Video::empty();
// 三路 match:成功 / EAGAIN(图未就绪,需要更多输入帧)/ 真错误。
match filter_sink.frame(&mut filtered) {
Ok(()) => {}
// EAGAIN 表示滤镜图内部缓冲不足,跳过本帧不报错(next iteration 继续喂下一帧)。
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => continue,
Err(e) => bail!("GPU filter sink get frame failed: {e}"),
}
let filter_us = t_filter.elapsed().as_micros() as u64;
// transfer 阶段:把 2K NV12 硬件帧拷贝到 CPU 内存(数据量 = 2K NV12 ≈ 3MB,远小于 CPU 路径 33MB)。
let t_transfer = Instant::now();
// SAFETY: sw_nv12 is allocated by FFmpeg and freed after format conversion.
let mut sw_nv12 = unsafe { ffi::av_frame_alloc() };
@@ -724,6 +971,8 @@ fn run_gpu_pipeline(
}
let transfer_us = t_transfer.elapsed().as_micros() as u64;
// format 阶段:NV12 → YUV420P 纯格式转换(同尺寸无缩放)。
// NV12 与 YUV420P 的 Y plane 完全相同,只是 UV plane 排列不同(NV12 = interleavedYUV420P = planar)。
let t_format = Instant::now();
// SAFETY: sw_nv12 contains CPU-side NV12 at enc dimensions; encoder.yuv_frame is writable
// YUV420P at the same dimensions, so sws_scale performs only chroma deinterleave/format conversion.
@@ -746,6 +995,8 @@ fn run_gpu_pipeline(
let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?;
let total_us = frame_start.elapsed().as_micros() as u64;
// GPU 路径的 stats 包含 6 个阶段(filter + format),CPU 路径只有 5 个(scale);
// FrameStats 的字段用 Option/空 Vec 区分。
stats.import_us.push(import_us);
stats.filter_us.push(filter_us);
stats.transfer_us.push(transfer_us);
@@ -773,6 +1024,9 @@ fn run_gpu_pipeline(
Ok(stats)
}
// 打印单条流水线的详细统计报告(捕获/编码分辨率、总时长、各阶段平均毫秒、FPS)。
// 纯展示函数:无 Result 返回值,无副作用(除 stdout),无错误路径。
// 类比 Go `func printResults(label string, stats *FrameStats, ...) { fmt.Println(...) }`。
fn print_detailed_results(
label: &str,
stats: &FrameStats,
@@ -781,20 +1035,25 @@ fn print_detailed_results(
enc_width: u32,
enc_height: u32,
) {
// println!() 无参数版本等价于 Go fmt.Println() —— 打印空行做视觉分隔。
println!();
println!("=== {label} Pipeline Results ===");
println!("Capture resolution: {}x{}", src_width, src_height);
println!("Encode resolution: {}x{}", enc_width, enc_height);
println!("Frames encoded: {}", stats.frames_encoded);
// {:.2} 保留 2 位小数;类比 Go fmt.Printf("%.2fs", v)。
println!("Total time: {:.2}s", stats.elapsed_secs);
println!("Output: {}", stats.output_path);
if stats.import_failures > 0 {
println!("Import failures: {}", stats.import_failures);
}
// FrameStats::avg_ms 是关联函数(不是 method),签名 `fn avg_ms(v: &[u64]) -> f64`。
// 类比 Go 顶层函数 `func avgMs(v []uint64) float64`Rust 关联函数等价于 Go 的 package-level。
println!(
"import avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.import_us)
);
// is_empty() 判断 Vec 是否为空;GPU 路径才有 filter_usCPU 路径此 Vec 永远空。
if !stats.filter_us.is_empty() {
println!(
"filter avg: {:.2} ms/frame",
@@ -805,12 +1064,14 @@ fn print_detailed_results(
"transfer avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.transfer_us)
);
// CPU 路径才有 scale_usGPU 路径此 Vec 永远空。
if !stats.scale_us.is_empty() {
println!(
"scale avg: {:.2} ms/frame",
FrameStats::avg_ms(&stats.scale_us)
);
}
// GPU 路径才有 format_usCPU 路径此 Vec 永远空。
if !stats.format_us.is_empty() {
println!(
"format avg: {:.2} ms/frame",
@@ -822,14 +1083,19 @@ fn print_detailed_results(
stats.codec_name,
FrameStats::avg_ms(&stats.encode_us)
);
// avg_total_ms / achieved_fps / theoretical_fps 都是 method&self 形式),调用语法 `stats.method()`。
println!("total avg: {:.2} ms/frame", stats.avg_total_ms());
println!("achieved FPS: {:.1}", stats.achieved_fps());
println!("max theoretical: {:.1} FPS", stats.theoretical_fps());
}
// 打印 CPU 与 GPU 流水线的对比摘要(一行 = 一条流水线),便于横向对比。
// 接收 Option<&FrameStats>:当基准只跑 CPU 或只跑 GPU 时,另一边为 None。
// 类比 Go `func printComparison(cpu, gpu *FrameStats)`Go 用 nil 表示缺失;Rust 用 Option<T> 强制处理。
fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
println!();
println!("=== Pipeline Comparison ===");
// if let Some(s) = cpu:模式匹配 Option;只在 Some 时打印,None 静默跳过(不需要 else)。
if let Some(s) = cpu {
println!(
"CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)",
@@ -855,7 +1121,16 @@ fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) {
}
}
// 二进制入口点。Rust 标准签名 `fn main() -> Result<()>`:返回 Result 时失败会用 exit code 1 + Debug 打印错误。
// 类比 Go `func main() { err := run(); if err != nil { log.Fatal(err) } }` —— Rust 用 `?` 传播更简洁。
//
// 流程概览(3 个阶段,对应 println 中的 [1/3]/[2/3]/[3/3] 标号):
// 1. 通过 XDG Portal 请求屏幕捕获权限 → 拿到 PipeWire fd → 构造 CapPortal
// 2. 等待首帧 → 测试 av_hwframe_map 导入(若失败,回退 mmap 测试后退出)
// 3. 根据 --mode 跑 CPU/GPU/Both 流水线,输出详细统计 + 对比报告
fn main() -> Result<()> {
// clap::Parser::parse() 解析 std::env::args,匹配失败的会自动 exit code 1 + 打印 help。
// 类比 Go `flag.Parse()` + cobra.Struct,但 clap 用 Derive 宏更声明式。
let bench_args = BenchArgs::parse();
println!("=== VAAPI Import Benchmark ===");
@@ -868,11 +1143,15 @@ fn main() -> Result<()> {
println!("DRM device: {}", bench_args.drm_device);
println!();
// ff::init()FFmpeg 全局初始化(注册所有编解码器/滤镜/格式)。必须在所有 FFmpeg 调用前执行一次。
// 类比 Go 的 `import _ "image/jpeg"` 副作用导入;FFmpeg 5+ 改为运行时自动注册,但 init 仍推荐。
ff::init()?;
println!("[1/3] Requesting screen capture via XDG Portal...");
println!(" (Select a screen to share in the portal dialog)");
// 构造 Args(生产 CLI 类型)——本基准复用 wl-webrtc 主程序的 Args 结构以驱动 CapPortal。
// 大部分字段写死;只有 output 从 BenchArgs 透传。类比 Go `args := &wlwebrtc.Args{...}`。
let portal_args = Args {
output: Some(bench_args.output.clone()),
output_name: None,
@@ -890,16 +1169,22 @@ fn main() -> Result<()> {
stats: false,
};
// CapPortal::new 启动 Portal 异步协商 + PipeWire 流;阻塞至用户在对话框点"允许"。
// 内部会启动 pipewire_thread 后台线程推帧到 frame_receiver 通道。
let cap = CapPortal::new(&portal_args)?;
println!("[1/3] Portal connected, PipeWire stream active\n");
println!("[2/3] Waiting for first frame from PipeWire...");
// 阻塞等首帧(带 30s 超时,详见 receive_first_frame 实现)。
let first_frame = receive_first_frame(&cap)?;
// 把 first_frame 的字段拷贝到局部变量;后续两条流水线都要用 src_width/src_height 做下采样。
// 注意:first_frame 必须 drop 之前不能让 import_dma_buf_to_vaapi 持有 fd 引用(所有权检查)。
let src_width = first_frame.width;
let src_height = first_frame.height;
let src_format = first_frame.format;
// 0x{:08X}8 位 16 进制(大写)前补 0 —— 用于打印 DRM 四字符码(ARGB8888 = 0x34325241)。
println!(
"[2/3] First frame: {}x{}, format=0x{:08X}, stride={}, modifier=0x{:X}",
src_width, src_height, src_format, first_frame.stride, first_frame.modifier
@@ -911,14 +1196,19 @@ fn main() -> Result<()> {
src_format
);
// 打开 DRM render node(默认 /dev/dri/renderD128),构造 VAAPI 硬件设备上下文。
// AvHwDevCtx 内部封装 AVBufferRefFFmpeg 引用计数),Drop 时自动释放。
let drm_device = Path::new(&bench_args.drm_device);
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
println!(" VAAPI device context created OK");
// 构造硬件帧上下文:绑定设备 + sw_format=BGRA + 源尺寸;scale_vaapi 滤镜需要此 ctx。
let frames_ctx =
AvHwFrameCtx::for_capture(&hw_dev, src_width, src_height, ff::format::Pixel::BGRA)?;
println!(" VAAPI frames context created OK (sw_format=BGRA)");
// 首帧导入测试:unsafe 块因为 import_dma_buf_to_vaapi 是 raw FFIav_hwframe_map + AVDRMFrameDescriptor)。
// 此处 unsafe 块**未写** SAFETY 标记,因为 import_dma_buf_to_vaapi 自身在 src/avhw.rs 内部已有详尽 SAFETY 注释。
let vaapi_frame = unsafe {
import_dma_buf_to_vaapi(
frames_ctx.as_ptr(),
@@ -932,11 +1222,13 @@ fn main() -> Result<()> {
)
};
// 用 match 处理 Result;分支内提前 return Ok(()) 表示"基准结束但不报错"(非失败路径)。
match &vaapi_frame {
Ok(_) => {
println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!");
}
Err(e) => {
// 失败路径:诊断 + mmap 对照测试 + 友好退出(不返回 Err)。
println!(" Result: FAILED");
println!(" Error: {e}");
println!();
@@ -947,8 +1239,10 @@ fn main() -> Result<()> {
println!();
println!(" Falling back to mmap readback test for comparison...");
// mmap 对照:如果 av_hwframe_map 失败,看 mmap 是否也失败(区分根因:driver vs 配置)。
let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize);
let mmap_start = Instant::now();
// unsafelibc::mmap 是 POSIX FFI,返回 void*MAP_FAILED (== -1) 表示失败。
let mmap_ptr = unsafe {
libc::mmap(
ptr::null_mut(),
@@ -962,6 +1256,7 @@ fn main() -> Result<()> {
let mmap_elapsed = mmap_start.elapsed();
if mmap_ptr == libc::MAP_FAILED {
// last_os_error():取 errno;类比 Go syscall.Errno。
let errno = std::io::Error::last_os_error();
println!(" mmap also FAILED: {errno}");
} else {
@@ -970,6 +1265,7 @@ fn main() -> Result<()> {
mmap_size as f64 / 1024.0 / 1024.0,
mmap_elapsed.as_secs_f64() * 1000.0
);
// 必须配对 munmap,否则内核 VMA 泄漏。
unsafe {
libc::munmap(mmap_ptr, mmap_size);
}
@@ -978,10 +1274,13 @@ fn main() -> Result<()> {
println!();
println!("=== Benchmark ended: av_hwframe_map import FAILED ===");
println!("Fix the import issue before proceeding to GPU downscale tests.");
// 主动 Ok(()):基准本身没崩,只是诊断后退出;让 CI 不报红。
return Ok(());
}
}
// 导入成功后释放首帧资源(vaapi_frame 持有硬件帧引用,first_frame 持有 fd);
// 后续主循环每帧重新 import,避免长持有造成硬件帧饥饿。
drop(vaapi_frame);
drop(first_frame);
@@ -989,12 +1288,19 @@ fn main() -> Result<()> {
let enc_width = bench_args.enc_width;
let enc_height = bench_args.enc_height;
// PipelineMode::Both 时输出文件名加 cpu/gpu 后缀(详见 output_for_mode)。
let split_outputs = bench_args.mode == PipelineMode::Both;
// 用 Option 包裹:mode 只跑 CPU 时 gpu_stats 永远 Noneprint_detailed_results/print_comparison 接 Option。
let mut cpu_stats = None;
let mut gpu_stats = None;
// matches! 宏:等价于 `match bench_args.mode { PipelineMode::Cpu | PipelineMode::Both => true, _ => false }`
// 但语法更紧凑(无臂返回值);类比 Go `switch mode { case Cpu, Both: ... }`。
if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) {
let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs);
// Some(...) 把 Result<FrameStats> 包成 Option<Result<FrameStats>>,再 ? 解开 Result;最终 cpu_stats = Option<FrameStats>。
// 注意 `?` 在 Option 上下文也工作(需要 main 返回 Option,但这里 main 返回 Result,所以 ? 只对 Result 起作用,
// Some(...) 是显式包装,里面的 run_cpu_pipeline()? 把 Err 传到 main)。
cpu_stats = Some(run_cpu_pipeline(
&cap,
&frames_ctx,
@@ -1022,6 +1328,7 @@ fn main() -> Result<()> {
)?);
}
// as_ref():把 &Option<T> 借用(避免消耗 T);print_detailed_results 接收 &FrameStats。
if let Some(stats) = cpu_stats.as_ref() {
print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height);
}
@@ -1030,6 +1337,9 @@ fn main() -> Result<()> {
}
print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref());
// 迭代器链:把两个 Option 串成统一迭代器,.any() 短路检查是否有任何一条流水线低于 30 FPS。
// Option::into_iter():把 Option<T> 转为 0/1 元素迭代器;chain 把两段接起来。
// 类比 Go`var all []*FrameStats; if cpu != nil { all = append(all, cpu) }; for _, s := range all { if s.FPS < 30 {...} }`。
if cpu_stats
.as_ref()
.into_iter()
+216
View File
@@ -1,3 +1,23 @@
//! XDG Desktop Portal + PipeWire 截屏后端。
//!
//! 本模块实现 `CaptureBackend::PortalPipeWire` 路径:通过 XDG Portal 的
//! ScreenCast 接口请求用户授权,拿到 PipeWire 远程 fd 与 node_id 后,在专用
//! 线程里跑 PipeWire 事件循环接收 DMA-BUF 帧。
//!
//! 关键设计:
//! - 使用 `ashpd` crate 走 XDG Portal 协议(高层 Rust 绑定,封装 D-Bus 调用)。
//! - `CapPortal` 在用户 cache 目录(`wl-webrtc/portal-restore-token`)缓存 Portal
//! restore token,下次启动可跳过用户授权对话框(token 有效时)。
//! - `--no-persist` 标志:跳过 restore token 读写,每次启动都弹授权对话框;测试
//! fresh authorization 时使用。
//! - 与 `backend_detect.rs` 的差异:检测阶段刻意用 raw `zbus` 避免 `ashpd` 缓存
//! `zbus::Connection` 到全局 OnceLockruntime drop 后变僵尸 connection)。本
//! 模块只在 Portal 路径使用 `ashpd`,且 Tokio runtime 由 `CapPortal` 自己拥有
//! `rt` 字段),生命周期与 `CapPortal` 一致,无跨实例复用问题。
//!
//! 分阶段超时(git 68a6eec):`Service`(无用户交互,5s)与 `TokenDependent`
//! (可能弹对话框,30s)两类,前者直接失败、后者清 token 后重试一次。
// cap_portal.rs — 通过 XDG Desktop Portal 的 ScreenCast 接口捕获屏幕帧
//
// 整体架构:
@@ -158,6 +178,10 @@ impl CapPortal {
let (frame_tx, frame_rx) = bounded(1);
let (event_tx, event_rx) = bounded(8);
// 创建 eventfd 对(Linux 特有的进程内事件通知机制)。
// EFD_CLOEXEC: exec() 时自动关闭 fd,避免泄露给子进程。
// EFD_NONBLOCK: 读取时非阻塞,配合 epoll/poll 使用。
// unsafe: libc::eventfd 是 C FFI,返回值 < 0 表示 errno 错误。
let efd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
if efd < 0 {
return Err(anyhow::anyhow!(
@@ -165,36 +189,54 @@ impl CapPortal {
std::io::Error::last_os_error()
));
}
// 复制 fd 得到独立的两端(读端 efd 给 PipeWire 线程,写端 write_fd 留给 Drop)。
// dup 返回的是新的 fd(最小可用整数),与原 fd 共享同一打开文件描述。
// unsafe: libc::dup 是 C FFI< 0 表示失败;失败时必须 close 原来的 efd 防止泄露。
let write_fd = unsafe { libc::dup(efd) };
if write_fd < 0 {
let err = std::io::Error::last_os_error();
// unsafe: 清理已分配但 dup 失败的 efd,避免 fd 泄漏。
unsafe { libc::close(efd) };
return Err(anyhow::anyhow!("dup eventfd failed: {err}"));
}
// Arc<AtomicU64> 跨线程共享的丢弃计数器(Arc 提供线程安全引用计数,
// 类似 Go 的 sync/atomic.Value 但带引用语义)。PipeWire 线程在 channel
// 满导致丢帧时原子递增它,主线程通过 dropped_count() 读取统计。
// Ordering::Relaxed:仅用于统计,不需要跨线程内存顺序保证。
let pw_dropped = Arc::new(AtomicU64::new(0));
// PwThreadCtx 聚合所有要 move 进 PipeWire 线程的资源。
// shutdown_read / pw_fd 用 OwnedFd 包装(Drop 时自动 close),
// 这避免手动管理 fd 生命周期。frame_tx / event_tx 是 crossbeam
// channel 的发送端(多生产者单消费者,Clone + Send)。
let ctx = PwThreadCtx {
frame_tx,
event_tx,
dropped: pw_dropped.clone(),
// unsafe: OwnedFd::from_raw_fd 接管 efd 的所有权(保证 RAII 关闭)。
// 之前 libc::eventfd 返回的 efd 没有 Owner,必须用 from_raw_fd 包一下。
shutdown_read: unsafe { OwnedFd::from_raw_fd(efd) },
pw_fd,
node_id,
fps: args.fps,
};
// thread::Builder 模式:name 给线程命名(便于调试/top 显示),spawn 启动。
// move || 闭包获取 ctx 所有权(不捕获引用),保证线程自带所有数据。
let pw_thread = thread::Builder::new()
.name("pipewire-capture".into())
.spawn(move || {
pipewire_thread(ctx);
})
.map_err(|e| {
// unsafe: spawn 失败时清理 write_fd 防止泄漏。
unsafe { libc::close(write_fd) };
anyhow::anyhow!("thread spawn failed: {e}")
})?;
Ok(Self {
// unsafe: from_raw_fd 接管 write_fd 的所有权,由 CapPortal::Drop 关闭。
shutdown_fd: unsafe { OwnedFd::from_raw_fd(write_fd) },
frame_rx,
event_rx,
@@ -239,17 +281,25 @@ impl CapPortal {
/// false`, clears the cached restore token and retries once with
/// `no_persist = true`.
async fn setup_portal(no_persist: bool) -> Result<(OwnedFd, u32)> {
// 首次尝试:使用缓存的 restore token(若存在且 no_persist=false)。
// _setup_portal_inner 内部根据 phase 失败分类返回 PortalPhaseTimeout。
match Self::_setup_portal_inner(no_persist, false).await {
Ok(result) => Ok(result),
// 通过 anyhow::Error 的 downcast 机制判断内层错误是否为 PortalPhaseTimeout。
// anyhow 包装动态类型错误,e.is::<T>() 检查,downcast_ref::<T>() 取引用。
Err(e) if e.is::<PortalPhaseTimeout>() => {
let inner_err = e.downcast_ref::<PortalPhaseTimeout>().unwrap();
match inner_err {
// 仅当 token-dependent phase 超时且原本允许 persist 时才重试。
// 重试策略:删除缓存的 token,强制 fresh authorization。
PortalPhaseTimeout::TokenDependent if !no_persist => {
tracing::warn!(
"Portal timed out during token-using phase. \
Clearing cached restore token and retrying with fresh authorization."
);
delete_restore_token();
// is_retry=true 阻止 _setup_portal_inner 再次进入重试分支
// (最多重试一次,避免无限循环)。
Self::_setup_portal_inner(true, true).await
}
_ => Err(e),
@@ -267,22 +317,31 @@ impl CapPortal {
no_persist: bool,
is_retry: bool,
) -> Result<(OwnedFd, u32)> {
// 函数内部 use:把 ashpd 子模块导入局部作用域(限制作用域避免污染整个文件)。
// CursorMode / SourceType / PersistMode 是 ashpd 提供的枚举,对应 Portal 协议字段。
use ashpd::desktop::screencast::{
CursorMode, Screencast, SelectSourcesOptions, SourceType,
};
use ashpd::desktop::PersistMode;
// Phase 1: Screencast proxy (no user interaction).
// D-Bus 代理对象,对应 XDG Portal ScreenCast 接口。
// tokio::time::timeout(dur, fut) 包装一个 future,超过 dur 返回 Err(Elapsed)。
// 返回 Result<Result<T, ashpd::Error>, Elapsed>,外层是 timeout,内层是 Portal 调用。
// 三路 matchOk(Ok) 成功 / Ok(Err) Portal 报错 / Err(_) 超时。
let proxy = match tokio::time::timeout(PORTAL_SERVICE_TIMEOUT, Screencast::new()).await {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to create Screencast proxy: {e}")),
Err(_) => {
log_portal_phase_timeout("creating Screencast proxy", false);
// .into() 把 PortalPhaseTimeout 转换为 anyhow::Errordyn Error trait object)。
return Err(PortalPhaseTimeout::Service.into());
}
};
// Phase 2: create_session (no user interaction).
// 建立 Portal 会话令牌(不是 PipeWire 会话),用于后续 select_sources 引用。
// Default::default() 揆 SessionOptions 是空 struct(用 trait 接口设置非默认值时显式构造)。
let session = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.create_session(Default::default()),
@@ -297,8 +356,13 @@ impl CapPortal {
}
};
// Portal 协议版本 ≥4 才支持 persist_mode 与 restore_token。
// version 由 Screencast proxy 在 D-Bus 属性中暴露。
let version_supported = proxy.version() >= 4;
// 决定 persist_mode 与已缓存的 token
// - no_persist=true 或版本不支持 → PersistMode::DoNot,不读 token。
// - 否则 → PersistMode::ExplicitlyRevoked(显式可撤销,配合 token 重用)。
let (persist_mode, saved_token) = if !no_persist && version_supported {
let token = load_restore_token();
if token.is_some() {
@@ -313,18 +377,26 @@ impl CapPortal {
(PersistMode::DoNot, None)
};
// Builder 模式链式调用:每个 set_X 返回新的 SelectSourcesOptions(按值消费 self)。
// CursorMode::Embedded:光标烧录进帧(不是单独的鼠标位置流)。
// BitFlags::from(SourceType::Monitor):仅捕获整个显示器(不捕获窗口)。
// set_multiple(false):单流(不开启多显示器拼接)。
let mut options = SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_sources(ashpd::enumflags2::BitFlags::from(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(persist_mode);
// 若有缓存的 token,附加到 options 实现免对话框恢复。
// if let Some(ref token) 模式:ref 关键字避免 move token(仅借用字符串引用)。
if let Some(ref token) = saved_token {
options = options.set_restore_token(token.as_str());
}
// Phase 3: select_sources — token path is fast (no dialog); fresh
// authorization may pop a dialog.
// 双超时策略:token_in_use=true 时无对话框(5s service timeout),
// false 时用户需要点 Allow30s user-dialog timeout)。
let token_in_use = saved_token.is_some();
let phase3_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
@@ -336,6 +408,7 @@ impl CapPortal {
Ok(Err(e)) => return Err(anyhow::anyhow!("Screen sharing permission denied: {e}")),
Err(_) => {
log_portal_phase_timeout("selecting sources", token_in_use);
// 按 token_in_use 分流错误类型,setup_portal 仅对 TokenDependent 重试。
return Err(
if token_in_use {
PortalPhaseTimeout::TokenDependent
@@ -348,11 +421,15 @@ impl CapPortal {
}
// Phase 4: start + response — same dialog-vs-token reasoning as phase 3.
// start 返回一个 futureresponse 解析 PortalDbus 返回值。
// 这里把两个 await 串起来放进 async 块,整体受 phase4_timeout 包裹。
let phase4_timeout = if token_in_use {
PORTAL_SERVICE_TIMEOUT
} else {
PORTAL_USER_DIALOG_TIMEOUT
};
// 内部 async 块:把 start + response 组成单一 future,便于 timeout 包装。
// ? 在 async 块里传播 ashpd::Error,外层 match 处理。
let start_fut = async {
proxy
.start(&session, None, Default::default())
@@ -375,12 +452,15 @@ impl CapPortal {
}
};
// 持久化新颁发的 restore tokenPortal 可能返回与之前不同的 token)。
if !no_persist && version_supported {
if let Some(new_token) = response.restore_token() {
save_restore_token(new_token);
}
}
// 假设单流(set_multiple(false)):first().ok_or_else 把 None 转 Error。
// ok_or_else 闭包延迟构造错误字符串,比 ok_or 节省开销。
let stream = response
.streams()
.first()
@@ -389,6 +469,8 @@ impl CapPortal {
let node_id = stream.pipe_wire_node_id();
// Phase 5: open_pipe_wire_remote (no user interaction).
// 请求 PipeWire 服务端 fd。返回的 OwnedFd 是 Portal 通过 D-Bus fd-passing
// 传过来的 PipeWire socketPipeWire 线程用它连接到 compositor 的 PipeWire 实例。
let fd = match tokio::time::timeout(
PORTAL_SERVICE_TIMEOUT,
proxy.open_pipe_wire_remote(&session, Default::default()),
@@ -409,17 +491,32 @@ impl CapPortal {
}
}
/// 计算 Portal restore token 的持久化路径(用户 cache 目录下 `wl-webrtc/portal-restore-token`)。
///
/// 返回 `Option<PathBuf>` 因为某些系统无合法 cache 目录(如 `$XDG_CACHE_HOME` 未设置
/// 且无 HOME),此时返回 None,调用方应跳过 token 持久化。
///
/// 路径布局:`$XDG_CACHE_HOME/wl-webrtc/portal-restore-token` 或 `~/.cache/wl-webrtc/portal-restore-token`。
fn token_path() -> Option<PathBuf> {
// dirs::cache_dir() 返回 Option<PathBuf>(无 cache 目录时为 None)。
// .map(|base| base.join("wl-webrtc").join("portal-restore-token"))
// 类似 Go 的 filepath.Join,跨平台路径拼接。
dirs::cache_dir().map(|base| base.join("wl-webrtc").join("portal-restore-token"))
}
/// Verify that `path` is a directory owned by the current user with no group/other permissions.
/// Rejects symlinks at the path itself (but allows the resolved target to be a real dir).
fn verify_secure_dir(path: &std::path::Path) -> bool {
// use 内导入 unix-only trait 扩展(Linux 特有的 stat/mode 字段)。
// 这些 trait 让 std::fs::Metadata 暴露 .uid()/.gid()/.mode() 等 Unix 字段。
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadata 不跟随符号链接(lstat),暴露链接本身的信息。
// 这是安全关键:若用 metadata()(跟随 symlink),攻击者可挂个 symlink 到任意目录
// 让我们以为权限正确(实际指向 /etc 之类)。
match std::fs::symlink_metadata(path) {
Ok(meta) => {
// 第一道防线:拒绝任何 symlink,即使权限看起来正确。
if meta.file_type().is_symlink() {
tracing::warn!(
"Token parent dir is a symlink, rejecting: {}",
@@ -433,6 +530,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// Must be owned by current user
// unsafe: libc::getuid 是 C FFI;它实际是安全操作(无失败模式),
// 标 unsafe 仅因 Rust 未对其建模。返回当前进程的 real UID。
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!(
"Token parent dir not owned by current user: {}",
@@ -441,6 +540,8 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
return false;
}
// No group or other permissions (mode must be 0o700 exactly within the 0o777 mask)
// mode & 0o777:剥离文件类型位(st_mode 高位),只保留 rwx 权限位。
// 要求严格 0o700owner rwxgroup 与 other 全无(防止其他用户读 token)。
let mode = meta.permissions().mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
@@ -462,12 +563,14 @@ fn verify_secure_dir(path: &std::path::Path) -> bool {
/// Ensure the parent directory exists with restrictive permissions (0o700).
/// Returns false if the directory could not be created or is insecure.
fn ensure_secure_parent(parent: &std::path::Path) -> bool {
// DirBuilderExt 扩展 DirBuilder::mode()Unix-only),OpenOptionsExt 用于后续步骤。
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
if parent.exists() {
// Directory exists — try to tighten permissions, then verify.
// set_permissions follows symlinks, which is fine here since
// we verify with symlink_metadata in verify_secure_dir.
// 收紧模式:把已存在目录强行改为 0700,然后 verify_secure_dir 校验最终状态。
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!("Failed to set directory permissions: {e}");
return false;
@@ -476,6 +579,8 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Create with restrictive mode — DirBuilderExt::mode bypasses umask.
// 关键:标准 create_dir 受 umask 影响(如 022 → 实际 0755)。
// DirBuilderExt::mode(0o700) 直接设置 inode mode,绕过 umask,保证 0700。
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
builder.mode(0o700);
@@ -485,18 +590,35 @@ fn ensure_secure_parent(parent: &std::path::Path) -> bool {
}
// Verify after creation (belt-and-suspenders)
// 双保险:再 verify 一次,防止 create 与 set_mode 之间被 TOCTOU 篡改。
verify_secure_dir(parent)
}
/// 加载已缓存的 Portal restore token(默认路径)。
///
/// 无 token 文件、文件不可读、权限不合规等情况均返回 None(不报错)。
/// 失败原因由 tracing::warn! 记录,便于排查。
fn load_restore_token() -> Option<String> {
// ? 在 Option 上传播:token_path() 返回 None 时直接 return None。
load_restore_token_from(token_path()?)
}
/// 从指定路径加载 token,附带严格的安全校验。
///
/// 校验规则(任一不满足返回 None):
/// 1. 必须是 regular file(拒绝 directory / fifo / socket
/// 2. 不能是 symlink(防 symlink attack
/// 3. owner 必须是当前用户
/// 4. group/other 不可读写(mode & 0o077 == 0
///
/// 这些校验防止攻击者通过预创建文件或符号链接窃取 token。
fn load_restore_token_from(path: PathBuf) -> Option<String> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// symlink_metadatalstat,不跟随 symlink(防攻击者指向 /etc/shadow 等敏感文件)。
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
// 文件不存在或不可访问:静默 None(首次启动无 token 是正常情况)。
Err(_) => return None,
};
@@ -511,10 +633,14 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
tracing::warn!("Token path is not a regular file: {}", path.display());
return None;
}
// unsafe: libc::getuid 标 unsafe 仅因 Rust 未建模;实际无失败模式。
// 比较 st_uid 与当前 real UID,防止其他用户写入的 token 被误用。
if meta.uid() != unsafe { libc::getuid() } {
tracing::warn!("Token file not owned by current user: {}", path.display());
return None;
}
// 检查 group/other 任何 r/w/x 位(mode & 0o077 != 0)→ 拒绝。
// 允许 owner 任意位(0o700 / 0o600 / 0o400 等都 OK)。
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
tracing::warn!(
@@ -525,6 +651,9 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
return None;
}
// .ok()? :把 std::io::Result<String> 转 Option<String>Err 变 None。
// 然后 trim 去掉首尾空白(Portal 返回的 token 可能带换行)。
// 若 trim 后为空字符串,返回 None(视为无 token)。
let token = std::fs::read_to_string(&path).ok()?;
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
@@ -534,7 +663,13 @@ fn load_restore_token_from(path: PathBuf) -> Option<String> {
}
}
/// 保存 Portal 颁发的 restore token 到默认 cache 路径。
///
/// 失败(无 cache 目录、目录权限不合规、磁盘满等)不返回错误,
/// 仅 tracing::warn!,下次启动会重新走对话框授权流程。
fn save_restore_token(token: &str) {
// let-else 模式(Rust 1.65+):let Some(x) = ... else { return; }。
// 无 cache 目录时早退,避免后续无谓 IO。
let Some(path) = token_path() else {
tracing::warn!("No secure cache directory available, skipping token save");
return;
@@ -542,10 +677,16 @@ fn save_restore_token(token: &str) {
save_restore_token_to(token, &path);
}
/// 删除已缓存的 restore token(用于 token 失效或用户重新授权)。
///
/// 文件不存在视为已删除(幂等),其他错误仅 warn 不传播。
fn delete_restore_token() {
// let-else 早退模式(与 save_restore_token 一致)。
let Some(path) = token_path() else {
return;
};
// match std::io::ErrorKind::NotFound 是 Rust 错误分类的常用模式。
// 幂等:文件已删除也视为成功,不报警告(避免日志噪音)。
match std::fs::remove_file(&path) {
Ok(()) => tracing::info!("Deleted stale portal restore token at {}", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -553,11 +694,21 @@ fn delete_restore_token() {
}
}
/// 把 token 原子写入指定路径(temp file + rename 模式)。
///
/// 原子性:通过临时文件 + rename(2) 实现,确保读到完整 token 或读到旧 token
/// 永远不会读到部分写入。这是 Linux/Unix 文件系统 rename 的保证。
///
/// 安全性:
/// - 父目录必须 0o700 且 owner = current userensure_secure_parent 校验)
/// - temp file 用 create_new + mode 0o600(不覆盖现有文件,不跟随 symlink)
/// - rename 是原子操作,但仅在同 filesystem 下保证
fn save_restore_token_to(token: &str, path: &std::path::Path) {
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
// path.parent() 返回 Option<&Path>root 路径无 parent)。
let Some(parent) = path.parent() else {
tracing::warn!("Token path has no parent directory");
return;
@@ -571,21 +722,31 @@ fn save_restore_token_to(token: &str, path: &std::path::Path) {
// Use a unique temp file to prevent symlink attacks.
// create_new(true) guarantees exclusive creation — fails if file already exists,
// and does NOT follow existing symlinks.
// temp 文件名带 PID 防并发:多个 wl-webrtc 实例同时运行不会互相覆盖 temp。
let tmp_path = path.with_extension(format!("{}.tmp", std::process::id()));
// IIFE (immediately-invoked closure) 把多步 IO 组合成单一 Result。
// ? 在闭包内传播 std::io::Error,外层统一 match 处理。
let result = (|| -> std::io::Result<()> {
// OpenOptions builderwrite + create_new = O_WRONLY | O_CREAT | O_EXCL。
// mode(0o600)owner rwgroup/other 无权限(绕过 umask)。
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(token.as_bytes())?;
// sync_allfsync(2),把数据 flush 到磁盘(防系统崩溃丢数据)。
// 必须 fsync 之后 rename,否则崩溃后可能 token 文件存在但内容为空。
f.sync_all()?;
// rename(2):原子替换。Linux 同 filesystem 下原子保证。
std::fs::rename(&tmp_path, path)?;
Ok(())
})();
match result {
Ok(()) => tracing::info!("Saved portal restore token"),
Err(e) => {
// 失败时清理 temp(避免遗留垃圾文件)。
// let _ = 显式忽略 remove_file 的错误(temp 可能已不存在)。
let _ = std::fs::remove_file(&tmp_path);
tracing::warn!("Failed to save restore token: {e}");
}
@@ -603,7 +764,14 @@ impl Drop for CapPortal {
fn drop(&mut self) {
// Signal the PipeWire loop to quit via eventfd.
// eventfd write is a kernel syscall — thread-safe and lock-free.
// 写入 8 字节(u64)到 eventfdPipeWire 线程 epoll_wait 立即返回。
// val=1 是任意非零值(PipeWire 线程只关心"可读"事件,不读具体值)。
let val: u64 = 1u64;
// unsafe: libc::write 是 C FFI。签名:write(fd, buf, count) → ssize_t。
// - self.shutdown_fd.as_raw_fd():取出 OwnedFd 内部的 raw int fd。
// - &val as *const u64 as *const _:把 Rust 引用强转成 *const c_void。
// - std::mem::size_of::<u64>()8 字节(eventfd 必须写 8 字节)。
// 返回值是写入字节数或 -1(错误),用 let _ = 忽略(Drop 不能 panic)。
let _ = unsafe {
libc::write(
self.shutdown_fd.as_raw_fd(),
@@ -614,6 +782,10 @@ impl Drop for CapPortal {
// 等待 PipeWire 线程完全退出
// 这确保 PipeWire 资源在线程中被正确清理后,主线程才继续
// Option::take():把 Option<JoinHandle> 里的值 move 出来,留下 None。
// 之后 CapPortal 自身的字段访问(如 Drop 结束)不会重复 join。
// handle.join():阻塞当前线程直到目标线程退出。返回 Result(线程 panic 时 Err)。
// let _ = 忽略 panic 错误(Drop 中无法恢复)。
if let Some(handle) = self.pw_thread.take() {
let _ = handle.join();
}
@@ -660,6 +832,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
fps,
} = ctx;
// PipeWire 三件套初始化(典型 PW 客户端架构):
// MainLoop —— 事件循环(epoll 后端),所有回调都在此线程派发。
// Context —— 加载 PW 模块、管理代理对象的上下文,挂在 MainLoop 上。
// Core —— 与 PipeWire daemon 的连接(此处用 connect_fd 走 Portal
// 下发的 socket fd 而非默认的 `pipewire-0`)。
// 任一初始化失败都通过 event_tx 上报 PwCtrlEvent::Error 并退出本线程,
// 让主线程的 select 报告具体阶段错误。
let mainloop = match pw::main_loop::MainLoopBox::new(None) {
Ok(ml) => ml,
Err(e) => {
@@ -721,8 +900,17 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
};
// 共享的可变格式信息容器:Rc<Cell<Option<(w, h, drm_fmt, modifier)>>>。
// - Rc 单线程引用计数(PipeWire 回调全在同一线程),类比 Go 中"通过指针
// 共享的可变全局变量"但带编译期 Send 约束。
// - Cell<Option<...>> 提供内部可变性(无需 Mutex),通过 .get()/.set()
// 整体替换值——比 RefCell 更轻,因为这里值是 Copy 的元组。
// - 类比 Go: var formatInfo = *(u32,u32,u32,u64) // 取地址 + atomic 赋值。
let format_info: Rc<Cell<Option<(u32, u32, u32, u64)>>> = Rc::new(Cell::new(None));
// crossbeam channel 的 Sender 是 Clone + Send,每次 clone 给一个回调
// 捕获,多回调可并发往同一 channel 投递事件。类比 Go: ch := make(chan T, 8)
// 各 goroutine 持有 ch 共享发送端。
let event_tx_state = event_tx.clone();
let _listener = stream
.add_local_listener::<()>()
@@ -803,6 +991,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
let frame_tx = frame_tx.clone();
let dropped = dropped;
move |stream, _| {
// 以下大量 unsafe 块均为对 PipeWire/libspa C API 的直接访问。
// pipewire-rs 的 stream 类型只暴露 `dequeue_raw_buffer` /
// `queue_raw_buffer` 这类 unsafe 接口,因为返回的是 C 分配的
// 裸 `*mut spa_buffer`,其生命周期由 PipeWire 控制(在
// dequeue 与下一次 queue 之间稳定),Rust 类型系统无法表达。
// 调用约定:每个 dequeue 必须恰好配一次 queue(包括所有错误
// 退出路径),否则 PipeWire 会认为该 buffer 仍被使用而耗尽池。
let raw_buf = unsafe { stream.dequeue_raw_buffer() };
if raw_buf.is_null() {
tracing::trace!("process: null raw_buf");
@@ -892,6 +1087,11 @@ fn pipewire_thread(ctx: PwThreadCtx) {
}
// 构建帧数据对象,所有必要的帧信息已收集完毕
// unsafe: OwnedFd::from_raw_fd 把刚刚 dup 出的 fd 所有权移交给
// Rust 的 RAII 包装。此后 dup_fd 的关闭由 PwDmaBufFrame::Drop
// 负责,不能再在外部 close 它。from_raw_fd 之所以 unsafe,是
// 因为调用方必须保证传入的 fd 此前没有任何 Owner(否则会 double
// close)。这里 libc::dup 刚返回的新 fd 满足该前提。
let frame = PwDmaBufFrame {
fd: unsafe { OwnedFd::from_raw_fd(dup_fd) },
offset,
@@ -903,9 +1103,13 @@ fn pipewire_thread(ctx: PwThreadCtx) {
pts,
};
// try_send 非阻塞投递;channel 容量=1(见 CapPortal::new),
// 当下游编码器落后时立刻返回 Full。
// 类比 Go: select { case ch <- frame: default: /* drop */ }
match frame_tx.try_send(frame) {
Ok(()) => {}
Err(crossbeam_channel::TrySendError::Full(_)) => {
// 丢帧计数(Relaxed 序,仅做统计;不要求与其他线程同步)。
dropped.fetch_add(1, Ordering::Relaxed);
}
Err(crossbeam_channel::TrySendError::Disconnected(_)) => {}
@@ -915,6 +1119,8 @@ fn pipewire_thread(ctx: PwThreadCtx) {
})
.register();
// 空的 SPA POD 参数数组——之前已在 param_changed 回调中接受了 PipeWire
// 推送的格式,这里不需要主动声明格式约束。`&mut [...]` 借用切片给 C API。
let mut params: [&pw::spa::pod::Pod; 0] = [];
if let Err(e) = stream.connect(
@@ -941,14 +1147,24 @@ fn pipewire_thread(ctx: PwThreadCtx) {
// previous detached helper thread approach.
// 保存 mainloop 的原始指针,用于在 shutdown 回调中调用 pw_main_loop_quit
// 这是安全的,因为回调只在 mainloop.run() 阻塞期间执行
//
// `as_raw_ptr()` 返回 `*mut pw_main_loop`(裸指针,不带生命周期),
// 取裸指针本身是 safe 的——风险在使用它。下面 `pw_main_loop_quit` 的
// unsafe 块依赖"回调仅在 run() 期间触发"这一 PipeWire 协议保证。
let mainloop_ptr = mainloop.as_raw_ptr();
// 把 shutdown_read 的可读事件注册到 PipeWire loop 的 epoll/win32 等价物。
// 每次 fd 变可读(CapPortal::drop 写入 8 字节触发),loop 在同一线程
// 调用此闭包。返回的 _shutdown_source 在 drop 时自动从 loop 注销。
let _shutdown_source = loop_.add_io(
shutdown_read,
libspa::support::system::IoFlags::IN,
move |fd| {
// Drain the eventfd so it doesn't re-trigger
let mut buf: u64 = 0;
// unsafe: libc::read 是 C 标准库 FFI。eventfd 语义保证 8 字节
// 整数读,因此 &mut u64 转 *mut void + size_of::<u64>() 安全。
// 返回值忽略——即使读失败也无法在此回调中做有意义处理。
let _ = unsafe {
libc::read(
fd.as_raw_fd(),
+43
View File
@@ -1,3 +1,20 @@
//! 文件:wlr-screencopy-unstable-v1 协议客户端绑定(`CaptureSource` 实现)
//!
//! 本文件实现 `CapWlrScreencopy`,作为 `state.rs` 中 `State<S>` 的泛型参数 `S`
//! 的两个具体实现之一(另一个是 `CapPortal`)。wlr-screencopy 是 wlroots 原生
//! 协议,优先于 XDG Portal/PipeWire:无需 D-Bus、无需用户授权对话框。
//!
//! 协议绑定来源:`wayland_protocols_wlr::screencopy::v1::client::*` 由
//! wayland-scanner 工具根据 `wlr-screencopy-unstable-v1.xml` 自动生成(类似 Go
//! 用 cgo 绑定 C 库,但 Rust 通过 wayland-client crate 暴露 type-safe wrapper
//! 无需手写 C FFI)。
//!
//! 异步模型:客户端无法主动"截屏",只能:(1) 绑定全局 manager、(2) 调用
//! `manager.capture_output()` 创建帧对象、(3) 等待内核推送 buffer/format 事件、
//! (4) 调用 `frame.copy(buffer)` 请求拷贝。因此本文件的 `alloc_frame()` 永远
//! 返回 `None`,真正的帧创建逻辑在 `state.rs` 的 Dispatch impl 中(英文注释
//! 标记为 T6b)。
use anyhow::Result;
use wayland_client::globals::GlobalList;
use wayland_client::protocol::wl_buffer::WlBuffer;
@@ -7,6 +24,9 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::Zwl
use crate::state::{CaptureSource, OutputInfo, State};
// L2: `CaptureSource` trait 的具体实现——wlroots 原生 wlr-screencopy 协议后端。
// 仅持有"当前在飞"的帧对象;协议管理器 `ZwlrScreencopyManagerV1` 的绑定存放
// 在 `State` 的状态机字段中(需要 Dispatch impl,见下方英文注释)。
/// wlr-screencopy capture backend.
///
/// Holds the current in-flight frame protocol object. The
@@ -17,15 +37,27 @@ pub struct CapWlrScreencopy {
/// The active frame object for the current capture cycle.
/// Set by Dispatch impls after `manager.capture_output()`, cleared
/// by `on_done_with_frame()`.
// L3: `Option<T>` 类似 Go 的 `*T`(指针)——要么持有 T 的值,要么是 None(空)。
pub current_frame: Option<ZwlrScreencopyFrameV1>,
}
// L2: 为 `CapWlrScreencopy` 实现 `CaptureSource` trait。
// Rust 的 `impl Trait for Type` 块类比 Go 的 method receiver——
// Go: `func (r *Type) Method(args)`receiver 作为第一个参数显式声明)
// Rust: `fn method(&self, args)``&self` 是 `self: &Self` 的语法糖,等价 Go receiver
// trait impl 要求方法签名与 trait 定义严格一致,编译器会校验。
impl CaptureSource for CapWlrScreencopy {
/// Unit type: wlr-screencopy is fully asynchronous — `alloc_frame()`
/// always returns `None`. The frame object is created by Dispatch
/// impls calling `manager.capture_output()`, not by this method.
// L3: `type Frame = ();` 关联类型(associated type):将"帧"的具体类型延迟到
// impl 处决定。wlr-screencopy 用 unit `()` 因为帧对象生命周期由 Dispatch 控制。
type Frame = ();
// L3: 构造函数。`Self` 在 impl 块内是 `CapWlrScreencopy` 的类型别名。
// 参数名以 `_` 前缀表示"有意未使用"——manager 绑定不在此处发生,故这些
// 参数(GlobalList/WlOutput/OutputInfo/QueueHandle)暂未消费。返回
// `Result<Self>`,失败由调用方用 `?` 操作符传播(类比 Go 的 `if err != nil`)。
fn new(
_gm: &GlobalList,
_output: &WlOutput,
@@ -35,11 +67,15 @@ impl CaptureSource for CapWlrScreencopy {
// Manager binding happens in state.rs during the ProbingOutputs →
// EverythingButFmt stage transition (T6b). It requires a Dispatch
// impl that doesn't exist yet, so we cannot call gm.bind() here.
// `Ok(...)` 是 `Result::Ok(...)` 的简写,将成功值包装为 Result 返回;
// `Self { ... }` 等价于 `CapWlrScreencopy { ... }`impl 块内可用。
Ok(Self {
current_frame: None,
})
}
// L3: 分配帧对象。返回 `Option<Self::Frame>`(此处 Frame = (),故永远返回 None)。
// `&mut self` 是 `self: &mut Self` 的简写(类比 Go 指针 receiver `*Type`)。
fn alloc_frame(&mut self) -> Option<Self::Frame> {
// wlr-screencopy is asynchronous: the Dispatch impl creates a new
// ZwlrScreencopyFrameV1 which triggers the buffer allocation flow
@@ -48,16 +84,23 @@ impl CaptureSource for CapWlrScreencopy {
None
}
// L3: 提交拷贝请求:将已分配的 DMA-BUF(WlBuffer)关联到当前帧对象。
fn queue_copy(&mut self, buffer: &WlBuffer, _qh: &QueueHandle<State<Self>>) {
// `if let Some(x) = &expr`pattern matching,当 expr 是 Some 时绑定内部值。
// 此处 `&self.current_frame` 不可变借用,调用 `frame.copy(buffer)` 提交拷贝。
if let Some(frame) = &self.current_frame {
frame.copy(buffer);
} else {
// `tracing::warn!` 是结构化日志宏(类比 Go log.Printf,但支持字段)。
tracing::warn!("queue_copy: no current wlr-screencopy frame");
}
}
// L3: 帧处理完成后的清理。`_frame: Self::Frame` 前缀 `_` 表示参数未使用(Frame 是 unit)。
fn on_done_with_frame(&mut self, _frame: Self::Frame) {
// `Option::take()`:取出 Some 并将原位置替换为 None,原值所有权转移给返回值。
if let Some(frame) = self.current_frame.take() {
// `frame.destroy()` 发送 wayland 析构请求,释放服务端协议对象资源。
frame.destroy();
}
}
+60
View File
@@ -1,39 +1,89 @@
//! 帧率限制器(FPS Limiter)。
//!
//! 基于时间间隔的下采样策略:当输入帧率高于目标时,按时间窗口丢弃多余帧,
//! 保证输出帧率不超过配置上限。本实现是「非阻塞丢帧」策略——调用方收到
//! `None` 时应主动丢弃该帧,而不是 `thread::sleep` 阻塞等待(这与 Go 中
//! 用 `time.Now()` + `time.Since(last)` + `time.Sleep(d)` 的阻塞式限速器不同)。
//!
//! - 时间点:`std::time::Instant`(单调时钟,类比 Go `time.Time` / `time.Now()`
//! - 时间差:`std::time::Duration`(类比 Go `time.Duration`
//!
//! Go 等价伪码:
//! ```text
//! type Limiter struct { last time.Time; minInterval time.Duration }
//! if time.Since(l.last) >= l.minInterval { /* 放行 */ } else { /* 丢帧 */ }
//! ```
use std::time::{Duration, Instant};
/// 帧率限制器。泛型参数 `T` 代表「帧」的载荷类型(如 AVFrame 包裹、纹理 ID、序号等),
/// 类比 Go 1.18+ 的 `type FpsLimit[T any] struct{ ... }`。
///
/// 字段全部私有,外部只能通过 [`new`](Self::new) / [`on_new_frame`](Self::on_new_frame)
/// / [`flush`](Self::flush) 三个方法操作,确保不变量(如「首帧必过」)不被绕过。
pub struct FpsLimit<T> {
/// 缓存最近一次被丢弃/待输出的帧。`Option<T>` 类比 Go 中可空指针 `*T`
/// `Some(frame)` 表示有缓存,`None` 表示空。`flush` 会取出此字段。
on_deck: Option<T>,
/// 最近一次「放行」(输出给下游)的时间戳;`None` 表示尚未放过任何帧,
/// 此时下一帧必放行(首帧直通语义)。
last_output_time: Option<Instant>,
/// 最小放行间隔 = `1 / fps` 秒。两次输出之间的时间差必须 ≥ 该值。
/// 类比 Go`time.Duration(float64(1) / float64(fps) * float64(time.Second))`。
min_interval: Duration,
}
impl<T> FpsLimit<T> {
/// 构造一个目标帧率为 `fps`(帧/秒)的限速器。
///
/// - `fps as f64`:把 `u32` 提升为 `f64` 才能做浮点除法,类比 Go 的 `float64(fps)`
/// Rust 不允许 `u32 / f64` 隐式转换,必须显式 cast。
/// - `Duration::from_secs_f64(1.0 / fps as f64)`:用浮点秒构造 `Duration`
/// 例如 `fps=30` → `min_interval ≈ 33.33ms`。
pub fn new(fps: u32) -> Self {
Self {
on_deck: None,
last_output_time: None,
// 见上文 `Duration::from_secs_f64` 的 Go 类比。
min_interval: Duration::from_secs_f64(1.0 / fps as f64),
}
}
// 下面的英文 `///` 块为既有文档(保持原样),中文说明见函数体内 `//` 注释。
/// Feed a new frame. Returns:
/// - Some(()) if enough time elapsed since the last output — proceed to encode current frame
/// - None if too close to the last output — drop current frame
///
/// 参数 `&mut self` 相当于 Go 方法接收者 `l *FpsLimit[T]`(可变借用 → 持有可写引用);
/// 返回值 `Option<T>` 相当于 Go 中可空返回值:`Some` 表示放行该帧,`None` 表示丢弃。
pub fn on_new_frame(&mut self, frame: T, timestamp: Instant) -> Option<T> {
// 判断本帧是否「就绪」(可放行)。Rust 的 `match` 强制穷尽,类比 Go 的 `switch`
// 但编译器会在漏掉分支时报错,比 Go 更严格。
let ready = match self.last_output_time {
// 首帧:从未输出过,直接放行。
None => true,
// 非首帧:`timestamp.duration_since(last)` 计算时间差,
// 类比 Go `timestamp.Sub(last)`;返回 `Duration`,与 `>=` 比较的是 `min_interval`。
Some(last) => timestamp.duration_since(last) >= self.min_interval,
};
if ready {
// 放行路径:先更新最近输出时间,再把本帧记到 `on_deck`(保留引用用于 flush)。
self.last_output_time = Some(timestamp);
self.on_deck = Some(frame);
// `Option::take`:移出内部值并把原位置置为 `None`。这里返回刚写入的 `frame`
// 即把本帧交给调用方编码输出。
self.on_deck.take()
} else {
// 丢弃路径:仍把本帧缓存到 `on_deck`(覆盖上一帧的丢弃值),以便 flush 时
// 取到「最后一帧」用于收尾。`Option::replace` 返回旧值(这里用 `let _ =` 丢弃)。
let _ = self.on_deck.replace(frame);
None
}
}
/// 取出并清空缓存的「最后一帧」。常用于流尾 flush,确保下游收到最后一帧。
/// 连续第二次调用必返回 `None`,因为 `take` 后 `on_deck` 已为 `None`。
pub fn flush(&mut self) -> Option<T> {
self.on_deck.take()
}
@@ -46,6 +96,7 @@ mod tests {
#[test]
fn first_frame_passes_immediately() {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
// `Instant::now()` 取单调时钟当前时间,类比 Go `time.Now()`。
let now = Instant::now();
let result = limiter.on_new_frame(1u32, now);
assert_eq!(result, Some(1));
@@ -56,6 +107,8 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// `now + Duration::from_millis(1)``Instant + Duration` 通过 `Add` trait 重载,
// 类比 Go `now.Add(1 * time.Millisecond)`。1ms 远小于 33ms,应被丢弃。
let result = limiter.on_new_frame(2, now + Duration::from_millis(1));
assert!(result.is_none());
}
@@ -65,6 +118,7 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// 34ms > 33.33ms30fps 的 min_interval),应放行。
let result = limiter.on_new_frame(2, now + Duration::from_millis(34));
assert_eq!(result, Some(2));
}
@@ -75,8 +129,11 @@ mod tests {
let base = Instant::now();
let mut outputs = Vec::new();
// 模拟 60fps 输入(每 16ms 一帧),目标 30fps(每 33ms 一帧),
// 期望 10 帧输入至少产生 3 帧输出。
for i in 0..10u32 {
let t = base + Duration::from_millis(i as u64 * 16);
// `if let Some(f) = ...`:模式匹配解构 `Option`,类比 Go 的 `if v, ok := ...; ok {}`。
if let Some(f) = limiter.on_new_frame(i, t) {
outputs.push(f);
}
@@ -96,8 +153,11 @@ mod tests {
let mut limiter: FpsLimit<u32> = FpsLimit::new(30);
let now = Instant::now();
limiter.on_new_frame(1, now);
// 第二帧被丢弃,但仍缓存到 `on_deck`。
limiter.on_new_frame(2, now + Duration::from_millis(1));
// flush 取出被丢弃的最后一帧(=2)。
assert_eq!(limiter.flush(), Some(2));
// 第二次 flush 应返回 `None``take` 已清空)。
assert_eq!(limiter.flush(), None);
}
}
+18
View File
@@ -1,11 +1,29 @@
//! `wl-webrtc` 库 crate 入口。
//!
//! 本 crate 既被三个二进制(`wl-webrtc`、`vaapi_import_bench`、`sw_encode_bench`
//! 复用,也对外暴露测试入口。下面按声明顺序列出所有子模块。
//!
//! Rust 的 `pub mod xxx;` 类似 Go 的 package 组织:每个文件即一个模块,
//! 但 Rust 模块是分层的文件树(`src/<mod>.rs` 或 `src/<mod>/mod.rs`)。
// CLI 参数定义(clap derive):类似 Go 的 flag 包,但用过程宏从结构体字段自动生成。
pub mod args;
// FFmpeg/VAAPI 硬件编码 FFI 绑定(含大量 unsafe),是项目最密集的 C interop 模块。
pub mod avhw;
// 后端自动检测:根据 Wayland global 与 D-Bus 服务在 wlr-screencopy 与 XDG Portal 之间选择。
pub mod backend_detect;
// XDG Portal + PipeWire 截屏后端实现。
pub mod cap_portal;
// wlroots `wlr-screencopy-unstable-v1` 协议绑定。
pub mod cap_wlr_screencopy;
// 帧率限制器:基于 `std::time::Instant` 控制捕获循环节奏。
pub mod fps_limit;
// wlroots 后端核心状态机:用 `mio` 直接跑 Wayland fd 事件循环。
pub mod state;
// Portal 后端核心状态机:基于 `tokio` + crossbeam channel 拉取 PipeWire 帧。
pub mod state_portal;
// 管道性能统计:用 `AtomicU64` + `Mutex<HashMap>` 暴露帧率/延迟计数。
pub mod stats;
// 图像变换(旋转/翻转):对传入帧做几何变换。
pub mod transform;
// str0m WebRTC 信令服务器:内嵌一个轻量 HTTP 端点做 SDP 交换。
pub mod webrtc;
+83
View File
@@ -1,11 +1,48 @@
//! # wl-webrtc 程序入口(main 函数所在文件)
//!
//! 本文件是 `wl-webrtc` 二进制 crate 的入口,等价于 Go 的 `func main()`。
//! 由于 Rust 的 `main()` 不允许返回错误(`Result`),本项目采用通用模式:
//! 真正的业务逻辑写在 `fn run() -> Result<()>`,而 `main()` 直接 `run()` 完成所有工作。
//!
//! 整体执行流程:
//! 1. 通过 `clap` 解析命令行参数(`Args`,包含分辨率、编码格式、帧率等)
//! 2. 初始化 `tracing` 日志系统(受 `RUST_LOG` 环境变量或 `-v` 参数控制)
//! 3. MVP 阶段拒绝非 H.264 编码格式
//! 4. 要求至少提供 `--output`(输出到文件)或 `--port`(启动 WebRTC 信号服务器)
//! 5. 调用 `backend_detect::detect_backend` 自动检测当前 Wayland 桌面支持的截屏后端
//! 6. 根据检测结果进入对应的事件循环:
//! - 支持 `zwlr_screencopy_manager_v1` 的合成器(Sway/Hyprland)→ `run_wlr_screencopy`
//! - 仅支持 XDG Portal ScreenCast 的桌面(GNOME/KDE)→ `run_portal_pipewire`
//!
//! 两个事件循环都基于 `mio`(一个手动驱动的事件循环库,类似 Go runtime netpoller 的手动版),
//! 底层在 Linux 上使用 epoll。
// 获取 Unix 原始文件描述符所需的 trait
// AsRawFd 提供了 as_raw_fd() 方法,用于从 std::io::Read/Write 等 Rust 抽象中
// 取出底层的 libc::c_intPOSIX 文件描述符),mio 注册 fd 监听时需要它
use std::os::unix::io::AsRawFd;
// anyhow::Result<T, anyhow::Error> 是一个简化的错误类型,等价于 Go 的 (T, error)
// ? 操作符会将任何实现了 std::error::Error 的错误转换为 anyhow::Error
use anyhow::Result;
// clap::Parser 是一个 derive 宏,实现后 args.parse() 即可从 std::env::args() 解析 CLI 参数
// 类比 Go 的 flag.Parse(),但 clap 自动生成 --help 文本和错误处理
use clap::Parser;
// mio::unix::SourceFd 是一个 bridge:将裸 fd 包装为实现 mio::Evented 的对象
// 这样 mio 的 epoll 可以监听任意 Unix fd,而不局限于 std::net::TcpStream 等标准类型
use mio::unix::SourceFd;
// mio 是一个手动驱动的事件循环库(与 tokio 的异步运行时不同,mio 不调度 future)
// - Pollepoll/kqueue 的 Rust 封装,poll.poll() 会阻塞直到 fd 就绪
// - Interest:注册时的关注事件类型(READABLE / WRITABLE
// - Token:用户自定义的事件源标识(u64 包装),用于在 poll 返回时区分是哪个 fd 触发的
// - Events:poll 返回的事件集合(一个容量固定的 Vec)
// 类比 Go runtime 的 netpoller,但 Go runtime 自动调度,mio 需要用户手动循环
use mio::{Events, Interest, Poll, Token};
// registry_queue_init 是 wayland-client 的便捷函数:连接到合成器并初始化全局注册表队列
// 它会在内部调用 Connection::connect_to_env() 并 roundtrip 一次拿到全局对象列表
use wayland_client::globals::registry_queue_init;
// Connection 是与 Wayland 合成器的会话连接,封装了 Unix socket 的读写和协议解析
// 类比 Go 中的 net.Conn,但 Wayland 协议是有状态的消息流而非字节流
use wayland_client::Connection;
// 各功能模块声明
@@ -21,6 +58,8 @@ mod stats; // 管道性能统计(卡顿诊断)
mod transform; // 图像变换(旋转/翻转)
mod webrtc; // WebRTC 传输(str0m Sans-IO
// 引入本 crate 内部模块,crate:: 前缀表示从 crate root 开始的绝对路径
// 类比 Go 中的 import "<module>/args" 写法
use crate::args::Args;
use crate::cap_wlr_screencopy::CapWlrScreencopy;
use crate::state::EncConstructionStage;
@@ -46,6 +85,11 @@ fn main() -> Result<()> {
// 根据 verbose 模式或 RUST_LOG 环境变量设置日志级别
// 支持 RUST_LOG 粒度控制(如 RUST_LOG=wl_webrtc::webrtc=trace
// 详细解释:
// - try_from_default_env() 返回 Result<EnvFilter>,读取 RUST_LOG 环境变量
// - unwrap_or_else(|_| {...}) 是 Result 的方法:成功则返回内部值,失败时调用闭包
// - |_| 是闭包参数语法:|参数| 表达式,单个 _ 表示忽略参数(这里是 Err 类型)
// 类比 Go 的 if err != nil { fallback },但 Rust 用闭包传递 fallback 逻辑
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if args.verbose {
tracing_subscriber::EnvFilter::new("debug")
@@ -53,6 +97,11 @@ fn main() -> Result<()> {
tracing_subscriber::EnvFilter::new("info")
}
});
// tracing_subscriber::fmt() 是 Builder 模式:链式调用配置,最后 .init() 消费 builder
// 完成全局订阅注册。再次调用 .init() 会 panic,因此只能初始化一次。
// - with_env_filter: 设置过滤规则
// - with_writer: 设置日志输出目标(这里为 stderr,避免污染 stdout 用于视频流)
// - init(): 消费 self,注册全局默认 subscriber,无返回值
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
@@ -69,6 +118,8 @@ fn main() -> Result<()> {
);
// MVP 阶段仅支持 H.264 编码,不支持 HEVC
// anyhow::bail! 是一个宏(注意感叹号 !),立即返回 Err(anyhow::Error)
// 类比 Go 的 fmt.Errorf("...") + return err,但是 Rust 用宏实现
if args.codec != "h264" {
anyhow::bail!("HEVC not supported in MVP. Use --codec h264");
}
@@ -79,9 +130,14 @@ fn main() -> Result<()> {
// 自动检测当前桌面环境可用的截屏后端
// 会尝试列举 Wayland 全局对象,判断合成器是否支持 wlr-screencopy 协议
// 行尾的 ? 是错误传播操作符:若 detect_backend 返回 Err,立即将该错误作为 fn main 的返回值
// 等价于 Go 的 if err != nil { return err },但 Rust 中 ? 适用于任何 Result/Option
let backend = crate::backend_detect::detect_backend(&args)?;
// 根据检测结果进入对应的事件循环
// match 是 Rust 的模式匹配表达式(类比 Go 的 switch 但更强大)
// 每个 => 左侧是模式(这里是枚举变体),右侧是返回 Result<()> 的函数调用
// 由于 fn main 返回 Result<()>,这里直接把 match 表达式作为函数返回值(无分号 + 无 return)
match backend {
crate::backend_detect::CaptureBackend::WlrScreencopy => run_wlr_screencopy(args),
crate::backend_detect::CaptureBackend::PortalPipeWire => run_portal_pipewire(args),
@@ -104,9 +160,13 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// Connect to Wayland compositor
// 建立 Wayland 连接并初始化全局注册表
// 通过环境变量 $WAYLAND_DISPLAY 找到合成器的 Unix socket
// 行尾的 ? 是 fn run_wlr_screencopy 内首次出现的错误传播操作符:
// 若 connect_to_env 返回 Err,立即作为函数返回值向上抛出(类比 Go 的 return err
let conn = Connection::connect_to_env()?;
// registry_queue_init 会绑定全局注册表回调,
// 当合成器广播其全局对象(输出、截屏管理器等)时,State 会收到通知
// 返回值是元组 (GlobalManager, EventQueue),用 let 解构模式匹配赋值
// mut queue 表示 queue 在后续代码中会被修改(Rust 默认不可变,需 mut 显式声明)
let (gm, mut queue) = registry_queue_init::<State<CapWlrScreencopy>>(&conn)?;
let qhandle = queue.handle();
@@ -119,14 +179,19 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// compositor has already sent (may be EAGAIN if nothing yet).
// 获取 Wayland socket 的文件描述符,并消费合成器已发送的事件
// 这个 fd 是后续 mio epoll 监听的对象,当合成器写入数据时变为可读
// 用 { ... } 块表达式将临时变量 guard 限制在作用域内,作用域结束自动 drop
let wayland_fd = {
let guard = queue
.prepare_read()
// ok_or_else 是 Option 的方法:None 时调用闭包生成 Err,得到 Result
// || anyhow::anyhow!(...) 是无参数闭包语法(类比 JS 的 () => ...
// 行尾 ? 将 Result<_, Err> 解开为 Err 时立即从函数返回
.ok_or_else(|| anyhow::anyhow!("Failed to prepare Wayland read"))?;
// 从 prepare_read 的 guard 中获取底层 socket 的原始文件描述符
let fd = guard.connection_fd().as_raw_fd();
// 尝试非阻塞读取合成器已发送但尚未消费的数据
// 如果没有数据会返回 EAGAIN,这里用 let _ 忽略
// let _ = expr 是显式忽略表达式返回值的惯用法,等价于 Go 的 _ = expr
let _ = guard.read();
fd
};
@@ -148,6 +213,9 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
revents: 0,
};
// timeout=0 表示非阻塞,立即返回当前 fd 状态
// unsafe { ... } 是 Rust 的不安全块:内部调用 C 库 libc::poll,需要程序员
// 手动保证 &mut pfd 是有效的可变引用、fd 合法、不并发访问等不变量。
// unsafe 不关闭 Rust 借用检查,只是声明"我对外部 FFI 调用负责"。
let ret = unsafe { libc::poll(&mut pfd, 1, 0) };
tracing::info!(
"Raw poll on wayland fd={wayland_fd}: ret={ret}, revents={}",
@@ -225,6 +293,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
});
// 检查是否收到退出信号
// for x in &collection 是 Rust 的迭代语法,&events 表示借用 Events(不消费)
// 类比 Go 的 for _, ev := range events {}
for event in &events {
if event.token() == TOKEN_QUIT {
tracing::info!("Received quit signal");
@@ -234,8 +304,12 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
// Wayland fd 可读时,读取并分发合成器事件
// 合成器可能发来多种事件:帧数据就绪、输出信息变化、协议错误等
// events.iter().any(|e| ...) 是迭代器方法,|e| 是单参数闭包
if events.iter().any(|e| e.token() == TOKEN_WAYLAND) {
// if let Some(x) = opt 是 Option 的模式匹配简写(类比 Go 的 if v, ok := m[k]; ok
if let Some(guard) = read_guard {
// match 是本函数内首次出现的多分支模式匹配
// Ok(_) 中下划线表示忽略成功值的具体内容(只关心成功/失败本身)
match guard.read() {
Ok(_) => {
// 读取成功后,dispatch_pending 会将合成器事件
@@ -276,7 +350,10 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
tracing::info!("Shutting down, flushing encoder...");
state.fps_limit.flush();
// 仅在编码器已构建完成(Streaming 阶段)时才需要刷新
// if let 枚举变体模式匹配:Streaming { enc, .. } 解构出内部字段 enc,.. 忽略其他字段
// &mut state.stage 表示可变借用(类比 Go 的指针,但 Rust 编译期保证独占)
if let crate::state::EncConstructionStage::Streaming { enc, .. } = &mut state.stage {
// if let Err(e) = result 只关心失败分支,成功值用 _ 隐式忽略
if let Err(e) = enc.flush() {
tracing::error!("Failed to flush encoder: {e}");
}
@@ -297,6 +374,8 @@ fn run_wlr_screencopy(args: Args) -> Result<()> {
/// - 收到退出信号时停止
/// 4. 退出时关闭 Portal 连接并释放 PipeWire 资源
fn run_portal_pipewire(args: Args) -> Result<()> {
// 函数内 use 声明:将长路径名简化为局部短名,仅在该函数作用域内生效
// 类比 Go 函数内的局部 import 别名
use crate::state_portal::StatePortal;
tracing::info!("Using Portal/PipeWire backend (KWin/KDE/GNOME)");
@@ -305,6 +384,7 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
// 1. 通过 D-Bus 连接到 XDG Portal 的 ScreenCast 接口
// 2. 请求用户授权屏幕录制权限
// 3. 建立 PipeWire 流连接,准备接收帧数据
// 行尾 ? 是本函数内首次出现的错误传播操作符:失败时立即从 fn run_portal_pipewire 返回 Err
let mut state = StatePortal::new(args)?;
// Set up signal handling only (no Wayland fd needed)
@@ -352,6 +432,9 @@ fn run_portal_pipewire(args: Args) -> Result<()> {
// poll_and_encode 会从 PipeWire 缓冲区取出帧,
// 编码为 H.264 并推送。返回 true 表示还有更多帧待处理,
// 返回 false 表示当前没有帧了,while 循环退出等待下一轮 poll
// 外层 if 触发首次取帧(drain_first=true 表示允许阻塞等待),
// 内层 while state.poll_and_encode(false)? {} 是空循环体语法:
// 循环条件持续求值,只要返回 true 就重复,循环体 {} 不做额外事
if state.poll_and_encode(true)? {
while state.poll_and_encode(false)? {}
}
+541
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -1,3 +1,32 @@
//! Portal 后端的主状态机:通过 PipeWire + DMA-BUF 进行屏幕采集并软件编码。
//!
//! ## 整体角色
//!
//! `StatePortal` 与 `src/state.rs::State` 是平行的两条采集路径:
//! - `state.rs`wlroots 路径):由外层 `mio` 事件循环驱动(手工版 epoll),
//! 通过 `zwlr_screencopy_manager_v1` 协议一帧一帧地拉取。
//! - `state_portal.rs`(本文件,XDG Portal / PipeWire 路径):由 `CapPortal`
//! 通过 `crossbeam_channel::Receiver<PwDmaBufFrame>` 推帧;本状态机只负责"消费"。
//!
//! ## 异步模型的真相
//!
//! 本文件**不**使用 `mio` 或 `tokio`——`CapPortal` 内部在独立线程跑 PipeWire
//! asyncio loop,把 DMA-BUF 帧通过 crossbeam channel 投递出来;外层 `main.rs`
//! 只需在 `while !is_errored()` 循环里轮询 `poll_and_encode(block)`。编码线程与
//! WebRTC 线程通过 `std::thread::spawn`(不是 `tokio::spawn`)启动,再借助
//! crossbeam channel 与主线程通信——类比 Go 的 `go func()` + channel。
//!
//! ## 阶段机
//!
//! `PortalStage::WaitingForFormat`(等首帧以确定格式)→ `Streaming`(持续编码)。
//!
//! ## 注意
//!
//! - T9a(本块)覆盖文件头 + struct 定义 + `impl StatePortal`(至 `fn encode_thread_loop` 之前);
//! T9b 覆盖 `encode_thread_loop` / `webrtc_thread_loop` / `resolve_drm_device` 等自由函数。
//! - 多处 `unsafe` 调用 FFmpeg/VAAPI FFI;现有英文 SAFETY 标记保留不动,
//! 本任务在每个 unsafe 块上方加普通 `//` 中文概述(不新增 SAFETY 标记)。
// 采集门户状态模块 —— 通过 PipeWire/DMA-BUF 进行屏幕采集并编码
use std::os::fd::AsRawFd;
use std::path::PathBuf;
@@ -24,12 +53,24 @@ enum PortalStage {
Streaming,
}
/// 编码线程单帧计时回执——由 `encode_thread_loop` 通过 `timing_tx` 发回主线程,
/// 用于在 `PipelineStats` 中窗口化统计 `sws_us`libswscale 缩放开销)和
/// `encode_us`H.264 软件编码开销)。类比 Go 的 `type EncodeThreadTiming struct`。
struct EncodeThreadTiming {
sws_us: u64,
encode_us: u64,
output_bytes: usize,
}
/// 编码工作线程的句柄与通信端点。
///
/// 由主线程持有,负责把 NV12 帧 (`CpuNv12Frame`) 通过 `input_tx` 投递给
/// `encode_thread_loop`;编码完成后通过 `timing_rx` 收回单帧计时;`duplicate_count`
/// 是跨线程共享的 `Arc<AtomicU64>`(类比 Go 的 `*uint64` protected by atomic),
/// 用于统计被去重跳过的帧数(影响 BWE 与丢弃策略)。
///
/// 字段全部用 `Option<...>`/`Sender`/`Receiver` 包装,是为了在 `shutdown` 时
/// 能用 `Option::take()` 把所有权转移到本地变量、显式 drop `input_tx`、再 `join()`。
struct EncodeThread {
handle: Option<std::thread::JoinHandle<()>>,
input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
@@ -37,6 +78,11 @@ struct EncodeThread {
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// WebRTC 工作线程的句柄与单向上行通道。
///
/// 主线程只能通过 `sent_gap_rx` **被动接收** WebRTC 线程上报的"已发送帧间隔/老化"
/// 指标(用于 `PipelineStats::record_send_from_thread`)。下行的码率 / 分辨率 / 暂停
/// 控制走另一组 channel`bitrate_tx` / `resolution_tx` / `webrtc_paused`),不在此处。
struct WebrtcThread {
handle: Option<std::thread::JoinHandle<()>>,
sent_gap_rx: crossbeam_channel::Receiver<(f64, Option<f64>)>,
@@ -71,6 +117,14 @@ pub struct StatePortal {
last_pts_emitted: Option<i64>,
}
// `impl StatePortal` 块集中了门户路径的所有主线程逻辑:
// - `new`:构造(DRM 设备探测 + CapPortal 初始化;编码器延后到首帧)。
// - `poll_and_encode`:外层 main 循环每轮调用一次,处理 1 个 PipeWire 帧 / 控制事件。
// - `shutdown`:幂等清理(编码线程 → WebRTC 线程 → MP4 flush)。
// - 私有辅助:`record_capture_timeout` / `record_frame_arrival`(采集空闲日志节流)、
// `resolve_drm_device_for_frame`DMA-BUF 导入兼容性探测)、
// `handle_pw_frame`VAAPI 导入 + 软件编码)、`compute_capture_pts`90kHz RTP PTS)。
// 内部不使用任何锁——所有 `&mut self` 由外层 main 循环单线程串行化保证独占。
impl StatePortal {
/// 创建门户状态实例
///
@@ -219,6 +273,10 @@ impl StatePortal {
if self.webrtc.is_some() {
let paused = self.webrtc_paused.as_ref()
.ok_or_else(|| anyhow::anyhow!("internal invariant broken: webrtc_paused missing while WebRTC mode is active"))?;
// WebRTC 模式需要 6 路 crossbeam channel 协调主线程 ↔ 编码线程 ↔ WebRTC 线程。
// `crossbeam_channel::bounded::<T>(n)` 类比 Go 的 `make(chan T, n)`——
// 容量满时 `send` 阻塞、空时 `recv` 阻塞;返回的 `(Sender, Receiver)` 各占一份
//所有权,可 move 到不同线程(前提是元素类型 `T: Send`)。
let (resolution_tx, resolution_rx) =
crossbeam_channel::bounded::<BitrateCommand>(4);
let (encoder_resolution_tx, encoder_resolution_rx) =
@@ -252,7 +310,19 @@ impl StatePortal {
let duplicate_count = std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
);
// Arc 引用计数克隆(不是深拷贝)——`duplicate_count` 留在主线程,
// `duplicate_count_for_thread` move 进编码线程;两者指向同一原子。
// 类比 Go 的 `*uint64` + atomic.Store,但 Rust 用类型系统保证线程安全。
let duplicate_count_for_thread = duplicate_count.clone();
// `std::thread::Builder::new().name(...).spawn(move || {...})?`
// - 类比 Go 的 `go func() {...}()`,但返回 `JoinHandle<T>` 而非 fire-and-forget——
// 主线程可在 shutdown 时 `handle.join()` 等待子线程退出。
// - **不**用 `tokio::spawn`:编码是 CPU 密集 + 阻塞 FFmpeg 调用,
// 不需要 async/await;标准线程更直接。
// - `move ||` 闭包:把 `encode` / `input_rx` / `timing_tx` /
// `duplicate_count_for_thread` 的所有权**转移**给子线程(类比 Go 里把变量
// 显式传入 goroutine 闭包参数)。
// - `?` 传播 `io::Error`——线程创建可能失败(资源限制)。
let handle = std::thread::Builder::new()
.name("wl-webrtc-encode".into())
.spawn(move || {
@@ -283,6 +353,10 @@ impl StatePortal {
let max_bitrate = self.args.max_bitrate;
let (sent_gap_tx, sent_gap_rx) =
crossbeam_channel::bounded::<(f64, Option<f64>)>(64);
// WebRTC 工作线程:同上 `std::thread::spawn(move || ...)` 模式——
// 内部跑 str0m 的 asyncio loop`WebRtcState` 自己驱动),
// 通过 `webrtc_rx` 接收 H.264 帧、通过 `bitrate_tx` / `resolution_tx`
// 接收码率/分辨率指令、通过 `sent_gap_tx` 上报发送指标。
let webrtc_handle = std::thread::Builder::new()
.name("wl-webrtc-webrtc".into())
.spawn(move || {
@@ -366,6 +440,11 @@ impl StatePortal {
Ok(true)
}
/// 记录"采集超时"——本次轮询未取到帧(PipeWire 队列空)。
///
/// 因为 Wayland 是 damage-driven(只有画面变化才推帧),静态画面下长时间无帧
/// 是**正常**行为,不是 compositor 卡死。所以本函数只做"5 秒阈值后的 DEBUG 一次性日志"
/// 用 `idle_log_start` 字段保证每次空闲区间只发一条日志(issue #15 / #18)。
fn record_capture_timeout(&mut self) {
let Some(last_capture_arrival) = self.last_capture_arrival else {
return;
@@ -391,6 +470,11 @@ impl StatePortal {
}
}
/// 记录"采集到达"——本次轮询成功取到一帧。
///
/// 与 `record_capture_timeout` 互补:若之前处于空闲区间,则通过 `Option::take()`
/// 取出 `idle_log_start` 并发一条 "resumed after idle" DEBUG 日志;然后刷新
/// `last_capture_arrival` 时间戳。两者共同实现"一次性空闲日志"语义。
fn record_frame_arrival(&mut self) {
if let Some(idle_start) = self.idle_log_start.take() {
tracing::debug!(
@@ -459,6 +543,10 @@ impl StatePortal {
// processing — DMA-BUF import, VAAPI scale, NV12 clone, channel send, and
// encode thread wakeup. This eliminates ~60fps of pointless work during
// the pre-connect idle window. MP4 mode (webrtc_paused == None) is unaffected.
// `Arc<AtomicBool>` 类比 Go 的 `*atomic.Bool`——`Arc` 提供跨线程共享所有权
// (引用计数原子递增/递减),`AtomicBool` 提供无锁读/写。
// `Ordering::Relaxed`:只保证单变量原子性,不建立与其他变量的 happens-before 关系——
// 对"暂停标志"足够(不需要它做屏障同步)。
if let Some(paused) = &self.webrtc_paused {
if paused.load(Ordering::Relaxed) {
return Ok(());
@@ -478,6 +566,10 @@ impl StatePortal {
if let Some(enc) = self.enc.as_mut() {
// 将 DMA-BUF 帧零拷贝导入 VAAPI 硬件帧池
// unsafeFFI 调用 FFmpeg `av_hwframe_ctx_init` / `av_hwframe_map` 系列,
// 内部会读取 `enc.frames_rgb()` 指向的 `AVBufferRef`(硬件帧池),
// 并把 `frame.fd.as_raw_fd()`DMA-BUF dmabuf fd)注册到 VAAPI。
// 安全性前提:`enc` 在本线程独占(main 串行化保证)、`frame.fd` 未被 close。
let mut vaapi_frame = unsafe {
avhw::import_dma_buf_to_vaapi(
enc.frames_rgb().as_ptr(),
@@ -515,6 +607,8 @@ impl StatePortal {
};
self.stats.record_encode(&timings);
} else if let Some(import) = self.enc_import.as_mut() {
// 同上 unsafeDMA-BUF → VAAPI 导入;`import.frames_rgb()` 是与编码线程
// **不共享**的独立硬件帧池(避免与 `import_and_scale` 的回读路径竞争)。
let mut vaapi_frame = unsafe {
avhw::import_dma_buf_to_vaapi(
import.frames_rgb().as_ptr(),
@@ -540,6 +634,9 @@ impl StatePortal {
"internal invariant broken: encode thread missing while async import is active"
)
})?;
// `try_send` 类比 Go 的 `select { case ch <- v: default: }`——
// 非阻塞投递;三种结果分别处理:成功递增、满了丢弃(DEBUG 日志)、
// 对端关闭(致命,置 `errored=true` 让外层循环退出)。
match enc_thread.input_tx.try_send(cpu_nv12) {
Ok(()) => {
self.frames_encoded += 1;
@@ -608,6 +705,10 @@ impl StatePortal {
self.shutdown_started = true;
// 1. Stop encode thread (drops webrtc_tx → signals WebRTC thread to exit)
// `Option::take()` 把 `EncodeThread` 的所有权从 `self.enc_thread` 转移到本地 `enc_thread`
// 同时 `self.enc_thread` 变成 `None`——这是 Rust 里"消费字段但保留父结构体"的标准习语,
// 类比 Go 里把字段设为 nil 但保留外层 struct。接下来显式 `drop(input_tx)` 关闭 channel
// 编码线程的 `input_rx.recv()` 会返回 `Err(Disconnected)` 从而退出循环。
if let Some(mut enc_thread) = self.enc_thread.take() {
drop(enc_thread.input_tx);
if let Some(handle) = enc_thread.handle.take() {
@@ -652,12 +753,21 @@ impl StatePortal {
}
}
// === 编码线程主循环(独立 std::thread,非 tokio ===
// 类比 Go`go func(input <-chan Frame) { for f := range input { encode(f) } }`。
// 线程持有 SwEncEncode 的所有权(move 语义),消费 input_rx 直到对端 drop 所有 Sender。
// 编码结果通过 timing_tx(单帧耗时)+ duplicate_count(重复帧统计)回传主线程。
fn encode_thread_loop(
mut encode: SwEncEncode,
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
// 阻塞循环:`match input_rx.recv()` 类比 Go `for frame := range input_rx {}`。
// - Ok(frame) → 调用 encode_cpu_framematch EncodeOutcome 各 variant 分别处理;
// timing_tx.try_send 非阻塞回执(满则丢,类比 Go `select { case ch <- v: default: }`);
// 重复帧计数通过 Arc<AtomicU64>::fetch_add + Relaxed 累加——无锁、无需 happens-before。
// - Err(_) → 所有 Sender 已 dropflush 编码器后退出循环。
loop {
match input_rx.recv() {
Ok(frame) => {
@@ -694,6 +804,13 @@ fn encode_thread_loop(
tracing::info!("Encode thread exiting");
}
// === WebRTC 信令 + 帧发送主循环(独立 std::thread,非 tokio ===
// 该线程串行处理 4 件事:
// 1. str0m 信令(ICE/DTLS+ RTP 打包发送(wrtc.handle_signaling / poll_and_feed);
// 2. 自适应码率(BWE)→ bitrate_tx 下发 UpdateBitrate/ForceKeyframe 给编码线程;
// 3. 自适应分辨率(每 1s 评估)→ resolution_tx 下发 UpdateResolution
// 4. 从 webrtc_rx 取已编码 H264 帧,写入 str0m RTP sink。
// 暂停状态由 Arc<AtomicBool> 跨线程共享:编码线程读,本线程写。
fn webrtc_thread_loop(
mut wrtc: WebRtcState,
webrtc_rx: crossbeam_channel::Receiver<EncodedH264Frame>,
@@ -713,6 +830,7 @@ fn webrtc_thread_loop(
let mut current_tier = initial_tier;
let mut upscale_counter = 0u32;
let mut last_resolution_eval = Instant::now();
// recv 超时 1ms——既能让循环周期性处理 str0m 信令,又能在帧到达时立即返回。
let timeout = Duration::from_millis(1);
loop {
@@ -730,6 +848,8 @@ fn webrtc_thread_loop(
}
let connected = wrtc.is_connected();
// Arc<AtomicBool> 跨线程协调:编码线程 Relaxed 读 paused;本线程 Relaxed 写。
// Relaxed 取舍:暂停标志无内存序需求(不保护其他共享数据),只需原子可见性。
let was_paused = paused.load(Ordering::Relaxed);
let now_paused = !connected;
if was_paused && !now_paused {
@@ -799,6 +919,8 @@ fn webrtc_thread_loop(
}
if connected {
// 已连接:批量 drain 已编码帧队列(类比 Go `for { select { case f := <-rx: send(f); default: break } }`)。
// saturating_add 防止计数器溢出(Go 没有,Rust 默认 panic-on-overflowdebug 下尤其危险)。
while let Ok(enc_frame) = webrtc_rx.try_recv() {
if let Err(e) = wrtc.write_h264_frame(&enc_frame.data, enc_frame.pts_ticks) {
tracing::debug!("WebRTC write frame error: {e}");
@@ -815,9 +937,12 @@ fn webrtc_thread_loop(
let _ = sent_gap_tx.try_send((gap_ms, age_ms));
}
} else {
// 未连接:丢弃积压帧防止 drain 时刻反向堆积(类比 Go `for { select { case <-rx: default: return } }`)。
while webrtc_rx.try_recv().is_ok() {}
}
// recv_timeout:阻塞至下一帧或最多 1ms——保证 str0m 信令循环周期性推进。
// 三路 ResultOk → 处理帧;Err(Timeout) → 继续下一轮循环处理信令;Err(Disconnected) → 编码线程已退出,本线程返回。
match webrtc_rx.recv_timeout(timeout) {
Ok(enc_frame) => {
if wrtc.is_connected() {
@@ -846,12 +971,19 @@ fn webrtc_thread_loop(
tracing::info!("WebRTC thread exiting");
}
// 自适应分辨率阶梯(从高到低)。下标 0 = 最高分辨率(2K),下标 2 = 最低(720p)。
// BWE 不足时 select_resolution 从数组下标小的(高分辨率)向大的(低分辨率)切换;
// 反向 upscale 由 next_upscale_tier 处理,受 initial_tier 上限约束(不会超过初始分辨率)。
const RESOLUTION_TIERS: &[(u32, u32)] = &[(2560, 1440), (1920, 1080), (1280, 720)];
// 启发式码率估算:`5 × W × H × fps / 100` 即 0.05 bits/pixel/frame。
// 类似 H.264 平均量化参考值,作为 BWE 充分性判据(≥ 60% 认为可承载当前分辨率)。
fn resolution_bitrate_bps(width: u32, height: u32, fps: u32) -> u64 {
5 * u64::from(width) * u64::from(height) * u64::from(fps) / 100
}
// WebRTC 启动码率:按总像素数分 4 档(≤1M / ≤2.5M / ≤4.5M / 其他 → 1/2/4/8 Mbps)。
// 仅影响客户端连接后第一个 IDR;BWE 估计(毫秒级到达)会覆盖此值。详见 issue #21。
/// Conservative startup bitrate for WebRTC mode, tier-based by total pixel count.
/// BWE estimate arrives within milliseconds of client connect and overrides this;
/// the startup value only affects the first IDR. See issue #21.
@@ -868,6 +1000,8 @@ fn webrtc_startup_bitrate_bps(width: u32, height: u32) -> u64 {
}
}
// 基于 BWE 选择分辨率阶梯。返回 (width, height)。
// 决策逻辑:若 BWE ≥ 当前分辨率所需码率的 60%,保持不变;否则降到下一档(最低 720p)。
/// Select resolution tier based on BWE estimate.
/// Returns (width, height) for the selected tier.
fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) -> (u32, u32) {
@@ -877,6 +1011,8 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
return current;
}
// 在 RESOLUTION_TIERS 中找当前分辨率的位置;若不在表中(如 1366×768),
// 用 unwrap_or_else 退回到第一个宽高都不超过 current 的档位,最终兜底取最小档(720p)。
let current_index = RESOLUTION_TIERS
.iter()
.position(|&tier| tier == current)
@@ -890,12 +1026,16 @@ fn select_resolution(current_w: u32, current_h: u32, bwe_bps: u64, fps: u32) ->
RESOLUTION_TIERS[next_index]
}
// 反向 upscale:在 ceiling 上限内尝试升一档;若已在最高档或下一档超出 ceiling 则返回 None。
// 调用方需要"连续 10 次 BWE 充足"才真正切换,避免 BWE 抖动导致频繁分辨率变化。
fn next_upscale_tier(current: (u32, u32), ceiling: (u32, u32)) -> Option<(u32, u32)> {
let current_index = RESOLUTION_TIERS.iter().position(|&tier| tier == current)?;
if current_index == 0 {
return None;
}
let next = RESOLUTION_TIERS[current_index - 1];
// bool::then_some(true → Some(next)false → None):将谓词结果转换为 Option,
// 类比 Go `if ok { return &tier } else { return nil }`。
(next.0 <= ceiling.0 && next.1 <= ceiling.1).then_some(next)
}
@@ -946,6 +1086,10 @@ fn resolve_drm_device(args: &Args) -> Result<Option<PathBuf>> {
/// 用于验证 DMA-BUF 元数据映射的正确性。
#[cfg(test)]
fn build_drm_descriptor(frame: &PwDmaBufFrame) -> ffmpeg_next::ffi::AVDRMFrameDescriptor {
// unsafe:调用 std::mem::zeroed() 对 #[repr(C)] 结构体进行零初始化——
// AVDRMFrameDescriptor 是 FFmpeg C 结构体,零值是合法的"空"状态(nb_objects/nb_layers=0
// 后续字段在下方显式赋值)。`std::mem::zeroed` 对带指针字段的类型可能产生空悬指针(UB),
// 此处安全:descriptor 的所有字段都是整数/数组,没有指针/引用。
let mut desc: ffmpeg_next::ffi::AVDRMFrameDescriptor = unsafe { std::mem::zeroed() };
desc.nb_objects = 1; // 单个 DMA-BUF 对象
desc.objects[0].fd = frame.fd.as_raw_fd(); // DMA-BUF 文件描述符
@@ -969,6 +1113,8 @@ mod tests {
fn make_test_frame() -> PwDmaBufFrame {
// Create a dummy fd from stderr (always valid fd 2)
// 使用 stderr(fd 2)的副本作为虚拟文件描述符
// unsafelibc::dup(2) 复制 stderr fd → 返回新整数 fdOwnedFd::from_raw_fd 接管
// 该 fd 的 close 责任(RAII)。前提:libc::dup 调用成功(fd 2 始终有效,不检查返回值是测试代码约定)。
let fd = unsafe { OwnedFd::from_raw_fd(libc::dup(2)) };
PwDmaBufFrame {
fd,
@@ -1092,6 +1238,7 @@ mod tests {
#[test]
fn build_drm_descriptor_custom_offset_and_stride() {
let frame = PwDmaBufFrame {
// unsafe:同 make_test_frame——dup(2) 复制 stderr fd 并交给 OwnedFd 管理。
fd: unsafe { OwnedFd::from_raw_fd(libc::dup(2)) },
offset: 4096, // 4KB 对齐偏移
stride: 3840 * 4, // 4K 宽度 × 4 字节
+265
View File
@@ -1,3 +1,25 @@
//! 管道性能统计模块 —— 用于卡顿诊断的轻量级滑动窗口统计。
//!
//! 本模块跟踪 capture / encode / send 三段流水线的每秒指标快照:
//! - **FPS**:捕获帧率、编码帧率、发送帧率
//! - **延迟分布**:各阶段(DMA-BUF import / VAAPI scale / GPU→CPU transfer /
//! sws_scale / H.264 encode)的 avg / p95 / max(微秒→毫秒)
//! - **队列深度**capture 队列与 encoded 队列的瞬时观测值
//! - **丢帧计数**PipeWire 丢弃、重复帧去重跳过、超预算帧
//!
//! 设计目标为低开销:仅收集计数器和时间样本,每秒输出一行结构化日志
//! (仅在 `--stats` 启用时)。所有统计在主线程独占持有 `&mut PipelineStats`
//! 跨线程数据(如 PipeWire dropped 计数、encode 线程的 duplicate 计数)
//! 通过外部 `AtomicU64` 在调用方读取后再传入本结构(见 `set_*` 系列)。
//!
//! ## 与 Go 类比
//!
//! - [`Instant::now()`] ≈ Go `time.Now()`,但精度更高(通常单调时钟)
//! - [`Duration::as_secs_f64`] ≈ Go `time.Duration.Seconds()`,但保留 f64
//! - `&mut self` ≈ Go 中显式持有 `sync.Mutex` 的写锁;本模块的字段独占模型
//! 天然无需 `Mutex`(外部跨线程读取后再以 `&mut self` 传入)
//! - `Vec<f64>` 样本缓冲 ≈ Go 中 `[]float64`,每窗口 `clear()` 复用容量
// stats.rs — Lightweight windowed pipeline statistics for stutter diagnosis
//
// Tracks per-second snapshots of capture/encode/send pipeline metrics.
@@ -6,6 +28,15 @@
use std::time::Instant;
/// 单帧流水线各阶段的耗时样本(中文概要,详细说明见下方英文文档)。
///
/// # 派生宏说明
///
/// - `#[derive(Debug)]`:便于 `dbg!()` 调试输出,类比 Go 的 `%+v` 格式化
/// - `#[derive(Default)]`:所有字段为 `u64`/`usize` 零值时构造默认实例,
/// 测试中可用 `FrameTimings { total_us: 5000, ..Default::default() }`
/// 仅指定关注字段(见 `record_and_snapshot_counts` 测试)
///
/// Per-stage timing for a single encode pipeline frame.
///
/// All values are in microseconds. The caller records timestamps around
@@ -28,6 +59,27 @@ pub struct FrameTimings {
pub output_bytes: usize,
}
/// 一秒窗口内的管道统计聚合器(中文概要,详细说明见下方英文文档)。
///
/// 本结构通过 `&mut self` 接口收集三类原始数据:计数器(`capture_frames` 等)、
/// 样本缓冲(`Vec<f64>`/`Vec<u64>`,窗口结束时 `clear()` 复用容量)、
/// 时间锚点(`Option<Instant>``None` 表示尚未观测过)。
///
/// # 所有权与并发模型
///
/// - 本结构**非 `Sync`**:`Vec` 字段无锁,跨线程读写需外部同步
/// - 主线程独占持有 `&mut self`;跨线程数据通过外部 `AtomicU64` 在调用方
/// 读取后以 `set_*` 接口注入
/// - 这与 Go 中 `sync.Mutex<PipelineStats>` 不同:Rust 借用检查器在编译期
/// 保证单一可变借用,无需运行时锁
///
/// # 与 Go 类比
///
/// - `Option<Instant>` ≈ Go `*time.Time``nil` 表示未设置),但 Rust 用枚举
/// 强制调用方处理"未设置"分支,避免 nil-pointer panic
/// - `Instant` 内部使用单调时钟,不受系统时间跳变影响;Go 1.9+ 的
/// `time.Since()` 也使用单调时钟,行为一致
///
/// Windowed statistics aggregator for the encode/send pipeline.
///
/// Collects counters and timing samples within a one-second window,
@@ -74,6 +126,11 @@ pub struct PipelineStats {
}
impl PipelineStats {
/// 构造一个空的统计聚合器(类比 Go 的 `NewXxx()` 工厂函数)。
///
/// `window_start` 初始化为当前时刻,确保 `should_snapshot()` 至少
/// 在 1 秒后才返回 true(首窗口可能短于 1 秒有效数据,但 elapsed_secs
/// 是真实窗口长度,FPS 计算依然准确)。
pub fn new() -> Self {
Self {
capture_frames: 0,
@@ -104,6 +161,21 @@ impl PipelineStats {
}
}
/// 记录一次来自 PipeWire 的捕获帧到达事件(中文 L3 解析见此)。
///
/// # 时间间隔(gap)计算
///
/// - [`Instant::now()`]:获取当前单调时刻(≈ Go `time.Now()`,但精度更高)
/// - `last.elapsed()`:返回 `Duration`,类比 Go `time.Since(last)`
/// - [`Duration::as_secs_f64`]:将 `Duration` 转为秒(f64),类比 Go
/// `dur.Seconds()`;此处乘以 1000.0 转毫秒,便于日志可读
///
/// # 首帧处理
///
/// `Option<Instant>::None` 表示窗口内首帧,没有"上一帧"参照点,
/// 因此首帧不产生 gap 样本(这与 Go 中 `*time.Time == nil` 检查等价,
/// 但 Rust 强制处理 None 分支,编译期避免 nil 解引用)。
///
/// Record that a capture frame was received from PipeWire.
pub fn record_capture(&mut self) {
let now = Instant::now();
@@ -115,6 +187,14 @@ impl PipelineStats {
self.capture_frames += 1;
}
/// 记录一帧完成编码(`FrameTimings` 路径,含各阶段微秒样本)。
///
/// # 参数借用
///
/// `timings: &FrameTimings`:以共享借用(`&`)读取,不获取所有权。
/// 类比 Go 中显式传递 `*FrameTimings` 指针;Rust 借用检查保证本调用
/// 期间原 `timings` 不会被释放。其余 gap 计算同 `record_capture`。
///
/// Record that a frame completed encoding with the given timings.
pub fn record_encode(&mut self, timings: &FrameTimings) {
let now = Instant::now();
@@ -134,10 +214,19 @@ impl PipelineStats {
self.output_bytes.push(timings.output_bytes);
}
/// 仅记录 import 阶段微秒数(用于 `record_encode_thread` 路径补齐 import 样本)。
pub fn record_import(&mut self, import_us: u64) {
self.import_us.push(import_us);
}
/// 编码线程路径:散参传入 sws / encode / output_bytes,跳过 `FrameTimings`。
///
/// # 溢出保护
///
/// `saturating_add` 在 `u64::MAX` 处饱和而非回绕,避免极端情况下
/// `total_us` 出现荒谬的小值。类比 Go 中需手动 `if total > MaxUint64 - x`
/// 检查;Rust 的 `saturating_*` / `checked_*` / `wrapping_*` 三件套
/// 让溢出策略在调用点显式表达。
pub fn record_encode_thread(&mut self, sws_us: u64, encode_us: u64, output_bytes: usize) {
let now = Instant::now();
if let Some(last) = self.last_encode_time {
@@ -153,6 +242,19 @@ impl PipelineStats {
self.output_bytes.push(output_bytes);
}
/// 记录一帧通过 WebRTC 发送(中文 L3 解析见此)。
///
/// - `wait_ms`:阻塞等待发送通道可写入的时间(毫秒);为 0 时不入样本
/// (避免拉低 p95,因为大多数帧应无等待)
/// - `capture_time`:该帧的原始捕获时刻;用于计算 **frame age**
/// (捕获→发送端到端延迟)。`Option<None>` 表示调用方未提供
/// (例如 XDG/screen-copy 路径无原始时间戳),此时不入样本
///
/// # frame_age 计算
///
/// `ct.elapsed()` 返回 `Duration`,类同 `record_capture` 中 gap 计算,
/// 但锚点是"捕获时刻"而非"上一帧发送时刻",因此测量的是端到端延迟。
///
/// Record that a frame was sent via WebRTC.
/// `wait_ms` is time spent blocked waiting to send into the channel.
/// `capture_time` is when the frame was originally captured (for frame age).
@@ -174,6 +276,18 @@ impl PipelineStats {
}
}
/// 从后台 WebRTC 发送线程记录一帧(gap_ms / age_ms 均已在调用方预算好)。
///
/// # 为何预算参数
///
/// 后台线程无法安全访问 `&mut self`(本结构非 `Sync`),因此调用方在
/// 发送时刻直接计算 `gap_ms` / `age_ms``Instant::now()` 在该线程
/// 局部调用),稍后批量 drain 到主线程的 `&mut self`。这样:
/// - 单调时钟读取在事件发生线程完成,时间戳精确
/// - 主线程仅做 `Vec::push`,无需锁
///
/// `gap_ms == 0.0` 表示首帧(无前一帧参照),不入样本。
///
/// Record a frame sent from a background WebRTC thread.
/// `gap_ms` is the pre-computed time since the previous send (0.0 = first frame).
/// `age_ms` is the pre-computed capture-to-send latency (None if unavailable).
@@ -189,11 +303,32 @@ impl PipelineStats {
}
}
/// 设置 PipeWire dropped 计数(绝对值,由调用方从外部 `AtomicU64` 读取)。
///
/// # 增量计算
///
/// 外部 `AtomicU64` 累计**总会话**的 dropped 帧数(从不重置),
/// 因此本函数计算 `total - prev` 得到本窗口内的增量。
/// `saturating_sub` 防止极端竞态(如原子读顺序不一致)导致负数回绕。
///
/// # 与 Go 类比
///
/// - 调用方代码 ≈ Go `atomic.LoadUint64(&pw.dropped)``Ordering::SeqCst`
/// 或 `Relaxed` 取决于是否需要与其他原子操作建立 happens-before
/// - `Mutex<HashMap>` 在本模块**未使用**:统计字段集固定,无需 Go
/// `sync.Map` 那样的动态键值存储;跨线程仅通过原子计数器通信
///
/// Update PipeWire dropped counter (absolute value from AtomicU64).
pub fn set_pipewire_dropped(&mut self, total_dropped: u64, prev_dropped: u64) {
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
}
/// 设置 duplicate frames skipped 计数(绝对值,由调用方从 encode 线程原子读取)。
///
/// 与 `set_pipewire_dropped` 增量算法一致,但**保留 `prev`** 在
/// `self.prev_duplicate_frames_skipped` 字段中(因为本结构才是状态持有者,
/// 调用方仅传入当前 total)。
///
/// Update duplicate frames skipped counter (absolute value from atomic).
/// Computes delta from previous value, like set_pipewire_dropped.
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
@@ -201,23 +336,49 @@ impl PipelineStats {
self.prev_duplicate_frames_skipped = total_skipped;
}
/// 更新队列深度瞬时观测值(capture 队列与 encoded 队列各一个值)。
///
/// 队列深度为快照值而非累计值,每窗口只保留最后一次观测。
///
/// Update queue depth observations.
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
self.capture_queue_depth = capture;
self.encoded_queue_depth = encoded;
}
/// 记录一帧超出预算(用于跟踪编码耗时超过 1/fps 的频次)。
///
/// Record that a frame exceeded its time budget.
pub fn record_over_budget(&mut self) {
self.over_budget_count += 1;
}
/// 判断是否到达快照点(距离上次 `snapshot_and_reset` 或构造时刻 ≥ 1 秒)。
///
/// 仅需 `&self`(共享借用):本方法不修改任何字段,借用检查器允许
/// 多个 `&self` 共存或与 `&mut self` 之外的调用并存。
///
/// Returns true if at least 1 second has elapsed since the last snapshot
/// (or since creation). If true, call `snapshot_and_reset` to get the stats.
pub fn should_snapshot(&self) -> bool {
self.window_start.elapsed().as_secs() >= 1
}
/// 计算当前窗口的统计快照并重置所有计数器与样本缓冲。
///
/// # 重置策略
///
/// - 计数器:置零
/// - `Vec`:调用 `clear()`(保留已分配容量 `Vec::capacity()`,避免下个窗口
/// 反复分配)。类比 Go 中 `s = s[:0]` 复用底层数组
/// - `window_start = Instant::now()`:重置窗口起点
///
/// # 返回值
///
/// 返回 `StatsSnapshot` 值(拷贝语义,调用方可自由使用与丢弃)。
/// 类比 Go 中返回值结构体的拷贝;Rust 中 `StatsSnapshot` 全部字段为
/// `Copy` 或 `Vec`(移动语义),返回时所有权转移至调用方。
///
/// Compute a snapshot of the current window and reset all counters.
pub fn snapshot_and_reset(&mut self) -> StatsSnapshot {
let elapsed = self.window_start.elapsed().as_secs_f64();
@@ -291,6 +452,17 @@ impl PipelineStats {
}
}
/// 一秒窗口的管道统计快照(不可变值对象,由 `snapshot_and_reset` 返回)。
///
/// 本结构持有所有派生指标(FPS、avg/p95/max、计数器快照),是日志输出的
/// 数据源。一旦创建即不可变(所有字段为 `f64`/`u64`/`usize`,天然 `Copy`),
/// 调用方可以安全地打印、记录或丢弃。
///
/// # `#[derive(Debug)]` 用途
///
/// 调试场景下可直接 `dbg!(&snap)` 或 `tracing::debug!(?snap)`
/// 类比 Go 的 `spew.Dump(snap)` / `fmt.Printf("%+v", snap)`。
///
/// A one-second snapshot of pipeline statistics.
#[derive(Debug)]
pub struct StatsSnapshot {
@@ -344,6 +516,40 @@ pub struct StatsSnapshot {
pub output_frame_bytes_max: usize,
}
/// 单行结构化日志格式化器(中文 L3 解析见此,英文说明保留在下方)。
///
/// # trait 与签名说明
///
/// - `impl std::fmt::Display for StatsSnapshot`:为本类型实现标准库 trait,
/// 使得 `format!("{snap}")` / `println!("{}", snap)` / `tracing::info!("{}", snap)`
/// 均可工作(隐式调用 `fmt` 方法)
/// - `fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result`
/// - `&self` 共享借用,格式化不改自身
/// - `Formatter<'_>`:匿名生命周期(`'_`),表示该借用与调用方持有的
/// `String`/输出流绑定;类比 Go 的 `io.Writer` 参数
/// - `std::fmt::Result``Result<(), std::fmt::Error>`,专用于格式化 trait
/// (比通用 `Result<T, E>` 更窄,避免 `?` 跨类型传播)
///
/// # `write!` 宏 vs `format!` 宏
///
/// - [`write!`]:直接写入 `Formatter`(零分配),类比 Go `fmt.Fprintf(w, ...)`
/// - [`format!`]:分配新 `String` 后返回,类比 Go `fmt.Sprintf(...)`
/// - 本实现选 `write!`:写入日志流时避免多余分配
///
/// # `?` 运算符
///
/// `write!(...)?` 中的 `?` 是早期返回:若 `write!` 返回 `Err(fmt::Error)`
/// 则立即从 `fmt` 返回该错误。类比 Go 中
/// `if _, err := w.Write(...); err != nil { return err }`
/// 但 Rust 的 `?` 让快乐路径线性化。
///
/// # 格式说明
///
/// - `{:.1}`:保留 1 位小数
/// - `{:.0}`:整数显示(无小数点)
/// - `{}`:默认 `Display` 格式(整数原样)
/// - 行尾反斜杠 `\` 跨行延续字符串字面量,类比 Python 隐式行连接;
/// 输出时不会引入额外换行或空格
impl std::fmt::Display for StatsSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
@@ -392,6 +598,13 @@ impl std::fmt::Display for StatsSnapshot {
// Statistics helpers
// ---------------------------------------------------------------------------
/// 计算 `f64` 切片平均值(空切片返回 0.0)。
///
/// # 切片借用
///
/// `data: &[f64]`:共享借用切片(fat pointer = 指针 + 长度),类比 Go 中
/// `func avg(data []float64)`。`&` 表示本函数不获取所有权,调用后原 `Vec`
/// 仍可用。
fn avg_f64(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -399,6 +612,21 @@ fn avg_f64(data: &[f64]) -> f64 {
data.iter().sum::<f64>() / data.len() as f64
}
/// 计算 p95(第 95 百分位),类比 Go 中需要手动 sort + index。
///
/// # 算法
///
/// 1. 复制输入到新 `Vec`(不修改调用方原数据):`data.to_vec()` 类比 Go
/// `append([]T{}, data...)`
/// 2. 排序:`sort_by` + `partial_cmp` —— `f64` 没有全序(NaN 特殊),
/// 不能直接用 `sort()``partial_cmp(b).unwrap_or(Equal)` 在 NaN 时
/// 降级为相等,避免 panic
/// 3. 计算 idx = `floor(len * 0.95)``idx.min(len-1)` 防越界
///
/// # 为何不用 `sort_unstable`
///
/// `f64` 的 `Ord` 未实现(NaN 不等于自身),故只能用 `sort_by` + 比较
/// 函数;`u64`/`usize` 实现 `Ord`,可用 `sort_unstable`(更快、内存友好)。
fn p95_f64(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -409,10 +637,22 @@ fn p95_f64(data: &[f64]) -> f64 {
sorted[idx.min(sorted.len() - 1)]
}
/// 返回切片最大值,空切片返回 0.0(业务上"无样本"等价于"无延迟")。
///
/// # `fold` + `f64::max`
///
/// `fold(0.0, f64::max)`:从初始值 0.0 开始,逐元素取较大值。
/// 类比 Go
/// ```go
/// m := 0.0
/// for _, v := range data { m = math.Max(m, v) }
/// ```
/// 注意:若样本全为负,0.0 仍是结果(业务上 latency 非负,不会出现)。
fn max_f64(data: &[f64]) -> f64 {
data.iter().copied().fold(0.0_f64, f64::max)
}
/// 计算 `u64` 微秒样本的平均值并转毫秒(÷1000)。
fn avg_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -420,6 +660,10 @@ fn avg_ms(data: &[u64]) -> f64 {
data.iter().sum::<u64>() as f64 / data.len() as f64 / 1000.0
}
/// 计算 `u64` 微秒样本的 p95 并转毫秒。
///
/// 与 `p95_f64` 算法相同,但 `u64` 实现 `Ord`,可用 `sort_unstable`
/// (无内存开销、更快;稳定性对本场景无关,因为只取索引位置)。
fn p95_ms(data: &[u64]) -> f64 {
if data.is_empty() {
return 0.0;
@@ -430,10 +674,15 @@ fn p95_ms(data: &[u64]) -> f64 {
sorted[idx.min(sorted.len() - 1)] as f64 / 1000.0
}
/// 对 `usize` 切片求和(用于累计 output_bytes 总量)。
///
/// `data.iter().sum()` 由标准库自动推导类型(`usize`),等价于
/// Go 中 `var total uint; for _, v := range data { total += v }`。
fn sum_usize(data: &[usize]) -> usize {
data.iter().sum()
}
/// 计算 `usize` 样本的 p95(字节大小分布),不转换单位。
fn p95_usize(data: &[usize]) -> usize {
if data.is_empty() {
return 0;
@@ -444,10 +693,26 @@ fn p95_usize(data: &[usize]) -> usize {
sorted[idx.min(sorted.len() - 1)]
}
/// 返回 `usize` 切片最大值,空切片返回 0。
///
/// `iter().copied().max()` 返回 `Option<usize>`(空时为 `None`),
/// `unwrap_or(0)` 提供默认值,类比 Go 中显式 `if len(data) == 0 { return 0 }`。
fn max_usize(data: &[usize]) -> usize {
data.iter().copied().max().unwrap_or(0)
}
/// 单元测试模块(仅在 `#[cfg(test)]` 时编译)。
///
/// # Rust 测试模式
///
/// - `#[cfg(test)]`:条件编译属性,`cargo test` 时才编译本模块,
/// 正常 `cargo build` 不包含本模块代码(类比 Go 中 `_test.go` 后缀
/// 仅在 `go test` 时段编译,但 Rust 用显式属性而非文件名约定)
/// - `use super::*`:导入父模块(本文件)所有 `pub` 与私密 item
/// 类比 Go test 文件无需 import 即可访问同包符号
/// - `#[test]`:标记测试函数;`cargo test` 自动发现并执行
/// - `assert_eq!` / `assert!`:宏(不是函数),失败时打印表达式原文
/// 便于调试,类比 Go 中 `t.Errorf` 但更早终止当前测试
#[cfg(test)]
mod tests {
use super::*;
+101
View File
@@ -1,3 +1,33 @@
//! 图像几何变换模块(纯坐标运算,不涉及像素缓冲区)。
//!
//! 对应 Wayland `wl_output::Transform` 的 8 种旋转变体(旋转 + 翻转),
//! 为屏幕捕获提供 ROIRegion of Interest)裁剪与坐标系换算。
//!
//! # 与 Go 的对照
//!
//! - Go 标准库 `image/geom.go` 的 `Rectangle` 仅支持轴对齐矩形;本模块额外处理
//! 90°/180°/270° 旋转与水平/垂直翻转下的矩形映射。
//! - Go 用 `int` 表示坐标;本模块用 `i32`(与 `wl_output` 协议一致)。
//! - Wayland 协议要求捕获 ROI 在变换后的"帧坐标"中给出,本模块负责
//! "屏坐标 → 帧坐标"的换算(见 [`screen_to_frame`])。
//!
//! 注意:本模块**不操作像素缓冲区**(无 `&[u8]` / `Vec::with_capacity`),
//! 只做整数算术;真正的像素拷贝在 `state.rs` / `cap_portal.rs` 中通过
//! DMA-BUF 或 shm 完成。计划文档中提到的 `&[u8]` slice / `Vec` 预分配
//! 等模式不属于本模块,本模块的"重量级"Rust 模式聚焦在 `match` 穷尽匹配、
//! 元组解构、if 表达式、or-pattern 与整数 helper 方法(`.abs()`/`.clamp()`)。
// Wayland `wl_output::Transform` 的 8 种变体:4 种纯旋转(Normal*+ 4 种
// "先水平翻转再旋转"Flipped*)。单元 enum(无关联数据),`Copy + Eq` 派生
// 使其可在 `match` / `==` 中零开销使用。
//
// Go 没有内置 enum,等价于 `type Transform int` + `const ( Normal = iota; ... )`
// Rust 的 enum 是真代数类型,编译期保证 `match` 穷尽性(漏写一个 variant
// 会直接编译失败,而 Go 的 switch 不强制 default)。
//
// `#[derive(...)]` 宏说明:`Debug`→允许 `{:?}` 调试输出;`Clone, Copy`→
// 单元 enum 按位复制即可(等价于 Go 整数值语义);`PartialEq, Eq`→自动生成
// `==`/`!=`,基于 variant tag 比较。
/// Coordinate transformation module for Wayland output transforms.
///
/// Handles the 8 `wl_output` transform variants (rotation + reflection)
@@ -16,6 +46,11 @@ pub enum Transform {
Flipped270,
}
// 轴对齐矩形(Axis-Aligned Bounding BoxAABB)。
//
// 所有字段 `i32`(与 Wayland 协议一致);Go 类比 `image.Rectangle` 但
// 用 `(x, y, w, h)` 而非 `(Min, Max)`,便于直接喂给 FFmpeg VAAPI 的 ROI 参数。
// `Copy + Eq`:值语义,函数传参/返回零开销(无 `&Rect` 借用开销)。
/// Axis-aligned rectangle in integer coordinates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
@@ -25,6 +60,11 @@ pub struct Rect {
pub h: i32,
}
// 返回变换对应的 2×2 基础矩阵 `(a, b, c, d)`。
//
// 这是纯算术查表,无副作用,编译器很容易内联到调用点。返回值用 4-tuple 而非
// `[i32; 4]` 数组:Rust 元组每字段可有不同类型(此处都是 i32 但语义不同),
// 模式匹配解构时更显式(`let (a, b, c, d) = ...`)。
/// Returns the 2×2 basis matrix (a, b, c, d) for the given transform.
///
/// The matrix represents the affine mapping from screen coordinates to
@@ -35,18 +75,37 @@ pub struct Rect {
/// [new_y] = [c d] [y]
/// ```
pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
// `match` 是 Rust 的模式匹配控制流,对 enum 必须**穷尽**exhaustive):
// 漏写任意 variant 会直接编译失败。Go 的 `switch` 不强制 default
// 此处 8 个 variant 必须全部列出,编译器即充当完整性检查器。
//
// 每个 arm 形如 `Pattern => expr,`,返回的 4-tuple 编码矩阵系数。
// 这些数值来自 Wayland `wl_output::Transform` 协议规范,不可随意修改。
match transform {
// 单位矩阵:屏幕坐标 = 帧坐标。
Transform::Normal => (1, 0, 0, 1),
// 顺时针 90°:x/y 互换并取反。
Transform::Normal90 => (0, 1, -1, 0),
// 180°:两轴都取反。
Transform::Normal180 => (-1, 0, 0, -1),
// 顺时针 270°(= 逆时针 90°)。
Transform::Normal270 => (0, -1, 1, 0),
// 水平翻转(沿 Y 轴镜像):x 取反。
Transform::Flipped => (-1, 0, 0, 1),
// 翻转 + 90°。
Transform::Flipped90 => (0, 1, 1, 0),
// 翻转 + 180°(等价于垂直翻转)。
Transform::Flipped180 => (1, 0, 0, -1),
// 翻转 + 270°。
Transform::Flipped270 => (0, -1, -1, 0),
}
}
// 将矩形从"屏幕坐标"映射到"帧坐标",并平移到第一象限([0, frame_w) × [0, frame_h))。
//
// 这是 ROI(捕获区域)参数换算的核心:用户在屏幕上选了一块 `(x, y, w, h)`
// 但 Wayland 帧已应用了 output transform(例如 90° 旋转),编码器看到的帧
// 坐标与屏幕坐标不同,必须先变换再喂给 VAAPI。
/// Transform a rectangle from screen space to frame space.
///
/// Applies the 2×2 basis matrix and computes offsets so the result
@@ -57,11 +116,17 @@ pub fn transform_basis(transform: Transform) -> (i32, i32, i32, i32) {
/// new_y = c * x + d * y + offset_y
/// ```
pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h: i32) -> Rect {
// 元组解构(tuple destructuring):4-tuple 一次性拆成 4 个 `i32` 变量。
// 类比 Go 的 `a, b, c, d := transformBasis(transform)`,但 Rust 的元组
// 是真类型(可作为参数/返回值),Go 只能用多返回值模拟。
let (a, b, c, d) = transform_basis(transform);
// Compute the offset so that the transformed origin maps correctly.
// For transforms with negative components, we need to shift by the
// frame dimension to keep coordinates in [0, frame_w) × [0, frame_h).
// `if ... { ... } else { ... }` 在 Rust 中是**表达式**(而非语句),
// 直接产出值赋给 `offset_x`。Go 没有 ternary,必须 `var offset_x int;
// if ... { offset_x = frame_w }`Rust 这种写法更紧凑。
let offset_x = if a + b < 0 { frame_w } else { 0 };
let offset_y = if c + d < 0 { frame_h } else { 0 };
@@ -70,6 +135,13 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
let new_w = a * rect.w + b * rect.h;
let new_h = c * rect.w + d * rect.h;
// 结构体字面量(struct literal):`Rect { x: ..., y: ..., ... }`。
// 类比 Go 的 `image.Rectangle{Min: ..., Max: ...}`Rust 允许字段简写
//(变量名与字段名相同时只写一个,例如 `x` 而非 `x: x`)。
//
// `.abs()` 是 `i32` 的内置方法(取绝对值):
// 旋转后 `new_w`/`new_h` 可能为负(例如 90° 下宽变成原高取反),
// 矩形尺寸必须非负,故取绝对值。
Rect {
x: new_x,
y: new_y,
@@ -78,32 +150,61 @@ pub fn screen_to_frame(transform: Transform, rect: Rect, frame_w: i32, frame_h:
}
}
// 90°/270° 旋转变换下,输出画布的宽高需要交换(横向屏幕旋转后变纵向)。
//
// 辅助函数:是则返回 `(h, w)`,否则原样返回 `(w, h)`。Go 类比:
// ```go
// func transposeIf(t Transform, w, h int) (int, int) {
// switch t { case Normal90, Normal270, Flipped90, Flipped270: return h, w }
// return w, h
// }
// ```
/// Swap width and height for 90° or 270° rotations.
///
/// After a quarter-turn rotation the output dimensions are transposed
/// relative to the input. This helper returns `(h, w)` for those cases
/// and `(w, h)` unchanged otherwise.
pub fn transpose_if_transform_transposed(transform: Transform, w: i32, h: i32) -> (i32, i32) {
// `match` 配合 **or-pattern**:用 `|` 把多个 variant 合并为一个 arm
// 共享同一个表达式分支。Go 的 `switch` 用 `case A, B, C:` fallthrough 等价。
// 注意 Rust 的 match 不存在隐式 fallthrough,每个 arm 必须 `=>` 显式给出表达式。
match transform {
// 四种"四分之一圈"旋转:宽高必须互换。
Transform::Normal90
| Transform::Normal270
| Transform::Flipped90
| Transform::Flipped270 => (h, w),
// `_` 是通配符(wildcard),匹配所有未列出的 variant。
// Rust 要求 match 穷尽,最后用 `_ =>` 兜底等价于 Go `default:` 分支。
// 此处涵盖 `Normal` / `Normal180` / `Flipped` / `Flipped180`。
_ => (w, h),
}
}
// 将矩形裁剪到 `(0, 0) .. (bounds_w, bounds_h)` 范围内。
//
// 用于 ROI 校验:用户给的坐标可能为负或越界,编码器不接受这样的区域,
// 必须先 clamp 到合法范围。Go 标准库没有 `clamp` 内置函数(Go 1.21 才加入
// `min`/`max` 内置),通常要手写 `if x < lo { x = lo } else if x > hi { x = hi }`
// Rust 的 `i32::clamp(lo, hi)` 是方法调用,语义更直观。
/// Clip a rectangle so it stays inside `(0, 0) .. (bounds_w, bounds_h)`.
///
/// The resulting rectangle has non-negative origin and its extent does
/// not exceed the bounds.
pub fn fit_inside_bounds(rect: Rect, bounds_w: i32, bounds_h: i32) -> Rect {
// `.clamp(lo, hi)`:将值限制在 `[lo, hi]` 闭区间内(小于 lo 返回 lo,
// 大于 hi 返回 hi,否则原值)。返回 `i32`self by value)。
let x = rect.x.clamp(0, bounds_w);
let y = rect.y.clamp(0, bounds_h);
// `.min(other)`:返回 `self` 与 `other` 的较小值(等价 Go 的 `if a < b` 三元)。
// 此处把矩形的右边界限制到 `bounds_w`,避免越界。
let right = (rect.x + rect.w).min(bounds_w);
let bottom = (rect.y + rect.h).min(bounds_h);
// `.max(other)`:返回较大值。此处保证宽高非负(`right - x` 在
// 完全越界的退化情形下可能为负,取 max(0) 兜底)。
let w = (right - x).max(0);
let h = (bottom - y).max(0);
// 字段简写:`x`/`y`/`w`/`h` 变量名与 `Rect` 字段名相同,可省略 `field: value`。
Rect { x, y, w, h }
}
+277
View File
@@ -1,3 +1,47 @@
//! # WebRTC 传输模块 — str0m Sans-IO 信令服务器与媒体出口
//!
//! ## 模块定位
//! 将 H.264 编码帧通过 WebRTC 推送到浏览器(替代文件输出)。仅在 `--port > 0` 时启用;
//! `--port 0`(默认)走纯文件输出路径,本模块不会被实例化(见 `main.rs` 入口判断)。
//!
//! ## str0m 是 Sans-IO WebRTC 库
//! 类比 Go 的 `net/http`,但 Sans-IO 哲学不同:
//! - **没有 background goroutine**str0m 不创建任何线程,所有进度都靠外部 poll 推动
//! - **手动驱动 3 步循环**(见 `poll_and_feed`/`feed_network`/`poll_rtc`):
//! 1. 读 UDP 包 → `Rtc::handle_input(Input::Receive(...))` 喂给 str0m
//! 2. 调 `Rtc::poll_output()` 拿 `Output::Transmit` 包 → 写回 UDP socket
//! 3. 定时喂 `Input::Timeout(Instant::now())` 推动内部时钟
//! - **同步而非 async**str0m 不是 async/await 库(与 `tokio::net::TcpListener` 等
//! 异步运行时无关);本文件用 `std::net::TcpListener` + `UdpSocket`(手动
//! `set_nonblocking(true)`),完全同步代码;上层 `main.rs` 在 mio 事件循环里
//! 周期性调 `poll_and_feed()` 推动 RTC 状态机
//! - **Go 等价物**`github.com/pion/webrtc`Go 主流 WebRTC 库)也是同步 + 手动驱动,
//! 但 str0m 把 Sans-IO 推得更彻底——连 UDP socket 都不持有,所有 I/O 都由调用方管理
//!
//! ## 内嵌 HTTP 信令服务器
//! 本模块自带一个极简 HTTP 服务器(`std::net::TcpListener`,非 tokio/axum),3 个端点:
//! - `GET /` → 返回 `HTML_PAGE`(自带 SDP 协商 + `<video>` 播放 + 实时 stats 的测试页)
//! - `POST /sdp`Content-Type: application/json)→ 接收浏览器 `RTCPeerConnection`
//! localDescriptionOffer SDP),交给 `Rtc::sdp_api().accept_offer()` 生成 Answer
//! 返回 JSON body 给浏览器 `setRemoteDescription`
//! - `GET /sdp`(无 JSON Content-Type)→ 与 `GET /` 同(兼容旧路径)
//!
//! ICE candidate 通过 SDP offer/answer 完成:浏览器等 `iceGatheringState == 'complete'`
//! 才 POST(见 `HTML_PAGE` 的 `onicegatheringstatechange`),所以 candidate 已全在
//! SDP 里,本服务端无需单独的 ICE endpointtrickle ICE 关闭)。
//!
//! ## 关键不变量
//! - **单连接**`WebRtcState::inner: Option<WebRtcInner>` 只持有 1 个 peer;新连接
//! POST 进来时,旧 `inner` 被 drop(旧 `Rtc` 析构,UDP socket 关闭)
//! - **非阻塞 IO**:所有 socket `set_nonblocking(true)``WouldBlock` 是常态而非错误
//! - **BWE 启动**`RtcConfig::enable_bwe(Some(Bitrate::mbps(5)))` 启用带宽估计,
//! 用于动态分辨率切换(见 `state_portal.rs::select_resolution`
//!
//! ## 引用
//! - `Cargo.toml`: `str0m = "0.20"`
//! - git `727893f`: bitrate 修复(BWE 与 VBV 协同)
//! - issue #23: PLI 节流(`FORCED_KEYFRAME_MIN_INTERVAL`
// WebRTC 传输模块 — 使用 str0m (Sans-IO) 将 H.264 编码帧推送到浏览器
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, UdpSocket};
@@ -192,28 +236,60 @@ connect();
// ── WebRTC 状态 ───────────────────────────────────────────────────────────
// 对外门面:持有 HTTP 信令监听器 + 当前唯一的 peer 连接(`inner`)。
// 类比 Go 的 `*http.Server`,但 Sans-IO:所有推进都靠调用方主动 poll。
pub struct WebRtcState {
// HTTP 信令监听器(`POST /sdp` 协商;`GET /` 测试页面)。`set_nonblocking(true)`
// 由上层 mio 事件循环可读时调 `handle_signaling()` 接受连接。
signal_listener: TcpListener,
// 当前 peer。`None` = 尚无连接 / 上次连接已断开。新 `POST /sdp` 会整体替换此字段,
// 旧 `Rtc` 实例被 dropUDP socket 随之关闭)。
inner: Option<WebRtcInner>,
// 上层期望的帧率(来自 CLI `--fps`),用于初始化 `WebRtcInner`。
fps: u32,
}
// 单个 WebRTC peer 的全部状态:str0m `Rtc` 实例 + 它专用的 UDP socket +
// 编解码参数协商结果 + 关键帧请求/BWE 估计的运行时缓存。
//
// 字段访问路径(每帧一次,由 `main.rs` 的事件循环驱动):
// 1. `feed_network()` 把 UDP 入包喂给 `Rtc::handle_input`
// 2. `poll_rtc()` 取出 `Rtc::poll_output` 的 `Transmit` 包写回 UDP,并处理 `Event`
// 3. `write_h264_frame()` 把编码后的 H.264 NALU 通过 `Rtc::writer(mid).write(...)` 发出
struct WebRtcInner {
// str0m `Rtc`:一个完整的 WebRTC peer connectionICE / DTLS / SRTP / RTP / RTCP)。
// Sans-IO:不持有任何 socket 或线程,只持有协议状态机。
rtc: Rtc,
// 本 peer 专用的 UDP socket(每连接一个,避免与不存在的其他 peer 串扰)。
socket: UdpSocket,
// 该 socket 绑定的本地地址(带随机端口),用作 `Candidate::host` 的发地址。
udp_addr: SocketAddr,
// 视频 Media IDSDP 协商后从 `Event::MediaAdded` 捕获)。`None` = 尚未协商到。
video_mid: Option<Mid>,
// H.264 payload type(从 `Rtc::writer(mid).payload_params()` 扫描得到)。
video_pt: Option<Pt>,
// ICE+DTLS 是否已完成(`Event::Connected`)。未连接时 `write_h264_frame` 静默丢弃。
connected: bool,
// 等待下一个 IDR 关键帧(连接建立/分辨率切换时置 true,写帧时若非 IDR 则丢帧)。
need_keyframe: bool,
// 通知上游编码器下一次输出 IDR`state.rs::State::take_force_keyframe` 拉取)。
force_keyframe_to_encode: bool,
// 最近一次强制关键帧时刻,用于 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(防 PLI 风暴)。
last_forced_keyframe_at: Option<Instant>,
// 最近一次 BWE 估计(来自 `Event::EgressBitrateEstimate`),用于上层动态分辨率选择。
current_bwe_estimate: Option<Bitrate>,
// 最近一次写入的 RTP 时间戳(90kHz),仅用于日志 trace,不参与协议正确性。
rtp_clock: u32,
// UDP 接收缓冲(重复利用以避免每包分配;65535 = max UDP payload)。
buf: Vec<u8>,
}
impl WebRtcState {
// 构造函数:绑定 HTTP 信令 TCP 监听器并设为非阻塞。`port` 来自 CLI `--port`
// `fps` 来自 CLI `--fps`,仅在 `--port > 0` 时被 `main.rs` 调用。
//
// 注意:本函数只创建信令监听器,**不**创建 UDP socket 或 `Rtc` 实例——
// 那些在第一次 `POST /sdp` 时由 `WebRtcInner::new` 按需创建。
pub fn new(port: u16, fps: u32) -> Result<Self> {
let signal_listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
signal_listener.set_nonblocking(true)?;
@@ -226,18 +302,36 @@ impl WebRtcState {
})
}
// 处理所有待接受的 HTTP 信令连接。上层 mio 循环在 `signal_listener` 可读时调用。
//
// 返回 `Ok(true)` 表示至少处理了一个请求(用于上层日志/计数)。
// 单次调用 drain 当前 accept 队列里所有连接(`Err(WouldBlock)` 时退出循环)。
//
// 路由:
// - `GET /` 或 `GET /sdp`(非 JSON)→ 返回 `HTML_PAGE`
// - `POST /sdp` → 解析 body,构造新 `WebRtcInner` 并替换 `self.inner`
// - 其他路径 → 404
pub fn handle_signaling(&mut self) -> Result<bool> {
let mut handled = false;
loop {
// `TcpListener::accept()` 类比 Go `ln.Accept()`;非阻塞模式下队列为空返回
// `WouldBlock`,是 drain 完成的信号而非错误(类比 Go `accept` + nonblocking + EAGAIN)。
let (mut stream, _addr) = match self.signal_listener.accept() {
Ok(s) => s,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
// `bail!` 是 anyhow 提供的宏,等价于 `return Err(anyhow::anyhow!(...))`
// 类比 Go `return fmt.Errorf("TCP accept error: %w", err)`。
Err(e) => bail!("TCP accept error: {e}"),
};
handled = true;
// 设为非阻塞——类比 Go `syscall.SetNonblock(fd, true)`。后续 `stream.read`
// 在没数据时返回 `WouldBlock`(用 `continue` 跳过本连接)。
stream.set_nonblocking(true)?;
// 64KB 一次性读完:HTTP/1.0 客户端默认 `Connection: close`,浏览器 POST 整个
// SDP offer 不会超过 64KB。`vec![0u8; N]` 类比 Go `make([]byte, N)`。
let mut req = vec![0u8; 65536];
// `stream.read(&mut req)` 类比 Go `conn.Read(buf)`——`Read` trait 即 Go `io.Reader`。
let n = match stream.read(&mut req) {
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
@@ -246,6 +340,8 @@ impl WebRtcState {
continue;
}
};
// `String::from_utf8_lossy` 把字节转成字符串,无效 UTF-8 替换为 U+FFFDHTTP 头都是 ASCII)。
// 类比 Go `string(buf[:n])`Go 字符串可包含任意字节,但后续 `starts_with` 也只看 ASCII)。
let req_str = String::from_utf8_lossy(&req[..n]);
if req_str.starts_with("GET / ")
@@ -270,12 +366,19 @@ impl WebRtcState {
continue;
}
// `and_then`Result 链式组合,类比 Go `if err != nil { return err }` 后继续。
// `new_inner.handle_sdp_offer(...)?``?` 操作符传播 `Result::Err`
// 类比 Go `result, err := ...; if err != nil { return err }` 的简写。
match WebRtcInner::new(self.fps).and_then(|mut new_inner| {
let answer_json = new_inner.handle_sdp_offer(body.as_bytes())?;
Ok((new_inner, answer_json))
}) {
Ok((new_inner, answer_json)) => {
// `Option::is_some()` = Rust 检查 `Option` 是否为 `Some(_)`
// 类比 Go `if p != nil`。这里用于日志区分"替换"vs"首次"。
let replacing = self.inner.is_some();
// 整体替换 `self.inner`:旧 `Rtc` 实例 dropUDP socket 关闭,
// peer 连接断开)。这是单连接不变量的核心实现。
self.inner = Some(new_inner);
if replacing {
tracing::info!("Replaced WebRTC connection (old dropped)");
@@ -311,6 +414,10 @@ impl WebRtcState {
Ok(handled)
}
// 推动 str0m `Rtc` 状态机:取出 `poll_output` 的 `Transmit` 包写回 UDP,处理 `Event`。
// 返回 `Ok(())`;若 `poll_rtc` 上报 peer 已断开,则清空 `self.inner`。
//
// 类比 Go pion/webrtc:没有 `go func()` 自动循环,必须由 main 线程显式调用。
pub fn poll_rtc(&mut self) -> Result<()> {
if let Some(inner) = self.inner.as_mut() {
if inner.poll_rtc()? {
@@ -321,6 +428,8 @@ impl WebRtcState {
Ok(())
}
// 从 UDP socket 读所有待处理包喂给 `Rtc::handle_input`。`WouldBlock` 退出循环。
// Go 类比:`for { n, _ := conn.ReadFrom(buf); if errors.Is(err, EAGAIN) { break } }`。
pub fn feed_network(&mut self) -> Result<()> {
if let Some(inner) = self.inner.as_mut() {
inner.feed_network()?;
@@ -328,12 +437,20 @@ impl WebRtcState {
Ok(())
}
// `poll_rtc` → `feed_network` → `poll_rtc` 三明治。中间多一次 poll 是因为
// `feed_network` 喂的入包可能触发 str0m 产生新的 `Transmit`(如 RTCP ACK),
// 这些出包必须在同一轮循环里写回 UDP,避免延迟一帧。
pub fn poll_and_feed(&mut self) -> Result<()> {
self.poll_rtc()?;
self.feed_network()?;
self.poll_rtc()
}
// 把一帧 H.264 NALU(已 annex-B 转码)写入 str0m `Rtc`,通过 RTP 发给 peer。
// `pts_ticks` = 90kHz 时钟下的 PTS(编码器 time_base = 1/90000,等同 RTP 时间戳)。
//
// 返回 `Ok(())`;若 `WebRtcInner::write_h264_frame` 上报 peer 断开,则清空 `self.inner`。
// 未连接 / 未协商到 mid/pt / 等待 IDR 时静默丢帧(`Ok(false)`)。
pub fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<()> {
let should_destroy = if let Some(inner) = self.inner.as_mut() {
inner.write_h264_frame(data, pts_ticks)?
@@ -347,10 +464,15 @@ impl WebRtcState {
Ok(())
}
// 是否有已连接的 peer。`Option::is_some_and` = Rust 短路求值,类比 Go
// `if p != nil && p.connected { ... }`。
pub fn is_connected(&self) -> bool {
self.inner.as_ref().is_some_and(WebRtcInner::is_connected)
}
// 上层(`state_portal.rs::select_resolution`)查询最近一次 BWE 估计(bps)。
// `None` = 尚未收到 `Event::EgressBitrateEstimate``Some(bps)` = str0m 推断的可用带宽。
// 上层据此切换分辨率 tier(防止过载导致卡顿)。
/// Returns the latest bandwidth estimation estimate in bits per second, if available.
pub fn get_bwe_estimate(&self) -> Option<u64> {
self.inner
@@ -358,6 +480,9 @@ impl WebRtcState {
.and_then(|inner| inner.current_bwe_estimate.map(|b| b.as_u64()))
}
// 内部触发:连接刚建立或分辨率刚变化,需要立刻 IDR 以让对端解码器重置。
// 不受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(本函数总是 honor),但会刷新
// `last_forced_keyframe_at`,使紧接着的 1 秒内 viewer PLI 被丢弃。
/// Internal keyframe request (connect, resolution change). Always honored,
/// but updates last_forced_keyframe_at so a subsequent viewer PLI in the next
/// second is throttled.
@@ -367,6 +492,9 @@ impl WebRtcState {
}
}
// 外部触发:viewer 通过 RTCP PLI/FIR 主动请求关键帧(`Event::KeyframeRequest`)。
// 受 `FORCED_KEYFRAME_MIN_INTERVAL` 节流(1 秒),防止恶意/频繁 PLI 触发 IDR 风暴
// 撑爆上行带宽。See issue #23。
/// External keyframe request from viewer (PLI/FIR via str0m
/// `Event::KeyframeRequest`). Rate-limited to FORCED_KEYFRAME_MIN_INTERVAL
/// to prevent PLI storms from swamping the network with IDR bursts.
@@ -378,6 +506,8 @@ impl WebRtcState {
}
}
// 上层拉取"是否需要下一帧为 IDR"。返回 `true` 仅一次(取后自动复位),
// 类比 Go `atomic.SwapInt32(&flag, 0)`。编码线程据此在下一帧 `force_idr=1`。
pub fn take_force_keyframe(&mut self) -> bool {
if let Some(inner) = self.inner.as_mut() {
let v = inner.force_keyframe_to_encode;
@@ -390,19 +520,46 @@ impl WebRtcState {
}
impl WebRtcInner {
// 构造一个全新的 WebRTC peer:创建 str0m `Rtc` 实例 + UDP socket + 候选地址。
// 在 `handle_signaling` 接到 `POST /sdp` 时被调用——也就是说**每来一个 SDP offer
// 都新建一个 peer**,旧 `Rtc` 实例随之 dropUDP socket 关闭,连接断开)。
//
// 步骤:
// 1. `RtcConfig::new().enable_bwe(...).build(...)`str0m 构造器链式 Builder 模式,
// 类比 Go `webrtc.NewAPI(webrtc.WithSettingEngine(...))`;启用 BWE5 Mbps 初始)
// 2. `UdpSocket::bind("0.0.0.0:0")`OS 随机分配端口,类比 Go `net.ListenUDP("udp", nil)`
// 3. `unsafe { libc::setsockopt(SO_SNDBUF) }`:扩大 UDP 发送缓冲到 2MB(默认 ~208KB
// 在 IDR 突发下会 EAGAIN 丢包);英文 SAFETY 注释见下方
// 4. `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用),
// `Rtc::add_local_candidate` 注册到 str0m
fn new(fps: u32) -> Result<Self> {
// `let _ = fps;` 显式标记 fps 暂未使用(保留接口给未来 fps-based pacing)。
// 类比 Go `_ = fps`。
let _ = fps;
// str0m `Rtc` 构造:Builder 模式 + 链式 setter。
// - `RtcConfig::new()`:空配置
// - `.enable_bwe(Some(Bitrate::mbps(5)))`:启用 bandwidth estimation,初始估 5 Mbps
// - `.build(Instant::now())`:传入当前时刻作为 Rtc 内部时钟起点
// 类比 Go pion/webrtc`webrtc.NewAPI(webrtc.WithSettingEngine(...))`
let mut rtc = RtcConfig::new()
.enable_bwe(Some(Bitrate::mbps(5)))
.build(Instant::now());
// `UdpSocket::bind("0.0.0.0:0")`OS 随机分配端口(每 peer 独享一个 socket)。
// 类比 Go `net.ListenUDP("udp", &net.UDPAddr{Port: 0})`。
let socket = UdpSocket::bind("0.0.0.0:0")?;
socket.set_nonblocking(true)?;
// 中文概述:调大 UDP 发送缓冲到 2MB(默认 ~208KB),原因详见下方英文注释。
// 然后用 `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且
// 通常会翻倍)。Go 等价:`net.ListenConfig{Control: ...}`。
// Increase UDP send buffer to absorb IDR frame bursts (256KB IDR → ~145 RTP
// packets in a single poll_rtc loop). Default Linux wmem is ~208KB which
// causes EAGAIN on large keyframes. 2MB comfortably buffers several IDRs.
const SND_BUF_REQ: usize = 2 * 1024 * 1024;
// 中文概述:调用 `setsockopt(SO_SNDBUF)` 调大 UDP 发送缓冲,然后用
// `getsockopt` 读取内核实际分配的大小(Linux 可能受 `wmem_max` 截断,且通常会
// 翻倍)。FFI 安全性论证见下方英文 SAFETY 块。
// SAFETY: fd is a valid UDP socket; setsockopt/getsockopt with SOL_SOCKET +
// SO_SNDBUF are safe on Linux. We check the return value and log the actual
// kernel-assigned buffer (Linux may cap at wmem_max and/or double the value).
@@ -443,13 +600,22 @@ impl WebRtcInner {
let local_addr = socket.local_addr()?;
// `local_ip().unwrap_or_else(closure)``Option<T>::unwrap_or_else` 类比 Go
// `if ip == "" { ip = "127.0.0.1" }`——`Option::None` 时执行闭包取兜底值。
let lan_ip = local_ip().unwrap_or_else(|| {
tracing::debug!("Failed to detect LAN IP, falling back to 127.0.0.1");
"127.0.0.1".to_string()
});
// `format!("{lan_ip}:{}", port)`Rust 格式化宏,类比 Go `fmt.Sprintf("%s:%d", ...)`.
// `.parse::<SocketAddr>()`:字符串解析为 `SocketAddr``?` 自动传播 `AddrParseError`。
let candidate_addr: SocketAddr = format!("{lan_ip}:{}", local_addr.port()).parse()?;
// `Candidate::host(addr, "udp")`:构造 host ICE candidate(局域网用,无 STUN/TURN)。
// `.map_err(|e| anyhow::anyhow!(...))?`:把 str0m 自定义错误转成 `anyhow::Error`
// 并传播,类比 Go `if err != nil { return fmt.Errorf("candidate: %w", err) }`。
let candidate = Candidate::host(candidate_addr, "udp")
.map_err(|e| anyhow::anyhow!("candidate: {e}"))?;
// `Rtc::add_local_candidate`:把 candidate 注册到 str0m,之后 SDP 协商时它会被
// 包含进 answer 的 `a=candidate:` 行。
rtc.add_local_candidate(candidate);
tracing::info!("WebRTC UDP: {candidate_addr} (bound 0.0.0.0)");
@@ -469,10 +635,27 @@ impl WebRtcInner {
})
}
// SDP offer/answer 交换:解析浏览器 POST 来的 SDP offer JSON → 喂给 str0m 协商 →
// 返回 answer JSON。
//
// 关键步骤:
// 1. `serde_json::from_slice`:反序列化 SDP offer(类比 Go `json.Unmarshal`
// 2. `self.rtc.sdp_api().accept_offer(offer)`str0m 内部协商出 answer
// 副作用是设置 `Event::MediaAdded` 等待异步触发
// 3. `self.need_keyframe = true; self.force_keyframe_to_encode = true;`
// 协商完成后立即请求 IDR,让对端尽快解码首帧
// 4. `discover_video_params()`:扫描 str0m writer 找到 H.264 payload type
// 5. `serde_json::to_vec`:序列化 answer(类比 Go `json.Marshal`
fn handle_sdp_offer(&mut self, body: &[u8]) -> Result<String> {
// `serde_json::from_slice::<SdpOffer>(body)`:把浏览器 POST 的 JSON 反序列化成
// str0m 的 `SdpOffer` 类型,类比 Go `json.Unmarshal(body, &offer)`。
// `.map_err(...)?`:把 serde 错误包装成 anyhow 错误并传播。
let offer: SdpOffer =
serde_json::from_slice(body).map_err(|e| anyhow::anyhow!("parse SDP offer: {e}"))?;
// `Rtc::sdp_api().accept_offer(offer)`str0m SDP 协商核心入口——
// 解析 offer 中的 m= 行、codec 列表、ICE candidate,构造对应的 answer。
// 副作用:触发后续 `Event::MediaAdded`(异步,要等 poll_rtc 才发)。
let answer = self
.rtc
.sdp_api()
@@ -491,6 +674,13 @@ impl WebRtcInner {
String::from_utf8(answer_json).map_err(|e| anyhow::anyhow!("answer utf8: {e}"))
}
// 扫描 str0m 内部协商出的 codec 列表,找到 H.264 payload type`Pt`)。
// 在 SDP 协商后、`Event::MediaAdded` 后、`Event::Connected` 后各调用一次
// (三处调用是因为 str0m 的 codec 信息可能在不同时机可用——多保险)。
//
// 副作用:调用 `direct_api().stream_tx_by_mid(mid, None).set_unpaced(true)`
// 关闭 str0m 的 LeakyBucketPacer(默认每包加 ~100ms pacing 延迟,与我们的 VBV
// 8 Mbps 上限冲突;关掉后由编码器侧 VBV 做速率控制)。
fn discover_video_params(&mut self) {
let mid = match self.video_mid {
Some(m) => m,
@@ -503,12 +693,18 @@ impl WebRtcInner {
// Disable str0m's LeakyBucketPacer for this video stream. Default pacing
// adds ~100ms send latency per large IDR; our 8Mbps cap + VBV already
// provide rate control. BWE stays enabled for adaptation feedback.
// `direct_api()` 返回 str0m 内部 API(不公开稳定接口),`stream_tx_by_mid(mid, None)`
// 取得该 mid 的发送流控制器;`set_unpaced(true)` 关闭 pacing。
if let Some(stream_tx) = self.rtc.direct_api().stream_tx_by_mid(mid, None) {
stream_tx.set_unpaced(true);
}
// `Rtc::writer(mid)` 返回媒体写入器,`payload_params()` 列出协商出的所有 codec。
// 我们扫描找 H.264`Codec::H264`)的 payload type,存入 `video_pt` 供后续 `write_h264_frame` 使用。
if let Some(writer) = self.rtc.writer(mid) {
for pp in writer.payload_params() {
tracing::debug!("Codec: pt={:?} spec={:?}", pp.pt(), pp.spec());
// `pp.spec().codec.is_video()`:先确认是视频 codec
// `pp.spec().codec == Codec::H264`:再确认是 H.264(非 VP8/VP9/AV1)。
if pp.spec().codec.is_video() && pp.spec().codec == Codec::H264 {
self.video_pt = Some(pp.pt());
tracing::info!("H.264 payload type: {:?}", pp.pt());
@@ -521,6 +717,8 @@ impl WebRtcInner {
}
}
// 内部不节流版本:直接置位 `need_keyframe` + `force_keyframe_to_encode`
// 并刷新 `last_forced_keyframe_at`(防紧接着 1 秒内的 viewer PLI 重复触发 IDR)。
/// Unthrottled keyframe trigger. Always sets the keyframe flags and refreshes
/// `last_forced_keyframe_at` so a follow-up viewer PLI within the next
/// `FORCED_KEYFRAME_MIN_INTERVAL` is dropped.
@@ -530,6 +728,8 @@ impl WebRtcInner {
self.last_forced_keyframe_at = Some(Instant::now());
}
// 节流版本:仅在距离 `last_forced_keyframe_at` 已过 `FORCED_KEYFRAME_MIN_INTERVAL`
//1 秒)时才 honor,否则记 warn 日志并丢弃。对应 `Event::KeyframeRequest`PLI/FIR)。
/// Throttled keyframe trigger used for viewer-originated PLI/FIR requests.
/// Honored only if enough time has elapsed since the last forced keyframe.
fn request_keyframe_from_viewer(&mut self) {
@@ -550,11 +750,24 @@ impl WebRtcInner {
}
}
// Sans-IO 推进主循环(出方向):取出 str0m 待发的 `Output::Transmit` 包写回 UDP
// 处理 `Output::Event`Connected/Disconnected/MediaAdded/KeyframeRequest/BWE 等)。
// 返回 `Ok(true)` 表示 peer 已断开(调用方应 drop `WebRtcInner`)。
//
// `Output::Timeout` 表示 str0m 需要在未来某时刻被再次唤醒——本实现简单 `break`,
// 依赖上层 mio 循环的 1ms tick 重新进入;更高性能的做法是读取 `_t` 安排 timer。
fn poll_rtc(&mut self) -> Result<bool> {
loop {
// `Rtc::poll_output()`str0m 主推进入口,返回 `Output` 枚举(Transmit/Event/Timeout
// 或 `Err`。Sans-IO 设计:调用方必须循环 poll 直到拿到 `Timeout`(表示 str0m
// 当前没活干了,等下一次外部输入)。
match self.rtc.poll_output() {
// `Output::Transmit`str0m 想发的网络包(RTP/RTCP/DTLS/STUN)。
// 我们写回 UDP socket——这就是 Sans-IO 的"输出"侧。
Ok(Output::Transmit(t)) => {
tracing::trace!("TX {} bytes -> {}", t.contents.len(), t.destination);
// `UdpSocket::send_to` 类比 Go `conn.WriteToUDP(b, addr)`。
// `WouldBlock` = 内核发送缓冲满(罕见,因为我们在 new() 里调大了)。
if let Err(e) = self.socket.send_to(&t.contents, t.destination) {
if e.kind() == std::io::ErrorKind::WouldBlock {
tracing::debug!(
@@ -566,20 +779,28 @@ impl WebRtcInner {
}
}
}
// `Output::Event`str0m 内部状态变化通知(ICE 连接、媒体添加、keyframe 请求等)。
// `Event` 是 enum,下方 `match &e` 对每种 variant 分发处理。
Ok(Output::Event(e)) => {
tracing::debug!("RTC event: {e:?}");
match &e {
// `Event::Connected`ICE+DTLS 握手完成,可以发 RTP 了。
// 立即触发 IDR 请求(让对端解码器拿到关键帧尽快起播)+ 重新扫 codec 参数。
Event::Connected => {
tracing::info!("WebRTC connected!");
self.connected = true;
self.set_need_keyframe();
self.discover_video_params();
}
// `Event::IceConnectionStateChange`ICE 状态变化。
// `Disconnected` 视为连接已死,向上层返回 `Ok(true)` 触发 drop。
Event::IceConnectionStateChange(IceConnectionState::Disconnected) => {
tracing::warn!("WebRTC disconnected");
self.connected = false;
return Ok(true);
}
// `Event::MediaAdded`SDP 协商后有新 m= 行就绪。
// 捕获视频 mid(只取第一个 sending direction 的视频流)。
Event::MediaAdded(ma) => {
tracing::info!("Media added: mid={} kind={:?}", ma.mid, ma.kind);
if ma.kind == MediaKind::Video {
@@ -592,10 +813,15 @@ impl WebRtcInner {
}
}
}
// `Event::KeyframeRequest`:对端发来 PLI/FIR,请求 IDR。
// 转发到节流版本 `request_keyframe_from_viewer`(防止 PLI 风暴)。
Event::KeyframeRequest(_) => {
tracing::info!("received keyframe request from viewer");
self.request_keyframe_from_viewer();
}
// `Event::EgressBitrateEstimate`BWE 推断的可用上行带宽。
// `BweKind::Twcc`Transport-CC,新标准)或 `BweKind::Remb`(老标准)。
// 提取数值存入 `current_bwe_estimate`,供 `state_portal.rs::select_resolution` 使用。
Event::EgressBitrateEstimate(est) => {
let bitrate = match est {
BweKind::Twcc(b) => *b,
@@ -613,6 +839,8 @@ impl WebRtcInner {
}
}
}
// `Output::Timeout`str0m 内部定时器到期点。本实现忽略 `_t`(即下次唤醒时刻),
// 简单 `break`——上层 mio 循环 1ms tick 会很快再次调用 `poll_rtc`。
Ok(Output::Timeout(_t)) => break,
Err(e) => {
tracing::error!("rtc.poll_output error: {e}");
@@ -624,15 +852,27 @@ impl WebRtcInner {
Ok(false)
}
// Sans-IO 推进主循环(入方向):从 UDP socket 读所有待处理包,封装为
// `Input::Receive` 喂给 str0m;最后喂一次 `Input::Timeout(now)` 推动内部时钟。
// 类比 Go pion/webrtc:手动调用 `peerConnection.Receive(rtpPacket)` 而不是
// 起 goroutine 监听 UDP。
fn feed_network(&mut self) -> Result<()> {
let mut recv_count = 0u32;
loop {
// `UdpSocket::recv_from(&mut self.buf)`:类比 Go `conn.ReadFrom(buf)`。
// 返回 `(n_bytes_read, source_addr)`。`WouldBlock`/`Interrupted` 是常态,
// 前者 break 出循环,后者重试(类比 Go EINTR 处理)。
match self.socket.recv_from(&mut self.buf) {
Ok((n, source)) => {
recv_count += 1;
if recv_count <= 5 {
tracing::trace!("UDP recv {} bytes from {}", n, source);
}
// 构造 `Input::Receive`str0m 的"入包"事件。
// `Receive { proto, source, destination, contents }` 完整描述一个网络包:
// - `proto: Protocol::Udp`str0m 也支持 TCP,但 WebRTC 主流用 UDP
// - `source` / `destination`ICE candidate 端点
// - `contents``self.buf[..n]` 转 `Box<[u8]>``.try_into()` 因为 slice→Box 长度可能变化)
let input = Input::Receive(
Instant::now(),
Receive {
@@ -644,6 +884,8 @@ impl WebRtcInner {
.map_err(|e| anyhow::anyhow!("receive contents: {e}"))?,
},
);
// `Rtc::handle_input(input)`:把入包喂给 str0m 解析(ICE/DTLS/SRTP/RTP/RTCP)。
// 这是 Sans-IO 的"输入"侧——str0m 不主动读 socket,全靠调用方喂。
self.rtc.handle_input(input).map_err(|e| {
anyhow::anyhow!("handle_input({n} bytes from {source}): {e}")
})?;
@@ -654,6 +896,8 @@ impl WebRtcInner {
}
}
// 喂一次 `Input::Timeout(now)`:让 str0m 推进内部定时器(重传、keepalive、BWE 周期等)。
// 即使没有任何入包,也必须定期调用,否则 str0m 内部超时不会触发。
self.rtc
.handle_input(Input::Timeout(Instant::now()))
.map_err(|e| anyhow::anyhow!("handle timeout: {e}"))?;
@@ -661,6 +905,17 @@ impl WebRtcInner {
Ok(())
}
// 把一帧 H.264 NALUannex-B 格式,含 0x000001 起始码)写入 str0m,转 RTP 发出。
//
// 5 步:
// 1. 检查 `connected`、`video_mid`、`video_pt`,未就绪则 `Ok(false)` 静默丢帧
// 2. 若 `need_keyframe`,校验此帧必须是 IDRNAL type=5),否则丢帧等下一帧
// 3. PTS 90kHz 时钟 → RTP 时间戳(直接复用,因编码器 time_base = 1/90000
// 4. `Rtc::writer(mid).write(pt, now, rtp_time, data)`str0m 内部分包(>MTU 切片)
// 并加密 SRTP,产生 `Output::Transmit` 包
// 5. 立即 `poll_rtc()` 把 Transmit 包写回 UDP(同步发出,避免延迟)
//
// 返回 `Ok(true)` = peer 断开,调用方应 drop 本 `WebRtcInner`。
fn write_h264_frame(&mut self, data: &[u8], pts_ticks: i64) -> Result<bool> {
if !self.connected {
return Ok(false);
@@ -696,10 +951,16 @@ impl WebRtcInner {
self.need_keyframe = false;
}
// PTS 90kHz → RTP 时间戳。`rtp_timestamp_from_pts_ticks` 把 i64 clamp 到 u64
//(见该函数文档)。`Frequency::NINETY_KHZ` 是视频 RTP 的标准时钟频率。
let rtp_timestamp = rtp_timestamp_from_pts_ticks(pts_ticks);
self.rtp_clock = rtp_timestamp as u32;
// `MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ)`:构造 str0m 媒体时间戳,
// 用于 RTP 头部 + jitter buffer 同步。
let rtp_time = MediaTime::new(rtp_timestamp, Frequency::NINETY_KHZ);
// `Rtc::writer(mid)`:取得 mid 对应的媒体写入器(之前在 `discover_video_params` 用过)。
// None 表示 mid 还没就绪(罕见,已在前面的 video_mid 检查里处理)。
let writer = match self.rtc.writer(mid) {
Some(w) => w,
None => {
@@ -714,6 +975,9 @@ impl WebRtcInner {
pt,
self.rtp_clock
);
// `writer.write(pt, Instant::now(), rtp_time, data)`:媒体写入入口。
// str0m 内部完成 (a) H.264 RTP 分包(FU-A for >MTU),(b) SRTP 加密,
// (c) 产生 `Output::Transmit` 包供 `poll_rtc` 取出。
writer
.write(pt, Instant::now(), rtp_time, data)
.map_err(|e| anyhow::anyhow!("writer.write: {e}"))?;
@@ -723,11 +987,15 @@ impl WebRtcInner {
Ok(should_destroy)
}
// 简单 getter,对应 `Event::Connected` / `Event::IceConnectionStateChange(Disconnected)`。
fn is_connected(&self) -> bool {
self.connected
}
}
// PTS→RTP 时间戳换算:编码器侧 time_base 已是 1/90000(与 RTP 视频时钟一致),
// 因此 1:1 直接复用,无需 fps-based 换算(旧版本曾用 `90000 / fps` 误导致时间戳错乱)。
// 返回 `u64` 喂 `MediaTime::new` 避免 u32 在 13.25 小时后过早回绕;str0m 内部处理 RTP u32 回绕。
/// Convert PTS in 90kHz media-clock ticks to RTP MediaTime ticks (u64).
///
/// With WebRTC encoder time_base = 1/90000, pts_ticks ARE RTP timestamps.
@@ -748,6 +1016,9 @@ fn extract_body(req: &str) -> &str {
}
}
// 探测本机 LAN IP(用于 ICE host candidate)。Go 等价:`net.Dial("udp", "1.1.1.1:80")`
// 后读 `LocalAddr()`——`connect` 不会发包,只设置路由表,从而选出默认网关对应的网卡 IP。
// `127.x` / `0.0.0.0` 视为无 LAN IP,由调用方 fallback 到 127.0.0.1loopback 调试用)。
fn local_ip() -> Option<String> {
std::net::UdpSocket::bind("0.0.0.0:0").ok().and_then(|s| {
s.connect("1.1.1.1:80").ok()?;
@@ -761,6 +1032,12 @@ fn local_ip() -> Option<String> {
})
}
// 检测 H.264 NALU 流中是否含 IDR sliceNAL type=5)。两种起始码:
// - 4 字节 `00 00 00 01`AVCC boundary,主流)
// - 3 字节 `00 00 01` Annex-B inline,少见)
// NAL header 低 5 位 = type5 = IDR slice。SPS=7、PPS=8、SEI=6 等不算 IDR。
//
// 用于 `need_keyframe` 时丢非 IDR 帧——Go 等价:`bytes.Index(data, []byte{0,0,0,1})` 循环。
fn is_idr_nalu(data: &[u8]) -> bool {
let mut i = 0;
while i < data.len() {
+78
View File
@@ -1,19 +1,77 @@
//! 集成测试:通过 shell out 到 `target/release/wl-webrtc` 二进制来验证 CLI 行为。
//!
//! 与单元测试(在进程内调用库函数)不同,集成测试把产物当作黑盒,启动子进程
//! 并检查其 stdout/stderr/exit code。这种模式类似 Go 的 `testing` 包配合
//! `os/exec.Command(...)` —— Rust 这边对应 `std::process::Command::new(...)`
//! 通过 `.arg(...)` 链式追加参数,最后 `.output()` 一次性等待子进程结束并
//! 拿到 `Output { status, stdout, stderr }`。
//!
//! # 运行前必读
//!
//! 这些测试**依赖 release 版二进制存在**。`cargo test --test integration_test`
//! 本身不会触发 release 构建,必须先手动执行:
//!
//! ```bash
//! cargo build --release
//! ```
//!
//! 否则 `Command::new("target/release/wl-webrtc")` 会因为找不到可执行文件而 panic,
//! 所有 `#[test]` 都将以 "failed to execute" 失败。详见 `AGENTS.md`
//! "Testing and verification" 章节。
//!
//! # 测试发现与断言
//!
//! `cargo test` 通过 `#[test]` 属性宏自动发现并运行标记的函数,无需像 Go 那样
//! 约定 `TestXxx(t *testing.T)` 签名 —— 普通函数加 `#[test]` 即可。
//! `#[ignore]` 标记的测试默认跳过,需 `cargo test -- --ignored` 显式开启。
//!
//! 断言方面,`assert!(cond, "msg")` 类似 Go 的 `if !cond { t.Errorf("msg") }`
//! 但 Rust 会在失败时立即 unwind 当前测试函数(而非 Go 那样继续执行后续断言)。
//! 当测试函数返回 `Result<(), E>` 时,可用 `?` 操作符把 IO/解码错误直接传播
//! 给测试 harness(失败时打印 `Err` 而非 `panic!`);本文件的测试为了聚焦于
//! 子进程行为,全部用 `.expect(...)`/`assert!` 风格,不返回 `Result`。
use std::process::Command;
/// Helper: get the binary path. Uses the release build if available.
///
/// 返回被测二进制的路径。集成测试 shell out 到 release 构建产物(debug 构建太慢,
/// 且无法真实反映发布行为)。返回 `&'static str` 而非 `PathBuf` 是因为这个路径
/// 是编译期常量,无需在每次调用时分配。
fn bin_path() -> &'static str {
"target/release/wl-webrtc"
}
/// 测试 `--help` 子命令:应正常退出(exit 0),且 stdout 至少包含关键字段名。
///
/// 验证 README/AGENTS.md 中列出的核心 CLI 参数(output/fps/codec/bitrate/gop-size/drm-device
/// 都能在 `--help` 输出中找到 —— 这是一道"防回归"测试:一旦某参数被改名或删除,
/// 此处 `assert!` 会立刻失败。
#[test]
fn test_help_flag() {
// `Command::new(...)` 类似 Go 的 `exec.Command(...)`:构造一个待运行的
// 子进程描述符,此时还未真正 fork/exec。链式 `.arg(...)` 把参数逐个追加到
// 命令行末尾(保持顺序),`.output()` 则 fork、exec、等待子进程退出,并
// 一次性捕获 stdout/stderr 到 `Output` 结构体。
//
// `.expect(...)` 等价于 `match result { Ok(v) => v, Err(_) => panic!(...) }`
// 用于在父进程侧(不是被测程序侧)报告"无法启动子进程"这种环境性错误。
let output = Command::new(bin_path())
.arg("--help")
.output()
.expect("failed to execute wl-webrtc --help");
// `String::from_utf8_lossy(...)` 把 `Vec<u8>` 字节流解码成字符串;遇到非法
// UTF-8 序列时用 U+FFFD 替换而非报错。这里用 `_lossy` 变体而非
// `String::from_utf8(...)?` 是因为 stdout 理论上可能包含任意字节(如 ANSI
// color code 失控),不值得为解码失败让整个测试 panic。
let stdout = String::from_utf8_lossy(&output.stdout);
// `output.status.success()` 检查子进程退出码是否为 0Unix 上即 WIFEXITED
// 且 exit code == 0)。`assert!(cond, "msg")` 失败时打印 `msg` 并 panic
// 当前测试函数,类似 Go 的 `t.Fatalf` 而非 `t.Errorf`。
assert!(output.status.success(), "--help should exit 0");
// 后续 `assert!(stdout.contains(...), "...")` 检查帮助文本是否覆盖每个
// 文档化参数。任何一个缺失都会让测试失败并打印自定义消息。
assert!(
stdout.contains("output"),
"help output should mention 'output'"
@@ -37,6 +95,11 @@ fn test_help_flag() {
);
}
/// 测试未知参数应被拒绝:非零退出码 + stderr 包含 error/unexpected/unrecognized 之一。
///
/// `clap` 默认对未识别的 flag 返回非零退出码并打印错误到 stderr。这里用
/// `!output.status.success()` 断言"应该失败",再检查 stderr 文本以排除"碰巧
/// 崩溃退出"的假阳性。
#[test]
fn test_rejects_invalid_args() {
let output = Command::new(bin_path())
@@ -44,8 +107,13 @@ fn test_rejects_invalid_args() {
.output()
.expect("failed to execute wl-webrtc with invalid args");
// 断言"非零退出码"。注意 `!` 取反 —— 与 Go 的 `t.Errorf` 风格不同,Rust 的
// `assert!` 直接接受 bool 表达式,没有 `assertFalse` 这种专门函数。
assert!(!output.status.success(), "should reject unrecognized flag");
let stderr = String::from_utf8_lossy(&output.stderr);
// 多个可能的错误措辞用 `||` 连接 —— 不同 clap 版本可能输出 "error: unexpected"
// 或 "error: unrecognized",任一匹配即可。自定义消息末尾的 `{stderr}` 利用
// `format!` 占位符在失败时打印实际 stderr 内容,便于排错。
assert!(
stderr.to_lowercase().contains("error")
|| stderr.to_lowercase().contains("unexpected")
@@ -54,8 +122,11 @@ fn test_rejects_invalid_args() {
);
}
/// 测试 `--codec hevc` 在 MVP 阶段应被拒绝:MVP 只支持 h264。
#[test]
fn test_rejects_hevc_codec() {
// 多个 `.arg(...)` 链式调用按顺序追加参数,等价于命令行
// `wl-webrtc --output /dev/null --codec hevc`。
let output = Command::new(bin_path())
.arg("--output")
.arg("/dev/null")
@@ -70,6 +141,13 @@ fn test_rejects_hevc_codec() {
/// Tests requiring a live Wayland compositor and VAAPI hardware.
/// Run with: cargo test -- --ignored
///
/// 该测试需要真实 Wayland 会话 + VAAPI GPU + 可写输出路径,无法在 CI 中运行。
/// `#[ignore]` 属性告诉 `cargo test` 默认跳过它,只有显式
/// `cargo test -- --ignored` 时才执行。
///
/// 注意:此测试只验证"参数解析不立即报错",并未真正完成捕获 —— 真正的捕获
/// 需要异步等待几秒再发 SIGINT,这里只做最小烟雾测试。
#[test]
#[ignore]
fn test_capture_starts_with_valid_output() {