Files
punktfunk/crates/punktfunk-host/src/encode/windows/nvenc.rs
T
enricobuehler fdda7144ed fix(encode): harden loss-recovery correctness across host encoders (F1–F7)
Phases 1–4 of design/encoder-recovery-hardening.md — make the shipped RFI/
freeze-until-reanchor recovery honest and rebuild-safe across every backend.

F1 — frame-index domain desync: the encode loop now owns a session-lifetime
`au_seq`; `Encoder::submit_indexed(au_seq + inflight)` pins NVENC inputTimeStamp
and AMF LTR slots to the WIRE frame index, so `invalidate_ref_frames` compares
client frame numbers in the same domain and survives adaptive-bitrate rebuilds
(an internal counter desynced on the first rebuild → RFI silently dead / an AMF
force-ref onto a never-decoded frame). `FrameMsg.frame_index` →
`Session::seal_frame_at`; GameStream gets the same via `VideoPacketizer::
packetize(.., Some(idx))`.

F2 — Windows NVENC left the client frozen ~1s per loss: NVENC RFI was
transparent (no anchor tag) while the session glue armed the 750ms IDR cooldown,
so the freeze only lifted on the ~1s keyframe re-ask. NVENC now mirrors AMF —
`pending_anchor` tags the first post-invalidate AU (the clean re-anchor
P-frame) `recovery_anchor`, incl. the covering-range dedupe re-arm; the client
lifts at ~RTT.

F3 — speed-test probe filler burned video frame indexes: moved to its own index
space (`Packetizer::alloc_probe_index` + `Session::submit_probe_frame`) with a
second client reassembly window routed on FLAG_PROBE, gated on the new
VIDEO_CAP_PROBE_SEQ Hello bit (mid-session probes declined for older clients).

F4 — RFI range sanity cap: forward gaps wider than `packet::RFI_MAX_RANGE` (256)
resync via keyframe instead of an out-of-range RFI, host- and client-side
(client huge-gap → keyframe in `RfiRecovery::observe` + the pf-client-core pump).

F5 — reset() parity: Windows NVENC (teardown + lazy re-init), Linux VAAPI
(drop-inner), Linux NVENC (reopen from stored OpenArgs) now give the stall
watchdog a heal lever instead of ending the session.

F6 — sw.rs `pending: VecDeque` (was `Option`), killing the silent AU drop at
capturer pipeline depth > 1. F7 — doc sweep on the RFI/anchor comments.

Verified: punktfunk-core lib tests (macOS + Linux), full punktfunk-host suite on
Linux (RTX 5070 Ti), Windows compile. Owed: the on-glass client matrix (F2
freeze A/B, AMF LTR spike across a bitrate rebuild).

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

1896 lines
99 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.
//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
//!
//! Drives the raw NVENC API via the `nvidia_video_codec_sdk` `sys` types and a **runtime-loaded**
//! entry table ([`EncodeApi`] — the crate's `ENCODE_API`/safe `Encoder` are deliberately unused:
//! the safe wrapper is CUDA-only, and its statically-declared entry points would put a load-time
//! `nvEncodeAPI64.dll` import on the all-vendor binary, killing it on every AMD/Intel-only box).
//! Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
//! synchronous — capture → submit → poll, where `poll`/`lock_bitstream` blocks until the encode
//! finishes — so the capturer never overwrites the texture mid-encode; if that loop ever becomes
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
//!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less and
//! **starts driver-less** (the DLL resolves at runtime; on an AMD/Intel box [`try_api`] fails
//! cleanly and the AMF/QSV/software backends carry the session). The software encoder
//! (`super::sw`) is the fallback.
//!
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
//! (`nvEncEncodePicture`) while a **secondary thread** waits on per-buffer completion events and
//! does `nvEncLockBitstream`. Today's sync mode does both on one thread, so under a GPU-saturating
//! game the whole pipeline serializes on the WDDM scheduling wait (`1000/17ms ≈ 59 fps` — the
//! depth-1 collapse). In async mode the session is opened `enableEncodeAsync=1`, each output
//! bitstream gets a registered auto-reset event, `submit` returns immediately, and an internal
//! retrieve thread waits + locks + copies + unlocks, handing finished AUs back through a channel
//! that `poll` drains without blocking. All input-resource calls (register/map/unmap) stay on the
//! encode thread; the retrieve thread touches ONLY the event + lock/unlock — the exact split the
//! guide blesses. Backpressure: `submit` blocks on the oldest completion when `POOL - 1` encodes
//! are in flight, so an output buffer is never reused mid-encode. Latency cost when idle ≈ 0 (the
//! AU completes within the same tick and `poll` picks it up); under contention completed frames
//! queue instead of stalling capture — throughput recovers up to the scheduler-granted share.
// 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, PixelFormat};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::{HashMap, VecDeque};
use std::ffi::c_void;
use std::ptr;
use std::sync::mpsc;
use windows::core::{Interface, PCWSTR};
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table.
//
// The NVENC entry points live in `nvEncodeAPI64.dll`, which exists ONLY where the NVIDIA driver
// is installed. They must be resolved at runtime (`LoadLibraryExW` + `GetProcAddress`), never as
// a link-time import: the shipped host binary compiles the `nvenc` feature in unconditionally,
// and a load-time DLL import makes the Windows loader refuse to start the process on every
// AMD/Intel-only box ("nvencodeapi64.dll was not found", before `main`) — `encode.rs` never gets
// the chance to dispatch to AMF/QSV. This is the Windows analogue of the Linux host's dlopen'd
// libcuda. Only the two real DLL exports are resolved by name; the rest of the table comes back
// through `NvEncodeAPICreateInstance`.
// ---------------------------------------------------------------------------------------------
/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses, unwrapped once at load so call
/// sites stay `(api().encode_picture)(…)`. Field names mirror the sdk crate's `EncodeAPI`, whose
/// lazy static must NOT be referenced — it calls the statically-declared externs, which is what
/// demanded the import lib at link time.
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,
register_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
unregister_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) 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 on this machine (no
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/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 {
// Once per process. Only reachable when something resolved to NVENC on this box
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
tracing::warn!("NVENC 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 (or the probe's own
/// gate) 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> {
use windows::core::{s, w};
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
// SAFETY: `LoadLibraryExW`/`GetProcAddress` take static NUL-terminated names; the
// System32-only search path keeps a planted DLL out of the SYSTEM-service process. The two
// transmutes cast the resolved exports to their documented prototypes (nvEncodeAPI.h), the
// same contract the C SDK's own loader applies. `NvEncodeAPIGetMaxSupportedVersion` writes
// one u32 through a live pointer; `NvEncodeAPICreateInstance` fills `list`, a stack-local
// `#[repr(C)]` function list with `version` set, only during the call. The module is never
// freed, so every extracted function pointer stays valid for the process lifetime.
unsafe {
let module = LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
.map_err(|e| format!("nvEncodeAPI64.dll not loadable (no NVIDIA driver?): {e}"))?;
let get_version = GetProcAddress(module, s!("NvEncodeAPIGetMaxSupportedVersion"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPIGetMaxSupportedVersion")?;
let create_instance = GetProcAddress(module, s!("NvEncodeAPICreateInstance"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPICreateInstance")?;
let get_version: unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS =
std::mem::transmute(get_version);
let create_instance: unsafe extern "C" fn(
*mut nv::NV_ENCODE_API_FUNCTION_LIST,
) -> nv::NVENCSTATUS = std::mem::transmute(create_instance);
let mut version = 0u32;
get_version(&mut version)
.nv_ok()
.map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
// The sdk's assert_versions_match, 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";
Ok(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)?,
register_async_event: list.nvEncRegisterAsyncEvent.ok_or(MISSING)?,
unregister_async_event: list.nvEncUnregisterAsyncEvent.ok_or(MISSING)?,
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
})
}
}
// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
// GPU-saturating game; 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 for H.264/HEVC). 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,
}
}
/// Live NVENC hardware-session units held by THIS host process (a plain session = 1; a forced
/// split-encode session occupies one session per engine = 23) — the Stage-W3 encoder budget
/// (`design/windows-parallel-virtual-displays.md` §4.5). Kept in ONE place so admitting a parallel
/// display consults the same accounting every open/teardown maintains; other processes' sessions
/// aren't visible here, but our own consumption is the deterministic part we can enforce
/// fail-closed at admission.
static LIVE_SESSION_UNITS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// The NVENC concurrent-session cap to budget against: GeForce (consumer) drivers allow 8
/// concurrent encode sessions since R550 (pro cards are effectively unlimited).
/// `PUNKTFUNK_NVENC_MAX_SESSIONS` overrides for older drivers / known-different cards.
fn session_cap() -> u32 {
std::env::var("PUNKTFUNK_NVENC_MAX_SESSIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8)
}
/// Whether one more (plain, non-split) encode session fits the NVENC budget — consulted by
/// admission before admitting a parallel display (`vdisplay::admission`). On a box that never
/// opened NVENC (AMD/Intel/none) the count is 0 and this always passes — the budget seam is
/// NVENC-only until the AMF/QSV equivalents grow their own accounting.
pub(crate) fn can_open_another_session() -> bool {
LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) < session_cap()
}
/// Session-unit weight of a chosen split-encode mode (one hardware session per engine).
fn split_mode_units(split_mode: u32) -> u32 {
match split_mode {
m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 => 3,
m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
|| m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 =>
{
2
}
_ => 1,
}
}
/// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy).
/// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until
/// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset
/// the env in that case.
fn async_retrieve_requested() -> bool {
std::env::var("PUNKTFUNK_NVENC_ASYNC")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
/// Max encodes in flight in async mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
/// `2..=POOL-1`). Two independent ceilings meet here: the output-bitstream pool (hard, `POOL-1` —
/// a buffer must never be reused mid-encode) and the capturer's texture ring (soft — NVENC encodes
/// the ring textures in place, so in-flight depth beyond the ring lets the capturer overwrite a
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
/// `PUNKTFUNK_IDD_DEPTH`; raise both together if deeper pipelining is needed.
fn async_inflight_cap() -> usize {
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(4)
.clamp(2, POOL - 1)
}
/// One in-flight encode handed to the retrieve thread: the output bitstream to lock once its
/// completion `event` signals. Raw pointers travel as `usize` (the addresses are process-global
/// driver handles; the thread is joined before the session they belong to is destroyed).
struct RetrieveJob {
bs: usize,
event: usize,
}
/// A finished retrieve: the locked-and-copied AU (or the retrieve-side error) for the oldest
/// in-flight bitstream. `bs` lets the encode thread cross-check FIFO pairing with `pending`.
struct RetrieveDone {
bs: usize,
result: std::result::Result<(Vec<u8>, bool), String>,
}
/// The async-retrieve runtime: the job channel feeding the retrieve thread, the completion channel
/// back, the thread handle (joined in `teardown` BEFORE the session is destroyed), and AUs already
/// absorbed by backpressure that `poll` hands out first.
struct AsyncRetrieve {
work_tx: Option<mpsc::SyncSender<RetrieveJob>>,
done_rx: mpsc::Receiver<RetrieveDone>,
join: Option<std::thread::JoinHandle<()>>,
ready: VecDeque<EncodedFrame>,
}
/// The retrieve-thread body (gpu-contention plan §5.B): for each submitted frame, wait on its
/// completion event, lock the bitstream, copy the AU out, unlock, and send it back. Exits when the
/// job channel closes (teardown drops the sender and joins BEFORE destroying the session, so
/// `enc`/`bs`/`event` outlive every use here). Touches ONLY the event wait + lock/unlock — the
/// NVENC threading model's sanctioned secondary-thread surface.
fn retrieve_loop(
enc: usize,
work_rx: mpsc::Receiver<RetrieveJob>,
done_tx: mpsc::Sender<RetrieveDone>,
) {
crate::punktfunk1::boost_thread_priority(false);
while let Ok(job) = work_rx.recv() {
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
// valid until `teardown`, which joins this thread first. `WaitForSingleObject` takes the
// handle by value. On WAIT_OBJECT_0 the driver has completed the encode into `job.bs`, so
// `lock_bitstream` (version set, struct a live stack local for the synchronous call)
// yields a CPU-readable `bitstreamBufferPtr`/`bitstreamSizeInBytes` valid until
// `unlock_bitstream`; the slice is copied (`to_vec`) before the unlock on the same buffer.
// Lock/unlock from a secondary thread while the encode thread submits is the NVENC
// guide's documented threading model.
let result = unsafe {
if WaitForSingleObject(HANDLE(job.event as *mut c_void), 5000) != WAIT_OBJECT_0 {
Err("NVENC completion event timeout (5s) — encoder wedged?".to_string())
} else {
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
outputBitstream: job.bs as *mut c_void,
..Default::default()
};
match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
Ok(()) => {
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
);
let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
Ok((data, keyframe))
}
Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
}
}
};
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
break; // encoder side gone (teardown drains us via join)
}
}
}
pub struct NvencD3d11Encoder {
encoder: *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 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
bit_depth: u8,
/// Full-chroma 4:4:4 (HEVC Range Extensions, `chroma_format_idc = 3`) requested for this session.
/// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally; the `FREXT` profile
/// and `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
/// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
chroma_444: bool,
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
/// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
yuv444_supported: bool,
/// HDR: the capturer is delivering BT.2020 PQ 10-bit (`PixelFormat::Rgb10a2`) frames. Sets the
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
/// (HDR can toggle mid-session); a change re-inits the session.
hdr: bool,
/// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
/// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
/// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
/// [`Encoder::set_hdr_meta`], so a mid-session regrade is picked up on the next keyframe.
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
/// Registrations of the capturer's input textures, cached by texture raw pointer — NVENC encodes
/// them in place (no per-frame copy). The cloned `ID3D11Texture2D` keeps each alive until we
/// unregister it (the capturer may drop its copy on a device recreate before our teardown runs).
regs: HashMap<isize, (nv::NV_ENC_REGISTERED_PTR, ID3D11Texture2D)>,
next: usize,
bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
/// Async mode: the registered completion event per pool bitstream (raw `HANDLE` as `usize`,
/// parallel to `bitstreams`); empty in sync mode. Unregistered + closed in `teardown`.
events: Vec<usize>,
/// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
async_rt: Option<AsyncRetrieve>,
/// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
async_supported: bool,
/// (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 (see [`EncodedFrame::recovery_anchor`]).
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 (an internal counter desyncs on the first adaptive-bitrate rebuild —
/// RFI then never matches again). 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
/// one coded against only-valid references — without tagging it the client's freeze can only
/// lift on an IDR, which the session glue suppresses after an RFI success (the cooldown):
/// a ~1 s frozen stall per loss event on NVIDIA hosts.
pending_anchor: bool,
inited: bool,
/// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring (Apollo's
/// `get_encoder_cap`): gates 10-bit/custom-VBV/RFI on what this card actually supports instead
/// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
rfi_supported: bool,
custom_vbv: bool,
/// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
/// loss event (the client resends until it sees recovery).
last_rfi_range: Option<(i64, i64)>,
/// Raw ptr of the D3D11 device this session was initialized with. The capturer recreates the
/// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
/// tear down and re-init NVENC against it.
init_device: *mut c_void,
/// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 23
/// under forced split-encode — a split session occupies one session per engine). `0` while no
/// session is open; set by `init_session`, returned by `teardown`.
session_units: u32,
}
// SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads
// EXCEPT along the NVENC guide's sanctioned split. The encoder object 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. In async mode the internal retrieve
// thread additionally calls `WaitForSingleObject`/`lock_bitstream`/`unlock_bitstream` on the same
// session — the exact two-thread model the NVENC API documents as thread-safe (submit-side vs
// output-side); it never touches registrations, mappings, or D3D11. `teardown` joins that thread
// BEFORE destroying the session, so no retrieve call can outlive the handles. Moving the encoder
// across its single ownership-transfer boundary is sound because no NVENC/D3D11 call is in flight
// during the move — so `Send` introduces no data race on the non-`Send` fields.
unsafe impl Send for NvencD3d11Encoder {}
impl NvencD3d11Encoder {
#[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: ChromaFormat,
) -> Result<Self> {
// The runtime DLL load is the real "is NVENC possible here" gate: fail the open with a
// clear reason (backend misdetect / forced PUNKTFUNK_ENCODER=nvenc on a non-NVIDIA box)
// instead of an opaque session error on the first frame. Every later NVENC call in this
// file sits behind this gate (or the probe's), so the infallible `api()` is sound.
try_api().map_err(|e| anyhow!("NVENC unavailable: {e}"))?;
Ok(Self {
encoder: 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_ARGB,
bit_depth,
// 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
chroma_444: chroma.is_444() && codec == Codec::H265,
yuv444_supported: false,
hdr: false,
hdr_meta: None,
regs: HashMap::new(),
next: 0,
bitstreams: Vec::new(),
events: Vec::new(),
async_rt: None,
async_supported: false,
pending: VecDeque::new(),
frame_idx: 0,
force_kf: false,
pending_anchor: false,
inited: false,
rfi_supported: false,
custom_vbv: false,
last_rfi_range: None,
init_device: ptr::null_mut(),
session_units: 0,
})
}
/// Tear down the encode session + pooled resources. Reused on a capture-device change (desktop
/// switch) and at Drop.
unsafe fn teardown(&mut self) {
if self.encoder.is_null() {
return;
}
// Async mode: retire the retrieve thread FIRST — drop the job sender so it finishes every
// queued job (each references the still-live session) and exits, then join. Only after the
// join is it sound to unmap/destroy anything the thread might have been touching.
if let Some(mut rt) = self.async_rt.take() {
drop(rt.work_tx.take());
if let Some(j) = rt.join.take() {
let _ = j.join();
}
// Completions the thread produced that poll() never absorbed — their AUs are dropped
// (the session is going away), but the FIFO pairing stands, so nothing extra to do
// beyond the pending unmap below.
while rt.done_rx.try_recv().is_ok() {}
}
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
for (_, map, _, _) in &self.pending {
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, *map);
}
}
for (reg, _tex) in self.regs.values() {
let _ = (api().unregister_resource)(self.encoder, *reg);
}
// Async events: unregister from the session, then close the Win32 handles.
for &ev in &self.events {
let mut ep = nv::NV_ENC_EVENT_PARAMS {
version: nv::NV_ENC_EVENT_PARAMS_VER,
completionEvent: ev as *mut c_void,
..Default::default()
};
let _ = (api().unregister_async_event)(self.encoder, &mut ep);
let _ = CloseHandle(HANDLE(ev as *mut c_void));
}
self.events.clear();
for &bs in &self.bitstreams {
let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
}
let _ = (api().destroy_encoder)(self.encoder);
// Return this session's units to the budget (see LIVE_SESSION_UNITS).
LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
self.session_units = 0;
self.regs.clear(); // drops the texture clones, releasing our refs
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 against it — and the IDR is itself the re-anchor,
// so a pending anchor tag from a pre-teardown RFI is stale too.
self.last_rfi_range = None;
self.pending_anchor = false;
}
/// Query one `NV_ENC_CAPS` value for this codec on an open session; 0 on any error (treat an
/// unqueryable cap as "unsupported", the conservative choice).
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 real capabilities once (Apollo's `get_encoder_cap`) before the bitrate-probe
/// loop configures the session: opens a throwaway session, queries the codec's max dimensions +
/// 10-bit / custom-VBV / ref-pic-invalidation support, destroys it. Rejects an out-of-range mode
/// up front with a clear error, downgrades 10-bit→8-bit when unsupported, and records the
/// RFI/custom-VBV flags the config + [`invalidate_ref_frames`](Encoder::invalidate_ref_frames)
/// gate on. Without this, an unsupported config surfaces only as an opaque `InvalidParam` that
/// the bitrate-clamp search misreads as "bitrate too high" and binary-searches into the floor.
unsafe fn query_caps(&mut self, device: &ID3D11Device) -> 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_DIRECTX,
device: device.as_raw(),
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 ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
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 async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
let _ = (api().destroy_encoder)(enc);
// Reject an over-range mode with a clear message instead of an opaque InvalidParam.
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
);
}
// Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
if self.bit_depth >= 10 && ten_bit == 0 {
tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
self.bit_depth = 8;
self.hdr = false;
}
// Same for 4:4:4: a card without YUV444 encode falls back to 4:2:0. (The host already probed
// this via `probe_can_encode_444` before the Welcome, so this is a belt-and-braces guard.)
self.yuv444_supported = yuv444 != 0;
if self.chroma_444 && !self.yuv444_supported {
tracing::warn!("NVENC: 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;
self.async_supported = async_enc != 0;
tracing::info!(
rfi = self.rfi_supported,
custom_vbv = self.custom_vbv,
async_encode = self.async_supported,
max = %format!("{wmax}x{hmax}"),
ten_bit = ten_bit != 0,
"NVENC capabilities probed"
);
Ok(())
}
/// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
/// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
/// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
unsafe fn try_open_session(
&self,
device: &ID3D11Device,
bitrate: u64,
split_mode: u32,
enable_async: bool,
) -> 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_DIRECTX,
device: device.as_raw(),
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;
// Mirror the Linux RC config: CBR, infinite GOP, P-only, ~1-frame VBV.
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;
// Shrink the VBV with the bitrate — NVENC validates it against the same level ceiling. Only
// when the GPU advertises custom-VBV support (else leave the preset default, per the caps probe).
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 — these union writes must match the negotiated codec.
// The old unconditional `hevcConfig.tier = 1` relied on "HEVC/AV1 share the union offset",
// which is true for the offsets but WRONG for the values: NVENC's AV1 encoder supports the
// Main tier only, and tier=1 fails the whole session open with NV_ENC_ERR_INVALID_PARAM
// (the "AV1 negotiates fine but the encoder rejects at any bitrate" field bug). It also
// scribbled HEVC offsets into h264Config, where they alias unrelated fields.
// HEVC keeps HIGH tier: its PER-LEVEL bitrate ceiling is otherwise the MAIN-tier cap — at
// 5K that's Level 6.2 Main ≈ 240 Mbps; HIGH lifts it to ≈800 Mbps. AV1's Main-tier level
// ceilings are high enough that autoselect alone suffices. Level 0 = autoselect for both.
match self.codec {
Codec::H265 => {
cfg.encodeCodecConfig.hevcConfig.tier = 1;
cfg.encodeCodecConfig.hevcConfig.level = 0;
}
Codec::Av1 => {
// Deliberately NO writes: the preset defaults are already the only accepted
// configuration — Main tier (tier=1 fails init: NVENC AV1 has no HIGH tier) and
// autoselect level. Do NOT copy HEVC's `level = 0` here: in the AV1 level enum
// 0 is LEVEL 2.0 (autoselect is a distinct constant), so "0 = autoselect" is an
// HEVC-ism that pins AV1 to its smallest level and rejects any real stream.
// idrPeriod likewise stays at the preset default: with PTD enabled the driver
// follows `gopLength` (INFINITE above), and writing INFINITE into it explicitly
// is itself rejected (all verified live on a 4090 / driver 561).
}
// H.264 has no tier; the preset default level is already autoselect.
Codec::H264 => {}
}
// Chroma + bit depth. Full-chroma 4:4:4 (HEVC Range Extensions) takes precedence and composes
// with 10-bit (Main 4:4:4 10): NVENC ingests the RGB input (ARGB / ABGR10) and CSCs it to
// YUV444 internally when `chromaFormatIDC = 3` under the FREXT profile. Only valid on an RGB
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
//
// ON-GLASS MEASURED (RTX 5070 Ti, driver 610.43, 2026-07-10 — `nvenc_444_on_glass_probe`
// below + colour-bar analysis): ARGB + chromaFormatIDC=3 + FREXT yields a TRUE 4:4:4
// stream (1-px chroma stripes survive, adjacent-column |dU| ≈ 138), and NVENC's internal
// RGB→YUV conversion FOLLOWS THE CONFIGURED VUI MATRIX (bars match BT.709 within ±1 code
// with our 709 VUI; the same driver produces exact BT.601 when libavcodec's nvenc wrapper
// sets its BT470BG VUI on Linux). The always-written SDR VUI above therefore makes the
// pixels and the signaling agree by construction — no AYUV shader needed.
let rgb_input = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
);
if self.chroma_444 && rgb_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); // Main 4:4:4 10
}
} else if self.bit_depth == 10 {
// 10-bit (HDR foundation): NVENC upconverts an 8-bit input; 8-bit leaves the preset
// default profile untouched. PER CODEC — stamping the HEVC Main10 GUID + hevcConfig
// bitfields onto an AV1 session was an unconditional INVALID_PARAM (the "AV1 10-bit
// session never opens" field bug); AV1's Main profile already covers 10-bit, it only
// needs the output depth set on its own config.
match self.codec {
Codec::H265 => {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
// 10 - 8
}
Codec::Av1 => {
cfg.encodeCodecConfig.av1Config.set_pixelBitDepthMinus8(2);
// The input rides at its real depth; NVENC upconverts (mirrors the HEVC path).
let ten_bit_in = matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
);
cfg.encodeCodecConfig
.av1Config
.set_inputPixelBitDepthMinus8(if ten_bit_in { 2 } else { 0 });
}
Codec::H264 => {} // no 10-bit H.264 encode on NVENC — negotiation never asks
}
}
// Colour signaling, written UNCONDITIONALLY (was HDR-only): the capturer hands NVENC
// pre-converted NV12 (BT.709 limited, the IDD VideoConverter) or P010 (BT.2020 PQ
// limited, the FP16→P010 shader), so the stream must SAY so — an SDR stream with no
// colour description decodes correctly only on clients whose "unspecified" default
// happens to be BT.709 limited (ours are, but Moonlight/third-party/Android-vendor
// decoders default 601 at sub-HD resolutions). HEVC/H.264 carry it in the VUI; AV1 has
// NO VUI, so the SAME CICP code points go in the sequence-header colour config
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`).
//
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
// sets Main10 above today).
{
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; // studio/limited swing
}
}
}
// Reference-frame invalidation: keep a deeper DPB so an invalidated reference can fall back
// to an older still-valid frame instead of a full IDR, while `numRefL0 = 1` keeps each
// P-frame single-reference for low latency. Only when this GPU supports RFI (else leave the
// preset default — `invalidate_ref_frames` then returns false and the caller forces an IDR).
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,
// Two-thread async retrieve (§5.B): completion events signal the retrieve thread
// instead of `lock_bitstream` blocking the submit thread.
enableEncodeAsync: enable_async as u32,
encodeConfig: &mut cfg,
..Default::default()
};
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
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 on the first frame's D3D11 device (so capture + encode share it).
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
// SAFETY: every call below goes through a function pointer resolved once from the
// runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
// escapes the encode thread.
unsafe {
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
// gated on what this card supports and an out-of-range mode fails with a clear error
// rather than being misread as a too-high bitrate by the clamp search.
self.query_caps(device)?;
// Bitrate clamp (see the search below): NVENC rejects `initialize_encoder` when the bitrate
// exceeds the GPU's max codec level. We try the requested rate, then binary-search down to
// the MAX the level accepts and clamp to it — so an over-asking client (e.g. 1 Gbps on HEVC)
// gets the highest the GPU can actually do, not a coarse fraction of it.
const FLOOR_BPS: u64 = 10_000_000;
let requested_bps = self.bitrate_bps;
// 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever
// the Linux host enables via libavcodec `split_encode_mode`. A single Ada NVENC session tops
// out ~0.8 Gpix/s, so at high motion a 5K@240 (1.77 Gpix/s) frame takes ~8 ms to encode and
// the rate caps ~125 fps; splitting across both engines roughly halves that. Force 2-way
// above ~1 Gpix/s (matching encode/linux.rs), AUTO below (the ~2% BD-rate cost isn't worth
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
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,
// Main10 (10-bit / HDR): 2-way split is measurably SLOWER on Ada — at 5120x1440@240
// Main10, forced-2 took 7.6 ms/frame (~131 fps) vs 2.8 ms (~357 fps) single-engine
// (the split/merge overhead dominates for 10-bit). A single Ada NVENC engine already
// handles 5K@240 Main10 well under the 4.17 ms budget, so DON'T split — splitting was
// the "broken animations in HDR" (the stream capped at ~131 fps). Env still overrides.
_ 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,
};
tracing::info!(
split_mode,
bit_depth = self.bit_depth,
pixel_rate,
"NVENC split-encode mode (0=auto 1=auto-forced 2=two 3=three 15=disable)"
);
// Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
// `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
// 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
// split-encode mode the codec doesn't support, disable split and retry; if the bitrate
// itself is too high, binary-search [FLOOR, requested] for the MAX accepted rate and clamp
// to THAT (don't undershoot — the old ×¾ step-down landed well below the real ceiling).
const CLAMP_TOL_BPS: u64 = 20_000_000; // stop bisecting within ~20 Mbps of the ceiling
// Two-thread async retrieve: operator opt-in AND the GPU reports async-encode support
// (query_caps above). Threaded into every session-open probe so the chosen session is
// built in the right mode from the start.
let use_async = self.async_supported && async_retrieve_requested();
let mut probe = self.try_open_session(device, requested_bps, split_mode, use_async);
// Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
// requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
// ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
// init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
// which then masqueraded as a bitrate cap and failed "even at the floor".
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(device, requested_bps, no_split, use_async) {
tracing::warn!("NVENC: 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.
// `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
// rejected; `best` holds the live session at `lo` so we end up with the clamped one.
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(device, mid, split_mode, use_async) {
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() {
// Nothing in (FLOOR, requested] accepted — fall back to the floor itself, also
// trying split-disabled in case a forced split (not the bitrate) is the blocker.
let no_split =
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
best = self
.try_open_session(device, FLOOR_BPS, split_mode, use_async)
.or_else(|_| {
self.try_open_session(device, FLOOR_BPS, no_split, use_async)
})
.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: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
);
self.bitrate_bps = best_bps;
best
}
};
self.encoder = enc;
// Session-budget accounting (Stage W3): record what this open holds so admission can
// decline a parallel display the hardware can't afford. Weighted by the FINAL split
// mode (a split session occupies one hardware session per engine).
self.session_units = split_mode_units(split_mode);
LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
if self.bitrate_bps < requested_bps {
tracing::info!(
requested_mbps = requested_bps / 1_000_000,
applied_mbps = self.bitrate_bps / 1_000_000,
"NVENC bitrate capped to this GPU's max for the codec"
);
}
// 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
// capturer's textures are registered on demand in `submit` and encoded in place.
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);
}
// Async retrieve: one auto-reset completion event per pool bitstream, registered with
// the session, plus the retrieve thread the events signal. The thread only ever sees
// raw addresses; `teardown` joins it before any of them die.
if use_async {
for _ in 0..POOL {
let ev = CreateEventW(None, false, false, PCWSTR::null())
.context("CreateEvent (NVENC completion)")?;
let mut ep = nv::NV_ENC_EVENT_PARAMS {
version: nv::NV_ENC_EVENT_PARAMS_VER,
completionEvent: ev.0,
..Default::default()
};
(api().register_async_event)(enc, &mut ep)
.nv_ok()
.map_err(|e| anyhow!("register_async_event: {e:?}"))?;
self.events.push(ev.0 as usize);
}
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
let enc_addr = enc as usize;
let join = std::thread::Builder::new()
.name("punktfunk-nvenc-out".into())
.spawn(move || retrieve_loop(enc_addr, work_rx, done_tx))
.context("spawn NVENC retrieve thread")?;
self.async_rt = Some(AsyncRetrieve {
work_tx: Some(work_tx),
done_rx,
join: Some(join),
ready: VecDeque::new(),
});
tracing::info!(
pool = POOL,
"NVENC async retrieve active (two-thread encode: submit here, \
lock_bitstream on the retrieve thread)"
);
}
self.inited = true;
tracing::info!(
"NVENC D3D11 session: {}x{}@{} {}-bit{} {} Mbps {:?}",
self.width,
self.height,
self.fps,
self.bit_depth,
if self.hdr { " HDR(BT.2020 PQ)" } else { "" },
self.bitrate_bps / 1_000_000,
self.codec_guid
);
Ok(())
}
}
/// Fold one retrieve-thread completion back into encoder state ON THE ENCODE THREAD: pop the
/// oldest `pending` entry (completions are FIFO — one retrieve thread, in-order jobs), verify
/// the bitstream pairing, unmap the input resource, and queue the AU for `poll`. A retrieve
/// error surfaces AFTER the unmap (the resource is retired either way) so the session glue's
/// rebuild path starts from clean state.
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
bail!("NVENC async: completion with no in-flight frame (pairing bug)");
};
if bs as usize != done.bs {
bail!("NVENC async: completion out of order (pairing bug)");
}
// SAFETY: `map` is the mapped input `submit` recorded for exactly this now-completed
// encode; the session is live (`async_rt` exists only between `init_session` and
// `teardown`) and this runs on the encode thread — the single unmap here mirrors the sync
// path's poll-side unmap, exactly once per mapping.
unsafe {
if !map.is_null() {
let _ = (api().unmap_input_resource)(self.encoder, map);
}
}
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
self.async_rt
.as_mut()
.expect("absorb_done is only reachable in async mode")
.ready
.push_back(EncodedFrame {
data,
pts_ns,
keyframe,
recovery_anchor: anchor,
});
Ok(())
}
}
impl Encoder for NvencD3d11Encoder {
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
let frame = match &captured.payload {
FramePayload::D3d11(f) => f,
FramePayload::Cpu(_) => {
bail!("NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)")
}
};
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
// back at a different resolution (user session applies its own mode on login). Re-init when the
// frame arrives on a different device OR at a different size than our session was built on.
// HDR (BT.2020 PQ 10-bit) when the capturer hands us a 10-bit R10G10B10A2 frame. This can flip
// mid-session when the user toggles HDR (which arrives as a capture device recreate anyway).
// HDR (BT.2020 PQ) when the capturer hands a 10-bit frame — either R10G10B10A2 (the legacy
// shader path) or P010 (the video-processor path). 8-bit NV12/ARGB → SDR.
let hdr = matches!(captured.format, PixelFormat::Rgb10a2 | PixelFormat::P010);
let dev_raw = frame.device.as_raw();
let size_changed =
self.inited && (self.width != captured.width || self.height != captured.height);
let hdr_changed = self.inited && self.hdr != hdr;
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
tracing::info!(
device_changed = self.init_device != dev_raw,
size_changed,
hdr_changed,
hdr,
new = format!("{}x{}", captured.width, captured.height),
"NVENC: capture device/size/HDR changed — re-initializing session"
);
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
// live session every cached resource was created against, and the previous frame's encode
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
unsafe { self.teardown() };
}
if !self.inited {
// Adopt the current frame size + colour so the encoder always matches the capturer output.
self.width = captured.width;
self.height = captured.height;
self.hdr = hdr;
// Pick the NVENC input format from the captured pixel format. YUV (NV12/P010) is the
// video-processor path — NVENC encodes it natively (no internal RGB→YUV, which is a hidden
// 3D/compute step that would fight a GPU-saturating game). RGB (ARGB/ABGR10) is the legacy
// shader path. 10-bit (P010/ABGR10) forces HEVC Main10 + the BT.2020 PQ VUI.
self.buffer_fmt = match captured.format {
PixelFormat::P010 => {
self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
}
PixelFormat::Rgb10a2 => {
self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
}
PixelFormat::Nv12 => {
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
self.bit_depth = 8;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
}
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
};
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
// the stream really carries instead of silently claiming full chroma.
if self.chroma_444
&& !matches!(
self.buffer_fmt,
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
)
{
tracing::warn!(
format = ?captured.format,
"4:4:4 negotiated but the capturer delivered subsampled YUV — encoding 4:2:0"
);
self.chroma_444 = false;
}
let device = frame.device.clone();
self.init_session(&device)?;
self.init_device = dev_raw;
}
// The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
// in-band HDR SEI must ride it too. Detected via the still-empty output slot counter
// (`teardown` zeroes it), NOT via `pts == 0`: `submit_indexed` pins pts to the wire frame
// index, which is non-zero on a mid-session encoder rebuild's first frame.
let opening = self.next == 0;
// Async backpressure: never hand NVENC an output bitstream that is still in flight, and
// keep in-flight depth within the capturer's texture ring (see `async_inflight_cap`). At
// the cap, block on the OLDEST completion (the retrieve thread is already waiting on its
// event) before submitting more — bounding depth exactly like the sync path's per-tick
// blocking poll, just `cap` deep instead of 1.
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
let done = {
let rt = self.async_rt.as_mut().expect("checked in loop condition");
rt.done_rx
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| anyhow!("NVENC async retrieve stalled (5s) — encoder wedged?"))?
};
self.absorb_done(done)?;
}
let slot = self.next % POOL;
self.next += 1;
// SAFETY: every NVENC call goes through a function pointer from the runtime-loaded `EncodeApi` table
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
// `frame.device`, which is the SAME device the session was opened against (any device change
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map 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 (`mastering_sei`/`cll_sei` and the
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
// sound because the encode loop is synchronous, as the module docs state.)
unsafe {
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
// capturer already produced a stable GPU texture; we just register + map + encode it.
let key = frame.texture.as_raw() as isize;
if !self.regs.contains_key(&key) {
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_DIRECTX,
width: self.width,
height: self.height,
pitch: 0,
resourceToRegister: frame.texture.as_raw(),
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: {e:?}"))?;
self.regs
.insert(key, (rr.registeredResource, frame.texture.clone()));
}
let reg = self.regs[&key].0;
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 one encoded after the invalidation — the clean re-anchor. 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: 0,
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 as u32,
// Async mode: the event the driver signals when this encode completes (the
// retrieve thread waits on it). Null in sync mode (`events` is empty).
completionEvent: self
.events
.get(slot)
.map(|&e| e as *mut c_void)
.unwrap_or(ptr::null_mut()),
..Default::default()
};
// In-band HDR10 SEI on every IDR (a forced keyframe, or the first frame NVENC opens with):
// `mastering_display_colour_volume` (ST.2086) + `content_light_level_info` (CEA-861.3),
// built from the source display's metadata. Any decoder — incl. stock Moonlight — then
// tone-maps from the real grade. HEVC/H.264 carry SEI; AV1 uses metadata OBUs (follow-up).
// The scratch buffers must outlive `encode_picture`, so they live in this scope.
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() {
// Writing a union field is safe; the pointers/len are read during encode_picture.
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;
}
// AV1 mastering/CLL ride METADATA OBUs, not SEI — separate follow-up.
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,
));
// Async: hand the in-flight encode to the retrieve thread (channel capacity = POOL ≥
// in-flight, so this send never blocks). The pending entry above pairs with its
// completion FIFO in `absorb_done`.
if let Some(rt) = &self.async_rt {
let job = RetrieveJob {
bs: self.bitstreams[slot] as usize,
event: self.events[slot],
};
if rt.work_tx.as_ref().is_none_or(|tx| tx.send(job).is_err()) {
bail!("NVENC retrieve thread gone — rebuilding the session");
}
}
}
Ok(())
}
/// Pin this submission's frame number (= its `inputTimeStamp`) to the wire frame index the AU
/// will carry, so the DPB timestamps `invalidate_ref_frames` matches client frame numbers
/// against are the wire's — 1:1 across every rebuild/reset (see the trait doc). Within a
/// session the loop's prediction is nondecreasing; a repeat after a reset lands on a fresh
/// session (teardown cleared the DPB and `last_rfi_range`), so re-pinning is always sound.
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 {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
// session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps {
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
chroma_444: self.chroma_444,
// The direct-NVENC path recovers via real RFI (or a forced IDR), not the Linux
// libavcodec intra-refresh mode.
intra_refresh: false,
intra_refresh_recovery: false,
intra_refresh_period: 0,
}
}
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
// frame; only changes when the source is regraded or HDR toggles.
self.hdr_meta = meta;
}
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
// No live session, the GPU can't invalidate, or a nonsense range → caller forces a full IDR.
// (NVENC handles are single-threaded; this runs on the encode thread, like submit/poll.)
if self.encoder.is_null() || !self.rfi_supported || first < 0 || first > last {
return false;
}
// Already invalidated a covering range for this loss event — no new driver calls needed,
// no IDR. RE-ARM the anchor though: the client re-asking means the previous recovery
// anchor AU may itself have been lost, and the next frame is just as clean a re-anchor
// (it too references only valid frames).
if let Some((pf, pl)) = self.last_rfi_range {
if first >= pf && last <= pl {
self.pending_anchor = true;
return true;
}
}
// `frame_idx` is the NEXT timestamp to assign, so the last encoded frame is `frame_idx - 1`
// and 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;
}
// Clamp to frames we've actually encoded (don't invalidate a timestamp we never assigned).
let last = last.min(self.frame_idx - 1);
if first > last {
return false;
}
// Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the WIRE
// frame index the AU carries — so the client's lost-frame range maps 1:1 onto the
// timestamps NVENC invalidates here, and stays 1:1 across encoder rebuilds/resets (an
// internal counter would desync on the first adaptive-bitrate rebuild and RFI would then
// clamp every range into first > last, silently degrading to IDR-only forever).
// SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
// lifetime concern.
unsafe {
for ts in first..=last {
if (api().invalidate_ref_frames)(self.encoder, ts as u64)
.nv_ok()
.is_err()
{
return false; // any failure → fall back to IDR
}
}
}
self.last_rfi_range = Some((first, last));
// The next submitted frame is the first one encoded after the invalidation — the clean
// re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and the client
// lifts its post-loss freeze on it (instead of waiting ~1 s for the cooldown-suppressed
// IDR fallback).
self.pending_anchor = true;
true
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// Async mode: drain whatever the retrieve thread has finished (non-blocking) and hand out
// the oldest ready AU. `None` = nothing completed yet — the session loop keeps the frame
// in flight and re-polls next tick, capture never blocks on the WDDM scheduling wait.
if self.async_rt.is_some() {
while let Ok(done) = self
.async_rt
.as_mut()
.expect("checked just above")
.done_rx
.try_recv()
{
self.absorb_done(done)?;
}
return Ok(self
.async_rt
.as_mut()
.expect("checked just above")
.ready
.pop_front());
}
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 below use function
// pointers from the runtime-loaded `EncodeApi` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
// same buffer — so it never outlives the lock. `map` (the input resource 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,
}))
}
}
/// Encode-stall recovery: tear the whole session down (the same teardown a capture-device
/// change uses) and let the next `submit` rebuild it lazily on the current device — the owed
/// AUs are forfeited and the fresh session opens on an IDR. Gives the encode-stall watchdog a
/// healing lever on NVENC instead of ending the session. Caveat: the SYNC retrieve mode blocks
/// inside `lock_bitstream`, so a driver wedge that hangs the lock never returns to the loop
/// for the watchdog to fire — this lever fully protects the async retrieve mode (5 s event
/// timeouts surface as poll errors) and the submit-side failure paths.
fn reset(&mut self) -> bool {
// SAFETY: `teardown` (an `unsafe fn`) 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, like every other method),
// 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 NvencD3d11Encoder {
fn drop(&mut self) {
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
// pending was created against that live session. It runs exactly once (here).
unsafe { self.teardown() };
}
}
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
pub fn probe_can_encode_444(codec: Codec) -> bool {
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
};
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if codec != Codec::H265 {
return false;
}
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
if try_api().is_err() {
return false;
}
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
// against that device's raw pointer (valid while `device` is held) or errors (→ false, tearing
// nothing down). `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
// wrappers. No handle escapes this call and nothing runs concurrently.
unsafe {
// Probe on the SELECTED render adapter — the GPU the session will actually encode on
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
// (NULL) can be the *other* GPU on a hybrid box, answering for hardware we won't use.
let adapter: Option<IDXGIAdapter1> = crate::win_adapter::resolve_render_adapter_luid()
.and_then(|luid| {
let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
factory.EnumAdapterByLuid(luid).ok()
});
let mut device: Option<ID3D11Device> = None;
let created = match &adapter {
Some(a) => D3D11CreateDevice(
a,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
),
None => D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
),
};
if created.is_err() {
return false;
}
let Some(device) = device else { return false };
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_DIRECTX,
device: device.as_raw(),
apiVersion: nv::NVENCAPI_VERSION,
..Default::default()
};
let mut enc: *mut c_void = ptr::null_mut();
if (api().open_encode_session_ex)(&mut params, &mut enc)
.nv_ok()
.is_err()
{
return false;
}
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
reserved: [0; 62],
};
let mut val: i32 = 0;
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
let _ = (api().destroy_encoder)(enc);
ok
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
use windows::Win32::Graphics::Direct3D11::{
D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
};
/// The 8 fully-saturated colour bars the matrix analysis samples (RGB). Saturated primaries
/// separate BT.601 from BT.709 by tens of code points (e.g. pure-green luma 145 vs 173).
const BARS: [(u8, u8, u8); 8] = [
(255, 255, 255), // white
(255, 255, 0), // yellow
(0, 255, 255), // cyan
(0, 255, 0), // green
(255, 0, 255), // magenta
(255, 0, 0), // red
(0, 0, 255), // blue
(0, 0, 0), // black
];
/// BGRA probe pattern: left half = the 8 colour bars (flat patches → matrix measurement),
/// right half = alternating 1-px red/blue columns (the chroma-resolution litmus: true 4:4:4
/// keeps adjacent columns' chroma distinct; an internally-subsampled encode blends them).
fn probe_pattern(w: usize, h: usize) -> Vec<u8> {
let mut px = vec![0u8; w * h * 4];
let bar_w = (w / 2) / BARS.len();
for y in 0..h {
for x in 0..w {
let (r, g, b) = if x < w / 2 {
BARS[(x / bar_w).min(BARS.len() - 1)]
} else if x % 2 == 0 {
(255, 0, 0) // red column
} else {
(0, 0, 255) // blue column
};
let o = (y * w + x) * 4;
px[o] = b;
px[o + 1] = g;
px[o + 2] = r;
px[o + 3] = 255;
}
}
px
}
/// Encode 30 static pattern frames through the real NVENC session (ARGB input, the exact
/// production configuration) at the given chroma and write the Annex-B stream to `path`.
fn encode_pattern(chroma: ChromaFormat, path: &str) {
const W: u32 = 1280;
const H: u32 = 720;
// SAFETY: (test-only) straight-line D3D11/DXGI COM calls on one thread; every out-pointer
// is checked before use; the texture/device outlive the encoder (dropped at scope end).
unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
let mut adapter = None;
for i in 0.. {
let Ok(a) = factory.EnumAdapters1(i) else {
break;
};
let desc = a.GetDesc1().expect("adapter desc");
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
adapter = Some(a);
break;
}
}
let adapter = adapter.expect("no hardware DXGI adapter");
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
let bytes = probe_pattern(W as usize, H as usize);
let init = D3D11_SUBRESOURCE_DATA {
pSysMem: bytes.as_ptr() as *const _,
SysMemPitch: W * 4,
SysMemSlicePitch: 0,
};
let desc = D3D11_TEXTURE2D_DESC {
Width: W,
Height: H,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
// NVENC registration requires RENDER_TARGET on D3D11 input textures.
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
MiscFlags: 0,
};
let mut tex = None;
device
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
.expect("pattern texture");
let tex = tex.expect("null pattern texture");
let mut enc = NvencD3d11Encoder::open(
Codec::H265,
PixelFormat::Bgra,
W,
H,
60,
100_000_000, // high rate: the 1-px stripes must survive quantization
8,
chroma,
)
.expect("NVENC open");
let mut out = Vec::new();
for i in 0..30u64 {
let frame = CapturedFrame {
width: W,
height: H,
pts_ns: i * 16_666_667,
format: PixelFormat::Bgra,
payload: FramePayload::D3d11(D3d11Frame {
texture: tex.clone(),
device: device.clone(),
}),
};
enc.submit(&frame).expect("submit");
while let Some(au) = enc.poll().expect("poll") {
out.extend_from_slice(&au.data);
}
}
enc.flush().ok();
while let Ok(Some(au)) = enc.poll() {
out.extend_from_slice(&au.data);
}
assert!(!out.is_empty(), "no AUs produced");
let caps444 = enc.caps().chroma_444;
std::fs::write(path, &out).expect("write bitstream");
println!(
"wrote {path}: {} bytes, requested {chroma:?}, caps.chroma_444={caps444}",
out.len()
);
}
}
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
/// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT
/// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
/// the FREXT stream is truly full-chroma and (2) which matrix NVENC's internal RGB→YUV CSC
/// used (BT.601 vs BT.709 — saturated bars differ by tens of code points). Run with:
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_444_on_glass --nocapture
#[test]
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box"]
fn nvenc_444_on_glass_probe() {
encode_pattern(
ChromaFormat::Yuv444,
"C:\\Users\\Public\\nvenc444_probe.h265",
);
encode_pattern(
ChromaFormat::Yuv420,
"C:\\Users\\Public\\nvenc420_probe.h265",
);
}
}