fix(host/encode): negotiate the cursor around what the encoder can blend
EncoderCaps::blends_cursor's contract said the HOST must fall back to capturer-side compositing when a cursor-as-metadata session lands on an encoder that can't composite — but that host half was never built: open_video warned and the session streamed WITHOUT a pointer (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths; latent on vulkan RGB-direct/native-NV12). The negotiation is now caps-aware, ahead of capture, on both planes: * pf-encode grows cursor_blend_capable() — the pre-open dispatch mirror (sibling of linux_native_nv12_ok) answering whether the resolved backend composites frame.cursor; its pure core is test-pinned arm by arm. * Native plane: handshake::cursor_forward grants the cursor channel only where the resolved backend can blend (the capture-mouse flip makes the host draw the pointer on demand); denied sessions keep the pre-channel path — the compositor EMBEDS the pointer, never cursorless, never doubled. The Welcome's HOST_CAP_CURSOR bit is computed once and read back at both session-wiring sites instead of recomputed. SessionPlan::output_format additionally keeps every cursor-blend session off producer-native NV12 (the arm with no CSC to fold a cursor into), and vulkan RGB-direct now yields to a cursor-blend session even when pinned (EFC cannot composite; the open logs the override). Windows plans cursor_blend=false via the new shared cursor_blend_for() rule — the IDD capturer composites the pointer itself, and asking the encoder anyway fired the blends-cursor warn spuriously on every cursor-channel session. * GameStream plane: the hardcoded cursor_blend=true is gone. The portal source asks for cursor-as-metadata only when the resolved backend blends, otherwise negotiates an Embedded pointer (choose_cursor_mode's new ladder); the capturer pool now also keys on that mode. The virtual-output source passes false — its capture embeds the pointer where it can. The per-arm warns in vulkan_video (RGB-direct, native-NV12) are now structurally unreachable and removed. open_video's post-open check stays as the single backstop for what planning cannot see: a Vulkan-open falling back to VAAPI mid-session, and the gamescope residual (no embedded mode exists there, so a never-blending backend — H.264-on-AMD VAAPI, software — still streams cursorless; fixing that needs a compositing stage, deliberately not built in this pass). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -447,14 +447,24 @@ pub mod synthetic_nv12;
|
|||||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
|
||||||
|
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
|
||||||
|
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
|
||||||
|
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
|
||||||
|
/// (the one-way edge).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor(
|
pub fn open_portal_monitor(
|
||||||
anchored: bool,
|
anchored: bool,
|
||||||
want_hdr: bool,
|
want_hdr: bool,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
policy: ZeroCopyPolicy,
|
policy: ZeroCopyPolicy,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
linux::PortalCapturer::open(
|
||||||
|
anchored,
|
||||||
|
want_hdr && !hdr_capture_failed(),
|
||||||
|
want_metadata_cursor,
|
||||||
|
policy,
|
||||||
|
)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,16 +105,21 @@ impl PortalCapturer {
|
|||||||
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
||||||
/// ScreenCast session (wlroots, which has no RemoteDesktop portal). `want_hdr` offers the
|
/// ScreenCast session (wlroots, which has no RemoteDesktop portal). `want_hdr` offers the
|
||||||
/// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set.
|
/// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set.
|
||||||
pub fn open(anchored: bool, want_hdr: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
|
pub fn open(
|
||||||
|
anchored: bool,
|
||||||
|
want_hdr: bool,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
policy: ZeroCopyPolicy,
|
||||||
|
) -> Result<PortalCapturer> {
|
||||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("punktfunk-portal".into())
|
.name("punktfunk-portal".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if anchored {
|
if anchored {
|
||||||
portal_thread_remote_desktop(setup_tx)
|
portal_thread_remote_desktop(setup_tx, want_metadata_cursor)
|
||||||
} else {
|
} else {
|
||||||
portal_thread(setup_tx)
|
portal_thread(setup_tx, want_metadata_cursor)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.context("spawn portal thread")?;
|
.context("spawn portal thread")?;
|
||||||
@@ -647,19 +652,23 @@ pub fn gnome_hdr_monitor_active() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`),
|
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||||
/// preferring **cursor-as-metadata**: the compositor keeps its cheap hardware cursor plane and
|
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||||
/// ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + an occasional bitmap),
|
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||||
/// which the consumer composites itself. That avoids forcing the producer to burn the cursor into
|
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
|
||||||
/// every frame — the `Embedded` mode — which on gamescope would defeat its HW cursor plane. Falls
|
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
|
||||||
/// back to `Embedded`, then `Hidden`, and (if the property query fails, e.g. an older portal)
|
/// Without it — the session's encode path has no compositing stage for a metadata cursor
|
||||||
/// keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
|
||||||
|
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
|
||||||
|
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
|
||||||
|
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||||
async fn choose_cursor_mode(
|
async fn choose_cursor_mode(
|
||||||
proxy: &ashpd::desktop::screencast::Screencast,
|
proxy: &ashpd::desktop::screencast::Screencast,
|
||||||
|
want_metadata: bool,
|
||||||
) -> ashpd::desktop::screencast::CursorMode {
|
) -> ashpd::desktop::screencast::CursorMode {
|
||||||
use ashpd::desktop::screencast::CursorMode;
|
use ashpd::desktop::screencast::CursorMode;
|
||||||
match proxy.available_cursor_modes().await {
|
match proxy.available_cursor_modes().await {
|
||||||
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?avail,
|
?avail,
|
||||||
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||||
@@ -667,12 +676,30 @@ async fn choose_cursor_mode(
|
|||||||
CursorMode::Metadata
|
CursorMode::Metadata
|
||||||
}
|
}
|
||||||
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||||
|
if want_metadata {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
?avail,
|
?avail,
|
||||||
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
|
||||||
|
composite a metadata cursor)"
|
||||||
|
);
|
||||||
|
}
|
||||||
CursorMode::Embedded
|
CursorMode::Embedded
|
||||||
}
|
}
|
||||||
|
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||||
|
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
|
||||||
|
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||||
|
tracing::warn!(
|
||||||
|
?avail,
|
||||||
|
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
|
||||||
|
(only CPU-path frames will composite it)"
|
||||||
|
);
|
||||||
|
CursorMode::Metadata
|
||||||
|
}
|
||||||
Ok(avail) => {
|
Ok(avail) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
?avail,
|
?avail,
|
||||||
@@ -692,7 +719,10 @@ async fn choose_cursor_mode(
|
|||||||
|
|
||||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive.
|
/// PipeWire remote, hand the fd + node id back, then keep the session alive.
|
||||||
fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
fn portal_thread(
|
||||||
|
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) {
|
||||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||||
use ashpd::desktop::PersistMode;
|
use ashpd::desktop::PersistMode;
|
||||||
use ashpd::enumflags2::BitFlags;
|
use ashpd::enumflags2::BitFlags;
|
||||||
@@ -722,7 +752,7 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
|
|||||||
.create_session(Default::default())
|
.create_session(Default::default())
|
||||||
.await
|
.await
|
||||||
.context("create_session")?;
|
.context("create_session")?;
|
||||||
let cursor_mode = choose_cursor_mode(&proxy).await;
|
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
|
||||||
proxy
|
proxy
|
||||||
.select_sources(
|
.select_sources(
|
||||||
&session,
|
&session,
|
||||||
@@ -781,7 +811,10 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
|
|||||||
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||||
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||||
/// identical.
|
/// identical.
|
||||||
fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
fn portal_thread_remote_desktop(
|
||||||
|
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) {
|
||||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||||
use ashpd::desktop::PersistMode;
|
use ashpd::desktop::PersistMode;
|
||||||
@@ -826,7 +859,7 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
|
|||||||
.context("select_devices")?
|
.context("select_devices")?
|
||||||
.response()
|
.response()
|
||||||
.context("select_devices rejected")?;
|
.context("select_devices rejected")?;
|
||||||
let cursor_mode = choose_cursor_mode(&screencast).await;
|
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
|
||||||
screencast
|
screencast
|
||||||
.select_sources(
|
.select_sources(
|
||||||
&session,
|
&session,
|
||||||
|
|||||||
@@ -307,8 +307,11 @@ pub struct EncoderCaps {
|
|||||||
///
|
///
|
||||||
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||||
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||||
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
/// host's call, since only the host can re-plan capture. That call is wired now — the
|
||||||
/// `open_video` can only warn, which it does.
|
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
|
||||||
|
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
|
||||||
|
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
|
||||||
|
/// open-time fallbacks the plan can't see.
|
||||||
pub blends_cursor: bool,
|
pub blends_cursor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,11 @@ fn quality_request() -> u32 {
|
|||||||
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
/// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the
|
||||||
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
/// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one
|
||||||
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
/// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2
|
||||||
/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor
|
/// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend
|
||||||
/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on
|
/// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor
|
||||||
/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default.
|
/// sessions; unset = the default. A cursor-blend session IGNORES `=1` — EFC cannot composite
|
||||||
|
/// the pointer, and the caps-aware negotiation promised the client a composited one, so the
|
||||||
|
/// pointer outranks the lab pin (the open logs the override).
|
||||||
///
|
///
|
||||||
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
|
/// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including
|
||||||
/// an empty value and a value that is only whitespace — falls back to the default rather than
|
/// an empty value and a value that is only whitespace — falls back to the default rather than
|
||||||
@@ -614,10 +616,6 @@ pub struct VulkanVideoEncoder {
|
|||||||
/// GPU reset (those paths keep their aligned-size sources/staging).
|
/// GPU reset (those paths keep their aligned-size sources/staging).
|
||||||
native_nv12: bool,
|
native_nv12: bool,
|
||||||
|
|
||||||
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session
|
|
||||||
/// (neither has a compositing stage — the cursor will be missing from the stream until the
|
|
||||||
/// CSC path is used).
|
|
||||||
warned_cursor: bool,
|
|
||||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||||
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
/// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into
|
||||||
@@ -671,7 +669,16 @@ impl VulkanVideoEncoder {
|
|||||||
cursor_blend: bool,
|
cursor_blend: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let native_nv12 = format == PixelFormat::Nv12;
|
let native_nv12 = format == PixelFormat::Nv12;
|
||||||
let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend);
|
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
|
||||||
|
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
|
||||||
|
// negotiation promised the client a composited pointer).
|
||||||
|
if cursor_blend && rgb_request() == Some(true) {
|
||||||
|
tracing::info!(
|
||||||
|
"PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \
|
||||||
|
pointer, which the EFC front-end cannot; using the compute-CSC path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true);
|
||||||
Self::open_opts_inner(
|
Self::open_opts_inner(
|
||||||
codec,
|
codec,
|
||||||
width,
|
width,
|
||||||
@@ -1448,7 +1455,6 @@ impl VulkanVideoEncoder {
|
|||||||
cpu_expand: Vec::new(),
|
cpu_expand: Vec::new(),
|
||||||
rgb: rgb_cfg,
|
rgb: rgb_cfg,
|
||||||
native_nv12,
|
native_nv12,
|
||||||
warned_cursor: false,
|
|
||||||
pending_bitrate: None,
|
pending_bitrate: None,
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -2621,17 +2627,10 @@ impl VulkanVideoEncoder {
|
|||||||
d.modifier
|
d.modifier
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer
|
// No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the
|
||||||
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost —
|
// session plan negotiates native NV12 only for a non-cursor-blend session
|
||||||
// say so once instead of silently.
|
// (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor
|
||||||
if frame.cursor.is_some() && !self.warned_cursor {
|
// bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels.
|
||||||
self.warned_cursor = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor bitmap on a native-NV12 session — nothing composites it; the cursor \
|
|
||||||
will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \
|
|
||||||
metadata-cursor captures)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let dev = self.device.clone();
|
let dev = self.device.clone();
|
||||||
let cmd = self.frames[slot].cmd;
|
let cmd = self.frames[slot].cmd;
|
||||||
let fence = self.frames[slot].fence;
|
let fence = self.frames[slot].fence;
|
||||||
@@ -2691,16 +2690,9 @@ impl VulkanVideoEncoder {
|
|||||||
let query_pool = self.frames[slot].query_pool;
|
let query_pool = self.frames[slot].query_pool;
|
||||||
let bs_buf = self.frames[slot].bs_buf;
|
let bs_buf = self.frames[slot].bs_buf;
|
||||||
let ts_pool = self.frames[slot].ts_pool;
|
let ts_pool = self.frames[slot].ts_pool;
|
||||||
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so
|
// EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct
|
||||||
// once instead of silently losing the pointer (gamescope, the flagship, embeds it).
|
// shape for a cursor-blend session (the pin override above), so no cursor bitmap ever
|
||||||
if frame.cursor.is_some() && !self.warned_cursor {
|
// reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels.
|
||||||
self.warned_cursor = true;
|
|
||||||
tracing::warn!(
|
|
||||||
"cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \
|
|
||||||
will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \
|
|
||||||
metadata-cursor captures)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
|
let padded = self.rgb.as_ref().is_some_and(|r| r.padded);
|
||||||
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
|
// Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the
|
||||||
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
|
// CPU-upload arm records its own command buffer and writes none. Default to "not written"
|
||||||
|
|||||||
+156
-8
@@ -203,14 +203,15 @@ pub fn open_video(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
// The session asked for a composited pointer; say so loudly if the backend that actually opened
|
||||||
// cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life
|
// cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates
|
||||||
// (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing
|
// the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct
|
||||||
// in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path.
|
// shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the
|
||||||
//
|
// resolved backend can't blend. What remains reachable is the open-time divergence the plan
|
||||||
// A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here
|
// cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the
|
||||||
// would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is
|
// gamescope residual (gamescope has no embedded mode, so a never-blending backend there —
|
||||||
// the only layer that can fall back to capturer-side compositing. This makes the condition
|
// H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those
|
||||||
// visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream.
|
// honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all
|
||||||
|
// it does.
|
||||||
if cursor_blend && !inner.caps().blends_cursor {
|
if cursor_blend && !inner.caps().blends_cursor {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
backend,
|
backend,
|
||||||
@@ -989,6 +990,87 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||||||
|
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||||||
|
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||||||
|
/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture
|
||||||
|
/// (pointer-free frames + host composite on demand — the cursor channel's contract); for
|
||||||
|
/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of
|
||||||
|
/// [`linux_native_nv12_ok`], threaded into the same negotiation.
|
||||||
|
///
|
||||||
|
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
|
||||||
|
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
|
||||||
|
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
|
||||||
|
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
|
||||||
|
// A negotiated PyroWave session routes to that backend before the pref is consulted
|
||||||
|
// (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor.
|
||||||
|
if codec == Codec::PyroWave {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let direct_nvenc = {
|
||||||
|
#[cfg(feature = "nvenc")]
|
||||||
|
{
|
||||||
|
nvenc_direct_enabled()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "nvenc"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let vulkan_csc = {
|
||||||
|
// The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`;
|
||||||
|
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
|
||||||
|
#[cfg(feature = "vulkan-encode")]
|
||||||
|
{
|
||||||
|
matches!(codec, Codec::H265 | Codec::Av1)
|
||||||
|
&& vulkan_encode_enabled()
|
||||||
|
&& vulkan_encode_available(codec)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "vulkan-encode"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let backend = resolve_linux_backend(
|
||||||
|
pf_host_config::config().encoder_pref.as_str(),
|
||||||
|
linux_auto_is_vaapi,
|
||||||
|
cuda_planned,
|
||||||
|
);
|
||||||
|
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
|
||||||
|
/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the
|
||||||
|
/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and
|
||||||
|
/// device-supported for the session's codec.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn cursor_blend_capable_for(
|
||||||
|
backend: Option<LinuxBackend>,
|
||||||
|
cuda_planned: bool,
|
||||||
|
ten_bit: bool,
|
||||||
|
direct_nvenc: bool,
|
||||||
|
vulkan_csc: bool,
|
||||||
|
) -> bool {
|
||||||
|
match backend {
|
||||||
|
// The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`).
|
||||||
|
Some(LinuxBackend::Pyrowave) => true,
|
||||||
|
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
|
||||||
|
// a CPU-payload session stays on libav NVENC, which cannot blend.
|
||||||
|
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
|
||||||
|
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
|
||||||
|
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
|
||||||
|
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
|
||||||
|
// so CSC eligibility IS the answer.
|
||||||
|
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
|
||||||
|
// CPU frames: the capturer composites the metadata cursor inline before the encoder
|
||||||
|
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
|
||||||
|
// contract can't be honored. Report the encoder's truth.
|
||||||
|
Some(LinuxBackend::Software) | None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
/// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per
|
||||||
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
/// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock.
|
||||||
///
|
///
|
||||||
@@ -1754,6 +1836,72 @@ mod tests {
|
|||||||
assert_eq!(none.wire_mask(), None);
|
assert_eq!(none.wire_mask(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation
|
||||||
|
/// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's
|
||||||
|
/// blending stage or the reason there is none.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn cursor_blend_capability_mirrors_the_dispatch() {
|
||||||
|
use LinuxBackend::*;
|
||||||
|
// PyroWave: the wavelet CSC composites, always.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Pyrowave),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Nvenc),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
|
||||||
|
"a CPU payload stays on libav NVENC, which cannot blend"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
|
||||||
|
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
|
||||||
|
);
|
||||||
|
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(AmdIntel),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
|
||||||
|
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
|
||||||
|
device) resolves to libav VAAPI, which cannot blend"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
|
||||||
|
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
|
||||||
|
);
|
||||||
|
assert!(cursor_blend_capable_for(
|
||||||
|
Some(Vulkan),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
// Software / unknown pref: CPU frames; the encoder blends nothing.
|
||||||
|
assert!(!cursor_blend_capable_for(
|
||||||
|
Some(Software),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!cursor_blend_capable_for(None, false, false, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||||
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
/// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the
|
||||||
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
/// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe,
|
||||||
|
|||||||
@@ -66,8 +66,14 @@ fn zero_copy_policy(
|
|||||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
|
/// 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
|
/// 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`]).
|
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
|
||||||
|
/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode
|
||||||
|
/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the
|
||||||
|
/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
pub fn open_portal_monitor(
|
||||||
|
want_hdr: bool,
|
||||||
|
want_metadata_cursor: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
|
||||||
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
|
||||||
// so use a plain ScreenCast session there.
|
// so use a plain ScreenCast session there.
|
||||||
@@ -76,11 +82,19 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
|||||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
// 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
|
// 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.
|
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
pf_capture::open_portal_monitor(
|
||||||
|
anchored,
|
||||||
|
want_hdr,
|
||||||
|
want_metadata_cursor,
|
||||||
|
zero_copy_policy(false, false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn open_portal_monitor(_want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
pub fn open_portal_monitor(
|
||||||
|
_want_hdr: bool,
|
||||||
|
_want_metadata_cursor: bool,
|
||||||
|
) -> Result<Box<dyn Capturer>> {
|
||||||
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ pub struct StreamConfig {
|
|||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on —
|
||||||
|
/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast
|
||||||
|
/// session (see `AppState::video_cap`).
|
||||||
|
pub type PooledCapturer = (Box<dyn Capturer>, bool, bool);
|
||||||
|
|
||||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||||
/// streams so a reconnect doesn't open a second (conflicting) screencast session. The `bool` is
|
/// streams so a reconnect doesn't open a second (conflicting) screencast session.
|
||||||
/// the pooled capturer's HDR-ness (see `AppState::video_cap`).
|
pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
|
||||||
pub type CapturerSlot = Arc<std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>>;
|
|
||||||
|
|
||||||
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
/// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the
|
||||||
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
|
||||||
@@ -136,7 +140,7 @@ fn run(
|
|||||||
running: &Arc<AtomicBool>,
|
running: &Arc<AtomicBool>,
|
||||||
force_idr: &AtomicBool,
|
force_idr: &AtomicBool,
|
||||||
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
rfi_range: &std::sync::Mutex<Option<(i64, i64)>>,
|
||||||
video_cap: &std::sync::Mutex<Option<(Box<dyn Capturer>, bool)>>,
|
video_cap: &std::sync::Mutex<Option<PooledCapturer>>,
|
||||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||||
// encode loop); per-frame sample emission is wired by a later pass.
|
// encode loop); per-frame sample emission is wired by a later pass.
|
||||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||||
@@ -250,6 +254,12 @@ fn run(
|
|||||||
return stream_body(
|
return stream_body(
|
||||||
&mut capturer,
|
&mut capturer,
|
||||||
Some(&rebuild),
|
Some(&rebuild),
|
||||||
|
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
|
||||||
|
// never called → the compositor EMBEDS the pointer where it can), so the encoder
|
||||||
|
// is handed nothing to composite. gamescope remains the pointerless residual —
|
||||||
|
// its capture carries no cursor either way (the native plane's XFixes source is
|
||||||
|
// not wired on this plane).
|
||||||
|
false,
|
||||||
&sock,
|
&sock,
|
||||||
cfg,
|
cfg,
|
||||||
running,
|
running,
|
||||||
@@ -266,13 +276,34 @@ fn run(
|
|||||||
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
|
// pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a
|
||||||
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
|
// PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a
|
||||||
// fresh session (same pattern as the audio capturer's channel-count gate).
|
// fresh session (same pattern as the audio capturer's channel-count gate).
|
||||||
|
// Cursor-as-metadata only where the encode backend this session resolves to composites
|
||||||
|
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
|
||||||
|
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
|
||||||
|
// cursorless. Synthetic frames carry no pointer either way.
|
||||||
|
let metadata_cursor = {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
|
||||||
|
// the NVIDIA resolution plus the zero-copy master switch.
|
||||||
|
let cuda_planned =
|
||||||
|
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||||
|
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
false
|
||||||
|
};
|
||||||
let pooled = match video_cap.lock().unwrap().take() {
|
let pooled = match video_cap.lock().unwrap().take() {
|
||||||
Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c),
|
Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => {
|
||||||
Some((c, was_hdr)) => {
|
Some(c)
|
||||||
|
}
|
||||||
|
Some((c, was_hdr, was_meta)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
was_hdr,
|
was_hdr,
|
||||||
want_hdr = cfg.hdr,
|
want_hdr = cfg.hdr,
|
||||||
"video source: pooled capturer depth mismatch — opening a fresh screencast session"
|
was_metadata_cursor = was_meta,
|
||||||
|
want_metadata_cursor = metadata_cursor,
|
||||||
|
"video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \
|
||||||
|
screencast session"
|
||||||
);
|
);
|
||||||
drop(c);
|
drop(c);
|
||||||
None
|
None
|
||||||
@@ -285,8 +316,13 @@ fn run(
|
|||||||
c
|
c
|
||||||
}
|
}
|
||||||
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
None if pf_host_config::config().video_source.as_deref() == Some("portal") => {
|
||||||
tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture");
|
tracing::info!(
|
||||||
capture::open_portal_monitor(cfg.hdr).context("open portal capturer")?
|
hdr = cfg.hdr,
|
||||||
|
metadata_cursor,
|
||||||
|
"video source: portal desktop capture"
|
||||||
|
);
|
||||||
|
capture::open_portal_monitor(cfg.hdr, metadata_cursor)
|
||||||
|
.context("open portal capturer")?
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("video source: synthetic test pattern");
|
tracing::info!("video source: synthetic test pattern");
|
||||||
@@ -298,6 +334,7 @@ fn run(
|
|||||||
let result = stream_body(
|
let result = stream_body(
|
||||||
&mut capturer,
|
&mut capturer,
|
||||||
None,
|
None,
|
||||||
|
metadata_cursor,
|
||||||
&sock,
|
&sock,
|
||||||
cfg,
|
cfg,
|
||||||
running,
|
running,
|
||||||
@@ -308,7 +345,7 @@ fn run(
|
|||||||
on_lost,
|
on_lost,
|
||||||
);
|
);
|
||||||
capturer.set_active(false);
|
capturer.set_active(false);
|
||||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor));
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +683,10 @@ fn stream_body(
|
|||||||
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
|
// Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch);
|
||||||
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
|
// `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error).
|
||||||
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
|
rebuild: Option<&dyn Fn() -> Result<Box<dyn Capturer>>>,
|
||||||
|
// The capture hands the encoder cursor bitmaps to composite (cursor-as-metadata negotiated
|
||||||
|
// because the resolved backend blends — see the callers). `false` = the pointer is embedded
|
||||||
|
// in the pixels (or absent), so the encoder is asked to composite nothing.
|
||||||
|
cursor_blend: bool,
|
||||||
sock: &UdpSocket,
|
sock: &UdpSocket,
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
running: &Arc<AtomicBool>,
|
running: &Arc<AtomicBool>,
|
||||||
@@ -681,9 +722,9 @@ fn stream_body(
|
|||||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||||
encode::ChromaFormat::Yuv420,
|
encode::ChromaFormat::Yuv420,
|
||||||
// Desktop monitor capture negotiates cursor-as-metadata where available — the encoder
|
// True only when THIS session's capture negotiated cursor-as-metadata — which the
|
||||||
// may be handed cursor bitmaps to composite.
|
// callers grant only where the resolved backend composites (`cursor_blend_capable`).
|
||||||
true,
|
cursor_blend,
|
||||||
)
|
)
|
||||||
.context("open video encoder for stream")?;
|
.context("open video encoder for stream")?;
|
||||||
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
|
||||||
@@ -855,7 +896,7 @@ fn stream_body(
|
|||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
gs_bit_depth(frame.format),
|
gs_bit_depth(frame.format),
|
||||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||||
true, // metadata-cursor capture — see the first open
|
cursor_blend, // same capture cursor mode — see the first open
|
||||||
)
|
)
|
||||||
.context("reopen encoder after rebuild")?;
|
.context("reopen encoder after rebuild")?;
|
||||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||||
|
|||||||
@@ -1012,9 +1012,10 @@ async fn serve_session(
|
|||||||
// just never fires then.
|
// just never fires then.
|
||||||
let (cursor_shape_tx, cursor_shape_rx) =
|
let (cursor_shape_tx, cursor_shape_rx) =
|
||||||
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
tokio::sync::mpsc::unbounded_channel::<punktfunk_core::quic::CursorShape>();
|
||||||
// Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised
|
// Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back
|
||||||
// (handshake::cursor_forward is the single predicate both read).
|
// rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder
|
||||||
let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor);
|
// blend-capability gate — re-running it here could drift, and would re-probe).
|
||||||
|
let cursor_forward = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||||
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
|
// Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse-
|
||||||
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
|
// model chord): `true` = client draws (exclude + forward), `false` = host composites (the
|
||||||
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
|
// capture model). Starts true — the pre-message behavior for cap sessions. Control task
|
||||||
|
|||||||
@@ -12,31 +12,46 @@ use super::*;
|
|||||||
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
|
||||||
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
|
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
|
||||||
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
|
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
|
||||||
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single
|
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
|
||||||
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off
|
/// Linux, the encode backend this session resolves to can composite the pointer on demand
|
||||||
/// wiring both read it, so they can never disagree.
|
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
|
||||||
|
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
|
||||||
|
/// compositing stage — granting the channel over a backend that can't blend (libav
|
||||||
|
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
|
||||||
|
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
|
||||||
|
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
|
||||||
|
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
|
||||||
pub(super) fn cursor_forward(
|
pub(super) fn cursor_forward(
|
||||||
client_caps: u8,
|
client_caps: u8,
|
||||||
compositor: Option<crate::vdisplay::Compositor>,
|
compositor: Option<crate::vdisplay::Compositor>,
|
||||||
|
codec: crate::encode::Codec,
|
||||||
|
bit_depth: u8,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
|
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
|
// CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution
|
||||||
|
// plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav
|
||||||
|
// NVENC (doesn't) inside the capability mirror.
|
||||||
|
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||||
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
|
||||||
|
&& crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
|
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
|
||||||
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
|
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
|
||||||
// copy would double it. The probe latches by opening the control device once.
|
// copy would double it. The probe latches by opening the control device once. The
|
||||||
let _ = compositor;
|
// encoder is deliberately NOT consulted: the IDD capturer itself composites on the
|
||||||
|
// capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends.
|
||||||
|
let _ = (compositor, codec, bit_depth);
|
||||||
crate::vdisplay::manager::hw_cursor_capable()
|
crate::vdisplay::manager::hw_cursor_capable()
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = compositor;
|
let _ = (compositor, codec, bit_depth);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,9 +503,10 @@ pub(super) async fn negotiate(
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
// Cursor channel granted (client asked + this capture path can deliver cursor
|
// Cursor channel granted (client asked + this capture path can deliver cursor
|
||||||
// metadata out of the frame) — the client turns its local renderer on ONLY when
|
// metadata out of the frame + the resolved encoder can composite on the
|
||||||
// it sees this bit, and serve_session wires forwarding from the same predicate.
|
// capture-mouse flip) — the client turns its local renderer on ONLY when it sees
|
||||||
| if cursor_forward(hello.client_caps, compositor) {
|
// this bit, and serve_session wires forwarding by reading the bit back.
|
||||||
|
| if cursor_forward(hello.client_caps, compositor, codec, bit_depth) {
|
||||||
punktfunk_core::quic::HOST_CAP_CURSOR
|
punktfunk_core::quic::HOST_CAP_CURSOR
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
@@ -536,9 +552,9 @@ pub(super) async fn negotiate(
|
|||||||
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
|
||||||
let client_identity = endpoint::peer_fingerprint(conn);
|
let client_identity = endpoint::peer_fingerprint(conn);
|
||||||
let client_hdr = hello.display_hdr;
|
let client_hdr = hello.display_hdr;
|
||||||
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and
|
// The bit the Welcome just advertised — read back rather than recomputed, so the
|
||||||
// the session wiring must agree with what we just advertised.
|
// prepared display and the session wiring cannot disagree with it.
|
||||||
let cursor_fw = cursor_forward(hello.client_caps, Some(comp));
|
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
|
||||||
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
|
||||||
let trace = bringup.clone();
|
let trace = bringup.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
|
|||||||
@@ -1022,8 +1022,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||||
// the blend must be built for every gamescope session.
|
// the blend must be built for every gamescope session. (`cursor_forward` is already
|
||||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
|
// blend-gated: `handshake::cursor_forward` grants the channel only where
|
||||||
|
// `encode::cursor_blend_capable` says the resolved backend composites.)
|
||||||
|
crate::session_plan::cursor_blend_for(
|
||||||
|
ctx.cursor_forward,
|
||||||
|
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
),
|
||||||
ctx.cursor_forward,
|
ctx.cursor_forward,
|
||||||
);
|
);
|
||||||
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
||||||
@@ -2098,8 +2103,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// capture-mode channel); a switch AWAY restores the prior
|
// capture-mode channel); a switch AWAY restores the prior
|
||||||
// gating. `plan` is `Copy` — this is the value the rebuild
|
// gating. `plan` is `Copy` — this is the value the rebuild
|
||||||
// (and its `build_pipeline` attach) reads.
|
// (and its `build_pipeline` attach) reads.
|
||||||
plan.cursor_blend = plan.cursor_forward
|
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
||||||
|| c == crate::vdisplay::Compositor::Gamescope;
|
plan.cursor_forward,
|
||||||
|
c == crate::vdisplay::Compositor::Gamescope,
|
||||||
|
);
|
||||||
plan.gamescope_cursor =
|
plan.gamescope_cursor =
|
||||||
c == crate::vdisplay::Compositor::Gamescope;
|
c == crate::vdisplay::Compositor::Gamescope;
|
||||||
gamescope_composite =
|
gamescope_composite =
|
||||||
@@ -2987,7 +2994,10 @@ pub(super) fn prepare_display(
|
|||||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||||
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
|
crate::session_plan::cursor_blend_for(
|
||||||
|
cursor_forward,
|
||||||
|
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
),
|
||||||
cursor_forward,
|
cursor_forward,
|
||||||
);
|
);
|
||||||
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
||||||
|
|||||||
@@ -104,9 +104,13 @@ pub struct SessionPlan {
|
|||||||
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
|
||||||
pub wire_chunk: Option<usize>,
|
pub wire_chunk: Option<usize>,
|
||||||
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||||
/// captures — every non-gamescope compositor; gamescope embeds the pointer itself).
|
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
||||||
/// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their
|
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
|
||||||
/// blending path when this is set, so the pointer never silently vanishes from the stream.
|
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
|
||||||
|
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
|
||||||
|
/// those shapes when this is set — see [`Self::output_format`] and
|
||||||
|
/// `encode::cursor_blend_capable`, the pre-open mirror that gates the cursor channel — so
|
||||||
|
/// the pointer never silently vanishes from the stream.
|
||||||
pub cursor_blend: bool,
|
pub cursor_blend: bool,
|
||||||
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
|
/// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer
|
||||||
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
||||||
@@ -198,13 +202,15 @@ impl SessionPlan {
|
|||||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||||
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
||||||
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
|
// has no CSC stage to fold the cursor into — so ANY cursor-compositing session
|
||||||
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
|
// (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a
|
||||||
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
|
// cursor-forward session, whose capture-mouse flip needs the host composite on
|
||||||
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
|
// demand) must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend
|
||||||
// blend is the perf-preserving follow-up.
|
// that draws `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the
|
||||||
|
// native-NV12 cursor blend is the perf-preserving follow-up. (`cursor_blend`
|
||||||
|
// subsumes `gamescope_cursor` — see [`cursor_blend_for`].)
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
|
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.cursor_blend,
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
nv12_native: false,
|
nv12_native: false,
|
||||||
}
|
}
|
||||||
@@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology {
|
|||||||
SessionTopology::SingleProcess
|
SessionTopology::SingleProcess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
|
||||||
|
/// the mid-stream compositor re-gate) so they can't drift:
|
||||||
|
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
|
||||||
|
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
|
||||||
|
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
|
||||||
|
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
|
||||||
|
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
|
||||||
|
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
|
||||||
|
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let _ = (cursor_forward, gamescope);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
cursor_forward || gamescope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn resolve_encoder() -> EncoderBackend {
|
fn resolve_encoder() -> EncoderBackend {
|
||||||
match crate::encode::windows_resolved_backend() {
|
match crate::encode::windows_resolved_backend() {
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
want_hdr,
|
want_hdr,
|
||||||
"spike source: xdg ScreenCast portal (live monitor)"
|
"spike source: xdg ScreenCast portal (live monitor)"
|
||||||
);
|
);
|
||||||
capture::open_portal_monitor(want_hdr).context("open portal capturer")?
|
// Embedded cursor: the spike passes `cursor_blend = false` to its encoder open, so
|
||||||
|
// a metadata pointer would be composited by nothing.
|
||||||
|
capture::open_portal_monitor(want_hdr, false).context("open portal capturer")?
|
||||||
}
|
}
|
||||||
Source::KwinVirtual => {
|
Source::KwinVirtual => {
|
||||||
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);
|
||||||
|
|||||||
Reference in New Issue
Block a user