//! # 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 // ===== 标准库导入 ===== // CString:FFI 传递给 C 函数的 NUL 结尾字符串;类比 Go 中显式末尾 0 的 []byte // AsRawFd trait:把 Rust 的 OwnedFd 暴露为原始 int fd(用于 DMA-BUF 导入) // Path:跨平台路径类型;类比 Go filepath // ptr:FFI 裸指针工具(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 ===== // anyhow:Result = Result;bail! 宏提前返回 Err;类比 Go (T, error) // clap:CLI 参数解析(Derive 宏);本文件 BenchArgs 与 args.rs Args 都用此模式 use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; // ffmpeg_next:FFmpeg 绑定。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, Gpu, Both, } /// 单条流水线(CPU 或 GPU)运行结束后的统计聚合。每个 `Vec` 保存每帧的耗时(微秒)。 /// /// 类比 Go 的 `BenchmarkResult`:把 N 帧的逐次耗时收集起来,最后统一计算均值。 /// 用 `Vec` 而非流式累积是为了支持后续可能的中位数/分位数扩展。 #[derive(Default)] struct FrameStats { // DMA-BUF 导入耗时(仅 GPU 路径有,CPU 路径为空) import_us: Vec, // GPU 滤镜图耗时(仅 GPU 路径有) filter_us: Vec, // CPU 路径的 sws_scale 耗时 transfer_us: Vec, // 预留:缩放耗时单独拆分(当前与 filter_us/transfer_us 重叠) scale_us: Vec, // 像素格式转换耗时(BGRA → YUV420P) format_us: Vec, // 编码器 send_frame + drain_packet 总耗时 encode_us: Vec, // 单帧总耗时(capture_start → encode_done),用于理论 FPS total_us: Vec, // 导入失败的次数(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::() 显式指定求和类型,避免类型推导失败;类比 Go 的 for-range 累加 data.iter().sum::() 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 } else { 0.0 } } // 理论 FPS = 1000 / 平均单帧总耗时(仅编码侧上限,不含 PipeWire 等待) fn theoretical_fps(&self) -> f64 { let avg = self.avg_total_ms(); if avg > 0.0 { 1000.0 / avg } else { 0.0 } } } /// 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, yuv_frame: *mut ffi::AVFrame, 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 { ffi::av_frame_free(&mut self.yuv_frame); } } } /// 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); } } } /// 把 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, // 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 { 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"), PwCtrlEvent::FormatChanged { .. } => {} 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)) { Ok(frame) => return Ok(frame), Err(crossbeam_channel::RecvTimeoutError::Timeout) => { bail!("Timeout waiting for first frame (10s)"); } Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { bail!("PipeWire frame channel disconnected"); } } } } /// 从编码器循环拉取已编码的 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() // 指向空 packet,FFmpeg 会在此调用中分配 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() 指向有效的 AVFormatContext;streams 数组至少有一个流 // (在 create_software_encoder 中由 avformat_new_stream 创建)。 let stream_tb = unsafe { let streams = (*octx.as_ptr()).streams; let st = *streams.add(0); ff::Rational::from((*st).time_base) }; 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. 寻找 codec(libx264 优先,libopenh264 回退) /// 2. 创建 encoder context(builder 模式) /// 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 { // CString 必须在 unsafe 块外构造,确保 NUL 结尾的字符串生命周期覆盖下面的 FFI 调用 let output_cstr = CString::new(output_path.to_str().unwrap())?; // 优先 libx264(性能最好,GPL 协议),其次 libopenh264(BSD,回退方案) let codec = ff::encoder::find_by_name("libx264") .or_else(|| ff::encoder::find_by_name("libopenh264")) .ok_or_else(|| { anyhow::anyhow!("No H.264 software encoder found (tried libx264, libopenh264)") })?; 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. unsafe { let key = CString::new("preset").unwrap(); let val = CString::new("veryfast").unwrap(); ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0); let key = CString::new("tune").unwrap(); let val = CString::new("zerolatency").unwrap(); ffi::av_opt_set((*enc.as_mut_ptr()).priv_data, key.as_ptr(), val.as_ptr(), 0); } } // 真正打开编码器(前面只是 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")) .unwrap_or(false); let fmt_name = if use_null_muxer { CString::new("null").unwrap() } else { CString::new("").unwrap() }; let fmt_name_ptr = if use_null_muxer { fmt_name.as_ptr() } else { ptr::null() }; let mut fmt_ctx_ptr: *mut ffi::AVFormatContext = ptr::null_mut(); // SAFETY: fmt_ctx_ptr is an out pointer initialized by FFmpeg; output_cstr and fmt_name live // across the call. let ret = unsafe { ffi::avformat_alloc_output_context2( &mut fmt_ctx_ptr, ptr::null_mut(), fmt_name_ptr, output_cstr.as_ptr(), ) }; if ret < 0 || fmt_ctx_ptr.is_null() { bail!("Failed to allocate output format context: error {ret}"); } // SAFETY: fmt_ctx_ptr is a valid output context allocated above. let stream_ptr = unsafe { ffi::avformat_new_stream(fmt_ctx_ptr, ptr::null()) }; if stream_ptr.is_null() { bail!("Failed to create output stream"); } // SAFETY: stream and codec context pointers are valid; parameters are copied into stream. let ret = unsafe { ffi::avcodec_parameters_from_context((*stream_ptr).codecpar, enc_video.as_ptr()) }; if ret < 0 { bail!("Failed to copy codec parameters: error {ret}"); } // 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( &mut (*fmt_ctx_ptr).pb, output_cstr.as_ptr(), ffi::AVIO_FLAG_WRITE, ); if ret < 0 { bail!("Failed to open output file: error {ret}"); } } } // SAFETY: fmt_ctx_ptr is a fully configured output context. let ret = unsafe { ffi::avformat_write_header(fmt_ctx_ptr, ptr::null_mut()) }; if ret < 0 { bail!("Failed to write header: error {ret}"); } // 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() { bail!("av_frame_alloc failed"); } (*f).width = width as i32; (*f).height = height as i32; (*f).format = ffi::AVPixelFormat::AV_PIX_FMT_YUV420P as i32; let r = ffi::av_frame_get_buffer(f, 0); if r < 0 { ffi::av_frame_free(&mut f); bail!("av_frame_get_buffer failed: {r}"); } f }; Ok(SoftwareEncoder { enc_video, octx, yuv_frame, codec_name, }) } /// 根据 `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, src_fmt: ffi::AVPixelFormat, dst_width: u32, dst_height: u32, ) -> Result { // 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, src_height as i32, src_fmt, dst_width as i32, dst_height as i32, ffi::AVPixelFormat::AV_PIX_FMT_YUV420P, 2, ptr::null_mut(), ptr::null_mut(), ptr::null_mut(), ) }; if ctx.is_null() { bail!("Failed to create sws_scale context"); } Ok(SwsContext(ctx)) } /// 把已填好 YUV420P 数据的 `encoder.yuv_frame` 送入编码器,并 drain 已编码 packet。 /// 返回编码阶段的耗时(微秒),用于 `FrameStats::encode_us` 统计。 fn encode_yuv_frame(encoder: &mut SoftwareEncoder, pts: &mut i64) -> Result { // 类比 Go time.Now();用 as_micros() as u64 转 u64(u128 截断不影响 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 { // 单调递增的 PTS;FFmpeg 要求 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); if r < 0 { 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 触发编码器 flush,drain 残余 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 { ffi::avcodec_send_frame(encoder.enc_video.as_mut_ptr(), ptr::null()); } drain_encoder(&mut encoder.enc_video, &mut encoder.octx)?; encoder .octx .write_trailer() .map_err(|e| anyhow::anyhow!("Failed to write trailer: {e}"))?; 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, ) -> Result { // SAFETY: frames_ctx is a live VAAPI frames context configured for the capture format; frame // carries a valid DMA-BUF fd and metadata from PipeWire for the duration of the call. unsafe { import_dma_buf_to_vaapi( frames_ctx.as_ptr(), frame.fd.as_raw_fd(), frame.width, frame.height, frame.format, frame.modifier, frame.stride, frame.offset, ) } } /// 构建 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, width: u32, height: u32, enc_width: u32, enc_height: u32, ) -> Result { 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"))?; // pix_fmt must be set via av_buffersrc_parameters_set (below), not in args — // FFmpeg 8.0+ rejects HW pixel formats during init() if hw_frames_ctx is missing. // Use a placeholder SW format here; it gets overridden by parameters_set below. let args = format!( "video_size={}x{}:pix_fmt=bgra:time_base=1/60:pixel_aspect=1/1", width, height, ); let mut src_ctx = graph.add(&buffersrc, "in", &args)?; // SAFETY: Allocate buffersrc parameters, attach a ref-counted hw_frames_ctx compatible with // imported VAAPI BGRA frames, apply it, then free only the parameter struct (not the ref). let par = unsafe { ffi::av_buffersrc_parameters_alloc() }; if par.is_null() { bail!("av_buffersrc_parameters_alloc returned null"); } // SAFETY: par and src_ctx are valid; frames_rgb.ref_clone returns an owned AVBufferRef. unsafe { (*par).format = Into::::into(ff::format::Pixel::VAAPI) as i32; (*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,输出 NV12(VAAPI H.264 要求的输入格式) let mut scale_ctx = graph.add( &scale_vaapi, "scale", &format!("{enc_width}:{enc_height}:format=nv12"), )?; // SAFETY: scale_vaapi uses this ref-counted VAAPI device context while graph is alive. unsafe { (*scale_ctx.as_mut_ptr()).hw_device_ctx = hw_dev.ref_clone(); } 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 .validate() .map_err(|e| anyhow::anyhow!("GPU filter graph validation failed: {e}"))?; Ok(graph) } #[allow(clippy::too_many_arguments)] fn run_cpu_pipeline( cap: &CapPortal, frames_ctx: &AvHwFrameCtx, output: &str, frames: u32, src_width: u32, src_height: u32, enc_width: u32, enc_height: u32, ) -> Result { let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?; let sws_ctx = create_sws_context( src_width, src_height, ffi::AVPixelFormat::AV_PIX_FMT_BGRA, enc_width, enc_height, )?; println!( " Encoder: {}, {}x{} YUV420P", encoder.codec_name, enc_width, enc_height ); println!(" Output: {output}"); println!(" CPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> av_hwframe_transfer_data -> sws_scale -> YUV420P 2K -> encode\n"); let mut stats = FrameStats { codec_name: encoder.codec_name.clone(), output_path: output.to_string(), ..FrameStats::default() }; let total_start = Instant::now(); let mut pts: i64 = 0; while stats.frames_encoded < frames { if let Ok(ctrl) = cap.event_receiver().try_recv() { match ctrl { PwCtrlEvent::StreamEnded => break, PwCtrlEvent::Error(e) => bail!( "PipeWire error after {} CPU frames: {e}", stats.frames_encoded ), PwCtrlEvent::FormatChanged { .. } => {} } } let frame = match cap .frame_receiver() .recv_timeout(std::time::Duration::from_secs(5)) { Ok(f) => f, Err(_) => break, }; let frame_start = Instant::now(); let t_import = Instant::now(); let vaapi_frame = match import_frame(frames_ctx, &frame) { Ok(f) => f, Err(e) => { stats.import_failures += 1; if stats.import_failures <= 3 { eprintln!("CPU frame {}: import failed: {e}", stats.frames_encoded); } continue; } }; let import_us = t_import.elapsed().as_micros() as u64; let t_transfer = Instant::now(); // SAFETY: sw_frame is allocated by FFmpeg and freed on all paths below. let mut sw_frame = unsafe { ffi::av_frame_alloc() }; if sw_frame.is_null() { bail!("CPU frame {}: av_frame_alloc failed", stats.frames_encoded); } // SAFETY: sw_frame is an allocated destination; vaapi_frame is a valid VAAPI source frame. let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_frame, vaapi_frame.as_ptr(), 0) }; if transfer_ret < 0 { // SAFETY: sw_frame was allocated above and has not been freed yet. unsafe { ffi::av_frame_free(&mut sw_frame) }; bail!( "CPU frame {}: av_hwframe_transfer_data failed: {} ({})", stats.frames_encoded, transfer_ret, av_err_to_string(transfer_ret) ); } let transfer_us = t_transfer.elapsed().as_micros() as u64; let t_scale = Instant::now(); // SAFETY: sw_frame contains transferred BGRA data; encoder.yuv_frame is writable YUV420P // at the configured output dimensions; sws_ctx converts and downscales between them. unsafe { ffi::av_frame_make_writable(encoder.yuv_frame); ffi::sws_scale( sws_ctx.0, (*sw_frame).data.as_ptr() as *const *const u8, (*sw_frame).linesize.as_ptr() as *const i32, 0, (*sw_frame).height, (*encoder.yuv_frame).data.as_ptr() as *mut *mut u8, (*encoder.yuv_frame).linesize.as_ptr() as *const i32, ); } let scale_us = t_scale.elapsed().as_micros() as u64; // SAFETY: sw_frame was allocated above and is no longer needed after scaling. unsafe { ffi::av_frame_free(&mut sw_frame) }; let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?; let total_us = frame_start.elapsed().as_micros() as u64; stats.import_us.push(import_us); stats.transfer_us.push(transfer_us); stats.scale_us.push(scale_us); stats.encode_us.push(encode_us); stats.total_us.push(total_us); stats.frames_encoded += 1; if stats.frames_encoded <= 3 || stats.frames_encoded % 30 == 0 { println!( " CPU frame {:>4}/{frames}: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms", stats.frames_encoded, import_us as f64 / 1000.0, transfer_us as f64 / 1000.0, scale_us as f64 / 1000.0, encode_us as f64 / 1000.0, total_us as f64 / 1000.0, ); } } finish_encoder(encoder)?; stats.elapsed_secs = total_start.elapsed().as_secs_f64(); Ok(stats) } #[allow(clippy::too_many_arguments)] fn run_gpu_pipeline( cap: &CapPortal, hw_dev: &AvHwDevCtx, frames_ctx: &AvHwFrameCtx, output: &str, frames: u32, src_width: u32, src_height: u32, enc_width: u32, enc_height: u32, ) -> Result { let mut encoder = create_software_encoder(Path::new(output), enc_width, enc_height)?; let format_ctx = create_sws_context( enc_width, enc_height, ffi::AVPixelFormat::AV_PIX_FMT_NV12, enc_width, enc_height, )?; let mut graph = build_gpu_filter_graph( hw_dev, frames_ctx, src_width, src_height, enc_width, enc_height, )?; println!( " Encoder: {}, {}x{} YUV420P", encoder.codec_name, enc_width, enc_height ); println!(" Output: {output}"); println!(" GPU Pipeline: DMA-BUF 4K BGRA -> av_hwframe_map -> scale_vaapi 2K NV12 -> transfer small NV12 -> sws_scale format-only -> encode\n"); let mut stats = FrameStats { codec_name: encoder.codec_name.clone(), output_path: output.to_string(), ..FrameStats::default() }; let total_start = Instant::now(); let mut pts: i64 = 0; while stats.frames_encoded < frames { if let Ok(ctrl) = cap.event_receiver().try_recv() { match ctrl { PwCtrlEvent::StreamEnded => break, PwCtrlEvent::Error(e) => bail!( "PipeWire error after {} GPU frames: {e}", stats.frames_encoded ), PwCtrlEvent::FormatChanged { .. } => {} } } let frame = match cap .frame_receiver() .recv_timeout(std::time::Duration::from_secs(5)) { Ok(f) => f, Err(_) => break, }; let frame_start = Instant::now(); let t_import = Instant::now(); let vaapi_frame = match import_frame(frames_ctx, &frame) { Ok(f) => f, Err(e) => { stats.import_failures += 1; if stats.import_failures <= 3 { eprintln!("GPU frame {}: import failed: {e}", stats.frames_encoded); } continue; } }; let import_us = t_import.elapsed().as_micros() as u64; let t_filter = Instant::now(); let mut filter_src_ctx = graph.get("in").unwrap(); let mut filter_src = filter_src_ctx.source(); let mut filter_sink_ctx = graph.get("out").unwrap(); let mut filter_sink = filter_sink_ctx.sink(); filter_src .add(&vaapi_frame) .map_err(|e| anyhow::anyhow!("GPU filter source add failed: {e}"))?; let mut filtered = ff::frame::Video::empty(); match filter_sink.frame(&mut filtered) { Ok(()) => {} Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => continue, Err(e) => bail!("GPU filter sink get frame failed: {e}"), } let filter_us = t_filter.elapsed().as_micros() as u64; let t_transfer = Instant::now(); // SAFETY: sw_nv12 is allocated by FFmpeg and freed after format conversion. let mut sw_nv12 = unsafe { ffi::av_frame_alloc() }; if sw_nv12.is_null() { bail!("GPU frame {}: av_frame_alloc failed", stats.frames_encoded); } // SAFETY: sw_nv12 is an allocated destination; filtered is a valid 2K NV12 VAAPI frame. let transfer_ret = unsafe { ffi::av_hwframe_transfer_data(sw_nv12, filtered.as_ptr(), 0) }; if transfer_ret < 0 { // SAFETY: sw_nv12 was allocated above and has not been freed yet. unsafe { ffi::av_frame_free(&mut sw_nv12) }; bail!( "GPU frame {}: av_hwframe_transfer_data failed: {} ({})", stats.frames_encoded, transfer_ret, av_err_to_string(transfer_ret) ); } let transfer_us = t_transfer.elapsed().as_micros() as u64; let t_format = Instant::now(); // SAFETY: sw_nv12 contains CPU-side NV12 at enc dimensions; encoder.yuv_frame is writable // YUV420P at the same dimensions, so sws_scale performs only chroma deinterleave/format conversion. unsafe { ffi::av_frame_make_writable(encoder.yuv_frame); ffi::sws_scale( format_ctx.0, (*sw_nv12).data.as_ptr() as *const *const u8, (*sw_nv12).linesize.as_ptr() as *const i32, 0, (*sw_nv12).height, (*encoder.yuv_frame).data.as_ptr() as *mut *mut u8, (*encoder.yuv_frame).linesize.as_ptr() as *const i32, ); } let format_us = t_format.elapsed().as_micros() as u64; // SAFETY: sw_nv12 was allocated above and is no longer needed. unsafe { ffi::av_frame_free(&mut sw_nv12) }; let encode_us = encode_yuv_frame(&mut encoder, &mut pts)?; let total_us = frame_start.elapsed().as_micros() as u64; stats.import_us.push(import_us); stats.filter_us.push(filter_us); stats.transfer_us.push(transfer_us); stats.format_us.push(format_us); stats.encode_us.push(encode_us); stats.total_us.push(total_us); stats.frames_encoded += 1; if stats.frames_encoded <= 3 || stats.frames_encoded % 30 == 0 { println!( " GPU frame {:>4}/{frames}: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms", stats.frames_encoded, import_us as f64 / 1000.0, filter_us as f64 / 1000.0, transfer_us as f64 / 1000.0, format_us as f64 / 1000.0, encode_us as f64 / 1000.0, total_us as f64 / 1000.0, ); } } finish_encoder(encoder)?; stats.elapsed_secs = total_start.elapsed().as_secs_f64(); Ok(stats) } fn print_detailed_results( label: &str, stats: &FrameStats, src_width: u32, src_height: u32, enc_width: u32, enc_height: u32, ) { println!(); println!("=== {label} Pipeline Results ==="); println!("Capture resolution: {}x{}", src_width, src_height); println!("Encode resolution: {}x{}", enc_width, enc_height); println!("Frames encoded: {}", stats.frames_encoded); println!("Total time: {:.2}s", stats.elapsed_secs); println!("Output: {}", stats.output_path); if stats.import_failures > 0 { println!("Import failures: {}", stats.import_failures); } println!( "import avg: {:.2} ms/frame", FrameStats::avg_ms(&stats.import_us) ); if !stats.filter_us.is_empty() { println!( "filter avg: {:.2} ms/frame", FrameStats::avg_ms(&stats.filter_us) ); } println!( "transfer avg: {:.2} ms/frame", FrameStats::avg_ms(&stats.transfer_us) ); if !stats.scale_us.is_empty() { println!( "scale avg: {:.2} ms/frame", FrameStats::avg_ms(&stats.scale_us) ); } if !stats.format_us.is_empty() { println!( "format avg: {:.2} ms/frame", FrameStats::avg_ms(&stats.format_us) ); } println!( "encode ({}): {:.2} ms/frame", stats.codec_name, FrameStats::avg_ms(&stats.encode_us) ); println!("total avg: {:.2} ms/frame", stats.avg_total_ms()); println!("achieved FPS: {:.1}", stats.achieved_fps()); println!("max theoretical: {:.1} FPS", stats.theoretical_fps()); } fn print_comparison(cpu: Option<&FrameStats>, gpu: Option<&FrameStats>) { println!(); println!("=== Pipeline Comparison ==="); if let Some(s) = cpu { println!( "CPU: import={:.2}ms transfer={:.2}ms scale={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)", FrameStats::avg_ms(&s.import_us), FrameStats::avg_ms(&s.transfer_us), FrameStats::avg_ms(&s.scale_us), FrameStats::avg_ms(&s.encode_us), s.avg_total_ms(), s.theoretical_fps(), ); } if let Some(s) = gpu { println!( "GPU: import={:.2}ms filter={:.2}ms transfer={:.2}ms format={:.2}ms encode={:.2}ms total={:.2}ms ({:.1} FPS)", FrameStats::avg_ms(&s.import_us), FrameStats::avg_ms(&s.filter_us), FrameStats::avg_ms(&s.transfer_us), FrameStats::avg_ms(&s.format_us), FrameStats::avg_ms(&s.encode_us), s.avg_total_ms(), s.theoretical_fps(), ); } } fn main() -> Result<()> { let bench_args = BenchArgs::parse(); println!("=== VAAPI Import Benchmark ==="); println!("Output: {}", bench_args.output); println!("Target frames: {}", bench_args.frames); println!( "Encode resolution: {}x{}", bench_args.enc_width, bench_args.enc_height ); println!("DRM device: {}", bench_args.drm_device); println!(); ff::init()?; println!("[1/3] Requesting screen capture via XDG Portal..."); println!(" (Select a screen to share in the portal dialog)"); let portal_args = Args { output: Some(bench_args.output.clone()), output_name: None, fps: 60, codec: "h264".to_string(), hw_accel: "vaapi".to_string(), drm_device: None, bitrate: None, max_bitrate: 8_000_000, gop_size: None, verbose: false, backend: Some("portal".to_string()), port: 0, no_persist: false, stats: false, }; let cap = CapPortal::new(&portal_args)?; println!("[1/3] Portal connected, PipeWire stream active\n"); println!("[2/3] Waiting for first frame from PipeWire..."); let first_frame = receive_first_frame(&cap)?; let src_width = first_frame.width; let src_height = first_frame.height; let src_format = first_frame.format; println!( "[2/3] First frame: {}x{}, format=0x{:08X}, stride={}, modifier=0x{:X}", src_width, src_height, src_format, first_frame.stride, first_frame.modifier ); println!("\n[2/3] Testing av_hwframe_map with sw_format=BGRA..."); println!( " DRM format chain: PipeWire BGRA -> DRM_FORMAT_ARGB8888 (0x{:08X}) -> VA_FOURCC_BGRA -> AV_PIX_FMT_BGRA", src_format ); let drm_device = Path::new(&bench_args.drm_device); let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?; println!(" VAAPI device context created OK"); let frames_ctx = AvHwFrameCtx::for_capture(&hw_dev, src_width, src_height, ff::format::Pixel::BGRA)?; println!(" VAAPI frames context created OK (sw_format=BGRA)"); let vaapi_frame = unsafe { import_dma_buf_to_vaapi( frames_ctx.as_ptr(), first_frame.fd.as_raw_fd(), first_frame.width, first_frame.height, first_frame.format, first_frame.modifier, first_frame.stride, first_frame.offset, ) }; match &vaapi_frame { Ok(_) => { println!(" Result: SUCCESS — av_hwframe_map imported DMA-BUF to VAAPI surface!"); } Err(e) => { println!(" Result: FAILED"); println!(" Error: {e}"); println!(); println!(" Possible causes:"); println!(" - sw_format mismatch (current: BGRA)"); println!(" - DRM format modifier not supported by VAAPI"); println!(" - VAAPI driver doesn't support DMA-BUF import for this format"); println!(); println!(" Falling back to mmap readback test for comparison..."); let mmap_size = (first_frame.stride as usize) * (first_frame.height as usize); let mmap_start = Instant::now(); let mmap_ptr = unsafe { libc::mmap( ptr::null_mut(), mmap_size, libc::PROT_READ, libc::MAP_SHARED, first_frame.fd.as_raw_fd(), first_frame.offset as i64, ) }; let mmap_elapsed = mmap_start.elapsed(); if mmap_ptr == libc::MAP_FAILED { let errno = std::io::Error::last_os_error(); println!(" mmap also FAILED: {errno}"); } else { println!( " mmap SUCCESS: {:.1} MB, setup in {:.2}ms", mmap_size as f64 / 1024.0 / 1024.0, mmap_elapsed.as_secs_f64() * 1000.0 ); unsafe { libc::munmap(mmap_ptr, mmap_size); } } println!(); println!("=== Benchmark ended: av_hwframe_map import FAILED ==="); println!("Fix the import issue before proceeding to GPU downscale tests."); return Ok(()); } } drop(vaapi_frame); drop(first_frame); println!("\n[3/3] Benchmarking selected pipeline(s)..."); let enc_width = bench_args.enc_width; let enc_height = bench_args.enc_height; let split_outputs = bench_args.mode == PipelineMode::Both; let mut cpu_stats = None; let mut gpu_stats = None; if matches!(bench_args.mode, PipelineMode::Cpu | PipelineMode::Both) { let output = output_for_mode(&bench_args.output, PipelineMode::Cpu, split_outputs); cpu_stats = Some(run_cpu_pipeline( &cap, &frames_ctx, &output, bench_args.frames, src_width, src_height, enc_width, enc_height, )?); } if matches!(bench_args.mode, PipelineMode::Gpu | PipelineMode::Both) { let output = output_for_mode(&bench_args.output, PipelineMode::Gpu, split_outputs); gpu_stats = Some(run_gpu_pipeline( &cap, &hw_dev, &frames_ctx, &output, bench_args.frames, src_width, src_height, enc_width, enc_height, )?); } if let Some(stats) = cpu_stats.as_ref() { print_detailed_results("CPU", stats, src_width, src_height, enc_width, enc_height); } if let Some(stats) = gpu_stats.as_ref() { print_detailed_results("GPU", stats, src_width, src_height, enc_width, enc_height); } print_comparison(cpu_stats.as_ref(), gpu_stats.as_ref()); if cpu_stats .as_ref() .into_iter() .chain(gpu_stats.as_ref()) .any(|stats| stats.achieved_fps() < 30.0 && stats.frames_encoded > 0) { println!("NOTE: At least one achieved FPS result is below 30 FPS target."); } Ok(()) }