fix(host/linux): a no-channel session composites the metadata cursor — Mutter never embeds on a virtual stream
ci / web (push) Successful in 3m26s
ci / docs-site (push) Successful in 3m32s
arch / build-publish (push) Failing after 4m29s
apple / swift (push) Successful in 4m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / rust-arm64 (push) Failing after 4m50s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Failing after 5m6s
ci / rust (push) Failing after 5m47s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 34s
android / android (push) Canceled after 8m16s
apple / screenshots (push) Canceled after 3m46s
deb / build-publish-host (push) Canceled after 4m15s
deb / build-publish-client-arm64 (push) Canceled after 4m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m58s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 8m21s
ci / web (push) Successful in 3m26s
ci / docs-site (push) Successful in 3m32s
arch / build-publish (push) Failing after 4m29s
apple / swift (push) Successful in 4m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / rust-arm64 (push) Failing after 4m50s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Failing after 5m6s
ci / rust (push) Failing after 5m47s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 34s
android / android (push) Canceled after 8m16s
apple / screenshots (push) Canceled after 3m46s
deb / build-publish-host (push) Canceled after 4m15s
deb / build-publish-client-arm64 (push) Canceled after 4m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m58s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 8m21s
The capture-latched client (console.rs latched_mouse) never advertises CLIENT_CAP_CURSOR, so its session resolved cursor_blend=false and asked Mutter to EMBED the pointer. On a Mutter virtual stream that is a fiction: since Mutter 48 (7ff5334a, hw-cursor inhibition removed) the software cursor overlay is suppressed stage-globally whenever any physical head realizes a HW cursor — dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no re-record either (mutter#4939). Probed on-glass on Mutter 50.3: embedded + relative motion = frozen frame counter; SPA_META_Cursor positions kept flowing in the same setup. So the no-channel session now takes the path that was verified end to end: cursor-as-metadata + the host composites, permanently — the same arm a channel session lands in after its capture-model flip. Embedded remains only the can't-blend fallback (libav VAAPI/NVENC, software). - session_plan::cursor_blend_for grows the no-channel arm (codec + depth in, the same CUDA-payload prediction handshake makes); gamescope excluded so patch-2+ keeps its native-NV12 zero-copy shape - the encode loop's composite refresh + one-shot breadcrumbs now cover the no-channel session; the park schedule keeps retrying while its composite is starved (relative-only clients cannot park themselves) - the compositor retarget re-applies set_hw_cursor — the rebuilt display used to come up EMBEDDED even for channel sessions - the GameStream virtual source takes the same rule (it never has a channel); its stream_body blend flag mirrors the request - punktfunk-probe grows --cursor-nochannel (the latched-capture client, headless); cursor-probe grows --dump (PPM frames + a content-change counter, the pixel evidence the embedded A/B lacked) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+34
-21
@@ -122,6 +122,13 @@ struct Args {
|
|||||||
/// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the
|
/// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the
|
||||||
/// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch.
|
/// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch.
|
||||||
cursor_capture: bool,
|
cursor_capture: bool,
|
||||||
|
/// `--cursor-nochannel` — the same relative wiggle WITHOUT the cursor channel: no
|
||||||
|
/// `CLIENT_CAP_CURSOR`, no render-mode flip. The headless reproduction of a client LATCHED
|
||||||
|
/// in capture mode at connect (`console.rs` `latched_mouse` — it never advertises the
|
||||||
|
/// channel), the shape of the 2026-07 "no cursor in Mutter capture mode" field report. The
|
||||||
|
/// host must composite the metadata cursor on its own; decode the dump and look for the
|
||||||
|
/// pointer.
|
||||||
|
cursor_nochannel: bool,
|
||||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||||
discover: Option<u64>,
|
discover: Option<u64>,
|
||||||
@@ -301,6 +308,7 @@ fn parse_args() -> Args {
|
|||||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||||
|
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,32 +853,37 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
"SPEED TEST complete",
|
"SPEED TEST complete",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else if args.cursor_capture {
|
} else if args.cursor_capture || args.cursor_nochannel {
|
||||||
// Capture-model cursor repro: flip the negotiated cursor channel to "host composites"
|
// Capture-model cursor repro. `--cursor-capture`: flip the negotiated cursor channel to
|
||||||
// and drive RELATIVE pointer motion, exactly like a pointer-lock client. Owns BOTH
|
// "host composites" and drive RELATIVE pointer motion, exactly like a pointer-lock
|
||||||
// control halves: the host may send CursorShape (0x50) messages while the channel is
|
// client. `--cursor-nochannel`: the same motion with NO channel at all (a
|
||||||
|
// capture-latched client) — the host must composite unprompted. Owns BOTH control
|
||||||
|
// halves: the host may send CursorShape (0x50) messages while a negotiated channel is
|
||||||
// still in its initial client-draws state, and dropping our recv half would fail those
|
// still in its initial client-draws state, and dropping our recv half would fail those
|
||||||
// writes host-side.
|
// writes host-side.
|
||||||
|
let flip_channel = args.cursor_capture;
|
||||||
let mut cs = send;
|
let mut cs = send;
|
||||||
let mut cr = recv;
|
let mut cr = recv;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
if flip_channel {
|
||||||
match io::write_msg(
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
&mut cs,
|
match io::write_msg(
|
||||||
&CursorRenderMode {
|
&mut cs,
|
||||||
client_draws: false,
|
&CursorRenderMode {
|
||||||
}
|
client_draws: false,
|
||||||
.encode(),
|
}
|
||||||
)
|
.encode(),
|
||||||
.await
|
)
|
||||||
{
|
.await
|
||||||
Ok(()) => tracing::info!(
|
{
|
||||||
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the host \
|
Ok(()) => tracing::info!(
|
||||||
must now composite the pointer into the video"
|
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the \
|
||||||
),
|
host must now composite the pointer into the video"
|
||||||
Err(e) => {
|
),
|
||||||
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
|
Err(e) => {
|
||||||
return;
|
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drain (and just log) whatever the host still sends on the control stream.
|
// Drain (and just log) whatever the host still sends on the control stream.
|
||||||
|
|||||||
@@ -50,13 +50,19 @@ const APPLY_TEMPORARY: u32 = 1;
|
|||||||
/// the frames. The capturer always negotiates the meta (pf-capture `meta_param`) and the encoder
|
/// the frames. The capturer always negotiates the meta (pf-capture `meta_param`) and the encoder
|
||||||
/// blend composites it for sessions where the client does not draw the cursor itself — while a
|
/// blend composites it for sessions where the client does not draw the cursor itself — while a
|
||||||
/// cursor-forwarding session strips the overlay and sends shape/state over the cursor channel.
|
/// cursor-forwarding session strips the overlay and sends shape/state over the cursor channel.
|
||||||
/// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing
|
/// This is the mode for EVERY session whose host plans a composite or forward (`set_hw_cursor`
|
||||||
/// to blend, and Mutter's own embedded painting is what the pre-channel path relied on.
|
/// on), channel or not: Mutter's embedded painting is a fiction on a virtual stream — since
|
||||||
|
/// Mutter 48 (commit `7ff5334a`, hw-cursor inhibition removed) the software cursor overlay is
|
||||||
|
/// suppressed STAGE-GLOBALLY whenever any physical head realizes a hardware cursor, so
|
||||||
|
/// dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no
|
||||||
|
/// re-record either (mutter#4939). Probed on-glass (Mutter 50.3): embedded + relative motion =
|
||||||
|
/// frozen frame counter; metadata positions kept flowing in the same setup.
|
||||||
const CURSOR_METADATA: u32 = 2;
|
const CURSOR_METADATA: u32 = 2;
|
||||||
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself —
|
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself.
|
||||||
/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the
|
/// Kept only as the can't-blend fallback (`set_hw_cursor` off — the resolved encode backend
|
||||||
/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate
|
/// cannot composite a metadata cursor, so metadata would strand the pointer in meta nothing
|
||||||
/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend).
|
/// draws). Know its limits (above): on a virtual stream it paints only into the MemFd/SHM
|
||||||
|
/// record path (`FORCE_CURSORS`) and only refreshes on unrelated damage.
|
||||||
const CURSOR_EMBEDDED: u32 = 1;
|
const CURSOR_EMBEDDED: u32 = 1;
|
||||||
|
|
||||||
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||||
|
|||||||
@@ -389,12 +389,14 @@ 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
|
// Mirrors the source's own `set_hw_cursor` request (`open_gs_virtual_source`):
|
||||||
// never called → the compositor EMBEDS the pointer where it can), so the encoder
|
// cursor-as-metadata + host blend wherever the backend composites — the
|
||||||
// is handed nothing to composite. gamescope remains the pointerless residual —
|
// compositor-EMBEDS fallback never paints on a Mutter virtual stream (the
|
||||||
// its capture carries no cursor either way (the native plane's XFixes source is
|
// native plane's no-channel rule, `session_plan::cursor_blend_for`). gamescope
|
||||||
// not wired on this plane).
|
// remains the pointerless residual — its capture carries no cursor either way
|
||||||
false,
|
// (the native plane's XFixes source is not wired on this plane).
|
||||||
|
compositor != crate::vdisplay::Compositor::Gamescope
|
||||||
|
&& blend_capable_metadata_cursor(&cfg),
|
||||||
&sock,
|
&sock,
|
||||||
cfg,
|
cfg,
|
||||||
running,
|
running,
|
||||||
@@ -415,18 +417,7 @@ fn run(
|
|||||||
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
|
// `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
|
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
|
||||||
// cursorless. Synthetic frames carry no pointer either way.
|
// cursorless. Synthetic frames carry no pointer either way.
|
||||||
let metadata_cursor = {
|
let metadata_cursor = blend_capable_metadata_cursor(&cfg);
|
||||||
#[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
|
|
||||||
};
|
|
||||||
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
|
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
|
||||||
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
|
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
|
||||||
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
|
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
|
||||||
@@ -643,6 +634,24 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
|||||||
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
|
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
|
||||||
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
|
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
|
||||||
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
|
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
|
||||||
|
/// Cursor-as-metadata for this plane (GameStream has no cursor channel): only where the encode
|
||||||
|
/// backend this session resolves to composites `frame.cursor` — the same CUDA-payload
|
||||||
|
/// prediction `SessionPlan`/`handshake::cursor_forward` make (the NVIDIA resolution plus the
|
||||||
|
/// zero-copy master switch). Shared by the monitor-mirror and virtual-output sources so their
|
||||||
|
/// `set_hw_cursor` request and `stream_body`'s blend flag cannot drift.
|
||||||
|
fn blend_capable_metadata_cursor(cfg: &StreamConfig) -> bool {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
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"))]
|
||||||
|
{
|
||||||
|
let _ = cfg;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_gs_virtual_source(
|
fn open_gs_virtual_source(
|
||||||
cfg: StreamConfig,
|
cfg: StreamConfig,
|
||||||
app: Option<&super::apps::AppEntry>,
|
app: Option<&super::apps::AppEntry>,
|
||||||
@@ -701,6 +710,16 @@ fn open_gs_virtual_source(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||||
|
// Out-of-band cursor for the virtual source (the native plane's no-channel rule, mirrored):
|
||||||
|
// GameStream has no cursor channel, and the compositor-EMBEDS fallback never paints on a
|
||||||
|
// Mutter virtual stream (stage-global overlay suppression since Mutter 48 — see
|
||||||
|
// pf-vdisplay `mutter.rs`), so ask for cursor-as-metadata wherever the resolved backend
|
||||||
|
// composites `frame.cursor`; `stream_body`'s blend flag mirrors this request. gamescope
|
||||||
|
// stays off: its capture carries no metadata either way, and the request would cost the
|
||||||
|
// native-NV12 shape for nothing.
|
||||||
|
vd.set_hw_cursor(
|
||||||
|
compositor != crate::vdisplay::Compositor::Gamescope && blend_capable_metadata_cursor(&cfg),
|
||||||
|
);
|
||||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||||
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
|
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
|
||||||
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
|
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ use super::*;
|
|||||||
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
|
/// (`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
|
/// `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
|
/// 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
|
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied — or
|
||||||
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
|
/// never asked for (a capture-latched client, `console.rs` `latched_mouse`) — the session
|
||||||
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
|
/// composites host-side anyway wherever the backend can blend
|
||||||
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
|
/// (`session_plan::cursor_blend_for`'s no-channel arm; the compositor-EMBEDS fallback never
|
||||||
|
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
|
||||||
|
/// compositor EMBED. 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>,
|
||||||
|
|||||||
@@ -1076,20 +1076,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
ctx.bit_depth,
|
ctx.bit_depth,
|
||||||
ctx.chroma,
|
ctx.chroma,
|
||||||
ctx.codec,
|
ctx.codec,
|
||||||
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
|
// Blend CAPABILITY (the single rule in `cursor_blend_for`): cursor-FORWARD sessions
|
||||||
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
|
// need it for the mid-stream capture-mouse flip (`CursorRenderMode` — WHETHER a
|
||||||
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
|
// frame's pointer is drawn stays per-tick, the encode loop strips `frame.cursor`
|
||||||
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
|
// while the client draws locally); gamescope (Phase C) can't embed a pointer, so the
|
||||||
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
|
// host always composites the XFixes-sourced cursor; and a NO-channel session gets
|
||||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
// metadata + host blend too wherever the backend composites — the compositor-EMBEDS
|
||||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
// fallback streams cursorless on a Mutter virtual output (the overlay-visibility
|
||||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
// gate is stage-global since Mutter 48; see `cursor_blend_for`'s doc). Embedded
|
||||||
// the blend must be built for every gamescope session. (`cursor_forward` is already
|
// remains only the can't-blend fallback.
|
||||||
// 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(
|
crate::session_plan::cursor_blend_for(
|
||||||
ctx.cursor_forward,
|
ctx.cursor_forward,
|
||||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
ctx.codec,
|
||||||
|
ctx.bit_depth,
|
||||||
),
|
),
|
||||||
ctx.cursor_forward,
|
ctx.cursor_forward,
|
||||||
);
|
);
|
||||||
@@ -1187,6 +1187,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
if gamescope_composite {
|
if gamescope_composite {
|
||||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||||
}
|
}
|
||||||
|
// No-channel metadata composite: the client never draws the pointer (it did not advertise
|
||||||
|
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
|
||||||
|
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
|
||||||
|
// cursor overlay is suppressed stage-globally whenever any physical head realizes a HW
|
||||||
|
// cursor, Mutter 48+ — dmabuf frames blit the view WITHOUT it, and cursor-only motion
|
||||||
|
// schedules no update either, mutter#4939). So the plan asked the backend for
|
||||||
|
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
|
||||||
|
// session lands in after its capture-model flip, minus the channel.
|
||||||
|
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
|
||||||
|
let mut metadata_composite = cursor_fwd.is_none()
|
||||||
|
&& plan.cursor_blend
|
||||||
|
&& compositor != pf_vdisplay::Compositor::Gamescope;
|
||||||
|
if metadata_composite {
|
||||||
|
tracing::info!(
|
||||||
|
"no cursor channel — compositing the metadata cursor into the video (embedded \
|
||||||
|
fallback is unreliable on virtual streams)"
|
||||||
|
);
|
||||||
|
}
|
||||||
if streamed_wire {
|
if streamed_wire {
|
||||||
// Client capability only — whether AUs actually stream per-slice depends on the encoder
|
// Client capability only — whether AUs actually stream per-slice depends on the encoder
|
||||||
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
|
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
|
||||||
@@ -1231,9 +1249,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
|
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
|
||||||
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
|
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
|
||||||
vd.set_hdr(bit_depth >= 10);
|
vd.set_hdr(bit_depth >= 10);
|
||||||
// Cursor-forward sessions ask the backend for an out-of-band hardware cursor
|
// Out-of-band cursor request: cursor-forward sessions (Windows pf-vdisplay /
|
||||||
// (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it).
|
// IddCx hardware cursor; Linux metadata mode) AND no-channel host-composite
|
||||||
vd.set_hw_cursor(cursor_forward);
|
// sessions (Linux only — `metadata_composite` is `plan.cursor_blend`-gated, so
|
||||||
|
// it is always false on Windows). The backend keeps the pointer out of the
|
||||||
|
// pixels; the host blend (or the client) puts it back.
|
||||||
|
vd.set_hw_cursor(cursor_forward || metadata_composite);
|
||||||
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
|
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
|
||||||
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
|
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
|
||||||
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
|
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
|
||||||
@@ -2378,6 +2399,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
||||||
plan.cursor_forward,
|
plan.cursor_forward,
|
||||||
c == crate::vdisplay::Compositor::Gamescope,
|
c == crate::vdisplay::Compositor::Gamescope,
|
||||||
|
plan.codec,
|
||||||
|
plan.bit_depth,
|
||||||
);
|
);
|
||||||
plan.gamescope_cursor =
|
plan.gamescope_cursor =
|
||||||
crate::session_plan::gamescope_cursor_for(
|
crate::session_plan::gamescope_cursor_for(
|
||||||
@@ -2385,6 +2408,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
);
|
);
|
||||||
gamescope_composite =
|
gamescope_composite =
|
||||||
plan.gamescope_cursor && cursor_fwd.is_none();
|
plan.gamescope_cursor && cursor_fwd.is_none();
|
||||||
|
metadata_composite = cursor_fwd.is_none()
|
||||||
|
&& plan.cursor_blend
|
||||||
|
&& c != crate::vdisplay::Compositor::Gamescope;
|
||||||
|
// The retargeted backend starts with `hw_cursor`
|
||||||
|
// unset — without re-applying the session's
|
||||||
|
// out-of-band cursor request, the rebuilt display
|
||||||
|
// would come up EMBEDDED: double-drawn for a
|
||||||
|
// desktop-model channel client, cursorless for
|
||||||
|
// every host-composite session.
|
||||||
|
vd.set_hw_cursor(plan.cursor_forward || metadata_composite);
|
||||||
}
|
}
|
||||||
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
|
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
|
||||||
"capture loss: opening the newly-detected compositor failed — retrying"),
|
"capture loss: opening the newly-detected compositor failed — retrying"),
|
||||||
@@ -2560,15 +2593,41 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if gamescope_composite {
|
} else if gamescope_composite || metadata_composite {
|
||||||
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
|
// No channel, host always composites: gamescope (Phase C — the XFixes source
|
||||||
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
|
// publishes on `capturer.cursor()`) and the metadata-composite session (the portal
|
||||||
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
|
// `SPA_META_Cursor` live overlay publishes there too). Refresh the (repeat or new)
|
||||||
// freezing at the last damage frame (the same reason the composite arm above re-reads
|
// frame's overlay from the capturer's LIVE cursor so pointer-only motion on a
|
||||||
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
|
// static desktop re-blends at tick rate instead of freezing at the last damage
|
||||||
|
// frame (the same reason the channel's composite arm above re-reads it). A
|
||||||
|
// grabbed/hidden pointer arrives `visible: false` and is stripped just below.
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
if let Some(live) = capturer.cursor() {
|
match capturer.cursor() {
|
||||||
frame.cursor = Some(live);
|
Some(live) => {
|
||||||
|
if !composite_saw_overlay {
|
||||||
|
composite_saw_overlay = true;
|
||||||
|
tracing::info!(
|
||||||
|
x = live.x,
|
||||||
|
y = live.y,
|
||||||
|
w = live.w,
|
||||||
|
h = live.h,
|
||||||
|
visible = live.visible,
|
||||||
|
"host-composite: first live cursor overlay handed to the encoder \
|
||||||
|
blend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame.cursor = Some(live);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if !composite_saw_none {
|
||||||
|
composite_saw_none = true;
|
||||||
|
tracing::info!(
|
||||||
|
"host-composite active but the capture has no live cursor overlay \
|
||||||
|
yet (no SPA_META_Cursor bitmap) — the stream is cursorless until \
|
||||||
|
one arrives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The overlay surfaces hidden pointers too (for the hint above) — strip them
|
// The overlay surfaces hidden pointers too (for the hint above) — strip them
|
||||||
@@ -2579,10 +2638,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
|
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
|
||||||
// the first frame of every (re)built display and by the capture-model flip). The first
|
// the first frame of every (re)built display and by the capture-model flip). The first
|
||||||
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
|
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
|
||||||
// connection. Past those, only a cursor-channel session in the capture model that STILL
|
// connection. Past those, only a host-composite session that STILL has no live overlay
|
||||||
// has no live overlay keeps trying: that combination means the pointer has not reached
|
// keeps trying — a channel session in the capture model, or a no-channel
|
||||||
// the streamed output (the compositor reports cursor metadata only while it is over the
|
// metadata-composite session (both relative-only): no overlay there means the pointer
|
||||||
// recorded view), and a relative-only client cannot get it there on its own.
|
// has not reached the streamed output (the compositor reports cursor metadata only
|
||||||
|
// while it is over the recorded view), and a relative-only client cannot get it there
|
||||||
|
// on its own.
|
||||||
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
|
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
|
||||||
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
|
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
|
||||||
// parked regardless.
|
// parked regardless.
|
||||||
@@ -2591,8 +2652,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
&& park_attempts < PARK_ATTEMPTS_MAX
|
&& park_attempts < PARK_ATTEMPTS_MAX
|
||||||
&& std::time::Instant::now() >= next_park_at
|
&& std::time::Instant::now() >= next_park_at
|
||||||
{
|
{
|
||||||
let composite_starved = cursor_fwd.is_some()
|
let composite_starved = ((cursor_fwd.is_some()
|
||||||
&& !cursor_client_draws.load(Ordering::Relaxed)
|
&& !cursor_client_draws.load(Ordering::Relaxed))
|
||||||
|
|| metadata_composite)
|
||||||
&& capturer.cursor().is_none();
|
&& capturer.cursor().is_none();
|
||||||
if park_attempts < 2 || composite_starved {
|
if park_attempts < 2 || composite_starved {
|
||||||
park_pointer(&input_tx, frame.width, frame.height);
|
park_pointer(&input_tx, frame.width, frame.height);
|
||||||
@@ -3406,13 +3468,14 @@ pub(super) fn prepare_display(
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
chroma,
|
chroma,
|
||||||
codec,
|
codec,
|
||||||
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
|
// Blend capability — must MATCH virtual_stream's resolve. Windows-only path, where
|
||||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
// the rule is a constant `false` (the IDD capturer composites itself); passed through
|
||||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
// the shared rule anyway so the two resolves cannot drift.
|
||||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
|
||||||
crate::session_plan::cursor_blend_for(
|
crate::session_plan::cursor_blend_for(
|
||||||
cursor_forward,
|
cursor_forward,
|
||||||
compositor == pf_vdisplay::Compositor::Gamescope,
|
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||||
|
codec,
|
||||||
|
bit_depth,
|
||||||
),
|
),
|
||||||
cursor_forward,
|
cursor_forward,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ pub struct SessionPlan {
|
|||||||
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). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
||||||
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
|
/// where the ENCODER is the compositing stage (Linux: cursor-forward sessions, gamescope,
|
||||||
|
/// AND no-channel sessions on a blend-capable backend — the compositor-EMBEDS fallback is
|
||||||
|
/// broken on Mutter virtual streams, see [`cursor_blend_for`]);
|
||||||
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
|
/// 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
|
/// 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
|
/// those shapes when this is set — see [`Self::output_format`] and
|
||||||
@@ -234,20 +236,46 @@ pub(crate) fn resolve_topology() -> SessionTopology {
|
|||||||
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
|
/// 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:
|
/// 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
|
/// * **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
|
/// capture-mouse flip needs the host composite on demand), for gamescope (its capture
|
||||||
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
|
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video), AND
|
||||||
|
/// for a no-channel session whenever the resolved backend can composite. The pre-channel
|
||||||
|
/// "compositor EMBEDS the pointer" fallback is a fiction on a Mutter virtual stream:
|
||||||
|
/// cursor-only motion never re-records the stream (probed on-glass, Mutter 50.3 — frames
|
||||||
|
/// froze the instant motion went relative while `SPA_META_Cursor` kept updating), so a
|
||||||
|
/// capture-latched client (which never advertises `CLIENT_CAP_CURSOR`, `console.rs`
|
||||||
|
/// `latched_mouse`) streamed cursorless. Metadata + host blend is the path that was
|
||||||
|
/// verified end-to-end; embedded remains only the can't-blend fallback (libav
|
||||||
|
/// VAAPI/NVENC, software).
|
||||||
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
|
/// * **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
|
/// 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.
|
/// `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 {
|
pub(crate) fn cursor_blend_for(
|
||||||
|
cursor_forward: bool,
|
||||||
|
gamescope: bool,
|
||||||
|
codec: crate::encode::Codec,
|
||||||
|
bit_depth: u8,
|
||||||
|
) -> bool {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = (cursor_forward, gamescope);
|
let _ = (cursor_forward, gamescope, codec, bit_depth);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
{
|
{
|
||||||
cursor_forward || gamescope_needs_host_cursor(gamescope)
|
if gamescope {
|
||||||
|
// gamescope's capture carries no SPA_META_Cursor; the blend-capable term below
|
||||||
|
// must not apply, or a patch-2+ gamescope (composites its own pointer) would lose
|
||||||
|
// its native-NV12 zero-copy shape for a blend that can never receive an overlay.
|
||||||
|
return gamescope_needs_host_cursor(true);
|
||||||
|
}
|
||||||
|
if cursor_forward {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// No cursor channel: the same CUDA-payload prediction `handshake::cursor_forward` and
|
||||||
|
// the GameStream monitor mirror make — the NVIDIA resolution plus the zero-copy master
|
||||||
|
// switch — deciding direct-SDK NVENC (blends) vs libav NVENC (doesn't).
|
||||||
|
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||||
|
crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ repository.workspace = true
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
pf-vdisplay = { path = "../../crates/pf-vdisplay" }
|
pf-vdisplay = { path = "../../crates/pf-vdisplay" }
|
||||||
pf-capture = { path = "../../crates/pf-capture" }
|
pf-capture = { path = "../../crates/pf-capture" }
|
||||||
|
pf-frame = { path = "../../crates/pf-frame" }
|
||||||
pf-inject = { path = "../../crates/pf-inject" }
|
pf-inject = { path = "../../crates/pf-inject" }
|
||||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ mod linux {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let dump = args.iter().any(|a| a == "--dump");
|
||||||
|
let dump_dir = format!(
|
||||||
|
"/tmp/pf-probe-frames-{}",
|
||||||
|
if embedded { "embedded" } else { "metadata" }
|
||||||
|
);
|
||||||
|
if dump {
|
||||||
|
let _ = std::fs::remove_dir_all(&dump_dir);
|
||||||
|
let _ = std::fs::create_dir_all(&dump_dir);
|
||||||
|
}
|
||||||
|
let mut prev: Vec<u8> = Vec::new();
|
||||||
|
let mut changed = 0u64;
|
||||||
|
let mut dumped = 0u32;
|
||||||
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
let (mut frames, mut with_overlay) = (0u64, 0u64);
|
let (mut frames, mut with_overlay) = (0u64, 0u64);
|
||||||
let mut last_report = Instant::now();
|
let mut last_report = Instant::now();
|
||||||
@@ -161,6 +174,24 @@ mod linux {
|
|||||||
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
|
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
|
||||||
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
|
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
|
||||||
frames += 1;
|
frames += 1;
|
||||||
|
if let pf_frame::FramePayload::Cpu(data) = &f.payload {
|
||||||
|
if !prev.is_empty()
|
||||||
|
&& prev.len() == data.len()
|
||||||
|
&& prev.as_slice() != data.as_slice()
|
||||||
|
{
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
prev.clear();
|
||||||
|
prev.extend_from_slice(data);
|
||||||
|
if dump && frames % 15 == 1 && dumped < 16 {
|
||||||
|
dumped += 1;
|
||||||
|
let path = format!("{dump_dir}/frame-{frames:05}.ppm");
|
||||||
|
match dump_ppm(&path, f.width, f.height, f.format, data) {
|
||||||
|
Ok(()) => println!("cursor-probe: dumped {path}"),
|
||||||
|
Err(e) => eprintln!("cursor-probe: dump failed: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(c) = &f.cursor {
|
if let Some(c) = &f.cursor {
|
||||||
with_overlay += 1;
|
with_overlay += 1;
|
||||||
if with_overlay == 1 {
|
if with_overlay == 1 {
|
||||||
@@ -195,9 +226,13 @@ mod linux {
|
|||||||
let _ = injector.join();
|
let _ = injector.join();
|
||||||
|
|
||||||
println!("---");
|
println!("---");
|
||||||
|
println!(
|
||||||
|
"cursor-probe: content-changed frames: {changed}/{frames} (a moving embedded \
|
||||||
|
cursor over a static desktop should change nearly every frame)"
|
||||||
|
);
|
||||||
if embedded {
|
if embedded {
|
||||||
println!(
|
println!(
|
||||||
"cursor-probe: EMBEDDED run — no metadata expected; check the stream visually. \
|
"cursor-probe: EMBEDDED run — no metadata expected; check the dumped frames. \
|
||||||
frames={frames}"
|
frames={frames}"
|
||||||
);
|
);
|
||||||
} else if live_seen || with_overlay > 0 {
|
} else if live_seen || with_overlay > 0 {
|
||||||
@@ -214,4 +249,26 @@ mod linux {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a packed 4-bpp frame as a PPM, swapping B/R for the BGR layouts. Enough fidelity
|
||||||
|
/// to answer "is the pointer in the pixels".
|
||||||
|
fn dump_ppm(
|
||||||
|
path: &str,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
format: pf_frame::PixelFormat,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
|
||||||
|
write!(out, "P6\n{w} {h}\n255\n")?;
|
||||||
|
let (ri, gi, bi) = match format {
|
||||||
|
pf_frame::PixelFormat::Rgbx | pf_frame::PixelFormat::Rgba => (0usize, 1usize, 2usize),
|
||||||
|
_ => (2usize, 1usize, 0usize),
|
||||||
|
};
|
||||||
|
for px in data.chunks_exact(4).take((w as usize) * (h as usize)) {
|
||||||
|
out.write_all(&[px[ri], px[gi], px[bi]])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user