On a hybrid box, picking a GPU in the web console whose vendor contradicts a host.env PUNKTFUNK_ENCODER pin produced an unrecoverable session: the pin won backend selection while the adapter followed the console, the wrong-vendor encoder failed deterministically at submit, the reset ladder burned its 5 in-place rebuilds on it, and the client reconnected into the identical wall forever (~10 s per cycle, no visible reason). Three legs: - windows_resolved_backend() now reconciles: a hardware pin whose vendor contradicts the selected GPU is overridden by the adapter-derived backend (capture + encode share one adapter, so honoring the pin can only fail); open_video warns loudly when a pin loses. The reconciliation is a pure, unit-tested table (resolve_windows_backend). - the QSV wrong-adapter bind is typed TerminalEncoderError, and the stream loop's reset ladder ends the session immediately on it instead of feeding a deterministic config error 5 futile rebuilds. - the un-pinned path was already correct (verified on-glass: NVIDIA preference + no pin = stable AV1 10-bit HDR on NVENC), so the override simply takes the conflict case onto that path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2211 lines
106 KiB
Rust
2211 lines
106 KiB
Rust
//! Hardware video encode (plan §7). Binds FFmpeg; never rewrites codecs. Low-latency preset,
|
||
//! B-frames off. The backend is per-GPU: NVENC on NVIDIA (`*_nvenc`, accepts `bgr0` and does
|
||
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
||
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`]. Extracted into a subsystem crate
|
||
//! (plan §W6): depends on the shared frame vocabulary (`pf-frame`) + zero-copy plumbing
|
||
//! (`pf-zerocopy`), never on capture — the capture→encode edge is one-way.
|
||
// NOTE: no crate-wide `#![allow(dead_code)]`. It was inherited from the pre-extraction host crate
|
||
// root as scaffolding for backend paths defined ahead of the build that used them, but a census
|
||
// across every feature combination on both platforms found it was hiding exactly two items — so it
|
||
// bought nothing and blinded the crate to future rot. Genuinely test-only helpers carry
|
||
// `#[cfg(test)]` instead.
|
||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||
// program). As a parent module this also covers the child modules (windows/linux backends).
|
||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||
|
||
use anyhow::Result;
|
||
use pf_frame::{CapturedFrame, PixelFormat};
|
||
|
||
#[path = "enc/codec.rs"]
|
||
mod codec;
|
||
pub use codec::*;
|
||
|
||
impl Codec {
|
||
/// The `quic` codec bitfield the host can currently **emit** on the punktfunk/1 native path,
|
||
/// given the resolved encode backend — the same GPU-aware advertisement GameStream builds for
|
||
/// Moonlight (the host `gamestream::serverinfo`), in `quic::CODEC_*` bits. The GPU-less software
|
||
/// encoder (openh264) produces H.264 only; the probed backends (Linux VAAPI, Windows AMF/QSV)
|
||
/// advertise exactly what the GPU encodes ([`vaapi_codec_support`] / [`windows_codec_support`] —
|
||
/// AV1 encode is narrow, an old iGPU might lack HEVC); NVENC keeps the Moonlight-validated
|
||
/// static superset. An empty probe means the GPU wasn't usable at probe time (GPU-less CI,
|
||
/// wrong-vendor pref), not that it encodes nothing — fall back to the superset so `resolve_codec`
|
||
/// still lands on HEVC for an auto client, exactly the pre-probe behaviour. Fed to
|
||
/// [`punktfunk_core::quic::resolve_codec`] against the client's advertised codecs.
|
||
pub fn host_wire_caps() -> u8 {
|
||
// PyroWave rides ON TOP of whatever H.26x set resolves below: feature-gated, Linux-only
|
||
// for now (the Windows host encoder is future work), and inert in negotiation unless the
|
||
// client explicitly prefers it (resolve_codec ignores the bit in its ladder). Advertised
|
||
// whenever the backend could open: AMD/Intel capture hands raw dmabufs it imports
|
||
// directly, and an NVIDIA-auto host's PyroWave sessions flip capture to CPU RGB
|
||
// per-session instead (the host `session_plan::SessionPlan::output_format`) — the EGL→CUDA
|
||
// frames the `auto` GPU path would deliver are NVENC-only. Only a software/GPU-less pref
|
||
// keeps the bit off (no Vulkan device to open).
|
||
// Resolved ONCE for every gate below (pyro bit, software pin, vulkan ceiling, the
|
||
// zero-copy plane): this fn backs the codec advertisement on polled paths, and the
|
||
// resolver's auto arm samples live GPU-preference state — four samples per call was the
|
||
// probe-amplification the WP7.6 diff critic caught.
|
||
#[cfg(target_os = "linux")]
|
||
let backend = linux_resolved_backend();
|
||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||
let pyro = if backend != LinuxBackend::Software {
|
||
punktfunk_core::quic::CODEC_PYROWAVE
|
||
} else {
|
||
0u8
|
||
};
|
||
// Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/
|
||
// QSV) — it opens its OWN Vulkan device by the render GPU's vendor/device-id and
|
||
// zero-copy-imports the capturer's NV12 D3D11 texture, so the H.26x backend is irrelevant to
|
||
// it. Only a software/GPU-less host keeps the bit off (no Vulkan GPU to open). Whether the
|
||
// Session-0 external-memory import actually works is confirmed at encoder open
|
||
// (`pyrowave_device_confirm_interop_support`); a failed open renegotiates to HEVC.
|
||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||
let pyro = if windows_resolved_backend() != WindowsBackend::Software {
|
||
punktfunk_core::quic::CODEC_PYROWAVE
|
||
} else {
|
||
0u8
|
||
};
|
||
#[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))]
|
||
let pyro = 0u8;
|
||
let base = (|| {
|
||
/// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream
|
||
/// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends.
|
||
const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264
|
||
| punktfunk_core::quic::CODEC_HEVC
|
||
| punktfunk_core::quic::CODEC_AV1;
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
if backend == LinuxBackend::Software {
|
||
return punktfunk_core::quic::CODEC_H264;
|
||
}
|
||
// A pref that FORCES the raw Vulkan Video backend can only ever serve what that
|
||
// backend encodes: `open_video_backend`'s `vulkan` arm bails outright for anything
|
||
// that is not HEVC/AV1 ("the Vulkan Video encoder supports HEVC + AV1; the session
|
||
// negotiated {codec:?}"). Advertising H.264 there let a client negotiate it and die
|
||
// at encoder open. Without the `vulkan-encode` feature that arm cannot open at all,
|
||
// so the pref advertises nothing.
|
||
//
|
||
// This is a CEILING intersected with the device probe below, never a replacement
|
||
// for it: pinning a static HEVC|AV1 would ADD AV1 on the AMD/Intel hosts whose probe
|
||
// currently withholds it (pre-RDNA3, pre-Arc), i.e. it would re-create this very bug
|
||
// for a different codec. Intersecting can only narrow.
|
||
let pref_ceiling: u8 = match backend {
|
||
// Feature-blindness rider (WP7.6 critics): the resolver knows the PREF is
|
||
// vulkan; only this cfg! knows the BUILD can open it. Dropping the inner
|
||
// branch would advertise HEVC|AV1 on a featureless build — the
|
||
// advertise-then-die-at-open bug this ceiling exists to prevent.
|
||
LinuxBackend::Vulkan => {
|
||
if cfg!(feature = "vulkan-encode") {
|
||
punktfunk_core::quic::CODEC_HEVC | punktfunk_core::quic::CODEC_AV1
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
_ => GPU_SUPERSET,
|
||
};
|
||
if linux_zero_copy_is_vaapi_for(backend) {
|
||
if let Some(m) = vaapi_codec_support().wire_mask() {
|
||
return m & pref_ceiling;
|
||
}
|
||
}
|
||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||
GPU_SUPERSET & pref_ceiling
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
if windows_resolved_backend() == WindowsBackend::Software {
|
||
return punktfunk_core::quic::CODEC_H264;
|
||
}
|
||
if windows_backend_is_probed() {
|
||
if let Some(m) = windows_codec_support().wire_mask() {
|
||
return m;
|
||
}
|
||
}
|
||
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
|
||
GPU_SUPERSET
|
||
}
|
||
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
|
||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||
{
|
||
let _ = GPU_SUPERSET;
|
||
match pf_host_config::config().encoder_pref.as_str() {
|
||
"software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264,
|
||
_ => punktfunk_core::quic::CODEC_HEVC,
|
||
}
|
||
}
|
||
})();
|
||
base | pyro
|
||
}
|
||
}
|
||
|
||
/// Open a hardware video encoder for frames of the given `format` and mode, selecting the GPU
|
||
/// backend for this host: **NVENC** on NVIDIA (Linux/Windows), **VAAPI** on AMD/Intel (Linux).
|
||
/// When `cuda` is true the encoder takes GPU frames (`AV_PIX_FMT_CUDA`) from the NVIDIA zero-copy
|
||
/// path; otherwise it takes packed RGB/BGR CPU frames (and, on VAAPI, a future dmabuf payload).
|
||
/// `format`/`bitrate_bps`/`codec`/mode come from session negotiation; the caller derives `cuda`
|
||
/// from the first captured frame's payload. The Linux backend is auto-detected (override:
|
||
/// `PUNKTFUNK_ENCODER=auto|nvenc|vaapi`).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn open_video(
|
||
codec: Codec,
|
||
format: PixelFormat,
|
||
width: u32,
|
||
height: u32,
|
||
fps: u32,
|
||
bitrate_bps: u64,
|
||
cuda: bool,
|
||
bit_depth: u8,
|
||
chroma: ChromaFormat,
|
||
// The session may hand this encoder cursor bitmaps to composite (cursor-as-metadata
|
||
// captures). Backends whose fast path can't blend (Vulkan EFC RGB-direct) key off it.
|
||
cursor_blend: bool,
|
||
) -> Result<Box<dyn Encoder>> {
|
||
let (inner, backend) = open_video_backend(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cuda,
|
||
bit_depth,
|
||
chroma,
|
||
cursor_blend,
|
||
)?;
|
||
// Record what this session encodes on (the mgmt API's "currently used GPU"): the backend label
|
||
// is reported by `open_video_backend` from the branch that ACTUALLY opened — not re-derived by
|
||
// mirroring its dispatch, which went stale the moment a backend gained an internal fallback
|
||
// (the default-on Vulkan Video path falls back to VAAPI on a failed open, and a dispatch
|
||
// mirror would report "vaapi" for every Vulkan session or vice versa). The GPU identity is the
|
||
// same selection the capturer was created on ([`pf_gpu::selected_gpu`]). Dropping the
|
||
// returned encoder ends the record, so the live count is correct by construction.
|
||
let gpu = if backend == "software" {
|
||
pf_gpu::ActiveGpu {
|
||
id: String::new(),
|
||
name: "CPU (openh264)".into(),
|
||
vendor_id: 0,
|
||
backend,
|
||
}
|
||
} else {
|
||
match pf_gpu::selected_gpu() {
|
||
Some(sel) => pf_gpu::ActiveGpu {
|
||
id: sel.info.id,
|
||
name: sel.info.name,
|
||
vendor_id: sel.info.vendor_id,
|
||
backend,
|
||
},
|
||
None => pf_gpu::ActiveGpu {
|
||
id: String::new(),
|
||
name: "GPU".into(),
|
||
vendor_id: 0,
|
||
backend,
|
||
},
|
||
}
|
||
};
|
||
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||
// cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
|
||
// the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
|
||
// shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the
|
||
// resolved backend can't blend. What remains reachable is the open-time divergence the plan
|
||
// cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
|
||
// gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
|
||
// H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
|
||
// honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all
|
||
// it does.
|
||
if cursor_blend && !inner.caps().blends_cursor {
|
||
tracing::warn!(
|
||
backend,
|
||
"session negotiated a composited cursor but this encode backend does not blend \
|
||
CapturedFrame::cursor — the pointer will be MISSING from the stream unless the \
|
||
capturer composites it"
|
||
);
|
||
}
|
||
Ok(Box::new(TrackedEncoder {
|
||
inner,
|
||
_session: pf_gpu::session_begin(gpu),
|
||
}))
|
||
}
|
||
|
||
/// Ties the `pf_gpu` live-session record to the encoder's lifetime; pure delegation
|
||
/// otherwise.
|
||
struct TrackedEncoder {
|
||
inner: Box<dyn Encoder>,
|
||
_session: pf_gpu::ActiveSession,
|
||
}
|
||
|
||
impl Encoder for TrackedEncoder {
|
||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||
self.inner.submit(frame)
|
||
}
|
||
fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
|
||
self.inner.submit_indexed(frame, wire_index)
|
||
}
|
||
fn caps(&self) -> EncoderCaps {
|
||
self.inner.caps()
|
||
}
|
||
fn request_keyframe(&mut self) {
|
||
self.inner.request_keyframe()
|
||
}
|
||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||
self.inner.set_hdr_meta(meta)
|
||
}
|
||
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
|
||
self.inner.invalidate_ref_frames(first_frame, last_frame)
|
||
}
|
||
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
|
||
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
|
||
// escalation for every session, since the host loop only ever holds the wrapped box.
|
||
fn set_pipelined(&mut self, on: bool) -> bool {
|
||
self.inner.set_pipelined(on)
|
||
}
|
||
// The classic TrackedEncoder trap: a defaulted trait method that isn't forwarded
|
||
// silently no-ops through the wrapper (bit the direct-NVENC work, then THIS — the
|
||
// §4.4 chunking probe run hit the default while the plan said Some(1408)).
|
||
fn set_wire_chunking(&mut self, shard_payload: usize) {
|
||
self.inner.set_wire_chunking(shard_payload)
|
||
}
|
||
// Forwarded for the same reason as `set_wire_chunking` above — an unforwarded default here
|
||
// would silently leave the in-place backends pipelining past the capturer's ring.
|
||
fn set_input_ring_depth(&mut self, depth: usize) {
|
||
self.inner.set_input_ring_depth(depth)
|
||
}
|
||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||
self.inner.poll()
|
||
}
|
||
// Both chunked-poll methods forwarded (the same trap class): the defaults would report
|
||
// "not chunked" and wrap whole AUs, silently discarding the sub-frame overlap.
|
||
fn supports_chunked_poll(&self) -> bool {
|
||
self.inner.supports_chunked_poll()
|
||
}
|
||
fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
|
||
self.inner.poll_chunk()
|
||
}
|
||
fn reset(&mut self) -> bool {
|
||
self.inner.reset()
|
||
}
|
||
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
||
self.inner.reconfigure_bitrate(bps)
|
||
}
|
||
// Forwarded (the same trap class as `set_wire_chunking`): the unforwarded default `None`
|
||
// would tell the session loop "no encoder truth here" and it would keep pacing/acking the
|
||
// requested rate even where NVENC clamped to the codec-level ceiling.
|
||
fn applied_bitrate_bps(&self) -> Option<u64> {
|
||
self.inner.applied_bitrate_bps()
|
||
}
|
||
fn flush(&mut self) -> Result<()> {
|
||
self.inner.flush()
|
||
}
|
||
}
|
||
|
||
/// Ceiling applied to the negotiated bitrate before it reaches openh264: software H.264 realistically
|
||
/// caps far below the rates a hardware session negotiates, and handing it the full figure just
|
||
/// misconfigures its rate control. Module-scope so BOTH software arms share one value — the Linux
|
||
/// arm was missing the clamp the Windows arm applied.
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
||
|
||
/// The Linux half of [`open_video_backend`], with the pref INJECTED — the seam exists so the
|
||
/// dispatch is testable without mutating process env (`set_var` races `getenv` in parallel
|
||
/// tests) or fighting the once-latched `pf_host_config::config()`. Production takes the
|
||
/// config pref via the caller; shared validation (dims/fps/chroma degrade) stays there too.
|
||
#[cfg(target_os = "linux")]
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn open_video_backend_linux(
|
||
pref: &str,
|
||
codec: Codec,
|
||
format: PixelFormat,
|
||
width: u32,
|
||
height: u32,
|
||
fps: u32,
|
||
bitrate_bps: u64,
|
||
cuda: bool,
|
||
bit_depth: u8,
|
||
chroma: ChromaFormat,
|
||
cursor_blend: bool,
|
||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||
// A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes
|
||
// straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override.
|
||
if codec == Codec::PyroWave {
|
||
#[cfg(feature = "pyrowave")]
|
||
{
|
||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||
}
|
||
#[cfg(not(feature = "pyrowave"))]
|
||
anyhow::bail!(
|
||
"session negotiated PyroWave but this host was built without --features \
|
||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||
);
|
||
}
|
||
// Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged);
|
||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||
// its errors crisply instead of silently trying the other).
|
||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||
#[cfg(feature = "vulkan-encode")]
|
||
if matches!(codec, Codec::H265 | Codec::Av1)
|
||
&& vulkan_encode_enabled()
|
||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||
{
|
||
match vulkan_video::VulkanVideoEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cursor_blend,
|
||
) {
|
||
Ok(e) => {
|
||
tracing::info!(
|
||
codec = ?codec,
|
||
"Linux Vulkan Video encode (real RFI via DPB reference slots) — \
|
||
set PUNKTFUNK_VULKAN_ENCODE=0 for libav VAAPI"
|
||
);
|
||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||
}
|
||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
||
Err(e) if format == PixelFormat::Nv12 => {
|
||
return Err(e.context(
|
||
"Vulkan Video open failed on a native-NV12 capture \
|
||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||
restore the packed-RGB negotiation",
|
||
));
|
||
}
|
||
Err(e) => tracing::warn!(
|
||
error = %format!("{e:#}"),
|
||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||
),
|
||
}
|
||
}
|
||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||
if format == PixelFormat::Nv12 {
|
||
anyhow::bail!(
|
||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||
the packed-RGB negotiation"
|
||
);
|
||
}
|
||
vaapi::VaapiEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
bit_depth,
|
||
chroma,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vaapi"))
|
||
};
|
||
let open_nvidia = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||
open_nvenc_probed(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cuda,
|
||
bit_depth,
|
||
chroma,
|
||
cursor_blend,
|
||
)
|
||
.map(|e| (e, "nvenc"))
|
||
};
|
||
// WP7.6: the dispatch consumes the SAME resolver the capability mirrors consult (the
|
||
// Windows shape — `windows_resolved_backend` + `match backend` below) so the alias table
|
||
// exists once. Arm bodies and their open-site labels are untouched.
|
||
match resolve_linux_backend(pref, linux_auto_is_vaapi, cuda) {
|
||
Some(LinuxBackend::Nvenc) => open_nvidia(),
|
||
Some(LinuxBackend::AmdIntel) => open_amd_intel(),
|
||
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
||
Some(LinuxBackend::Vulkan) => {
|
||
#[cfg(feature = "vulkan-encode")]
|
||
{
|
||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||
anyhow::bail!(
|
||
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
|
||
);
|
||
}
|
||
vulkan_video::VulkanVideoEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cursor_blend,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "vulkan"))
|
||
}
|
||
#[cfg(not(feature = "vulkan-encode"))]
|
||
{
|
||
let _ = (format, bit_depth, chroma);
|
||
anyhow::bail!(
|
||
"PUNKTFUNK_ENCODER=vulkan requires a build with --features vulkan-encode"
|
||
)
|
||
}
|
||
}
|
||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec. Explicit-only, and
|
||
// EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2): no shipping
|
||
// client can decode the stream yet, so this arm exists for host-side bring-up and
|
||
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
|
||
// codec — every AU is an independently-decodable wavelet frame.
|
||
Some(LinuxBackend::Pyrowave) => {
|
||
#[cfg(feature = "pyrowave")]
|
||
{
|
||
tracing::warn!(
|
||
?codec,
|
||
"PUNKTFUNK_ENCODER=pyrowave forces the all-intra wavelet stream \
|
||
regardless of the negotiated codec — only a pyrowave-feature client \
|
||
that ALSO preferred CODEC_PYROWAVE can display it (lab override; \
|
||
normal sessions negotiate it instead)"
|
||
);
|
||
// The lab override forces the wavelet stream onto a session negotiated for
|
||
// another codec — that session's chroma may be HEVC-4:4:4, which the
|
||
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
|
||
pyrowave::PyroWaveEncoder::open(
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
ChromaFormat::Yuv420,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||
}
|
||
#[cfg(not(feature = "pyrowave"))]
|
||
{
|
||
anyhow::bail!(
|
||
"PUNKTFUNK_ENCODER=pyrowave requires a build with --features punktfunk-host/pyrowave"
|
||
)
|
||
}
|
||
}
|
||
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
|
||
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
|
||
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
|
||
// RGB frame, which the capturer delivers because the software backend resolves `gpu=false`.
|
||
Some(LinuxBackend::Software) => {
|
||
if codec != Codec::H264 {
|
||
anyhow::bail!(
|
||
"the software encoder emits H.264 only; the session negotiated {codec:?} \
|
||
(a client must advertise CODEC_H264 to reach a software host)"
|
||
);
|
||
}
|
||
let _ = (cuda, bit_depth); // software path is CPU + 8-bit only
|
||
sw::OpenH264Encoder::open(
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||
}
|
||
// The auto arm lives in the resolver now (a CUDA frame can only be consumed by
|
||
// NVENC; else the shared manual-preference/NVIDIA-presence decision).
|
||
None => anyhow::bail!(
|
||
"unknown PUNKTFUNK_ENCODER={pref:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Open the platform encoder backend. Returns the encoder together with the display label of the
|
||
/// branch that ACTUALLY opened (`nvenc`/`vaapi`/`vulkan`/`amf`/`qsv`/`software`) — the label feeds
|
||
/// the mgmt API's live-session record, and only the open site knows which internal fallback won
|
||
/// (e.g. Vulkan Video falling back to VAAPI).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn open_video_backend(
|
||
codec: Codec,
|
||
format: PixelFormat,
|
||
width: u32,
|
||
height: u32,
|
||
fps: u32,
|
||
bitrate_bps: u64,
|
||
cuda: bool,
|
||
bit_depth: u8,
|
||
chroma: ChromaFormat,
|
||
cursor_blend: bool,
|
||
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
||
let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
|
||
validate_dimensions(codec, width, height)?;
|
||
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
|
||
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
|
||
// The mid-stream Reconfigure path already guards `refresh_hz > 0`; enforcing it at this single
|
||
// open chokepoint makes EVERY path (initial Hello, GameStream ANNOUNCE, Reconfigure) safe
|
||
// regardless of which backend opens (security-review 2026-06-28 S5).
|
||
if fps == 0 || fps > 1000 {
|
||
anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz");
|
||
}
|
||
// 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another
|
||
// codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future
|
||
// caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request
|
||
// degrades to 4:2:0 with a warning.
|
||
let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave {
|
||
tracing::warn!(
|
||
?codec,
|
||
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
||
);
|
||
ChromaFormat::Yuv420
|
||
} else {
|
||
chroma
|
||
};
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
open_video_backend_linux(
|
||
pf_host_config::config().encoder_pref.as_str(),
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cuda,
|
||
bit_depth,
|
||
chroma,
|
||
cursor_blend,
|
||
)
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the
|
||
// NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST,
|
||
// like the Linux branch. It opens its own Vulkan device by the render GPU's vendor/device-id
|
||
// and imports the capturer's shared NV12 texture; the H.26x backend selection below is moot.
|
||
if codec == Codec::PyroWave {
|
||
#[cfg(feature = "pyrowave")]
|
||
{
|
||
let _ = (format, cuda);
|
||
return pyrowave::PyroWaveEncoder::open(
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
chroma,
|
||
bit_depth,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||
}
|
||
#[cfg(not(feature = "pyrowave"))]
|
||
anyhow::bail!(
|
||
"session negotiated PyroWave but this host was built without --features \
|
||
punktfunk-host/pyrowave (the advertisement bit should not have been set)"
|
||
);
|
||
}
|
||
let _ = cuda; // always false on Windows (no Cuda payload)
|
||
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
|
||
// H.264. `auto` (the default) resolves from the selected render adapter's vendor.
|
||
let backend = windows_resolved_backend();
|
||
// An explicit PUNKTFUNK_ENCODER pin contradicting the selected GPU's vendor was just
|
||
// overridden by the adapter-derived backend (`resolve_windows_backend`) — honoring it
|
||
// could only fail, and used to feed the reset ladder five futile rebuilds per connect
|
||
// (an unrecoverable session + client reconnect loop on hybrid boxes). Warn per session
|
||
// open so the stale pin gets removed from host.env.
|
||
if windows_pinned_backend().is_some_and(|pin| pin != backend) {
|
||
tracing::warn!(
|
||
adapter = pf_gpu::selected_gpu()
|
||
.map(|s| s.info.name)
|
||
.as_deref()
|
||
.unwrap_or("?"),
|
||
pinned = %pf_host_config::config().encoder_pref,
|
||
using = ?backend,
|
||
"explicit PUNKTFUNK_ENCODER pin does not match the selected GPU's vendor — the \
|
||
pin is overridden (remove it from host.env, or point the GPU preference at the \
|
||
pinned vendor's adapter)"
|
||
);
|
||
}
|
||
match backend {
|
||
WindowsBackend::Nvenc => {
|
||
// Hardware path: NVENC over D3D11. The DXGI capturer switches to its zero-copy
|
||
// FramePayload::D3d11 output under the same env var so capture + encode share textures.
|
||
#[cfg(feature = "nvenc")]
|
||
{
|
||
nvenc::NvencD3d11Encoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
bit_depth,
|
||
chroma,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "nvenc"))
|
||
}
|
||
#[cfg(not(feature = "nvenc"))]
|
||
{
|
||
anyhow::bail!(
|
||
"NVENC requested/detected but this host was built without it — rebuild \
|
||
with `--features nvenc`"
|
||
)
|
||
}
|
||
}
|
||
WindowsBackend::Amf => {
|
||
// AMD: the native AMF SDK encoder, unconditionally (design/native-amf-encoder.md
|
||
// Phase 3). The libavcodec AMF fallback and the `PUNKTFUNK_AMF_FFMPEG` hatch were
|
||
// removed once the native path was validated — two permanently-maintained AMF
|
||
// paths double the driver-matrix burden, and the one kept "for safety" is exactly
|
||
// the one with the wedge/latency pathology. No build feature: amfrt64.dll resolves
|
||
// at runtime like NVENC's DLL. A missing/ancient runtime fails HERE with the
|
||
// "install/update the AMD driver" message `AmfEncoder::open` raises (§6), rather
|
||
// than silently degrading — FFmpeg now serves QSV only.
|
||
amf::AmfEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
bit_depth,
|
||
chroma,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "amf"))
|
||
.map_err(|e| {
|
||
e.context(
|
||
"native AMF encode failed to open (update the AMD driver / amfrt64.dll \
|
||
runtime)",
|
||
)
|
||
})
|
||
}
|
||
WindowsBackend::Qsv => {
|
||
// Intel: native VPL first (design/native-qsv-encoder.md — supersedes the
|
||
// native-amf-encoder.md §2 "QSV stays on FFmpeg" decision). During bring-up the
|
||
// ffmpeg path remains as an automatic open-failure fallback plus the explicit
|
||
// `PUNKTFUNK_QSV_FFMPEG=1` escape hatch (the AMF §7 pattern); both go away in
|
||
// Phase 4 after the field-silence gate.
|
||
#[cfg(feature = "qsv")]
|
||
{
|
||
// Trimmed like every sibling knob, so `"1 "` from a shell script or a `.env`
|
||
// line takes effect instead of silently doing nothing. Case-insensitivity is
|
||
// kept deliberately — this knob already accepted `TRUE` via
|
||
// `eq_ignore_ascii_case`, and the bare house `matches!` would have dropped
|
||
// that spelling; widening to `yes`/`on` only adds accepted values.
|
||
let ffmpeg_forced = std::env::var("PUNKTFUNK_QSV_FFMPEG").is_ok_and(|v| {
|
||
matches!(
|
||
v.trim().to_ascii_lowercase().as_str(),
|
||
"1" | "true" | "yes" | "on"
|
||
)
|
||
});
|
||
if !ffmpeg_forced {
|
||
match qsv::QsvEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
bit_depth,
|
||
chroma,
|
||
) {
|
||
Ok(e) => return Ok((Box::new(e) as Box<dyn Encoder>, "qsv")),
|
||
Err(e) => {
|
||
#[cfg(feature = "amf-qsv")]
|
||
tracing::warn!(
|
||
error = %format!("{e:#}"),
|
||
"native QSV open failed — falling back to the ffmpeg QSV path"
|
||
);
|
||
#[cfg(not(feature = "amf-qsv"))]
|
||
return Err(e.context(
|
||
"native QSV encode failed to open (update the Intel driver / \
|
||
no VPL runtime on this box)",
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
tracing::warn!(
|
||
"PUNKTFUNK_QSV_FFMPEG=1 — skipping native QSV (bring-up escape hatch)"
|
||
);
|
||
}
|
||
}
|
||
// The libavcodec path: the pre-native default, the native open-failure fallback,
|
||
// and the `PUNKTFUNK_QSV_FFMPEG` hatch (async_depth=1 + low_power VDEnc).
|
||
#[cfg(feature = "amf-qsv")]
|
||
{
|
||
ffmpeg_win::FfmpegWinEncoder::open(
|
||
ffmpeg_win::WinVendor::Qsv,
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
bit_depth,
|
||
chroma,
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "qsv"))
|
||
}
|
||
#[cfg(all(not(feature = "amf-qsv"), not(feature = "qsv")))]
|
||
{
|
||
anyhow::bail!(
|
||
"Intel (QSV) encode requested/detected but this host was built without \
|
||
it — rebuild with `--features qsv` (native VPL) or `--features amf-qsv` \
|
||
(libavcodec)"
|
||
)
|
||
}
|
||
#[cfg(all(not(feature = "amf-qsv"), feature = "qsv"))]
|
||
{
|
||
anyhow::bail!(
|
||
"native QSV was skipped via PUNKTFUNK_QSV_FFMPEG but this host was built \
|
||
without the ffmpeg fallback (`amf-qsv`)"
|
||
)
|
||
}
|
||
}
|
||
WindowsBackend::Software => {
|
||
anyhow::ensure!(
|
||
codec == Codec::H264,
|
||
"the Windows software encoder supports H.264 only; client negotiated {codec:?} \
|
||
(build a GPU backend: --features nvenc or amf-qsv, or request H264)"
|
||
);
|
||
let _ = (bit_depth, chroma); // the software H.264 path is 8-bit 4:2:0 only
|
||
sw::OpenH264Encoder::open(
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps.min(SW_BITRATE_CEIL),
|
||
)
|
||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
||
}
|
||
}
|
||
}
|
||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||
{
|
||
let _ = (
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cuda,
|
||
bit_depth,
|
||
chroma,
|
||
);
|
||
anyhow::bail!("video encode requires Linux or Windows")
|
||
}
|
||
}
|
||
|
||
/// Open NVENC, probing this GPU's real max bitrate. NVENC rejects `avcodec_open2` with EINVAL
|
||
/// when the bitrate exceeds what any codec level can express, and that ceiling is
|
||
/// GPU/driver-specific (an RTX 4090 caps HEVC at ~800 Mbps; an RTX 5070 Ti accepts >1 Gbps). So
|
||
/// open at the requested rate first and step down ONLY if this GPU refuses it — each GPU then
|
||
/// runs at its own actual maximum, and a capable card is never clamped to a conservative guess.
|
||
/// The codec's theoretical level ceiling is just the first step-down candidate, not a blind cap.
|
||
#[cfg(target_os = "linux")]
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn open_nvenc_probed(
|
||
codec: Codec,
|
||
format: PixelFormat,
|
||
width: u32,
|
||
height: u32,
|
||
fps: u32,
|
||
bitrate_bps: u64,
|
||
cuda: bool,
|
||
bit_depth: u8,
|
||
chroma: ChromaFormat,
|
||
cursor_blend: bool,
|
||
) -> Result<Box<dyn Encoder>> {
|
||
#[cfg(not(feature = "nvenc"))]
|
||
let _ = cursor_blend; // consumed by the direct-SDK arm below
|
||
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
|
||
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
|
||
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
|
||
// PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own
|
||
// level-ceiling binary search at session open), so it skips the probe-loop stepping below.
|
||
#[cfg(feature = "nvenc")]
|
||
if cuda && nvenc_direct_enabled() {
|
||
tracing::info!(
|
||
codec = codec.nvenc_name(),
|
||
"Linux direct-SDK NVENC (real RFI + recovery anchor) — set PUNKTFUNK_NVENC_DIRECT=0 for libav"
|
||
);
|
||
return Ok(Box::new(nvenc_cuda::NvencCudaEncoder::open(
|
||
codec,
|
||
format,
|
||
width,
|
||
height,
|
||
fps,
|
||
bitrate_bps,
|
||
cuda,
|
||
bit_depth,
|
||
chroma,
|
||
cursor_blend,
|
||
)?) as Box<dyn Encoder>);
|
||
}
|
||
// The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK
|
||
// path OUT, and a CUDA session quietly loses real RFI + the no-IDR bitrate reconfigure
|
||
// with nothing in the logs. This bit the Linux packagers once (fixed e89b2f60) and an
|
||
// ad-hoc host deploy again on 2026-07-14 — say it loudly instead. (Skipped when the
|
||
// operator explicitly chose libav via PUNKTFUNK_NVENC_DIRECT=0.)
|
||
#[cfg(not(feature = "nvenc"))]
|
||
if cuda
|
||
&& !std::env::var("PUNKTFUNK_NVENC_DIRECT")
|
||
.map(|v| matches!(v.trim(), "0" | "false" | "no" | "off"))
|
||
.unwrap_or(false)
|
||
{
|
||
// Once per process — featureless builds rebuild the encoder on every bitrate step,
|
||
// and one line is enough to diagnose the build.
|
||
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||
tracing::warn!(
|
||
"direct-SDK NVENC is NOT compiled into this build (`--features punktfunk-host/nvenc`) \
|
||
— CUDA frames take the libav path: no RFI loss recovery, and every adaptive-bitrate \
|
||
step costs an encoder rebuild + IDR"
|
||
);
|
||
}
|
||
}
|
||
const MIN_PROBE_BPS: u64 = 50_000_000;
|
||
let mut candidates = vec![bitrate_bps];
|
||
let cap = codec.max_bitrate_bps();
|
||
if cap < bitrate_bps {
|
||
candidates.push(cap);
|
||
}
|
||
let mut b = bitrate_bps.min(cap);
|
||
while b > MIN_PROBE_BPS {
|
||
b = b * 3 / 4;
|
||
candidates.push(b);
|
||
}
|
||
let mut last: Option<anyhow::Error> = None;
|
||
for (i, &b) in candidates.iter().enumerate() {
|
||
match linux::NvencEncoder::open(
|
||
codec, format, width, height, fps, b, cuda, bit_depth, chroma,
|
||
) {
|
||
Ok(enc) => {
|
||
if i > 0 {
|
||
tracing::warn!(
|
||
requested_mbps = bitrate_bps / 1_000_000,
|
||
opened_mbps = b / 1_000_000,
|
||
codec = codec.nvenc_name(),
|
||
"this GPU's NVENC refused the requested bitrate (EINVAL) — opened at the \
|
||
highest rate it accepts; request AV1 or a lower bitrate for more"
|
||
);
|
||
}
|
||
return Ok(Box::new(enc) as Box<dyn Encoder>);
|
||
}
|
||
// EINVAL = above this GPU's level ceiling → step down. Any other failure (no GPU,
|
||
// bad mode, OOM) is real — surface it rather than masking it with bitrate retries.
|
||
Err(e) if nvenc_open_einval(&e) => last = Some(e),
|
||
Err(e) => return Err(e),
|
||
}
|
||
}
|
||
Err(last.unwrap_or_else(|| anyhow::anyhow!("encoder open failed at every probed bitrate")))
|
||
}
|
||
|
||
/// Whether a libav NVENC open failed with EINVAL — the "bitrate above this GPU's level ceiling"
|
||
/// signal [`open_nvenc_probed`]'s ladder steps down on. Typed: the root `ffmpeg::Error` survives
|
||
/// the `anyhow` context chain, so match it there instead of substring-matching the English
|
||
/// strerror rendering of the whole chain — which also fired on any OTHER wrapped EINVAL (e.g. a
|
||
/// CUDA-context errno) and steered the ladder on failures that have nothing to do with bitrate.
|
||
#[cfg(target_os = "linux")]
|
||
fn nvenc_open_einval(e: &anyhow::Error) -> bool {
|
||
use ffmpeg_next as ffmpeg;
|
||
matches!(
|
||
e.downcast_ref::<ffmpeg::Error>(),
|
||
Some(ffmpeg::Error::Other {
|
||
errno: ffmpeg::util::error::EINVAL
|
||
})
|
||
)
|
||
}
|
||
|
||
/// Whether the direct-SDK NVENC path is active. **Default ON** — on-glass validated 2026-07-12:
|
||
/// real RFI landed 73/73 as clean P-frame recovery anchors (never IDR) on an RTX host with a real
|
||
/// Steam Deck client (design/linux-direct-nvenc.md §9). `PUNKTFUNK_NVENC_DIRECT=0` (also `false`/
|
||
/// `no`/`off`) is the libav escape hatch. Only consulted for a CUDA capture payload on an NVIDIA
|
||
/// host — the `cuda` gate in `open_nvenc_probed` keeps AMD/Intel on VAAPI regardless — and only
|
||
/// with `--features nvenc`.
|
||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||
fn nvenc_direct_enabled() -> bool {
|
||
std::env::var("PUNKTFUNK_NVENC_DIRECT")
|
||
.map(|v| !matches!(v.trim(), "0" | "false" | "no" | "off"))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// Whether the raw Vulkan Video HEVC encode backend is active for AMD/Intel. **Default ON** —
|
||
/// on-glass validated 2026-07-12 on an AMD RADV 780M with a real Deck-class client: the pipelined
|
||
/// encoder ran a rock-solid 1080p@240 HEVC session and healed loss with clean P-frame recovery
|
||
/// anchors (never IDR) via explicit DPB reference slots — real reference-frame invalidation the
|
||
/// libavcodec VAAPI path can't express (design/linux-vulkan-video-encode.md). `PUNKTFUNK_VULKAN_ENCODE=0`
|
||
/// (also `false`/`no`/`off`) is the libav-VAAPI escape hatch. Only consulted with
|
||
/// `--features vulkan-encode`, and a failed open falls back to VAAPI, so an unsupported device
|
||
/// (e.g. a Mesa without h265 encode, or an untested Intel/ANV where the path misbehaves at open)
|
||
/// degrades gracefully to the old backend rather than breaking the stream.
|
||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||
fn vulkan_encode_enabled() -> bool {
|
||
std::env::var("PUNKTFUNK_VULKAN_ENCODE")
|
||
.map(|v| !matches!(v.trim(), "0" | "false" | "no" | "off"))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
|
||
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
|
||
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
|
||
/// the backend must be eligible to open. The host facade threads the verdict into the capture
|
||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||
/// escapes at the capture gate).
|
||
///
|
||
/// This verdict is load-bearing in a way the other capability helpers are not: once the producer
|
||
/// has been asked for two-plane NV12 there is **no fallback**. [`open_video`] deliberately makes a
|
||
/// failed Vulkan open FATAL for an NV12 capture rather than degrading to libav VAAPI, because VAAPI
|
||
/// would import that buffer as packed RGB and stream silent garbage. So a wrong `true` here does
|
||
/// not cost quality — it kills the session at its first frame. Hence the real device probe below,
|
||
/// and hence the conjuncts are ordered cheapest-first so it only runs when everything else already
|
||
/// said yes.
|
||
#[cfg(target_os = "linux")]
|
||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||
#[cfg(feature = "vulkan-encode")]
|
||
{
|
||
matches!(codec, Codec::H265 | Codec::Av1)
|
||
&& vulkan_encode_enabled()
|
||
// Which backend this host actually resolves to. This used to be a denylist of the
|
||
// EXPLICIT prefs that skip Vulkan Video ("nvenc"|"nvidia"|"cuda"|"pyrowave"), which
|
||
// silently missed the one that matters: the DEFAULT `encoder_pref` is `""`, and `""`
|
||
// resolves to `auto`, which on an NVIDIA box opens NVENC. So a stock NVIDIA host passed
|
||
// this gate. `linux_zero_copy_is_vaapi` layers the pref on top of the same auto decision
|
||
// `open_video` makes, which is exactly what the note on `linux_auto_is_vaapi` says a
|
||
// capability probe must consult — and it is what the downstream consumer of this very
|
||
// verdict already uses to pick its zero-copy path.
|
||
&& linux_zero_copy_is_vaapi()
|
||
// …and only then ask the GPU. Ordered last on purpose: it opens a Vulkan instance.
|
||
&& vulkan_encode_available(codec)
|
||
}
|
||
#[cfg(not(feature = "vulkan-encode"))]
|
||
{
|
||
let _ = codec;
|
||
false
|
||
}
|
||
}
|
||
|
||
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||
/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture
|
||
/// (pointer-free frames + host composite on demand — the cursor channel's contract); for
|
||
/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of
|
||
/// [`linux_native_nv12_ok`], threaded into the same negotiation.
|
||
///
|
||
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
|
||
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
|
||
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
|
||
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
|
||
#[cfg(target_os = "linux")]
|
||
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
|
||
// A negotiated PyroWave session routes to that backend before the pref is consulted
|
||
// (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor.
|
||
if codec == Codec::PyroWave {
|
||
return true;
|
||
}
|
||
let direct_nvenc = {
|
||
#[cfg(feature = "nvenc")]
|
||
{
|
||
nvenc_direct_enabled()
|
||
}
|
||
#[cfg(not(feature = "nvenc"))]
|
||
{
|
||
false
|
||
}
|
||
};
|
||
let vulkan_csc = {
|
||
// The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`;
|
||
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
|
||
#[cfg(feature = "vulkan-encode")]
|
||
{
|
||
matches!(codec, Codec::H265 | Codec::Av1)
|
||
&& vulkan_encode_enabled()
|
||
&& vulkan_encode_available(codec)
|
||
}
|
||
#[cfg(not(feature = "vulkan-encode"))]
|
||
{
|
||
false
|
||
}
|
||
};
|
||
let backend = resolve_linux_backend(
|
||
pf_host_config::config().encoder_pref.as_str(),
|
||
linux_auto_is_vaapi,
|
||
cuda_planned,
|
||
);
|
||
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
|
||
}
|
||
|
||
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
|
||
/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the
|
||
/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and
|
||
/// device-supported for the session's codec.
|
||
#[cfg(target_os = "linux")]
|
||
fn cursor_blend_capable_for(
|
||
backend: Option<LinuxBackend>,
|
||
cuda_planned: bool,
|
||
ten_bit: bool,
|
||
direct_nvenc: bool,
|
||
vulkan_csc: bool,
|
||
) -> bool {
|
||
match backend {
|
||
// The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`).
|
||
Some(LinuxBackend::Pyrowave) => true,
|
||
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
|
||
// a CPU-payload session stays on libav NVENC, which cannot blend.
|
||
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
|
||
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
|
||
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
|
||
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
|
||
// so CSC eligibility IS the answer.
|
||
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
|
||
// CPU frames: the capturer composites the metadata cursor inline before the encoder
|
||
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
|
||
// contract can't be honored. Report the encoder's truth.
|
||
Some(LinuxBackend::Software) | None => false,
|
||
}
|
||
}
|
||
|
||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||
///
|
||
/// Only [`linux_native_nv12_ok`] consults this, and only for the no-fallback decision described
|
||
/// there. It is deliberately NOT wired into [`open_video`]'s dispatch: that path already degrades
|
||
/// to VAAPI on a failed open for every non-NV12 capture, so making it pay for a probe would buy
|
||
/// nothing. Prediction and truth stay separate — the probe answers "would it open", the open itself
|
||
/// reports what actually did.
|
||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||
fn vulkan_encode_available(codec: Codec) -> bool {
|
||
use std::collections::HashMap;
|
||
use std::sync::{Mutex, OnceLock};
|
||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||
let key = (pf_gpu::selection_key(), codec.label());
|
||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||
return *v;
|
||
}
|
||
let verdict = vulkan_video::probe_encode_support(codec);
|
||
let ok = verdict.is_ok();
|
||
match &verdict {
|
||
Ok(()) => tracing::info!(
|
||
?codec,
|
||
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
|
||
),
|
||
Err(why) => tracing::info!(
|
||
?codec,
|
||
why = *why,
|
||
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
|
||
(the native-NV12 path has no VAAPI fallback)"
|
||
),
|
||
}
|
||
cache.lock().unwrap().insert(key, ok);
|
||
ok
|
||
}
|
||
|
||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||
/// NVIDIA). `PUNKTFUNK_ENCODER` overrides this entirely.
|
||
#[cfg(target_os = "linux")]
|
||
fn nvidia_present() -> bool {
|
||
std::path::Path::new("/dev/nvidiactl").exists() || std::path::Path::new("/dev/nvidia0").exists()
|
||
}
|
||
|
||
/// The `auto` Linux backend decision, shared by [`open_video`] and [`linux_zero_copy_is_vaapi`]:
|
||
/// a manual web-console GPU preference (when that GPU is present — [`pf_gpu::manual_selection`])
|
||
/// picks its vendor's backend — AMD/Intel → VAAPI on that GPU's render node, NVIDIA → NVENC (still
|
||
/// requiring the proprietary driver's device nodes; a nouveau NVIDIA GPU can't NVENC) — otherwise
|
||
/// today's NVIDIA-presence probe, unchanged.
|
||
///
|
||
/// ⚠ This resolves the **`auto` case only** — it deliberately ignores `encoder_pref`. It is NOT a
|
||
/// mirror of [`open_video`]'s dispatch and must not be used to decide which backend a capability
|
||
/// probe should ask: use [`linux_zero_copy_is_vaapi`], which layers `encoder_pref` on top of this.
|
||
/// (`can_encode_10bit` used this directly and answered for the wrong backend whenever a host
|
||
/// forced one.)
|
||
#[cfg(target_os = "linux")]
|
||
fn linux_auto_is_vaapi() -> bool {
|
||
if let Some(g) = pf_gpu::manual_selection() {
|
||
if g.vendor_id == pf_gpu::VENDOR_NVIDIA {
|
||
return !nvidia_present();
|
||
}
|
||
return true;
|
||
}
|
||
!nvidia_present()
|
||
}
|
||
|
||
/// The resolved Linux encode backend — the Linux twin of [`WindowsBackend`] (WP7.6). ONE alias
|
||
/// table, consumed by BOTH [`open_video_backend`]'s dispatch and every capability/advertisement
|
||
/// mirror below, so the two can never drift again (the audit counted five partial hand-copies of
|
||
/// this decision). Labels still come from the open sites (the mgmt-record convention) — this
|
||
/// resolves which ARM runs, never what actually opened.
|
||
#[cfg(target_os = "linux")]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum LinuxBackend {
|
||
Nvenc,
|
||
AmdIntel,
|
||
Vulkan,
|
||
Pyrowave,
|
||
Software,
|
||
}
|
||
|
||
/// The pure core. `None` = the pref names no backend — [`open_video_backend`] BAILS on that,
|
||
/// while the capability mirrors historically resolved it as auto (via
|
||
/// [`linux_resolved_backend`]); that divergence is recorded, not accidental. `auto_is_vaapi` is
|
||
/// deliberately LAZY: explicit prefs must stay zero-probe — `/serverinfo` polls through the
|
||
/// mirrors, and `gamestream/serverinfo.rs` already litigated exactly that probe cost.
|
||
#[cfg(target_os = "linux")]
|
||
fn resolve_linux_backend(
|
||
pref: &str,
|
||
auto_is_vaapi: impl FnOnce() -> bool,
|
||
cuda: bool,
|
||
) -> Option<LinuxBackend> {
|
||
Some(match pref {
|
||
"nvenc" | "nvidia" | "cuda" => LinuxBackend::Nvenc,
|
||
"vaapi" | "amd" | "intel" => LinuxBackend::AmdIntel,
|
||
"vulkan" | "vulkan-video" => LinuxBackend::Vulkan,
|
||
"pyrowave" => LinuxBackend::Pyrowave,
|
||
"software" | "sw" | "openh264" => LinuxBackend::Software,
|
||
// A CUDA frame can ONLY be consumed by NVENC. Otherwise the shared auto decision
|
||
// (manual web-console GPU preference, else the NVIDIA-presence probe) picks the backend —
|
||
// see `linux_auto_is_vaapi`.
|
||
"auto" | "" => {
|
||
if cuda || !auto_is_vaapi() {
|
||
LinuxBackend::Nvenc
|
||
} else {
|
||
LinuxBackend::AmdIntel
|
||
}
|
||
}
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Config-reading wrapper for the capability/advertisement mirrors: an unknown pref resolves as
|
||
/// auto — exactly what every mirror's old `_` arm did. `cuda = false`: the mirrors answer
|
||
/// pre-session questions (a CUDA payload exists only inside a live NVIDIA session).
|
||
#[cfg(target_os = "linux")]
|
||
fn linux_resolved_backend() -> LinuxBackend {
|
||
let pref = pf_host_config::config().encoder_pref.as_str();
|
||
resolve_linux_backend(pref, linux_auto_is_vaapi, false).unwrap_or_else(|| {
|
||
if linux_auto_is_vaapi() {
|
||
LinuxBackend::AmdIntel
|
||
} else {
|
||
LinuxBackend::Nvenc
|
||
}
|
||
})
|
||
}
|
||
|
||
/// The dmabuf modifiers the PyroWave encoder's Vulkan device imports for the capture's
|
||
/// packed-RGB fourcc — advertised by the capture when the pyrowave passthrough is active
|
||
/// (the VAAPI LINEAR-only policy starves it on Mutter+NVIDIA, which allocates tiled only).
|
||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||
pub fn pyrowave_capture_modifiers(fourcc: u32) -> Vec<u64> {
|
||
pyrowave::capture_modifiers(fourcc)
|
||
}
|
||
|
||
/// True if the Linux GPU encode backend resolves to VAAPI (AMD/Intel) rather than NVENC — mirrors
|
||
/// [`open_video`]'s dispatch so the capturer can choose the matching zero-copy path (raw dmabuf
|
||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||
#[cfg(target_os = "linux")]
|
||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||
linux_zero_copy_is_vaapi_for(linux_resolved_backend())
|
||
}
|
||
/// The zero-copy-plane decision for an ALREADY-resolved backend — so a caller that has just
|
||
/// resolved (e.g. `host_wire_caps`, which consults several gates per call on a POLLED endpoint)
|
||
/// pays for the resolution once instead of once per gate (the `serverinfo.rs` probe-cost class,
|
||
/// caught by the WP7.6 diff critic).
|
||
#[cfg(target_os = "linux")]
|
||
fn linux_zero_copy_is_vaapi_for(backend: LinuxBackend) -> bool {
|
||
match backend {
|
||
LinuxBackend::Nvenc => false,
|
||
LinuxBackend::AmdIntel => true,
|
||
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
|
||
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
|
||
LinuxBackend::Pyrowave => true,
|
||
// EXACTLY the old `_` fallthrough for these two prefs — preserved, not endorsed. The
|
||
// vulkan-on-NVIDIA half is FILED (EGL→CUDA capture negotiated for a dmabuf-importing
|
||
// backend; reachable only via the explicit lab pref); the software half is latent (a
|
||
// software host pins H.264, so the GPU-backend probes this steers never negotiate).
|
||
LinuxBackend::Vulkan | LinuxBackend::Software => linux_auto_is_vaapi(),
|
||
}
|
||
}
|
||
|
||
/// Which codecs the active GPU can actually ENCODE. Used to build the GameStream codec
|
||
/// advertisement so a client never negotiates a codec the GPU can't do (AV1 encode is narrow —
|
||
/// Intel Arc/Xe2+, AMD RDNA3+/RDNA4 — so it must be probed, not assumed).
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct CodecSupport {
|
||
pub h264: bool,
|
||
pub h265: bool,
|
||
pub av1: bool,
|
||
}
|
||
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
impl CodecSupport {
|
||
/// The probed codecs as a `quic::CODEC_*` bitfield, or `None` when the probe found nothing —
|
||
/// meaning the GPU wasn't usable at probe time (GPU-less CI, a wrong-vendor pref), NOT that it
|
||
/// encodes zero codecs; the caller then falls back to the static superset (the native-path
|
||
/// analogue of `gamestream::serverinfo::probed_mask`).
|
||
pub fn wire_mask(self) -> Option<u8> {
|
||
let mut m = 0u8;
|
||
if self.h264 {
|
||
m |= punktfunk_core::quic::CODEC_H264;
|
||
}
|
||
if self.h265 {
|
||
m |= punktfunk_core::quic::CODEC_HEVC;
|
||
}
|
||
if self.av1 {
|
||
m |= punktfunk_core::quic::CODEC_AV1;
|
||
}
|
||
(m != 0).then_some(m)
|
||
}
|
||
}
|
||
|
||
/// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per
|
||
/// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated
|
||
/// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]).
|
||
#[cfg(target_os = "linux")]
|
||
pub fn vaapi_codec_support() -> CodecSupport {
|
||
use std::sync::OnceLock;
|
||
static CACHE: OnceLock<CodecSupport> = OnceLock::new();
|
||
*CACHE.get_or_init(|| {
|
||
let caps = CodecSupport {
|
||
h264: vaapi::probe_can_encode(Codec::H264),
|
||
h265: vaapi::probe_can_encode(Codec::H265),
|
||
av1: vaapi::probe_can_encode(Codec::Av1),
|
||
};
|
||
tracing::info!(
|
||
h264 = caps.h264,
|
||
h265 = caps.h265,
|
||
av1 = caps.av1,
|
||
"VAAPI encode capabilities probed"
|
||
);
|
||
caps
|
||
})
|
||
}
|
||
|
||
/// Whether the active GPU encode backend can actually produce a full-chroma **4:4:4** HEVC stream.
|
||
/// Resolved (and cached, once) *before* the Welcome so the host advertises the chroma it will really
|
||
/// encode — the honest-downgrade channel. 4:4:4 is HEVC-only; the probe opens a tiny encoder on the
|
||
/// active backend (NVENC FREXT is broad on NVIDIA, but VAAPI / AMF / QSV 4:4:4 is hardware-specific,
|
||
/// so it must be probed, never assumed). Non-HEVC codecs are always `false`.
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
pub fn can_encode_444(codec: Codec) -> bool {
|
||
use std::collections::HashMap;
|
||
use std::sync::{Mutex, OnceLock};
|
||
if codec == Codec::PyroWave {
|
||
// PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source),
|
||
// so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant:
|
||
// `rgb2yuv444.comp` on Linux (Phase 2) and the mode-aware `BgraToYuvPlanes` on
|
||
// Windows (Phase 3) — both landed (design/pyrowave-444-hdr.md).
|
||
return true;
|
||
}
|
||
if codec != Codec::H265 {
|
||
return false;
|
||
}
|
||
// Cached per selected GPU (was a process-lifetime OnceLock): a web-console preference change
|
||
// re-probes on the newly selected adapter before the next Welcome.
|
||
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||
let key = pf_gpu::selection_key();
|
||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||
return *v;
|
||
}
|
||
let supported = {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// Mirror open_video's backend dispatch: VAAPI (AMD/Intel) vs NVENC (NVIDIA).
|
||
if linux_zero_copy_is_vaapi() {
|
||
vaapi::probe_can_encode_444(codec)
|
||
} else {
|
||
linux::probe_can_encode_444(codec)
|
||
}
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
match windows_resolved_backend() {
|
||
WindowsBackend::Nvenc => {
|
||
#[cfg(feature = "nvenc")]
|
||
{
|
||
nvenc::probe_can_encode_444(codec)
|
||
}
|
||
#[cfg(not(feature = "nvenc"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
// AMD: native AMF never encodes 4:4:4 — VCN hardware limit, permanent, no probe
|
||
// needed (design/native-amf-encoder.md §3.5, Phase 3).
|
||
WindowsBackend::Amf => false,
|
||
WindowsBackend::Qsv => {
|
||
#[cfg(feature = "amf-qsv")]
|
||
{
|
||
ffmpeg_win::probe_can_encode_444(ffmpeg_win::WinVendor::Qsv, codec)
|
||
}
|
||
#[cfg(not(feature = "amf-qsv"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
WindowsBackend::Software => false,
|
||
}
|
||
}
|
||
};
|
||
tracing::info!(supported, "HEVC 4:4:4 encode capability probed");
|
||
cache.lock().unwrap().insert(key, supported);
|
||
supported
|
||
}
|
||
|
||
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
|
||
/// no GPU encode backend exists here, so 4:4:4 is never advertised.
|
||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||
pub fn can_encode_444(_codec: Codec) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Whether the active GPU encode backend can actually produce a **10-bit** stream for `codec`
|
||
/// (HEVC Main10 / AV1 10-bit). Resolved (and cached per selected GPU) *before* the Welcome so the
|
||
/// negotiated bit depth — and the HDR/SDR colour label derived from it — matches what the encoder
|
||
/// will really emit: the honest-downgrade channel, exactly like [`can_encode_444`]. Without this
|
||
/// gate a default-on `PUNKTFUNK_10BIT` would negotiate 10-bit on a GPU/backend that then silently
|
||
/// falls back to 8-bit post-Welcome (label HDR / stream SDR).
|
||
///
|
||
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
|
||
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
|
||
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
|
||
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. **Linux**
|
||
/// probes a tiny real Main10 open on the auto-resolved backend — libav NVENC (the HDR X2RGB10→
|
||
/// P010 swscale path) or VAAPI (P010 pool + Main10) — for the GNOME 50+ HDR portal capture;
|
||
/// the direct-SDK CUDA path and Vulkan-video stay 8-bit and a 10-bit session routes around them.
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
pub fn can_encode_10bit(codec: Codec) -> bool {
|
||
use std::collections::HashMap;
|
||
use std::sync::{Mutex, OnceLock};
|
||
if !codec.supports_10bit() {
|
||
return false;
|
||
}
|
||
if codec == Codec::PyroWave {
|
||
// PyroWave needs no GPU encode probe (the wavelet is depth-agnostic) — only the HDR
|
||
// capture CSC (scRGB FP16 → 16-bit studio-code planes), which exists on the Windows
|
||
// IDD-push path only (design/pyrowave-444-hdr.md Phase 3; Linux capture has no HDR).
|
||
return cfg!(target_os = "windows");
|
||
}
|
||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||
let key = (pf_gpu::selection_key(), codec.label());
|
||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||
return *v;
|
||
}
|
||
let supported = {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// NVENC (libav, the HDR P010 swscale path) or VAAPI (P010 upload / dmabuf graph),
|
||
// probed by opening a tiny real Main10 encoder — the same honesty contract as
|
||
// `can_encode_444`. Vulkan-video and the direct-SDK CUDA path stay 8-bit; a 10-bit
|
||
// session routes around them (see `open_video_backend`). NOTE: encode capability is
|
||
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
|
||
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||
// honor), since this probe can't know what the compositor will negotiate.
|
||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
||
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
|
||
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
|
||
// would answer for NVENC while the session actually opens VAAPI, and the negotiated bit
|
||
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
|
||
// never runs. That is exactly the dishonesty this probe exists to prevent.
|
||
if linux_zero_copy_is_vaapi() {
|
||
vaapi::probe_can_encode_10bit(codec)
|
||
} else {
|
||
linux::probe_can_encode_10bit(codec)
|
||
}
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
match windows_resolved_backend() {
|
||
WindowsBackend::Nvenc => {
|
||
#[cfg(feature = "nvenc")]
|
||
{
|
||
nvenc::probe_can_encode_10bit(codec)
|
||
}
|
||
#[cfg(not(feature = "nvenc"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
WindowsBackend::Amf => amf::probe_can_encode_10bit(codec),
|
||
// QSV: the native VPL probe (`MFXVideoENCODE_Query` with the Main10/P010 or
|
||
// 10-bit-AV1 parameter block) — the driver clears/errors what the silicon can't
|
||
// do. The ffmpeg path stays unprobed (its Main10 incantation can silently
|
||
// encode 8-bit), so without the `qsv` feature this remains an honest `false`.
|
||
WindowsBackend::Qsv => {
|
||
#[cfg(feature = "qsv")]
|
||
{
|
||
qsv::probe_can_encode_10bit(codec)
|
||
}
|
||
#[cfg(not(feature = "qsv"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
WindowsBackend::Software => false,
|
||
}
|
||
}
|
||
};
|
||
tracing::info!(codec = ?codec, supported, "10-bit encode capability probed");
|
||
cache.lock().unwrap().insert(key, supported);
|
||
supported
|
||
}
|
||
|
||
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
|
||
/// no GPU encode backend exists here, so 10-bit is never negotiated.
|
||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||
pub fn can_encode_10bit(_codec: Codec) -> bool {
|
||
false
|
||
}
|
||
|
||
/// Marker in an encoder error's `anyhow` context chain: the failure is a deterministic
|
||
/// consequence of the session's configuration, so an in-place rebuild retry can never succeed
|
||
/// (e.g. the backend binding to a wrong-vendor adapter — the same wall on every attempt). The
|
||
/// stream loop's reset ladder downcasts for this and ends the session immediately with the
|
||
/// carried cause instead of burning its rebuild budget first. Attach with
|
||
/// `anyhow::Error::new(TerminalEncoderError).context("the actual cause")`.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct TerminalEncoderError;
|
||
|
||
impl std::fmt::Display for TerminalEncoderError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str("deterministic configuration error — an encoder rebuild cannot fix this")
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for TerminalEncoderError {}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
|
||
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
|
||
// SELECTED render adapter (pf_gpu — web-console preference / env pin / max VRAM), so the
|
||
// backend always matches the GPU the capture ring and virtual display sit on.
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
// Un-gated (unlike everything else in this section): the pure reconciliation half below is
|
||
// platform-agnostic so its decision table is unit-tested on every CI leg, not just Windows.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum WindowsBackend {
|
||
Nvenc,
|
||
Amf,
|
||
Qsv,
|
||
Software,
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum GpuVendor {
|
||
Nvidia,
|
||
Amd,
|
||
Intel,
|
||
}
|
||
|
||
/// The PCI vendor a Windows hardware backend can open on (`None` for the vendor-agnostic
|
||
/// software encoder). Capture ring, virtual display, and encoder share ONE adapter, so a backend
|
||
/// whose vendor differs from the selected GPU's can never open — the table the pin-vs-preference
|
||
/// reconciliation in [`resolve_windows_backend`] stands on.
|
||
pub fn windows_backend_vendor_id(backend: WindowsBackend) -> Option<u32> {
|
||
match backend {
|
||
WindowsBackend::Nvenc => Some(pf_gpu::VENDOR_NVIDIA),
|
||
WindowsBackend::Amf => Some(pf_gpu::VENDOR_AMD),
|
||
WindowsBackend::Qsv => Some(pf_gpu::VENDOR_INTEL),
|
||
WindowsBackend::Software => None,
|
||
}
|
||
}
|
||
|
||
/// Pure half of [`windows_resolved_backend`]: reconcile an explicit `PUNKTFUNK_ENCODER` pin with
|
||
/// the selected render adapter. A hardware pin whose vendor contradicts the selected GPU is
|
||
/// OVERRIDDEN by the adapter-derived backend (`derived`, evaluated lazily) — honoring it can only
|
||
/// produce a deterministically-failing session, observed on a hybrid box as a stale `qsv` pin vs.
|
||
/// a console NVIDIA preference: five futile in-place rebuilds, session end, client reconnect,
|
||
/// forever. The console preference is the newer, user-visible, per-box intent; the pin is usually
|
||
/// a stale provisioning artifact — [`open_video`] warns loudly whenever a pin loses. The software
|
||
/// pin has no vendor and is always honored; with no selected GPU there is nothing to reconcile
|
||
/// against, so a pin is trusted as-is.
|
||
pub fn resolve_windows_backend(
|
||
pinned: Option<WindowsBackend>,
|
||
selected_vendor_id: Option<u32>,
|
||
derived: impl FnOnce() -> WindowsBackend,
|
||
) -> WindowsBackend {
|
||
match (pinned, selected_vendor_id) {
|
||
(None, _) => derived(),
|
||
(Some(pin), Some(vendor)) => match windows_backend_vendor_id(pin) {
|
||
Some(required) if required != vendor => derived(),
|
||
_ => pin,
|
||
},
|
||
(Some(pin), None) => pin,
|
||
}
|
||
}
|
||
|
||
/// The explicit `PUNKTFUNK_ENCODER` pin as a backend — `None` for `auto`, unset, and unknown
|
||
/// spellings (which all mean "derive from the selected adapter's vendor").
|
||
#[cfg(target_os = "windows")]
|
||
fn windows_pinned_backend() -> Option<WindowsBackend> {
|
||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||
match pf_host_config::config().encoder_pref.as_str() {
|
||
"nvenc" | "hw" | "nvidia" | "cuda" => Some(WindowsBackend::Nvenc),
|
||
"amf" | "amd" => Some(WindowsBackend::Amf),
|
||
"qsv" | "intel" => Some(WindowsBackend::Qsv),
|
||
"sw" | "software" | "openh264" => Some(WindowsBackend::Software),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
|
||
/// render adapter's vendor; an explicit pin contradicting that vendor is overridden — see
|
||
/// [`resolve_windows_backend`]). Shared by [`open_video`] and the GameStream codec advertisement
|
||
/// so both agree.
|
||
#[cfg(target_os = "windows")]
|
||
pub fn windows_resolved_backend() -> WindowsBackend {
|
||
let pinned = windows_pinned_backend();
|
||
// The selected vendor is only consulted to reconcile a pin — skipping the query keeps the
|
||
// common auto path at ONE inventory walk (the one inside `windows_gpu_vendor`).
|
||
let selected = if pinned.is_some() {
|
||
pf_gpu::selected_gpu().map(|s| s.info.vendor_id)
|
||
} else {
|
||
None
|
||
};
|
||
resolve_windows_backend(pinned, selected, || match windows_gpu_vendor() {
|
||
Some(GpuVendor::Nvidia) => WindowsBackend::Nvenc,
|
||
Some(GpuVendor::Amd) => WindowsBackend::Amf,
|
||
Some(GpuVendor::Intel) => WindowsBackend::Qsv,
|
||
None => WindowsBackend::Software,
|
||
})
|
||
}
|
||
|
||
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
||
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
|
||
/// wants CPU staging. This is the single source for [`pf_frame::OutputFormat`]'s `gpu` bit:
|
||
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
|
||
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
|
||
/// frames are GPU-resident (plan §2.4 / §W4).
|
||
#[cfg(target_os = "windows")]
|
||
pub fn resolved_backend_is_gpu() -> bool {
|
||
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||
}
|
||
/// Linux: every backend but the GPU-less software encoder (openh264) is GPU-resident. Resolver-
|
||
/// backed (WP7.6); cross-crate mirrors still exist in `session_plan::resolve_encoder` and
|
||
/// `gamestream/serverinfo.rs` — out of this crate's reach, listed in the WP7.6 design doc.
|
||
#[cfg(target_os = "linux")]
|
||
pub fn resolved_backend_is_gpu() -> bool {
|
||
linux_resolved_backend() != LinuxBackend::Software
|
||
}
|
||
/// Other non-Windows targets (the macOS dev host): keep the string gate — no resolver exists
|
||
/// there. `not(any(...))`, NEVER a target list, so no exotic target loses the fn (the
|
||
/// `can_encode_444`/`can_encode_10bit` shape).
|
||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||
pub fn resolved_backend_is_gpu() -> bool {
|
||
!matches!(
|
||
pf_host_config::config().encoder_pref.as_str(),
|
||
"software" | "sw" | "openh264"
|
||
)
|
||
}
|
||
|
||
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
|
||
/// the *encoder* half of the 4:4:4 capture gate (the host capture `capturer_supports_444`). Only Windows
|
||
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
|
||
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
|
||
/// backend, so the gate never consults this there.
|
||
#[cfg(target_os = "windows")]
|
||
pub fn resolved_backend_ingests_rgb_444() -> bool {
|
||
windows_resolved_backend() == WindowsBackend::Nvenc
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
pub fn resolved_backend_ingests_rgb_444() -> bool {
|
||
false
|
||
}
|
||
|
||
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV qualifies
|
||
/// with either the native VPL build (`qsv`, the authoritative Query probe) or the `amf-qsv`
|
||
/// (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
|
||
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
|
||
#[cfg(target_os = "windows")]
|
||
pub fn windows_backend_is_probed() -> bool {
|
||
match windows_resolved_backend() {
|
||
WindowsBackend::Amf => true,
|
||
WindowsBackend::Qsv => cfg!(feature = "qsv") || cfg!(feature = "amf-qsv"),
|
||
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||
}
|
||
}
|
||
|
||
/// Detect the encode-GPU vendor from the **selected render adapter** ([`pf_gpu::selected_gpu`]:
|
||
/// web-console preference > `PUNKTFUNK_RENDER_ADAPTER` > max VRAM) — the same adapter the capture
|
||
/// ring and the IddCx render pin sit on, so the encoder backend can never disagree with where the
|
||
/// captured frames live. The old first-DXGI-adapter scan did exactly that on hybrid boxes: adapter
|
||
/// 0 is often the iGPU (e.g. Intel Arc) while capture/encode pin the dGPU — resolving QSV for a
|
||
/// pipeline whose textures sit on the NVIDIA card. Uncached: selection is preference-dependent and
|
||
/// only consulted at session setup / serverinfo time, never per-frame. Falls back to the first
|
||
/// known-vendor adapter when the selected one is an unknown vendor.
|
||
#[cfg(target_os = "windows")]
|
||
fn windows_gpu_vendor() -> Option<GpuVendor> {
|
||
fn by_id(vendor_id: u32) -> Option<GpuVendor> {
|
||
match vendor_id {
|
||
pf_gpu::VENDOR_NVIDIA => Some(GpuVendor::Nvidia),
|
||
pf_gpu::VENDOR_AMD => Some(GpuVendor::Amd),
|
||
pf_gpu::VENDOR_INTEL => Some(GpuVendor::Intel),
|
||
_ => None,
|
||
}
|
||
}
|
||
let sel = pf_gpu::selected_gpu()?;
|
||
by_id(sel.info.vendor_id)
|
||
.or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id)))
|
||
}
|
||
|
||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
||
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
|
||
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
|
||
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
|
||
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||
///
|
||
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
|
||
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
|
||
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
|
||
/// **Intel/QSV advertises from the native VPL Query probe** (`qsv::probe_can_encode`,
|
||
/// design/native-qsv-encoder.md §4), falling back to the libavcodec probe on builds without the
|
||
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV).
|
||
#[cfg(target_os = "windows")]
|
||
pub fn windows_codec_support() -> CodecSupport {
|
||
use std::collections::HashMap;
|
||
use std::sync::{Mutex, OnceLock};
|
||
static CACHE: OnceLock<Mutex<HashMap<String, CodecSupport>>> = OnceLock::new();
|
||
let backend = windows_resolved_backend();
|
||
let key = format!("{backend:?}:{}", pf_gpu::selection_key());
|
||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||
if let Some(c) = cache.lock().unwrap().get(&key) {
|
||
return *c;
|
||
}
|
||
let probe_one = |codec: Codec| -> bool {
|
||
match backend {
|
||
// AMD: the native factory probe is authoritative — it opens exactly the component the
|
||
// session will, so the advertisement matches what the encoder can emit by construction.
|
||
WindowsBackend::Amf => amf::probe_can_encode(codec),
|
||
WindowsBackend::Qsv => {
|
||
// Native VPL Query probe first (the same parameter block the session opens, so
|
||
// the advertisement matches what the encoder can emit by construction); the
|
||
// libavcodec probe only answers on builds without the native backend.
|
||
#[cfg(feature = "qsv")]
|
||
{
|
||
qsv::probe_can_encode(codec)
|
||
}
|
||
#[cfg(all(not(feature = "qsv"), feature = "amf-qsv"))]
|
||
{
|
||
ffmpeg_win::probe_can_encode(ffmpeg_win::WinVendor::Qsv, codec)
|
||
}
|
||
#[cfg(all(not(feature = "qsv"), not(feature = "amf-qsv")))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
|
||
// (the advertisement then falls back to the static superset).
|
||
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||
}
|
||
};
|
||
let caps = CodecSupport {
|
||
h264: probe_one(Codec::H264),
|
||
h265: probe_one(Codec::H265),
|
||
av1: probe_one(Codec::Av1),
|
||
};
|
||
tracing::info!(
|
||
?backend,
|
||
h264 = caps.h264,
|
||
h265 = caps.h265,
|
||
av1 = caps.av1,
|
||
"Windows AMF/QSV encode capabilities probed"
|
||
);
|
||
// A concurrent first call may double-probe; both arrive at the same answer, last insert wins.
|
||
cache.lock().unwrap().insert(key, caps);
|
||
caps
|
||
}
|
||
|
||
/// Stage-W3 encoder session-budget seam (`design/windows-parallel-virtual-displays.md` §4.5):
|
||
/// whether one more encode session fits the hardware budget — consulted by the display admission
|
||
/// before admitting a parallel display, so an unaffordable display is DECLINED instead of silently
|
||
/// degrading a live sibling's encode. NVENC is the only backend with hard session caps today
|
||
/// (GeForce consumer limit); AMF/QSV equivalents follow the same seam when they grow accounting.
|
||
#[cfg(target_os = "windows")]
|
||
pub fn can_open_another_session() -> bool {
|
||
#[cfg(feature = "nvenc")]
|
||
{
|
||
nvenc::can_open_another_session()
|
||
}
|
||
#[cfg(not(feature = "nvenc"))]
|
||
{
|
||
true
|
||
}
|
||
}
|
||
|
||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV
|
||
// ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
|
||
// `crate::*` module names flat.
|
||
// Native AMF (direct SDK, design/native-amf-encoder.md): compiled unconditionally on Windows —
|
||
// no build feature, the driver-installed amfrt64.dll resolves at runtime like NVENC's DLL.
|
||
#[cfg(target_os = "windows")]
|
||
#[path = "enc/windows/amf.rs"]
|
||
mod amf;
|
||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||
#[path = "enc/windows/ffmpeg_win.rs"]
|
||
mod ffmpeg_win;
|
||
// Native Intel QSV (VPL, design/native-qsv-encoder.md): behind the `qsv` feature — the vendored
|
||
// dispatcher is statically linked (libvpl-sys, cmake+bindgen at build), the GPU runtime resolves
|
||
// from the Intel driver store at run time.
|
||
#[cfg(target_os = "linux")]
|
||
#[path = "enc/linux/mod.rs"]
|
||
mod linux;
|
||
#[cfg(all(target_os = "windows", feature = "qsv"))]
|
||
#[path = "enc/windows/qsv.rs"]
|
||
mod qsv;
|
||
// Direct-SDK NVENC on Linux (CUDA input; design/linux-direct-nvenc.md) — real RFI + recovery anchor
|
||
// + reset() lever the libavcodec `linux::NvencEncoder` can't express. Opt-in behind
|
||
// `PUNKTFUNK_NVENC_DIRECT` until on-glass validated; the `.so` resolves at runtime like the Windows
|
||
// path, so `--features nvenc` stays safe on a driver-less/AMD Linux box.
|
||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||
#[path = "enc/windows/nvenc.rs"]
|
||
mod nvenc;
|
||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||
#[path = "enc/linux/nvenc_cuda.rs"]
|
||
mod nvenc_cuda;
|
||
// Actionable `NVENCSTATUS` → cause mapping shared by both direct-NVENC backends, so a failed
|
||
// session open names its real cause instead of the old misleading "(no NVIDIA GPU?)" — including
|
||
// splitting the two opposite failures the driver reports as the SAME `INVALID_VERSION` (a genuine
|
||
// driver skew vs. this process's driver state going bad after a session already opened).
|
||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||
#[path = "enc/nvenc_status.rs"]
|
||
mod nvenc_status;
|
||
// Platform-agnostic direct-SDK NVENC glue (`NvStatusExt`/`nv_ok`, `codec_guid`) shared by both
|
||
// `nvEncodeAPI` backends — the byte-identical Tier-2 leaves (plan §2.2). Sibling of `nvenc_status`.
|
||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
|
||
#[path = "enc/nvenc_core.rs"]
|
||
mod nvenc_core;
|
||
// The slot-family RFI recovery policy (WP7.2) shared by native AMF, native QSV and Vulkan Video —
|
||
// the taint sweep + pre-loss anchor pick extracted from three hand-copies that had already
|
||
// diverged once (the fecbec2d sweep predates the Vulkan backend's carve-out and was never ported
|
||
// to it until a later fix). Policy only: every mechanism (LTR bitfield, RefListCtrl, DPB slot
|
||
// table) stays in its backend. cfg = the union of the callers': amf.rs is featureless on Windows,
|
||
// vulkan_video needs `vulkan-encode` on Linux — and each ITEM inside is live under the module's
|
||
// whole cfg because `plan_slot_recovery` delegates to `pick_anchor` (dead_code is an item lint;
|
||
// module-granular reasoning is how a Windows-dead helper reaches main).
|
||
#[cfg(any(
|
||
target_os = "windows",
|
||
all(target_os = "linux", feature = "vulkan-encode")
|
||
))]
|
||
#[path = "enc/rfi.rs"]
|
||
mod rfi;
|
||
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
|
||
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
|
||
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
|
||
#[path = "enc/libav.rs"]
|
||
mod libav;
|
||
// Software (openh264) H.264 encoder — the GPU-less path on BOTH Windows and Linux (a headless /
|
||
// GPU-less test box, or a fallback when no hardware encoder is available). Platform-agnostic: it
|
||
// consumes CPU RGB `CapturedFrame`s and the statically-bundled openh264 build.
|
||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||
#[path = "enc/sw.rs"]
|
||
mod sw;
|
||
#[cfg(target_os = "linux")]
|
||
#[path = "enc/linux/vaapi.rs"]
|
||
mod vaapi;
|
||
// Raw Vulkan Video HEVC encode on Linux (AMD/Intel; design/linux-vulkan-video-encode.md) — real RFI
|
||
// via explicit DPB reference slots (the app owns the DPB), the open-stack twin of the direct-NVENC
|
||
// path. Does an on-GPU RGB→NV12 compute CSC since capture delivers packed-RGB dmabufs. Opt-in behind
|
||
// `PUNKTFUNK_VULKAN_ENCODE` until on-glass validated; needs `--features vulkan-encode`.
|
||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||
#[path = "enc/linux/vulkan_video.rs"]
|
||
mod vulkan_video;
|
||
// Vendored `VK_KHR_video_encode_av1` bindings (host-only) — the AV1 encode structs our pinned
|
||
// `ash 0.38.0+1.3.281` predates (finalized Vulkan 1.3.290). Copied verbatim from ash-master's
|
||
// generated code rather than bumping `ash` (which breaks the SDL/Vulkan client). Consumed by
|
||
// `vulkan_video.rs` via `super::vk_av1_encode`.
|
||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||
#[path = "enc/linux/vk_av1_encode.rs"]
|
||
mod vk_av1_encode;
|
||
// Vendored `VK_VALVE_video_encode_rgb_conversion` bindings (host-only) — RGB encode source with
|
||
// the VCN EFC front-end doing the CSC (design/vulkan-rgb-direct-encode.md). ash 0.38 predates
|
||
// the extension; same vendoring rationale as `vk_av1_encode`.
|
||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||
#[path = "enc/linux/vk_valve_rgb.rs"]
|
||
mod vk_valve_rgb;
|
||
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
|
||
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
|
||
#[cfg(all(
|
||
target_os = "linux",
|
||
any(feature = "vulkan-encode", feature = "pyrowave")
|
||
))]
|
||
#[path = "enc/linux/vk_util.rs"]
|
||
mod vk_util;
|
||
// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md §4.3):
|
||
// pure Vulkan compute via the vendored `pyrowave-sys`, sub-ms encode, every frame a keyframe.
|
||
// Explicit-only behind PUNKTFUNK_ENCODER=pyrowave; EXPERIMENTAL until CODEC_PYROWAVE lands.
|
||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||
#[path = "enc/linux/pyrowave.rs"]
|
||
mod pyrowave;
|
||
// The Windows PyroWave encoder — NV12 zero-copy D3D11→Vulkan via pyrowave's own compat device
|
||
// (design/pyrowave-windows-host-zerocopy.md). Same module name as the Linux one (per-platform
|
||
// `#[path]`, mutually-exclusive cfg) so `crate::pyrowave::*` is flat on both.
|
||
#[cfg(all(target_os = "windows", feature = "pyrowave"))]
|
||
#[path = "enc/windows/pyrowave.rs"]
|
||
mod pyrowave;
|
||
// Shared PyroWave AU wire-framing (§4.4) — the single source of truth both platform backends emit,
|
||
// so the on-wire access-unit layout the clients parse can never drift between Linux and Windows.
|
||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))]
|
||
#[path = "enc/pyrowave_wire.rs"]
|
||
mod pyrowave_wire;
|
||
|
||
/// Whether a PyroWave mode fits the vendored rate controller's packed 16-bit block index
|
||
/// (`patches/0002-rdo-saving-clamp.patch` note): false ≈ 8K-class 4:4:4. The negotiator
|
||
/// downgrades such a session to 4:2:0 before the Welcome; the encoders also refuse outright.
|
||
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))]
|
||
pub fn pyrowave_mode_fits_rdo(width: u32, height: u32, chroma444: bool) -> bool {
|
||
pyrowave_wire::block_count_32x32(width, height, chroma444) <= u16::MAX as u32
|
||
}
|
||
#[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))]
|
||
pub fn pyrowave_mode_fits_rdo(_width: u32, _height: u32, _chroma444: bool) -> bool {
|
||
false
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The pin-vs-adapter reconciliation table (`resolve_windows_backend`): an explicit
|
||
/// `PUNKTFUNK_ENCODER` pin whose vendor contradicts the selected GPU must be overridden by
|
||
/// the adapter-derived backend — never "pin + proceed", which fed the reset ladder a
|
||
/// deterministic failure (the hybrid-box `qsv` pin × console NVIDIA preference loop).
|
||
#[test]
|
||
fn encoder_pin_reconciles_against_the_selected_adapter() {
|
||
use WindowsBackend::*;
|
||
let derived = |b: WindowsBackend| move || b;
|
||
let unreachable = || -> WindowsBackend { panic!("derived must not be consulted") };
|
||
// The repro: pinned qsv, console-selected NVIDIA → NVENC (the un-pinned path's answer).
|
||
assert_eq!(
|
||
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_NVIDIA), derived(Nvenc)),
|
||
Nvenc
|
||
);
|
||
// A pin matching the selected vendor is honored without deriving.
|
||
assert_eq!(
|
||
resolve_windows_backend(Some(Qsv), Some(pf_gpu::VENDOR_INTEL), unreachable),
|
||
Qsv
|
||
);
|
||
// No selected GPU → nothing to reconcile against; the pin is trusted as-is.
|
||
assert_eq!(
|
||
resolve_windows_backend(Some(Nvenc), None, unreachable),
|
||
Nvenc
|
||
);
|
||
// The software pin has no vendor: honored on any adapter.
|
||
assert_eq!(
|
||
resolve_windows_backend(Some(Software), Some(pf_gpu::VENDOR_NVIDIA), unreachable),
|
||
Software
|
||
);
|
||
// No pin (`auto`/unset) → always the adapter-derived backend.
|
||
assert_eq!(
|
||
resolve_windows_backend(None, Some(pf_gpu::VENDOR_AMD), derived(Amf)),
|
||
Amf
|
||
);
|
||
assert_eq!(
|
||
resolve_windows_backend(None, None, derived(Software)),
|
||
Software
|
||
);
|
||
}
|
||
|
||
/// The terminal-marker contract the stream loop's reset ladder relies on: a
|
||
/// [`TerminalEncoderError`] attached at the failure site must stay downcastable through the
|
||
/// context layers added on the way up (a `format!`/stringify on any layer would break it).
|
||
#[test]
|
||
fn terminal_encoder_error_survives_the_context_chain() {
|
||
use anyhow::Context as _;
|
||
let site: anyhow::Error = anyhow::Error::new(TerminalEncoderError)
|
||
.context("capture device's adapter is not an Intel VPL implementation");
|
||
let bubbled = Err::<(), _>(site)
|
||
.context("QSV lazy bring-up")
|
||
.context("encoder submit")
|
||
.unwrap_err();
|
||
assert!(bubbled.downcast_ref::<TerminalEncoderError>().is_some());
|
||
// The operator-facing rendering leads with the actual cause, not the marker.
|
||
assert!(format!("{bubbled:#}").contains("not an Intel VPL implementation"));
|
||
}
|
||
|
||
/// The probed-capability → wire-bitfield mapping the native codec advertisement is built from.
|
||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||
#[test]
|
||
fn codec_support_wire_mask() {
|
||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC};
|
||
let all = CodecSupport {
|
||
h264: true,
|
||
h265: true,
|
||
av1: true,
|
||
};
|
||
assert_eq!(all.wire_mask(), Some(CODEC_H264 | CODEC_HEVC | CODEC_AV1));
|
||
let hevc_only = CodecSupport {
|
||
h264: false,
|
||
h265: true,
|
||
av1: false,
|
||
};
|
||
assert_eq!(hevc_only.wire_mask(), Some(CODEC_HEVC));
|
||
// An all-false probe means "GPU unusable at probe time", not "zero codecs" — `None` tells
|
||
// the caller to advertise the static superset instead of refusing every handshake.
|
||
let none = CodecSupport {
|
||
h264: false,
|
||
h265: false,
|
||
av1: false,
|
||
};
|
||
assert_eq!(none.wire_mask(), None);
|
||
}
|
||
|
||
/// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation
|
||
/// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's
|
||
/// blending stage or the reason there is none.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn cursor_blend_capability_mirrors_the_dispatch() {
|
||
use LinuxBackend::*;
|
||
// PyroWave: the wavelet CSC composites, always.
|
||
assert!(cursor_blend_capable_for(
|
||
Some(Pyrowave),
|
||
false,
|
||
false,
|
||
false,
|
||
false
|
||
));
|
||
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
|
||
assert!(cursor_blend_capable_for(
|
||
Some(Nvenc),
|
||
true,
|
||
false,
|
||
true,
|
||
false
|
||
));
|
||
assert!(
|
||
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
|
||
"a CPU payload stays on libav NVENC, which cannot blend"
|
||
);
|
||
assert!(
|
||
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
|
||
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
|
||
);
|
||
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
|
||
assert!(cursor_blend_capable_for(
|
||
Some(AmdIntel),
|
||
false,
|
||
false,
|
||
false,
|
||
true
|
||
));
|
||
assert!(
|
||
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
|
||
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
|
||
device) resolves to libav VAAPI, which cannot blend"
|
||
);
|
||
assert!(
|
||
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
|
||
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
|
||
);
|
||
assert!(cursor_blend_capable_for(
|
||
Some(Vulkan),
|
||
false,
|
||
false,
|
||
false,
|
||
true
|
||
));
|
||
// Software / unknown pref: CPU frames; the encoder blends nothing.
|
||
assert!(!cursor_blend_capable_for(
|
||
Some(Software),
|
||
false,
|
||
false,
|
||
true,
|
||
true
|
||
));
|
||
assert!(!cursor_blend_capable_for(None, false, false, true, true));
|
||
}
|
||
|
||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||
/// `set_pipelined`'s LN3 escalation, `applied_bitrate_bps`'s ABR truth), and every Phase 7
|
||
/// consolidation that adds a trait method re-arms it. Source-text parse, not reflection: both
|
||
/// blocks are top-level rustfmt items, so each ends at the first column-0 `}` and every method
|
||
/// name sits on a line starting `fn ` (a wrapped signature still carries the name there).
|
||
#[test]
|
||
fn tracked_encoder_forwards_every_trait_method() {
|
||
fn item_block<'a>(src: &'a str, marker: &str) -> &'a str {
|
||
let start = src
|
||
.find(marker)
|
||
.unwrap_or_else(|| panic!("marker {marker:?} not found — update this guard"));
|
||
let body = &src[start..];
|
||
let end = body
|
||
.find("\n}")
|
||
.unwrap_or_else(|| panic!("no column-0 close brace after {marker:?}"));
|
||
&body[..end]
|
||
}
|
||
fn fn_names(block: &str) -> std::collections::BTreeSet<&str> {
|
||
block
|
||
.lines()
|
||
.map(str::trim_start)
|
||
.filter(|l| !l.starts_with("//"))
|
||
.filter_map(|l| l.strip_prefix("fn "))
|
||
.map(|rest| {
|
||
rest.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||
.next()
|
||
.expect("split yields at least one item")
|
||
})
|
||
.collect()
|
||
}
|
||
// `find` takes the FIRST occurrence: the real impl precedes this test's own copy of the
|
||
// marker string in the included source.
|
||
let trait_fns = fn_names(item_block(
|
||
include_str!("enc/codec.rs"),
|
||
"pub trait Encoder: Send {",
|
||
));
|
||
let impl_fns = fn_names(item_block(
|
||
include_str!("lib.rs"),
|
||
"impl Encoder for TrackedEncoder {",
|
||
));
|
||
assert!(
|
||
trait_fns.len() >= 12,
|
||
"only {} trait methods parsed — the extraction markers have rotted, fix the parse \
|
||
before trusting this guard",
|
||
trait_fns.len()
|
||
);
|
||
let missing: Vec<_> = trait_fns.difference(&impl_fns).collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"Encoder methods NOT forwarded by TrackedEncoder: {missing:?} — the host loop only \
|
||
ever holds the wrapped box, so an unforwarded default silently disables the feature \
|
||
for every session. Forward each one in `impl Encoder for TrackedEncoder`."
|
||
);
|
||
// The reverse direction (impl fn absent from the trait) is a compile error, so equality
|
||
// here is pure belt-and-braces against a parse regression.
|
||
assert_eq!(trait_fns, impl_fns);
|
||
}
|
||
|
||
/// The typed-EINVAL classifier the bitrate ladder keys on (Phase 8): the `ffmpeg::Error`
|
||
/// must survive `with_context` layers as a downcastable source — pinned here because the
|
||
/// entire ladder's step-down behavior rests on it, and an eager `format!` anywhere between
|
||
/// `open_with` and the ladder would silently break it (the ladder would stop stepping and
|
||
/// 4K sessions would surface errors instead of degrading).
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn nvenc_open_einval_survives_context_layers() {
|
||
use ffmpeg_next as ffmpeg;
|
||
let e = anyhow::Error::from(ffmpeg::Error::Other {
|
||
errno: ffmpeg::util::error::EINVAL,
|
||
})
|
||
.context("open hevc_nvenc (3840x2160@120, 400000000 bps)")
|
||
.context("outer");
|
||
assert!(nvenc_open_einval(&e));
|
||
// ENOSYS (or any other errno) must not step the ladder.
|
||
let e = anyhow::Error::from(ffmpeg::Error::Other {
|
||
errno: ffmpeg::util::error::ENOSYS,
|
||
})
|
||
.context("open");
|
||
assert!(!nvenc_open_einval(&e));
|
||
}
|
||
|
||
/// The phrase WITHOUT the type no longer classifies — the fragility that was removed: the
|
||
/// old string match trusted any error whose rendering contained "Invalid argument".
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn nvenc_open_einval_ignores_untyped_text() {
|
||
let e = anyhow::anyhow!("driver said: Invalid argument (not a typed libav errno)");
|
||
assert!(!nvenc_open_einval(&e));
|
||
}
|
||
|
||
/// WP7.6: the resolver's full alias table, pinned. The panicking closure is the laziness
|
||
/// contract — an explicit pref must resolve WITHOUT running the auto probe (`/serverinfo`
|
||
/// polls through the mirrors; `gamestream/serverinfo.rs` litigated exactly that cost).
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn linux_backend_resolver_table() {
|
||
use LinuxBackend::*;
|
||
let no_probe = || -> bool { panic!("explicit prefs must not run the auto probe") };
|
||
for pref in ["nvenc", "nvidia", "cuda"] {
|
||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Nvenc));
|
||
}
|
||
for pref in ["vaapi", "amd", "intel"] {
|
||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(AmdIntel));
|
||
}
|
||
for pref in ["vulkan", "vulkan-video"] {
|
||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Vulkan));
|
||
}
|
||
assert_eq!(
|
||
resolve_linux_backend("pyrowave", no_probe, false),
|
||
Some(Pyrowave)
|
||
);
|
||
for pref in ["software", "sw", "openh264"] {
|
||
assert_eq!(resolve_linux_backend(pref, no_probe, false), Some(Software));
|
||
}
|
||
// auto (and the default empty pref): the probe decides…
|
||
assert_eq!(resolve_linux_backend("", || true, false), Some(AmdIntel));
|
||
assert_eq!(resolve_linux_backend("auto", || false, false), Some(Nvenc));
|
||
// …except a CUDA frame, which only NVENC can consume — and that short-circuits the
|
||
// probe too (`||` order).
|
||
assert_eq!(resolve_linux_backend("auto", no_probe, true), Some(Nvenc));
|
||
// Unknown prefs name no backend: the dispatch bails; the mirrors' wrapper maps this to
|
||
// auto (the historical `_`-arm behavior, recorded in the design doc).
|
||
assert_eq!(resolve_linux_backend("banana", no_probe, false), None);
|
||
// An explicit pref is never overridden by `cuda` (a CUDA payload cannot appear in a
|
||
// session whose pref forced a non-NVENC backend; the table must not mask such a bug).
|
||
assert_eq!(
|
||
resolve_linux_backend("vaapi", no_probe, true),
|
||
Some(AmdIntel)
|
||
);
|
||
}
|
||
|
||
/// WP7.6: the REAL dispatch through the resolver, GPU-free via the software arm (openh264 is
|
||
/// vendored + CPU-only; its own tests already run on every leg). This is the only in-CI
|
||
/// execution of the Linux dispatch — before this test it had zero test call sites. The pref
|
||
/// is INJECTED through `open_video_backend_linux` — the first draft mutated `PUNKTFUNK_ENCODER`
|
||
/// via `set_var`, the binary's first non-ignored env mutation, racing `getenv` in the sw/codec/
|
||
/// nvenc_core tests (diff-critic catch); the seam removes the race and the config-latch
|
||
/// coupling outright.
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn open_video_backend_dispatches_software() {
|
||
let (enc, label) = open_video_backend_linux(
|
||
"software",
|
||
Codec::H264,
|
||
PixelFormat::Bgrx,
|
||
64,
|
||
64,
|
||
30,
|
||
1_000_000,
|
||
false,
|
||
8,
|
||
ChromaFormat::Yuv420,
|
||
false,
|
||
)
|
||
.expect("software arm must open GPU-free");
|
||
assert_eq!(label, "software");
|
||
drop(enc);
|
||
// The software arm is codec-gated — a non-H.264 session must take the bail, not fall
|
||
// through to another backend.
|
||
let err = match open_video_backend_linux(
|
||
"software",
|
||
Codec::H265,
|
||
PixelFormat::Bgrx,
|
||
64,
|
||
64,
|
||
30,
|
||
1_000_000,
|
||
false,
|
||
8,
|
||
ChromaFormat::Yuv420,
|
||
false,
|
||
) {
|
||
// `expect_err` needs `Ok: Debug` and `Box<dyn Encoder>` isn't — match instead.
|
||
Ok(_) => panic!("software emits H.264 only; an H.265 session must be refused"),
|
||
Err(e) => e,
|
||
};
|
||
assert!(err.to_string().contains("H.264"), "{err:#}");
|
||
}
|
||
}
|