9a36ea2132
encode.rs + encode/* (NVENC, VAAPI, native AMF, AMF/QSV ffmpeg, direct-SDK NVENC/CUDA, raw Vulkan-Video, PyroWave, openh264) move into crates/pf-encode behind one Encoder trait + open_video selector (plan §W6). The crate speaks the shared frame vocabulary (pf-frame: CapturedFrame/PixelFormat + the DXGI identity D3d11Frame/make_device) and pf-zerocopy (CUDA context/buffers), and NEVER pf-capture — the capture→encode edge is one-way (ZeroCopyPolicy, prior commit). Dep moves: the heavy encoder deps (ffmpeg-next, the NVENC SDK, openh264, pyrowave-sys) move from the host to pf-encode; the host's nvenc/amf-qsv/vulkan-encode/pyrowave features now FORWARD to pf-encode/*. The host keeps a mod-encode shim (pub use pf_encode) so every crate::encode::* path (negotiator + GameStream/native/mgmt planes) is unchanged. resolve_render_adapter_luid moves from the host's windows/win_adapter.rs into pf-gpu (both pf-encode and pf-capture need it as a peer of GPU selection); its 5 call sites (encode amf/nvenc, capture idd_push/synthetic_nv12, vdisplay manager) rewire to pf_gpu::resolve_render_adapter_luid and win_adapter.rs is deleted. pf-frame's make_device gains a # Safety section (public-unsafe-fn lint, latent since the pf-frame carve — a full-workspace -D warnings clippy catches it). Verified: Linux clippy -D warnings (pf-encode + host nvenc,vulkan-encode,pyrowave --all-targets) + 13/13 pf-encode + 299/299 host tests; Windows clippy -D warnings (pf-encode nvenc,amf-qsv --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1223 lines
56 KiB
Rust
1223 lines
56 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.
|
|
// Scaffold: some backend paths + trait defaults are defined ahead of the per-feature build that
|
|
// uses them (mirrors the host crate root's allow before the extraction).
|
|
#![allow(dead_code)]
|
|
// 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).
|
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
|
let pyro = if !matches!(
|
|
pf_host_config::config().encoder_pref.as_str(),
|
|
"software" | "sw" | "openh264"
|
|
) {
|
|
punktfunk_core::quic::CODEC_PYROWAVE
|
|
} else {
|
|
0u8
|
|
};
|
|
#[cfg(not(all(target_os = "linux", 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 matches!(
|
|
pf_host_config::config().encoder_pref.as_str(),
|
|
"software" | "sw" | "openh264"
|
|
) {
|
|
return punktfunk_core::quic::CODEC_H264;
|
|
}
|
|
if linux_zero_copy_is_vaapi() {
|
|
if let Some(m) = vaapi_codec_support().wire_mask() {
|
|
return m;
|
|
}
|
|
}
|
|
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
|
GPU_SUPERSET
|
|
}
|
|
#[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,
|
|
) -> Result<Box<dyn Encoder>> {
|
|
let (inner, backend) = open_video_backend(
|
|
codec,
|
|
format,
|
|
width,
|
|
height,
|
|
fps,
|
|
bitrate_bps,
|
|
cuda,
|
|
bit_depth,
|
|
chroma,
|
|
)?;
|
|
// 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,
|
|
},
|
|
}
|
|
};
|
|
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)
|
|
}
|
|
// 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)
|
|
}
|
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
|
self.inner.poll()
|
|
}
|
|
fn reset(&mut self) -> bool {
|
|
self.inner.reset()
|
|
}
|
|
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
|
|
self.inner.reconfigure_bitrate(bps)
|
|
}
|
|
fn flush(&mut self) -> Result<()> {
|
|
self.inner.flush()
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
) -> Result<(Box<dyn Encoder>, &'static str)> {
|
|
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-only. The negotiator should never pass `Yuv444` for another codec (it gates on
|
|
// `codec == H265`), but defend the contract here so a future caller can't silently emit a stream
|
|
// no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning.
|
|
let chroma = if chroma.is_444() && codec != Codec::H265 {
|
|
tracing::warn!(
|
|
?codec,
|
|
"4:4:4 requested for a non-HEVC codec — encoding 4:2:0"
|
|
);
|
|
ChromaFormat::Yuv420
|
|
} else {
|
|
chroma
|
|
};
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// 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)
|
|
.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).
|
|
let pref = pf_host_config::config().encoder_pref.as_str();
|
|
// 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)> {
|
|
#[cfg(feature = "vulkan-encode")]
|
|
if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() {
|
|
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
|
|
{
|
|
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"));
|
|
}
|
|
Err(e) => tracing::warn!(
|
|
error = %format!("{e:#}"),
|
|
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
|
),
|
|
}
|
|
}
|
|
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,
|
|
)
|
|
.map(|e| (e, "nvenc"))
|
|
};
|
|
match pref {
|
|
"nvenc" | "nvidia" | "cuda" => open_nvidia(),
|
|
"vaapi" | "amd" | "intel" => open_amd_intel(),
|
|
// Force the raw Vulkan Video HEVC backend (real RFI). Needs `--features vulkan-encode`.
|
|
"vulkan" | "vulkan-video" => {
|
|
#[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, width, height, fps, bitrate_bps)
|
|
.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.
|
|
"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)"
|
|
);
|
|
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
|
|
.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`.
|
|
"software" | "sw" | "openh264" => {
|
|
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)
|
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "software"))
|
|
}
|
|
"auto" | "" => {
|
|
// 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`.
|
|
if cuda || !linux_auto_is_vaapi() {
|
|
open_nvidia()
|
|
} else {
|
|
open_amd_intel()
|
|
}
|
|
}
|
|
other => anyhow::bail!(
|
|
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
|
|
),
|
|
}
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3);
|
|
// host_wire_caps never advertises the bit here, so this only guards a forged preference.
|
|
if codec == Codec::PyroWave {
|
|
anyhow::bail!("PyroWave host encode is not available on Windows yet");
|
|
}
|
|
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();
|
|
// With `auto` the backend is derived from the selected GPU, so this can only fire when an
|
|
// explicit PUNKTFUNK_ENCODER contradicts the GPU the pipeline sits on (e.g. `nvenc` forced
|
|
// while the web-console preference pins the Intel iGPU) — the open below will then fail on
|
|
// a wrong-vendor device; say why up front instead of leaving an opaque encoder error.
|
|
if let Some(sel) = pf_gpu::selected_gpu() {
|
|
let mismatched = match backend {
|
|
WindowsBackend::Nvenc => sel.info.vendor_id != pf_gpu::VENDOR_NVIDIA,
|
|
WindowsBackend::Amf => sel.info.vendor_id != pf_gpu::VENDOR_AMD,
|
|
WindowsBackend::Qsv => sel.info.vendor_id != pf_gpu::VENDOR_INTEL,
|
|
WindowsBackend::Software => false,
|
|
};
|
|
if mismatched {
|
|
tracing::warn!(
|
|
adapter = sel.info.name,
|
|
?backend,
|
|
"encoder backend does not match the selected GPU's vendor (explicit \
|
|
PUNKTFUNK_ENCODER conflicting with the GPU preference?) — the encoder \
|
|
open will likely fail on this device"
|
|
);
|
|
}
|
|
}
|
|
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 QSV via libavcodec (stays on FFmpeg — design/native-amf-encoder.md §2:
|
|
// async_depth=1 + low_power VDEnc is already near the hardware latency floor).
|
|
#[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(not(feature = "amf-qsv"))]
|
|
{
|
|
anyhow::bail!(
|
|
"Intel (QSV) encode requested/detected but this host was built without \
|
|
it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a FFMPEG_DIR \
|
|
with the QSV encoders at build time)"
|
|
)
|
|
}
|
|
}
|
|
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
|
|
// Software H.264 realistically caps far below the negotiated hardware rates.
|
|
const SW_BITRATE_CEIL: u64 = 100_000_000;
|
|
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,
|
|
) -> Result<Box<dyn Encoder>> {
|
|
// 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,
|
|
)?) 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 format!("{e:#}").contains("Invalid argument") => last = Some(e),
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
Err(last.unwrap_or_else(|| anyhow::anyhow!("encoder open failed at every probed bitrate")))
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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.
|
|
#[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 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 {
|
|
match pf_host_config::config().encoder_pref.as_str() {
|
|
"nvenc" | "nvidia" | "cuda" => false,
|
|
"vaapi" | "amd" | "intel" => 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.
|
|
"pyrowave" => true,
|
|
_ => 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::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. Every
|
|
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
|
|
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
|
|
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
|
|
/// 8-bit SDR.
|
|
#[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;
|
|
}
|
|
// 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")]
|
|
{
|
|
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
|
|
false
|
|
}
|
|
#[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: deferred like its 4:4:4 probe (`ffmpeg_win::probe_can_encode_444`) — no
|
|
// Intel Windows box in the lab to validate that the libavcodec profile really
|
|
// emits Main10 rather than silently 8-bit.
|
|
WindowsBackend::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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
#[cfg(target_os = "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,
|
|
}
|
|
|
|
/// Resolve the active Windows encode backend from `PUNKTFUNK_ENCODER` (`auto` → the selected
|
|
/// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so
|
|
/// both agree.
|
|
#[cfg(target_os = "windows")]
|
|
pub fn windows_resolved_backend() -> 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" => WindowsBackend::Nvenc,
|
|
"amf" | "amd" => WindowsBackend::Amf,
|
|
"qsv" | "intel" => WindowsBackend::Qsv,
|
|
"sw" | "software" | "openh264" => WindowsBackend::Software,
|
|
_ => 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/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
|
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
|
#[cfg(not(target_os = "windows"))]
|
|
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 still needs
|
|
/// 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 = "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 uses the libavcodec probe** (all-`false` without the `amf-qsv` feature, matching a
|
|
/// build that cannot open QSV at all).
|
|
#[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 => {
|
|
#[cfg(feature = "amf-qsv")]
|
|
{
|
|
ffmpeg_win::probe_can_encode(ffmpeg_win::WinVendor::Qsv, codec)
|
|
}
|
|
#[cfg(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;
|
|
#[cfg(target_os = "linux")]
|
|
#[path = "enc/linux/mod.rs"]
|
|
mod linux;
|
|
// 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 logs "update/reboot the driver" instead of the old misleading "(no NVIDIA GPU?)".
|
|
#[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;
|
|
// 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;
|
|
// 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;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// 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);
|
|
}
|
|
}
|