Files
punktfunk/crates/punktfunk-host/src/session_plan.rs
T
enricobuehler 0e977817f9
ci / docs-site (push) Successful in 58s
ci / rust (push) Failing after 1m3s
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
windows-host / package (push) Failing after 5m38s
ci / bench (push) Successful in 6m12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m38s
apple / screenshots (push) Successful in 6m25s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Failing after 7m30s
deb / build-publish (push) Successful in 11m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m59s
arch / build-publish (push) Successful in 11m57s
android / android (push) Successful in 15m26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m46s
feat(hdr): GNOME 50 HDR screencast capture + Linux Main10 encode
GNOME 50 (Mutter MR 4928, PipeWire >= 1.6) added HDR screen sharing for
monitor streams: 10-bit PQ formats (xRGB_210LE/xBGR_210LE) with MANDATORY
BT.2020 + SMPTE-2084 colorimetry props, advertised while the mirrored
monitor is in BT.2100 colour mode. Wire the Linux host into it end-to-end
on the GameStream desktop-mirror path (PUNKTFUNK_VIDEO_SOURCE=portal):

* pf-frame: PixelFormat::X2Rgb10/X2Bgr10 (DRM XR30/XB30; X2Bgr10 is the
  Windows Rgb10a2 layout) + fourccs.
* pf-capture: want_hdr portal offer — HDR-only LINEAR-dmabuf pods with
  MANDATORY PQ/BT.2020 props (SHM excluded: Mutter's SHM record path
  paints 8-bit ARGB32 regardless of format; tiled excluded: the EGL
  de-tile blit is 8-bit RGBA8), negotiated-colorimetry parse, generic
  HDR10 hdr_meta(), packed-10-bit CPU cursor blend, a process-wide SDR
  downgrade latch on negotiation timeout, and a DisplayConfig BT.2100
  colour-mode probe (gnome_hdr_monitor_active).
* pf-encode: libav NVENC X2RGB10->P010 swscale (BT.2020 limited) ->
  HEVC Main10 / 10-bit AV1 with PQ VUI; VAAPI 10-bit on both paths (CPU
  P010 upload + dmabuf XR30 scale_vaapi p010/bt2020); can_encode_10bit
  now probes for real on Linux; 10-bit sessions route around the
  8-bit-only Vulkan-video/direct-NVENC backends.
* GameStream: host_hdr_capable() Linux arm, live monitor-HDR check at
  RTSP honor time, capturer-pool reuse keyed on HDR-ness, gs_bit_depth
  covers the new formats. New `punktfunk-host hdr-probe` diagnostic and
  a PUNKTFUNK_SPIKE_HDR spike lever.
* Native plane stays honestly 8-bit via capturer_supports_hdr(): Mutter
  RecordVirtual streams are SDR-only upstream (GNOME 50 and 51-dev), so
  virtual-display sources cannot deliver HDR yet.

Validated on the RTX 5070 Ti (GNOME 50.3 / PipeWire 1.6.8): the Main10
probes pass and the ignored nvenc_hdr10_smoke GPU test emits an IDR that
ffprobe reads as Main 10 / yuv420p10le / bt2020nc / smpte2084 / limited.
Live HDR capture negotiation still needs an HDR monitor on glass; VAAPI
10-bit needs the AMD box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:30:55 +02:00

220 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from
//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value.
//!
//! **Goal-1 stage 3** (`design/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
//! process topology in `native::should_use_helper`, and the encode backend in
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
//! resolves them together, once, so the deployed path reads one typed artifact.
//!
//! Stage 3 routes the **capture** and **topology** decisions through the plan (see
//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading
//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed
//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as
//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which
//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` —
//! is **stage 5**.
//!
//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline`
//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a
//! Windows-only concern).
/// Where a session's frames come from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptureBackend {
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
Portal,
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
/// (in-process, Session 0; captures the secure desktop too). The sole Windows capture path —
/// DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
IddPush,
}
impl CaptureBackend {
/// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by
/// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift.
#[cfg(target_os = "linux")]
pub fn resolve() -> Self {
CaptureBackend::Portal
}
/// Windows: IDD direct-push is the sole capture path (DDA + the WGC two-process relay were removed).
#[cfg(target_os = "windows")]
pub fn resolve() -> Self {
CaptureBackend::IddPush
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn resolve() -> Self {
CaptureBackend::Portal
}
}
/// How a session is structured across processes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionTopology {
/// One process captures + encodes. The only topology: Linux (portal) and Windows (in-process
/// IDD-push in Session 0). The SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
SingleProcess,
}
/// The resolved encode backend (recorded for logging / stages 45; the per-session encoder open still
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EncoderBackend {
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
PlatformAuto,
Nvenc,
Amf,
Qsv,
Software,
}
impl EncoderBackend {
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
pub fn is_gpu(self) -> bool {
!matches!(self, EncoderBackend::Software)
}
}
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
#[derive(Clone, Copy, Debug)]
pub struct SessionPlan {
pub capture: CaptureBackend,
pub topology: SessionTopology,
pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
pub bit_depth: u8,
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag handed to the capturer so it
/// proactively enables advanced color on the virtual display. The Linux NATIVE plane is 8-bit
/// (Mutter's virtual-monitor streams are SDR-only upstream — GNOME 50 HDR is monitor-mirror
/// only, which the GameStream portal path uses; see `capture::capturer_supports_hdr`).
pub hdr: bool,
/// Handshake-negotiated chroma subsampling (4:2:0, or full-chroma 4:4:4 when the client + host +
/// GPU all support it). Resolved before the Welcome; `Yuv420` on every backend that declined it.
pub chroma: crate::encode::ChromaFormat,
/// Handshake-negotiated video codec the encoder emits — HEVC by default, H.264 for a GPU-less
/// software host (`resolve_codec` over the client's advertised codecs ∩ the host's capability).
pub codec: crate::encode::Codec,
/// Datagram-aligned wire chunking for the encoder (plan §4.4): `Some(shard_payload)` on a
/// PyroWave session — applied to EVERY encoder this plan opens (initial + all rebuilds) so
/// AUs stay shard-aligned across mode/bitrate/stall rebuilds. `None` for the H.26x codecs.
pub wire_chunk: Option<usize>,
}
impl SessionPlan {
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`,
/// `chroma`, and `codec`.
pub fn resolve(
bit_depth: u8,
chroma: crate::encode::ChromaFormat,
codec: crate::encode::Codec,
) -> Self {
SessionPlan {
capture: CaptureBackend::resolve(),
topology: resolve_topology(),
encoder: resolve_encoder(),
bit_depth,
hdr: bit_depth >= 10,
chroma,
codec,
wire_chunk: None,
}
}
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
/// capturer never re-derives the encode backend.
pub fn output_format(&self) -> crate::capture::OutputFormat {
let gpu = self.encoder.is_gpu();
// Linux NVENC 4:4:4: libavcodec `hevc_nvenc` only emits 4:4:4 from a YUV444 *input* frame —
// RGB-in is always subsampled to 4:2:0 (verified on the RTX 5070 Ti). With zero-copy
// enabled the import worker produces that input ON the GPU (`ImportKind::Tiled444` — the
// planar-YUV444 convert), so the session stays fully zero-copy at full chroma. Without
// zero-copy the encoder swscales CPU RGB → YUV444P, which needs CPU-resident frames —
// force the GPU capture off for that case only. (VAAPI 4:4:4, where the hardware supports
// it, keeps its dmabuf path via `scale_vaapi`; Windows NVENC ingests BGRA directly.)
#[cfg(target_os = "linux")]
let gpu = {
let force_cpu_for_nvenc_444 = self.chroma.is_444()
&& !crate::encode::linux_zero_copy_is_vaapi()
&& !crate::zerocopy::enabled();
if gpu && force_cpu_for_nvenc_444 {
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
// it looks like an unexplained fps ceiling if you don't know it happened.
tracing::warn!(
"4:4:4 session on the NVENC path without PUNKTFUNK_ZEROCOPY: zero-copy GPU \
capture DISABLED — every frame is CPU RGB + swscale RGB→YUV444P; expect a \
lower fps ceiling than 4:2:0 at this mode (set PUNKTFUNK_ZEROCOPY=1 for the \
GPU 4:4:4 convert)"
);
}
gpu && !force_cpu_for_nvenc_444
};
// PyroWave on an NVIDIA-auto host: the `gpu` capture path resolves to the EGL→CUDA
// import that only NVENC can consume — the wavelet backend ingests raw dmabufs
// (the AMD/Intel path) or CPU RGB. Flip THIS session to CPU RGB capture; the
// Phase-2 exit sessions ran exactly this shape at 60 fps (the encode itself stays
// sub-ms GPU compute). Per-session raw-dmabuf passthrough on NVIDIA (true
// zero-copy without the PUNKTFUNK_ENCODER=pyrowave capture policy) is the
// follow-up; the AMD/Intel dmabuf path is untouched.
#[cfg(target_os = "linux")]
let gpu = {
let pyro_needs_cpu = self.codec == crate::encode::Codec::PyroWave
&& !crate::encode::linux_zero_copy_is_vaapi();
if gpu && pyro_needs_cpu {
tracing::info!(
"PyroWave session on the NVIDIA capture path: GPU (CUDA) capture disabled \
for this session — frames arrive as CPU RGB and upload to the wavelet \
encoder (raw-dmabuf zero-copy on NVIDIA is a follow-up)"
);
}
gpu && !pyro_needs_cpu
};
crate::capture::OutputFormat {
gpu,
hdr: self.hdr,
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
chroma_444: self.chroma.is_444(),
// PyroWave (Windows): the IDD-push capturer makes its NV12 out-ring shareable + signals a
// shared fence so the wavelet encoder can zero-copy-import the texture into its own Vulkan
// device. Inert on Linux (the wavelet backend ingests dmabufs / CPU RGB there — handled
// by the `gpu` flips above, not this flag).
pyrowave: self.codec == crate::encode::Codec::PyroWave,
}
}
}
/// Process topology. Single-process is the only topology now: Linux (portal) and Windows (in-process
/// IDD-push in Session 0). The Windows SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
pub(crate) fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess
}
#[cfg(target_os = "windows")]
fn resolve_encoder() -> EncoderBackend {
match crate::encode::windows_resolved_backend() {
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
}
}
#[cfg(not(target_os = "windows"))]
fn resolve_encoder() -> EncoderBackend {
// `PUNKTFUNK_ENCODER=software` forces the GPU-less openh264 path — which must take CPU-staged
// capture (`EncoderBackend::Software.is_gpu() == false` → `output_format().gpu = false`), so the
// portal capturer delivers CPU RGB. Everything else stays `PlatformAuto` (NVENC/VAAPI resolved
// inside `encode::open_video`).
match pf_host_config::config().encoder_pref.as_str() {
"software" | "sw" | "openh264" => EncoderBackend::Software,
_ => EncoderBackend::PlatformAuto,
}
}