Files
wl-webrtc/src/bin/vaapi_import_bench.rs
T

1211 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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,
Gpu,
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
} 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<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"),
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()
// 指向空 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);
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. 寻找 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(|| {
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<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,
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<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);
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 触发编码器 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 {
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<ff::frame::Video> {
// 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<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"))?;
// 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::<ffi::AVPixelFormat>::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,输出 NV12VAAPI 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<FrameStats> {
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<FrameStats> {
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(())
}