Files
punktfunk/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs
T
enricobuehler ad532b08a0
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 52s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
ci / bench (push) Successful in 6m1s
docker / deploy-docs (push) Successful in 20s
arch / build-publish (push) Successful in 12m23s
android / android (push) Successful in 13m26s
deb / build-publish (push) Successful in 14m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m22s
ci / rust (push) Successful in 18m25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m14s
windows-host / package (push) Successful in 14m15s
apple / swift (push) Successful in 4m49s
apple / screenshots (push) Successful in 20m11s
style(encode): rustfmt the direct-SDK NVENC Linux backend
`cargo fmt` was not run on the Phase 5.2 additions (nvenc_cuda.rs + the encode.rs
dispatcher fork), failing the ci.yml rust fmt job. Whitespace/wrapping only — no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:35:07 +02:00

1303 lines
60 KiB
Rust

//! Direct-SDK NVENC encoder (Linux, CUDA input) — the raw `nvEncodeAPI` port that gives the Linux
//! NVIDIA host **real reference-frame invalidation + the recovery-anchor tag + a `reset()` stall
//! lever + HDR/Main10 plumbing** that libavcodec `hevc_nvenc` (`super::NvencEncoder`) structurally
//! cannot express (no avcodec option maps to `nvEncInvalidateRefFrames`). Design:
//! `design/linux-direct-nvenc.md`; the recovery semantics it delivers: `encoder-recovery-hardening.md`.
//!
//! This is the CUDA sibling of `encode/windows/nvenc.rs`. It drives the same runtime-loaded entry
//! table (`nvidia_video_codec_sdk::sys::nvEncodeAPI` `sys` types) but:
//! * loads `libnvidia-encode.so.1` via `dlopen` (the Linux analogue of the Windows System32 DLL
//! load, and of this crate's `zerocopy::cuda` libcuda loader) — never a link-time import, so
//! one binary still starts on AMD/Intel Linux boxes (no NVIDIA driver) and falls through to
//! VAAPI/software;
//! * opens the encode session on `NV_ENC_DEVICE_TYPE_CUDA` bound to the **shared process-wide
//! `CUcontext`** (`zerocopy::cuda::context()`) the capture/import path already uses;
//! * feeds NVENC from an **encoder-owned ring of registered CUDA input surfaces**
//! ([`zerocopy::cuda::InputSurface`]): each captured `FramePayload::Cuda` `DeviceBuffer` is
//! device→device copied into the current ring slot (via the existing `copy_*_to_device`
//! helpers) before `encode_picture`. This mirrors the libav path's recycled-hwframe-pool copy
//! (NVENC rejects a null-`buf[0]` frame and its CUDADEVICEPTR registration cache is bounded +
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
//! zero regression versus today; true zero-copy input registration is a follow-up.
//!
//! **Sync-only.** NVENC async mode (`enableEncodeAsync` + Win32 completion events) is Windows-only,
//! so the whole two-thread async-retrieve subsystem of the Windows backend is absent here: `poll`
//! does the blocking `lock_bitstream`, exactly like the libav path.
//!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
//! and the VAAPI/software backends carry the session).
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use crate::capture::{CapturedFrame, FramePayload};
use crate::zerocopy::cuda::{self, InputSurface};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::VecDeque;
use std::ffi::c_void;
use std::ptr;
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table (Linux). Same shape as the Windows backend's `EncodeApi`, minus
// the async-event entry points (Windows-only). Resolved once from `libnvidia-encode.so.1` — the two
// real exports (`NvEncodeAPIGetMaxSupportedVersion`, `NvEncodeAPICreateInstance`) by name, the rest
// through `NvEncodeAPICreateInstance`. NEVER a link-time import: the shipped binary compiles the
// `nvenc` feature in unconditionally and a load-time `.so` dependency would refuse to start the
// process on every AMD/Intel-only Linux box (the Linux analogue of the Windows nvEncodeAPI64.dll
// problem, and of this crate's dlopen'd libcuda).
// ---------------------------------------------------------------------------------------------
/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses. Field names mirror the sdk crate's
/// list; the crate's safe `ENCODE_API` must NOT be referenced (its statically-declared externs put
/// a load-time `.so` import on the all-vendor binary — the exact thing the runtime load avoids).
struct EncodeApi {
open_encode_session_ex: unsafe extern "C" fn(
*mut nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS,
*mut *mut c_void,
) -> nv::NVENCSTATUS,
initialize_encoder:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
get_encode_caps: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
*mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int,
) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
nv::GUID,
nv::NV_ENC_TUNING_INFO,
*mut nv::NV_ENC_PRESET_CONFIG,
) -> nv::NVENCSTATUS,
create_bitstream_buffer: unsafe extern "C" fn(
*mut c_void,
*mut nv::NV_ENC_CREATE_BITSTREAM_BUFFER,
) -> nv::NVENCSTATUS,
destroy_bitstream_buffer:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
lock_bitstream:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_LOCK_BITSTREAM) -> nv::NVENCSTATUS,
unlock_bitstream: unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
register_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_REGISTER_RESOURCE) -> nv::NVENCSTATUS,
unregister_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_REGISTERED_PTR) -> nv::NVENCSTATUS,
map_input_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_MAP_INPUT_RESOURCE) -> nv::NVENCSTATUS,
unmap_input_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_INPUT_PTR) -> nv::NVENCSTATUS,
encode_picture:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (the sdk's own helper lives in the `safe` module this file must
/// not pull in). The raw status's Debug repr is the error payload.
trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable (no NVIDIA driver/.so,
/// or a driver older than our headers) — [`NvencCudaEncoder::open`] gates on it and the VAAPI/
/// software backends carry on.
fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
static TABLE: std::sync::OnceLock<std::result::Result<EncodeApi, String>> =
std::sync::OnceLock::new();
TABLE
.get_or_init(|| {
let table = load_api();
if let Err(e) = &table {
tracing::warn!("NVENC (Linux direct) API unavailable: {e}");
}
table
})
.as_ref()
.map_err(|e| e.as_str())
}
/// The loaded table, for call sites past a [`try_api`] gate — a live session implies the load
/// succeeded, and the table lives for the process lifetime.
fn api() -> &'static EncodeApi {
try_api().expect("NVENC call before a successful try_api() gate")
}
fn load_api() -> std::result::Result<EncodeApi, String> {
// SAFETY: `Library::new` runs `libnvidia-encode.so.1`'s initializers — the trusted NVIDIA driver
// library, so loading has no unexpected effects; `map_err` handles its absence (AMD/Intel/no
// driver). Each `lib.get::<T>(name)` asserts the symbol's ABI equals `T`, the documented
// `nvEncodeAPI.h` prototype. `NvEncodeAPIGetMaxSupportedVersion` writes one u32 through a live
// pointer; `NvEncodeAPICreateInstance` fills `list` (a `#[repr(C)]` function list with `version`
// set) during the call only. Each extracted fn pointer is deref-copied out of its borrowing
// `Symbol` before `forget(lib)` leaks the mapping, so every address stays valid for the process
// lifetime. Runs once under the `OnceLock` init — no aliasing.
unsafe {
let lib = libloading::Library::new("libnvidia-encode.so.1")
.or_else(|_| libloading::Library::new("libnvidia-encode.so"))
.map_err(|e| format!("libnvidia-encode.so.1 not loadable (no NVIDIA driver?): {e}"))?;
let get_version: libloading::Symbol<unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS> =
lib.get(b"NvEncodeAPIGetMaxSupportedVersion\0")
.map_err(|e| {
format!("libnvidia-encode exports no NvEncodeAPIGetMaxSupportedVersion: {e}")
})?;
let create_instance: libloading::Symbol<
unsafe extern "C" fn(*mut nv::NV_ENCODE_API_FUNCTION_LIST) -> nv::NVENCSTATUS,
> = lib
.get(b"NvEncodeAPICreateInstance\0")
.map_err(|e| format!("libnvidia-encode exports no NvEncodeAPICreateInstance: {e}"))?;
let get_version = *get_version;
let create_instance = *create_instance;
let mut version = 0u32;
get_version(&mut version)
.nv_ok()
.map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
// The sdk's version assert, minus the panic: an older driver is a clean Err.
let (major, minor) = (version >> 4, version & 0xf);
if (major, minor) < (nv::NVENCAPI_MAJOR_VERSION, nv::NVENCAPI_MINOR_VERSION) {
return Err(format!(
"driver NVENC API {major}.{minor} is older than the host's headers {}.{}\
update the NVIDIA driver",
nv::NVENCAPI_MAJOR_VERSION,
nv::NVENCAPI_MINOR_VERSION
));
}
let mut list = nv::NV_ENCODE_API_FUNCTION_LIST {
version: nv::NV_ENCODE_API_FUNCTION_LIST_VER,
..Default::default()
};
create_instance(&mut list)
.nv_ok()
.map_err(|e| format!("NvEncodeAPICreateInstance: {e:?}"))?;
const MISSING: &str = "NvEncodeAPICreateInstance left an entry point unfilled";
let api = EncodeApi {
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
lock_bitstream: list.nvEncLockBitstream.ok_or(MISSING)?,
unlock_bitstream: list.nvEncUnlockBitstream.ok_or(MISSING)?,
register_resource: list.nvEncRegisterResource.ok_or(MISSING)?,
unregister_resource: list.nvEncUnregisterResource.ok_or(MISSING)?,
map_input_resource: list.nvEncMapInputResource.ok_or(MISSING)?,
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
};
std::mem::forget(lib); // keep the .so mapped for the fn pointers' lifetime (process)
Ok(api)
}
}
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. The host
/// loop deep-pipelines (submits several frames before locking the oldest) so this must be ≥ the
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
const POOL: usize = 8;
/// Reference-frame DPB depth when RFI is supported (Apollo uses 5). A deeper DPB lets an invalidated
/// reference fall back to an older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each
/// P-frame single-reference for low latency.
const RFI_DPB: u32 = 5;
fn codec_guid(codec: Codec) -> nv::GUID {
match codec {
Codec::H264 => nv::NV_ENC_CODEC_H264_GUID,
Codec::H265 => nv::NV_ENC_CODEC_HEVC_GUID,
Codec::Av1 => nv::NV_ENC_CODEC_AV1_GUID,
}
}
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
fn buffer_format(buf: &cuda::DeviceBuffer) -> nv::NV_ENC_BUFFER_FORMAT {
if buf.yuv444 {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
} else if buf.is_nv12() {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
} else {
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input path.
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
}
}
/// One encoder-owned CUDA input surface + its NVENC registration. The surface is copied into each
/// use (device→device) and the registration is created once at session init, unregistered at teardown.
struct RingSlot {
surface: InputSurface,
reg: nv::NV_ENC_REGISTERED_PTR,
}
pub struct NvencCudaEncoder {
encoder: *mut c_void,
/// The shared process-wide `CUcontext` the session is bound to (from `zerocopy::cuda::context`).
cu_ctx: *mut c_void,
codec: Codec,
codec_guid: nv::GUID,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
/// Encoded bit depth (8 on Linux until Phase 5.1 lands a P010 capture path). Kept for parity with
/// the Windows Main10 config, which is ported but inert until a 10-bit input exists.
bit_depth: u8,
/// Full-chroma 4:4:4 (HEVC Range Extensions) — set when the capturer delivers a planar-YUV444
/// `DeviceBuffer` on an HEVC session and the GPU supports YUV444 encode.
chroma_444: bool,
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` — whether this GPU can 4:4:4 encode at all.
yuv444_supported: bool,
/// HDR (BT.2020 PQ 10-bit). Always `false` on Linux today (no 10-bit input); the VUI/SEI/Main10
/// plumbing is ported for Phase 5.1 readiness.
hdr: bool,
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
/// The encoder-owned input-surface ring (allocated + registered at session init, round-robin per
/// submit). Empty until the session is initialized.
ring: Vec<RingSlot>,
next: usize,
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
/// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
/// in-flight encode. The fourth field tags the first frame encoded after a successful
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
/// client lifts its post-loss freeze on.
pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool)>,
/// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
/// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
/// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
/// encoder rebuilds/resets. Self-increments as a fallback for un-indexed callers (tests).
frame_idx: i64,
force_kf: bool,
/// A successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) arms this; the next
/// `submit` consumes it into `pending` so that AU ships as the recovery anchor (NVENC applies the
/// invalidation at the next `encode_picture`, so that frame is by construction the first coded
/// against only-valid references — the F2 fix, identical to the Windows backend).
pending_anchor: bool,
inited: bool,
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring.
rfi_supported: bool,
custom_vbv: bool,
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for one loss.
last_rfi_range: Option<(i64, i64)>,
}
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
// (`cu_ctx`, a process-global handle valid from any thread once `cuCtxSetCurrent` is issued), and the
// raw NVENC bitstream/registered/mapped pointers in `bitstreams`/`ring`/`pending`. The encoder is
// owned by exactly one thread: it is moved onto the host encode thread once at construction, and every
// method (`submit`/`poll`/`invalidate_ref_frames`/`Drop`) runs there. There is no secondary thread
// (unlike the Windows async retrieve) — this backend is sync-only. Moving the encoder across its one
// ownership-transfer boundary is sound because no NVENC/CUDA call is in flight during the move, so
// `Send` introduces no data race on the non-`Send` fields.
unsafe impl Send for NvencCudaEncoder {}
impl NvencCudaEncoder {
/// Signature mirrors `super::NvencEncoder::open` so the Linux dispatcher fork is a one-line swap.
/// `format`/`cuda` are advisory: the session's real input format is derived from the first
/// captured `DeviceBuffer`'s layout (lazy init in `submit`), and this backend only accepts CUDA
/// frames (a CPU/dmabuf payload `bail`s). `bit_depth` is pinned to 8 on Linux (Phase 5.1 will
/// lift it once P010 capture exists).
#[allow(clippy::too_many_arguments)]
pub fn open(
codec: Codec,
_format: crate::capture::PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
_cuda: bool,
bit_depth: u8,
chroma: ChromaFormat,
) -> Result<Self> {
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
// clear reason instead of an opaque session error on the first frame.
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
if bit_depth >= 10 {
tracing::warn!(
"Linux direct-NVENC: 10-bit requested but no P010 capture path exists yet \
(Phase 5.1) — encoding 8-bit SDR"
);
}
Ok(Self {
encoder: ptr::null_mut(),
cu_ctx: ptr::null_mut(),
codec,
codec_guid: codec_guid(codec),
width,
height,
fps,
bitrate_bps,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
bit_depth: 8,
// 4:4:4 is HEVC-only; confirmed against the frame layout + GPU support at init.
chroma_444: chroma.is_444() && codec == Codec::H265,
yuv444_supported: false,
hdr: false,
hdr_meta: None,
ring: Vec::new(),
next: 0,
bitstreams: Vec::new(),
pending: VecDeque::new(),
frame_idx: 0,
force_kf: false,
pending_anchor: false,
inited: false,
rfi_supported: false,
custom_vbv: false,
last_rfi_range: None,
})
}
/// Tear down the encode session + pooled resources. Reused on a size change and at Drop.
unsafe fn teardown(&mut self) {
if self.encoder.is_null() {
return;
}
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
for (_, map, _, _) in &self.pending {
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, *map);
}
}
for slot in &self.ring {
let _ = (api().unregister_resource)(self.encoder, slot.reg);
}
for &bs in &self.bitstreams {
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
}
let _ = (api().destroy_encoder)(self.encoder);
self.ring.clear(); // drops the InputSurfaces, freeing their CUDA allocations
self.bitstreams.clear();
self.pending.clear();
self.encoder = ptr::null_mut();
self.inited = false;
self.next = 0;
// The new session starts with an empty DPB (its first frame is an IDR), so any prior
// invalidation range is meaningless and a pending anchor from a pre-teardown RFI is stale.
self.last_rfi_range = None;
self.pending_anchor = false;
}
/// Query one `NV_ENC_CAPS` value for this codec; 0 on any error (treat unqueryable as unsupported).
unsafe fn get_cap(&self, enc: *mut c_void, which: nv::NV_ENC_CAPS) -> i32 {
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: which,
reserved: [0; 62],
};
let mut val: i32 = 0;
match (api().get_encode_caps)(enc, self.codec_guid, &mut param, &mut val).nv_ok() {
Ok(()) => val,
Err(_) => 0,
}
}
/// Probe this GPU's capabilities once (max dims / 4:4:4 / ref-pic-invalidation / custom-VBV) on a
/// throwaway CUDA session before configuring, so the config is gated on what the card supports and
/// an out-of-range mode fails with a clear error rather than an opaque `InvalidParam`.
unsafe fn query_caps(&mut self) -> Result<()> {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device: self.cu_ctx,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| {
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
})?;
let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
let rfi = self.get_cap(
enc,
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_REF_PIC_INVALIDATION,
);
let custom_vbv = self.get_cap(
enc,
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
);
let _ = (api().destroy_encoder)(enc);
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
bail!(
"this GPU's NVENC max encode size for {:?} is {wmax}x{hmax}; client requested \
{}x{} (lower the client resolution or use a codec/GPU that supports it)",
self.codec,
self.width,
self.height
);
}
self.yuv444_supported = yuv444 != 0;
if self.chroma_444 && !self.yuv444_supported {
tracing::warn!("NVENC (Linux): this GPU can't 4:4:4 encode — falling back to 4:2:0");
self.chroma_444 = false;
}
self.rfi_supported = rfi != 0;
self.custom_vbv = custom_vbv != 0;
tracing::info!(
rfi = self.rfi_supported,
custom_vbv = self.custom_vbv,
yuv444 = self.yuv444_supported,
max = %format!("{wmax}x{hmax}"),
"NVENC (Linux direct) capabilities probed"
);
Ok(())
}
/// Open + configure + initialize ONE NVENC CUDA session at `bitrate` (bps) and `split_mode`.
/// Returns the session handle, or destroys it and returns the error.
unsafe fn try_open_session(&self, bitrate: u64, split_mode: u32) -> Result<*mut c_void> {
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
device: self.cu_ctx,
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
(api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
// Seed the P1 + ultra-low-latency preset config.
let mut preset = nv::NV_ENC_PRESET_CONFIG {
version: nv::NV_ENC_PRESET_CONFIG_VER,
presetCfg: nv::NV_ENC_CONFIG {
version: nv::NV_ENC_CONFIG_VER,
..Default::default()
},
..Default::default()
};
if let Err(e) = (api().get_encode_preset_config_ex)(
enc,
self.codec_guid,
nv::NV_ENC_PRESET_P1_GUID,
nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
&mut preset,
)
.nv_ok()
{
let _ = (api().destroy_encoder)(enc);
return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
}
let mut cfg = preset.presetCfg;
// CBR, infinite GOP, P-only, ~1-frame VBV (mirror the Windows/Linux-libav RC config).
cfg.gopLength = nv::NVENC_INFINITE_GOPLENGTH;
cfg.frameIntervalP = 1;
cfg.rcParams.rateControlMode = nv::NV_ENC_PARAMS_RC_MODE::NV_ENC_PARAMS_RC_CBR;
let bps = bitrate.min(u32::MAX as u64) as u32;
cfg.rcParams.averageBitRate = bps;
cfg.rcParams.maxBitRate = bps;
if self.custom_vbv {
let vbv = (bitrate as f64 / self.fps.max(1) as f64) as u32;
cfg.rcParams.vbvBufferSize = vbv;
cfg.rcParams.vbvInitialDelay = vbv;
}
// Tier + autoselect level, PER CODEC (HEVC HIGH tier for the higher bitrate ceiling; AV1
// Main tier only — tier=1 fails AV1 init; H.264 has no tier). Level 0 = autoselect.
match self.codec {
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
Codec::Av1 => {}
Codec::H264 => {}
}
// Chroma + bit depth. 4:4:4 (HEVC Range Extensions, chromaFormatIDC=3) engages on a YUV444
// input; 10-bit Main10 is ported but never engages on Linux yet (input is 8-bit).
let yuv444_input = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
);
if self.chroma_444 && yuv444_input {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_FREXT_GUID;
cfg.encodeCodecConfig.hevcConfig.set_chromaFormatIDC(3);
if self.bit_depth == 10 {
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
}
} else if self.bit_depth == 10 {
match self.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(0);
}
Codec::H264 => {}
}
}
// Colour signaling, written UNCONDITIONALLY: the CUDA input is already CSC'd to a specific
// matrix (NV12/YUV444 8-bit BT.709 limited from the convert worker, or the packed-RGB path
// where NVENC's internal CSC follows this VUI), so the stream must say BT.709 or a decoder
// whose "unspecified" default is 601 will mis-render.
{
let (prim, trc, mat) = if self.hdr {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
)
} else {
(
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
)
};
match self.codec {
Codec::H265 => {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::H264 => {
let vui = &mut cfg.encodeCodecConfig.h264Config.h264VUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0;
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries = prim;
vui.transferCharacteristics = trc;
vui.colourMatrix = mat;
}
Codec::Av1 => {
let av1 = &mut cfg.encodeCodecConfig.av1Config;
av1.colorPrimaries = prim;
av1.transferCharacteristics = trc;
av1.matrixCoefficients = mat;
av1.colorRange = 0;
}
}
}
// Reference-frame invalidation: a deeper DPB so an invalidated reference can fall back to an
// older still-valid frame instead of a full IDR; `numRefL0 = 1` keeps each P-frame
// single-reference for low latency. Only when this GPU supports RFI.
if self.rfi_supported {
let one = nv::NV_ENC_NUM_REF_FRAMES::NV_ENC_NUM_REF_FRAMES_1;
match self.codec {
Codec::H264 => {
cfg.encodeCodecConfig.h264Config.maxNumRefFrames = RFI_DPB;
cfg.encodeCodecConfig.h264Config.numRefL0 = one;
}
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.maxNumRefFramesInDPB = RFI_DPB;
cfg.encodeCodecConfig.hevcConfig.numRefL0 = one;
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.maxNumRefFramesInDPB = RFI_DPB;
}
}
}
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
encodeGUID: self.codec_guid,
presetGUID: nv::NV_ENC_PRESET_P1_GUID,
tuningInfo: nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
encodeWidth: self.width,
encodeHeight: self.height,
darWidth: self.width,
darHeight: self.height,
frameRateNum: self.fps,
frameRateDen: 1,
enablePTD: 1,
encodeConfig: &mut cfg,
..Default::default()
};
init.set_splitEncodeMode(split_mode);
match (api().initialize_encoder)(enc, &mut init).nv_ok() {
Ok(()) => Ok(enc),
Err(e) => {
let _ = (api().destroy_encoder)(enc);
Err(anyhow!("initialize_encoder: {e:?}"))
}
}
}
/// Lazily create the session + input-surface ring on the first frame's format.
fn init_session(&mut self) -> Result<()> {
// SAFETY: every NVENC call goes through a function pointer resolved once from the runtime
// table (`api()`, gated in `open`). `try_open_session`/`query_caps` return either a valid
// open NVENC session handle or `Err`; `destroy_encoder` is only called on a handle
// `try_open_session` just returned (and `best` only when non-null). `create_bitstream_buffer`
// and `register_resource` take `enc`, the chosen live session, and `&mut` locals whose
// `version` is set and which outlive the synchronous call. `InputSurface::alloc_*` returns a
// live pitched CUDA allocation on the shared context. No handle escapes the encode thread.
unsafe {
// Bind to the shared CUDA context; make it current on this (encode) thread for both the
// session open and every subsequent device→device input copy.
self.cu_ctx = cuda::context().context("shared CUDA context (Linux direct NVENC)")?;
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
self.query_caps()?;
const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) above ~1 Gpix/s; env override
// PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3. HEVC/AV1 only.
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
{
Some("0") | Some("disable") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
Some("1") | Some("auto") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32
}
Some("3") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32,
Some("2") => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32,
_ if self.bit_depth >= 10 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
_ if pixel_rate > 1_000_000_000 => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
}
_ => nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32,
};
const CLAMP_TOL_BPS: u64 = 20_000_000;
let mut probe = self.try_open_session(requested_bps, split_mode);
// Disambiguate a forced-split rejection from a bitrate-cap rejection.
let split_on =
split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if probe.is_err() && split_on {
let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
if let Ok(e) = self.try_open_session(requested_bps, no_split) {
tracing::warn!(
"NVENC (Linux): split-encode rejected by codec/config — disabled"
);
split_mode = no_split;
probe = Ok(e);
}
}
let enc = match probe {
Ok(enc) => {
self.bitrate_bps = requested_bps;
enc
}
Err(_) => {
// Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
let mut lo = FLOOR_BPS;
let mut hi = requested_bps;
let mut best: *mut c_void = ptr::null_mut();
let mut best_bps = 0u64;
while hi > lo + CLAMP_TOL_BPS {
let mid = lo + (hi - lo) / 2;
match self.try_open_session(mid, split_mode) {
Ok(e) => {
if !best.is_null() {
let _ = (api().destroy_encoder)(best);
}
best = e;
best_bps = mid;
lo = mid;
}
Err(_) => hi = mid,
}
}
if best.is_null() {
let no_split =
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
best = self
.try_open_session(FLOOR_BPS, split_mode)
.or_else(|_| self.try_open_session(FLOOR_BPS, no_split))
.context(
"NVENC initialize_encoder rejected even at the floor bitrate",
)?;
best_bps = FLOOR_BPS;
}
tracing::warn!(
requested_mbps = requested_bps / 1_000_000,
clamped_mbps = best_bps / 1_000_000,
"NVENC (Linux): requested bitrate above the GPU codec-level ceiling — clamped"
);
self.bitrate_bps = best_bps;
best
}
};
self.encoder = enc;
// Output bitstream pool.
for _ in 0..POOL {
let mut cb = nv::NV_ENC_CREATE_BITSTREAM_BUFFER {
version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
..Default::default()
};
(api().create_bitstream_buffer)(enc, &mut cb)
.nv_ok()
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
self.bitstreams.push(cb.bitstreamBuffer);
}
// Encoder-owned input-surface ring: allocate + register POOL surfaces in the negotiated
// format. Registered once here, mapped per submit, unregistered at teardown.
for _ in 0..POOL {
let surface = match self.buffer_fmt {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
InputSurface::alloc_yuv444(self.width, self.height)
}
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
InputSurface::alloc_nv12(self.width, self.height)
}
_ => InputSurface::alloc_rgb(self.width, self.height),
}
.context("alloc NVENC input surface")?;
let mut rr = nv::NV_ENC_REGISTER_RESOURCE {
version: nv::NV_ENC_REGISTER_RESOURCE_VER,
resourceType:
nv::NV_ENC_INPUT_RESOURCE_TYPE::NV_ENC_INPUT_RESOURCE_TYPE_CUDADEVICEPTR,
width: self.width,
height: self.height,
pitch: surface.pitch as u32,
resourceToRegister: surface.ptr as *mut c_void,
bufferFormat: self.buffer_fmt,
bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
..Default::default()
};
(api().register_resource)(self.encoder, &mut rr)
.nv_ok()
.map_err(|e| anyhow!("register_resource (CUDADEVICEPTR): {e:?}"))?;
self.ring.push(RingSlot {
surface,
reg: rr.registeredResource,
});
}
self.inited = true;
tracing::info!(
"NVENC CUDA session: {}x{}@{} {}-bit {} Mbps {:?} fmt={:?}",
self.width,
self.height,
self.fps,
self.bit_depth,
self.bitrate_bps / 1_000_000,
self.codec_guid,
self.buffer_fmt,
);
Ok(())
}
}
/// Copy the captured `DeviceBuffer` into the ring slot's registered input surface (device→device
/// on the shared context, synchronized by the copy helpers).
fn copy_into_slot(&self, buf: &cuda::DeviceBuffer, slot: usize) -> Result<()> {
let s = &self.ring[slot].surface;
let base = s.ptr;
let pitch = s.pitch;
let hh = s.height as u64;
match self.buffer_fmt {
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => {
if !buf.yuv444 {
bail!("4:4:4 session but the captured buffer is not planar YUV444");
}
let planes = [
(base, pitch),
(base + pitch as u64 * hh, pitch),
(base + 2 * pitch as u64 * hh, pitch),
];
cuda::copy_yuv444_to_device(buf, planes)
}
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => {
if !buf.is_nv12() {
bail!("NV12 session but the captured buffer has no chroma plane");
}
// Contiguous NV12: UV follows Y at base + pitch*height, same pitch.
cuda::copy_nv12_to_device(buf, base, pitch, base + pitch as u64 * hh, pitch)
}
_ => cuda::copy_device_to_device(buf, base, pitch),
}
}
}
impl Encoder for NvencCudaEncoder {
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
let buf = match &captured.payload {
FramePayload::Cuda(b) => b,
_ => bail!(
"Linux direct-NVENC needs a CUDA frame (FramePayload::Cuda); got a CPU/dmabuf frame"
),
};
// Re-init on a size change (the capturer can return at a different resolution after a mode
// switch). Format changes (NV12↔YUV444) likewise re-init.
let new_fmt = buffer_format(buf);
let size_changed =
self.inited && (self.width != captured.width || self.height != captured.height);
let fmt_changed = self.inited && self.buffer_fmt != new_fmt;
if self.inited && (size_changed || fmt_changed) {
tracing::info!(
size_changed,
fmt_changed,
new = format!("{}x{}", captured.width, captured.height),
"NVENC (Linux): capture size/format changed — re-initializing session"
);
// SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session
// whose cached ring/bitstreams/pending all belong to `self.encoder` — all hold: this is
// the synchronous encode thread, `self.inited` so `self.encoder` is live, and the previous
// frame was already polled (synchronous submit→poll), so nothing is mid-encode.
unsafe { self.teardown() };
}
if !self.inited {
self.width = captured.width;
self.height = captured.height;
self.buffer_fmt = new_fmt;
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
self.chroma_444 = self.chroma_444 && buf.yuv444;
self.init_session()?;
} else {
// Steady state: the copy helpers need the shared context current on this thread.
cuda::make_current().context("cuCtxSetCurrent (encode thread)")?;
}
// The session's opening frame is an IDR regardless of pic flags. Detected via the still-empty
// output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the
// wire frame index, non-zero on a mid-session rebuild's first frame.
let opening = self.next == 0;
let slot = self.next % POOL;
self.next += 1;
// Copy the captured buffer into this slot's input surface before encoding it.
self.copy_into_slot(buf, slot)?;
// SAFETY: every NVENC call goes through a function pointer from the runtime table and takes
// `self.encoder`, the live session `init_session` established (non-null here). `mp`
// (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps the ring slot's registration (created in
// `init_session`) and is recorded in `pending` to be unmapped exactly once in `poll`/teardown.
// `pic` (`NV_ENC_PIC_PARAMS`, version set) points `inputBuffer` at `mp.mappedResource` and
// `outputBitstream` at the live pool bitstream `bitstreams[slot]`; the optional SEI scratch is
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
// just filled by the (synchronized) device→device copy and is not overwritten until this slot
// is reused POOL submits later, by which time this encode was polled (POOL ≥ in-flight depth).
unsafe {
let reg = self.ring[slot].reg;
let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
version: nv::NV_ENC_MAP_INPUT_RESOURCE_VER,
registeredResource: reg,
..Default::default()
};
(api().map_input_resource)(self.encoder, &mut mp)
.nv_ok()
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
let pts = self.frame_idx as u64;
self.frame_idx += 1;
let flags = if std::mem::take(&mut self.force_kf) {
nv::NV_ENC_PIC_FLAGS::NV_ENC_PIC_FLAG_FORCEIDR as u32
| nv::NV_ENC_PIC_FLAGS::NV_ENC_PIC_FLAG_OUTPUT_SPSPPS as u32
} else {
0
};
// Recovery anchor (armed by a successful invalidate_ref_frames): THIS frame is the first
// encoded after the invalidation. A simultaneous forced IDR is itself the re-anchor, so
// the tag is dropped in that case.
let anchor = std::mem::take(&mut self.pending_anchor) && flags == 0;
let mut pic = nv::NV_ENC_PIC_PARAMS {
version: nv::NV_ENC_PIC_PARAMS_VER,
inputWidth: self.width,
inputHeight: self.height,
inputPitch: self.ring[slot].surface.pitch as u32,
inputBuffer: mp.mappedResource,
bufferFmt: mp.mappedBufferFmt,
outputBitstream: self.bitstreams[slot],
pictureStruct: nv::NV_ENC_PIC_STRUCT::NV_ENC_PIC_STRUCT_FRAME,
inputTimeStamp: pts,
encodePicFlags: flags,
..Default::default()
};
// In-band HDR10 SEI on every IDR when in HDR mode (inert on Linux until Phase 5.1 lands a
// 10-bit input, but ported so the wiring is complete). HEVC/H.264 carry SEI; AV1 = OBUs.
let is_idr = flags != 0 || opening;
let mastering_sei = self
.hdr_meta
.map(|m| crate::hdr::hevc_mastering_display_sei(&m));
let cll_sei = self
.hdr_meta
.map(|m| crate::hdr::hevc_content_light_level_sei(&m));
let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
if is_idr && self.hdr {
if let Some(p) = mastering_sei.as_ref() {
sei.push(nv::NV_ENC_SEI_PAYLOAD {
payloadSize: p.len() as u32,
payloadType: crate::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
payload: p.as_ptr() as *mut u8,
});
}
if let Some(p) = cll_sei.as_ref() {
sei.push(nv::NV_ENC_SEI_PAYLOAD {
payloadSize: p.len() as u32,
payloadType: crate::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
payload: p.as_ptr() as *mut u8,
});
}
}
if !sei.is_empty() {
match self.codec {
Codec::H265 => {
pic.codecPicParams.hevcPicParams.seiPayloadArray = sei.as_mut_ptr();
pic.codecPicParams.hevcPicParams.seiPayloadArrayCnt = sei.len() as u32;
}
Codec::H264 => {
pic.codecPicParams.h264PicParams.seiPayloadArray = sei.as_mut_ptr();
pic.codecPicParams.h264PicParams.seiPayloadArrayCnt = sei.len() as u32;
}
Codec::Av1 => {}
}
}
(api().encode_picture)(self.encoder, &mut pic)
.nv_ok()
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
self.pending.push_back((
self.bitstreams[slot],
mp.mappedResource,
captured.pts_ns,
anchor,
));
}
Ok(())
}
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
self.frame_idx = wire_index as i64;
self.submit(frame)
}
fn request_keyframe(&mut self) {
self.force_kf = true;
}
fn caps(&self) -> EncoderCaps {
EncoderCaps {
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
chroma_444: self.chroma_444,
intra_refresh: false,
intra_refresh_recovery: false,
intra_refresh_period: 0,
}
}
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
self.hdr_meta = meta;
}
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
return false;
}
// Already invalidated a covering range for this loss event — re-arm the anchor (the previous
// anchor AU may itself have been lost) but skip the driver calls.
if let Some((pf, pl)) = self.last_rfi_range {
if first >= pf && last <= pl {
self.pending_anchor = true;
return true;
}
}
// The DPB holds `[frame_idx - RFI_DPB, frame_idx - 1]`; a lost frame older than that can't be
// invalidated, so the only correct recovery is an IDR.
let oldest_in_dpb = self.frame_idx - RFI_DPB as i64;
if first < oldest_in_dpb {
return false;
}
let last = last.min(self.frame_idx - 1);
if first > last {
return false;
}
// Each input's `inputTimeStamp` is the WIRE frame index (pinned by `submit_indexed`), so the
// client's lost-frame range maps 1:1 onto the timestamps NVENC invalidates here.
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime table; `self.encoder`
// was checked non-null and is the live session; this runs on the encode thread (no concurrent
// NVENC use). Each `ts` is clamped to `[oldest_in_dpb, frame_idx - 1]`, naming a frame still
// in the DPB; the call passes only that `u64` (no struct).
unsafe {
for ts in first..=last {
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
.nv_ok()
.is_err()
{
return false;
}
}
}
self.last_rfi_range = Some((first, last));
// The next submitted frame is the clean re-anchor — arm the tag so its AU ships with
// `recovery_anchor` and the client lifts its post-loss freeze on it.
self.pending_anchor = true;
true
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
return Ok(None);
};
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
// (`teardown` clears `pending` whenever it nulls the handle); all calls use function pointers
// from the runtime table on the encode thread. `lock_bitstream` (version set) locks `bs`, a
// pool bitstream a prior `encode_picture` targeted, and blocks until that encode finishes, so
// `lock.bitstreamBufferPtr` points at `bitstreamSizeInBytes` bytes of CPU-readable output
// valid until `unlock_bitstream`; the slice is copied (`to_vec`) BEFORE the unlock on the same
// buffer. `map` (paired with `bs` in `pending`) is unmapped here, after the encode completed,
// exactly once.
unsafe {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: bs,
..Default::default()
};
(api().lock_bitstream)(self.encoder, &mut lock)
.nv_ok()
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8,
lock.bitstreamSizeInBytes as usize,
)
.to_vec();
let keyframe = matches!(
lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
);
(api().unlock_bitstream)(self.encoder, bs)
.nv_ok()
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
Ok(Some(EncodedFrame {
data,
pts_ns,
keyframe,
recovery_anchor: anchor,
}))
}
}
fn reset(&mut self) -> bool {
// SAFETY: `teardown` requires the encode thread with no NVENC call in flight and a session
// whose cached resources belong to `self.encoder` — all hold here (reset is called from the
// session loop between submit/poll), and it early-returns on an already-null session.
unsafe { self.teardown() };
self.force_kf = true;
true
}
fn flush(&mut self) -> Result<()> {
Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
}
}
impl Drop for NvencCudaEncoder {
fn drop(&mut self) {
// SAFETY: at Drop this encoder is owned exclusively, runs on the encode thread it was confined
// to, and `teardown` early-returns on a null session; otherwise every cached ring/bitstream/
// pending was created against that live session. Runs exactly once (here).
unsafe { self.teardown() };
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use crate::zerocopy::cuda::DeviceBuffer;
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
// session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B).
let buf = DeviceBuffer::alloc_nv12(w, h).expect("alloc NV12 device buffer");
CapturedFrame {
width: w,
height: h,
pts_ns: i as u64 * 16_666_667,
format: PixelFormat::Nv12,
payload: FramePayload::Cuda(buf),
}
}
/// ON-HARDWARE (RTX box `.21`): drives the full direct-SDK CUDA path end to end — open the
/// session on the shared `CUcontext`, allocate + register the CUDA input-surface ring, encode
/// synthetic NV12 frames, then perform a **real** `invalidate_ref_frames` over an in-DPB range
/// and assert the next AU carries the recovery-anchor tag (the F2 fix) and that `caps()`
/// advertises RFI. Needs an NVIDIA GPU + driver. Run:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_smoke --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_smoke_rfi_anchor() {
const W: u32 = 1280;
const H: u32 = 720;
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
W,
H,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
)
.expect("open NVENC CUDA session");
// Warm up: submit 8 frames pinning wire indices 0..8; the sync poll returns one AU per frame.
let mut aus = 0usize;
let mut first_key = false;
for i in 0..8u32 {
let frame = nv12_frame(W, H, i);
enc.submit_indexed(&frame, i).expect("submit");
while let Some(au) = enc.poll().expect("poll") {
if aus == 0 {
first_key = au.keyframe;
}
aus += 1;
}
}
assert!(aus > 0, "no AUs produced");
assert!(
first_key,
"first AU must be a keyframe (session opening IDR)"
);
assert!(enc.caps().supports_rfi, "RTX NVENC must advertise RFI");
// Invalidate a recent, still-in-DPB range (frames 5..=6; DPB depth is RFI_DPB=5, so 3..=7 are
// live). Must perform a real reference invalidation, not fall back to IDR.
assert!(
enc.invalidate_ref_frames(5, 6),
"invalidate_ref_frames should succeed for an in-DPB range"
);
// The next submitted frame is the clean re-anchor — its AU must be tagged recovery_anchor and
// must NOT be a forced IDR (RFI recovers via a P-frame, not a keyframe).
let frame = nv12_frame(W, H, 8);
enc.submit_indexed(&frame, 8).expect("submit post-RFI");
let mut saw_anchor = false;
let mut anchor_was_keyframe = false;
while let Some(au) = enc.poll().expect("poll") {
if au.recovery_anchor {
saw_anchor = true;
anchor_was_keyframe = au.keyframe;
}
}
assert!(
saw_anchor,
"the post-RFI AU must carry recovery_anchor (the F2 fix)"
);
assert!(
!anchor_was_keyframe,
"RFI re-anchor must be a P-frame, not an IDR"
);
enc.flush().ok();
println!(
"nvenc_cuda smoke: {aus} AUs, RFI succeeded, recovery-anchor tagged on the P-frame"
);
}
/// ON-HARDWARE (RTX box `.21`): the 4:4:4 path — a planar-YUV444 `DeviceBuffer` through an HEVC
/// FREXT (chromaFormatIDC=3) session, exercising the stacked-plane input surface + copy that NV12
/// doesn't. Asserts AUs come out and `caps().chroma_444` reports true (the GPU supports it). Run:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_yuv444 --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
fn nvenc_cuda_yuv444() {
const W: u32 = 1280;
const H: u32 = 720;
crate::zerocopy::cuda::make_current().expect("shared CUDA context current");
let mut enc = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Yuv444,
W,
H,
60,
40_000_000,
true,
8,
ChromaFormat::Yuv444,
)
.expect("open NVENC CUDA 4:4:4 session");
let mut aus = 0usize;
for i in 0..6u32 {
let buf = DeviceBuffer::alloc_yuv444(W, H).expect("alloc YUV444 device buffer");
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i as u64 * 16_666_667,
format: PixelFormat::Yuv444,
payload: FramePayload::Cuda(buf),
};
enc.submit_indexed(&frame, i).expect("submit 444");
while let Some(_au) = enc.poll().expect("poll") {
aus += 1;
}
}
assert!(aus > 0, "no 4:4:4 AUs produced");
assert!(enc.caps().chroma_444, "RTX NVENC HEVC must report 4:4:4");
println!("nvenc_cuda 4:4:4 smoke: {aus} AUs, caps.chroma_444=true");
}
/// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR).
/// Needs no GPU session (it short-circuits on the null encoder / range checks), so it runs in the
/// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA
/// driver is absent (driverless CI) instead of failing.
#[test]
fn rfi_declines_impossible_ranges() {
let Ok(mut enc) = NvencCudaEncoder::open(
Codec::H265,
PixelFormat::Nv12,
1920,
1080,
60,
20_000_000,
true,
8,
ChromaFormat::Yuv420,
) else {
eprintln!(
"skipping rfi_declines_impossible_ranges: NVENC unavailable (no NVIDIA driver)"
);
return;
};
// No live session yet (lazy init on first frame) → every RFI request declines.
assert!(!enc.invalidate_ref_frames(0, 0), "no session → decline");
assert!(!enc.invalidate_ref_frames(10, 5), "first > last → decline");
assert!(
!enc.invalidate_ref_frames(-1, 3),
"negative first → decline"
);
}
}