Compare commits

18 Commits
Author SHA1 Message Date
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
14 changed files with 1988 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,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,
// 每秒打印管线统计(编码帧数、延迟等),用于卡顿诊断
}
+253
View File
@@ -1,3 +1,28 @@
//! # avhw — FFmpeg / VAAPI 硬件加速 FFI 绑定(FFI 最密集的文件)
//!
//! 本文件是整个 crate 中 `unsafe` 块密度最高的模块:直接调用 FFmpeg C API。
//! `ffmpeg-next` 是 ffmpeg-sys-nextC FFI 绑定)的薄包装;硬件加速相关 API
//! `AVBufferRef`/`av_hwframe_*`)仍是裸 C 接口。
//!
//! ## FFmpeg 引用计数对象的生命周期
//! - `AVBufferRef`FFmpeg 引用计数句柄,`av_buffer_ref` 加 1、`av_buffer_unref` 减 1。
//! `AvHwDevCtx`/`AvHwFrameCtx` 用 RAII 包装:`new` alloc`Drop` unref。
//! - `AVFrame`:硬件帧(`AV_PIX_FMT_VAAPI`)的 `data[0]` 是 GPU 表面指针。
//!
//! ## `unsafe impl Send` 的存在原因(AGENTS.md 明确警告)
//! `*mut AVBufferRef` 默认 `!Send`wrapper 显式声明 Send 的前提是**外部调用方
//! 保证 `&mut self` 独占访问**。AGENTS.md:不要跨线程移动这些 wrapper 而不重新
//! 检查 exclusivity 假设。
//!
//! ## 与 Go cgo 的类比
//! - `extern "C" fn` ≈ Go `//export` C 回调;
//! - `unsafe { ffi::av_*() }` ≈ Go `C.av_xxx()`
//! - RAII `Drop` ≈ Go `runtime.SetFinalizer`(但 Rust Drop 是确定性的)。
// std 导入:CStringC 字符串)、mem::zeroedFFI 零初始化)、RawFd/AsRawFdfd 桥),
// c_voidC void 跨语言)、PathDRM 设备路径)、ptrnull_mut 等裸指针工具),
// slice(从裸指针构造切片)、AtomicBool/Ordering/Arc(跨线程暂停标志,T10b 用),
// Instant(编码计时,T10b 用)。本 sub-todolines 1-330)仅部分使用。
use std::ffi::CString;
use std::mem;
use std::os::fd::{AsRawFd, RawFd};
@@ -9,17 +34,28 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
// anyhow 错误处理:bail!(提前返回 Err)、Result<T>(错误传播)。
// ffmpeg-next:稍安全的 APIff::frame::Video、ff::format::Pixel、ff::codec 等)。
// ffmpeg_next::ffiFFmpeg C 头绑定的裸 APIAVBufferRef、av_hwdevice_ctx_create 等),
// 所有 `unsafe` FFI 调用的入口。`packet::Mut as _`:导入 packet 的 Mut trait 但匿名,
// 仅用于 trait method 解析,不污染命名空间。
use anyhow::{bail, Result};
use ffmpeg_next as ff;
use ffmpeg_next::ffi;
use ffmpeg_next::packet::Mut as _;
// crate 内部模块:PwDmaBufFramePipeWire DMA-BUF 帧元数据:fd/width/height/stride/modifier),
// TransformWayland wl_output 变换,决定 ROI 是否需要转置处理)。
use crate::cap_portal::PwDmaBufFrame;
use crate::transform::{transpose_if_transform_transposed, Transform};
// ---------------------------------------------------------------------------
// Bitrate feedback command (WebRTC BWE → SW encoder)
// ---------------------------------------------------------------------------
// 跨线程控制信令:WebRTC 线程根据 BWEbandwidth estimation)估算的可用带宽,
// 通过 MPSC channel 把 `BitrateCommand` 发给软件编码线程,由后者调整 x264 参数。
// 注意:硬件(VAAPI)路径目前不接受动态码率调整,只有软件(x264)路径消费这些命令。
// 类比 Go`chan BitrateCommand` 单向 channel。
/// Commands sent from the WebRTC thread to the SW encoder when the
/// bandwidth estimate changes significantly.
@@ -31,12 +67,26 @@ pub enum BitrateCommand {
ForceKeyframe,
}
// 分辨率变更事件:WebRTC 线程根据对端能力(SVC/layered encoding 协商)建议切换分辨率,
// 编码线程收到后重建 encoder(x264 不支持运行时改变 width/height,必须销毁重建)。
// `#[derive(Clone, Copy, Debug)]`Copy 表示按位拷贝(小值类型,无需 clone 调用),
// 类比 Go 的 `type ResolutionChange struct{...}`Go 默认值语义即 Copy)。
#[derive(Clone, Copy, Debug)]
pub struct ResolutionChange {
pub width: u32,
pub height: u32,
}
// 每帧编码耗时快照,由编码线程填好后通过 channel 发给统计线程(stats thread)。
// `#[derive(Default, Clone, Copy, Debug)]`Default 允许 `SwEncodeTiming::default()` 全零初始化;
// Copy/Clone 表示小值类型按位拷贝(无堆字段)。
//
// 字段语义:
// - sws_uslibswscale 把 NV12VAAPI 输出)转 YUV420Px264 输入)的耗时(微秒)
// - encode_us`avcodec_send_frame` + drain 接收所有 packet 的总耗时(微秒)
// - output_bytes:本帧产出的 H.264 字节数(即使下游 WebRTC 因暂停丢帧也计入)
//
// 类比 Go`type SwEncodeTiming struct{...}` + atomic store/load 跨 goroutine 传递。
/// Per-frame timing snapshot for the software encoder, consumed by the stats
/// thread. `sws_us` measures NV12→YUV420P conversion, `encode_us` measures
/// `avcodec_send_frame` + drain, and `output_bytes` counts encoded bytes
@@ -48,6 +98,10 @@ pub struct SwEncodeTiming {
pub output_bytes: usize,
}
// `encode_cpu_frame` 的返回值,编码线程据此判断是否计 encoded_fps(只有 `Encoded`
// 才算一次真实编码)。`#[derive(Debug, Clone, Copy, PartialEq, Eq)]`PartialEq/Eq
// 允许 `==` 比较(测试和 stats 聚合用),Copy 表示小值类型按位拷贝。
// 类比 Go`type EncodeOutcome int` + const 枚举值。
/// Outcome of a single `encode_cpu_frame` call. Used by the encode thread
/// to decide whether to report timing stats (only real encodes tick encoded_fps).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -66,19 +120,39 @@ pub enum EncodeOutcome {
// AvHwDevCtx
// ---------------------------------------------------------------------------
// VAAPI 设备上下文的 RAII 包装。`ptr` 是 FFmpeg 引用计数的 `AVBufferRef`
// 包装 `AVHWDeviceContext`VAAPI 内部的 `VADisplay`)。`new_vaapi` 时 alloc
// `Drop` 时 `av_buffer_unref``ref_clone` 用于把内部 ref 传递给编码器的
// `AVCodecContext::hw_device_ctx`FFmpeg 会再 `av_buffer_ref` 一次)。
// **独占访问假设**`unsafe impl Send` 的前提是外部调用方保证 `&mut self`
// 不允许多线程共享 `&AvHwDevCtx` 同时调用 mut method(详见 AGENTS.md 警告)。
pub struct AvHwDevCtx {
// FFmpeg 内部引用计数句柄,类型擦除为 `*mut AVBufferRef`。
// 永不为 null(除非在 Drop 中被置 null)。
ptr: *mut ffi::AVBufferRef,
}
// 中文概述:声明 `AvHwDevCtx` 可以跨线程 `Send`。前提是调用方用 `&mut self`
// 独占访问(无并发可变借用)。VAAPI 的 `VADisplay` 在硬件驱动层线程安全,
// FFmpeg 的 `AVHWDeviceContext` 内部状态在 send/receive 编码模式下也线程安全。
// 不修复:保留 AGENTS.md 文档的 exclusivity 假设(这是本 crate 的并发模型基础)。
// SAFETY: AvHwDevCtx wraps an FFmpeg AVBufferRef which is not Send by default,
// but we guarantee exclusive access through &mut self. The underlying VAAPI
// device context is thread-safe for the operations we perform.
unsafe impl Send for AvHwDevCtx {}
impl AvHwDevCtx {
// 创建 VAAPI 设备上下文。`drm_device` 通常是 `/dev/dri/renderD128`。
// 失败原因:设备不存在、无 VAAPI 驱动、权限不足(用户不在 `video` 组)。
// 成功时返回 `AvHwDevCtx`ptr 持有一个 refrefcount=1)。
pub fn new_vaapi(drm_device: &Path) -> Result<Self> {
// CString::new 在路径含内部 NUL 时返回 ErrFFmpeg C API 要求 NUL 结尾)。
// `to_str().unwrap()`:路径非 UTF-8 时 panicLinux DRM 设备路径通常 ASCII)。
let device_cstr = CString::new(drm_device.to_str().unwrap())?;
// 初始化为 null_mutFFmpeg 的 out-pointer 约定(调用者置 null,被调者赋值)。
let mut p: *mut ffi::AVBufferRef = ptr::null_mut();
// 中文概述:调用 FFmpeg C API 创建 VAAPI `AVHWDeviceContext`,写入 `*p`。
// 失败返回负的 AVERROR;成功返回 0 且 `p` 指向新分配的 `AVBufferRef`refcount=1)。
// SAFETY: device_cstr is a valid C string for the duration of the call;
// p is a valid out-pointer that FFmpeg initializes on success.
let ret = unsafe {
@@ -100,19 +174,30 @@ impl AvHwDevCtx {
Ok(Self { ptr: p })
}
// 返回裸 `*mut AVBufferRef`,调用方负责管理引用计数。
// 通常用于传给 FFmpeg API 的 `hw_device_ctx` 字段(FFmpeg 内部会 av_buffer_ref)。
pub fn as_ptr(&self) -> *mut ffi::AVBufferRef {
self.ptr
}
// 增加 refcount,返回新的 `*mut AVBufferRef`(不复制底层缓冲)。
// 用于把设备 ref 传给编码器的 `AVCodecContext::hw_device_ctx`,编码器 Drop 时
// 调用 `av_buffer_unref` 释放自己持有的 ref,不影响本 wrapper。
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// 中文概述:FFmpeg 引用计数 +1,返回新 ref;线程安全(内部原子操作)。
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
// RAII 析构:减少 refcount。如果是最后一个 refrefcount→0),FFmpeg 释放
// 底层的 `AVHWDeviceContext` 和 `VADisplay`。`is_null()` 守卫是为了应对
// `Drop` 被多次调用或 `new_vaapi` 失败后 ptr 仍为 null 的边缘情况(实际上
// Rust 的 Drop 不会被调用两次,但 FFmpeg 的 av_buffer_unref 接受 null 入参)。
impl Drop for AvHwDevCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// 中文概述:FFmpeg 引用计数 -1,refcount=0 时释放底层设备上下文。
// SAFETY: av_buffer_unref decrements refcount; frees the buffer when it hits zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
@@ -123,23 +208,38 @@ impl Drop for AvHwDevCtx {
// AvHwFrameCtx
// ---------------------------------------------------------------------------
// VAAPI 硬件帧池的 RAII 包装。`AVHWFramesContext` 是 FFmpeg 的硬件帧分配器,
// 编码器通过它分配 `AV_PIX_FMT_VAAPI` 帧(GPU 表面)。`initial_pool_size=4`
// 表示池大小为 4(足够 send/receive pipeline1 帧 encode 中 + 1 帧 queue + 2 缓冲)。
// 字段配置(width/height/sw_format)必须在 `av_hwframe_ctx_init` 前设置;
// `sw_fmt` 是 CPU 可读的软件格式(VAAPI 内部从该格式上传/下载)。
pub struct AvHwFrameCtx {
ptr: *mut ffi::AVBufferRef,
}
// 中文概述:声明 `AvHwFrameCtx` 可以跨线程 `Send`。前提是 `&mut self` 独占访问,
// 且底层硬件帧池在 send/receive 模式下线程安全。与 `AvHwDevCtx` 同样的 exclusivity 假设。
// 不修复:保留 AGENTS.md 文档的并发约束。
// SAFETY: AvHwFrameCtx wraps an FFmpeg AVBufferRef to an AVHWFramesContext.
// It is only accessed through &mut self, ensuring no concurrent mutation.
// The underlying hardware frames pool is thread-safe for the send/receive pattern.
unsafe impl Send for AvHwFrameCtx {}
impl AvHwFrameCtx {
// 内部构造函数:分配 `AVHWFramesContext`、配置字段、调用 `av_hwframe_ctx_init`。
// 失败时(alloc 或 init 失败)返回 Err,确保 ptr 不泄漏。
fn new_inner(hw_dev: &AvHwDevCtx, w: u32, h: u32, sw_fmt: ff::format::Pixel) -> Result<Self> {
// 中文概述:分配 `AVHWFramesContext`refcount=1,关联到 hw_dev 的设备上下文。
// 返回的 ref 的 `data` 字段指向未初始化的 `AVHWFramesContext`,需手动配置。
// SAFETY: hw_dev is a live AVHWDeviceContext; FFmpeg returns either a valid
// frames context ref or null (checked below).
let mut p = unsafe { ffi::av_hwframe_ctx_alloc(hw_dev.as_ptr()) };
if p.is_null() {
bail!("av_hwframe_ctx_alloc returned null");
}
// 中文概述:通过 `(*p).data as *mut AVHWFramesContext` 访问 FFmpeg 内部类型擦除的
// 字段(`AVBufferRef::data` 是 `*mut u8`,实际指向具体 context 类型)。
// 直接赋值 `format`/`sw_format`/`width`/`height`/`initial_pool_size` 5 个字段。
// SAFETY: p is a valid AVBufferRef from av_hwframe_ctx_alloc.
// Its .data field points to an AVHWFramesContext that we must configure.
unsafe {
@@ -150,10 +250,12 @@ impl AvHwFrameCtx {
(*fc).height = h as i32;
(*fc).initial_pool_size = 4;
}
// 中文概述:初始化帧池(实际分配 GPU 表面)。失败时必须手动 unref p 否则泄漏。
// SAFETY: p is a valid AVHWFramesContext ref configured above and not yet
// transferred or freed.
let ret = unsafe { ffi::av_hwframe_ctx_init(p) };
if ret < 0 {
// 中文概述:init 失败的清理路径——p 仍有效但不可用,unref 释放。
// SAFETY: p is valid but init failed; clean up.
unsafe { ffi::av_buffer_unref(&mut p) };
bail!("av_hwframe_ctx_init failed: {}", ff_err(ret));
@@ -161,6 +263,7 @@ impl AvHwFrameCtx {
Ok(Self { ptr: p })
}
// 为采集路径创建硬件帧池。`sw_fmt` 通常是 `BGRA`PipeWire DMA-BUF 像素格式)。
pub fn for_capture(
hw_dev: &AvHwDevCtx,
w: u32,
@@ -175,26 +278,35 @@ impl AvHwFrameCtx {
}
pub fn ref_clone(&self) -> *mut ffi::AVBufferRef {
// 中文概述:FFmpeg 引用计数 +1,返回新 ref;用于把帧池 ref 传给编码器。
// SAFETY: av_buffer_ref atomically increments refcount and returns a new ref.
unsafe { ffi::av_buffer_ref(self.ptr) }
}
}
// RAII 析构:与 AvHwDevCtx::Drop 同样的模式(unref 释放 ref)。
impl Drop for AvHwFrameCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
// 中文概述:FFmpeg 引用计数 -1refcount=0 时释放帧池和 GPU 表面。
// SAFETY: av_buffer_unref decrements refcount; frees when zero.
unsafe { ffi::av_buffer_unref(&mut self.ptr) };
}
}
}
// 启动时探针:测试 `drm_device` 能否通过 VAAPI 导入 PipeWire 给的 DMA-BUF 帧。
// 用于 backend_detect 决定走硬件(VAAPI)还是软件(x264)编码路径。
// 失败原因:硬件不支持给定格式/修饰符组合、DRM 设备无 VAAPI、内核 driver 限制。
// 成功只是"探针通过",不代表实际编码时一直可用(运行时仍可能因 OOM 等失败)。
/// Test whether `drm_device` can import the PipeWire DMA-BUF frame via VAAPI.
pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<()> {
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
let frames =
AvHwFrameCtx::for_capture(&hw_dev, frame.width, frame.height, ff::format::Pixel::BGRA)?;
// 中文概述:调用下面的 `import_dma_buf_to_vaapi`,用 `av_hwframe_map` 把 DMA-BUF
// 映射到 VAAPI 表面(零拷贝)。`as_raw_fd()` 把 `OwnedFd` 转为裸 fd 传给 FFI。
// SAFETY: frames is a live VAAPI frames context; frame carries valid DMA-BUF metadata.
unsafe {
import_dma_buf_to_vaapi(
@@ -212,6 +324,10 @@ pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<(
Ok(())
}
// 中文概述:把 PipeWire 的 DMA-BUF 帧零拷贝映射为 VAAPI `AVFrame`。
// DMA-BUF 是 Linux 内核的 GPU 缓冲共享机制(fd 指向 GPU 内存);`av_hwframe_map`
// 在 VAAPI 内部通过 `vaCreateSurfaces` + `vaMapBuffer` 直接 GPU 映射,无需 CPU 拷贝。
// 类比 GoGo 没有等价物——cgo + libavutil 才能实现同样的零拷贝路径。
/// Import a DMA-BUF into a VAAPI hardware frame via zero-copy `av_hwframe_map`.
///
/// # Safety
@@ -227,11 +343,16 @@ pub unsafe fn import_dma_buf_to_vaapi(
stride: u32,
offset: u64,
) -> Result<ff::frame::Video> {
// `libc::dup` 复制 fd——`AVDRMFrameDescriptor` 持有 dup 后的 fd
// 当 descriptor 释放时 `cleanup_drm_descriptor` 会 `libc::close` 它;
// 原始 `raw_fd` 不被本函数消费(仍由调用方拥有,通常是 PipeWire)。
let duped_fd = libc::dup(raw_fd);
if duped_fd < 0 {
bail!("dup(fd) failed: {}", std::io::Error::last_os_error());
}
// `mem::zeroed()`FFI 零初始化(所有字段置 0),类似 Go 的 `&drmDesc{}`。
// SAFETY 后续不再赘述——这是 `unsafe fn` 内部,调用契约由函数签名保证。
let mut desc: ffi::AVDRMFrameDescriptor = mem::zeroed();
desc.nb_objects = 1;
desc.objects[0].fd = duped_fd;
@@ -244,9 +365,14 @@ pub unsafe fn import_dma_buf_to_vaapi(
desc.layers[0].planes[0].offset = offset as isize;
desc.layers[0].planes[0].pitch = stride as isize;
// `Box::new(desc)` 把栈上的 descriptor 移到堆上;`Box::into_raw` 交出所有权给 FFmpeg。
// `av_buffer_create` 会接管这块内存:当 AVBufferRef refcount 归零时调用
// `cleanup_drm_descriptor`(自定义 free 回调)→ close fd + Box::from_raw 释放堆内存。
let desc_box = Box::new(desc);
let desc_ptr = Box::into_raw(desc_box);
// 创建 FFmpeg 自定义 AVBufferRefdata 指向 descriptorfree 回调为 cleanup_drm_descriptor。
// 失败时(极少)需要手动恢复 Box 并 close fd,否则泄漏。
let buf_ref = ffi::av_buffer_create(
desc_ptr as *mut u8,
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
@@ -260,6 +386,8 @@ pub unsafe fn import_dma_buf_to_vaapi(
bail!("av_buffer_create returned null for DRM descriptor");
}
// 构造 src AVFrame:格式为 `AV_PIX_FMT_DRM_PRIME`FFmpeg 的 DMA-BUF 包装格式),
// `data[0]` 指向 AVBufferRef 的 data(即 descriptor),`buf[0]` 持有 ref 防止提前释放。
let mut src = ff::frame::Video::empty();
{
let sp = src.as_mut_ptr();
@@ -270,7 +398,10 @@ pub unsafe fn import_dma_buf_to_vaapi(
(*sp).buf[0] = buf_ref;
}
// 构造 dst AVFrame:格式为 `AV_PIX_FMT_VAAPI`,挂上 `hw_frames_ctx` 让 FFmpeg 知道用哪个池。
let mut dst = ff::frame::Video::empty();
// 中文概述:把 dst 的 format 设为 VAAPI、`hw_frames_ctx` 设为新的 ref。
// `av_buffer_ref(frames_ctx)` 必须成功,否则 av_hwframe_map 无法定位帧池。
// SAFETY: frames_ctx is guaranteed by this unsafe function's contract to be a
// valid initialized VAAPI frames context; we set format/hw_frames_ctx on a
// freshly allocated dst frame.
@@ -282,6 +413,8 @@ pub unsafe fn import_dma_buf_to_vaapi(
bail!("av_buffer_ref(frames_ctx) returned null");
}
}
// 中文概述:核心 FFI 调用——FFmpeg 在内部调用 VAAPI `vaCreateSurfaces`+`vaMapBuffer`
// 把 DMA-BUF 映射到 GPU 表面,写入 dst。零 CPU 拷贝。
// SAFETY: src and dst are initialized AVFrames; dst has a valid hw_frames_ctx
// ref and av_hwframe_map fills dst from src.
let ret = unsafe {
@@ -298,6 +431,11 @@ pub unsafe fn import_dma_buf_to_vaapi(
Ok(dst)
}
// `av_buffer_create` 的 free 回调:当自定义 AVBufferRef 的 refcount 归零时被 FFmpeg 调用。
// `unsafe extern "C" fn` 是因为 FFmpeg 通过 C 函数指针调用它(C ABI 兼容)。
// 职责:(1) close DMA-BUF 的 dup'd fd(2) `Box::from_raw` 恢复堆内存所有权并立即 drop。
// `_opaque`FFmpeg 的 AVBufferRef 允许附带 opaque 数据(本例未用,故 `_` 前缀)。
// 类比 Go:相当于 `runtime.SetFinalizer` 的回调,但是 FFmpeg 主动调用的(确定性)。
unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8) {
let desc = data as *mut ffi::AVDRMFrameDescriptor;
if !desc.is_null() && (*desc).nb_objects > 0 && (*desc).objects[0].fd >= 0 {
@@ -306,19 +444,29 @@ unsafe extern "C" fn cleanup_drm_descriptor(_opaque: *mut c_void, data: *mut u8)
let _ = Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor);
}
// 把 FFmpeg 的负数错误码(AVERROR)转成人类可读字符串。
// `av_strerror` 是 FFmpeg 的线程安全 strerror 等价物,写入调用方提供的缓冲区。
// `pub(crate)` 可见性:仅供本 crate 内部错误格式化使用(不暴露给外部)。
/// Convert an FFmpeg error code to a human-readable string.
pub(crate) fn av_err_to_string(err: i32) -> String {
// 128 字节足够 AVERROR 描述(FFmpeg 内部字符串都很短)。
let mut buf = vec![0u8; 128];
// 中文概述:调用 FFmpeg `av_strerror`,把错误码描述写入 buf。
// 失败(无效错误码)时 buf 仍为全 0,trim 后为空字符串。
// SAFETY: buf points to 128 writable bytes and lives for the duration of
// av_strerror.
unsafe {
ffi::av_strerror(err, buf.as_mut_ptr() as *mut i8, buf.len());
}
// `from_utf8_lossy` 容忍非 UTF-8 字节;`trim_end_matches('\0')` 去掉 C 字符串结尾的 NUL。
String::from_utf8_lossy(&buf)
.trim_end_matches('\0')
.to_string()
}
// 带数字码的错误格式化:`"error -22 (Invalid argument)"`。
// 数字码很重要——AVERROR 是负数,但有些 FFmpeg 错误是宏(如 AVERROR(EAGAIN)),
// 看数字能区分底层 errno 还是 FFmpeg 自定义错误。
/// Format an FFmpeg error code with both numeric value and description.
/// Example output: "error -22 (Invalid argument)"
pub(crate) fn ff_err(ret: i32) -> String {
@@ -328,6 +476,20 @@ pub(crate) fn ff_err(ret: i32) -> String {
// EncState
// ---------------------------------------------------------------------------
// 编码状态机:把 VAAPI 硬件帧 → H.264 ES 流 → MP4 文件的"主循环编排者"。
//
// 字段角色(按数据流顺序):
// - enc_video : 已打开的 h264_vaapi 编码器句柄(FFmpeg AVCodecContext 包装)
// - video_filter : scale/crop/format 过滤图(BGRA→NV12 + VAAPI 像素格式上传)
// - frames_rgb : 捕获侧 hw frames 池(与 import_dma_buf_to_vaapi 共享)
// - hw_device_ctx : VAAPI 设备上下文(DRM render node 句柄)
// - octx : 输出 muxer 上下文(MP4 文件)
// - starting_timestamp : 首帧 PTS 锚点,用于把绝对时间戳归零(避免 MP4 起始时间漂移)
// - frames_written : 是否已写出过帧(用于决定 flush 时是否写 trailer
//
// 跨线程约束:state.rs/state_portal.rs 的设计是"单线程驱动"——`&mut EncState`
// 由外层 main 串行化访问,因此 `enc_video` / `octx` 等 FFmpeg 上下文不需要锁。
// `unsafe impl Send` 仅表达"类型上可以跨线程移动",不代表"可以并发访问"。
pub struct EncState {
enc_video: ff::codec::encoder::video::Video,
frames_rgb: AvHwFrameCtx,
@@ -338,10 +500,26 @@ pub struct EncState {
frames_written: bool,
}
// 安全说明:FFmpeg 的 AVCodecContext/AVFormatContext 等 C 对象不是自动 Sync 的,
// 但本工程的并发模型保证 EncState 只在单一编码线程内被 `&mut` 访问,因此标记
// Send(跨线程移动)是安全的;若未来引入并发编码必须重新审视(参见 AGENTS.md
// 关于 avhw.rs 显式 `unsafe impl Send` 与独占性假设的说明)。
unsafe impl Send for EncState {}
// impl EncState:编码主循环对外暴露的方法集合。
//
// 方法职责(按数据流顺序):
// - new : 构造 VAAPI 设备/帧池/filter 图/编码器/muxer 6 步流水线(FFmpeg 严格顺序)
// - frames_rgb : 暴露内部 hw frames 池给捕获侧 import_dma_buf_to_vaapi 共享
// - encode_frame : 单帧驱动:送入 filter → 拉过滤后帧 → avcodec_send_frame → drain_encoder
// - flush : EOF 处理:filter drain + 空帧 avcodec_send_frame 触发 encoder flush + write_trailer
// - drain_encoder: 内部辅助:循环 avcodec_receive_packet 直到 EAGAIN/EOF,重缩放 PTS 并写 muxer
impl EncState {
// 抑制 clippy::too_many_arguments:构造函数需要 11 个参数(捕获尺寸/编码尺寸/码率/GOP/帧率/变换/共享 hw ctx),
// 拆分为 builder 模式反而会增加 FFI 调用顺序出错的风险(FFmpeg 各步骤有严格依赖关系)。
#[allow(clippy::too_many_arguments)]
// 构造编码器:类比 Go `func NewEncState(...) (*EncState, error)`11 个参数对应 6 步流水线配置。
// 返回 `Result<Self>` 用 `?` 把任何 FFI/IO 错误传播给调用方(state.rs/state_portal.rs)。
pub fn new(
drm_device: &Path,
output_path: &Path,
@@ -359,6 +537,8 @@ impl EncState {
"EncState::new: {width}x{height} enc={enc_width}x{enc_height} transform={transform:?}"
);
// 1. VAAPI device — reuse existing context if provided
// `match` 是 Rust 的 exhaustive 模式匹配(类似 Go `switch` 但更强:编译器强制覆盖所有分支)。
// `Option<AvHwDevCtx>` 等价于 Go `*AvHwDevCtx`Some=valueNone=nil),但类型系统强制处理 nil 情况。
let hw_device_ctx = match existing_hw_ctx {
Some(ctx) => ctx,
None => AvHwDevCtx::new_vaapi(drm_device)?,
@@ -382,7 +562,11 @@ impl EncState {
let mut sink_ctx = video_filter
.get("out")
// `ok_or_else(|| ...)` 把 `Option<T>` 转为 `Result<T, E>`None 时执行闭包构造错误。
// 闭包 `|| ...` 延迟构造(类比 Go `if x == nil { return fmt.Errorf(...) }`);
// 末尾 `?` 把 Err 传播给调用方,Ok(t) 则解包继续。
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
// 中文概述:从 buffersink 取出硬件帧上下文并 `av_buffer_ref` 提升为拥有引用。
// SAFETY: sink_ctx is a live buffersink; the returned hw_frames_ctx is
// borrowed, so av_buffer_ref creates an owned reference.
let sink_hw_frames = unsafe {
@@ -397,6 +581,8 @@ impl EncState {
hw_ref
};
// 中文概述:解引用 sink_hw_frames 检查 filter graph 输出尺寸与编码器期望尺寸是否一致;
// 不一致仅 warnfilter 可能做隐式 scale),不视为硬错误。
// SAFETY: sink_hw_frames is an owned AVBufferRef to an AVHWFramesContext
// returned by the validated filter graph.
unsafe {
@@ -411,9 +597,14 @@ impl EncState {
}
// 4. Find h264_vaapi encoder
// `ff::encoder::find_by_name("h264_vaapi")` 返回 `Option<Codec>`FFmpeg 未编译 VAAPI 时为 None。
// `.ok_or_else(...)?` 链式:Option → Result → 自动传播。
let codec = ff::encoder::find_by_name("h264_vaapi")
.ok_or_else(|| anyhow::anyhow!("h264_vaapi encoder not found"))?;
// 块表达式 `{ ... }` 求值为最后一个表达式(无分号)的值:这里 `ctx.encoder().video()?`
// 返回 `Result<Encoder<Video>>``?` 解开为 `Encoder<Video>`,作为整个块的值赋给 `enc`。
// 类比 Go 的 `enc := func() *Encoder { ... return x }()` 但 Rust 的块是表达式级。
let mut enc = {
let ctx = ff::codec::Context::new_with_codec(codec);
ctx.encoder().video()?
@@ -430,18 +621,21 @@ impl EncState {
// VBV rate limiting: caps IDR burst size for WebRTC. Without this a 4K
// scene change can produce a 256KB keyframe that overflows the UDP send
// buffer. bufsize=bitrate/4 ≈ 250ms of video at the target bitrate.
// 中文概述:直接写裸指针字段 rc_max_rate/rc_buffer_size 设置 VBV 上限,避免 4K IDR 帧击爆 UDP 缓冲。
unsafe {
let ctx_ptr = enc.as_mut_ptr();
(*ctx_ptr).rc_max_rate = bitrate as i64;
(*ctx_ptr).rc_buffer_size = (bitrate / 4) as i32;
}
// 中文概述:AV_CODEC_FLAG_GLOBAL_HEADER 必须在 open 之前设置,触发 SPS/PPS extradata 生成(muxer 做 Annex B → AVCC 转换时需要)。
// SAFETY: AV_CODEC_FLAG_GLOBAL_HEADER must be set BEFORE opening the encoder.
// It triggers SPS/PPS extradata generation needed by the muxer for
// Annex B to AVCC conversion.
unsafe {
(*enc.as_mut_ptr()).flags |= ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32;
}
// 中文概述:把 hw_device_ctx 与 sink_hw_framesfilter 输出帧池)引用赋给编码器,编码器从该池分配 VAAPI surface。
// SAFETY: Assign hw device and frames ctx to the encoder.
unsafe {
(*enc.as_mut_ptr()).hw_device_ctx = hw_device_ctx.ref_clone();
@@ -465,15 +659,23 @@ impl EncState {
}
// 5. Open encoder. Video::open() returns Encoder(Video); .0 extracts the Video.
// `.map_err(|e| ...)?` 用闭包包装错误上下文(保留原始 ffmpeg::Error,附加上下文消息),
// `?` 自动传播。元组解构 `opened.0``Encoder(Video)` 是 newtype 包装,`.0` 取内部 Video。
let opened = enc
.open()
.map_err(|e| anyhow::anyhow!("Failed to open h264_vaapi encoder: {e}"))?;
let enc_video = opened.0;
// 6. Muxer setup (strict order)
// `CString` 是 Rust ↔ C FFI 的桥梁:FFmpeg C API 需要以 `\0` 结尾的字符串,
// Rust `String`/`&str` 不带终止符,必须用 `CString::new` 包装。
// `output_path.to_str().unwrap()` 把 `Path` 转 UTF-8 字符串切片(非 UTF-8 路径会 panic)。
let output_cstr = CString::new(output_path.to_str().unwrap())?;
// 裸指针声明:`*mut ffi::AVFormatContext` 是 C 指针(FFI 类型),Rust 中默认不可解引用。
// `ptr::null_mut()` 等价于 C 的 `NULL`FFmpeg C API 接收 `**` 时会重新分配并写回。
let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut();
// 中文概述:根据文件扩展名(.mp4/.mkv/...)分配输出 format context,但不打开文件。
// SAFETY: avformat_alloc_output_context2 creates format context from
// the file extension. Does NOT open the file.
let ret = unsafe {
@@ -488,6 +690,7 @@ impl EncState {
bail!("Failed to allocate output format context: {}", ff_err(ret));
}
// 中文概述:检查编码器 codec_id 与 muxer oformat 是否兼容(H.264 + MP4 应返回 ≥0)。
// SAFETY: avformat_query_codec checks codec+format compatibility.
let codec_id = unsafe { (*enc_video.as_ptr()).codec_id };
let oformat = unsafe { (*fmt_ctx_ptr).oformat };
@@ -498,12 +701,14 @@ impl EncState {
bail!("H.264 codec not supported by output container format");
}
// 中文概述:在 format context 中新建一条 stream(视频流),返回的指针由 fmt_ctx 拥有,无需手动释放。
// SAFETY: avformat_new_stream creates a new stream in the format context.
let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) };
if stream_ptr.is_null() {
bail!("Failed to create new stream in output context");
}
// 中文概述:从 encoder 拷贝参数(含 SPS/PPS extradata)到 stream->codecpar,供 demuxer 解析。
// SAFETY: avcodec_parameters_from_context copies encoder params + extradata.
let ret = unsafe {
ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr())
@@ -515,11 +720,13 @@ impl EncState {
);
}
// 中文概述:把 encoder 的 time_base1/fps)拷贝到 stream,muxer 写头部时按此时基打时间戳。
// SAFETY: Copy encoder time_base to stream.
unsafe {
(*stream_ptr).time_base = (*enc_video.as_ptr()).time_base;
}
// 中文概述:打开输出文件 IO 上下文(AVIO),把 pbAVIOContext*)挂到 fmt_ctx。
// SAFETY: avio_open opens the output file for writing.
let ret = unsafe {
ffi::avio_open(
@@ -536,15 +743,19 @@ impl EncState {
);
}
// 中文概述:写入容器头部(MP4 ftyp box 等),失败需关闭 IO 释放资源(此处简化为直接 bail)。
// SAFETY: avformat_write_header writes the container header.
let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) };
if ret < 0 {
bail!("Failed to write output header: {}", ff_err(ret));
}
// 中文概述:把裸 fmt_ctx_ptr 包装回 safe Rust 的 Output 类型,由 octx 的 Drop 负责最终释放。
// SAFETY: We created fmt_ctx_ptr above and it's valid.
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// `Ok(Self { ... })` 是 Rust 构造语法:struct literal 字段简写(`enc_video` 等价于 `enc_video: enc_video`)。
// 类比 Go `return &EncState{enc_video: ..., ...}, nil`,但 Rust 的 `Self` 是类型别名。
Ok(Self {
enc_video,
frames_rgb,
@@ -556,10 +767,15 @@ impl EncState {
})
}
// 共享访问器:返回内部 hw frames 池的引用,捕获侧(state.rs/state_portal.rs)用它
// 给 `import_dma_buf_to_vaapi` 分配目标 VAAPI surface。借用检查器保证调用方不能
// 在持有 `&AvHwFrameCtx` 期间 `&mut self`(例如调用 `encode_frame`)。
pub fn frames_rgb(&self) -> &AvHwFrameCtx {
&self.frames_rgb
}
// 单帧驱动主循环:`&mut self` 表示独占借用(类比 Go `*receiver` + 单线程保证)。
// 接收一个 VAAPI 硬件帧,通过 filter 图(BGRA→NV12 + scale/crop)后送给编码器。
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
let mut filter_src_ctx = self
.video_filter
@@ -577,10 +793,17 @@ impl EncState {
.add(hw_frame)
.map_err(|e| anyhow::anyhow!("Filter source add failed: {e}"))?;
// 持续从 sink 拉过滤后帧,直到 EAGAINfilter 缓冲空)。
// `loop {}` 是 Rust 无限循环(类比 Go `for {}`),靠 `break` 退出。
loop {
// `ff::frame::Video::empty()` 分配零值帧(无数据),filter_sink.frame() 填充它。
// `&mut filtered` 把可变借用传给 ffmpeg-next,类比 Go `&filtered`。
let mut filtered = ff::frame::Video::empty();
// 多路 matchOk 表示成功;带 guard 的 `Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN`
// 是 pattern guard(类比 Go `switch v := err.(type); ...`);通配 `Err(e)` 兜底。
match filter_sink.frame(&mut filtered) {
Ok(()) => {
// 若 filter 没设 PTSpts()==None),用输入 hw_frame 的 PTS 兜底。
if filtered.pts().is_none() {
filtered.set_pts(hw_frame.pts());
}
@@ -595,18 +818,23 @@ impl EncState {
}
let start_ts = self.starting_timestamp.unwrap();
// 中文概述:把过滤后的 NV12 VAAPI surface 发送给编码器输入队列;<0 表示 FFI 失败。
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
let ret =
unsafe { ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr()) };
if ret < 0 {
bail!("avcodec_send_frame failed: {}", ff_err(ret));
}
// 立即 drain 编码器输出队列:FFmpeg 编码可能延迟一帧或多帧才产出 packet,
// send_frame 后必须 receive_packet 直到 EAGAIN,否则编码器内部缓冲溢出。
self.drain_encoder(start_ts)?;
}
Ok(())
}
// EOF 处理:在调用方(state.rs/state_portal.rs)完成所有 `encode_frame` 后调用,
// 负责把 filter 图残余帧排空 + 触发编码器 EOS + 写 muxer trailermoov box 等)。
pub fn flush(&mut self) -> Result<()> {
// Flush filter graph
let mut filter_src_ctx = self
@@ -614,6 +842,7 @@ impl EncState {
.get("in")
.ok_or_else(|| anyhow::anyhow!("filter 'in' not found"))?;
let mut filter_src = filter_src_ctx.source();
// `if let Err(e) = ...` 模式匹配:只关心 Err 分支,Ok 丢弃。类比 Go `if err := ...; err != nil {}`。
if let Err(e) = filter_src.flush() {
tracing::debug!("filter source flush error: {e}");
}
@@ -623,12 +852,18 @@ impl EncState {
.video_filter
.get("out")
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
// Drain filter:循环拉 sink,把 filter 图中残余的帧全部送入编码器。
// 与 encode_frame 不同:这里 Err 通配 `Err(_) => break`(任何错误都视为 drain 完毕),
// 因为 flush 是收尾阶段,不再传播错误。
let mut filter_sink = filter_sink_ctx.sink();
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
Ok(()) => {
// `unwrap_or(0)` 是 Option 的兜底方法:None 返回 0Some(v) 返回 v。
// flush 时若从未编码过帧(starting_timestamp=None),用 0 作为 PTS 基线。
let start_ts = self.starting_timestamp.unwrap_or(0);
// 中文概述:把 drain 出的帧送编码器;flush 路径失败也 bail(停止 flush)。
// SAFETY: filtered is a valid VAAPI frame drained from the
// filter graph; enc_video is an opened encoder.
let ret = unsafe {
@@ -643,15 +878,18 @@ impl EncState {
}
}
// 中文概述:发送 NULL 帧给编码器,触发 EOSEnd Of Stream),编码器内部缓冲的帧会被强制输出。
// SAFETY: Sending null frame signals end of stream to encoder.
unsafe {
ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), ptr::null());
}
let start_ts = self.starting_timestamp.unwrap_or(0);
// 最后一次 drain:取出 EOS 触发后所有剩余 packet。
self.drain_encoder(start_ts)?;
// Write trailer only if at least one frame was encoded.
// 写 trailer 条件:若全程 0 帧编码,写 trailer 会让 muxer 生成空 moov(损坏文件),所以用 frames_written 守卫。
if self.frames_written {
self.octx
.write_trailer()
@@ -661,14 +899,19 @@ impl EncState {
Ok(())
}
// 内部辅助方法(无 pub):每次 send_frame 后调用,循环 receive_packet 直到 EAGAIN/EOF。
// `start_ts: i64` 是首帧 PTS,用于把所有 packet 的 PTS/DTS 减去首帧 PTS(归零起点)。
fn drain_encoder(&mut self, start_ts: i64) -> Result<()> {
loop {
let mut pkt = ff::Packet::empty();
// 中文概述:从编码器拉一个已编码 packet;返回 <0 时区分 EAGAIN(暂时无)和 EOF(流结束)。
// SAFETY: avcodec_receive_packet retrieves an encoded packet.
let ret = unsafe {
ffi::avcodec_receive_packet(self.enc_video.as_mut_ptr(), pkt.as_mut_ptr())
};
if ret < 0 {
// 嵌套 if:先判 ret<0FFI 错误),再判具体码(EAGAIN/EOF)。
// `ffi::AVERROR(ffi::EAGAIN)` 是 FFmpeg 的 errno 包装宏(负数)。
if ret == ffi::AVERROR(ffi::EAGAIN) || ret == ffi::AVERROR_EOF {
break;
}
@@ -676,7 +919,10 @@ impl EncState {
}
// Rescale timestamps from encoder time_base to stream time_base
// 时间基重缩放:encoder 用 1/fpsstream 可能用 1/1000 等不同基。
// rescale_ts 用有理数运算,避免浮点误差累积。
let enc_tb = self.enc_video.time_base();
// 中文概述:通过裸指针读 octx.streams[0].time_base,得到 stream 的时间基(由 muxer 决定)。
// SAFETY: octx was created with stream 0 during muxer setup; streams
// is non-null and stream 0 remains owned by the format context.
let stream_tb = unsafe {
@@ -684,12 +930,16 @@ impl EncState {
if fmt.nb_streams == 0 || fmt.streams.is_null() {
bail!("no streams in output context");
}
// `fmt.streams.add(0)` 是裸指针算术: streams 是 `*mut *mut AVStream`
// `.add(0)` 取首元素地址,外层 `*` 解引用得到 `*mut AVStream`。
let st = *fmt.streams.add(0);
ff::Rational::from((*st).time_base)
};
pkt.rescale_ts(enc_tb, stream_tb);
// Offset timestamps so first frame starts at 0
// PTS/DTS 归零:把首帧 PTS 减去自身得到 0,后续帧相对首帧的偏移。`if let Some(pts) = ...`
// 模式匹配:只有当 PTS 存在时才改写(部分 packet 可能没有 PTS)。
if let Some(pts) = pkt.pts() {
pkt.set_pts(Some(pts - start_ts));
}
@@ -697,10 +947,13 @@ impl EncState {
pkt.set_dts(Some(dts - start_ts));
}
// 标记 packet 属于 stream 0(视频流)。
pkt.set_stream(0);
// 交错写入:muxer 内部会按 DTS 排序后写出,类比 Go `mp4Writer.WriteInterleaved(pkt)`。
pkt.write_interleaved(&mut self.octx)
.map_err(|e| anyhow::anyhow!("Failed to write packet: {e}"))?;
// 标记:至少一帧已写入 muxer(flush 时用此守卫决定是否写 trailer)。
self.frames_written = true;
}
Ok(())
+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}"))?;
}
+168
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];
// SAFETY: 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();
// SAFETY: 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();
// SAFETY: 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"))
@@ -266,6 +395,7 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
}
// SAFETY: fmt_ctx_ptr is valid; pb is initialized for non-NOFILE muxers.
// AVFMT_NOFILE 表示该 muxer 不需要物理文件(如 null muxer),跳过 avio_open
unsafe {
if (*(*fmt_ctx_ptr).oformat).flags & ffi::AVFMT_NOFILE == 0 {
let ret = ffi::avio_open(
@@ -286,9 +416,11 @@ fn create_software_encoder(output_path: &Path, width: u32, height: u32) -> Resul
}
// SAFETY: ownership of fmt_ctx_ptr transfers into ffmpeg-next Output wrapper.
// 此后 octx 拥有 fmt_ctx_ptr,会在 Drop 时调用 avformat_free_context
let octx = unsafe { ff::format::context::Output::wrap(fmt_ctx_ptr) };
// SAFETY: Allocate and configure an owned writable YUV420P frame for encoder input.
// 这个 yuv_frame 在每次 encode_yuv_frame 中复用(不重新分配),由 SoftwareEncoder::drop 释放
let yuv_frame = unsafe {
let mut f = ffi::av_frame_alloc();
if f.is_null() {
@@ -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,
@@ -343,6 +488,7 @@ fn create_sws_context(
dst_height: u32,
) -> Result<SwsContext> {
// SAFETY: sws_getContext creates an owned scaler context for the provided dimensions/formats.
// 返回的 *mut SwsContext 由 SwsContext 包装并在 Drop 中通过 sws_freeContext 释放。
let ctx = unsafe {
ffi::sws_getContext(
src_width as i32,
@@ -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
+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 字节
+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 }
}