feat(host/windows): WGC capture backend (overlay/HDR-correct) with watchdog'd DDA fallback
android / android (push) Failing after 46s
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m16s
ci / web (push) Successful in 31s
ci / docs-site (push) Successful in 27s
deb / build-publish (push) Successful in 2m23s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m15s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m50s

The capture-architecture reset from the research: add a Windows.Graphics.Capture (WGC) backend that
captures the COMPOSED desktop — including the overlay/independent-flip/MPO planes DXGI Desktop
Duplication misses — which structurally fixes the frozen HDR animations + video (proven live: a WGC
frame decodes to the real 5120x1440 HDR content DDA freezes on). It reuses the whole pipeline
unchanged: the WGC frame's GPU texture → same scRGB→BT.2020-PQ shader → NVENC zero-copy; the OS
composites the cursor (IsCursorCaptureEnabled) so no manual cursor pass. crates/punktfunk-host/src/
capture/wgc.rs; find_output/make_device/HdrConverter/nudge_cursor_onto made pub(crate) for reuse.

Reliability findings + mitigations (live on the RTX 4090):
- WGC can't activate under the SYSTEM account (0x80070424) — it needs the interactive user token. The
  host must run as the user for WGC (run.cmd: drop PsExec -s). DDA still needs SYSTEM for the secure
  desktop — that token reconciliation (impersonation) is the remaining task.
- WGC's Direct3D11CaptureFramePool::CreateFreeThreaded intermittently HANGS on the headless SudoVDA
  (IddCx) display, correlated with accumulated SudoVDA churn (failed REMOVEs leaving lingering
  displays); clean-state opens reliably. Since it's a blocking hang, capture_virtual_output runs WGC
  open on a watchdog thread with a 5s timeout and falls back to DDA on hang/error — the session is
  NEVER left black: WGC when it opens (fixed animations), DDA otherwise. First-frame nudge added (WGC
  fires FrameArrived on change; a static desktop otherwise never delivers the first frame).
- Default WGC; PUNKTFUNK_CAPTURE=dda forces DDA. DDA path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 06:32:54 +00:00
parent 84e17fbb49
commit 28ab448a29
4 changed files with 544 additions and 8 deletions
+50 -2
View File
@@ -265,8 +265,54 @@ pub fn capture_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<Bo
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
)
})?;
dxgi::DuplCapturer::open(target, vout.preferred_mode, vout.keepalive)
.map(|c| Box::new(c) as Box<dyn Capturer>)
let pref = vout.preferred_mode;
let keep = vout.keepalive;
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback.
let backend = std::env::var("PUNKTFUNK_CAPTURE")
.unwrap_or_default()
.to_ascii_lowercase();
if backend == "dda" || backend == "dxgi" {
return dxgi::DuplCapturer::open(target, pref, keep)
.map(|c| Box::new(c) as Box<dyn Capturer>);
}
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
// intermittently HANGS on the headless SudoVDA (IddCx) display — a blocking call we can't error out
// of in place. So run WGC open on a dedicated thread and bound it: if it doesn't finish in time
// (hang) or errors, fall back to the reliable DDA path so the session is NEVER left black. WGC,
// when it opens, captures the composed desktop (overlay/MPO-correct HDR — fixes frozen animations);
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
unsafe {
let _ = windows::Win32::System::WinRT::RoInitialize(
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
);
}
let (tx, rx) = std::sync::mpsc::channel();
let t = target.clone();
let _ = std::thread::Builder::new()
.name("wgc-open".into())
.spawn(move || {
let _ = tx.send(wgc::WgcCapturer::open(t, pref));
});
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(Ok(mut c)) => {
c.attach_keepalive(keep);
Ok(Box::new(c) as Box<dyn Capturer>)
}
Ok(Err(e)) => {
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<dyn Capturer>)
}
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<dyn Capturer>)
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
@@ -278,3 +324,5 @@ pub fn capture_virtual_output(_vout: crate::vdisplay::VirtualOutput) -> Result<B
pub mod dxgi;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "windows")]
pub mod wgc;