feat(video): HDR10/P010 end to end (phase 6)

The client now advertises VIDEO_CAP_10BIT|HDR and carries the result all
the way to glass:

- csc_rows is bit-depth exact (10-bit limited code points differ from
  8-bit by ~half a code) and folds in the P010/X6 MSB-packing factor;
  new 10-bit white/black tests.
- The CSC shader grows a params block: mode 0 passes the transfer
  through (SDR as-is, or PQ onto an HDR10 swapchain); mode 1 tonemaps
  PQ→SDR in-shader (ST.2084 EOTF, 203-nit reference white exposure,
  BT.2020→709, soft maxRGB rolloff, sRGB encode) for desktops without
  an HDR surface. PUNKTFUNK_TONEMAP_PEAK tunes the rolloff.
- The presenter probes VK_EXT_swapchain_colorspace + an HDR10/ST.2084
  10-bit surface format and flips modes in-band with the stream's PQ
  signaling: fence-quiesce, then CSC pass + video image (10-bit
  A2B10G10R10 intermediate — PQ in 8 bits bands) + overlay pipe +
  swapchain rebuild through the deferred-destroy rules.
- P010 decodes through all three paths: Vulkan Video (X6 multiplanar
  pool, R10X6 plane views), VAAPI dmabuf (R16/RG1616 plane imports),
  software (swscale as before).
- session pump advertises the caps; the host still gates Main10 behind
  its PUNKTFUNK_10BIT policy.

Probed on glass hardware: the KDE/NVIDIA surface exposes
A2B10G10R10+HDR10_ST2084, so true PQ passthrough is available there.
Known v1 gaps: software-decode PQ shows untonemapped (8-bit RGBA
carries the transfer baked); the SDR overlay composites unscaled onto
an HDR10 surface (dim OSD); no vkSetHdrMetadataEXT yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:31:48 +02:00
parent c299a41a67
commit 4543a3f529
10 changed files with 416 additions and 153 deletions
+13 -7
View File
@@ -35,11 +35,17 @@ hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirr
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout `--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
only, no Skia anywhere in the dependency tree. only, no Skia anywhere in the dependency tree.
Decode follows the Settings preference: VAAPI frames import zero-copy into Vulkan Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software):
(per-plane dmabuf + the stream's CICP-driven CSC shader); boxes whose driver can't FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack
import (NVIDIA proprietary by design) fall back to software decode automatically. supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere;
Debug/bisect knobs: `PUNKTFUNK_DECODER=software|vaapi`, `PUNKTFUNK_PRESENT_MODE= software is the universal fallback. 10-bit Main10 and HDR10 are advertised
(`VIDEO_CAP_10BIT|HDR`): P010 decodes through all three paths, and PQ streams present
on an HDR10/ST.2084 swapchain when the desktop offers one (KDE HDR, gamescope) or
tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the rolloff,
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=vulkan|vaapi|software`, `PUNKTFUNK_PRESENT_MODE=
mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and mailbox|immediate` (default FIFO), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every dmabuf import — proves the three-strike `PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
demotion to software on healthy hardware). HDR/P010 and the Skia console UI demotion to software on healthy hardware).
(`--browse`) are later phases of the plan.
+5 -1
View File
@@ -211,7 +211,11 @@ fn pump(
params.compositor, params.compositor,
params.gamepad, params.gamepad,
params.bitrate_kbps, params.bitrate_kbps,
0, // video_caps: the Linux client has no 10-bit/HDR present path yet // 10-bit Main10 + PQ HDR10: the Vulkan presenter decodes P010 (Vulkan
// Video/VAAPI/software) and presents PQ on an HDR10 swapchain where the desktop
// offers one, tonemapping in the CSC shader where it doesn't. The host still
// gates the upgrade behind its own PUNKTFUNK_10BIT policy.
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR,
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto) params.preferred_codec, // the user's soft codec preference (0 = auto)
+4 -2
View File
@@ -1063,8 +1063,10 @@ impl VulkanDecoder {
} }
let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext; let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext;
let sw = (*fc).sw_format; let sw = (*fc).sw_format;
if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12 { if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12
bail!("Vulkan decode output {sw:?} unsupported (NV12 only for now)"); && sw != ffi::AVPixelFormat::AV_PIX_FMT_P010LE
{
bail!("Vulkan decode output {sw:?} unsupported (NV12/P010 only)");
} }
let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext; let vkfc = (*fc).hwctx as *const pf_ffvk::AVVulkanFramesContext;
let vk_format = (*vkfc).format[0] as i32; let vk_format = (*vkfc).format[0] as i32;
+70 -16
View File
@@ -1,8 +1,20 @@
// NV12 → RGBA with the stream's CICP signaling — the Vulkan port of the GL presenter's // YCbCr (2-plane 4:2:0) → RGBA with the stream's CICP signaling — the Vulkan port of
// fragment shader (video_gl.rs). The YUV→RGB matrix + range expansion arrive as three // the GL presenter's fragment shader, grown depth- and HDR-aware.
// push-constant rows precomputed on the CPU (csc.rs `csc_rows`): rgb[i] = dot(r_i.xyz, //
// yuv) + r_i.w — one shader for BT.601/709/2020, full/limited. The chroma plane is // The YUV→RGB matrix + range expansion arrive as three push-constant rows precomputed
// half-res; the linear sampler interpolates, same as the GL path. // on the CPU (csc.rs `csc_rows` — bit-depth exact, including the P010/X6 MSB-packing
// factor): rgb[i] = dot(r_i.xyz, yuv) + r_i.w. One shader for BT.601/709/2020,
// full/limited, 8- and 10-bit. The chroma plane is half-res; the linear sampler
// interpolates, same as the GL path.
//
// params.x selects the output mode:
// 0 — passthrough: the transfer stays baked (SDR BT.709 shown as-is; PQ BT.2020
// written to an HDR10 swapchain that expects exactly PQ-encoded values).
// 1 — PQ → SDR tonemap (an HDR stream on a desktop without an HDR10 surface):
// PQ EOTF → linear light (nits/10000), exposure anchored at the 203-nit HDR
// reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights
// (BT.2390-flavored simplicity, not libplacebo), then sRGB encode.
// params.y = tonemap peak (display-relative, ~= peak_nits / 203).
// //
// Regenerate: shaders/build.sh (committed .spv, no build-time toolchain). // Regenerate: shaders/build.sh (committed .spv, no build-time toolchain).
#version 450 #version 450
@@ -17,20 +29,62 @@ layout(push_constant) uniform Csc {
vec4 r0; vec4 r0;
vec4 r1; vec4 r1;
vec4 r2; vec4 r2;
vec4 params; // x: mode, y: tonemap peak, z/w: reserved
} pc; } pc;
// SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 =
// 10000 nits.
vec3 pq_eotf(vec3 e) {
const float m1 = 0.1593017578125; // 2610/16384
const float m2 = 78.84375; // 2523/4096 * 128
const float c1 = 0.8359375; // 3424/4096
const float c2 = 18.8515625; // 2413/4096 * 32
const float c3 = 18.6875; // 2392/4096 * 32
vec3 p = pow(max(e, vec3(0.0)), vec3(1.0 / m2));
return pow(max(p - c1, vec3(0.0)) / (c2 - c3 * p), vec3(1.0 / m1));
}
// BT.2020 → BT.709 primaries (linear light).
vec3 bt2020_to_709(vec3 c) {
return mat3(
1.6605, -0.1246, -0.0182,
-0.5876, 1.1329, -0.1006,
-0.0728, -0.0083, 1.1187
) * c;
}
// Linear → sRGB OETF.
vec3 srgb_oetf(vec3 c) {
c = clamp(c, 0.0, 1.0);
bvec3 lo = lessThanEqual(c, vec3(0.0031308));
vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
return mix(hi, c * 12.92, vec3(lo));
}
void main() { void main() {
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg); vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
frag = vec4( vec3 rgb = vec3(
clamp( dot(pc.r0.xyz, yuv) + pc.r0.w,
vec3( dot(pc.r1.xyz, yuv) + pc.r1.w,
dot(pc.r0.xyz, yuv) + pc.r0.w, dot(pc.r2.xyz, yuv) + pc.r2.w
dot(pc.r1.xyz, yuv) + pc.r1.w,
dot(pc.r2.xyz, yuv) + pc.r2.w
),
0.0,
1.0
),
1.0
); );
if (pc.params.x > 0.5) {
// PQ BT.2020 → SDR BT.709: linearize, anchor exposure at the 203-nit HDR
// reference white (SDR diffuse white), convert primaries, roll off highlights.
vec3 lin = pq_eotf(clamp(rgb, 0.0, 1.0)) * (10000.0 / 203.0);
lin = max(bt2020_to_709(lin), vec3(0.0));
float peak = max(pc.params.y, 1.0001);
float l = max(lin.r, max(lin.g, lin.b));
if (l > 1.0) {
// Soft maxRGB rolloff: identity below 1.0, asymptotic to `peak` above —
// keeps colors from clipping to white the way per-channel clamp would.
float mapped = 1.0 + (l - 1.0) / (1.0 + (l - 1.0) / (peak - 1.0));
lin *= mapped / l;
}
rgb = srgb_oetf(lin);
} else {
rgb = clamp(rgb, 0.0, 1.0);
}
frag = vec4(rgb, 1.0);
} }
Binary file not shown.
+46 -15
View File
@@ -11,9 +11,15 @@ use anyhow::{Context as _, Result};
use ash::vk; use ash::vk;
use pf_client_core::video::ColorDesc; use pf_client_core::video::ColorDesc;
/// The push-constant block: three vec4 rows, `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`. /// The push-constant block's matrix half: three vec4 rows,
/// One layout for BT.601/709/2020 × full/limited; PQ transfer joins in the HDR phase. /// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact.
pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] { ///
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
/// `65535/65472` recovers exact `code/1023`.
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's // BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
// BT.709 SDR default (mirrors the software path's swscale coefficient choice). // BT.709 SDR default (mirrors the software path's swscale coefficient choice).
let (kr, kb) = match desc.matrix { let (kr, kb) = match desc.matrix {
@@ -22,13 +28,22 @@ pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] {
_ => (0.2126, 0.0722), _ => (0.2126, 0.0722),
}; };
let kg = 1.0 - kr - kb; let kg = 1.0 - kr - kb;
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
let (sy, oy, sc) = if desc.full_range { let (sy, oy, sc) = if desc.full_range {
(1.0f64, 0.0f64, 1.0f64) (pack, 0.0f64, pack)
} else { } else {
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0) (
pack * max / (219.0 * step),
-(16.0 * step) / max,
pack * max / (224.0 * step),
)
}; };
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into w. // rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
let off = [oy, -0.5, -0.5]; // w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
// factor to land on the same scale.
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
let m = [ let m = [
[sy, 0.0, 2.0 * (1.0 - kr) * sc], [sy, 0.0, 2.0 * (1.0 - kr) * sc],
[ [
@@ -58,12 +73,14 @@ pub struct CscPass {
} }
impl CscPass { impl CscPass {
pub fn new(device: &ash::Device) -> Result<CscPass> { /// `attachment_format` = the video image's format: R8G8B8A8 for SDR, a 10-bit
// One color attachment: the presenter's R8G8B8A8 video image. Content is fully /// format when the pass writes PQ (8 bits would band the PQ curve visibly).
pub fn new(device: &ash::Device, attachment_format: vk::Format) -> Result<CscPass> {
// One color attachment: the presenter's video image. Content is fully
// overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the // overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the
// existing letterbox blit consumes it with no extra barrier. // existing letterbox blit consumes it with no extra barrier.
let attachment = [vk::AttachmentDescription::default() let attachment = [vk::AttachmentDescription::default()
.format(vk::Format::R8G8B8A8_UNORM) .format(attachment_format)
.samples(vk::SampleCountFlags::TYPE_1) .samples(vk::SampleCountFlags::TYPE_1)
.load_op(vk::AttachmentLoadOp::DONT_CARE) .load_op(vk::AttachmentLoadOp::DONT_CARE)
.store_op(vk::AttachmentStoreOp::STORE) .store_op(vk::AttachmentStoreOp::STORE)
@@ -139,7 +156,7 @@ impl CscPass {
let set_layouts = [set_layout]; let set_layouts = [set_layout];
let push = [vk::PushConstantRange::default() let push = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT) .stage_flags(vk::ShaderStageFlags::FRAGMENT)
.size(48)]; // three vec4 rows .size(64)]; // three vec4 rows + a params vec4 (mode, tonemap peak)
let pipeline_layout = unsafe { let pipeline_layout = unsafe {
device.create_pipeline_layout( device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default() &vk::PipelineLayoutCreateInfo::default()
@@ -340,11 +357,25 @@ mod tests {
}) })
} }
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
/// chroma 512 — sampled as UNORM16 of `code << 6`.
#[test]
fn bt2020_10bit_limited_white_black() {
let rows = csc_rows(desc(9, false), 10, true);
let s = |code: u32| ((code << 6) as f32) / 65535.0;
let white = apply(&rows, [s(940), s(512), s(512)]);
let black = apply(&rows, [s(64), s(512), s(512)]);
for (w, b) in white.iter().zip(black) {
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
assert!(b.abs() < 0.002, "black {black:?}");
}
}
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0 /// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
/// — the GL presenter's test, in row form. /// — the GL presenter's test, in row form.
#[test] #[test]
fn bt709_limited_white_black() { fn bt709_limited_white_black() {
let rows = csc_rows(desc(1, false)); let rows = csc_rows(desc(1, false), 8, false);
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]); let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]); let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
for (w, b) in white.iter().zip(black) { for (w, b) in white.iter().zip(black) {
@@ -357,12 +388,12 @@ mod tests {
/// matrix-code dispatch), same as the GL presenter's test. /// matrix-code dispatch), same as the GL presenter's test.
#[test] #[test]
fn full_range_and_red_excursion() { fn full_range_and_red_excursion() {
let rows = csc_rows(desc(5, true)); let rows = csc_rows(desc(5, true), 8, false);
let white = apply(&rows, [1.0, 0.5, 0.5]); let white = apply(&rows, [1.0, 0.5, 0.5]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}"); assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
let red = apply(&rows, [0.0, 0.5, 1.0]); let red = apply(&rows, [0.0, 0.5, 1.0]);
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}"); assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
let rows709 = csc_rows(desc(1, true)); let rows709 = csc_rows(desc(1, true), 8, false);
let red709 = apply(&rows709, [0.0, 0.5, 1.0]); let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
assert!( assert!(
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4, (red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
@@ -377,7 +408,7 @@ mod tests {
fn rows_match_the_gl_matrix_form() { fn rows_match_the_gl_matrix_form() {
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] { for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
let d = desc(matrix, full); let d = desc(matrix, full);
let rows = csc_rows(d); let rows = csc_rows(d, 8, false);
// Reimplementation of video_gl::yuv_to_rgb's application for comparison. // Reimplementation of video_gl::yuv_to_rgb's application for comparison.
let (kr, kb) = match matrix { let (kr, kb) = match matrix {
5 | 6 => (0.299f32, 0.114f32), 5 | 6 => (0.299f32, 0.114f32),
+23 -10
View File
@@ -1,4 +1,5 @@
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8 luma + GR88 chroma) with the //! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
//! for 10-bit P010) with the
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter //! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
//! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import //! (`video_gl.rs`) proved on this hardware, minus the toolkit. Same-Mesa export/import
//! is the contract; anything a driver rejects surfaces as a clean error and the caller //! is the contract; anything a driver rejects surfaces as a clean error and the caller
@@ -9,8 +10,10 @@ use ash::vk;
use pf_client_core::video::{DmabufFrame, DrmFrameGuard}; use pf_client_core::video::{DmabufFrame, DrmFrameGuard};
use std::os::fd::{BorrowedFd, IntoRawFd as _}; use std::os::fd::{BorrowedFd, IntoRawFd as _};
/// `fourcc('N','V','1','2')` — the only VAAPI decoder output today (8-bit 4:2:0). /// `fourcc('N','V','1','2')` — 8-bit 4:2:0 VAAPI output.
const DRM_FORMAT_NV12: u32 = 0x3231_564e; const DRM_FORMAT_NV12: u32 = 0x3231_564e;
/// `fourcc('P','0','1','0')` — 10-bit 4:2:0, 10 bits MSB-aligned in 16 (the HDR path).
const DRM_FORMAT_P010: u32 = 0x3031_3050;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff; const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier. /// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier.
const DRM_FORMAT_MOD_LINEAR: u64 = 0; const DRM_FORMAT_MOD_LINEAR: u64 = 0;
@@ -34,6 +37,8 @@ pub struct HwFrame {
pub color: pf_client_core::video::ColorDesc, pub color: pf_client_core::video::ColorDesc,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
/// 10-bit MSB-packed (P010) — the CSC picks its depth-exact rows off this.
fourcc: u32,
images: [vk::Image; 2], images: [vk::Image; 2],
memories: [vk::DeviceMemory; 2], memories: [vk::DeviceMemory; 2],
views: [vk::ImageView; 2], views: [vk::ImageView; 2],
@@ -41,6 +46,11 @@ pub struct HwFrame {
} }
impl HwFrame { impl HwFrame {
/// 10-bit MSB-packed layout (P010)?
pub fn is_p010(&self) -> bool {
self.fourcc == DRM_FORMAT_P010
}
/// The raw plane images — the presenter's foreign-acquire barriers need them. /// The raw plane images — the presenter's foreign-acquire barriers need them.
pub fn luma_image(&self) -> vk::Image { pub fn luma_image(&self) -> vk::Image {
self.images[0] self.images[0]
@@ -80,11 +90,13 @@ pub fn import(
if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") { if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") {
bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)"); bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)");
} }
if frame.fourcc != DRM_FORMAT_NV12 { let (luma_fmt, chroma_fmt) = match frame.fourcc {
bail!("hw presenter handles NV12 only (got {:#x})", frame.fourcc); DRM_FORMAT_NV12 => (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM),
} DRM_FORMAT_P010 => (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM),
other => bail!("hw presenter handles NV12/P010 only (got {other:#x})"),
};
if frame.planes.len() < 2 { if frame.planes.len() < 2 {
bail!("NV12 needs 2 planes (got {})", frame.planes.len()); bail!("2-plane 4:2:0 needs 2 planes (got {})", frame.planes.len());
} }
// EGL could leave an INVALID modifier to the driver's implied choice; explicit- // EGL could leave an INVALID modifier to the driver's implied choice; explicit-
// modifier images can't — LINEAR is the only honest guess (debug-visible if wrong). // modifier images can't — LINEAR is the only honest guess (debug-visible if wrong).
@@ -102,7 +114,7 @@ pub fn import(
ext_mem_fd, ext_mem_fd,
frame.width, frame.width,
frame.height, frame.height,
vk::Format::R8_UNORM, luma_fmt,
y.fd, y.fd,
y.offset, y.offset,
y.stride, y.stride,
@@ -114,7 +126,7 @@ pub fn import(
ext_mem_fd, ext_mem_fd,
frame.width.div_ceil(2), frame.width.div_ceil(2),
frame.height.div_ceil(2), frame.height.div_ceil(2),
vk::Format::R8G8_UNORM, chroma_fmt,
c.fd, c.fd,
c.offset, c.offset,
c.stride, c.stride,
@@ -159,14 +171,14 @@ pub fn import(
device.free_memory(luma_mem, None); device.free_memory(luma_mem, None);
device.free_memory(chroma_mem, None); device.free_memory(chroma_mem, None);
}; };
let luma_view = match view(luma_img, vk::Format::R8_UNORM) { let luma_view = match view(luma_img, luma_fmt) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
destroy_images(&[]); destroy_images(&[]);
return Err(e); return Err(e);
} }
}; };
let chroma_view = match view(chroma_img, vk::Format::R8G8_UNORM) { let chroma_view = match view(chroma_img, chroma_fmt) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
destroy_images(&[luma_view]); destroy_images(&[luma_view]);
@@ -180,6 +192,7 @@ pub fn import(
color: frame.color, color: frame.color,
width: frame.width, width: frame.width,
height: frame.height, height: frame.height,
fourcc: frame.fourcc,
images: [luma_img, chroma_img], images: [luma_img, chroma_img],
memories: [luma_mem, chroma_mem], memories: [luma_mem, chroma_mem],
views: [luma_view, chroma_view], views: [luma_view, chroma_view],
+165 -27
View File
@@ -336,6 +336,12 @@ pub struct Presenter {
/// gone through a fence cycle. /// gone through a fence cycle.
retired_display: Vec<DisplayGarbage>, retired_display: Vec<DisplayGarbage>,
format: vk::SurfaceFormatKHR, format: vk::SurfaceFormatKHR,
/// The surface's HDR10/ST.2084 pairing, when the stack offers one.
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR, present_mode: vk::PresentModeKHR,
swapchain: vk::SwapchainKHR, swapchain: vk::SwapchainKHR,
images: Vec<vk::Image>, images: Vec<vk::Image>,
@@ -368,6 +374,16 @@ impl Presenter {
let app_info = vk::ApplicationInfo::default() let app_info = vk::ApplicationInfo::default()
.application_name(&app_name) .application_name(&app_name)
.api_version(vk::API_VERSION_1_3); .api_version(vk::API_VERSION_1_3);
// HDR10 presentation needs the extended colorspaces at the INSTANCE level.
let mut instance_extensions: Vec<String> = instance_extensions.to_vec();
let inst_available = unsafe { entry.enumerate_instance_extension_properties(None) }
.unwrap_or_default();
let has_colorspace_ext = inst_available.iter().any(|e| {
e.extension_name_as_c_str() == Ok(c"VK_EXT_swapchain_colorspace")
});
if has_colorspace_ext {
instance_extensions.push("VK_EXT_swapchain_colorspace".into());
}
let ext_cstrings: Vec<CString> = instance_extensions let ext_cstrings: Vec<CString> = instance_extensions
.iter() .iter()
.map(|e| CString::new(e.as_str()).unwrap()) .map(|e| CString::new(e.as_str()).unwrap())
@@ -551,7 +567,7 @@ impl Presenter {
} else { } else {
None None
}; };
let csc = CscPass::new(&device)?; let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
// The exported handle bundle for FFmpeg's Vulkan Video decoder (None = the // The exported handle bundle for FFmpeg's Vulkan Video decoder (None = the
// decoder chain skips straight to VAAPI/software). Extension lists must mirror // decoder chain skips straight to VAAPI/software). Extension lists must mirror
@@ -587,9 +603,10 @@ impl Presenter {
None None
}; };
let format = pick_format(&surface_i, pdev, surface)?; let (format, hdr10_format) =
pick_formats(&surface_i, pdev, surface, has_colorspace_ext)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?; let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(?format, ?present_mode, "swapchain config"); tracing::info!(?format, ?hdr10_format, ?present_mode, "swapchain config");
let overlay_pipe = OverlayPipe::new(&device, format.format)?; let overlay_pipe = OverlayPipe::new(&device, format.format)?;
let cmd_pool = unsafe { let cmd_pool = unsafe {
@@ -635,6 +652,9 @@ impl Presenter {
retired_hw: None, retired_hw: None,
retired_display: Vec::new(), retired_display: Vec::new(),
format, format,
hdr10_format,
hdr_active: false,
video_format: vk::Format::R8G8B8A8_UNORM,
present_mode, present_mode,
swapchain: vk::SwapchainKHR::null(), swapchain: vk::SwapchainKHR::null(),
images: Vec::new(), images: Vec::new(),
@@ -777,6 +797,54 @@ impl Presenter {
} }
} }
/// Flip the presenter between SDR and HDR10 output (stream SDR↔PQ, in-band). A
/// fence quiesce, then everything format-bound is rebuilt: the CSC pass + video
/// image (10-bit intermediate — PQ in 8 bits bands visibly), the overlay pipe, and
/// the swapchain (old one parked per the deferred-destroy rules).
fn set_hdr_mode(&mut self, window: &sdl3::video::Window, on: bool) -> Result<()> {
let target = if on {
self.hdr10_format.expect("caller checked availability")
} else {
// Recompute the SDR pick? It never changed — the sdr format is immutable.
// (self.format currently holds the HDR pairing.)
pick_formats(&self.surface_i, self.pdev, self.surface, false)?.0
};
tracing::info!(hdr = on, format = ?target, "switching presentation mode");
self.quiesce_own()?;
self.video_format = if on {
vk::Format::A2B10G10R10_UNORM_PACK32
} else {
vk::Format::R8G8B8A8_UNORM
};
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
self.csc = CscPass::new(&self.device, self.video_format)?;
if let Some(v) = self.video.take() {
unsafe {
self.device.destroy_framebuffer(v.framebuffer, None);
self.device.destroy_image_view(v.view, None);
self.device.destroy_image(v.image, None);
self.device.free_memory(v.memory, None);
}
}
// New overlay pipe against the new swapchain format; the old one's targets are
// parked (empty sems/swapchain — those ride the recreate below).
let mut old_pipe = std::mem::replace(
&mut self.overlay_pipe,
OverlayPipe::new(&self.device, target.format)?,
);
let (overlay_views, overlay_framebuffers) = old_pipe.take_targets();
self.retired_display.push(DisplayGarbage {
swapchain: vk::SwapchainKHR::null(),
render_sems: Vec::new(),
overlay_views,
overlay_framebuffers,
});
old_pipe.destroy(&self.device);
self.format = target;
self.hdr_active = on;
self.recreate_swapchain(window)
}
/// Present one frame: route `input` into the video image (staging upload or dmabuf /// Present one frame: route `input` into the video image (staging upload or dmabuf
/// import + CSC pass; `Redraw` re-blits what's retained), clear, letterbox-blit, /// import + CSC pass; `Redraw` re-blits what's retained), clear, letterbox-blit,
/// blend the console-UI `overlay` quad if one arrived, present. Returns false when /// blend the console-UI `overlay` quad if one arrived, present. Returns false when
@@ -791,6 +859,22 @@ impl Presenter {
if self.extent.width == 0 || self.extent.height == 0 { if self.extent.width == 0 || self.extent.height == 0 {
return Ok(true); // minimized — nothing to do return Ok(true); // minimized — nothing to do
} }
// SDR↔HDR follows the FRAMES' own signaling (the host flips PQ in-band):
// switch modes before anything touches this frame. Only where the surface
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
// tonemaps (mode 1).
let frame_pq = match &input {
FrameInput::Redraw => None,
FrameInput::Cpu(f) => Some(f.color.is_pq()),
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
};
if let Some(pq) = frame_pq {
let want = pq && self.hdr10_format.is_some();
if want != self.hdr_active {
self.set_hdr_mode(window, want)?;
}
}
// Hardware frames prepare before anything touches the queue: an import/view the // Hardware frames prepare before anything touches the queue: an import/view the
// driver rejects must fail out here, before this present consumed the acquire // driver rejects must fail out here, before this present consumed the acquire
// semaphore. // semaphore.
@@ -910,7 +994,8 @@ impl Presenter {
width: v.width, width: v.width,
height: v.height, height: v.height,
}; };
self.record_csc(v.framebuffer, extent, f.color); let ten_bit = f.is_p010();
self.record_csc(v.framebuffer, extent, f.color, if ten_bit { 10 } else { 8 }, ten_bit);
} }
// Vulkan-Video frame: the decoded image is already on THIS device. Read the // Vulkan-Video frame: the decoded image is already on THIS device. Read the
@@ -932,7 +1017,9 @@ impl Presenter {
width: v.width, width: v.width,
height: v.height, height: v.height,
}; };
self.record_csc(v.framebuffer, extent, f.color); let ten_bit =
f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw();
self.record_csc(v.framebuffer, extent, f.color, if ten_bit { 10 } else { 8 }, ten_bit);
vk_sync = Some(sync); vk_sync = Some(sync);
} }
@@ -1168,6 +1255,8 @@ impl Presenter {
framebuffer: vk::Framebuffer, framebuffer: vk::Framebuffer,
extent: vk::Extent2D, extent: vk::Extent2D,
color: pf_client_core::video::ColorDesc, color: pf_client_core::video::ColorDesc,
depth: u8,
msb_packed: bool,
) { ) {
unsafe { unsafe {
self.device.cmd_begin_render_pass( self.device.cmd_begin_render_pass(
@@ -1214,8 +1303,19 @@ impl Presenter {
&[self.csc.desc_set], &[self.csc.desc_set],
&[], &[],
); );
let rows = csc_rows(color); let rows = csc_rows(color, depth, msb_packed);
let bytes = std::slice::from_raw_parts(rows.as_ptr().cast::<u8>(), 48); // Mode 1 = PQ→SDR tonemap (a PQ stream without an HDR10 surface); mode 0
// passes the transfer through (SDR as-is, or PQ onto the HDR10 swapchain).
let mode = if color.is_pq() && !self.hdr_active { 1.0f32 } else { 0.0 };
let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = mode;
pc[13] = peak;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants( self.device.cmd_push_constants(
self.cmd_buf, self.cmd_buf,
self.csc.pipeline_layout, self.csc.pipeline_layout,
@@ -1228,16 +1328,25 @@ impl Presenter {
} }
} }
/// Per-plane views over a Vulkan-Video frame's multiplanar image (R8 luma + /// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's
/// R8G8 chroma — the CSC pass's exact sampling contract; the frames pool was /// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this).
/// created MUTABLE_FORMAT for this). NV12 only, like the rest of the pipeline. /// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6).
fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> { fn vkframe_plane_views(&self, f: &VkVideoFrame) -> Result<[vk::ImageView; 2]> {
if f.vk_format != vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw() { let (luma_fmt, chroma_fmt) = if f.vk_format
== vk::Format::G8_B8R8_2PLANE_420_UNORM.as_raw()
{
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
} else if f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw() {
(
vk::Format::R10X6_UNORM_PACK16,
vk::Format::R10X6G10X6_UNORM_2PACK16,
)
} else {
bail!( bail!(
"Vulkan-Video pool format {} unsupported (expected G8_B8R8_2PLANE_420)", "Vulkan-Video pool format {} unsupported (expected 2-plane 4:2:0, 8/10-bit)",
f.vk_format f.vk_format
); );
} };
// img[0] is creation-constant (only the sync fields need the frames lock). // img[0] is creation-constant (only the sync fields need the frames lock).
let image = vk::Image::from_raw( let image = vk::Image::from_raw(
unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64, unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64,
@@ -1260,8 +1369,8 @@ impl Presenter {
} }
.context("vk-frame plane view") .context("vk-frame plane view")
}; };
let luma = make(vk::ImageAspectFlags::PLANE_0, vk::Format::R8_UNORM)?; let luma = make(vk::ImageAspectFlags::PLANE_0, luma_fmt)?;
let chroma = match make(vk::ImageAspectFlags::PLANE_1, vk::Format::R8G8_UNORM) { let chroma = match make(vk::ImageAspectFlags::PLANE_1, chroma_fmt) {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
unsafe { self.device.destroy_image_view(luma, None) }; unsafe { self.device.destroy_image_view(luma, None) };
@@ -1318,7 +1427,7 @@ impl Presenter {
self.device.create_image( self.device.create_image(
&vk::ImageCreateInfo::default() &vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D) .image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM) .format(self.video_format)
.extent(vk::Extent3D { .extent(vk::Extent3D {
width, width,
height, height,
@@ -1347,7 +1456,7 @@ impl Presenter {
&vk::ImageViewCreateInfo::default() &vk::ImageViewCreateInfo::default()
.image(image) .image(image)
.view_type(vk::ImageViewType::TYPE_2D) .view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM) .format(self.video_format)
.subresource_range(subresource_range()), .subresource_range(subresource_range()),
None, None,
) )
@@ -1515,24 +1624,47 @@ fn pick_device(
bail!("no Vulkan device with a graphics+present queue family") bail!("no Vulkan device with a graphics+present queue family")
} }
/// Prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else /// SDR: prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else
/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already /// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already
/// display-referred, the blit must not re-encode it. /// display-referred, the blit must not re-encode it. HDR: a 10-bit UNORM format paired
fn pick_format( /// with the HDR10/ST.2084 colorspace, when the instance ext + surface offer one (KDE/
/// gamescope with HDR enabled; absent elsewhere → the shader tonemaps instead).
fn pick_formats(
surface_i: &ash::khr::surface::Instance, surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice, pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR, surface: vk::SurfaceKHR,
) -> Result<vk::SurfaceFormatKHR> { colorspace_ext: bool,
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?; let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
let mut sdr = None;
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] { for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
if let Some(f) = formats.iter().find(|f| f.format == want) { if let Some(f) = formats
return Ok(*f); .iter()
.find(|f| f.format == want && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR)
{
sdr = Some(*f);
break;
} }
} }
formats let sdr = sdr
.first() .or_else(|| formats.first().copied())
.copied() .ok_or_else(|| anyhow!("surface offers no formats"))?;
.ok_or_else(|| anyhow!("surface offers no formats")) let hdr10 = colorspace_ext
.then(|| {
formats
.iter()
.find(|f| {
f.color_space == vk::ColorSpaceKHR::HDR10_ST2084_EXT
&& matches!(
f.format,
vk::Format::A2B10G10R10_UNORM_PACK32
| vk::Format::A2R10G10B10_UNORM_PACK32
)
})
.copied()
})
.flatten();
Ok((sdr, hdr10))
} }
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available — /// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
@@ -1555,6 +1687,12 @@ fn pick_present_mode(
}) })
} }
/// Flatten the 3×vec4 rows for the push-constant block.
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::<f32>(), 12) }
}
/// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered. /// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered.
fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) { fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) {
let (ew, eh) = (f64::from(extent.width), f64::from(extent.height)); let (ew, eh) = (f64::from(extent.width), f64::from(extent.height));
@@ -8,15 +8,15 @@
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports //! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll //! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
//! `hyprctl -j monitors` until the name shows up. //! `hyprctl -j monitors` until the name shows up.
//! 2. A monitor rule sets the client's exact mode. The grammar changed in 0.55 (hyprlang → //! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword
//! Lua), so [`set_monitor_rule`] tries both eras (D5): `hyprctl keyword monitor NAME,WxH@Hz,…` //! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current
//! (≤0.54, still loads on 0.55 during the deprecation window) and `hyprctl eval 'hl.monitor{…}'` //! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a
//! (≥0.55), ordered by the detected version and falling back to the other. //! user on the opt-in Lua config manager, confirming the output actually adopted the mode (D5).
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is //! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a //! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
//! managed config (`~/.config/hypr/xdph.conf`) points `custom_picker_binary` at a tiny installed //! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
//! shim that cats a per-session selection file we write (`screen:<NAME>`) right before the //! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
//! handshake — byte-for-byte the xdpw pattern, different picker wire format. //! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs //! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
//! `hyprctl output remove NAME`. //! `hyprctl output remove NAME`.
//! //!
@@ -25,10 +25,12 @@
//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with //! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`). //! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
//! //!
//! ⚠ **Spike-pending contract** (`design/hyprland-support.md` Phase 0): the xdph `custom_picker_binary` //! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
//! stdout format and config path, and the `hl.monitor{}` Lua signature, are documented best-guesses //! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
//! here. A wrong picker line degrades to the interactive picker (per the plan's risk table), and //! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
//! `set_monitor_rule` tries both eras, so a wrong guess self-heals rather than breaking capture. //! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
//! surfaces that as a clear error instead of streaming a 0×0 output.
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput}; use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -36,7 +38,7 @@ use std::os::fd::OwnedFd;
use std::process::Command; use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::{Arc, Once, OnceLock}; use std::sync::{Arc, Once};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -58,12 +60,13 @@ fn picker_shim_path() -> String {
format!("{dir}/punktfunk-xdph-picker.sh") format!("{dir}/punktfunk-xdph-picker.sh")
} }
/// The picker line for output `name`. ⚠ Spike-pending: xdph's custom-picker stdout contract is /// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
/// `screen:<output-name>` (vs `window:<addr>` / `region:…`) to the best of current knowledge — this /// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
/// is the single place to correct once the Phase-0 spike pins it. A wrong value just means xdph /// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
/// falls back to its interactive picker (degraded, not broken). /// "strange output" and falls back to the interactive picker. So a monitor selection is
/// `[SELECTION]screen:<name>`.
fn picker_selection_line(name: &str) -> String { fn picker_selection_line(name: &str) -> String {
format!("screen:{name}\n") format!("[SELECTION]screen:{name}\n")
} }
/// The managed xdph config: point the screencopy custom picker at our shim so headless output /// The managed xdph config: point the screencopy custom picker at our shim so headless output
@@ -126,6 +129,9 @@ pub fn probe() -> Result<()> {
"hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \ "hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \
host must run inside, or be able to reach, the Hyprland session)", host must run inside, or be able to reach, the Hyprland session)",
)?; )?;
if let Some((maj, min, pat)) = hyprland_version() {
tracing::info!(version = %format!("{maj}.{min}.{pat}"), "Hyprland backend ready");
}
warn_if_permissions_enforced(); warn_if_permissions_enforced();
Ok(()) Ok(())
} }
@@ -261,6 +267,10 @@ fn hyprctl_dispatch(args: &[&str]) -> Result<()> {
|| lc.contains("unknown") || lc.contains("unknown")
|| lc.contains("no such") || lc.contains("no such")
|| lc.contains("error") || lc.contains("error")
// `hyprctl eval` on a hyprlang (non-Lua) config: "eval is only supported with the lua
// config manager" — a rejection hyprctl reports with exit 0 and no other marker.
|| lc.contains("only supported")
|| lc.contains("not supported")
{ {
bail!("hyprctl {:?} rejected: {t}", args); bail!("hyprctl {:?} rejected: {t}", args);
} }
@@ -297,10 +307,17 @@ fn monitor_exists(name: &str) -> Result<bool> {
} }
/// Set the client's exact mode on `name`, supporting both config eras (D5). Encapsulates the whole /// Set the client's exact mode on `name`, supporting both config eras (D5). Encapsulates the whole
/// era split: ≤0.54 uses `hyprctl keyword monitor NAME,WxH@Hz,auto,1`; ≥0.55 (hyprlang → Lua) /// era split (D5). `hyprctl keyword monitor NAME,WxH@Hz,auto,1` is the hyprlang path — the default
/// prefers `hyprctl eval 'hl.monitor{…}'`. Whichever era we detect, we try its form first and fall /// config manager on **every** current release, including ≥0.55 (verified on 0.55.4: version does
/// back to the other — hyprlang configs still load during the 0.55 deprecation window, so `keyword` /// NOT imply the Lua eraa stock 0.55.4 rejects `eval` with "only supported with the lua config
/// keeps working for a while, and the fallback makes a wrong version guess self-heal. /// manager"). So we try `keyword` first and fall back to the Lua `hyprctl eval 'hl.monitor{…}'` only
/// for a user who opted into the Lua config manager (where `keyword` is gone). Either way we confirm
/// the output actually adopted the mode — some forms print `ok` for a command they ignored.
///
/// A headless output starts at 0×0 and only gets a framebuffer once a mode commits; if neither form
/// makes it adopt a usable (non-zero) size the compositor couldn't back the mode (a headless GBM /
/// dmabuf allocation failure — Sunshine#4197, seen on some NVIDIA setups), which we surface clearly
/// rather than streaming a 0×0 corpse.
fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> { fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
let hz = mode.refresh_hz.max(1); let hz = mode.refresh_hz.max(1);
let spec = format!("{name},{}x{}@{hz},auto,1", mode.width, mode.height); let spec = format!("{name},{}x{}@{hz},auto,1", mode.width, mode.height);
@@ -310,41 +327,46 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
); );
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec]; let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
let eval: Vec<&str> = vec!["eval", &lua]; let eval: Vec<&str> = vec!["eval", &lua];
let attempts: [&Vec<&str>; 2] = if hyprland_version_at_least(0, 55) { for a in [&keyword, &eval] {
[&eval, &keyword] // A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to
} else { // the other form. A command that's accepted then has up to the timeout to take effect.
[&keyword, &eval] if hyprctl_dispatch(a).is_err() {
};
let mut errs = Vec::new();
for a in attempts {
if let Err(e) = hyprctl_dispatch(a) {
errs.push(format!("{a:?}: {e:#}"));
continue; continue;
} }
// Confirm the monitor actually adopted the mode — some versions print `ok` for a command if wait_exact_mode(name, mode, Duration::from_millis(1500)) {
// they silently ignored (e.g. a Lua form the era doesn't accept), which would otherwise tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode");
// leave the output at the default 1080p60. If it didn't take, fall through to the other era.
if wait_mode_applied(a, name, mode, Duration::from_millis(800)) {
return Ok(()); return Ok(());
} }
errs.push(format!(
"{a:?}: dispatched but monitor never adopted {}x{}",
mode.width, mode.height
));
} }
bail!( // Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a
"hyprctl monitor rule failed on both config eras: {}", // warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all).
errs.join(" | ") match monitor_size(name)? {
) Some((w, h)) if w > 0 && h > 0 => {
tracing::warn!(
output = %name,
requested = %format!("{}x{}", mode.width, mode.height),
got = %format!("{w}x{h}"),
"Hyprland did not adopt the exact requested mode — streaming at the output's current size"
);
Ok(())
}
_ => bail!(
"headless output {name} never got a framebuffer (stayed 0x0) after the monitor rule for \
{}x{}@{hz} — the compositor could not back the mode, likely a headless GBM/dmabuf \
allocation failure (GPU driver; cf. Sunshine#4197). Check the Hyprland log.",
mode.width,
mode.height
),
}
} }
/// Poll until monitor `name` reports the requested width×height (the rule applies asynchronously), /// Poll until monitor `name` reports exactly `mode`'s width×height (the rule applies asynchronously),
/// up to `timeout`. Logs which command form finally took. Returns `false` on timeout. /// up to `timeout`. Returns `false` on timeout.
fn wait_mode_applied(attempt: &[&str], name: &str, mode: Mode, timeout: Duration) -> bool { fn wait_exact_mode(name: &str, mode: Mode, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if monitor_has_mode(name, mode).unwrap_or(false) { if matches!(monitor_size(name), Ok(Some((w, h))) if w == mode.width as u64 && h == mode.height as u64)
tracing::debug!(output = %name, cmd = ?attempt, "monitor adopted the requested mode"); {
return true; return true;
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
@@ -354,38 +376,35 @@ fn wait_mode_applied(attempt: &[&str], name: &str, mode: Mode, timeout: Duration
} }
} }
/// Does monitor `name` currently report `mode`'s resolution in `hyprctl -j monitors`? Checks /// Monitor `name`'s current `(width, height)` from `hyprctl -j monitors all` (includes a disabled
/// width×height (the capture-critical dimension); the fractional `refreshRate` isn't compared. /// output), or `None` if it isn't present. A freshly-created headless output reports `0×0` until a
fn monitor_has_mode(name: &str, mode: Mode) -> Result<bool> { /// mode commits.
let out = hyprctl(&["-j", "monitors"])?; fn monitor_size(name: &str) -> Result<Option<(u64, u64)>> {
let out = hyprctl(&["-j", "monitors", "all"])?;
let monitors: serde_json::Value = let monitors: serde_json::Value =
serde_json::from_str(&out).context("parse hyprctl -j monitors")?; serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
let Some(arr) = monitors.as_array() else { let Some(arr) = monitors.as_array() else {
return Ok(false); return Ok(None);
}; };
for m in arr { for m in arr {
if m.get("name").and_then(|n| n.as_str()) == Some(name) { if m.get("name").and_then(|n| n.as_str()) == Some(name) {
let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0); let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0); let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
return Ok(w == mode.width as u64 && h == mode.height as u64); return Ok(Some((w, h)));
} }
} }
Ok(false) Ok(None)
} }
/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.0`), /// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.4`),
/// cached for the process. A compositor upgrade + restart mid-host-life would leave this stale, but /// for a diagnostic log — the mode-rule path is version-independent (see [`set_monitor_rule`]).
/// [`set_monitor_rule`] tries both eras regardless, so the cache is an optimization, not correctness.
fn hyprland_version() -> Option<(u16, u16, u16)> { fn hyprland_version() -> Option<(u16, u16, u16)> {
static V: OnceLock<Option<(u16, u16, u16)>> = OnceLock::new(); let out = hyprctl(&["-j", "version"]).ok()?;
*V.get_or_init(|| { let json: serde_json::Value = serde_json::from_str(&out).ok()?;
let out = hyprctl(&["-j", "version"]).ok()?; parse_version_tag(json.get("tag").and_then(|t| t.as_str())?)
let json: serde_json::Value = serde_json::from_str(&out).ok()?;
parse_version_tag(json.get("tag").and_then(|t| t.as_str())?)
})
} }
/// Parse a Hyprland `tag` (`v0.55.0`, or a dev `v0.41.2-13-gabcdef`) to `(major, minor, patch)`. /// Parse a Hyprland `tag` (`v0.55.4`, or a dev `v0.41.2-13-gabcdef`) to `(major, minor, patch)`.
fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> { fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> {
let t = tag.trim().trim_start_matches(['v', 'V']); let t = tag.trim().trim_start_matches(['v', 'V']);
let mut it = t.split(['.', '-', '_', '+']); let mut it = t.split(['.', '-', '_', '+']);
@@ -395,12 +414,6 @@ fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> {
Some((major, minor, patch)) Some((major, minor, patch))
} }
/// Is the running Hyprland at least `major.minor`? Unknown version → `false` (assume the older
/// `keyword` era, which the fallback covers anyway).
fn hyprland_version_at_least(major: u16, minor: u16) -> bool {
matches!(hyprland_version(), Some((a, b, _)) if (a, b) >= (major, minor))
}
/// Log the permission-system caveat at most once per process: with /// Log the permission-system caveat at most once per process: with
/// `ecosystem.enforce_permissions = true` (0.49+, off by default), direct screencopy/virtual-input /// `ecosystem.enforce_permissions = true` (0.49+, off by default), direct screencopy/virtual-input
/// clients can be denied — and denial is **silent black frames / dropped input**, not an error. /// clients can be denied — and denial is **silent black frames / dropped input**, not an error.
@@ -579,7 +592,8 @@ mod tests {
} }
#[test] #[test]
fn picker_line_is_screen_scoped() { fn picker_line_carries_the_selection_marker() {
assert_eq!(picker_selection_line("PF-1"), "screen:PF-1\n"); // xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
} }
} }
+4 -3
View File
@@ -34,9 +34,10 @@ See [Configuration](/docs/configuration) for the full reference.
## How it works ## How it works
- **Video** — the host runs `hyprctl output create headless PF-1` and applies a monitor rule for the - **Video** — the host runs `hyprctl output create headless PF-1` and applies a monitor rule for the
client's exact mode. Outputs are **named**, so there's no before/after diffing. Both config eras client's exact mode. Outputs are **named**, so there's no before/after diffing. The rule uses
are supported: `hyprctl keyword monitor …` (≤ 0.54) and the Lua `hyprctl eval 'hl.monitor{…}'` `hyprctl keyword monitor …` (the hyprlang config manager — the default on every release, 0.55
(≥ 0.55), selected from `hyprctl version`. included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only if you've opted into the
Lua config manager. The host confirms the output actually adopted the mode before streaming.
- **Capture** — it captures that output through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast - **Capture** — it captures that output through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast
portal. To pick the output without a GUI on a headless host, the host writes a managed portal. To pick the output without a GUI on a headless host, the host writes a managed
`~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the `~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the