feat(windows): AMD (AMF) + Intel (QSV) hardware encode on the Windows host

The Windows host was NVIDIA-only (NVENC) with an openh264 software fallback. Add
AMD AMF and Intel QSV via libavcodec — the Windows analogue of the Linux VAAPI
backend — so one installer serves all three GPU vendors.

- encode/ffmpeg_win.rs: new WinVendor{Amf,Qsv} encoder. System-memory NV12/P010
  readback (default, robust) + opt-in zero-copy D3D11 (PUNKTFUNK_ZEROCOPY: shares
  the capturer's ID3D11Device; AMF takes AV_PIX_FMT_D3D11, QSV derives a QSV frames
  ctx and maps) with a system fallback for the format-group mismatch the capturer's
  video-processor fallback can produce. HDR Main10 (P010 + BT.2020/PQ VUI; an
  Rgb10a2->P010 swscale covers the shader fallback).
- encode.rs: Codec::amf_name/qsv_name; open_video + windows_resolved_backend()
  resolve PUNKTFUNK_ENCODER=auto|nvenc|amf|qsv|sw via a DXGI adapter VendorId probe.
- capture/dxgi.rs: gpu_mode mirrors the resolved backend (D3D11 NV12/P010 for AMF/QSV).
- gamestream/serverinfo.rs: GPU-aware codec advertisement (windows_codec_support;
  AV1 gated to RDNA3+/Arc, like the VAAPI path).
- Cargo.toml: amf-qsv feature (optional ffmpeg-next in the windows target block).
- CI/installer: windows-host.yml sets FFMPEG_DIR + builds --features nvenc,amf-qsv;
  the Inno installer bundles the FFmpeg DLLs; host.env default nvenc -> auto.

CI-green target; AMF/QSV not yet on-glass validated (no AMD/Intel Windows box in the
lab) — NVENC stays live-validated. An adversarial-review pass caught + fixed real
FFI bugs (AV_PIX_FMT_P010 is a macro -> P010LE; windows-rs 0.62 GetImmediateContext/
GetDesc1 return Result; AV_HWFRAME_MAP_* is a bindgen enum with no BitOr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:31:54 +00:00
parent fde438a1ed
commit 72eeedc4da
12 changed files with 1515 additions and 86 deletions
+209 -38
View File
@@ -49,6 +49,26 @@ impl Codec {
Codec::Av1 => "av1_vaapi",
}
}
/// The FFmpeg AMD **AMF** encoder name (the Windows AMD backend). Selected by name (the codec id
/// would pick the software encoder). AV1 (`av1_amf`) is RDNA3+/RX 7000+ — probe, never assume.
pub fn amf_name(self) -> &'static str {
match self {
Codec::H264 => "h264_amf",
Codec::H265 => "hevc_amf",
Codec::Av1 => "av1_amf",
}
}
/// The FFmpeg Intel **QSV** encoder name (the Windows Intel backend). Selected by name. AV1
/// (`av1_qsv`) is Arc/Xe2+; HEVC Main10 is Gen9.5+ — probe, never assume.
pub fn qsv_name(self) -> &'static str {
match self {
Codec::H264 => "h264_qsv",
Codec::H265 => "hevc_qsv",
Codec::Av1 => "av1_qsv",
}
}
}
/// A hardware encoder. One per session; runs on the encode thread.
@@ -198,49 +218,83 @@ pub fn open_video(
#[cfg(target_os = "windows")]
{
let _ = cuda; // always false on Windows (no Cuda payload)
let _ = bit_depth; // used by the NVENC path below; the software H.264 path is 8-bit only
let pref = std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(pref.as_str(), "nvenc" | "hw" | "nvidia") {
// 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")]
{
let enc = nvenc::NvencD3d11Encoder::open(
codec,
// NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software
// H.264. `auto` (the default) resolves from the DXGI adapter vendor.
match windows_resolved_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,
)
.map(|e| Box::new(e) as Box<dyn Encoder>)
}
#[cfg(not(feature = "nvenc"))]
{
anyhow::bail!(
"NVENC requested/detected but this host was built without it — rebuild \
with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
)
}
}
backend @ (WindowsBackend::Amf | WindowsBackend::Qsv) => {
// AMD AMF / Intel QSV via libavcodec (the Windows analogue of the Linux VAAPI path).
#[cfg(feature = "amf-qsv")]
{
let vendor = if matches!(backend, WindowsBackend::Amf) {
ffmpeg_win::WinVendor::Amf
} else {
ffmpeg_win::WinVendor::Qsv
};
ffmpeg_win::FfmpegWinEncoder::open(
vendor,
codec,
format,
width,
height,
fps,
bitrate_bps,
bit_depth,
)
.map(|e| Box::new(e) as Box<dyn Encoder>)
}
#[cfg(not(feature = "amf-qsv"))]
{
let _ = backend;
anyhow::bail!(
"AMD/Intel (AMF/QSV) encode requested/detected but this host was built \
without it — rebuild with `--features amf-qsv` (needs ffmpeg-next + a \
FFMPEG_DIR with the AMF/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; // the software H.264 path is 8-bit 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,
bit_depth,
)?;
return Ok(Box::new(enc) as Box<dyn Encoder>);
}
#[cfg(not(feature = "nvenc"))]
{
anyhow::bail!(
"NVENC requested but this host was built without it — rebuild with \
`--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)"
);
bitrate_bps.min(SW_BITRATE_CEIL),
)
.map(|e| Box::new(e) as Box<dyn Encoder>)
}
}
anyhow::ensure!(
codec == Codec::H264,
"the Windows software encoder supports H.264 only; client negotiated {codec:?} \
(set PUNKTFUNK_ENCODER=nvenc for a GPU host, or request H264)"
);
// Software H.264 realistically caps far below the negotiated hardware rates.
const SW_BITRATE_CEIL: u64 = 100_000_000;
let enc = sw::OpenH264Encoder::open(
format,
width,
height,
fps,
bitrate_bps.min(SW_BITRATE_CEIL),
)?;
Ok(Box::new(enc) as Box<dyn Encoder>)
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
@@ -339,7 +393,7 @@ pub fn linux_zero_copy_is_vaapi() -> bool {
/// 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(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[derive(Clone, Copy, Debug)]
pub struct CodecSupport {
pub h264: bool,
@@ -370,6 +424,123 @@ pub fn vaapi_codec_support() -> CodecSupport {
})
}
// ---------------------------------------------------------------------------------------------
// 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 DXGI adapter vendor.
// ---------------------------------------------------------------------------------------------
#[cfg(target_os = "windows")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) 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 DXGI adapter
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
#[cfg(target_os = "windows")]
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
let pref = std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase();
match 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 active Windows backend is the libavcodec AMF/QSV path (so the codec advertisement
/// consults a real GPU probe rather than the NVENC static superset). Always false when the
/// `amf-qsv` feature is off — there's then no ffmpeg backend to probe.
#[cfg(target_os = "windows")]
pub fn windows_backend_is_ffmpeg() -> bool {
cfg!(feature = "amf-qsv")
&& matches!(
windows_resolved_backend(),
WindowsBackend::Amf | WindowsBackend::Qsv
)
}
/// Detect the host GPU vendor from the first hardware DXGI adapter (Windows has no `/dev/nvidia*`
/// probe). Cached. NVIDIA=0x10DE, AMD=0x1002, Intel=0x8086; the software/WARP adapter is skipped.
#[cfg(target_os = "windows")]
fn windows_gpu_vendor() -> Option<GpuVendor> {
use std::sync::OnceLock;
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
};
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
*CACHE.get_or_init(|| unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
let mut i = 0u32;
while let Ok(adapter) = factory.EnumAdapters1(i) {
i += 1;
// windows-rs 0.62: GetDesc1 returns the desc by value (no out-param).
let Ok(desc) = adapter.GetDesc1() else {
continue;
};
if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0 {
continue; // skip the Microsoft Basic Render / WARP adapter
}
match desc.VendorId {
0x10DE => return Some(GpuVendor::Nvidia),
0x1002 => return Some(GpuVendor::Amd),
0x8086 => return Some(GpuVendor::Intel),
_ => continue,
}
}
None
})
}
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached; opens a tiny encoder
/// per codec, once). Mirrors [`vaapi_codec_support`]; called only when [`windows_backend_is_ffmpeg`]
/// is true. AV1 is narrow (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
pub fn windows_codec_support() -> CodecSupport {
use std::sync::OnceLock;
static CACHE: OnceLock<CodecSupport> = OnceLock::new();
*CACHE.get_or_init(|| {
let vendor = match windows_resolved_backend() {
WindowsBackend::Qsv => ffmpeg_win::WinVendor::Qsv,
_ => ffmpeg_win::WinVendor::Amf,
};
let caps = CodecSupport {
h264: ffmpeg_win::probe_can_encode(vendor, Codec::H264),
h265: ffmpeg_win::probe_can_encode(vendor, Codec::H265),
av1: ffmpeg_win::probe_can_encode(vendor, Codec::Av1),
};
tracing::info!(
backend = ?vendor,
h264 = caps.h264,
h265 = caps.h265,
av1 = caps.av1,
"Windows AMF/QSV encode capabilities probed"
);
caps
})
}
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
mod ffmpeg_win;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(all(target_os = "windows", feature = "nvenc"))]