diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 52accf5..fe3f9f1 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -286,7 +286,7 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result); } // WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded @@ -316,11 +316,13 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result { tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA"); - dxgi::DuplCapturer::open(target, pref, keep).map(|c| Box::new(c) as Box) + dxgi::DuplCapturer::open(target, pref, keep, false) + .map(|c| Box::new(c) as Box) } Err(_) => { tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA"); - dxgi::DuplCapturer::open(target, pref, keep).map(|c| Box::new(c) as Box) + dxgi::DuplCapturer::open(target, pref, keep, false) + .map(|c| Box::new(c) as Box) } } } diff --git a/crates/punktfunk-host/src/capture/dxgi.rs b/crates/punktfunk-host/src/capture/dxgi.rs index 33bf574..65ef733 100644 --- a/crates/punktfunk-host/src/capture/dxgi.rs +++ b/crates/punktfunk-host/src/capture/dxgi.rs @@ -33,8 +33,8 @@ use windows::Win32::Graphics::Direct3D11::{ D3D11_USAGE_DYNAMIC, D3D11_USAGE_STAGING, D3D11_VIEWPORT, }; use windows::Win32::Graphics::Dxgi::Common::{ - DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R16G16B16A16_FLOAT, - DXGI_SAMPLE_DESC, + DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, + DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC, }; use windows::Win32::Graphics::Dxgi::{ CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput1, IDXGIOutput5, @@ -157,6 +157,7 @@ pub(crate) unsafe fn make_device( /// recovery to rebuild the whole capture on the current (possibly secure) input desktop. unsafe fn reopen_duplication( gdi_name: &str, + want_hdr: bool, ) -> Result<( ID3D11Device, ID3D11DeviceContext, @@ -165,7 +166,8 @@ unsafe fn reopen_duplication( )> { let (adapter, out) = find_output(gdi_name)?; let (dev, ctx) = make_device(&adapter)?; - let dupl = duplicate_output(&out, &dev).context("re-DuplicateOutput after ACCESS_LOST")?; + let dupl = + duplicate_output(&out, &dev, want_hdr).context("re-DuplicateOutput after ACCESS_LOST")?; Ok((dev, ctx, out, dupl)) } @@ -179,13 +181,19 @@ unsafe fn reopen_duplication( unsafe fn duplicate_output( output: &IDXGIOutput1, device: &ID3D11Device, + want_hdr: bool, ) -> Result { if let Ok(output5) = output.cast::() { - // BGRA8 only for now (SDR). NOTE: DuplicateOutput1 returns the FIRST format it can provide and - // DXGI will CONVERT to it — so listing FP16 first would hand back FP16 even on an SDR desktop, - // wrongly tripping the HDR path. Real HDR capture (FP16 first + IDXGIOutput6 colorspace - // detection to pick the path) is the follow-up once the churn is settled. - let formats = [DXGI_FORMAT_B8G8R8A8_UNORM]; + // For an HDR session, request FP16 FIRST so DuplicateOutput1 hands back the desktop's real + // scRGB HDR surface → the `hdr_fp16` path converts it to BT.2020 PQ 10-bit for NVENC Main10. + // For SDR request BGRA8 only: listing FP16 first would make DXGI hand back FP16 even on an SDR + // desktop, wrongly tripping the HDR path. (HDR DDA is used for the secure desktop, where the + // SudoVDA may be in HDR and legacy DuplicateOutput — the SDR-era API — can't capture FP16.) + let formats: &[DXGI_FORMAT] = if want_hdr { + &[DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_B8G8R8A8_UNORM] + } else { + &[DXGI_FORMAT_B8G8R8A8_UNORM] + }; // RETRY DuplicateOutput1. The caller releases the OLD duplication (self.dupl = None) immediately // before calling us, and the kernel-side teardown of that duplication is ASYNC — the FIRST // DuplicateOutput1 right after can race it and return E_ACCESSDENIED ("output still duplicated") @@ -207,14 +215,17 @@ unsafe fn duplicate_output( // and on the normal desktop the release-before-reduplicate + gentle recovery already keep the // legacy dup stable. Raise PUNKTFUNK_DUP_RETRY_N only on a box where DuplicateOutput1 can win // the old-dup-teardown race (then PUNKTFUNK_DUP_RETRY_MS sets the per-wait, default 200). + // HDR DDA genuinely NEEDS DuplicateOutput1 (legacy DuplicateOutput can't capture an FP16/HDR + // desktop — it returns E_INVALIDARG), so give it several attempts even on the secure desktop + // rather than bailing after one try to the useless legacy fallback. SDR keeps the default 1. let attempts: u64 = std::env::var("PUNKTFUNK_DUP_RETRY_N") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or(1) + .unwrap_or(if want_hdr { 5 } else { 1 }) .max(1); let mut last_err = None; for attempt in 0..attempts { - match output5.DuplicateOutput1(device, 0, &formats) { + match output5.DuplicateOutput1(device, 0, formats) { Ok(d) => { if attempt > 0 { tracing::debug!( @@ -1026,6 +1037,10 @@ pub struct DuplCapturer { /// Format-tagged because the SDR path presents BGRA `gpu_copy` while the HDR path presents the /// 10-bit `hdr10_out` — the encoder needs the right format on every frame. last_present: Option<(ID3D11Texture2D, PixelFormat)>, + /// Whether this capturer should request an HDR (FP16) duplication — `DuplicateOutput1` with FP16 + /// first, retried (legacy DuplicateOutput can't capture HDR). Set for the secure-desktop DDA leg + /// when the SudoVDA is in HDR; threaded into every (re)duplication incl. ACCESS_LOST recovery. + want_hdr: bool, /// HDR (scRGB FP16) capture state. Set when the duplication surface is `R16G16B16A16_FLOAT` /// (the desktop has HDR on). The frame can't be `CopyResource`d into a BGRA target, so the HDR /// path copies it into an FP16 SRV texture, composites the cursor, then runs [`HdrConverter`] to @@ -1080,6 +1095,7 @@ impl DuplCapturer { target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, keepalive: Box, + want_hdr: bool, ) -> Result { unsafe { // Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs @@ -1220,7 +1236,7 @@ impl DuplCapturer { // (registry-persisted), so the secure desktop has nowhere to render but the output we // capture — no per-open re-isolation needed. attach_input_desktop(); - let dupl = duplicate_output(&output, &device) + let dupl = duplicate_output(&output, &device, want_hdr) .context("DuplicateOutput (already duplicated by another app?)")?; // Did DXGI actually call our win32u GPU-pref hook during factory/device/dupl creation? hits==0 // here means the hook is NOT on DXGI's reparenting path on this build → reparenting can't be @@ -1284,6 +1300,7 @@ impl DuplCapturer { gpu_mode, gpu_copy: None, last_present: None, + want_hdr, hdr_fp16: dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT, fp16_src: None, fp16_srv: None, @@ -1602,7 +1619,7 @@ impl DuplCapturer { // allows one duplication per output; leaving the stale one alive is exactly why DuplicateOutput1 // returned E_ACCESSDENIED and the legacy fallback produced a born-lost dup. self.dupl = None; - let dupl = match duplicate_output(&self.output, &self.device) { + let dupl = match duplicate_output(&self.output, &self.device, self.want_hdr) { Ok(d) => d, Err(_) => return false, }; @@ -1664,7 +1681,7 @@ impl DuplCapturer { // and the new one is born-lost / E_ACCESSDENIED. (On reopen failure self.dupl stays None and // acquire's None-guard re-drives recovery.) self.dupl = None; - let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name)?; // Err → caller repeats + retries + let (dev, ctx, out, dupl) = reopen_duplication(&self.gdi_name, self.want_hdr)?; // Err → caller repeats + retries // (The born-lost guard is now the capture-acquire at the end: we adopt, then grab the current // frame; ACCESS_LOST there means born-lost, and we seed black + let the throttled caller retry.) diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 2ba25c9..2c030fe 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -2385,33 +2385,37 @@ fn virtual_stream_relay( } // Note: takes the dimensions as args rather than capturing `cur_mode` — `cur_mode` is reassigned // on reconfig, and a closure holding a shared borrow of it for the whole fn would forbid that. - let open_dda = |target: &WinCaptureTarget, w: u32, h: u32, hz: u32| -> Result { - // The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one. - let mut cap = crate::capture::dxgi::DuplCapturer::open( - target.clone(), - Some((w, h, hz)), - Box::new(()), - ) - .context("open DDA for secure desktop")?; - cap.set_active(true); - let frame = cap.next_frame().context("DDA first frame")?; - let enc = crate::encode::open_video( - crate::encode::Codec::H265, - frame.format, - frame.width, - frame.height, - hz, - bitrate_kbps as u64 * 1000, - frame.is_cuda(), - bit_depth, - ) - .context("open NVENC for DDA")?; - Ok(DdaPipe { - cap: Box::new(cap), - enc, - frame, - }) - }; + let open_dda = + |target: &WinCaptureTarget, w: u32, h: u32, hz: u32, hdr: bool| -> Result { + // The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one. + // `hdr` requests an FP16 DuplicateOutput1 so the secure desktop is captured in HDR (→ BT.2020 + // PQ Main10) instead of black — legacy DuplicateOutput can't capture an HDR/FP16 desktop. + let mut cap = crate::capture::dxgi::DuplCapturer::open( + target.clone(), + Some((w, h, hz)), + Box::new(()), + hdr, + ) + .context("open DDA for secure desktop")?; + cap.set_active(true); + let frame = cap.next_frame().context("DDA first frame")?; + let enc = crate::encode::open_video( + crate::encode::Codec::H265, + frame.format, + frame.width, + frame.height, + hz, + bitrate_kbps as u64 * 1000, + frame.is_cuda(), + bit_depth, + ) + .context("open NVENC for DDA")?; + Ok(DdaPipe { + cap: Box::new(cap), + enc, + frame, + }) + }; let perf = std::env::var("PUNKTFUNK_PERF").is_ok(); let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB") @@ -2470,10 +2474,6 @@ fn virtual_stream_relay( // decoder must resume on a keyframe — the two encoders keep independent infinite-GOP state). let mut dda: Option = None; let mut on_secure = false; - // Whether we dropped the SudoVDA out of HDR for the secure (DDA) leg, so we know to restore it on - // the way back. Keyed off the monitor's REAL HDR state at the moment of the switch (a user can - // toggle Windows HDR mid-session), not the handshake bit depth. - let mut dropped_hdr_for_secure = false; let mut next = std::time::Instant::now(); let mut await_idr = false; // Step 6 relaunch watchdog: how many times in a row the helper has died without producing a frame. @@ -2563,46 +2563,17 @@ fn virtual_stream_relay( "two-process: source switch" ); if secure { - // SDR-while-secure (HDR sessions ONLY): drop the SudoVDA out of HDR so the secure - // (Winlogon) desktop renders SDR/composed — HDR fullscreen independent-flip is what made - // DDA storm ACCESS_LOST (black). Key off the monitor's REAL HDR state (a user may have - // toggled Windows HDR on the virtual display), not the negotiated bit depth — the pipeline - // streams HDR whenever the monitor is HDR regardless of the 8/10 handshake. For an SDR - // monitor this is a no-op (no needless topology change, nothing to restore). - dropped_hdr_for_secure = + // Capture the secure (Winlogon) desktop in its NATIVE colorspace. Don't try to drop the + // SudoVDA out of HDR for the DDA leg — display-config changes are denied on the secure + // desktop (the drop just churned + still went black). Instead, if the monitor is in HDR, + // open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA + // overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI. + let hdr = unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) }; - if dropped_hdr_for_secure { - // The DDA path is SDR-only (BGRA8) — leaving the SudoVDA in HDR makes the secure - // desktop capture black. Drop to SDR and VERIFY it actually took before opening DDA: - // the CCD advanced-color toggle can transiently fail (rc=5) or lag, so retry until - // advanced_color_enabled() reads false (or we give up and open DDA regardless). - let mut off = false; - for attempt in 0..6 { - unsafe { - crate::vdisplay::sudovda::set_advanced_color(target.target_id, false); - } - std::thread::sleep(std::time::Duration::from_millis(200)); - if !unsafe { - crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) - } { - off = true; - tracing::info!( - attempt, - "SudoVDA dropped to SDR for the secure DDA leg" - ); - break; - } - } - if !off { - tracing::warn!( - "could not drop the SudoVDA out of HDR for the secure desktop — DDA may \ - be black (display-config change likely denied on the Winlogon desktop)" - ); - } - } - dda = None; // reopen so we capture the (SDR) output - match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz) { + dda = None; // reopen to capture the secure desktop + match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) { Ok(mut p) => { + tracing::info!(hdr, "two-process: opened DDA for the secure desktop"); p.enc.request_keyframe(); dda = Some(p); } @@ -2622,30 +2593,8 @@ fn virtual_stream_relay( dda = None; // free the secure DDA encoder; the relay (helper) is the source again while relay.try_recv().is_ok() {} // drop secure-dwell backlog relay.request_keyframe(); // client decoder resumes on the helper's next IDR - if dropped_hdr_for_secure { - // We dropped the SudoVDA to SDR for the DDA leg → restore HDR AND rebuild the helper - // so WGC re-detects the HDR colorspace. (An SDR session never changed the colorspace - // → dropped_hdr_for_secure is false → no rebuild, no recreate.) - dropped_hdr_for_secure = false; - unsafe { - crate::vdisplay::sudovda::set_advanced_color(target.target_id, true); - } - match build(&mut vd, cur_mode) { - Ok((ka, rl, tg, hz)) => { - relay = rl; - _keepalive = ka; - target = tg; - effective_hz = hz; - interval = std::time::Duration::from_secs_f64(1.0 / hz.max(1) as f64); - } - Err(e) => { - tracing::error!(error = %format!("{e:#}"), - "two-process: helper rebuild on secure-exit failed"); - while relay.try_recv().is_ok() {} - relay.request_keyframe(); - } - } - } + // Nothing to restore: we no longer toggle the SudoVDA's HDR state for the DDA leg, so the + // monitor's colorspace is unchanged and the still-alive WGC helper just resumes. next = std::time::Instant::now(); } }