audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Successful in 15s
arch / build-publish (push) Successful in 12m46s
windows-host / package (push) Successful in 12m53s
android / android (push) Successful in 13m48s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / bench (push) Successful in 5m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m10s
ci / rust (push) Successful in 19m38s
decky / build-publish (push) Successful in 27s
deb / build-publish (push) Successful in 8m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 1m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 1m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m54s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 10m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m26s
flatpak / build-publish (push) Successful in 6m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
release / apple (push) Successful in 56m19s
apple / screenshots (push) Successful in 23m11s
Pen, touch, and absolute mouse arrive normalized to the STREAMED display's frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped them over the whole virtual desktop — correct only when the virtual display is the sole active display (Exclusive topology). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive isolate degraded to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero origin, so every sample landed shifted and mis-scaled. The pen exposed it first (field report 2026-07-24): a stylus is strictly absolute, with no closed-loop correction onto the target like a cursor. New pf-inject::stream_target (Windows): the host publishes the streamed output's CCD target id at capture bring-up (one central site — capture_virtual_output covers the native and GameStream planes); mapping sites resolve its current desktop rect through pf-win-display's source_desktop_rect — the same resolver the cursor-readback poller uses, so inject and readback always agree — TTL-cached (250 ms) because a group-layout re-arrange moves a live output's origin mid-session. No target set / never resolved falls back to the whole virtual desktop (the historical mapping, still right for Exclusive topology and devtest). One change in to_screen covers pen + touch; MouseMoveAbs converts the same desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing the identical latent absolute-mouse bug (apollo-comparison open item #14/#30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
235 lines
12 KiB
Rust
235 lines
12 KiB
Rust
//! 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 `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_supports_hdr, 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")]
|
|
fn zero_copy_policy(
|
|
pyrowave_session: bool,
|
|
native_nv12_session: bool,
|
|
) -> pf_capture::ZeroCopyPolicy {
|
|
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
|
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
|
|
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
|
|
// global `PUNKTFUNK_ENCODER=pyrowave` lab lever (which also flips `backend_is_vaapi`).
|
|
#[cfg(feature = "pyrowave")]
|
|
let pyrowave_session =
|
|
pyrowave_session || pf_host_config::config().encoder_pref.as_str() == "pyrowave";
|
|
#[cfg(not(feature = "pyrowave"))]
|
|
let pyrowave_session = {
|
|
let _ = pyrowave_session;
|
|
false
|
|
};
|
|
#[cfg(feature = "pyrowave")]
|
|
let pyrowave_modifiers = if pyrowave_session {
|
|
// 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 {
|
|
Vec::new()
|
|
};
|
|
#[cfg(not(feature = "pyrowave"))]
|
|
let pyrowave_modifiers = Vec::new();
|
|
pf_capture::ZeroCopyPolicy {
|
|
backend_is_vaapi,
|
|
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
|
|
pyrowave_session,
|
|
pyrowave_modifiers,
|
|
native_nv12_session,
|
|
}
|
|
}
|
|
|
|
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
|
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
|
|
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
|
#[cfg(target_os = "linux")]
|
|
pub fn open_portal_monitor(want_hdr: bool) -> 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;
|
|
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
|
|
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
|
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
|
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
|
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
|
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
|
}
|
|
|
|
/// 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,
|
|
want: OutputFormat,
|
|
_capture: crate::session_plan::CaptureBackend,
|
|
) -> Result<Box<dyn Capturer>> {
|
|
// The Linux NATIVE plane stays 8-bit (Mutter's virtual-monitor streams are SDR-only upstream;
|
|
// the GNOME 50+ HDR path is monitor-mirror only — `open_portal_monitor`) 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. `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(want.pyrowave, want.nv12_native),
|
|
vout.expect_exact_dims,
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
pub fn capture_virtual_output(
|
|
vout: crate::vdisplay::VirtualOutput,
|
|
want: OutputFormat,
|
|
_capture: crate::session_plan::CaptureBackend,
|
|
) -> Result<Box<dyn Capturer>> {
|
|
let target = vout.win_capture.clone().ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"pf-vdisplay target not yet an active display path (activation failed — see the \
|
|
virtual-display warnings above)"
|
|
)
|
|
})?;
|
|
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
|
|
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
|
|
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
|
|
// degraded to the keep-physicals fallback) — the pen-offset field bug.
|
|
crate::inject::set_stream_target(Some(target.target_id));
|
|
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::driver::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. `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.
|
|
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
|
|
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
|
|
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
|
|
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
|
|
std::sync::Arc::new(
|
|
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
|
|
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
|
// never closed for the process lifetime (`send_cursor_channel`'s precondition).
|
|
unsafe {
|
|
crate::vdisplay::driver::send_cursor_channel(
|
|
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
|
req,
|
|
)
|
|
}
|
|
},
|
|
) as pf_capture::CursorChannelSender
|
|
});
|
|
// The secure-desktop guard's actuator (`IOCTL_SET_CURSOR_FORWARD`): the capturer flips the
|
|
// driver's hardware-cursor declare off while UAC/Winlogon is up (the secure desktop renders
|
|
// only through the OS's software-cursor path) and back on at dismissal. The stand-down needs
|
|
// the same-mode re-commit that actualises the software-cursor default — driven here because
|
|
// topology commits belong under the vdisplay manager's lock, which pf-capture cannot take.
|
|
// Built for EVERY session (not just `want.hw_cursor`): a channel-less session can reuse a
|
|
// driver monitor whose cursor worker (an earlier session's) is still live and re-declaring —
|
|
// the flip is the only way to stop it; on a never-declared target the driver answers
|
|
// NOT_FOUND, which the capturer logs and ignores.
|
|
let target_id = target.target_id;
|
|
let cursor_forward: Option<pf_capture::CursorForwardSender> = Some({
|
|
std::sync::Arc::new(move |enable: bool| {
|
|
let req = pf_driver_proto::control::SetCursorForwardRequest {
|
|
target_id,
|
|
enable: enable as u32,
|
|
};
|
|
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
|
// never closed for the process lifetime (`send_cursor_forward`'s precondition).
|
|
unsafe {
|
|
crate::vdisplay::driver::send_cursor_forward(
|
|
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
|
&req,
|
|
)?;
|
|
}
|
|
if !enable {
|
|
crate::vdisplay::manager::force_recommit();
|
|
}
|
|
Ok(())
|
|
}) as pf_capture::CursorForwardSender
|
|
});
|
|
pf_capture::open_idd_push(
|
|
target,
|
|
pref,
|
|
want.hdr,
|
|
want.chroma_444,
|
|
want.pyrowave,
|
|
keep,
|
|
sender,
|
|
cursor_sender,
|
|
cursor_forward,
|
|
)
|
|
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
|
pub fn capture_virtual_output(
|
|
_vout: crate::vdisplay::VirtualOutput,
|
|
_want: OutputFormat,
|
|
_capture: crate::session_plan::CaptureBackend,
|
|
) -> Result<Box<dyn Capturer>> {
|
|
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
|
}
|