diff --git a/Cargo.lock b/Cargo.lock index ac8af439..8fab979e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2799,6 +2799,28 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "pf-encode" +version = "0.12.0" +dependencies = [ + "anyhow", + "ash", + "ffmpeg-next", + "libc", + "libloading", + "nvidia-video-codec-sdk", + "openh264", + "pf-frame", + "pf-gpu", + "pf-host-config", + "pf-zerocopy", + "punktfunk-core", + "pyrowave-sys", + "tracing", + "tracing-subscriber", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "pf-ffvk" version = "0.12.0" @@ -3166,7 +3188,6 @@ dependencies = [ "base64", "bytemuck", "cbc", - "ffmpeg-next", "futures-util", "hex", "hmac", @@ -3180,11 +3201,10 @@ dependencies = [ "log", "mac_address", "mdns-sd", - "nvidia-video-codec-sdk", - "openh264", "opus", "parking_lot", "pf-driver-proto", + "pf-encode", "pf-frame", "pf-gpu", "pf-host-config", @@ -3193,7 +3213,6 @@ dependencies = [ "pf-zerocopy", "pipewire", "punktfunk-core", - "pyrowave-sys", "quinn", "rand 0.8.6", "rcgen", diff --git a/Cargo.toml b/Cargo.toml index 30a3db07..e3ef152d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/pf-zerocopy", "crates/pf-frame", "crates/pf-win-display", + "crates/pf-encode", "crates/pyrowave-sys", "clients/probe", "clients/linux", diff --git a/crates/pf-encode/Cargo.toml b/crates/pf-encode/Cargo.toml new file mode 100644 index 00000000..9de8e620 --- /dev/null +++ b/crates/pf-encode/Cargo.toml @@ -0,0 +1,74 @@ +# Hardware/software video encode (plan §7 / §W6): the per-vendor backends (NVENC, VAAPI, AMF, QSV, +# Vulkan-Video, PyroWave, openh264) behind one `Encoder` trait + `open_video` selector, extracted +# from the host so it depends on the shared frame vocabulary (pf-frame) rather than living inside +# the orchestrator. Speaks pf-frame (CapturedFrame/PixelFormat/dxgi identity) and pf-zerocopy +# (CUDA), never pf-capture — the capture→encode edge is one-way (plan §2.4). +[package] +name = "pf-encode" +version = "0.12.0" +edition = "2021" +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "punktfunk host video encode: NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264 backends behind one Encoder trait." +publish = false + +[dependencies] +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +pf-frame = { path = "../pf-frame" } +pf-gpu = { path = "../pf-gpu" } +pf-host-config = { path = "../pf-host-config" } +pf-zerocopy = { path = "../pf-zerocopy" } +anyhow = "1" +tracing = "0.1" + +[dev-dependencies] +# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`). +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] +# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms. +openh264 = "0.9" + +[target.'cfg(target_os = "linux")'.dependencies] +# libavcodec (NVENC libav + VAAPI backends). `ffmpeg-sys-next` auto-detects the FFmpeg version. +ffmpeg-next = "8" +libc = "0.2" +# Vulkan bindings for the raw Vulkan-Video encode + PyroWave compute backends (feature-gated below; +# the dep stays unconditional to mirror the host's Linux target — unused-but-declared is harmless). +ash = "0.38" +# `libnvidia-encode.so.1` is dlopen'd at runtime for the direct-SDK NVENC/CUDA backend. +libloading = "0.8" +# Direct-SDK NVENC (raw `sys::nvEncodeAPI` types; entry points resolved at runtime). `ci-check` = +# vendored bindings, no CUDA toolkit at build. +nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true } +# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under `pyrowave`. +pyrowave-sys = { path = "../pyrowave-sys", optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +# NVENC (direct SDK, D3D11 input) + the shared D3D11/DXGI vocabulary via pf-frame. +nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true } +# AMD (AMF) + Intel (QSV) hardware encode via libavcodec (behind `amf-qsv`; link-imports FFmpeg). +ffmpeg-next = { version = "8", optional = true } +# `libnvidia-encode`/`nvEncodeAPI64.dll` resolved at runtime; the NVENC status→cause table dlopen. +libloading = "0.8" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Storage_FileSystem", + "Win32_System_LibraryLoader", + "Win32_System_Threading", +] } + +[features] +default = [] +# NVENC hardware encode (Linux CUDA + Windows D3D11); entry points resolved at runtime. +nvenc = ["dep:nvidia-video-codec-sdk"] +# AMD (AMF) + Intel (QSV) hardware encode on Windows via libavcodec. +amf-qsv = ["dep:ffmpeg-next"] +# Raw Vulkan-Video HEVC/AV1 encode on Linux (reuses the `ash` bindings; no new dep). +vulkan-encode = [] +# PyroWave — the opt-in wired-LAN intra-only wavelet codec (Linux encode backend). +pyrowave = ["dep:pyrowave-sys"] diff --git a/crates/punktfunk-host/src/encode/codec.rs b/crates/pf-encode/src/enc/codec.rs similarity index 98% rename from crates/punktfunk-host/src/encode/codec.rs rename to crates/pf-encode/src/enc/codec.rs index f1b48ca0..cf2d970d 100644 --- a/crates/punktfunk-host/src/encode/codec.rs +++ b/crates/pf-encode/src/enc/codec.rs @@ -1,11 +1,11 @@ //! The encoder contract (plan §7, Tier 1): the [`Encoder`] trait plus the plain-data value types its //! signatures use — [`EncodedFrame`], [`Codec`], [`ChromaFormat`], [`EncoderCaps`] — and the //! dimension/VBV helpers [`validate_dimensions`] and [`vbv_frames_env`]. Backend selection, the -//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent [`crate::encode`] -//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::encode::*` path +//! capability probes that mirror it, and `Codec::host_wire_caps` stay in the parent the `pf-encode` crate root +//! facade, which re-exports this module (`pub(crate) use codec::*;`) so every `crate::*` path //! is unchanged. -use crate::capture::CapturedFrame; use anyhow::Result; +use pf_frame::CapturedFrame; /// An encoded access unit (one NAL/AU) to hand to `punktfunk_core` for FEC + packetization. /// `data` is in-band Annex-B (the encoder is opened without a global header), so each @@ -94,7 +94,7 @@ impl Codec { } /// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into - /// the web console's session meta ([`crate::stats_recorder::StatsRecorder::register_session`]). + /// the web console's session meta (the host `stats_recorder::StatsRecorder::register_session`). pub fn label(self) -> &'static str { match self { Codec::H264 => "h264", @@ -108,7 +108,7 @@ impl Codec { /// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation /// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the /// *codec-level* gate: the active GPU/backend must still pass - /// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit. + /// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit. pub fn supports_10bit(self) -> bool { matches!(self, Codec::H265 | Codec::Av1) } @@ -311,7 +311,7 @@ impl Codec { } /// The codec's *spec* top level/tier bitrate (bits/s) — the usual boundary at which NVENC - /// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate::encode:: + /// starts rejecting `avcodec_open2` with EINVAL. NOT a hard cap: [`open_video`](crate:: /// open_video) probes the actual GPU ceiling by stepping DOWN from the requested bitrate only on /// EINVAL, and uses this purely as the first step-down candidate (so a card that accepts more — /// an RTX 5070 Ti does >1 Gbps HEVC where a 4090 caps at ~800 Mbps — is never clamped to it). diff --git a/crates/punktfunk-host/src/encode/libav.rs b/crates/pf-encode/src/enc/libav.rs similarity index 98% rename from crates/punktfunk-host/src/encode/libav.rs rename to crates/pf-encode/src/enc/libav.rs index 7e786f48..71cbd6e2 100644 --- a/crates/punktfunk-host/src/encode/libav.rs +++ b/crates/pf-encode/src/enc/libav.rs @@ -3,7 +3,7 @@ //! (`encode/windows/ffmpeg_win.rs`) — so the byte-identical pieces live once (plan §2.2, the Tier-2 //! gap). Free functions and consts over borrowed handles; nothing here is per-frame `dyn`, //! allocating, or on the zero-copy ingest path. -use crate::encode::EncodedFrame; +use crate::EncodedFrame; use anyhow::{Context, Result}; use ffmpeg_next as ffmpeg; use ffmpeg_next::ffi; // = ffmpeg_sys_next @@ -54,7 +54,7 @@ pub(crate) fn apply_low_latency_rc(video: &mut encoder::video::Video, fps: u32, video.set_bit_rate(bitrate_bps as usize); video.set_max_bit_rate(bitrate_bps as usize); video.set_max_b_frames(0); - let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env()) + let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::vbv_frames_env()) .clamp(1.0, i32::MAX as f64); // SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened // yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain diff --git a/crates/punktfunk-host/src/encode/linux/cursor_blend.cu b/crates/pf-encode/src/enc/linux/cursor_blend.cu similarity index 100% rename from crates/punktfunk-host/src/encode/linux/cursor_blend.cu rename to crates/pf-encode/src/enc/linux/cursor_blend.cu diff --git a/crates/punktfunk-host/src/encode/linux/cursor_blend.ptx b/crates/pf-encode/src/enc/linux/cursor_blend.ptx similarity index 100% rename from crates/punktfunk-host/src/encode/linux/cursor_blend.ptx rename to crates/pf-encode/src/enc/linux/cursor_blend.ptx diff --git a/crates/punktfunk-host/src/encode/linux/mod.rs b/crates/pf-encode/src/enc/linux/mod.rs similarity index 97% rename from crates/punktfunk-host/src/encode/linux/mod.rs rename to crates/pf-encode/src/enc/linux/mod.rs index 152ee532..140e9296 100644 --- a/crates/punktfunk-host/src/encode/linux/mod.rs +++ b/crates/pf-encode/src/enc/linux/mod.rs @@ -12,12 +12,12 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::{ChromaFormat, Codec, EncodedFrame, Encoder}; -use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; use ffmpeg::util::frame::Video as VideoFrame; use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; +use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use std::os::raw::c_int; use std::ptr; @@ -347,7 +347,7 @@ impl NvencEncoder { // hwdevice/hwframes contexts and set `pix_fmt = CUDA` on the raw encoder context // *before* open (NVENC derives the device from `hw_frames_ctx`). let cuda_hw = if cuda { - let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?; + let cu_ctx = pf_zerocopy::cuda::context().context("shared CUDA context")?; // SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()` // above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from // `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel` @@ -722,12 +722,7 @@ impl NvencEncoder { /// device pointer with a bounded table, so a fresh pointer every frame would thrash/overflow /// it — the pool recycles a small set of pointers. The extra copy is device-local (~8 MB at /// 1080p, sub-millisecond on the GPU) and keeps the host fully off the pixel path. - fn submit_cuda( - &mut self, - buf: &crate::zerocopy::DeviceBuffer, - pts: i64, - idr: bool, - ) -> Result<()> { + fn submit_cuda(&mut self, buf: &pf_zerocopy::DeviceBuffer, pts: i64, idr: bool) -> Result<()> { let frames_ref = self .cuda .as_ref() @@ -735,7 +730,7 @@ impl NvencEncoder { .frames_ref; // The device→device copy below uses our shared context directly; make it current on the // encode thread (ffmpeg pushes its own around the pool alloc, so order is fine). - crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?; + pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?; // SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via // `.context(..)?` above), and the shared CUDA context was just made current on THIS thread // (`make_current()?`), the precondition for the device-pointer copies below. @@ -770,11 +765,11 @@ impl NvencEncoder { let copy_res = if buf.yuv444 { let dsts = core::array::from_fn(|i| { ( - (*f).data[i] as crate::zerocopy::cuda::CUdeviceptr, + (*f).data[i] as pf_zerocopy::cuda::CUdeviceptr, (*f).linesize[i] as usize, ) }); - crate::zerocopy::cuda::copy_yuv444_to_device(buf, dsts) + pf_zerocopy::cuda::copy_yuv444_to_device(buf, dsts) } else if self.want_444 { ffi::av_frame_free(&mut f); bail!( @@ -783,15 +778,15 @@ impl NvencEncoder { CPU 4:4:4 path on this compositor" ); } else if buf.is_nv12() { - let y_ptr = (*f).data[0] as crate::zerocopy::cuda::CUdeviceptr; + let y_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let y_pitch = (*f).linesize[0] as usize; - let uv_ptr = (*f).data[1] as crate::zerocopy::cuda::CUdeviceptr; + let uv_ptr = (*f).data[1] as pf_zerocopy::cuda::CUdeviceptr; let uv_pitch = (*f).linesize[1] as usize; - crate::zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch) + pf_zerocopy::cuda::copy_nv12_to_device(buf, y_ptr, y_pitch, uv_ptr, uv_pitch) } else { - let dst_ptr = (*f).data[0] as crate::zerocopy::cuda::CUdeviceptr; + let dst_ptr = (*f).data[0] as pf_zerocopy::cuda::CUdeviceptr; let dst_pitch = (*f).linesize[0] as usize; - crate::zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch) + pf_zerocopy::cuda::copy_device_to_device(buf, dst_ptr, dst_pitch) }; if let Err(e) = copy_res { ffi::av_frame_free(&mut f); @@ -827,7 +822,7 @@ impl Drop for NvencEncoder { /// Probe whether this NVIDIA GPU + driver + libavcodec can actually encode HEVC **4:4:4** (Range /// Extensions). Opens a tiny real `hevc_nvenc` 4:4:4 session — the exact path [`NvencEncoder::open`] /// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached -/// by the caller ([`crate::encode::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails +/// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails /// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade). pub fn probe_can_encode_444(codec: Codec) -> bool { if codec != Codec::H265 { diff --git a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs similarity index 99% rename from crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs rename to crates/pf-encode/src/enc/linux/nvenc_cuda.rs index dd35c0ea..59261447 100644 --- a/crates/punktfunk-host/src/encode/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -36,9 +36,9 @@ use super::nvenc_core::{ }; use super::nvenc_status; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; -use crate::capture::{CapturedFrame, FramePayload}; -use crate::zerocopy::cuda::{self, InputSurface}; use anyhow::{anyhow, bail, Context, Result}; +use pf_frame::{CapturedFrame, FramePayload}; +use pf_zerocopy::cuda::{self, InputSurface}; use std::collections::VecDeque; use std::ffi::c_void; use std::ptr; @@ -321,7 +321,7 @@ impl NvencCudaEncoder { #[allow(clippy::too_many_arguments)] pub fn open( codec: Codec, - _format: crate::capture::PixelFormat, + _format: pf_frame::PixelFormat, width: u32, height: u32, fps: u32, @@ -1253,8 +1253,8 @@ impl Drop for NvencCudaEncoder { #[cfg(test)] mod tests { use super::*; - use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; - use crate::zerocopy::cuda::DeviceBuffer; + use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; + use pf_zerocopy::cuda::DeviceBuffer; fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame { // Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the @@ -1281,7 +1281,7 @@ mod tests { fn nvenc_cuda_smoke_rfi_anchor() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); let mut enc = NvencCudaEncoder::open( Codec::H265, @@ -1358,7 +1358,7 @@ mod tests { fn nvenc_cuda_yuv444() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); let mut enc = NvencCudaEncoder::open( Codec::H265, PixelFormat::Yuv444, @@ -1403,7 +1403,7 @@ mod tests { fn nvenc_cuda_reconfigure_no_idr() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); let mut enc = NvencCudaEncoder::open( Codec::H265, PixelFormat::Nv12, @@ -1510,7 +1510,7 @@ mod tests { fn nvenc_cuda_codec_switch_reopen() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); for (leg, codec) in [ Codec::H265, Codec::Av1, @@ -1552,7 +1552,7 @@ mod tests { fn nvenc_cuda_dirty_teardown_reopen() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); for round in 0..3 { let mut enc = open_h265(); for f in 0..4u32 { @@ -1581,7 +1581,7 @@ mod tests { fn nvenc_cuda_open_failure_diagnosis_and_recovery() { const W: u32 = 1280; const H: u32 = 720; - crate::zerocopy::cuda::make_current().expect("shared CUDA context current"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); try_api().expect("nvenc api"); let shared = cuda::context().expect("shared ctx"); diff --git a/crates/punktfunk-host/src/encode/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs similarity index 99% rename from crates/punktfunk-host/src/encode/linux/pyrowave.rs rename to crates/pf-encode/src/enc/linux/pyrowave.rs index 2af97f1d..42b6c929 100644 --- a/crates/punktfunk-host/src/encode/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -24,11 +24,11 @@ // Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it). use super::vk_util::{color_range, find_mem, import_rgb_dmabuf, make_plain_image, pixel_to_vk}; -use crate::capture::{CapturedFrame, FramePayload}; -use crate::encode::{EncodedFrame, Encoder, EncoderCaps}; +use crate::{EncodedFrame, Encoder, EncoderCaps}; use anyhow::{bail, Context, Result}; use ash::vk; use ash::vk::Handle as _; +use pf_frame::{CapturedFrame, FramePayload}; use pyrowave_sys as pw; use std::collections::VecDeque; use std::os::fd::AsRawFd; @@ -637,10 +637,7 @@ impl PyroWaveEncoder { /// Records the small upload (only when the bitmap `serial` changed) + layout transition into /// `cmd`, ahead of the CSC dispatch that samples binding 3. Encode is synchronous, so the single /// shared image never races a prior frame; the first use transitions it to SHADER_READ_ONLY. - unsafe fn prep_cursor( - &mut self, - cursor: Option<&crate::capture::CursorOverlay>, - ) -> Result<[i32; 4]> { + unsafe fn prep_cursor(&mut self, cursor: Option<&pf_frame::CursorOverlay>) -> Result<[i32; 4]> { let dev = self.device.clone(); let cmd = self.cmd; let img = self.cursor_img; @@ -748,7 +745,7 @@ impl PyroWaveEncoder { /// Import a dmabuf with per-buffer caching — same policy as `vulkan_video.rs::import_cached`. unsafe fn import_cached( &mut self, - d: &crate::capture::DmabufFrame, + d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::ImageView, bool)> { @@ -1303,7 +1300,7 @@ impl Drop for PyroWaveEncoder { #[cfg(test)] mod tests { use super::*; - use crate::capture::PixelFormat; + use pf_frame::PixelFormat; fn cpu_frame(w: u32, h: u32, pts_ns: u64, fill: [u8; 4]) -> CapturedFrame { let mut buf = vec![0u8; (w * h * 4) as usize]; diff --git a/crates/punktfunk-host/src/encode/linux/rgb2yuv.comp b/crates/pf-encode/src/enc/linux/rgb2yuv.comp similarity index 100% rename from crates/punktfunk-host/src/encode/linux/rgb2yuv.comp rename to crates/pf-encode/src/enc/linux/rgb2yuv.comp diff --git a/crates/punktfunk-host/src/encode/linux/rgb2yuv.spv b/crates/pf-encode/src/enc/linux/rgb2yuv.spv similarity index 100% rename from crates/punktfunk-host/src/encode/linux/rgb2yuv.spv rename to crates/pf-encode/src/enc/linux/rgb2yuv.spv diff --git a/crates/punktfunk-host/src/encode/linux/vaapi.rs b/crates/pf-encode/src/enc/linux/vaapi.rs similarity index 99% rename from crates/punktfunk-host/src/encode/linux/vaapi.rs rename to crates/pf-encode/src/enc/linux/vaapi.rs index 0feee5d5..4634cdca 100644 --- a/crates/punktfunk-host/src/encode/linux/vaapi.rs +++ b/crates/pf-encode/src/enc/linux/vaapi.rs @@ -23,11 +23,11 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::{Codec, EncodedFrame, Encoder}; -use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; +use pf_frame::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use std::ffi::{CStr, CString}; use std::os::fd::AsRawFd; use std::os::raw::c_int; @@ -561,7 +561,7 @@ impl DmabufInner { fps: u32, bitrate_bps: u64, ) -> Result { - let drm_fourcc = crate::zerocopy::drm_fourcc(format) + let drm_fourcc = pf_frame::drm_fourcc(format) .ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?; let node = render_node(); // SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before diff --git a/crates/punktfunk-host/src/encode/linux/vk_av1_encode.rs b/crates/pf-encode/src/enc/linux/vk_av1_encode.rs similarity index 100% rename from crates/punktfunk-host/src/encode/linux/vk_av1_encode.rs rename to crates/pf-encode/src/enc/linux/vk_av1_encode.rs diff --git a/crates/punktfunk-host/src/encode/linux/vk_util.rs b/crates/pf-encode/src/enc/linux/vk_util.rs similarity index 98% rename from crates/punktfunk-host/src/encode/linux/vk_util.rs rename to crates/pf-encode/src/enc/linux/vk_util.rs index 96cff023..a24fc1cc 100644 --- a/crates/punktfunk-host/src/encode/linux/vk_util.rs +++ b/crates/pf-encode/src/enc/linux/vk_util.rs @@ -3,9 +3,9 @@ //! when the PyroWave backend arrived so the two don't fork copies. // Every unsafe block carries a `// SAFETY:` proof (parent module enforces it). -use crate::capture::PixelFormat; use anyhow::Result; use ash::vk; +use pf_frame::PixelFormat; pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange { vk::ImageSubresourceRange { @@ -74,7 +74,7 @@ pub(crate) unsafe fn import_rgb_dmabuf( device: &ash::Device, ext_fd: &ash::khr::external_memory_fd::Device, mem_props: &vk::PhysicalDeviceMemoryProperties, - d: &crate::capture::DmabufFrame, + d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { diff --git a/crates/punktfunk-host/src/encode/linux/vulkan_video.rs b/crates/pf-encode/src/enc/linux/vulkan_video.rs similarity index 99% rename from crates/punktfunk-host/src/encode/linux/vulkan_video.rs rename to crates/pf-encode/src/enc/linux/vulkan_video.rs index 19afc4a9..994a92f3 100644 --- a/crates/punktfunk-host/src/encode/linux/vulkan_video.rs +++ b/crates/pf-encode/src/enc/linux/vulkan_video.rs @@ -11,10 +11,10 @@ #![allow(clippy::too_many_arguments)] use super::vk_util::{color_range, find_mem, make_plain_image, make_view, pixel_to_vk}; -use crate::capture::{CapturedFrame, FramePayload}; -use crate::encode::{Codec, EncodedFrame, Encoder, EncoderCaps}; +use crate::{Codec, EncodedFrame, Encoder, EncoderCaps}; use anyhow::{bail, Context, Result}; use ash::vk; +use pf_frame::{CapturedFrame, FramePayload}; use std::collections::VecDeque; use std::ffi::c_void; use std::os::fd::AsRawFd; @@ -729,7 +729,7 @@ impl VulkanVideoEncoder { &mut self, slot: usize, compute_cmd: vk::CommandBuffer, - cursor: Option<&crate::capture::CursorOverlay>, + cursor: Option<&pf_frame::CursorOverlay>, ) -> Result<[i32; 4]> { let dev = self.device.clone(); let img = self.frames[slot].cursor_img; @@ -837,7 +837,7 @@ impl VulkanVideoEncoder { /// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys. unsafe fn import_dmabuf( &self, - d: &crate::capture::DmabufFrame, + d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { @@ -850,7 +850,7 @@ impl VulkanVideoEncoder { /// true only on a first import (caller uses UNDEFINED old-layout to preserve modifier-tiled data). unsafe fn import_cached( &mut self, - d: &crate::capture::DmabufFrame, + d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::ImageView, bool)> { @@ -2680,8 +2680,8 @@ unsafe fn build_parameters_av1( #[cfg(test)] mod tests { use super::{build_h265_rps_s0, pick_recovery_slot, VulkanVideoEncoder}; - use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; - use crate::encode::{Codec, Encoder}; + use crate::{Codec, Encoder}; + use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; /// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer /// slots never qualify. @@ -2761,7 +2761,7 @@ mod tests { /// the reference-slot RFI end-to-end; returns the AUs. Wire frame [`SMOKE_LOST`] is "lost", one /// normal P referencing it is still encoded (the in-flight window), then frame [`SMOKE_ANCHOR`] /// is the clean recovery anchor referencing pre-loss frame 3 (no IDR). - fn run_smoke(codec: Codec) -> Vec { + fn run_smoke(codec: Codec) -> Vec { let env_dim = |k: &str, d: u32| { std::env::var(k) .ok() @@ -2782,7 +2782,7 @@ mod tests { [120, 200, 80, 255], [80, 120, 200, 255], ]; - let mut aus: Vec = Vec::new(); + let mut aus: Vec = Vec::new(); for (i, c) in colors.iter().enumerate() { if i == SMOKE_ANCHOR { // The client reports wire frame SMOKE_LOST lost → the next frame must re-anchor @@ -2836,7 +2836,7 @@ mod tests { /// concealment the client's freeze hides) and NONE at the anchor — a complaint about the /// anchor's reference (frame 3 / POC 3) means reference retention regressed and the "clean" /// re-anchor ships corruption. - fn dump_smoke(aus: &[crate::encode::EncodedFrame], ext: &str) { + fn dump_smoke(aus: &[crate::EncodedFrame], ext: &str) { let Ok(home) = std::env::var("HOME") else { return; }; diff --git a/crates/punktfunk-host/src/encode/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs similarity index 99% rename from crates/punktfunk-host/src/encode/nvenc_core.rs rename to crates/pf-encode/src/enc/nvenc_core.rs index a119e6f0..f970c089 100644 --- a/crates/punktfunk-host/src/encode/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -125,7 +125,7 @@ pub(super) unsafe fn apply_low_latency_config(cfg: &mut nv::NV_ENC_CONFIG, c: Lo // when the GPU advertises custom-VBV support — else keep the preset default. if c.custom_vbv { // ~1-frame VBV by default; PUNKTFUNK_VBV_FRAMES scales it (parity with AMF/VAAPI/QSV). - let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::encode::vbv_frames_env()) + let vbv = ((c.bitrate as f64 / c.fps.max(1) as f64) * crate::vbv_frames_env()) .clamp(1.0, u32::MAX as f64) as u32; cfg.rcParams.vbvBufferSize = vbv; cfg.rcParams.vbvInitialDelay = vbv; diff --git a/crates/punktfunk-host/src/encode/nvenc_status.rs b/crates/pf-encode/src/enc/nvenc_status.rs similarity index 100% rename from crates/punktfunk-host/src/encode/nvenc_status.rs rename to crates/pf-encode/src/enc/nvenc_status.rs diff --git a/crates/punktfunk-host/src/encode/sw.rs b/crates/pf-encode/src/enc/sw.rs similarity index 99% rename from crates/punktfunk-host/src/encode/sw.rs rename to crates/pf-encode/src/enc/sw.rs index 84b3e9a5..df056192 100644 --- a/crates/punktfunk-host/src/encode/sw.rs +++ b/crates/pf-encode/src/enc/sw.rs @@ -12,7 +12,6 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::{EncodedFrame, Encoder}; -use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use anyhow::{bail, ensure, Context, Result}; use openh264::encoder::{ BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod, @@ -20,6 +19,7 @@ use openh264::encoder::{ }; use openh264::formats::YUVSlices; use openh264::OpenH264API; +use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use std::collections::VecDeque; pub struct OpenH264Encoder { @@ -258,7 +258,7 @@ fn num_threads() -> u16 { #[cfg(test)] mod tests { use super::*; - use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; + use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; /// The BT.709 limited-range anchor points: reference white → (235,128,128), black → /// (16,128,128), pure red's Cr must hit the positive extreme 240 (it does exactly: diff --git a/crates/punktfunk-host/src/encode/windows/amf.rs b/crates/pf-encode/src/enc/windows/amf.rs similarity index 99% rename from crates/punktfunk-host/src/encode/windows/amf.rs rename to crates/pf-encode/src/enc/windows/amf.rs index 3265c0cc..1c4f3484 100644 --- a/crates/punktfunk-host/src/encode/windows/amf.rs +++ b/crates/pf-encode/src/enc/windows/amf.rs @@ -47,8 +47,8 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; -use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; +use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use std::collections::VecDeque; use std::ffi::c_void; use std::ptr; @@ -1334,7 +1334,7 @@ impl AmfEncoder { /// same shape every backend ships. Shared by [`apply_static_props`](Self::apply_static_props) /// and [`Encoder::reconfigure_bitrate`] so a dynamic retarget rescales the buffer it opened with. fn vbv_bits(&self, bps: u64) -> i64 { - ((bps as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env()) + ((bps as f64 / self.fps.max(1) as f64) * crate::vbv_frames_env()) .clamp(1.0, i32::MAX as f64) as i64 } @@ -1777,7 +1777,7 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool { /// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the /// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is /// the honest per-codec answer — read *before* the Welcome by -/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's +/// [`crate::can_encode_10bit`] so the negotiated bit depth matches what the session's /// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session /// open bails on it too). pub fn probe_can_encode_10bit(codec: Codec) -> bool { @@ -1881,8 +1881,8 @@ fn selected_adapter_device() -> Option { // `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE) // fills `device` only on success. Everything drops with its COM wrapper. unsafe { - let adapter: Option = crate::win_adapter::resolve_render_adapter_luid() - .and_then(|luid| { + let adapter: Option = + pf_gpu::resolve_render_adapter_luid().and_then(|luid| { let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?; factory.EnumAdapterByLuid(luid).ok() }); @@ -2785,7 +2785,7 @@ mod tests { height: h, pts_ns: 1 + i as u64, format: fmt, - payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame { + payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), }), @@ -2856,8 +2856,8 @@ mod tests { ); drop(native); - let mut ffmpeg = crate::encode::ffmpeg_win::FfmpegWinEncoder::open( - crate::encode::ffmpeg_win::WinVendor::Amf, + let mut ffmpeg = crate::ffmpeg_win::FfmpegWinEncoder::open( + crate::ffmpeg_win::WinVendor::Amf, Codec::H265, PixelFormat::Nv12, w, @@ -2970,7 +2970,7 @@ mod tests { height: h, pts_ns: base + i as u64, format: PixelFormat::Nv12, - payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame { + payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), }), @@ -3111,7 +3111,7 @@ mod tests { height: h, pts_ns: 1 + i as u64, format: PixelFormat::P010, - payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame { + payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), }), @@ -3258,7 +3258,7 @@ mod tests { height: h, pts_ns: i, format: PixelFormat::Nv12, - payload: FramePayload::D3d11(crate::capture::dxgi::D3d11Frame { + payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), }), diff --git a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs b/crates/pf-encode/src/enc/windows/ffmpeg_win.rs similarity index 99% rename from crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs rename to crates/pf-encode/src/enc/windows/ffmpeg_win.rs index 1402648d..f362a6b8 100644 --- a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs +++ b/crates/pf-encode/src/enc/windows/ffmpeg_win.rs @@ -41,11 +41,11 @@ #![deny(clippy::undocumented_unsafe_blocks)] use super::{ChromaFormat, Codec, EncodedFrame, Encoder}; -use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; +use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use std::os::raw::{c_int, c_uint, c_void}; use std::ptr; use windows::core::Interface; diff --git a/crates/punktfunk-host/src/encode/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs similarity index 99% rename from crates/punktfunk-host/src/encode/windows/nvenc.rs rename to crates/pf-encode/src/enc/windows/nvenc.rs index a53e96b8..d0b36131 100644 --- a/crates/punktfunk-host/src/encode/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -41,8 +41,8 @@ use super::nvenc_core::{ }; use super::nvenc_status; use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; -use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; +use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use std::collections::{HashMap, VecDeque}; use std::ffi::c_void; use std::ptr; @@ -1568,7 +1568,7 @@ impl Drop for NvencD3d11Encoder { } /// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`). -/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* +/// HEVC-only; the result is cached by the caller ([`crate::can_encode_444`]) and read *before* /// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a /// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics. pub fn probe_can_encode_444(codec: Codec) -> bool { @@ -1580,7 +1580,7 @@ pub fn probe_can_encode_444(codec: Codec) -> bool { /// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit** /// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit). -/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the +/// The result is cached by the caller ([`crate::can_encode_10bit`]) and read *before* the /// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC /// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard /// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback). @@ -1622,8 +1622,8 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool { // Probe on the SELECTED render adapter — the GPU the session will actually encode on // (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter // (NULL) can be the *other* GPU on a hybrid box, answering for hardware we won't use. - let adapter: Option = crate::win_adapter::resolve_render_adapter_luid() - .and_then(|luid| { + let adapter: Option = + pf_gpu::resolve_render_adapter_luid().and_then(|luid| { let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?; factory.EnumAdapterByLuid(luid).ok() }); @@ -1692,7 +1692,7 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload}; + use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload}; use windows::Win32::Graphics::Direct3D11::{ D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, }; @@ -1760,7 +1760,7 @@ mod tests { } } let adapter = adapter.expect("no hardware DXGI adapter"); - let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device"); + let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device"); let bytes = probe_pattern(W as usize, H as usize); let init = D3D11_SUBRESOURCE_DATA { @@ -1860,7 +1860,7 @@ mod tests { } } let adapter = adapter.expect("no hardware DXGI adapter"); - let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device"); + let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device"); let bytes = probe_pattern(W as usize, H as usize); let init = D3D11_SUBRESOURCE_DATA { diff --git a/crates/punktfunk-host/src/encode.rs b/crates/pf-encode/src/lib.rs similarity index 96% rename from crates/punktfunk-host/src/encode.rs rename to crates/pf-encode/src/lib.rs index 70b3f18a..93fa5e24 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/pf-encode/src/lib.rs @@ -2,21 +2,27 @@ //! 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`]. +//! 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 (encode::windows/linux::*). +// program). As a parent module this also covers the child modules (windows/linux backends). #![deny(clippy::undocumented_unsafe_blocks)] -use crate::capture::{CapturedFrame, PixelFormat}; use anyhow::Result; +use pf_frame::{CapturedFrame, PixelFormat}; +#[path = "enc/codec.rs"] mod codec; -pub(crate) use 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 ([`crate::gamestream::serverinfo`]), in `quic::CODEC_*` bits. The GPU-less software + /// 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 @@ -30,7 +36,7 @@ impl Codec { // 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 ([`crate::session_plan::SessionPlan::output_format`]) — the EGL→CUDA + // 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"))] @@ -159,7 +165,7 @@ pub fn open_video( })) } -/// Ties the [`crate::gpu`] live-session record to the encoder's lifetime; pure delegation +/// Ties the `pf_gpu` live-session record to the encoder's lifetime; pure delegation /// otherwise. struct TrackedEncoder { inner: Box, @@ -699,7 +705,7 @@ fn linux_auto_is_vaapi() -> bool { /// 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(crate) fn pyrowave_capture_modifiers(fourcc: u32) -> Vec { +pub fn pyrowave_capture_modifiers(fourcc: u32) -> Vec { pyrowave::capture_modifiers(fourcc) } @@ -918,13 +924,13 @@ pub fn can_encode_10bit(_codec: Codec) -> bool { // --------------------------------------------------------------------------------------------- // 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 (crate::gpu — web-console preference / env pin / max VRAM), so 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(crate) enum WindowsBackend { +pub enum WindowsBackend { Nvenc, Amf, Qsv, @@ -943,7 +949,7 @@ enum GpuVendor { /// render adapter's vendor). Shared by [`open_video`] and the GameStream codec advertisement so /// both agree. #[cfg(target_os = "windows")] -pub(crate) fn windows_resolved_backend() -> WindowsBackend { +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, @@ -961,18 +967,18 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend { /// 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 [`crate::capture::OutputFormat`]'s `gpu` bit: +/// 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(crate) fn resolved_backend_is_gpu() -> bool { +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(crate) fn resolved_backend_is_gpu() -> bool { +pub fn resolved_backend_is_gpu() -> bool { !matches!( pf_host_config::config().encoder_pref.as_str(), "software" | "sw" | "openh264" @@ -980,16 +986,16 @@ pub(crate) fn resolved_backend_is_gpu() -> bool { } /// 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 ([`crate::capture::capturer_supports_444`]). Only Windows +/// 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(crate) fn resolved_backend_ingests_rgb_444() -> bool { +pub fn resolved_backend_ingests_rgb_444() -> bool { windows_resolved_backend() == WindowsBackend::Nvenc } #[cfg(not(target_os = "windows"))] -pub(crate) fn resolved_backend_ingests_rgb_444() -> bool { +pub fn resolved_backend_ingests_rgb_444() -> bool { false } @@ -1095,7 +1101,7 @@ pub fn windows_codec_support() -> CodecSupport { /// 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(crate) fn can_open_another_session() -> bool { +pub fn can_open_another_session() -> bool { #[cfg(feature = "nvenc")] { nvenc::can_open_another_session() @@ -1108,62 +1114,65 @@ pub(crate) fn can_open_another_session() -> bool { // 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::encode::*` module names flat. +// `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 = "encode/windows/amf.rs"] +#[path = "enc/windows/amf.rs"] mod amf; #[cfg(all(target_os = "windows", feature = "amf-qsv"))] -#[path = "encode/windows/ffmpeg_win.rs"] +#[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 = "encode/windows/nvenc.rs"] +#[path = "enc/windows/nvenc.rs"] mod nvenc; #[cfg(all(target_os = "linux", feature = "nvenc"))] -#[path = "encode/linux/nvenc_cuda.rs"] +#[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 = "encode/nvenc_status.rs"] +#[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 = "encode/nvenc_core.rs"] +#[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 = "encode/linux/vaapi.rs"] +#[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 = "encode/linux/vulkan_video.rs"] +#[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 = "encode/linux/vk_av1_encode.rs"] +#[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. @@ -1171,13 +1180,13 @@ mod vk_av1_encode; target_os = "linux", any(feature = "vulkan-encode", feature = "pyrowave") ))] -#[path = "encode/linux/vk_util.rs"] +#[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 = "encode/linux/pyrowave.rs"] +#[path = "enc/linux/pyrowave.rs"] mod pyrowave; #[cfg(test)] diff --git a/crates/pf-frame/src/dxgi.rs b/crates/pf-frame/src/dxgi.rs index 7d0993a3..479833e1 100644 --- a/crates/pf-frame/src/dxgi.rs +++ b/crates/pf-frame/src/dxgi.rs @@ -55,6 +55,11 @@ pub fn pack_luid(luid: LUID) -> i64 { /// adapter). Used at open and on every ACCESS_LOST: a device created on one desktop cannot sustain a /// duplication on a *different* desktop (perpetual ACCESS_LOST), so the secure-desktop switch needs a /// device made while the thread is attached to that desktop. +/// +/// # Safety +/// `adapter` must be a live `IDXGIAdapter1` for the duration of the call. The fn calls the D3D11 / +/// DXGI FFI (`D3D11CreateDevice`, GPU scheduling-priority hardening) but forms no lasting alias to +/// `adapter`; the returned device/context are the sole owners of the new COM objects. pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D11DeviceContext)> { let mut device: Option = None; let mut context: Option = None; diff --git a/crates/pf-gpu/src/lib.rs b/crates/pf-gpu/src/lib.rs index 61090e82..b663482c 100644 --- a/crates/pf-gpu/src/lib.rs +++ b/crates/pf-gpu/src/lib.rs @@ -914,3 +914,39 @@ mod tests { } } } + +/// Pick the render GPU LUID the Windows pipeline is created on: the IDD-push capturer's +/// shared-texture ring, the IddCx `SET_RENDER_ADAPTER` pin, and (via the captured frame's device) +/// NVENC/AMF/QSV all follow this one decision — see [`selected_gpu`] for the precedence (operator +/// preference > `PUNKTFUNK_RENDER_ADAPTER` substring > max `DedicatedVideoMemory`). A configured +/// preference that doesn't match a present GPU falls back to auto selection (with a warning) rather +/// than returning `None`, so a stale preference never stops the host from streaming. +/// +/// Lives here (not in a host module) so BOTH the capture and encode subsystem crates depend on it +/// as a peer of GPU selection instead of the orchestrator — the plan's `windows/adapter.rs`, folded +/// into `pf-gpu` (plan §W6). It was historically the SudoVDA backend's, then the host's +/// `win_adapter.rs`; the LUID-shaped view of [`selected_gpu`] plus the per-decision logging. +#[cfg(target_os = "windows")] +pub fn resolve_render_adapter_luid() -> Option { + match selected_gpu() { + Some(sel) => { + tracing::info!( + adapter = sel.info.name, + vram_mb = sel.info.vram_bytes / (1024 * 1024), + source = sel.source.tag(), + "render adapter selected" + ); + if sel.source == PickSource::PreferenceMissing { + tracing::warn!( + "the preferred GPU is not present — auto-selected the adapter above \ + (fix or clear the preference in the web console)" + ); + } + Some(sel.info.luid()) + } + None => { + tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER"); + None + } + } +} diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 156b3fef..cb7cba0b 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -25,6 +25,11 @@ pf-frame = { path = "../pf-frame" } # Windows display-topology helpers (CCD/GDI mode-set, PnP monitor devnodes, display-change watch), # extracted to a leaf crate (plan §W6). Empty on non-Windows, so it lives in the main deps. pf-win-display = { path = "../pf-win-display" } +# Video encode backends (NVENC/VAAPI/AMF/QSV/Vulkan-Video/PyroWave/openh264) behind one Encoder +# trait, extracted to a subsystem crate (plan §W6). The host's nvenc/amf-qsv/vulkan-encode/pyrowave +# features forward here (see [features]); the heavy encoder deps (ffmpeg-next, the NVENC SDK, +# openh264, pyrowave-sys) moved with it. +pf-encode = { path = "../pf-encode" } # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP). quinn = "0.11" anyhow = "1" @@ -102,14 +107,7 @@ log = "0.4" # crate vendors libopus (cmake-built from source — no system lib, no vcpkg), so it builds on Windows # MSVC too (needs CMake + NASM, both on the box). Both platforms that have an audio-capture backend. [target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] -# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only compiled -# under `--features pyrowave`. Stub-empty on other targets, so the cfg here is belt-and-braces. -pyrowave-sys = { path = "../pyrowave-sys", optional = true } opus = "0.3" -# Software H.264 encoder — the GPU-less encode path on both Linux and Windows (and a fallback when no -# hardware encoder is available). The default `source` feature statically compiles OpenH264 (BSD-2) — -# no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path. -openh264 = "0.9" [target.'cfg(target_os = "linux")'.dependencies] # `screencast` gates the ScreenCast portal module; `remote_desktop` adds the RemoteDesktop @@ -117,16 +115,7 @@ openh264 = "0.9" # `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not # needed — we drive PipeWire with the `pipewire` crate below. ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] } -ffmpeg-next = "8" libc = "0.2" -# Direct-SDK NVENC on Linux (design/linux-direct-nvenc.md): the RAW `sys::nvEncodeAPI` types only — -# the entry points are resolved at RUNTIME from the driver's `libnvidia-encode.so.1` -# (encode/linux/nvenc_cuda.rs), NOT link-imported, so the same binary starts fine on AMD/Intel -# Linux boxes (no NVIDIA driver) and falls through to VAAPI/software. `ci-check` = vendored -# bindings + cudarc `dynamic-loading` (no CUDA toolkit/headers at build); we never call the crate's -# cudarc — CUDA is driven through the existing `zerocopy::cuda` dlopen table. Same crate + feature -# as the Windows target dep (Cargo.toml, windows target section) so the `sys` structs never drift. -nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true } # Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is # unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions. pipewire = "0.9" @@ -254,20 +243,6 @@ winreg = "0.56" roxmltree = "0.21" # WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path). wasapi = "0.23" -# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver), -# driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency. -# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with -# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches -# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how -# the crate builds on docs.rs/CI. We enable it so the GPU-less VM/CI compiles; the DirectX NVENC path -# never calls CUDA at runtime, so the pinned CUDA bindings version is irrelevant. -nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = true } -# AMD (AMF) + Intel (QSV) hardware encode on Windows via libavcodec — the analogue of the Linux -# VAAPI backend (`src/encode/ffmpeg_win.rs`). Optional + behind the `amf-qsv` feature because it -# link-imports the FFmpeg libs at build time (needs a `FFMPEG_DIR` with the AMF/QSV encoders — the -# same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs -# at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62). -ffmpeg-next = { version = "8", optional = true } # Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend # (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, # defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs @@ -275,32 +250,29 @@ ffmpeg-next = { version = "8", optional = true } pf-driver-proto = { path = "../pf-driver-proto" } bytemuck = { version = "1.19", features = ["derive"] } +# The encode feature flags now FORWARD to the pf-encode subsystem crate (the heavy encoder deps — +# ffmpeg-next, the NVENC SDK, openh264, pyrowave-sys — moved there, plan §W6). Selecting a feature +# on the host turns on the matching backend inside pf-encode. [features] # PyroWave ships in every default build (the codec stays strictly opt-in per session — a client # must explicitly prefer CODEC_PYROWAVE; nothing changes for normal HEVC/AV1 sessions). default = ["pyrowave"] -# NVENC hardware encode (Windows). OFF by default (it pulls the NVENC SDK crate); nothing is -# needed at link time — the entry points are resolved at RUNTIME from the driver's -# nvEncodeAPI64.dll (encode/windows/nvenc.rs `load_api`), so the same binary starts fine on -# AMD/Intel-only boxes and falls through to AMF/QSV/software. Build the GPU host with -# `--features nvenc`. -nvenc = ["dep:nvidia-video-codec-sdk"] -# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a -# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used, -# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the -# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`. -amf-qsv = ["dep:ffmpeg-next"] -# Raw Vulkan Video HEVC encode on Linux (AMD/Intel) — real reference-frame-invalidation loss -# recovery via explicit DPB reference slots (design/linux-vulkan-video-encode.md), the open-stack -# twin of the direct-NVENC path. OFF by default; pulls NO new dependency (reuses the `ash` Vulkan -# bindings already carried for the dmabuf zero-copy bridge). Runtime-gated further by -# PUNKTFUNK_VULKAN_ENCODE (opt-in for now). Build the AMD/Intel RFI host with `--features vulkan-encode`. -vulkan-encode = [] -# PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md). -# Builds the vendored codec from source (crates/pyrowave-sys, CMake + bindgen; Linux/Windows — -# the encoder backend itself is Linux-only, the Windows host just carries the library). ON by -# default (see `default` above); sessions reach it only through explicit client opt-in. -pyrowave = ["dep:pyrowave-sys"] +# NVENC hardware encode (Linux CUDA + Windows D3D11). OFF by default; entry points resolved at +# RUNTIME from the driver DLL/so, so the same binary starts fine on AMD/Intel boxes. Build the GPU +# host with `--features nvenc`. +nvenc = ["pf-encode/nvenc"] +# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: needs a `FFMPEG_DIR` +# (BtbN lgpl-shared with `*_amf`/`*_qsv`) at build and bundles the FFmpeg DLLs at runtime. Build the +# all-vendor GPU host with `--features nvenc,amf-qsv`. +amf-qsv = ["pf-encode/amf-qsv"] +# Raw Vulkan Video HEVC/AV1 encode on Linux (AMD/Intel) — real reference-frame-invalidation loss +# recovery via explicit DPB reference slots (design/linux-vulkan-video-encode.md). OFF by default; +# reuses pf-encode's `ash` bindings (no new dep). Runtime-gated further by PUNKTFUNK_VULKAN_ENCODE. +vulkan-encode = ["pf-encode/vulkan-encode"] +# PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md). Builds +# the vendored codec from source in pf-encode. ON by default (see `default`); sessions reach it only +# through explicit client opt-in. +pyrowave = ["pf-encode/pyrowave"] # Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging # builds of this crate never execute the winresource block). diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index b878720d..d0fa92ce 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -65,8 +65,8 @@ use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos}; // `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides // `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts). use frame::{ - SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, - DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, + unpack_opened_detail, SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NONE, + DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, }; /// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not @@ -561,7 +561,7 @@ impl IddPushCapturer { // the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and // this open, or a stale kept monitor across an adapter re-init — the driver reports // TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that. - let luid = crate::win_adapter::resolve_render_adapter_luid().unwrap_or(LUID { + let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID { LowPart: (target.adapter_luid & 0xffff_ffff) as u32, HighPart: (target.adapter_luid >> 32) as i32, }); @@ -935,15 +935,63 @@ impl IddPushCapturer { } if Instant::now() > deadline { bail!( - "IDD-push: driver_status={st} but no frame published within 4s (despite compose \ - kicks) — the virtual display is likely in a format/size the ring can't match \ - (fullscreen game?); falling back" + "IDD-push: no frame published within 4s (despite compose kicks) — {}; \ + falling back", + self.no_first_frame_diagnosis(st) ); } std::thread::sleep(Duration::from_millis(20)); } } + /// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live + /// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames + /// states look identical from the host side but have disjoint causes and fixes; the lid-closed + /// field report burned days for lack of exactly this line. Appends a console-session hint when + /// the host itself is in the wrong session (display writes + input kicks can't work from there). + fn no_first_frame_diagnosis(&self, st: u32) -> String { + let what = match st { + // The delivery was never consumed: no swap-chain worker ran for this monitor at all. + DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \ + consumed, so the OS ran no swap-chain worker for this monitor (display not \ + composed at all: console display-off / modern standby, or the mode commit \ + never reached the adapter)" + .to_string(), + DRV_STATUS_OPENED => { + // SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping + // (same best-effort diagnostic access as the `driver_status` read in the caller); + // no reference into the shared region is formed. + let detail = unsafe { (*self.header).driver_status_detail }; + match unpack_opened_detail(detail) { + Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \ + ZERO frames — an undamaged or powered-off desktop, and the compose \ + kicks didn't bite (synthetic input is blocked on the secure desktop)" + .to_string(), + Some((offered, mismatched)) => format!( + "driver attached and DWM composed {offered} frame(s), but none matched \ + the ring — {mismatched} dropped for a size/format mismatch (the \ + display's actual mode differs from what the host sized the ring to: \ + a mid-open mode-set, a fullscreen game, or a stale GDI view)" + ), + // A pre-detail driver never stamps the live bit — say so rather than guess. + None => "driver attached but published nothing; this pf-vdisplay build \ + predates attach diagnostics, so the cause can't be named — update the \ + driver for a precise line here" + .to_string(), + } + } + other => format!("driver_status={other} (unexpected at this point)"), + }; + match crate::interactive::console_session_mismatch() { + Some((own, console)) => format!( + "{what} [host is in session {own} but the console is session {console} — display \ + writes and input kicks cannot work from a non-console session; reconnect the \ + console or run via the installed service]" + ), + None => what, + } + } + #[inline] fn latest(&self) -> u64 { // SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a diff --git a/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs b/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs index 666beb5f..3abe6101 100644 --- a/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs +++ b/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs @@ -140,7 +140,7 @@ impl Capturer for SyntheticNv12Capturer { /// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error. unsafe fn resolve_render_adapter() -> Result { let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; - if let Some(luid) = crate::win_adapter::resolve_render_adapter_luid() { + if let Some(luid) = pf_gpu::resolve_render_adapter_luid() { if let Ok(a) = factory.EnumAdapterByLuid::(luid) { return Ok(a); } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index f1c5ecff..6ffcca93 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -35,7 +35,13 @@ mod ddc; #[cfg(target_os = "linux")] #[path = "linux/drm_sync.rs"] mod drm_sync; -mod encode; +// The video encode backends live in the `pf-encode` leaf crate (plan §W6); this shim keeps every +// existing `crate::encode::*` path valid (the host is the sole consumer, via the negotiator + the +// GameStream/native/mgmt planes). Feature flags (nvenc/amf-qsv/vulkan-encode/pyrowave) forward to +// pf-encode from this crate's `[features]`. +mod encode { + pub(crate) use pf_encode::*; +} mod events; mod gamestream; #[cfg(target_os = "linux")] @@ -67,9 +73,6 @@ mod spike; mod stats_recorder; mod stream_marker; mod vdisplay; -#[cfg(target_os = "windows")] -#[path = "windows/win_adapter.rs"] -mod win_adapter; // The Windows display-topology cluster (CCD/GDI mode-set, PnP monitor devnodes, the display-change // watch) lives in the `pf-win-display` leaf crate (plan §W6); import the modules at the crate root // so every existing `crate::{win_display,monitor_devnode,display_events}::*` path stays valid. diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 98aa392c..7cadcc71 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -1445,12 +1445,12 @@ pub(crate) fn slot_id_for(client_fp: Option<[u8; 32]>, mode: (u32, u32)) -> u32 /// The render-GPU pin (backend-neutral): IDD-push — the sole Windows capture path — runs NVENC on the /// render adapter, so it must always be pinned to the selected encoder GPU (a hybrid box would /// otherwise render on the wrong one). The selection itself (web-console preference > -/// `PUNKTFUNK_RENDER_ADAPTER` > max VRAM) lives in [`crate::win_adapter::resolve_render_adapter_luid`]. +/// `PUNKTFUNK_RENDER_ADAPTER` > max VRAM) lives in [`pf_gpu::resolve_render_adapter_luid`]. /// (This was gated on the removed `PUNKTFUNK_IDD_PUSH` knob — a dispatch disagreement, since capture /// stopped consulting it when DDA/WGC were removed.) fn resolve_render_pin() -> Option { tracing::info!("IDD push: pinning the render GPU (SET_RENDER_ADAPTER)"); - crate::win_adapter::resolve_render_adapter_luid() + pf_gpu::resolve_render_adapter_luid() } /// A reused monitor keeps the render GPU the driver was pinned to at its ADD — the pin is never diff --git a/crates/punktfunk-host/src/windows/win_adapter.rs b/crates/punktfunk-host/src/windows/win_adapter.rs deleted file mode 100644 index f5a03adc..00000000 --- a/crates/punktfunk-host/src/windows/win_adapter.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Backend-neutral DXGI adapter selection. -//! -//! The discrete render-GPU LUID picker used to live in the SudoVDA backend (`vdisplay::sudovda`) — a -//! historical accident, since it is display-utility, not SudoVDA-specific. It lives here so the capturers -//! (IDD-push) and the pf-vdisplay backend depend on it as a *peer* instead of reaching into the SudoVDA -//! module — breaking that circular reach-in, which let the SudoVDA backend be dropped without losing this -//! helper (audit §9 / Goal 2 — done). This is the plan's `windows/adapter.rs`. -//! -//! The selection logic itself now lives in [`crate::gpu`] (shared with the mgmt API's GPU -//! endpoints): **operator preference (web console) > `PUNKTFUNK_RENDER_ADAPTER` substring > max -//! `DedicatedVideoMemory`**, WARP/Basic-Render and indirect-display ghost twins always excluded. -//! This wrapper is the LUID-shaped view of it, plus the per-decision logging (call sites are -//! per-session, never per-frame). - -use windows::Win32::Foundation::LUID; - -/// Pick the render GPU LUID the pipeline is created on: the IDD-push capturer's shared-texture -/// ring, the IddCx SET_RENDER_ADAPTER pin, and (via the captured frame's device) NVENC/AMF/QSV all -/// follow this one decision — see [`pf_gpu::selected_gpu`] for the precedence. A configured -/// preference that doesn't match a present GPU falls back to auto selection (with a warning) -/// rather than returning `None`, so a stale preference never stops the host from streaming. -pub(crate) fn resolve_render_adapter_luid() -> Option { - match pf_gpu::selected_gpu() { - Some(sel) => { - tracing::info!( - adapter = sel.info.name, - vram_mb = sel.info.vram_bytes / (1024 * 1024), - source = sel.source.tag(), - "render adapter selected" - ); - if sel.source == pf_gpu::PickSource::PreferenceMissing { - tracing::warn!( - "the preferred GPU is not present — auto-selected the adapter above \ - (fix or clear the preference in the web console)" - ); - } - Some(sel.info.luid()) - } - None => { - tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER"); - None - } - } -}