1211 lines
47 KiB
Rust
1211 lines
47 KiB
Rust
//! # 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<T> = Result<T, anyhow::Error>;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<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()
|
||
// 指向空 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<SoftwareEncoder> {
|
||
// 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<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 转 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<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,输出 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<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(())
|
||
}
|