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
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>
262 lines
13 KiB
Rust
262 lines
13 KiB
Rust
//! The shared media-pipeline vocabulary (plan §W6): the frame + pixel-format types that capture
|
||
//! (producer) and encode (consumer) both speak, extracted into a leaf crate so `pf-capture` and
|
||
//! `pf-encode` depend on the vocabulary WITHOUT depending on each other. The GPU payloads pull
|
||
//! their heavy backends in from below: `FramePayload::Cuda` owns a [`pf_zerocopy::DeviceBuffer`],
|
||
//! `FramePayload::D3d11` a [`dxgi::D3d11Frame`].
|
||
//!
|
||
//! Alongside the vocabulary live the small pure helpers that ride the same capture-encode seam:
|
||
//! [`hdr`] (HDR static metadata / in-band SEI), [`metronome`] (the metronomic-stall detector),
|
||
//! [`thread_qos`] (per-thread scheduling QoS), [`session_tuning`] (Windows process session
|
||
//! tuning), and — on Windows — [`dxgi`] (the capture identity + D3D11 device creation).
|
||
|
||
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` must carry a `// SAFETY:` proof.
|
||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||
|
||
pub mod hdr;
|
||
pub mod metronome;
|
||
pub mod session_tuning;
|
||
pub mod thread_qos;
|
||
|
||
// The Windows DXGI capture identity + shared D3D11 device creation (plan §W6). Consumed by the
|
||
// capture IDD-push path, the encode D3D11 backends, and pf-vdisplay's `WinCaptureTarget`.
|
||
#[cfg(target_os = "windows")]
|
||
pub mod dxgi;
|
||
|
||
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||
/// format; on wlroots it is commonly packed `RGB` (3 bytes/pixel). The encoder maps these
|
||
/// to an NVENC-accepted input format (`rgb0`/`bgr0`/`rgba`/`bgra`), expanding 3→4 bytes
|
||
/// where needed — no host-side colour conversion.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum PixelFormat {
|
||
/// `[B,G,R,x]`, 4 bpp.
|
||
Bgrx,
|
||
/// `[R,G,B,x]`, 4 bpp.
|
||
Rgbx,
|
||
/// `[B,G,R,A]`, 4 bpp.
|
||
Bgra,
|
||
/// `[R,G,B,A]`, 4 bpp.
|
||
Rgba,
|
||
/// `[R,G,B]`, 3 bpp.
|
||
Rgb,
|
||
/// `[B,G,R]`, 3 bpp.
|
||
Bgr,
|
||
/// 10-bit RGB packed as `R10G10B10A2` (DXGI `R10G10B10A2_UNORM`), 4 bpp. The HDR capture path
|
||
/// produces this: scRGB FP16 desktop pixels are converted to BT.2020 PQ and written here, then
|
||
/// handed to NVENC as `ABGR10` for an HEVC Main10 / HDR10 encode.
|
||
Rgb10a2,
|
||
/// `NV12` (DXGI `NV12`): 8-bit BT.709 limited-range YUV 4:2:0. Produced by the D3D11 **video
|
||
/// processor** (video engine, not the 3D engine) so the per-frame colour conversion doesn't fight a
|
||
/// GPU-saturating game; handed to NVENC as `NV12` (it encodes YUV natively — no internal RGB→YUV).
|
||
Nv12,
|
||
/// `P010` (DXGI `P010`): 10-bit BT.2020 PQ limited-range YUV 4:2:0. HDR analogue of [`Nv12`]:
|
||
/// video-processor output for HEVC Main10 / HDR10, handed to NVENC as `YUV420_10BIT`.
|
||
P010,
|
||
/// Planar 8-bit YUV **4:4:4** (BT.709; range per `PUNKTFUNK_444_FULLRANGE`). Produced by the
|
||
/// Linux zero-copy worker's GPU convert for a 4:4:4 session ([`FramePayload::Cuda`] with
|
||
/// `DeviceBuffer::yuv444` — three full-res planes stacked in one allocation); NVENC encodes
|
||
/// it natively under the Range-Extensions profile. Never a CPU payload.
|
||
Yuv444,
|
||
/// 10-bit RGB packed `x:R:G:B 2:10:10:10` little-endian (SPA `xRGB_210LE`, DRM `XRGB2101010` /
|
||
/// `XR30`, ffmpeg `x2rgb10le`, NVENC `ARGB10`) — as an LE u32: B in bits 0-9, G 10-19, R 20-29.
|
||
/// The Linux GNOME 50+ HDR screencast source format: Mutter advertises it (with BT.2020
|
||
/// primaries + SMPTE ST.2084 PQ transfer) for a monitor in HDR mode, so the samples are
|
||
/// PQ-encoded BT.2020 RGB. Linux-only; the Windows HDR path stays `Rgb10a2`/`P010`.
|
||
X2Rgb10,
|
||
/// 10-bit RGB packed `x:B:G:R 2:10:10:10` little-endian (SPA `xBGR_210LE`, DRM `XBGR2101010` /
|
||
/// `XB30`, ffmpeg `x2bgr10le`, NVENC `ABGR10`) — as an LE u32: R in bits 0-9, G 10-19, B 20-29;
|
||
/// the same memory layout as the Windows [`Rgb10a2`](Self::Rgb10a2) (DXGI `R10G10B10A2`). The
|
||
/// second GNOME 50+ HDR screencast format (same PQ/BT.2020 colorimetry as
|
||
/// [`X2Rgb10`](Self::X2Rgb10)); kept separate from `Rgb10a2` so the Linux and Windows HDR
|
||
/// paths stay independently greppable.
|
||
X2Bgr10,
|
||
}
|
||
|
||
impl PixelFormat {
|
||
pub fn bytes_per_pixel(self) -> usize {
|
||
match self {
|
||
PixelFormat::Rgb | PixelFormat::Bgr => 3,
|
||
// Three full-res 1-byte planes (GPU-resident only; no CPU payload carries this).
|
||
PixelFormat::Yuv444 => 3,
|
||
_ => 4,
|
||
}
|
||
}
|
||
|
||
/// True for the packed 10-bit RGB layouts a Linux HDR (BT.2020 PQ) capture negotiates —
|
||
/// the formats that make a session's encode bit depth 10 (HEVC Main10 / 10-bit AV1).
|
||
pub fn is_hdr_rgb10(self) -> bool {
|
||
matches!(self, PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10)
|
||
}
|
||
}
|
||
|
||
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
|
||
#[cfg(target_os = "linux")]
|
||
const fn drm_fourcc_code(c: &[u8; 4]) -> u32 {
|
||
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)
|
||
}
|
||
|
||
/// Map a SPA/our [`PixelFormat`] to the DRM FourCC EGL expects for import. SPA byte order `BGRx`
|
||
/// ⇒ DRM `XRGB8888` (memory B,G,R,X), etc. Lives with the frame vocabulary (not in
|
||
/// `pf-zerocopy`) because it consumes [`PixelFormat`], which sits above that crate.
|
||
#[cfg(target_os = "linux")]
|
||
pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
|
||
use PixelFormat::*;
|
||
Some(match format {
|
||
Bgrx => drm_fourcc_code(b"XR24"), // DRM_FORMAT_XRGB8888
|
||
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
||
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
||
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
|
||
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
|
||
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
|
||
})
|
||
}
|
||
|
||
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
|
||
/// `capture_virtual_output` (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
|
||
/// capturer stop re-deriving the encode backend itself — it kills the
|
||
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
|
||
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
|
||
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct OutputFormat {
|
||
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
|
||
/// staging. `false` **only** for the GPU-less software encoder.
|
||
pub gpu: bool,
|
||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
|
||
/// `false` = 8-bit SDR.
|
||
pub hdr: bool,
|
||
/// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
|
||
/// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
|
||
/// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
|
||
/// on-glass (RTX 5070 Ti): ARGB + `chromaFormatIDC=3` yields TRUE 4:4:4 and the conversion
|
||
/// follows the configured VUI matrix (BT.709 limited since the VUI is always written). On
|
||
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||
/// 4:2:0 session.
|
||
pub chroma_444: bool,
|
||
/// A PyroWave (wavelet) session on Windows: the IDD-push capturer must make its NV12 out-ring
|
||
/// **shareable** (`SHARED | SHARED_NTHANDLE`) and signal a **shared fence** after each convert,
|
||
/// so the pyrowave encoder can zero-copy-import the texture into its own Vulkan device
|
||
/// (design/pyrowave-windows-host-zerocopy.md). Also forces the NV12 4:2:0 SDR convert branch
|
||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||
pub pyrowave: bool,
|
||
}
|
||
|
||
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` is the encoder's GPU-residency,
|
||
/// resolved by the caller via `pf_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,
|
||
hdr,
|
||
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
|
||
chroma_444: false,
|
||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||
pyrowave: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// devbuf / VA surface). The CPU de-pad path composites the cursor inline instead, so it leaves
|
||
/// this `None`. `rgba` is `Arc` so attaching the (unchanged) bitmap to every frame is a refcount
|
||
/// bump, not a copy; `serial` bumps only when the bitmap image changes, so the encoder re-uploads
|
||
/// its small GPU texture on change and just moves a push-constant otherwise.
|
||
#[derive(Clone)]
|
||
pub struct CursorOverlay {
|
||
/// Top-left in frame pixels where the bitmap is drawn (already = reported position − hotspot).
|
||
pub x: i32,
|
||
pub y: i32,
|
||
pub w: u32,
|
||
pub h: u32,
|
||
/// Straight-alpha RGBA pixels, `w*h*4` (bytes R,G,B,A).
|
||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||
/// Bumps whenever `rgba`/`w`/`h` change; stable across position-only moves.
|
||
pub serial: u64,
|
||
}
|
||
|
||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||
pub struct CapturedFrame {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub pts_ns: u64,
|
||
/// Pixel layout of the payload.
|
||
pub format: PixelFormat,
|
||
pub payload: FramePayload,
|
||
/// Cursor overlay to blend at encode time (GPU zero-copy payloads only); `None` when there's no
|
||
/// visible cursor or the pixels were already composited on the CPU de-pad path. See
|
||
/// [`CursorOverlay`].
|
||
pub cursor: Option<CursorOverlay>,
|
||
}
|
||
|
||
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||
/// imported into a VA surface there without the compositor's buffer being closed underneath it.
|
||
/// (Content stability across the brief import window relies on the compositor's buffer pool depth,
|
||
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
|
||
#[cfg(target_os = "linux")]
|
||
pub struct DmabufFrame {
|
||
pub fd: std::os::fd::OwnedFd,
|
||
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
|
||
pub fourcc: u32,
|
||
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
||
pub modifier: u64,
|
||
pub offset: u32,
|
||
pub stride: u32,
|
||
}
|
||
|
||
/// Where a captured frame's pixels live.
|
||
pub enum FramePayload {
|
||
/// Tightly-packed CPU pixels in `format`, `width*height*bytes_per_pixel` (no row padding).
|
||
Cpu(Vec<u8>),
|
||
/// A pitched GPU buffer (BGRA-order, on the shared CUDA context) — the NVIDIA zero-copy path.
|
||
/// The dmabuf has already been imported + copied into this owned device buffer.
|
||
#[cfg(target_os = "linux")]
|
||
Cuda(pf_zerocopy::DeviceBuffer),
|
||
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
|
||
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
|
||
#[cfg(target_os = "linux")]
|
||
Dmabuf(DmabufFrame),
|
||
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
||
#[cfg(target_os = "windows")]
|
||
D3d11(dxgi::D3d11Frame),
|
||
}
|
||
|
||
impl CapturedFrame {
|
||
/// True if the frame's pixels are a GPU/CUDA buffer (the NVIDIA zero-copy path).
|
||
pub fn is_cuda(&self) -> bool {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
matches!(self.payload, FramePayload::Cuda(_))
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
|
||
/// True if the frame is a raw dmabuf (the VAAPI zero-copy path).
|
||
pub fn is_dmabuf(&self) -> bool {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
matches!(self.payload, FramePayload::Dmabuf(_))
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
}
|