refactor(host/W4): make the capture→encode edge one-way (OutputFormat back-ref)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m4s
windows-host / package (push) Successful in 9m4s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m7s
ci / bench (push) Successful in 5m11s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m38s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m34s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m41s
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m4s
windows-host / package (push) Successful in 9m4s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m7s
ci / bench (push) Successful in 5m11s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m38s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m59s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m34s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m41s
The capture.rs facade no longer re-derives the encode backend. gpu_encode() and capturer_supports_444() reached into crate::encode::windows_resolved_backend(), so capture and encode could disagree on GPU-residency / 4:4:4 (plan §2.4). Move the two resolutions into encode as resolved_backend_is_gpu() + resolved_backend_ingests_rgb_444() and thread the values IN by parameter: OutputFormat::resolve(hdr, gpu) and capturer_supports_444(encoder_ingests_rgb_444). Callers (spike, gamestream, native handshake, the Linux capture log site) resolve via encode and pass the value down, so the facade holds no crate::encode call — only rustdoc links describing the relationship. Completes task #8 of W4. Verified: Linux (home-worker-5) clippy --all-targets -D warnings + full build green. Windows (.173) verify owed — box was offline this session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,12 +81,14 @@ pub struct OutputFormat {
|
||||
|
||||
impl OutputFormat {
|
||||
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
|
||||
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
|
||||
/// resolved the encoder), so neither path makes a capturer re-derive it.
|
||||
pub fn resolve(hdr: bool) -> Self {
|
||||
/// (`crate::session_plan`) — the GameStream + spike paths. `gpu` is the encoder's GPU-residency,
|
||||
/// resolved by the caller via [`crate::encode::resolved_backend_is_gpu`] and passed **in** (capture
|
||||
/// never re-derives the backend — the one-way capture→encode edge, plan §2.4 / §W4); `hdr` as given.
|
||||
/// The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already resolved the
|
||||
/// encoder), so neither path makes a capturer re-derive it.
|
||||
pub fn resolve(hdr: bool, gpu: bool) -> Self {
|
||||
OutputFormat {
|
||||
gpu: gpu_encode(),
|
||||
gpu,
|
||||
hdr,
|
||||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||||
chroma_444: false,
|
||||
@@ -94,26 +96,6 @@ impl OutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
||||
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
!matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
// The GPU-less software encoder (openh264) needs CPU-staged RGB frames; every other Linux
|
||||
// backend (NVENC/CUDA, VAAPI) is GPU-resident. Mirrors `session_plan::resolve_encoder`, for the
|
||||
// GameStream/spike entry points that use `OutputFormat::resolve` instead of a full `SessionPlan`.
|
||||
!matches!(
|
||||
crate::config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
)
|
||||
}
|
||||
|
||||
/// A mouse-cursor overlay to composite onto a frame at encode time (cursor-as-metadata). Rides on
|
||||
/// [`CapturedFrame::cursor`] for the GPU zero-copy payloads (Cuda/Dmabuf), whose pixels never touch
|
||||
/// the CPU — the encoder blends this small bitmap into its owned surface (Vulkan CSC image / CUDA
|
||||
@@ -453,25 +435,27 @@ pub fn capture_virtual_output(
|
||||
|
||||
/// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
|
||||
/// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
|
||||
/// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows
|
||||
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up.
|
||||
/// produce subsampled frames. `encoder_ingests_rgb_444` is the encoder half of the gate, resolved by
|
||||
/// the caller ([`crate::encode::resolved_backend_ingests_rgb_444`]) and passed **in** so capture never
|
||||
/// re-derives the backend (the one-way capture→encode edge, plan §2.4 / §W4). Linux (the portal capturer
|
||||
/// feeding CPU RGB → `yuv444p`) can regardless; the Windows IDD-push path delivers subsampled NV12/P010
|
||||
/// today, so full-chroma capture there rides entirely on the encoder gate.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
true
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12
|
||||
// VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4
|
||||
// (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it
|
||||
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||
pub(crate) fn capturer_supports_444(encoder_ingests_rgb_444: bool) -> bool {
|
||||
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12 VideoConverter),
|
||||
// but only a backend that ingests RGB and CSCs it to 4:4:4 itself can use it — today just
|
||||
// direct-NVENC (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
||||
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
||||
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
||||
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc
|
||||
encoder_ingests_rgb_444
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(crate) fn capturer_supports_444() -> bool {
|
||||
pub(crate) fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -1657,7 +1657,7 @@ mod pipewire {
|
||||
sample = ?&modifiers[..modifiers.len().min(6)],
|
||||
"zero-copy: advertising EGL-importable dmabuf modifiers"
|
||||
);
|
||||
} else if backend_is_vaapi && crate::capture::gpu_encode() {
|
||||
} else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() {
|
||||
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
|
||||
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
|
||||
tracing::warn!(
|
||||
|
||||
@@ -959,6 +959,40 @@ pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the session's resolved encode backend produces GPU-resident frames (so the capturer should
|
||||
/// hand GPU surfaces straight through rather than CPU-stage them) — only the GPU-less software encoder
|
||||
/// wants CPU staging. This is the single source for [`crate::capture::OutputFormat`]'s `gpu` bit:
|
||||
/// resolving it in `encode` and threading it *into* the capturer (rather than having `capture` re-derive
|
||||
/// the backend) keeps the capture→encode dependency one-way, so the two can never disagree on whether
|
||||
/// frames are GPU-resident (plan §2.4 / §W4).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
||||
!matches!(windows_resolved_backend(), WindowsBackend::Software)
|
||||
}
|
||||
/// Linux/other: every backend but the GPU-less software encoder (openh264) is GPU-resident. Config-backed
|
||||
/// (mirrors `session_plan::resolve_encoder`; the NVENC vs VAAPI split is auto-detected in [`open_video`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn resolved_backend_is_gpu() -> bool {
|
||||
!matches!(
|
||||
crate::config::config().encoder_pref.as_str(),
|
||||
"software" | "sw" | "openh264"
|
||||
)
|
||||
}
|
||||
|
||||
/// True if the resolved encode backend can ingest a full-chroma (RGB) source and CSC it to 4:4:4 itself —
|
||||
/// the *encoder* half of the 4:4:4 capture gate ([`crate::capture::capturer_supports_444`]). Only Windows
|
||||
/// direct-NVENC does (measured on-glass: ARGB + `chromaFormatIDC=3` → true 4:4:4); AMF/QSV can't. On Linux
|
||||
/// the 4:4:4 source is the capturer's own (portal RGB → `yuv444p`), independent of the auto-detected
|
||||
/// backend, so the gate never consults this there.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
||||
windows_resolved_backend() == WindowsBackend::Nvenc
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn resolved_backend_ingests_rgb_444() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||||
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||||
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV still needs
|
||||
|
||||
@@ -383,7 +383,7 @@ fn open_gs_virtual_source(
|
||||
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
||||
let capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(cfg.hdr),
|
||||
capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
|
||||
@@ -246,7 +246,8 @@ pub(super) async fn negotiate(
|
||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||
let capture_supports_444 = crate::capture::capturer_supports_444();
|
||||
let capture_supports_444 =
|
||||
crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444());
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
.context("create virtual output")?;
|
||||
capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
|
||||
Reference in New Issue
Block a user