diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index fba08b52..70c28e01 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -22,7 +22,9 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use pf_frame::DmabufFrame; /// Produces frames from a captured output. Lives on its own thread, feeding the encoder -/// over a bounded drop-oldest channel (never block the compositor). +/// over a bounded channel that never blocks the compositor — the Linux portal's is drop-**newest** +/// (a full channel discards the ARRIVING frame; `linux/mod.rs`'s `try_send`), so under a consumer +/// stall the encoder is handed the stalest queued frame. pub trait Capturer: Send { fn next_frame(&mut self) -> Result; @@ -66,11 +68,6 @@ pub trait Capturer: Send { /// duration of a stream, `false` when it ends. fn set_active(&self, _active: bool) {} - /// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the - /// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown / - /// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet). - /// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram), - /// so the two stay a single source of truth. May change mid-session if the source is regraded. /// The capture source's LIVE cursor state, when it arrives out-of-band from the frames /// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and /// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves @@ -99,6 +96,11 @@ pub trait Capturer: Send { /// no-op: every non-gamescope capturer already has a cursor source. fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option)>) {} + /// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the + /// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown / + /// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet). + /// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram), + /// so the two stay a single source of truth. May change mid-session if the source is regraded. fn hdr_meta(&self) -> Option { None } diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 8244c710..697a1b30 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -2265,29 +2265,38 @@ mod pipewire { ); } else if zerocopy && !want_dmabuf { tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); - } else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() { + } else if vaapi_passthrough { + // The raw-passthrough advertisement. Covers the PyroWave case too: its extra + // Vulkan-importable modifiers were appended (and logged) just above, so this arm must + // NOT be gated on `pyrowave_modifiers.is_empty()` — that gate is what dropped a fully + // zero-copy PyroWave session through to the CPU-path warning below (L11). tracing::info!( native_nv12_preferred = prefer_native_nv12, - "zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \ - when enabled, packed RGB fallback)" + modifier_count = modifiers.len(), + pyrowave_extended = !policy.pyrowave_modifiers.is_empty(), + "zero-copy: advertising DMA-BUF modifiers for direct encoder import (LINEAR \ + always; native NV12 first when enabled, packed RGB fallback)" ); - } else if want_dmabuf && !vaapi_passthrough { + } else if want_dmabuf { tracing::info!( count = modifiers.len(), sample = ?&modifiers[..modifiers.len().min(6)], "zero-copy: advertising EGL-importable dmabuf modifiers" ); } else if backend_is_vaapi && policy.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. + // Reached only when no dmabuf is advertised at all (every arm above rules out a + // zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays + // three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) — + // make the silent fallback visible. tracing::warn!( "VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \ - upload) — zero-copy was disabled ({}); clear PUNKTFUNK_ZEROCOPY to restore \ - the dmabuf default", + upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \ + restore the dmabuf default", if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() { "PUNKTFUNK_ZEROCOPY is set falsy" } else { - "downgraded after a failed dmabuf negotiation" + "a latched downgrade after a failed dmabuf negotiation, or this session's \ + output format asked for CPU frames" } ); } @@ -2679,10 +2688,13 @@ mod pipewire { // version and are composited host-side instead (see `xfixes_cursor.rs`). // // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers - // (offering shm too makes the compositor pick shm). The modifier list is advertised with - // DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in - // `param_changed` (the two-step DMA-BUF handshake). Otherwise offer the multi-format shm - // pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit + // (offering shm too makes the compositor pick shm). The modifier list goes out as a plain + // MANDATORY `ChoiceEnum::Enum` and the producer fixates one of the alternatives directly — + // this is NOT the two-step DONT_FIXATE handshake (libspa 0.9's `ChoiceFlags` cannot express + // `SPA_POD_PROP_FLAG_DONT_FIXATE`, and `param_changed` only READS the fixated format, it + // re-emits nothing). Worth revisiting if a multi-modifier offer is ever seen to fail + // negotiation on a compositor that needs the allocator round-trip. Otherwise offer the + // multi-format shm pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit // PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering // SDR alongside would make the producer pick its earlier-listed SDR format, and the // negotiation-timeout path latches the process-wide SDR downgrade if nothing matches. @@ -2751,7 +2763,9 @@ mod pipewire { ) .context("pw stream connect")?; - // Blocks this thread, pumping frame callbacks until process exit. + // Blocks this thread, pumping frame callbacks until the capturer's `Drop` fires the quit + // channel attached above (`_quit_attach` → `quit_loop.quit()`), at which point `run()` + // returns and the thread unwinds — releasing the importer / CUDA context deterministically. mainloop.run(); Ok(()) } diff --git a/crates/pf-capture/src/windows/dxgi.rs b/crates/pf-capture/src/windows/dxgi.rs index fd69c8bf..1e12ded9 100644 --- a/crates/pf-capture/src/windows/dxgi.rs +++ b/crates/pf-capture/src/windows/dxgi.rs @@ -40,12 +40,20 @@ use windows::Win32::Graphics::Dxgi::Common::{ DXGI_FORMAT_R16_UNORM, DXGI_SAMPLE_DESC, }; -/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. If this -/// stays 0 while DDA churns with ACCESS_LOST, the hook is NOT on DXGI's GPU-preference path on this -/// build (so reparenting can't be the cause — look at composition/independent-flip instead). >0 with -/// continuing churn means the hook fires but reparenting isn't the trigger here. +/// How many times DXGI has actually called our hooked `NtGdiDdDDIGetCachedHybridQueryValue`. +/// Reported by [`hybrid_hook_hits`] on every IDD-push open, which is the first point at which DXGI +/// has been exercised (factory → `EnumAdapterByLuid` → device). The patch-readback check in +/// [`install_gpu_pref_hook`] proves the BYTES landed; only this counter proves DXGI actually +/// reaches the export on this build — so `0` here means the hook is inert and a +/// reparenting-flavoured symptom (see [`install_gpu_pref_hook`]) has some other cause. static HYBRID_HOOK_HITS: AtomicU64 = AtomicU64::new(0); +/// See [`HYBRID_HOOK_HITS`] — surfaced in the IDD-push open log so a dead hook is visible in the +/// field instead of being a silent write-only counter. +pub(crate) fn hybrid_hook_hits() -> u64 { + HYBRID_HOOK_HITS.load(Ordering::Relaxed) +} + // kernel32 — declared directly so we don't pull the whole Win32_System_Diagnostics_Debug feature for // one call. FlushInstructionCache serializes the i-cache after the inline patch: the patch is written // on the main thread but DXGI runs the hooked export from the encode/worker thread (possibly a @@ -75,11 +83,19 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 { /// The win32u GPU-preference hook (the same technique Apollo applies, reimplemented here from the /// documented DDI — no GPL source copied). On a HYBRID-GPU box DXGI resolves a GPU preference /// (registry + power settings + the hybrid-adapter DDI) and REPARENTS outputs onto the chosen render -/// GPU — which constantly invalidates Desktop Duplication (DXGI_ERROR_ACCESS_LOST 0x887A0026, the -/// freeze/churn observed on the RTX 4090 + AMD iGPU box; `SET_RENDER_ADAPTER` is ignored there). Faking -/// a cached preference of UNSPECIFIED makes DXGI skip the resolution, so the output is NOT reparented -/// and DDA stays stable on one adapter (this is what makes Apollo's DDA work on this hardware). -/// Installed once, before the first DXGI factory/enumeration; lasts the process lifetime (like Apollo). +/// GPU, ignoring `SET_RENDER_ADAPTER` (observed on the RTX 4090 + AMD iGPU box). Faking a cached +/// preference of UNSPECIFIED makes DXGI skip that resolution, so an output is NOT reparented and +/// stays on one adapter. +/// +/// **Why it is still installed now that DXGI Desktop Duplication is gone:** the IDD-push ring and +/// the driver's swap-chain must live on the SAME adapter, and the host pins that with +/// `SET_RENDER_ADAPTER` at monitor ADD (see `idd_push::open_inner`). A DXGI reparent moves the +/// virtual output off the pinned GPU behind the host's back — which surfaces as the driver's +/// `DRV_STATUS_TEX_FAIL` ("could not open our textures — render-adapter mismatch") and costs a +/// ring rebind. So the hook's job changed from "keep DDA on one adapter" to "keep the VIRTUAL +/// DISPLAY on the adapter we pinned"; the mechanism is unchanged. Installed once from +/// `main.rs`, before the virtual-display setup creates the first DXGI factory; lasts the process +/// lifetime. [`hybrid_hook_hits`] reports whether DXGI ever actually calls it. pub fn install_gpu_pref_hook() { use std::sync::Once; static HOOK: Once = Once::new(); @@ -103,25 +119,38 @@ pub fn install_gpu_pref_hook() { GetAwarenessFromDpiAwarenessContext, GetThreadDpiAwarenessContext, SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, }; - // Per-monitor-v2 DPI awareness — REQUIRED for IDXGIOutput5::DuplicateOutput1 (without it the - // call returns E_ACCESSDENIED forever, forcing the legacy DuplicateOutput path). Matches - // Apollo's startup. SetProcessDpiAwarenessContext fails with E_ACCESS_DENIED if awareness was - // already set (manifest / earlier call) — log the outcome AND the effective awareness so a - // 100% DuplicateOutput1 E_ACCESSDENIED is diagnosable instead of silent. + // Per-monitor-v2 DPI awareness. It was originally set here because + // `IDXGIOutput5::DuplicateOutput1` returns E_ACCESSDENIED without it; DDA is gone, but the + // awareness still matters — an UNAWARE/SYSTEM-aware process gets DPI-VIRTUALIZED window and + // cursor coordinates, while every geometry the host computes comes from CCD in PHYSICAL + // pixels (`source_desktop_rect`, `desktop_bounds`). Mixing the two mis-aims the compose + // kick's `SetCursorPos` and the cursor-blend placement on any scaled display. Set here + // because this is the earliest process-wide hook point. `SetProcessDpiAwarenessContext` + // fails with E_ACCESS_DENIED if awareness was already set (manifest / earlier call) — log + // the outcome AND the effective awareness so a mis-scaled pointer is diagnosable. match SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) { Ok(()) => tracing::info!("DPI awareness set: PER_MONITOR_AWARE_V2"), Err(e) => tracing::warn!(error = ?e, - "SetProcessDpiAwarenessContext failed (already set?) — DuplicateOutput1 may E_ACCESSDENIED"), + "SetProcessDpiAwarenessContext failed (already set?) — cursor/desktop coordinates \ + may be DPI-virtualized against the host's physical-pixel CCD geometry"), } - // 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). DuplicateOutput1 needs 2. + // 0=UNAWARE 1=SYSTEM 2=PER_MONITOR(_V2). Physical-pixel coordinates need 2. let awareness = GetAwarenessFromDpiAwarenessContext(GetThreadDpiAwarenessContext()).0; - tracing::info!(awareness, "effective DPI awareness (need 2=PER_MONITOR for DuplicateOutput1)"); + tracing::info!( + awareness, + "effective DPI awareness (need 2=PER_MONITOR for physical-pixel coordinates)" + ); let Ok(lib) = LoadLibraryA(s!("win32u.dll")) else { - tracing::warn!("GPU-pref hook: win32u.dll not loadable — skipping (DDA may churn on hybrid GPUs)"); + tracing::warn!( + "GPU-pref hook: win32u.dll not loadable — skipping (on a hybrid-GPU box DXGI may \ + reparent the virtual display off the pinned render adapter → TEX_FAIL rebinds)" + ); return; }; let Some(target) = GetProcAddress(lib, s!("NtGdiDdDDIGetCachedHybridQueryValue")) else { - tracing::warn!("GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping"); + tracing::warn!( + "GPU-pref hook: NtGdiDdDDIGetCachedHybridQueryValue not exported — skipping" + ); return; }; let target = target as usize as *mut u8; @@ -135,7 +164,14 @@ pub fn install_gpu_pref_hook() { patch[10] = 0xFF; patch[11] = 0xE0; // jmp rax let mut old = PAGE_PROTECTION_FLAGS(0); - if VirtualProtect(target as *const c_void, 12, PAGE_EXECUTE_READWRITE, &mut old).is_err() { + if VirtualProtect( + target as *const c_void, + 12, + PAGE_EXECUTE_READWRITE, + &mut old, + ) + .is_err() + { tracing::warn!("GPU-pref hook: VirtualProtect failed — skipping"); return; } @@ -153,12 +189,15 @@ pub fn install_gpu_pref_hook() { std::ptr::copy_nonoverlapping(target, readback.as_mut_ptr(), 12); if readback == patch { tracing::info!( - "GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): reparenting disabled" + "GPU-pref hook installed + verified (win32u hybrid-query -> UNSPECIFIED): DXGI \ + output reparenting disabled. Whether DXGI actually CALLS it shows up as \ + hybrid_hook_hits on the IDD-push open line." ); } else { tracing::error!( want = %format!("{patch:02x?}"), got = %format!("{readback:02x?}"), - "GPU-pref hook patch did NOT land — hook is DEAD (DXGI will still reparent → ACCESS_LOST churn)" + "GPU-pref hook patch did NOT land — hook is DEAD (on a hybrid-GPU box DXGI can \ + still reparent the virtual display off the pinned render adapter)" ); } }); @@ -775,22 +814,6 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) { (yc, cbc, crc) } -/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware -/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0) -/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64 -/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error -/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL. -#[cfg(target_os = "windows")] -pub fn hdr_p010_selftest() -> Result<()> { - hdr_p010_selftest_at(64, 64, None) -} - -/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor -/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on -/// top of the 64×64 default because the field sessions run at capture resolutions whose height -/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar -/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default -/// adapter is not the one the session encodes on. /// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/ /// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the /// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with @@ -920,6 +943,18 @@ pub fn hdr_p010_convert_bars_on_luid( } } +/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware +/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0) +/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64 +/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error +/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL. +/// +/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run +/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a +/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI +/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where +/// the default adapter is not the one the session encodes on. The chosen adapter is always printed, +/// because a PASS only means anything for the GPU it actually ran on. #[cfg(target_os = "windows")] pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option) -> Result<()> { use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN}; @@ -1286,8 +1321,15 @@ use windows::Win32::Graphics::Dxgi::Common::{ /// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT /// the 3D engine, so the per-frame RGB→YUV conversion does not contend with a GPU-saturating game (the /// HDR pixel-shader path and NVENC's internal RGB→YUV both use the 3D/compute engine, which an AAA -/// title pins at ~100%). Output is NV12 (SDR, BT.709 studio-range) or P010 (HDR, BT.2020 PQ -/// studio-range) — NVENC's native YUV inputs, so it encodes them with no further conversion. +/// title pins at ~100%). Output is **always NV12, BT.709 studio-range** — one of NVENC's native YUV +/// inputs, so it encodes with no further conversion. +/// +/// It does NOT produce P010/BT.2020 PQ: [`VideoConverter::new`] pins the output colour space to +/// `YCBCR_STUDIO_G22_LEFT_P709` unconditionally, and NVIDIA's video processor cannot do RGB→P010 at +/// all (it renders green) — the HDR path is [`HdrP010Converter`]'s shader instead. The `scrgb_input` +/// arm of `new` is likewise the only part of the HDR story here (an FP16 desktop tone-mapped DOWN to +/// 8-bit BT.709), and it currently has no caller: `idd_push::ensure_converter` builds this converter +/// only on the SDR/BGRA path and always passes `false`. pub(crate) struct VideoConverter { vdev: ID3D11VideoDevice, vctx: ID3D11VideoContext1, @@ -1361,7 +1403,8 @@ impl VideoConverter { } } - /// Convert `input` (BGRA or scRGB FP16) → `output` (NV12 or P010) on the video engine. Views are + /// Convert `input` (BGRA, or scRGB FP16 for a converter built with `scrgb_input`) → `output` + /// (NV12, BT.709 studio-range — see the type doc: never P010) on the video engine. Views are /// created per call (cheap relative to the Blt) so the input texture can vary frame to frame. pub(crate) unsafe fn convert( &self, diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 706ad5d6..169e7fc8 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -234,9 +234,16 @@ impl Drop for KeyedMutexGuard<'_> { /// desktop region (always true single-display), two net-zero 1 px relative moves (the historical /// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the /// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties -/// the cursor layer of the display it lands on, so the target composes at least one frame; the -/// round trip is sub-millisecond and throttled). Best-effort — injection can be unavailable on the -/// secure desktop, where a fresh compose just happened anyway. +/// the cursor layer of the display it lands on, so the target composes at least one frame). +/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just +/// happened anyway. +/// +/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two +/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick +/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick +/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or +/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle +/// plus the callers' own 600–800 ms schedules bound how often it can happen. /// /// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual /// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a @@ -1097,6 +1104,12 @@ impl IddPushCapturer { client_10bit, want_444, ring_fp16 = display_hdr, + // Whether DXGI ever reached the win32u GPU-preference hook. By this point the + // factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a + // 0 here means the hook is inert on this build — the first thing to check if a + // hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches + // (`dxgi::install_gpu_pref_hook`). + hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(), "IDD push(host): created sealed ring + delivered the channel; waiting for the driver \ to attach + publish" ); @@ -1273,6 +1286,9 @@ impl IddPushCapturer { "IDD push: no first frame after attach delivery — falling back to a synthetic \ compose kick (stash-capable drivers republish instantly; old driver?)" ); + // May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see + // `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time + // first-frame gate, so no frames are flowing yet. kick_dwm_compose(self.target_id); next_kick = Instant::now() + Duration::from_millis(800); } @@ -2022,7 +2038,11 @@ impl IddPushCapturer { // never sees a frame and the 3 s recover-or-drop above kills a healthy session. A // stash-capable driver republishes its retained frame at the re-attach, so this kick // is the legacy-driver fallback here too. Nudge DWM (rate-limited) once the natural - // post-recreate compose (and the stash republish) has had its chance. + // post-recreate compose (and the stash republish) has had its chance. This is the ONE + // call site on the live frame path: the kick may BLOCK this (capture/encode) thread + // ~35 ms on its cursor-on-a-sibling-display branch (see `kick_dwm_compose`'s COST + // note) — acceptable only because we are already ≥600 ms into a recovery window with + // no frames arriving, and the 800 ms schedule below bounds the repeat rate. if since.elapsed() > Duration::from_millis(600) && self.last_kick.elapsed() > Duration::from_millis(800) { diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 11f884f2..306857f4 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -16,7 +16,7 @@ pub use pf_capture::{ capturer_supports_444, capturer_supports_hdr, Capturer, FastSyntheticCapturer, SyntheticCapturer, }; -// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (main.rs subcommands) and +// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and // `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules. #[cfg(target_os = "windows")] pub use pf_capture::{dxgi, synthetic_nv12}; diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 65b9607d..bd20f761 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -291,9 +291,14 @@ pub(super) async fn negotiate( let host_wants_444 = pf_host_config::config().four_four_four; let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0; // The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade - // gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010 - // 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 + // gate. Linux's portal capturer always can (`capturer_supports_444` returns `true` + // unconditionally). On WINDOWS the IDD-push path CAN too — for an SDR 4:4:4 session it passes + // the BGRA ring slot straight through, skipping the NV12 VideoConverter — but only a backend + // that ingests RGB and CSCs it to 4:4:4 itself can consume that, so the Windows arm forwards + // `resolved_backend_ingests_rgb_444()` (today: direct-NVENC only; AMF can't 4:4:4 at all and + // the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR display still downgrades to 4:2:0 + // at capture time — there is no 10-bit full-chroma source — and the encoder's caps cross-check + // reports that truth. (Replaces the old `single_process` gate — single-process is now the only // topology, and 4:4:4 routed to DDA, which was removed.) // PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma // (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real