diff --git a/Cargo.lock b/Cargo.lock index 8fab979e..3f006047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2754,6 +2754,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pf-capture" +version = "0.12.0" +dependencies = [ + "anyhow", + "ashpd", + "libc", + "pf-driver-proto", + "pf-frame", + "pf-gpu", + "pf-host-config", + "pf-win-display", + "pf-zerocopy", + "pipewire", + "punktfunk-core", + "tokio", + "tracing", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "pf-client-core" version = "0.12.0" @@ -3203,6 +3223,7 @@ dependencies = [ "mdns-sd", "opus", "parking_lot", + "pf-capture", "pf-driver-proto", "pf-encode", "pf-frame", diff --git a/Cargo.toml b/Cargo.toml index e3ef152d..f8042282 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/pf-frame", "crates/pf-win-display", "crates/pf-encode", + "crates/pf-capture", "crates/pyrowave-sys", "clients/probe", "clients/linux", diff --git a/crates/pf-capture/Cargo.toml b/crates/pf-capture/Cargo.toml new file mode 100644 index 00000000..4434489b --- /dev/null +++ b/crates/pf-capture/Cargo.toml @@ -0,0 +1,52 @@ +# Frame capture (plan §7 / §W6): the Linux xdg-ScreenCast/PipeWire portal capturer and the Windows +# IDD direct-push capturer, plus the synthetic sources + the Capturer trait, extracted into a +# subsystem crate. Depends on the shared frame vocabulary (pf-frame), the zero-copy plumbing +# (pf-zerocopy), and the display leaves (pf-win-display) — never on pf-encode: the encode-backend +# facts arrive pre-resolved (ZeroCopyPolicy) and the sealed-channel delivery as a closure +# (FrameChannelSender), so the capture→encode edge is one-way (plan §2.4). +[package] +name = "pf-capture" +version = "0.12.0" +edition = "2021" +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "punktfunk host frame capture: Linux PipeWire portal + Windows IDD direct-push capturers behind one Capturer trait." +publish = false + +[dependencies] +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +pf-frame = { path = "../pf-frame" } +pf-zerocopy = { path = "../pf-zerocopy" } +pf-win-display = { path = "../pf-win-display" } +pf-gpu = { path = "../pf-gpu" } +pf-host-config = { path = "../pf-host-config" } +anyhow = "1" +tracing = "0.1" + +[target.'cfg(target_os = "linux")'.dependencies] +# The xdg ScreenCast + RemoteDesktop portals, and the PipeWire consumer for the capture frames. +ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] } +pipewire = "0.9" +libc = "0.2" +# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane). +tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header). +pf-driver-proto = { path = "../pf-driver-proto" } +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Direct3D_Fxc", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Threading", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs new file mode 100644 index 00000000..5d63300f --- /dev/null +++ b/crates/pf-capture/src/lib.rs @@ -0,0 +1,339 @@ +//! Frame capture (plan §7 / §W6): the capturers themselves — the Linux xdg-ScreenCast/PipeWire +//! portal capturer and the Windows IDD direct-push capturer — plus the synthetic test sources and +//! the `Capturer` trait, extracted into a subsystem crate. Speaks the shared frame vocabulary +//! (`pf-frame`) + the zero-copy plumbing (`pf-zerocopy`) and the display leaves (`pf-win-display`), +//! and NEVER `pf-encode` — the capture→encode edge is one-way (the encode-backend facts arrive +//! pre-resolved in a [`ZeroCopyPolicy`], and the Windows sealed-channel delivery arrives as a +//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host +//! orchestrator). + +// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them. +#![allow(dead_code)] +// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use anyhow::Result; +use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; +// The Linux capturer reaches `DmabufFrame` through `super::`; `CursorOverlay` it names directly as +// `pf_frame::CursorOverlay`, so only `DmabufFrame` needs to sit in this crate root's scope. +#[cfg(target_os = "linux")] +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). +pub trait Capturer: Send { + fn next_frame(&mut self) -> Result; + + /// Non-blocking: the freshest frame available since the last call, or `None` if none has + /// arrived (the caller reuses its last frame to hold a steady output rate). The default + /// just produces a frame each call — fine for instant synthetic sources; the portal + /// overrides it to drain its channel without blocking. + fn try_latest(&mut self) -> Result> { + self.next_frame().map(Some) + } + + /// Gate expensive per-frame work so the capturer can be kept alive (reused) between + /// streams without burning CPU. The portal capturer skips the de-pad copy while inactive; + /// the default is a no-op (synthetic sources are produced on demand). Set `true` for the + /// 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. + fn hdr_meta(&self) -> Option { + None + } + + /// How many frames the encode loop may keep in flight (submitted but not yet polled) before it + /// blocks. `1` (the default) is the synchronous loop: capture → submit → poll-blocks, so the + /// per-frame wall time is `capture+convert + encode`. A capturer that hands a fresh output texture + /// per frame (so the encode of N reads a different texture than the convert of N+1 writes) can return + /// `>1` to PIPELINE: the loop submits N+1 before polling N, overlapping the convert/copy on the 3D + /// engine with the NVENC-ASIC encode of the prior frame, dropping per-frame wall toward `max(...)`. + fn pipeline_depth(&self) -> usize { + 1 + } +} + +/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file → +/// `punktfunk_core` path with no live capture session, and produces obviously non-static +/// content (a sweeping bar + animated gradient) so the encoded output is verifiable. +pub struct SyntheticCapturer { + width: u32, + height: u32, + fps: u32, + frame_idx: u64, + buf: Vec, +} + +impl SyntheticCapturer { + const BPP: usize = 4; // emits BGRx + + pub fn new(width: u32, height: u32, fps: u32) -> Self { + assert!(width > 0 && height > 0 && fps > 0); + let buf = vec![0u8; width as usize * height as usize * Self::BPP]; + SyntheticCapturer { + width, + height, + fps, + frame_idx: 0, + buf, + } + } +} + +impl Capturer for SyntheticCapturer { + fn next_frame(&mut self) -> Result { + let w = self.width as usize; + let h = self.height as usize; + let bpp = Self::BPP; + let t = self.frame_idx; + // A vertical bar sweeps left→right once every ~2s; the background is a gradient + // whose phase advances each frame, so every pixel changes frame-to-frame. + let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64; + let phase = (t % 256) as usize; + for y in 0..h { + let row = y * w * bpp; + for x in 0..w { + let i = row + x * bpp; + let on_bar = (x as u64).abs_diff(bar_x) < 8; + // BGRx byte order: [B, G, R, x] + self.buf[i] = if on_bar { + 255 + } else { + ((x + phase) & 0xff) as u8 + }; + self.buf[i + 1] = if on_bar { + 255 + } else { + ((y + phase) & 0xff) as u8 + }; + self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 }; + self.buf[i + 3] = 0; + } + } + let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64; + self.frame_idx += 1; + Ok(CapturedFrame { + width: self.width, + height: self.height, + pts_ns, + format: PixelFormat::Bgrx, + payload: FramePayload::Cpu(self.buf.clone()), + cursor: None, + }) + } +} + +/// A cheap moving test pattern (BGRx) for the streaming path: a pulsing field + a white band +/// sweeping down, generated with whole-buffer `fill`s so it stays real-time even at 5K. +pub struct FastSyntheticCapturer { + width: u32, + height: u32, + frame_idx: u64, + buf: Vec, + /// PUNKTFUNK_SYNTH_NOISE: every frame is fresh high-entropy noise NVENC can't compress or + /// predict, so the encoder hits its (CBR) bitrate target — a throughput test of the real + /// encode→FEC→send→recv path. The default flat/band content compresses to ~nothing, so it + /// can't generate real Mbps (the encoder is content-driven). xorshift over u64 chunks. + noise: bool, + rng: u64, +} + +impl FastSyntheticCapturer { + pub fn new(width: u32, height: u32) -> Self { + assert!(width > 0 && height > 0); + FastSyntheticCapturer { + width, + height, + frame_idx: 0, + buf: vec![0u8; width as usize * height as usize * 4], + noise: std::env::var_os("PUNKTFUNK_SYNTH_NOISE").is_some(), + rng: 0x9e3779b97f4a7c15, + } + } +} + +impl Capturer for FastSyntheticCapturer { + fn next_frame(&mut self) -> Result { + if self.noise { + // Fresh, every-frame-decorrelated noise: reseed from the frame index so consecutive + // frames share no structure (forces large P-frames too, not just the keyframe). + let mut s = self + .rng + .wrapping_add(self.frame_idx.wrapping_mul(0x2545F491_4F6CDD1D)) + | 1; + for c in self.buf.chunks_exact_mut(8) { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + c.copy_from_slice(&s.to_le_bytes()); + } + self.rng = s; + } else { + let (w, h) = (self.width as usize, self.height as usize); + let row = w * 4; + let shade = (self.frame_idx % 256) as u8; + self.buf.fill(shade); + let band_h = (h / 20).max(1); + let band_y = (self.frame_idx as usize * 6) % h; + for y in band_y..(band_y + band_h).min(h) { + self.buf[y * row..(y + 1) * row].fill(0xff); + } + } + self.frame_idx += 1; + Ok(CapturedFrame { + width: self.width, + height: self.height, + pts_ns: 0, + format: PixelFormat::Bgrx, + payload: FramePayload::Cpu(self.buf.clone()), + cursor: None, + }) + } +} + +/// The encode-backend facts the Linux zero-copy negotiation needs, resolved **once** here (the host +/// facade, which may reach the host `encode`) and passed **into** the capturer — so the capturer never +/// calls back into `encode`, keeping the capture→encode dependency one-way (plan §2.4 / §W6). The +/// three facts were formerly re-derived inside the PipeWire thread via +/// `encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu, pyrowave_capture_modifiers}`. +#[cfg(target_os = "linux")] +#[derive(Clone, Default)] +pub struct ZeroCopyPolicy { + /// The GPU encode backend resolves to VAAPI (AMD/Intel) — the capturer hands raw dmabufs + /// straight through instead of the EGL→CUDA import (the host `encode::linux_zero_copy_is_vaapi`). + pub backend_is_vaapi: bool, + /// The resolved backend produces GPU-resident frames (everything but the software encoder) — + /// used only to phrase the CPU-fallback warning (the host `encode::resolved_backend_is_gpu`). + pub backend_is_gpu: bool, + /// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc, + /// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA, + /// which allocates tiled-only, still negotiates zero-copy). Empty otherwise. + pub pyrowave_modifiers: Vec, +} + +#[cfg(target_os = "linux")] +pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { + true +} +#[cfg(target_os = "windows")] +pub 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). + encoder_ingests_rgb_444 +} +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { + false +} + +/// Host-registered HID compose-kick hook: `(target_rect, desktop_bounds) -> accepted`, both +/// `(x, y, w, h)` in desktop coordinates (from CCD). The host facade registers it once at startup +/// when the resident virtual HID mouse exists (`inject::mouse_windows::hid_kick`); the IDD-push +/// capturer's compose kick then prefers it over `SendInput`, because device-level input is +/// delivered regardless of this process's session or the active desktop and wakes a powered-off +/// display — the lid-closed first-frame fix. Same one-way-edge philosophy as +/// [`FrameChannelSender`]: this crate never reaches back into the host's inject module. `false` +/// from the hook = mouse not available right now → the caller falls back to `SendInput`. +#[cfg(target_os = "windows")] +pub static HID_COMPOSE_KICK: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The [`HID_COMPOSE_KICK`] hook's shape: `(target_rect, desktop_bounds) -> accepted`, both +/// `(x, y, w, h)` in desktop coordinates. +#[cfg(target_os = "windows")] +pub type HidKickFn = fn((i32, i32, i32, i32), (i32, i32, i32, i32)) -> bool; + +/// Delivers a monitor's sealed frame channel to the pf-vdisplay driver (`IOCTL_SET_FRAME_CHANNEL`) — +/// the ONE reach the IDD-push capturer would otherwise make into the host's `vdisplay` module. The +/// host facade builds this closure (capturing the pf-vdisplay control device handle + the +/// `send_frame_channel` IOCTL wrapper) and hands it in, so this crate delivers the channel without a +/// path back to the orchestrator. Called once per ring generation (at attach), never per-frame — +/// guardrail-compliant. The handle values in `req` were just duplicated into the driver's WUDFHost +/// by the capturer's [`windows::idd_push`] broker; on IOCTL success the DRIVER owns them. +#[cfg(target_os = "windows")] +pub type FrameChannelSender = std::sync::Arc< + dyn Fn(&pf_driver_proto::control::SetFrameChannelRequest) -> Result<()> + Send + Sync, +>; + +// One-time PipeWire library init, shared by the video (portal) and audio capture threads. +#[cfg(target_os = "linux")] +pub mod pwinit; + +// The Windows backend lives under `windows/`, the Linux one under `linux/`. Windows capture is IDD +// direct-push only (DXGI Desktop Duplication + the WGC relay were removed). +#[cfg(target_os = "windows")] +#[path = "windows/dxgi.rs"] +pub mod dxgi; +#[cfg(target_os = "windows")] +#[path = "windows/idd_push.rs"] +mod idd_push; +// The WUDFHost-identity check the IDD-push broker uses is reused by the host's gamepad-channel +// bootstrap (`inject::windows::gamepad_raii`); re-export it so that reach stays a leaf dependency. +#[cfg(target_os = "windows")] +pub use idd_push::verify_is_wudfhost; +#[cfg(target_os = "linux")] +#[path = "linux/mod.rs"] +mod linux; +#[cfg(target_os = "windows")] +#[path = "windows/synthetic_nv12.rs"] +pub mod synthetic_nv12; + +/// Open the Linux xdg-ScreenCast portal capturer for a client-sized monitor. `anchored` drives +/// ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits that grant headlessly. The +/// [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge). +#[cfg(target_os = "linux")] +pub fn open_portal_monitor(anchored: bool, policy: ZeroCopyPolicy) -> Result> { + linux::PortalCapturer::open(anchored, policy).map(|c| Box::new(c) as Box) +} + +/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The +/// caller (host facade) explodes its `VirtualOutput` into these primitives + owns nothing after — +/// the capturer takes `keepalive`, so dropping it releases the output. `allow_zerocopy` mirrors +/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert. +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +pub fn open_virtual_output( + remote_fd: Option, + node_id: u32, + preferred_mode: Option<(u32, u32, u32)>, + keepalive: Box, + allow_zerocopy: bool, + want_444: bool, + policy: ZeroCopyPolicy, +) -> Result> { + linux::PortalCapturer::from_virtual_output( + remote_fd, + node_id, + preferred_mode, + keepalive, + allow_zerocopy, + want_444, + policy, + ) + .map(|c| Box::new(c) as Box) +} + +/// Open the Windows IDD direct-push capturer on a pf-vdisplay target. `sender` delivers the sealed +/// frame channel to the driver (the host facade builds it from the vdisplay control device). On +/// failure the `keepalive` is handed back so the caller can retire the display. +#[cfg(target_os = "windows")] +#[allow(clippy::too_many_arguments)] +pub fn open_idd_push( + target: pf_frame::dxgi::WinCaptureTarget, + preferred: Option<(u32, u32, u32)>, + client_10bit: bool, + want_444: bool, + keepalive: Box, + sender: FrameChannelSender, +) -> std::result::Result, (anyhow::Error, Box)> { + idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender) + .map(|c| Box::new(c) as Box) +} diff --git a/crates/punktfunk-host/src/capture/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs similarity index 97% rename from crates/punktfunk-host/src/capture/linux/mod.rs rename to crates/pf-capture/src/linux/mod.rs index daded32d..0de70ccd 100644 --- a/crates/punktfunk-host/src/capture/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -55,7 +55,7 @@ pub struct PortalCapturer { stall_since: Option, /// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If /// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches - /// the process-wide downgrade ([`crate::zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline + /// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline /// rebuild retries on the CPU offer instead of failing identically forever. vaapi_dmabuf: bool, /// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis. @@ -107,36 +107,39 @@ impl PortalCapturer { ) } - /// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]): - /// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and - /// take ownership of its keepalive so the output lives exactly as long as this capturer. This - /// is how the client's requested resolution becomes the captured resolution without scaling. - /// `allow_zerocopy` mirrors [`OutputFormat::gpu`](crate::capture::OutputFormat): `false` forces - /// the CPU mmap path, `true` keeps the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. - /// `want_444` (a 4:4:4 session) makes the zero-copy worker convert tiled dmabufs to planar - /// YUV444 on the GPU instead of NV12/RGB. + /// Build a capturer from an already-created virtual output's PipeWire node. The host facade + /// explodes its `vdisplay::VirtualOutput` into these primitives so this crate never depends on + /// the vdisplay type: `remote_fd` selects portal-remote vs. default-daemon, `node_id` is the + /// output's screencast node, `preferred_mode` seeds format negotiation, and `keepalive` owns the + /// output (dropping the capturer releases it). `allow_zerocopy` mirrors + /// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps + /// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the + /// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB. + #[allow(clippy::too_many_arguments)] pub fn from_virtual_output( - vout: crate::vdisplay::VirtualOutput, + remote_fd: Option, + node_id: u32, + preferred_mode: Option<(u32, u32, u32)>, + keepalive: Box, allow_zerocopy: bool, want_444: bool, policy: ZeroCopyPolicy, ) -> Result { tracing::info!( - node_id = vout.node_id, + node_id, allow_zerocopy, want_444, "connecting PipeWire to virtual output" ); - let node_id = vout.node_id; Ok(spawn_pipewire( - vout.remote_fd, + remote_fd, node_id, - vout.preferred_mode, + preferred_mode, allow_zerocopy, want_444, policy, )? - .into_capturer(node_id, Some(vout.keepalive))) + .into_capturer(node_id, Some(keepalive))) } } @@ -209,7 +212,7 @@ fn spawn_pipewire( // sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the // inner `mod pipewire` shadows the crate name at this scope. let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>(); - let zerocopy = allow_zerocopy && crate::zerocopy::enabled(); + let zerocopy = allow_zerocopy && pf_zerocopy::enabled(); // Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI // backend the EGL→CUDA importer is never built) — kept on the capturer so `next_frame`'s // negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer. @@ -339,11 +342,11 @@ impl PortalCapturer { or capture never started)", self.node_id )) - } else if self.vaapi_dmabuf && !crate::zerocopy::vaapi_dmabuf_forced() { + } else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() { // The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted. // Latch the process-wide downgrade so the encode loop's pipeline rebuild // retries on the CPU offer instead of failing this same negotiation forever. - crate::zerocopy::note_vaapi_dmabuf_failed(); + pf_zerocopy::note_vaapi_dmabuf_failed(); Err(anyhow!( "no PipeWire frame within 10s (node {}): the compositor never accepted \ the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \ @@ -664,11 +667,11 @@ mod pipewire { impl CursorState { /// A shareable overlay for the GPU encode paths (blended at encode time), or `None` when /// there is nothing to draw. Cheap: clones an `Arc` + a few scalars. - fn overlay(&self) -> Option { + fn overlay(&self) -> Option { if !self.visible || self.rgba.is_empty() { return None; } - Some(crate::capture::CursorOverlay { + Some(pf_frame::CursorOverlay { x: self.x, y: self.y, w: self.bw, @@ -702,8 +705,8 @@ mod pipewire { /// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`]. import_fail_streak: u32, /// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer, - /// normally via the isolated worker process (`crate::zerocopy::Importer::Remote`). - importer: Option, + /// normally via the isolated worker process (`pf_zerocopy::Importer::Remote`). + importer: Option, /// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead /// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the /// encoder backend is VAAPI (AMD/Intel). @@ -1246,7 +1249,7 @@ mod pipewire { if ud.vaapi_passthrough { if let Some(fmt) = ud.format { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { - if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) { + if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { let chunk = datas[0].chunk(); let offset = chunk.offset(); let stride = chunk.stride().max(0) as u32; @@ -1309,7 +1312,7 @@ mod pipewire { let mut gpu_import_broken = false; if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { - let plane = crate::zerocopy::DmabufPlane { + let plane = pf_zerocopy::DmabufPlane { fd: datas[0].fd(), offset: datas[0].chunk().offset(), stride: datas[0].chunk().stride().max(0) as u32, @@ -1318,7 +1321,7 @@ mod pipewire { // gamescope) → direct CUDA external-memory import (NVIDIA EGL can't // sample LINEAR). let modifier = (ud.modifier != 0).then_some(ud.modifier); - if let Some(fourcc) = crate::zerocopy::drm_fourcc(fmt) { + if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { // GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4 // session gets the planar-YUV444 convert (full chroma, takes precedence over // NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — @@ -1342,7 +1345,7 @@ mod pipewire { match imported { Ok(devbuf) => { ud.import_fail_streak = 0; - crate::zerocopy::note_gpu_import_ok(); + pf_zerocopy::note_gpu_import_ok(); static ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if ONCE.swap(false, Ordering::Relaxed) { @@ -1380,7 +1383,7 @@ mod pipewire { Err(e) => { let dead = importer.dead(); if dead { - crate::zerocopy::note_gpu_import_death(); + pf_zerocopy::note_gpu_import_death(); } if modifier.is_some() { // Tiled buffer: the CPU fallback below would mmap TILED bytes @@ -1595,13 +1598,13 @@ mod pipewire { // repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop). let backend_is_vaapi = policy.backend_is_vaapi; let mut importer = if zerocopy && !backend_is_vaapi { - if crate::zerocopy::gpu_import_disabled() { + if pf_zerocopy::gpu_import_disabled() { tracing::warn!( "zero-copy GPU import disabled after repeated import-worker deaths — using CPU path" ); None } else { - match crate::zerocopy::Importer::new_for_capture() { + match pf_zerocopy::Importer::new_for_capture() { Ok(i) => Some(i), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path"); @@ -1629,7 +1632,7 @@ mod pipewire { // radeonsi/iHD import it and any compositor can allocate it. let mut modifiers = importer .as_mut() - .map(|i| i.supported_modifiers(crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap())) + .map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap())) .unwrap_or_default(); if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) { modifiers.push(0); // DRM_FORMAT_MOD_LINEAR @@ -1638,8 +1641,8 @@ mod pipewire { // advertisement with every modifier its device samples from, so compositors that // never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers // were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when - // the encoder pref is `pyrowave` — so capture never calls back into `encode`. - #[cfg(feature = "pyrowave")] + // the host's `pyrowave` feature is on AND the encoder pref is `pyrowave` — so capture never + // calls back into `encode` and needs no feature gate of its own (the emptiness check gates it). if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() { for &m in &policy.pyrowave_modifiers { if !modifiers.contains(&m) { @@ -1688,7 +1691,7 @@ mod pipewire { "4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \ NVENC fed native full-chroma YUV, no CPU pixel path" ); - } else if want_dmabuf && !vaapi_passthrough && crate::zerocopy::nv12_enabled() { + } else if want_dmabuf && !vaapi_passthrough && pf_zerocopy::nv12_enabled() { tracing::info!( "PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \ fed native YUV (no internal RGB→YUV CSC)" @@ -1707,7 +1710,7 @@ mod pipewire { import_fail_streak: 0, importer, vaapi_passthrough, - nv12: crate::zerocopy::nv12_enabled(), + nv12: pf_zerocopy::nv12_enabled(), yuv444: want_444, dbg_log_n: 0, cursor: CursorState::default(), diff --git a/crates/punktfunk-host/src/pwinit.rs b/crates/pf-capture/src/pwinit.rs similarity index 100% rename from crates/punktfunk-host/src/pwinit.rs rename to crates/pf-capture/src/pwinit.rs diff --git a/crates/punktfunk-host/src/capture/windows/dxgi.rs b/crates/pf-capture/src/windows/dxgi.rs similarity index 99% rename from crates/punktfunk-host/src/capture/windows/dxgi.rs rename to crates/pf-capture/src/windows/dxgi.rs index 6629d606..6fcc04dd 100644 --- a/crates/punktfunk-host/src/capture/windows/dxgi.rs +++ b/crates/pf-capture/src/windows/dxgi.rs @@ -6,7 +6,7 @@ //! [`make_device`] (the D3D11 device factory + GPU scheduling-priority hardening) — moved into the //! `pf-frame` leaf crate so capture, encode, and pf-vdisplay share one identity type without a //! capture↔encode↔vdisplay cycle (plan §W6); this module re-exports it so every existing -//! `crate::capture::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this +//! `crate::dxgi::*` path keeps resolving. DXGI Desktop Duplication has been removed; this //! module contains no capturer. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). @@ -77,7 +77,7 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 { /// 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). -pub(crate) fn install_gpu_pref_hook() { +pub fn install_gpu_pref_hook() { use std::sync::Once; static HOOK: Once = Once::new(); // SAFETY: this one-time hook install only touches a region it has just validated. diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs similarity index 95% rename from crates/punktfunk-host/src/capture/windows/idd_push.rs rename to crates/pf-capture/src/windows/idd_push.rs index d0fa92ce..01b18daf 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -219,6 +219,14 @@ impl Drop for KeyedMutexGuard<'_> { /// 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. +/// +/// **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 +/// HID device is real input to win32k — delivered regardless of this process's session or the +/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off / +/// modern standby) and counts as user presence — every condition under which `SendInput` is +/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off → +/// nothing composes at all). That set is exactly the lid-closed field-report state. fn kick_dwm_compose(target_id: u32) { // Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own // schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT @@ -240,7 +248,21 @@ fn kick_dwm_compose(target_id: u32) { let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok(); // SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local // buffers; the `Copy` target id crosses by value. - let rect = unsafe { crate::win_display::source_desktop_rect(target_id) }; + let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) }; + // HID-first (see the doc comment): the registered virtual-mouse kick works from any + // session/desktop and wakes an off display. Both geometries come from CCD (global database), + // NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall + // through to SendInput only when the hook isn't registered / the mouse isn't up. + if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) { + // SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local + // buffers. + let bounds = unsafe { pf_win_display::win_display::desktop_bounds() }; + if let Some(bounds) = bounds { + if kick(rect, bounds) { + return; + } + } + } if let (true, Some((x, y, w, h))) = (have_pos, rect) { let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1); if !inside { @@ -295,7 +317,7 @@ fn kick_dwm_compose(target_id: u32) { /// /// # Safety /// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`. -pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> { +pub unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> { let mut buf = [0u16; 512]; let mut len = buf.len() as u32; // SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and @@ -395,7 +417,7 @@ pub struct IddPushCapturer { /// periodic-stutter diagnostic. stall_watch: StallWatch, /// Stall↔OS-event correlation counters for the metronomic warn: how many stalls this session, - /// and how many had a coinciding [`crate::display_events`] event in their gap window — the + /// and how many had a coinciding a `pf_win_display::display_events` event in their gap window — the /// discriminator between "Windows re-enumerates a monitor each cycle" (devnode churn the /// `pnp_disable_monitors` axis suppresses) and "the disturbance is below the OS" (GPU driver /// servicing a standby sink / display-poller software). @@ -420,6 +442,13 @@ pub struct IddPushCapturer { last_seq: u64, last_present: Option<(ID3D11Texture2D, PixelFormat)>, status_logged: bool, + /// Session-lifetime `PowerRequestDisplayRequired` (RAII, `powercfg /requests`-visible): keeps + /// the console out of display-off while this capturer lives — DWM composes nothing (for ANY + /// display) once the console's displays power down, so without this a lid-closed/idle box can + /// go dark mid-stream and the ring runs dry. Prevention only; waking an ALREADY-off display is + /// the HID compose kick's job ([`crate::HID_COMPOSE_KICK`]). `None` when the kernel refused + /// (best-effort, the pre-existing behavior). + _display_wake: Option, _keepalive: Box, } // SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the @@ -533,11 +562,12 @@ impl IddPushCapturer { client_10bit: bool, want_444: bool, keepalive: Box, + sender: crate::FrameChannelSender, ) -> std::result::Result)> { // The stall-attribution listener (idempotent): started with the first IDD-push capturer so // the stall log can correlate DWM holes with OS display events for the session's lifetime. - crate::display_events::spawn_once(); - match Self::open_inner(target, preferred, client_10bit, want_444) { + pf_win_display::display_events::spawn_once(); + match Self::open_inner(target, preferred, client_10bit, want_444, sender) { Ok(mut me) => { me._keepalive = keepalive; Ok(me) @@ -551,6 +581,7 @@ impl IddPushCapturer { preferred: Option<(u32, u32, u32)>, client_10bit: bool, want_444: bool, + sender: crate::FrameChannelSender, ) -> Result { // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the // selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor @@ -565,7 +596,14 @@ impl IddPushCapturer { LowPart: (target.adapter_luid & 0xffff_ffff) as u32, HighPart: (target.adapter_luid >> 32) as i32, }); - match Self::open_on(target.clone(), preferred, client_10bit, want_444, luid) { + match Self::open_on( + target.clone(), + preferred, + client_10bit, + want_444, + luid, + sender.clone(), + ) { Ok(me) => Ok(me), Err(e) => { // Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the @@ -576,7 +614,7 @@ impl IddPushCapturer { let driver_luid = e .downcast_ref::() .map(|tf| tf.driver_luid) - .filter(|d| *d != 0 && *d != crate::capture::dxgi::pack_luid(luid)); + .filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid)); let Some(packed) = driver_luid else { return Err(e); }; @@ -590,7 +628,7 @@ impl IddPushCapturer { "IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \ driver's reported adapter" ); - Self::open_on(target, preferred, client_10bit, want_444, drv) + Self::open_on(target, preferred, client_10bit, want_444, drv, sender) .context("IDD-push rebind to the driver's reported render adapter") } } @@ -602,6 +640,7 @@ impl IddPushCapturer { client_10bit: bool, want_444: bool, luid: LUID, + sender: crate::FrameChannelSender, ) -> Result { let (pw, ph, _hz) = preferred .context("IDD push needs the negotiated mode (WxH) to size the shared ring")?; @@ -612,8 +651,8 @@ impl IddPushCapturer { // SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a // copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from // us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`). - let (w, h) = - unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph)); + let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) } + .unwrap_or((pw, ph)); if (w, h) != (pw, ph) { tracing::info!( target_id = target.target_id, @@ -656,8 +695,8 @@ impl IddPushCapturer { // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have // settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format // mismatch → an immediate ring recreate + dropped first frames (audit §5.4). - let enabled_hdr = - client_10bit && crate::win_display::set_advanced_color(target.target_id, true); + let enabled_hdr = client_10bit + && pf_win_display::win_display::set_advanced_color(target.target_id, true); if enabled_hdr { // Let the colorspace change settle before the driver composes + we size the ring. std::thread::sleep(Duration::from_millis(250)); @@ -665,7 +704,8 @@ impl IddPushCapturer { // A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) — // there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session. let display_hdr = enabled_hdr - || crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false); + || pf_win_display::win_display::advanced_color_enabled(target.target_id) + .unwrap_or(false); // Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was // NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display // could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit @@ -757,7 +797,7 @@ impl IddPushCapturer { // driver's WUDFHost and hand it the values over the control device. All-or-nothing (the // broker reaps its remote duplicates on failure), and a failure fails the open — without // the delivery the driver can never attach. - let broker = ChannelBroker::open(target.wudf_pid)?; + let broker = ChannelBroker::open(target.wudf_pid, sender)?; broker .send( target.target_id, @@ -819,6 +859,9 @@ impl IddPushCapturer { last_seq: 0, last_present: None, status_logged: false, + // Held from BEFORE the first-frame gate (the display must not idle off while we + // wait for the first compose) until the capturer drops with the session. + _display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(), // Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand // it back to the caller for the DDA fallback (audit §5.1). _keepalive: Box::new(()), @@ -982,7 +1025,7 @@ impl IddPushCapturer { } other => format!("driver_status={other} (unexpected at this point)"), }; - match crate::interactive::console_session_mismatch() { + match pf_win_display::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 \ @@ -1388,7 +1431,7 @@ impl IddPushCapturer { let window = stall.gap + Duration::from_millis(300); let events = now .checked_sub(window) - .map(|from| crate::display_events::events_between(from, now)) + .map(|from| pf_win_display::display_events::events_between(from, now)) .unwrap_or_default(); self.stalls_seen = self.stalls_seen.saturating_add(1); if !events.is_empty() { @@ -1399,12 +1442,12 @@ impl IddPushCapturer { // at debug level, and the web-console debug ring captures these. tracing::debug!( gap_ms = stall.gap.as_millis() as u64, - os_display_events = %crate::display_events::summarize(&events), + os_display_events = %pf_win_display::display_events::summarize(&events), "IDD-push capture stall — the desktop was composing at speed, then DWM \ delivered no frame for the gap; the present path stalled below capture" ); if let Some(period) = stall.metronomic { - let suspects = crate::display_events::connected_inactive_externals(); + let suspects = pf_win_display::display_events::connected_inactive_externals(); let suspects = if suspects.is_empty() { "none".to_string() } else { diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/channel.rs b/crates/pf-capture/src/windows/idd_push/channel.rs similarity index 92% rename from crates/punktfunk-host/src/capture/windows/idd_push/channel.rs rename to crates/pf-capture/src/windows/idd_push/channel.rs index f50997c6..a4a8e993 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push/channel.rs +++ b/crates/pf-capture/src/windows/idd_push/channel.rs @@ -20,9 +20,11 @@ pub(super) struct ChannelBroker { process: OwnedHandle, /// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail). pub(super) wudf_pid: u32, - /// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the - /// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound. - control: HANDLE, + /// Delivers a filled `SetFrameChannelRequest` to the pf-vdisplay driver + /// (`IOCTL_SET_FRAME_CHANNEL`). The host facade builds this from the vdisplay control device + + /// `send_frame_channel` IOCTL wrapper, so this crate delivers the channel without reaching into + /// the orchestrator's `vdisplay` module (plan §W6). Called once per generation, never per-frame. + sender: crate::FrameChannelSender, } impl ChannelBroker { @@ -35,13 +37,10 @@ impl ChannelBroker { /// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a /// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes /// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening). - pub(super) fn open(wudf_pid: u32) -> Result { + pub(super) fn open(wudf_pid: u32, sender: crate::FrameChannelSender) -> Result { if wudf_pid == 0 { bail!("driver reported no WUDFHost pid for the frame channel"); } - let control = crate::vdisplay::manager::control_device_handle().context( - "pf-vdisplay control device not open (monitor not created via the manager?)", - )?; // SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and // moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it // for the duration of the synchronous check and forms no lasting alias. @@ -59,7 +58,7 @@ impl ChannelBroker { Ok(Self { process, wudf_pid, - control, + sender, }) } @@ -182,8 +181,9 @@ impl ChannelBroker { slots: &[HostSlot], ) -> Result<()> { // SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live - // handles of this process, and `self.control` is the manager's control handle, never closed for - // the process lifetime (`send_frame_channel`'s precondition). + // handles of this process. The `sender` closure encapsulates the manager's control handle + + // the `send_frame_channel` IOCTL (its precondition — a live control handle — is upheld by the + // host facade that built it). unsafe { // Least privilege per handle: the header maps read/write, the event is only signalled, and // the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`). @@ -192,7 +192,7 @@ impl ChannelBroker { for (k, s) in slots.iter().enumerate() { req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?; } - crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req) + (self.sender)(req) } } } diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs b/crates/pf-capture/src/windows/idd_push/descriptor.rs similarity index 97% rename from crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs rename to crates/pf-capture/src/windows/idd_push/descriptor.rs index 7f1d97b5..470e3b6c 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs +++ b/crates/pf-capture/src/windows/idd_push/descriptor.rs @@ -63,8 +63,8 @@ impl DescriptorPoller { // target id (see their own SAFETY docs); nothing is borrowed across the calls. let (hdr, res) = unsafe { ( - crate::win_display::advanced_color_enabled(target_id), - crate::win_display::active_resolution(target_id), + pf_win_display::win_display::advanced_color_enabled(target_id), + pf_win_display::win_display::active_resolution(target_id), ) }; let took = t.elapsed(); diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/stall.rs b/crates/pf-capture/src/windows/idd_push/stall.rs similarity index 100% rename from crates/punktfunk-host/src/capture/windows/idd_push/stall.rs rename to crates/pf-capture/src/windows/idd_push/stall.rs diff --git a/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs b/crates/pf-capture/src/windows/synthetic_nv12.rs similarity index 97% rename from crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs rename to crates/pf-capture/src/windows/synthetic_nv12.rs index 3abe6101..b1b687f6 100644 --- a/crates/punktfunk-host/src/capture/windows/synthetic_nv12.rs +++ b/crates/pf-capture/src/windows/synthetic_nv12.rs @@ -2,15 +2,15 @@ //! without a real capture session. //! //! The native AMF path (and the D3D11 zero-copy NVENC/QSV paths) require an NV12 texture that lives -//! on the GPU — the CPU-Bgrx [`SyntheticCapturer`](crate::capture::SyntheticCapturer) can't provide +//! on the GPU — the CPU-Bgrx [`SyntheticCapturer`](crate::SyntheticCapturer) can't provide //! one, and DXGI Desktop Duplication can't create one under an ssh session-0 (E_ACCESSDENIED). This //! source builds an NV12 texture on the selected render adapter and fills it with a **moving** luma //! ramp each frame, so the encoder sees genuine motion (P-frame residuals + the intra-refresh wave //! under content change) — exactly what an intra-refresh recovery validation needs. Driven by //! `spike --source synthetic-nv12`. -use crate::capture::dxgi::{make_device, D3d11Frame}; -use crate::capture::{CapturedFrame, Capturer, FramePayload, PixelFormat}; +use crate::dxgi::{make_device, D3d11Frame}; +use crate::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use anyhow::{Context, Result}; use windows::Win32::Graphics::Direct3D11::{ ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D, D3D11_BIND_SHADER_RESOURCE, diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index cb7cba0b..46db0254 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -30,6 +30,10 @@ pf-win-display = { path = "../pf-win-display" } # 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" } +# Frame capture backends (Linux PipeWire portal + Windows IDD direct-push) behind one Capturer +# trait, extracted to a subsystem crate (plan §W6). The facade (src/capture.rs) hands it the +# pre-resolved encode facts + the sealed-channel delivery closure so the edge stays one-way. +pf-capture = { path = "../pf-capture" } # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP). quinn = "0.11" anyhow = "1" diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index 92529e71..d535d33a 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -246,7 +246,7 @@ fn mic_pw_thread( // the blocking run share one frame; the IIFE lets every setup `?` funnel through the ready // handshake below (mirrors the Windows render_thread). let result = (|| -> Result<()> { - crate::pwinit::ensure_init(); + pf_capture::pwinit::ensure_init(); let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw mic MainLoop")?; let context = pw::context::ContextRc::new(&mainloop, None).context("pw mic Context")?; let core = context @@ -493,7 +493,7 @@ fn pw_thread( use spa::param::audio::{AudioFormat, AudioInfoRaw}; use spa::pod::Pod; - crate::pwinit::ensure_init(); + pf_capture::pwinit::ensure_init(); let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw audio MainLoop")?; let context = pw::context::ContextRc::new(&mainloop, None).context("pw audio Context")?; let core = context diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index ca70c615..e25204c5 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -1,229 +1,36 @@ -//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. The spike uses the -//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU -//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk). - -// 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 (capture::windows/linux::*). -#![deny(clippy::undocumented_unsafe_blocks)] +//! Frame capture facade (plan §7 / §W6). The capturers themselves live in the `pf-capture` +//! subsystem crate; this host module is the thin BRIDGE that (a) re-exports the shared frame +//! vocabulary + the capturer types so every `crate::capture::*` path is unchanged, and (b) keeps +//! the orchestration entry points — [`open_portal_monitor`] / [`capture_virtual_output`] — which +//! know about `crate::{vdisplay, session_plan, inject, encode}` and hand pf-capture the pre-resolved +//! facts it needs (the [`pf_capture::ZeroCopyPolicy`] and, on Windows, the +//! [`pf_capture::FrameChannelSender`]) so the capturer never reaches back into the orchestrator. use anyhow::Result; -// The shared frame vocabulary lives in the `pf-frame` leaf crate (plan §W6); re-export it here so -// every existing `crate::capture::{PixelFormat, CapturedFrame, …}` path stays valid. -pub use pf_frame::{CapturedFrame, FramePayload, OutputFormat, PixelFormat}; -// `CursorOverlay` (cursor-as-metadata) and the dmabuf vocabulary are named only by the Linux -// capture/encode paths; gate the re-exports so the Windows build doesn't flag them unused. +// The shared frame vocabulary lives in `pf-frame`; re-export the pieces host modules still name via +// `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture). +pub use pf_frame::{CapturedFrame, OutputFormat, PixelFormat}; +// The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths. +pub use pf_capture::{capturer_supports_444, Capturer, FastSyntheticCapturer, SyntheticCapturer}; +// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest}` (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}; + +/// Resolve the [`pf_capture::ZeroCopyPolicy`] for a Linux capture session from the encode backend — +/// the one reach into `crate::encode` the capturer must NOT make itself (it would recreate the +/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way +/// (plan §2.4 / §W6). #[cfg(target_os = "linux")] -pub use pf_frame::{drm_fourcc, CursorOverlay, 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). -pub trait Capturer: Send { - fn next_frame(&mut self) -> Result; - - /// Non-blocking: the freshest frame available since the last call, or `None` if none has - /// arrived (the caller reuses its last frame to hold a steady output rate). The default - /// just produces a frame each call — fine for instant synthetic sources; the portal - /// overrides it to drain its channel without blocking. - fn try_latest(&mut self) -> Result> { - self.next_frame().map(Some) - } - - /// Gate expensive per-frame work so the capturer can be kept alive (reused) between - /// streams without burning CPU. The portal capturer skips the de-pad copy while inactive; - /// the default is a no-op (synthetic sources are produced on demand). Set `true` for the - /// 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. - fn hdr_meta(&self) -> Option { - None - } - - /// How many frames the encode loop may keep in flight (submitted but not yet polled) before it - /// blocks. `1` (the default) is the synchronous loop: capture → submit → poll-blocks, so the - /// per-frame wall time is `capture+convert + encode`. A capturer that hands a fresh output texture - /// per frame (so the encode of N reads a different texture than the convert of N+1 writes) can return - /// `>1` to PIPELINE: the loop submits N+1 before polling N, overlapping the convert/copy on the 3D - /// engine with the NVENC-ASIC encode of the prior frame, dropping per-frame wall toward `max(...)`. - fn pipeline_depth(&self) -> usize { - 1 - } -} - -/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file → -/// `punktfunk_core` path with no live capture session, and produces obviously non-static -/// content (a sweeping bar + animated gradient) so the encoded output is verifiable. -pub struct SyntheticCapturer { - width: u32, - height: u32, - fps: u32, - frame_idx: u64, - buf: Vec, -} - -impl SyntheticCapturer { - const BPP: usize = 4; // emits BGRx - - pub fn new(width: u32, height: u32, fps: u32) -> Self { - assert!(width > 0 && height > 0 && fps > 0); - let buf = vec![0u8; width as usize * height as usize * Self::BPP]; - SyntheticCapturer { - width, - height, - fps, - frame_idx: 0, - buf, - } - } -} - -impl Capturer for SyntheticCapturer { - fn next_frame(&mut self) -> Result { - let w = self.width as usize; - let h = self.height as usize; - let bpp = Self::BPP; - let t = self.frame_idx; - // A vertical bar sweeps left→right once every ~2s; the background is a gradient - // whose phase advances each frame, so every pixel changes frame-to-frame. - let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64; - let phase = (t % 256) as usize; - for y in 0..h { - let row = y * w * bpp; - for x in 0..w { - let i = row + x * bpp; - let on_bar = (x as u64).abs_diff(bar_x) < 8; - // BGRx byte order: [B, G, R, x] - self.buf[i] = if on_bar { - 255 - } else { - ((x + phase) & 0xff) as u8 - }; - self.buf[i + 1] = if on_bar { - 255 - } else { - ((y + phase) & 0xff) as u8 - }; - self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 }; - self.buf[i + 3] = 0; - } - } - let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64; - self.frame_idx += 1; - Ok(CapturedFrame { - width: self.width, - height: self.height, - pts_ns, - format: PixelFormat::Bgrx, - payload: FramePayload::Cpu(self.buf.clone()), - cursor: None, - }) - } -} - -/// A cheap moving test pattern (BGRx) for the streaming path: a pulsing field + a white band -/// sweeping down, generated with whole-buffer `fill`s so it stays real-time even at 5K. -pub struct FastSyntheticCapturer { - width: u32, - height: u32, - frame_idx: u64, - buf: Vec, - /// PUNKTFUNK_SYNTH_NOISE: every frame is fresh high-entropy noise NVENC can't compress or - /// predict, so the encoder hits its (CBR) bitrate target — a throughput test of the real - /// encode→FEC→send→recv path. The default flat/band content compresses to ~nothing, so it - /// can't generate real Mbps (the encoder is content-driven). xorshift over u64 chunks. - noise: bool, - rng: u64, -} - -impl FastSyntheticCapturer { - pub fn new(width: u32, height: u32) -> Self { - assert!(width > 0 && height > 0); - FastSyntheticCapturer { - width, - height, - frame_idx: 0, - buf: vec![0u8; width as usize * height as usize * 4], - noise: std::env::var_os("PUNKTFUNK_SYNTH_NOISE").is_some(), - rng: 0x9e3779b97f4a7c15, - } - } -} - -impl Capturer for FastSyntheticCapturer { - fn next_frame(&mut self) -> Result { - if self.noise { - // Fresh, every-frame-decorrelated noise: reseed from the frame index so consecutive - // frames share no structure (forces large P-frames too, not just the keyframe). - let mut s = self - .rng - .wrapping_add(self.frame_idx.wrapping_mul(0x2545F491_4F6CDD1D)) - | 1; - for c in self.buf.chunks_exact_mut(8) { - s ^= s << 13; - s ^= s >> 7; - s ^= s << 17; - c.copy_from_slice(&s.to_le_bytes()); - } - self.rng = s; - } else { - let (w, h) = (self.width as usize, self.height as usize); - let row = w * 4; - let shade = (self.frame_idx % 256) as u8; - self.buf.fill(shade); - let band_h = (h / 20).max(1); - let band_y = (self.frame_idx as usize * 6) % h; - for y in band_y..(band_y + band_h).min(h) { - self.buf[y * row..(y + 1) * row].fill(0xff); - } - } - self.frame_idx += 1; - Ok(CapturedFrame { - width: self.width, - height: self.height, - pts_ns: 0, - format: PixelFormat::Bgrx, - payload: FramePayload::Cpu(self.buf.clone()), - cursor: None, - }) - } -} - -/// The encode-backend facts the Linux zero-copy negotiation needs, resolved **once** here (the host -/// facade, which may reach `crate::encode`) and passed **into** the capturer — so the capturer never -/// calls back into `encode`, keeping the capture→encode dependency one-way (plan §2.4 / §W6). The -/// three facts were formerly re-derived inside the PipeWire thread via -/// `encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu, pyrowave_capture_modifiers}`. -#[cfg(target_os = "linux")] -#[derive(Clone, Default)] -pub struct ZeroCopyPolicy { - /// The GPU encode backend resolves to VAAPI (AMD/Intel) — the capturer hands raw dmabufs - /// straight through instead of the EGL→CUDA import ([`crate::encode::linux_zero_copy_is_vaapi`]). - pub backend_is_vaapi: bool, - /// The resolved backend produces GPU-resident frames (everything but the software encoder) — - /// used only to phrase the CPU-fallback warning ([`crate::encode::resolved_backend_is_gpu`]). - pub backend_is_gpu: bool, - /// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc, - /// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA, - /// which allocates tiled-only, still negotiates zero-copy). Empty otherwise. - pub pyrowave_modifiers: Vec, -} - -/// Resolve the [`ZeroCopyPolicy`] for a Linux capture session from the encode backend. Called by the -/// facade openers below so the capturer receives the facts rather than re-deriving them. -#[cfg(target_os = "linux")] -fn zero_copy_policy() -> ZeroCopyPolicy { +fn zero_copy_policy() -> pf_capture::ZeroCopyPolicy { let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi(); #[cfg(feature = "pyrowave")] let pyrowave_modifiers = if backend_is_vaapi && pf_host_config::config().encoder_pref.as_str() == "pyrowave" { - // BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys on - // it). `drm_fourcc(Bgrx)` is always `Some`. - drm_fourcc(PixelFormat::Bgrx) + // BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys + // on it). `drm_fourcc(Bgrx)` is always `Some`. + pf_frame::drm_fourcc(PixelFormat::Bgrx) .map(crate::encode::pyrowave_capture_modifiers) .unwrap_or_default() } else { @@ -231,23 +38,21 @@ fn zero_copy_policy() -> ZeroCopyPolicy { }; #[cfg(not(feature = "pyrowave"))] let pyrowave_modifiers = Vec::new(); - ZeroCopyPolicy { + pf_capture::ZeroCopyPolicy { backend_is_vaapi, backend_is_gpu: crate::encode::resolved_backend_is_gpu(), pyrowave_modifiers, } } -/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal -/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule. +/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. #[cfg(target_os = "linux")] pub fn open_portal_monitor() -> Result> { // On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop // session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal, // so use a plain ScreenCast session there. let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei; - linux::PortalCapturer::open(anchored, zero_copy_policy()) - .map(|c| Box::new(c) as Box) + pf_capture::open_portal_monitor(anchored, zero_copy_policy()) } #[cfg(not(target_os = "linux"))] @@ -255,11 +60,9 @@ pub fn open_portal_monitor() -> Result> { anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)") } -/// Build a capturer from an already-created virtual output (see [`crate::vdisplay`]). Consumes -/// the output's PipeWire node + optional remote fd + keepalive — the capturer owns the keepalive, -/// so dropping the capturer releases the virtual output. Compositor-agnostic: works for any -/// [`crate::vdisplay::VirtualDisplay`] backend. The captured size is the size the output was -/// created at — native, no scaling. +/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]). +/// Explodes the output into the primitives pf-capture needs (so the capturer never depends on the +/// vdisplay type); the capturer takes the keepalive, so dropping it releases the output. #[cfg(target_os = "linux")] pub fn capture_virtual_output( vout: crate::vdisplay::VirtualOutput, @@ -269,11 +72,17 @@ pub fn capture_virtual_output( // The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own pixel // format, so `want.gpu` gates GPU zero-copy capture (the capture backend is always the portal — // the `CaptureBackend` arg is a Windows-only dispatch) and `want.chroma_444` selects the - // worker's planar-YUV444 GPU convert for tiled dmabufs (the 4:4:4 zero-copy path). `gpu = - // false` (4:4:4 without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident - // RGB to swscale into YUV444P. - linux::PortalCapturer::from_virtual_output(vout, want.gpu, want.chroma_444, zero_copy_policy()) - .map(|c| Box::new(c) as Box) + // worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4 without zero-copy) forces the CPU + // mmap path so the encoder gets CPU-resident RGB to swscale into YUV444P. + pf_capture::open_virtual_output( + vout.remote_fd, + vout.node_id, + vout.preferred_mode, + vout.keepalive, + want.gpu, + want.chroma_444, + zero_copy_policy(), + ) } #[cfg(target_os = "windows")] @@ -290,46 +99,42 @@ pub fn capture_virtual_output( })?; let pref = vout.preferred_mode; let keep = vout.keepalive; + // The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is + // process-global — a dead one is retired, kept alive — so the raw value is stable for the + // process) and wrap `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls + // at ring attach. This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; + // building it here keeps the capture→vdisplay dependency out of pf-capture (plan §W6). + let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| { + anyhow::anyhow!( + "pf-vdisplay control device not open (monitor not created via the manager?)" + ) + })?; + // `HANDLE` is not `Send`; capture the raw value and rebuild it inside the closure (the control + // device is never closed for the process lifetime, so the value stays valid). + let control_raw = control.0 as isize; + let sender: pf_capture::FrameChannelSender = std::sync::Arc::new( + move |req: &pf_driver_proto::control::SetFrameChannelRequest| { + // SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is never + // closed for the process lifetime, so reconstructing the `HANDLE` and issuing the + // `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition). + unsafe { + crate::vdisplay::pf_vdisplay::send_frame_channel( + windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void), + req, + ) + } + }, + ); // IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay // driver's shared ring (in-process, Session 0 — it captures the secure desktop too; no Desktop - // Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's - // swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display - // is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and - // selects the per-frame conversion (FP16 → P010 vs BGRA → NV12, or BGRA → AYUV for a - // `want.chroma_444` SDR session). `IddPushCapturer` takes the keepalive (it owns the virtual - // display). There is NO fallback (DDA + the WGC relay were removed): if it can't open or the - // driver doesn't attach, the session fails cleanly and the client reconnects. - idd_push::IddPushCapturer::open(target, pref, want.hdr, want.chroma_444, keep) - .map(|c| Box::new(c) as Box) + // Duplication, no WGC helper). A FRESH monitor + ring is created per session. `want.hdr` + // proactively enables advanced color and selects the per-frame conversion. There is NO fallback: + // if it can't open or the driver doesn't attach, the session fails cleanly and the client + // reconnects. + pf_capture::open_idd_push(target, pref, want.hdr, want.chroma_444, keep, sender) .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) } -/// 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. `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(_encoder_ingests_rgb_444: bool) -> bool { - true -} -#[cfg(target_os = "windows")] -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). - encoder_ingests_rgb_444 -} -#[cfg(not(any(target_os = "linux", target_os = "windows")))] -pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool { - false -} - #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub fn capture_virtual_output( _vout: crate::vdisplay::VirtualOutput, @@ -338,18 +143,3 @@ pub fn capture_virtual_output( ) -> Result> { anyhow::bail!("virtual-output capture requires Linux or Windows") } - -// Goal-1 stage 6: the Windows backend lives under `capture/windows/`, the Linux one under `capture/linux/` -// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged). Windows capture -// is IDD direct-push only — DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed. -#[cfg(target_os = "windows")] -#[path = "capture/windows/dxgi.rs"] -pub mod dxgi; -#[cfg(target_os = "windows")] -#[path = "capture/windows/idd_push.rs"] -pub mod idd_push; -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "windows")] -#[path = "capture/windows/synthetic_nv12.rs"] -pub mod synthetic_nv12; diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index ef6280e8..1d3b98af 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -370,7 +370,7 @@ impl PadChannel { ) .context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?; let process = OwnedHandle::from_raw_handle(h.0 as _); - crate::capture::idd_push::verify_is_wudfhost( + pf_capture::verify_is_wudfhost( HANDLE(process.as_raw_handle()), pid, "gamepad-channel", diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index aab324df..9c61e94e 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -62,7 +62,6 @@ mod mgmt_token; mod native; mod native_pairing; mod pipeline; -mod pwinit; mod send_pacing; #[cfg(target_os = "windows")] #[path = "windows/service.rs"] @@ -73,17 +72,16 @@ mod spike; mod stats_recorder; mod stream_marker; mod vdisplay; -// 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. +// The Windows display-topology cluster (CCD/GDI mode-set + PnP monitor devnodes) lives in the +// `pf-win-display` leaf crate (plan §W6); import the modules at the crate root so the host's +// `crate::{win_display,monitor_devnode}::*` paths stay valid. (`display_events` moved with the +// IDD-push capturer into pf-capture, which names it directly.) #[cfg(target_os = "windows")] -use pf_win_display::{display_events, monitor_devnode, win_display}; +use pf_win_display::{monitor_devnode, win_display}; // The zero-copy GPU plumbing lives in the `pf-zerocopy` leaf crate (plan §W6); this shim keeps -// every existing `crate::zerocopy::*` path valid. `drm_fourcc` consumes the frame vocabulary, so -// it sits with `capture` and is re-exported here for its old callers. +// every existing `crate::zerocopy::*` path valid for the host's remaining callers (session_plan). #[cfg(target_os = "linux")] mod zerocopy { - pub(crate) use crate::capture::drm_fourcc; pub(crate) use pf_zerocopy::*; }