refactor(host/W6.2): extract the frame-capture backends into the pf-capture crate
capture/linux (PipeWire portal) + capture/windows (IDD direct-push: dxgi mechanics, idd_push + submodules, synthetic_nv12) + pwinit move into crates/pf-capture behind the Capturer trait + synthetic sources (plan §W6). The crate speaks pf-frame (CapturedFrame/PixelFormat + the DXGI identity), pf-zerocopy (CUDA import), and the pf-win-display leaves, and NEVER pf-encode — the capture->encode edge is one-way. This completes the deliberate capture/encode crate split (the invasive path the plan had merged into one pf-media): capture and encode are now separate subsystem crates sharing only pf-frame. Four seams keep the capturer off the orchestrator: - VirtualOutput is EXPLODED into primitives (remote_fd/node_id/preferred_mode/ keepalive) by the host facade, so pf-capture never depends on the vdisplay type; - FrameChannelSender: the sealed-channel delivery is a Send+Sync closure the host facade builds from the pf-vdisplay control device + send_frame_channel IOCTL and hands in; ChannelBroker holds the closure instead of the control HANDLE (the whole-desktop handle-duplication security boundary is byte-for-byte unchanged); - console_session_mismatch + desktop_bounds live in pf-win-display (leaf peers); - pwinit moves here (audio caller -> pf_capture::pwinit). The host keeps capture.rs as a thin BRIDGE: it re-exports the vocabulary + capturer types (every crate::capture::* path is unchanged) and keeps open_portal_monitor / capture_virtual_output, which resolve the ZeroCopyPolicy + FrameChannelSender and call into pf-capture. verify_is_wudfhost + install_gpu_pref_hook are re-exported (the gamepad-channel bootstrap + the main.rs subcommand consume them). Co-developed: the resident-HID-mouse compose-kick hook (HID_COMPOSE_KICK + the HID-first cursor kick + _display_wake) rides this commit into pf-capture; the host mouse_windows registration side lands separately on top. Verified: Linux clippy -D warnings (pf-capture + host nvenc,vulkan-encode,pyrowave --all-targets) + host tests 299/299; Windows clippy -D warnings (pf-capture --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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<CapturedFrame>;
|
||||
|
||||
/// 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<Option<CapturedFrame>> {
|
||||
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<punktfunk_core::quic::HdrMeta> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<CapturedFrame> {
|
||||
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<u8>,
|
||||
/// 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<CapturedFrame> {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
#[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<HidKickFn> = 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<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(anchored, policy).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// 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<std::os::fd::OwnedFd>,
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::from_virtual_output(
|
||||
remote_fd,
|
||||
node_id,
|
||||
preferred_mode,
|
||||
keepalive,
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
policy,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// 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<dyn Send>,
|
||||
sender: FrameChannelSender,
|
||||
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
|
||||
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
+37
-34
@@ -55,7 +55,7 @@ pub struct PortalCapturer {
|
||||
stall_since: Option<std::time::Instant>,
|
||||
/// 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<OwnedFd>,
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<PortalCapturer> {
|
||||
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<crate::capture::CursorOverlay> {
|
||||
fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||
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<crate::zerocopy::Importer>,
|
||||
/// normally via the isolated worker process (`pf_zerocopy::Importer::Remote`).
|
||||
importer: Option<pf_zerocopy::Importer>,
|
||||
/// 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(),
|
||||
+2
-2
@@ -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.
|
||||
+61
-18
@@ -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<pf_frame::session_tuning::DisplayWakeRequest>,
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// 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<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// 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<Self> {
|
||||
// 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::<AttachTexFail>()
|
||||
.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<Self> {
|
||||
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 {
|
||||
+11
-11
@@ -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<Self> {
|
||||
pub(super) fn open(wudf_pid: u32, sender: crate::FrameChannelSender) -> Result<Self> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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();
|
||||
+3
-3
@@ -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,
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CapturedFrame>;
|
||||
|
||||
/// 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<Option<CapturedFrame>> {
|
||||
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<punktfunk_core::quic::HdrMeta> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<CapturedFrame> {
|
||||
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<u8>,
|
||||
/// 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<CapturedFrame> {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
/// 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<Box<dyn Capturer>> {
|
||||
// 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<dyn Capturer>)
|
||||
pf_capture::open_portal_monitor(anchored, zero_copy_policy())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -255,11 +60,9 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
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<dyn Capturer>)
|
||||
// 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<dyn Capturer>)
|
||||
// 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<Box<dyn Capturer>> {
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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::*;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user