Files
punktfunk/crates/pf-encode/src/enc/linux/vaapi.rs
T
enricobuehler a83acf3ee1
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 1m2s
apple / swift (push) Successful in 1m15s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
windows-host / package (push) Failing after 5m25s
ci / bench (push) Successful in 6m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
apple / screenshots (push) Successful in 6m11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish-host (push) Failing after 7m52s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m42s
ci / rust (push) Failing after 9m20s
docker / deploy-docs (push) Successful in 23s
deb / build-publish (push) Successful in 10m3s
arch / build-publish (push) Successful in 12m4s
android / android (push) Successful in 17m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 10m5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m38s
style(fmt): rustfmt the GNOME 50 HDR files under the pinned toolchain
`feat(hdr): GNOME 50 HDR screencast capture` (0e977817) landed with rustfmt
drift — six files were not clean under the pinned 1.96.0 toolchain, so
`cargo fmt --all --check` (ci.yml "Format") is red on main. Pure whitespace/
wrapping from `cargo fmt --all`; no semantic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:09:34 +02:00

1271 lines
60 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 encoder via `ffmpeg-next` — AMD (Mesa `radeonsi`) and Intel (`iHD`/`i965`) over one
//! libavcodec backend (`h264_vaapi`/`hevc_vaapi`/`av1_vaapi`). The kernel driver differs per
//! vendor; the libva userspace API is identical, so a single encoder covers both. This is the
//! sibling of [`super::linux`] (NVENC/CUDA) behind the shared [`Encoder`] trait — selected in
//! [`super::open_video`] (NVIDIA → NVENC, AMD/Intel → here).
//!
//! Two input paths, chosen lazily from the FIRST frame's payload (so `open_video`'s signature
//! is unchanged and the encoder self-configures for whatever the capturer produces):
//! * **CPU upload** ([`CpuInner`]): the portal hands packed RGB/BGR CPU frames; we swscale to
//! BT.709-limited NV12 and `av_hwframe_transfer_data` it into a pooled VA surface. Works on any
//! VAAPI GPU with no capture changes (the capturer falls back to CPU frames on non-NVIDIA).
//! * **Zero-copy dmabuf** ([`DmabufInner`], `PUNKTFUNK_ZEROCOPY=1`): the capturer hands a packed-RGB
//! dmabuf. We wrap it as an `AV_PIX_FMT_DRM_PRIME` frame and push it through a tiny filter graph
//! `buffer(drm_prime) → hwmap=derive_device=vaapi → scale_vaapi=format=nv12 → buffersink`, so
//! the import AND the RGB→NV12 colour conversion run on the GPU's video engine — no host CSC, no
//! upload. The encoder takes the NV12 surfaces straight from the filter sink.
//!
//! Raw FFI: `ffmpeg-next` has no hwcontext/filter wrappers for what we need, so the
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg;
use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
use std::ffi::{CStr, CString};
use std::os::fd::AsRawFd;
use std::os::raw::c_int;
use std::ptr;
use std::sync::atomic::{AtomicU8, Ordering};
use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
}
/// The render node a VAAPI/DRM device should open, from [`pf_gpu::linux_render_node`]: a
/// matched web-console GPU preference pins it, else `PUNKTFUNK_RENDER_NODE`, else the single-GPU
/// default.
fn render_node() -> CString {
let p = pf_gpu::linux_render_node().to_string_lossy().into_owned();
CString::new(p).unwrap_or_else(|_| CString::new("/dev/dri/renderD128").unwrap())
}
/// The swscale *source* pixel format for a captured CPU layout (packed RGB/BGR only).
fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
Ok(match format {
PixelFormat::Bgrx => Pixel::BGRZ, // bgr0
PixelFormat::Rgbx => Pixel::RGBZ, // rgb0
PixelFormat::Bgra => Pixel::BGRA,
PixelFormat::Rgba => Pixel::RGBA,
PixelFormat::Rgb => Pixel::RGB24,
PixelFormat::Bgr => Pixel::BGR24,
// The GNOME 50+ HDR capture formats (PQ/BT.2020 packed 2:10:10:10) — the HDR CPU path's
// swscale source for the X2RGB10→P010 conversion.
PixelFormat::X2Rgb10 => Pixel::X2RGB10LE,
PixelFormat::X2Bgr10 => Pixel::X2BGR10LE,
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
bail!("VAAPI CPU-input path supports packed RGB/BGR only; got {format:?}")
}
})
}
/// Which VAAPI entrypoint mode opened successfully, cached per codec (index = [`lp_idx`]):
/// 0 = unknown, 1 = default (full-feature `EncSlice`), 2 = low-power (`EncSliceLP`/VDEnc).
/// Modern Intel (Gen12+/Arc) removed the full-feature encode entrypoints, so the default open
/// fails there and only `low_power=1` works; AMD (radeonsi) is the reverse. Caching the resolved
/// mode lets later sessions/probes skip the known-failing attempt (and its libav error spew).
static LP_MODE: [AtomicU8; 3] = [AtomicU8::new(0), AtomicU8::new(0), AtomicU8::new(0)];
fn lp_idx(codec: Codec) -> usize {
match codec {
Codec::H264 => 0,
Codec::H265 => 1,
Codec::Av1 => 2,
// Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend.
Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"),
}
}
/// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature
/// only); unset → try full-feature first, fall back to low-power.
fn low_power_override() -> Option<bool> {
match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
/// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first
/// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes
/// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors
/// "no usable encoding entrypoint" there; LP additionally needs the HuC firmware, loaded by
/// default on those kernels). AMD keeps its first-try full-feature open byte-for-byte unchanged.
/// The resolved mode is cached per codec; `PUNKTFUNK_VAAPI_LOW_POWER` pins it.
/// Safety contract is [`open_vaapi_encoder_mode`]'s (borrowed `device_ref`/`frames_ref`).
#[allow(clippy::too_many_arguments)]
unsafe fn open_vaapi_encoder(
codec: Codec,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
ten_bit: bool,
) -> Result<encoder::video::Encoder> {
let idx = lp_idx(codec);
let modes: &[bool] = match low_power_override() {
Some(true) => &[true],
Some(false) => &[false],
None => match LP_MODE[idx].load(Ordering::Relaxed) {
1 => &[false],
2 => &[true],
_ => &[false, true],
},
};
let mut first_err = None;
for &lp in modes {
match open_vaapi_encoder_mode(
codec,
width,
height,
fps,
bitrate_bps,
device_ref,
frames_ref,
ten_bit,
lp,
) {
Ok(enc) => {
LP_MODE[idx].store(if lp { 2 } else { 1 }, Ordering::Relaxed);
if lp {
tracing::info!(
encoder = codec.vaapi_name(),
"VAAPI using the low-power (VDEnc) entrypoint"
);
}
return Ok(enc);
}
Err(e) => {
tracing::debug!(
encoder = codec.vaapi_name(),
low_power = lp,
"VAAPI encoder open failed: {e:#}"
);
first_err.get_or_insert(e);
}
}
}
// `modes` is never empty, so at least one attempt ran and recorded its error. The first
// (full-feature) error is the informative one — "no VA display" etc.
Err(first_err.unwrap())
}
/// Build the FFmpeg encoder context (shared by both inner paths): name, mode, low-latency RC,
/// infinite GOP, the VUI (BT.709 limited SDR, or BT.2020 PQ limited for `ten_bit` HDR),
/// `pix_fmt=VAAPI`, and the given hw device + frames contexts. Returns the opened encoder.
/// `device_ref`/`frames_ref` are borrowed (ref'd into the context).
#[allow(clippy::too_many_arguments)]
unsafe fn open_vaapi_encoder_mode(
codec: Codec,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
ten_bit: bool,
low_power: bool,
) -> Result<encoder::video::Encoder> {
let name = codec.vaapi_name();
let av_codec = encoder::find_by_name(name).ok_or_else(|| {
anyhow!("{name} not built into libavcodec (no VAAPI encoder for {codec:?})")
})?;
let mut video = codec::context::Context::new_with_codec(av_codec)
.encoder()
.video()
.context("alloc video encoder")?;
video.set_width(width);
video.set_height(height);
// sw view (pix_fmt overridden to VAAPI below): NV12, or P010 for the 10-bit HDR session.
video.set_format(if ten_bit { Pixel::P010LE } else { Pixel::NV12 });
// Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
apply_low_latency_rc(&mut video, fps, bitrate_bps);
let raw = video.as_mut_ptr();
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit {
// HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010
// the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the
// zero-copy path). The client decoder auto-detects PQ from the VUI.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
} else {
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
}
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
(*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
let mut opts = Dictionary::new();
if ten_bit && codec == Codec::H265 {
// HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so
// the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.)
opts.set("profile", "main10");
}
// async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest
// latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1
// = 8.3 ms end-to-end p50 vs depth 2 = 18 ms, because with depth ≥ 2 frame N's packet only
// materializes once frame N+1 is queued (a structural +1-frame delay no poll can beat). The
// knob exists for pixel rates beyond the ASIC's serial budget (e.g. 1440p120+ on an iGPU),
// where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks
// GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot);
// see `gpuclocks` for the session clock pin that removes the ramp tax.
let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|d| (1..=8).contains(d))
.unwrap_or(1);
opts.set("async_depth", &depth.to_string());
if low_power {
opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel
}
video.open_with(opts).with_context(|| {
format!("open {name} ({width}x{height}@{fps}, {bitrate_bps} bps, low_power={low_power})")
})
}
/// Probe whether THIS GPU can VAAPI-encode `codec`, by opening a tiny encoder: the driver rejects
/// codecs its video engine can't do (e.g. AV1 on pre-RDNA3 AMD / pre-Arc Intel). Used to build the
/// GameStream codec advertisement so a client never negotiates a codec the GPU can't encode. The
/// device + encoder are torn down immediately (RAII).
pub fn probe_can_encode(codec: Codec) -> bool {
if ffmpeg::init().is_err() {
return false;
}
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
unsafe {
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
// ffmpeg's "No VA display found" error for the probe, then restore the level.
let prev = ffi::av_log_get_level();
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, 640, 480, 2) {
Ok(hw) => open_vaapi_encoder(
codec,
640,
480,
30,
2_000_000,
hw.device_ref,
hw.frames_ref,
false,
)
.is_ok(),
Err(_) => false,
};
ffi::av_log_set_level(prev);
ok
}
}
/// Probe whether the active VAAPI GPU can encode **10-bit** (HEVC Main10 / 10-bit AV1) from P010
/// surfaces — the exact shape a live HDR session opens (P010 pool + Main10 profile + PQ VUI). The
/// driver rejects what the video engine can't do; the result is cached by the caller
/// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before
/// the Welcome (honest downgrade).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() || codec == Codec::PyroWave {
return false;
}
if ffmpeg::init().is_err() {
return false;
}
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_{get,set}_level`
// only read/write libav's global integer log level (no pointer args). `VaapiHw::new` (an
// `unsafe fn`) builds a VAAPI device + P010 frames pool from the literal args and hands back a
// RAII handle; `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` —
// the two non-null refs `VaapiHw::new` just created, live locals for the whole match arm — and
// `av_buffer_ref`s them into the probe encoder. Both `hw` and the encoder drop (RAII) at arm end.
unsafe {
// A missing VA device / no Main10 entrypoint is an expected probe outcome — quiet ffmpeg's
// error for the probe, then restore the level.
let prev = ffi::av_log_get_level();
ffi::av_log_set_level(ffi::AV_LOG_FATAL);
let ok = match VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_P010LE, 640, 480, 2) {
Ok(hw) => open_vaapi_encoder(
codec,
640,
480,
30,
2_000_000,
hw.device_ref,
hw.frames_ref,
true,
)
.is_ok(),
Err(_) => false,
};
ffi::av_log_set_level(prev);
ok
}
}
/// Whether the active VAAPI GPU can encode HEVC **4:4:4** (Range Extensions). **Deferred in v1 —
/// always `false`.** VAAPI HEVC 4:4:4 encode is narrow and vendor-specific (the lab's AMD Phoenix1 /
/// RDNA3 exposes only `VAProfileHEVCMain`/`Main10` `EncSlice`, no `Main444`), and there is no
/// validated hardware to build + verify the 4:4:4 surface/profile path against. Returning `false`
/// keeps the negotiation honest: a VAAPI host resolves every session to 4:2:0 before the Welcome, so
/// the client never builds a 4:4:4 decoder it would only get 4:2:0 frames for. (Follow-up: implement
/// and validate on an Intel Arc / RDNA4-class box that advertises a HEVC 4:4:4 encode entrypoint.)
pub fn probe_can_encode_444(_codec: Codec) -> bool {
tracing::info!("VAAPI HEVC 4:4:4 encode is not implemented yet — declining (encoding 4:2:0)");
false
}
// ---------------------------------------------------------------------------------------------
// CPU upload path (Phase 1): swscale RGB→NV12 → upload into a pooled VA surface → encode.
// ---------------------------------------------------------------------------------------------
/// VAAPI device + NV12 frames pool (the encoder's input surfaces for the CPU path).
struct VaapiHw {
device_ref: *mut ffi::AVBufferRef,
frames_ref: *mut ffi::AVBufferRef,
}
impl VaapiHw {
unsafe fn new(sw_format: ffi::AVPixelFormat, w: u32, h: u32, pool: c_int) -> Result<Self> {
let mut device_ref: *mut ffi::AVBufferRef = ptr::null_mut();
let node = render_node();
let r = ffi::av_hwdevice_ctx_create(
&mut device_ref,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
node.as_ptr(),
ptr::null_mut(),
0,
);
if r < 0 {
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
}
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
if frames_ref.is_null() {
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_alloc(VAAPI) failed");
}
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
(*fc).sw_format = sw_format;
(*fc).width = w as c_int;
(*fc).height = h as c_int;
(*fc).initial_pool_size = pool;
let r = ffi::av_hwframe_ctx_init(frames_ref);
if r < 0 {
ffi::av_buffer_unref(&mut frames_ref);
ffi::av_buffer_unref(&mut device_ref);
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
}
Ok(VaapiHw {
device_ref,
frames_ref,
})
}
}
impl Drop for VaapiHw {
fn drop(&mut self) {
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
unsafe {
ffi::av_buffer_unref(&mut self.frames_ref);
ffi::av_buffer_unref(&mut self.device_ref);
}
}
}
struct CpuInner {
enc: encoder::video::Encoder,
hw: VaapiHw,
sws: *mut ffi::SwsContext,
nv12: *mut ffi::AVFrame, // reusable software NV12 staging frame (swscale dst → upload src)
src_format: PixelFormat,
width: u32,
height: u32,
}
impl CpuInner {
fn open(
codec: Codec,
format: PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
) -> Result<Self> {
let src_pixel = vaapi_sws_src(format)?;
// A 10-bit HDR capture (X2RGB10/X2BGR10, PQ/BT.2020) uploads P010 and encodes Main10; the
// 8-bit paths keep NV12/BT.709 byte-for-byte unchanged.
let ten_bit = format.is_hdr_rgb10();
let staging_av = if ten_bit {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else {
ffi::AVPixelFormat::AV_PIX_FMT_NV12
};
const POOL: c_int = 16;
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
// `ffmpeg::init()`. The args are valid: an NV12/P010 sw_format, the validated positive
// `width`/`height`, pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s
// on drop.
let hw = unsafe { VaapiHw::new(staging_av, width, height, POOL)? };
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
let enc = unsafe {
open_vaapi_encoder(
codec,
width,
height,
fps,
bitrate_bps,
hw.device_ref,
hw.frames_ref,
ten_bit,
)?
};
// swscale RGB→NV12 (BT.709 limited) or X2RGB10→P010 (BT.2020 limited, HDR) — matches the
// VUI; no rescale.
let src_av = pixel_to_av(src_pixel);
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
// `src_pixel`), the dst is NV12/P010. The three trailing pointers (srcFilter, dstFilter,
// param) are explicitly null = "use defaults", which the API documents as accepted. No Rust
// memory is borrowed — only by-value ints/enums — and the returned pointer is null-checked
// just below.
let sws = unsafe {
ffi::sws_getContext(
width as c_int,
height as c_int,
src_av,
width as c_int,
height as c_int,
staging_av,
SWS_POINT,
ptr::null_mut(),
ptr::null_mut(),
ptr::null(),
)
};
if sws.is_null() {
bail!(
"sws_getContext(RGB→{})",
if ten_bit { "P010" } else { "NV12" }
);
}
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
// check immediately preceding returned false). The coefficient table from
// `sws_getCoefficients` (ITU-709, or BT.2020 NCL for the HDR path — matching the VUI) is a
// libswscale static const valid for the whole process, reused here for both the inverse
// (src) and forward (dst) matrices. `sws_setColorspaceDetails` only reads those tables and
// writes scalar CSC settings into `sws`; the table pointer outlives the synchronous call and
// no Rust memory is passed.
unsafe {
let cs = ffi::sws_getCoefficients(if ten_bit {
super::libav::SWS_CS_BT2020
} else {
SWS_CS_ITU709
});
ffi::sws_setColorspaceDetails(sws, cs, 1, cs, 0, 0, 1 << 16, 1 << 16);
}
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
// NV12/P010 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a
// unique fresh pointer, so none of these writes alias anything.
let nv12 = unsafe {
let f = ffi::av_frame_alloc();
if f.is_null() {
ffi::sws_freeContext(sws);
bail!("av_frame_alloc(staging) failed");
}
(*f).format = staging_av as c_int;
(*f).width = width as c_int;
(*f).height = height as c_int;
if ffi::av_frame_get_buffer(f, 0) < 0 {
let mut f = f;
ffi::av_frame_free(&mut f);
ffi::sws_freeContext(sws);
bail!("av_frame_get_buffer(staging) failed");
}
f
};
tracing::info!(
encoder = codec.vaapi_name(),
"VAAPI encode active ({width}x{height}@{fps}, CPU→{} upload path)",
if ten_bit { "P010 (HDR10)" } else { "NV12" }
);
Ok(CpuInner {
enc,
hw,
sws,
nv12,
src_format: format,
width,
height,
})
}
fn submit(&mut self, bytes: &[u8], format: PixelFormat, pts: i64, idr: bool) -> Result<()> {
anyhow::ensure!(
format == self.src_format,
"captured format {format:?} != encoder source {:?}",
self.src_format
);
let w = self.width as usize;
let h = self.height as usize;
let src_row = w * self.src_format.bytes_per_pixel();
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
unsafe {
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
if ffi::sws_scale(
self.sws,
src_data.as_ptr(),
src_stride.as_ptr(),
0,
h as c_int,
(*self.nv12).data.as_ptr(),
(*self.nv12).linesize.as_ptr(),
) < 0
{
bail!("sws_scale RGB→NV12 failed");
}
let mut hwf = ffi::av_frame_alloc();
if hwf.is_null() {
bail!("av_frame_alloc(hw) failed");
}
if ffi::av_hwframe_get_buffer(self.hw.frames_ref, hwf, 0) < 0 {
ffi::av_frame_free(&mut hwf);
bail!("av_hwframe_get_buffer(VAAPI) failed");
}
if ffi::av_hwframe_transfer_data(hwf, self.nv12, 0) < 0 {
ffi::av_frame_free(&mut hwf);
bail!("av_hwframe_transfer_data(→VAAPI) failed");
}
(*hwf).pts = pts;
(*hwf).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), hwf);
ffi::av_frame_free(&mut hwf);
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
}
Ok(())
}
}
impl Drop for CpuInner {
fn drop(&mut self) {
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
// irrelevant to soundness.
unsafe {
if !self.nv12.is_null() {
ffi::av_frame_free(&mut self.nv12);
}
if !self.sws.is_null() {
ffi::sws_freeContext(self.sws);
}
}
}
}
// ---------------------------------------------------------------------------------------------
// Zero-copy dmabuf path: DRM-PRIME → hwmap(vaapi) → scale_vaapi(nv12) filter graph → encode.
// ---------------------------------------------------------------------------------------------
struct DmabufInner {
enc: encoder::video::Encoder,
/// DRM device the source dmabuf frames reference (the buffersrc's `hw_frames_ctx` device).
drm_device: *mut ffi::AVBufferRef,
/// VAAPI device driving `hwmap`/`scale_vaapi`/the encoder.
vaapi_device: *mut ffi::AVBufferRef,
/// DRM-PRIME frames context for the imported dmabufs (buffersrc input).
drm_frames: *mut ffi::AVBufferRef,
graph: *mut ffi::AVFilterGraph,
src: *mut ffi::AVFilterContext,
sink: *mut ffi::AVFilterContext,
width: u32,
height: u32,
fourcc: u32,
/// Frames submitted — drives the sampled `PUNKTFUNK_PERF` breakdown of the synchronous
/// submit (import+push vs CSC pull vs encoder send), the stage that dominates AMD/Intel
/// host latency (7.9 ms p50 at 1440p on the 780M).
frames: u64,
}
impl DmabufInner {
fn open(
codec: Codec,
format: PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
) -> Result<Self> {
let drm_fourcc = pf_frame::drm_fourcc(format)
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
// A 10-bit HDR capture (X2RGB10/X2BGR10 dmabufs, PQ/BT.2020) maps + CSCs to P010 and
// encodes Main10; the 8-bit paths keep the NV12/BT.709 graph byte-for-byte unchanged.
let ten_bit = format.is_hdr_rgb10();
let sw_format = match format {
PixelFormat::X2Rgb10 => ffi::AVPixelFormat::AV_PIX_FMT_X2RGB10LE,
PixelFormat::X2Bgr10 => ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
// The 8-bit capture formats are all XR24-shaped packed RGB (the historical BGR0 view).
_ => ffi::AVPixelFormat::AV_PIX_FMT_BGR0,
};
let node = render_node();
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
// such an object:
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
// 'static C literals.
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
// live ref) and `av_buffer_ref`s both into the encoder.
// Every early-error path unref's the allocated buffers and frees the graph in the right order
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
// the final `?`.)
unsafe {
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
// hwmap/scale_vaapi/the encoder.
let mut drm_device: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwdevice_ctx_create(
&mut drm_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_DRM,
node.as_ptr(),
ptr::null_mut(),
0,
);
if r < 0 {
bail!(
"av_hwdevice_ctx_create(DRM {:?}): {}",
node,
ffmpeg::Error::from(r)
);
}
let mut vaapi_device: *mut ffi::AVBufferRef = ptr::null_mut();
let r = ffi::av_hwdevice_ctx_create_derived(
&mut vaapi_device,
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
drm_device,
0,
);
if r < 0 {
ffi::av_buffer_unref(&mut drm_device);
bail!("derive VAAPI from DRM: {}", ffmpeg::Error::from(r));
}
// DRM-PRIME frames context for the imported dmabufs.
let mut drm_frames = ffi::av_hwframe_ctx_alloc(drm_device);
if drm_frames.is_null() {
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("av_hwframe_ctx_alloc(DRM) failed");
}
let fc = (*drm_frames).data as *mut ffi::AVHWFramesContext;
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
(*fc).sw_format = sw_format; // packed XR24 RGB plane, or XR30/XB30 for HDR
(*fc).width = width as c_int;
(*fc).height = height as c_int;
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("av_hwframe_ctx_init(DRM) failed");
}
// Filter graph: buffer(drm_prime) → hwmap=derive_device=vaapi:mode=read →
// scale_vaapi=format=nv12 → buffersink.
let mut graph = ffi::avfilter_graph_alloc();
if graph.is_null() {
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_graph_alloc failed");
}
let mk = |name: &CStr, inst: &CStr| -> *mut ffi::AVFilterContext {
let f = ffi::avfilter_get_by_name(name.as_ptr());
if f.is_null() {
return ptr::null_mut();
}
ffi::avfilter_graph_alloc_filter(graph, f, inst.as_ptr())
};
let src = mk(c"buffer", c"in");
let hwmap = mk(c"hwmap", c"map");
let scale = mk(c"scale_vaapi", c"csc");
let sink = mk(c"buffersink", c"out");
if src.is_null() || hwmap.is_null() || scale.is_null() || sink.is_null() {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("a VAAPI filter (buffer/hwmap/scale_vaapi/buffersink) is missing");
}
// hwmap maps the DRM-PRIME input onto THIS vaapi device; scale_vaapi runs the CSC on
// it. Giving both our device (rather than `hwmap=derive_device`) keeps every surface —
// and the sink's output frames ctx the encoder adopts — on one VADisplay.
(*hwmap).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
(*scale).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
// buffersrc params: DRM-PRIME frames, the drm_frames ctx.
let par = ffi::av_buffersrc_parameters_alloc();
(*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*par).width = width as c_int;
(*par).height = height as c_int;
// Declare the link's colour up front (full-range RGB — the compositor's desktop) so
// the per-frame tags in `submit` match the negotiated link instead of reading as a
// mid-stream property change.
(*par).color_space = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*par).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*par).time_base = ffi::AVRational {
num: 1,
den: fps as c_int,
};
// Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
// that extra ref every session (the persistent listener would accumulate them).
(*par).hw_frames_ctx = drm_frames;
let r = ffi::av_buffersrc_parameters_set(src, par);
ffi::av_free(par as *mut _);
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("av_buffersrc_parameters_set failed ({r})");
}
macro_rules! init {
($ctx:expr, $args:expr, $what:literal) => {{
let r = ffi::avfilter_init_str($ctx, $args);
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!(concat!("init ", $what, " failed ({})"), r);
}
}};
}
init!(src, ptr::null(), "buffer");
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR,
// or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through
// the matrix untouched). Without the explicit options the conversion matrix is
// whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue
// shift against the signaled VUI.
if ten_bit {
init!(
scale,
c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(),
"scale_vaapi"
);
} else {
init!(
scale,
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
"scale_vaapi"
);
}
init!(sink, ptr::null(), "buffersink");
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
ffi::avfilter_link(a, 0, b, 0)
};
if link(src, hwmap) < 0 || link(hwmap, scale) < 0 || link(scale, sink) < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_link failed");
}
let r = ffi::avfilter_graph_config(graph, ptr::null_mut());
if r < 0 {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("avfilter_graph_config failed ({r})");
}
// The encoder takes NV12 surfaces from the sink's output frames context.
let nv12_ctx = ffi::av_buffersink_get_hw_frames_ctx(sink);
if nv12_ctx.is_null() {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
bail!("filter sink has no VAAPI frames context");
}
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
let enc = match open_vaapi_encoder(
codec,
width,
height,
fps,
bitrate_bps,
vaapi_device,
nv12_ctx,
ten_bit,
) {
Ok(enc) => enc,
Err(e) => {
ffi::avfilter_graph_free(&mut graph);
ffi::av_buffer_unref(&mut drm_frames);
ffi::av_buffer_unref(&mut vaapi_device);
ffi::av_buffer_unref(&mut drm_device);
return Err(e);
}
};
tracing::info!(
encoder = codec.vaapi_name(),
"VAAPI encode active ({width}x{height}@{fps}, zero-copy dmabuf → GPU {})",
if ten_bit { "P010 (HDR10)" } else { "NV12" }
);
Ok(DmabufInner {
enc,
drm_device,
vaapi_device,
drm_frames,
graph,
src,
sink,
width,
height,
fourcc: drm_fourcc,
frames: 0,
})
}
}
fn submit(&mut self, dmabuf: &DmabufFrame, pts: i64, idr: bool) -> Result<()> {
anyhow::ensure!(
dmabuf.fourcc == self.fourcc,
"dmabuf fourcc {:#x} != encoder {:#x}",
dmabuf.fourcc,
self.fourcc
);
// Sampled breakdown of this synchronous submit under PUNKTFUNK_PERF: push = descriptor
// build + buffersrc (the per-frame DRM→VA import happens inside hwmap on the pull path),
// pull = buffersink (VPP CSC + any sync), send = avcodec_send_frame. One line per ~2 s.
let sample = pf_host_config::config().perf && self.frames % 120 == 0;
self.frames += 1;
let t0 = std::time::Instant::now();
let t_push: std::time::Duration;
let t_pull: std::time::Duration;
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
// `Box` puts it on the heap with a unique owner.
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
// whole synchronous `submit`; we describe one object/layer/plane from its
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
unsafe {
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
desc.nb_objects = 1;
desc.objects[0].fd = dmabuf.fd.as_raw_fd();
desc.objects[0].size = 0;
desc.objects[0].format_modifier = dmabuf.modifier;
desc.nb_layers = 1;
desc.layers[0].format = self.fourcc;
desc.layers[0].nb_planes = 1;
desc.layers[0].planes[0].object_index = 0;
desc.layers[0].planes[0].offset = dmabuf.offset as isize;
desc.layers[0].planes[0].pitch = dmabuf.stride as isize;
let mut drm = ffi::av_frame_alloc();
if drm.is_null() {
bail!("av_frame_alloc(drm) failed");
}
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
(*drm).width = self.width as c_int;
(*drm).height = self.height as c_int;
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
// which outlives this call — the graph reads the surface before submit returns).
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
}
(*drm).buf[0] = ffi::av_buffer_create(
(*drm).data[0],
std::mem::size_of::<ffi::AVDRMFrameDescriptor>(),
Some(free_desc),
ptr::null_mut(),
0,
);
// Push through hwmap → scale_vaapi; pull the NV12 surface back out.
let r = ffi::av_buffersrc_add_frame_flags(
self.src,
drm,
ffi::AV_BUFFERSRC_FLAG_KEEP_REF as c_int,
);
ffi::av_frame_free(&mut drm);
if r < 0 {
bail!("av_buffersrc_add_frame failed ({r})");
}
t_push = t0.elapsed();
let mut nv12 = ffi::av_frame_alloc();
if nv12.is_null() {
bail!("av_frame_alloc(nv12) failed");
}
let r = ffi::av_buffersink_get_frame(self.sink, nv12);
if r < 0 {
ffi::av_frame_free(&mut nv12);
bail!("av_buffersink_get_frame failed ({r})");
}
t_pull = t0.elapsed() - t_push;
(*nv12).pts = pts;
(*nv12).pict_type = if idr {
ffi::AVPictureType::AV_PICTURE_TYPE_I
} else {
ffi::AVPictureType::AV_PICTURE_TYPE_NONE
};
let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), nv12);
ffi::av_frame_free(&mut nv12);
if r < 0 {
bail!("avcodec_send_frame(VAAPI) failed ({r})");
}
}
if sample {
let t_send = t0.elapsed() - t_push - t_pull;
tracing::info!(
push_us = t_push.as_micros() as u64,
pull_us = t_pull.as_micros() as u64,
send_us = t_send.as_micros() as u64,
"VAAPI submit split (sampled): push=desc+buffersrc pull=hwmap-import+VPP-CSC \
send=avcodec_send_frame"
);
}
Ok(())
}
}
impl Drop for DmabufInner {
fn drop(&mut self) {
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
unsafe {
ffi::avfilter_graph_free(&mut self.graph);
ffi::av_buffer_unref(&mut self.drm_frames);
ffi::av_buffer_unref(&mut self.vaapi_device);
ffi::av_buffer_unref(&mut self.drm_device);
}
}
}
// ---------------------------------------------------------------------------------------------
enum Inner {
Cpu(CpuInner),
Dmabuf(DmabufInner),
}
pub struct VaapiEncoder {
codec: Codec,
format: PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
/// Built lazily from the first frame's payload (CPU upload vs zero-copy dmabuf).
inner: Option<Inner>,
frame_idx: i64,
force_kf: bool,
/// Frames sent to the encoder but not yet returned as packets. Gates [`poll`](Encoder::poll)'s
/// bounded wait: with `async_depth > 1` a submitted frame's AU lands ~ASIC-time later, so poll
/// briefly waits for it (same-tick delivery) — but only when something is actually in flight.
in_flight: u32,
}
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
unsafe impl Send for VaapiEncoder {}
impl VaapiEncoder {
#[allow(clippy::too_many_arguments)]
pub fn open(
codec: Codec,
format: PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
bit_depth: u8,
chroma: super::ChromaFormat,
) -> Result<Self> {
// 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 /
// Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit
// request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an
// 8-bit session) is refused so PQ content is never mislabeled BT.709.
if format.is_hdr_rgb10() && bit_depth != 10 {
bail!(
"captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \
refusing to mislabel PQ content"
);
}
if bit_depth == 10 && !format.is_hdr_rgb10() {
tracing::warn!(
bit_depth,
?format,
"10-bit requested but the capture stayed SDR — encoding 8-bit"
);
}
// VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the
// lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never
// negotiates 4:4:4 for a VAAPI session. If a request slips through, fall back to 4:2:0 rather
// than emit an unverified stream — the host signalled 4:2:0 in the Welcome anyway.
if chroma.is_444() {
tracing::warn!("VAAPI 4:4:4 encode not implemented — encoding 4:2:0");
}
ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
// is a valid level and there are no pointer args. libav was just initialized by the
// `ffmpeg::init()` above, so the call is always sound.
unsafe { ffi::av_log_set_level(48) };
}
// Validate the codec/format up front so a bad request fails at open, not on the first frame.
let _ = vaapi_sws_src(format)?;
Ok(VaapiEncoder {
codec,
format,
width,
height,
fps,
bitrate_bps,
inner: None,
frame_idx: 0,
force_kf: false,
in_flight: 0,
})
}
fn ensure_inner(&mut self, want_dmabuf: bool) -> Result<&mut Inner> {
if self.inner.is_none() {
let inner = if want_dmabuf {
Inner::Dmabuf(DmabufInner::open(
self.codec,
self.format,
self.width,
self.height,
self.fps,
self.bitrate_bps,
)?)
} else {
Inner::Cpu(CpuInner::open(
self.codec,
self.format,
self.width,
self.height,
self.fps,
self.bitrate_bps,
)?)
};
self.inner = Some(inner);
}
Ok(self.inner.as_mut().unwrap())
}
}
impl Encoder for VaapiEncoder {
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
anyhow::ensure!(
captured.width == self.width && captured.height == self.height,
"captured frame {}x{} != encoder {}x{}",
captured.width,
captured.height,
self.width,
self.height
);
let pts = self.frame_idx;
self.frame_idx += 1;
let idr = self.force_kf;
self.force_kf = false;
match &captured.payload {
FramePayload::Cpu(bytes) => match self.ensure_inner(false)? {
Inner::Cpu(c) => c.submit(bytes, captured.format, pts, idr),
Inner::Dmabuf(_) => bail!("VAAPI encoder built for dmabuf got a CPU frame"),
},
FramePayload::Dmabuf(d) => match self.ensure_inner(true)? {
Inner::Dmabuf(dm) => dm.submit(d, pts, idr),
Inner::Cpu(_) => bail!("VAAPI encoder built for CPU got a dmabuf frame"),
},
FramePayload::Cuda(_) => bail!(
"VAAPI encoder received a CUDA frame — that payload is NVENC-only; \
unset PUNKTFUNK_ZEROCOPY or don't force PUNKTFUNK_ENCODER=vaapi on an NVIDIA host"
),
}?;
self.in_flight += 1;
Ok(())
}
fn request_keyframe(&mut self) {
self.force_kf = true;
}
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the VA
/// surfaces/filter graph/devices) and let the next `submit` rebuild it lazily from the first
/// frame's payload, exactly like first-frame bring-up — the same drop-and-reopen lever the
/// Windows QSV path has. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
/// encoder's first frame is forced IDR so the client resyncs immediately. Without this the
/// encode-stall watchdog had no lever on Linux AMD/Intel and a wedged driver ended the session.
fn reset(&mut self) -> bool {
self.inner = None;
self.in_flight = 0;
self.force_kf = true;
true
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// With `async_depth > 1`, `submit` no longer waits for the ASIC — the AU for the frame we
// just sent lands ~one hardware-encode-time later. Wait for it (bounded) so it still ships
// this tick: the same blocking-retrieve model as NVENC's lock_bitstream, at the ASIC's
// real per-frame latency instead of send_frame's synchronous ~2× wait. The budget is 3/4
// of a frame interval (capped 12 ms); on expiry return None — the AU rides the next poll.
let enc = match &mut self.inner {
Some(Inner::Cpu(c)) => &mut c.enc,
Some(Inner::Dmabuf(d)) => &mut d.enc,
None => return Ok(None),
};
let budget = std::time::Duration::from_micros(750_000 / self.fps.max(1) as u64)
.min(std::time::Duration::from_millis(12));
let deadline = std::time::Instant::now() + budget;
loop {
match poll_encoder(enc, self.fps)? {
PollOutcome::Packet(au) => {
self.in_flight = self.in_flight.saturating_sub(1);
return Ok(Some(au));
}
// No AU yet (EAGAIN) or drained (EOF) both fall through to the budget check,
// exactly as the previous `Option::None` did.
PollOutcome::Again | PollOutcome::Eof => {}
}
// Nothing ready: only wait when a frame is actually in flight (a drained/EOF'd
// encoder must not spin the budget), and give the ASIC ~250 µs between checks.
if self.in_flight == 0 || std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_micros(250));
}
}
fn flush(&mut self) -> Result<()> {
match &mut self.inner {
Some(Inner::Cpu(c)) => c.enc.send_eof().context("send_eof")?,
Some(Inner::Dmabuf(d)) => d.enc.send_eof().context("send_eof")?,
None => {}
}
Ok(())
}
}