From aaa3dcec32b401f8f276144ea8baa45f51c5b88b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 23:59:41 +0200 Subject: [PATCH] =?UTF-8?q?refactor(host/W4):=20make=20the=20capture?= =?UTF-8?q?=E2=86=92encode=20edge=20one-way=20(OutputFormat=20back-ref)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture.rs facade no longer re-derives the encode backend. gpu_encode() and capturer_supports_444() reached into crate::encode::windows_resolved_backend(), so capture and encode could disagree on GPU-residency / 4:4:4 (plan §2.4). Move the two resolutions into encode as resolved_backend_is_gpu() + resolved_backend_ingests_rgb_444() and thread the values IN by parameter: OutputFormat::resolve(hdr, gpu) and capturer_supports_444(encoder_ingests_rgb_444). Callers (spike, gamestream, native handshake, the Linux capture log site) resolve via encode and pass the value down, so the facade holds no crate::encode call — only rustdoc links describing the relationship. Completes task #8 of W4. Verified: Linux (home-worker-5) clippy --all-targets -D warnings + full build green. Windows (.173) verify owed — box was offline this session. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/capture.rs | 54 +++++++------------ .../punktfunk-host/src/capture/linux/mod.rs | 2 +- crates/punktfunk-host/src/encode.rs | 34 ++++++++++++ .../punktfunk-host/src/gamestream/stream.rs | 2 +- crates/punktfunk-host/src/native/handshake.rs | 3 +- crates/punktfunk-host/src/spike.rs | 2 +- 6 files changed, 58 insertions(+), 39 deletions(-) diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 6c230f2f..fe427af0 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -81,12 +81,14 @@ pub struct OutputFormat { impl OutputFormat { /// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`] - /// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend, - /// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already - /// resolved the encoder), so neither path makes a capturer re-derive it. - pub fn resolve(hdr: bool) -> Self { + /// (`crate::session_plan`) — the GameStream + spike paths. `gpu` is the encoder's GPU-residency, + /// resolved by the caller via [`crate::encode::resolved_backend_is_gpu`] and passed **in** (capture + /// never re-derives the backend — the one-way capture→encode edge, plan §2.4 / §W4); `hdr` as given. + /// The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already resolved the + /// encoder), so neither path makes a capturer re-derive it. + pub fn resolve(hdr: bool, gpu: bool) -> Self { OutputFormat { - gpu: gpu_encode(), + gpu, hdr, // The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only). chroma_444: false, @@ -94,26 +96,6 @@ impl OutputFormat { } } -/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single -/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU). -#[cfg(target_os = "windows")] -pub(crate) fn gpu_encode() -> bool { - !matches!( - crate::encode::windows_resolved_backend(), - crate::encode::WindowsBackend::Software - ) -} -#[cfg(not(target_os = "windows"))] -pub(crate) fn gpu_encode() -> bool { - // The GPU-less software encoder (openh264) needs CPU-staged RGB frames; every other Linux - // backend (NVENC/CUDA, VAAPI) is GPU-resident. Mirrors `session_plan::resolve_encoder`, for the - // GameStream/spike entry points that use `OutputFormat::resolve` instead of a full `SessionPlan`. - !matches!( - crate::config::config().encoder_pref.as_str(), - "software" | "sw" | "openh264" - ) -} - /// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on /// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch /// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA @@ -453,25 +435,27 @@ pub fn capture_virtual_output( /// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The /// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only -/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows -/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up. +/// produce subsampled frames. `encoder_ingests_rgb_444` is the encoder half of the gate, resolved by +/// the caller ([`crate::encode::resolved_backend_ingests_rgb_444`]) and passed **in** so capture never +/// re-derives the backend (the one-way capture→encode edge, plan §2.4 / §W4). Linux (the portal capturer +/// feeding CPU RGB → `yuv444p`) can regardless; the Windows IDD-push path delivers subsampled NV12/P010 +/// today, so full-chroma capture there rides entirely on the encoder gate. #[cfg(target_os = "linux")] -pub(crate) fn capturer_supports_444() -> bool { +pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { true } #[cfg(target_os = "windows")] -pub(crate) fn capturer_supports_444() -> bool { - // IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 - // VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4 - // (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it - // (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR +pub(crate) fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool { + // IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter), + // but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just + // direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR // display can't be known here (the virtual display's mode settles after the Welcome); that // combination downgrades at capture time — the capturer emits P010 and the encoder's caps // cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way). - crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc + encoder_ingests_rgb_444 } #[cfg(not(any(target_os = "linux", target_os = "windows")))] -pub(crate) fn capturer_supports_444() -> bool { +pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { false } diff --git a/crates/punktfunk-host/src/capture/linux/mod.rs b/crates/punktfunk-host/src/capture/linux/mod.rs index 89cf5428..03042662 100644 --- a/crates/punktfunk-host/src/capture/linux/mod.rs +++ b/crates/punktfunk-host/src/capture/linux/mod.rs @@ -1657,7 +1657,7 @@ mod pipewire { sample = ?&modifiers[..modifiers.len().min(6)], "zero-copy: advertising EGL-importable dmabuf modifiers" ); - } else if backend_is_vaapi && crate::capture::gpu_encode() { + } else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() { // A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad + // swscale RGB→NV12 + surface upload) — make the silent fallback visible. tracing::warn!( diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index ece3ceae..cbe4e4c0 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -959,6 +959,40 @@ 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: +/// 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 { + !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 { + !matches!( + crate::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 ([`crate::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 { + windows_resolved_backend() == WindowsBackend::Nvenc +} +#[cfg(not(target_os = "windows"))] +pub(crate) 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 diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 089edb09..d5f34761 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -383,7 +383,7 @@ fn open_gs_virtual_source( // capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there). let capturer = capture::capture_virtual_output( vout, - capture::OutputFormat::resolve(cfg.hdr), + capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()), crate::session_plan::CaptureBackend::resolve(), ) .context("capture virtual output")?; diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 823643f1..539eaaaf 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -246,7 +246,8 @@ pub(super) async fn negotiate( // today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only // topology, and 4:4:4 routed to DDA, which was removed.) - let capture_supports_444 = crate::capture::capturer_supports_444(); + let capture_supports_444 = + crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444()); // The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the // compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when // the cheap gates already pass. The result is cached process-wide (a negative latches until diff --git a/crates/punktfunk-host/src/spike.rs b/crates/punktfunk-host/src/spike.rs index 6f190de2..0e22f8e5 100644 --- a/crates/punktfunk-host/src/spike.rs +++ b/crates/punktfunk-host/src/spike.rs @@ -108,7 +108,7 @@ pub fn run(opts: Options) -> Result<()> { .context("create virtual output")?; capture::capture_virtual_output( vout, - capture::OutputFormat::resolve(false), + capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()), crate::session_plan::CaptureBackend::resolve(), ) .context("capture virtual output")?