From 4543a3f529b2f3d33389db0ddc0b098fe2f85332 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 22:31:48 +0200 Subject: [PATCH] feat(video): HDR10/P010 end to end (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/linux-session/README.md | 20 +- crates/pf-client-core/src/session.rs | 6 +- crates/pf-client-core/src/video.rs | 6 +- crates/pf-presenter/shaders/nv12_csc.frag | 86 ++++++-- crates/pf-presenter/shaders/nv12_csc.frag.spv | Bin 1580 -> 3396 bytes crates/pf-presenter/src/csc.rs | 61 ++++-- crates/pf-presenter/src/dmabuf.rs | 33 ++- crates/pf-presenter/src/vk.rs | 192 +++++++++++++++--- .../src/vdisplay/linux/hyprland.rs | 158 +++++++------- docs-site/content/docs/hyprland.md | 7 +- 10 files changed, 416 insertions(+), 153 deletions(-) diff --git a/clients/linux-session/README.md b/clients/linux-session/README.md index 51769077..dae1e349 100644 --- a/clients/linux-session/README.md +++ b/clients/linux-session/README.md @@ -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 only, no Skia anywhere in the dependency tree. -Decode follows the Settings preference: VAAPI frames import zero-copy into Vulkan -(per-plane dmabuf + the stream's CICP-driven CSC shader); boxes whose driver can't -import (NVIDIA proprietary by design) fall back to software decode automatically. -Debug/bisect knobs: `PUNKTFUNK_DECODER=software|vaapi`, `PUNKTFUNK_PRESENT_MODE= +Decode follows the Settings preference (auto: Vulkan Video → VAAPI → software): +FFmpeg's Vulkan Video decoder runs on the presenter's own device where the stack +supports it (every vendor, zero copy); VAAPI dmabufs import per-plane elsewhere; +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=` (multi-GPU), and -`PUNKTFUNK_HW_FAULT=import` (fault every dmabuf import — proves the three-strike -demotion to software on healthy hardware). HDR/P010 and the Skia console UI -(`--browse`) are later phases of the plan. +`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike +demotion to software on healthy hardware). diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 543797ca..ffdd98bf 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -211,7 +211,11 @@ fn pump( params.compositor, params.gamepad, 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, crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) params.preferred_codec, // the user's soft codec preference (0 = auto) diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 8d1b9739..6456b901 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -1063,8 +1063,10 @@ impl VulkanDecoder { } let fc = (*hwfc_ref).data as *mut ffi::AVHWFramesContext; let sw = (*fc).sw_format; - if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12 { - bail!("Vulkan decode output {sw:?} unsupported (NV12 only for now)"); + if sw != ffi::AVPixelFormat::AV_PIX_FMT_NV12 + && 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 vk_format = (*vkfc).format[0] as i32; diff --git a/crates/pf-presenter/shaders/nv12_csc.frag b/crates/pf-presenter/shaders/nv12_csc.frag index 6df1239d..862445b7 100644 --- a/crates/pf-presenter/shaders/nv12_csc.frag +++ b/crates/pf-presenter/shaders/nv12_csc.frag @@ -1,8 +1,20 @@ -// NV12 → RGBA with the stream's CICP signaling — the Vulkan port of the GL presenter's -// fragment shader (video_gl.rs). The YUV→RGB matrix + range expansion arrive as three -// 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 -// half-res; the linear sampler interpolates, same as the GL path. +// YCbCr (2-plane 4:2:0) → RGBA with the stream's CICP signaling — the Vulkan port of +// the GL presenter's fragment shader, grown depth- and HDR-aware. +// +// The YUV→RGB matrix + range expansion arrive as three push-constant rows precomputed +// 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). #version 450 @@ -17,20 +29,62 @@ layout(push_constant) uniform Csc { vec4 r0; vec4 r1; vec4 r2; + vec4 params; // x: mode, y: tonemap peak, z/w: reserved } 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() { vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg); - frag = vec4( - clamp( - vec3( - dot(pc.r0.xyz, yuv) + pc.r0.w, - dot(pc.r1.xyz, yuv) + pc.r1.w, - dot(pc.r2.xyz, yuv) + pc.r2.w - ), - 0.0, - 1.0 - ), - 1.0 + vec3 rgb = vec3( + dot(pc.r0.xyz, yuv) + pc.r0.w, + dot(pc.r1.xyz, yuv) + pc.r1.w, + dot(pc.r2.xyz, yuv) + pc.r2.w ); + + 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); } diff --git a/crates/pf-presenter/shaders/nv12_csc.frag.spv b/crates/pf-presenter/shaders/nv12_csc.frag.spv index 4b82da407ba3625087e0098600641740071ff02f..dd710b027438c3038c55745560dc7facfa7eb1bf 100644 GIT binary patch literal 3396 zcmZ9OYiO2b7{{Nz)2*rL%v@WEyweW}vz4W$*nC?q=3qH9Y_Ko6X4&!(q0zatRoY2i zHOsQxmq4MSGALs!3xZCDMERk_)|Y}JDo8qz*zfo5xn3W<@$db={@3BY?!)tHd2VK9 zl9VOmle3d4Wl3>NPI4#-T19bgZNIL)apR`djdSN+V!*_tyclQ9q@*$#!z|W!_ioU! z1{?#`#Hq7D(hBQgt_O~=XN;Of+q~%++hWGMj`7;3^AD{WU->5OK9= zuO9BWBI4?>)dCSW4J}EoFNB@fJhH1f?0W6Z110-H%(Ww*Tp4v|VXFmd>2o%=y=D8p z2)pHpuS@GRVz)J(8jf`?#%`z`7>c@{nK{N4ed+$^VE?(U=7Y$)6#ICtzf|`!>>Db- z9F973vCT0~U*ye`*AA4{pO1a;zsjNTUyi+V&W=LVS%7Vhar)HV=R#nwu47E(7vLOR z;aqNZ3C#O!VU}mlWhwTw#%mVEI&Ii$fvDGx)--NwX`jomSFZeKapZMis|6x&1={Pa z-A!S4Vy}32S##udVcTnpb^ z#*M$3xeTZque~PKjPHdT?{_|xzt=M7fx~@huPa)o*V~ZV)5-K3wni4Ndly<2(C@n1 zcc+@`+yl=8dvhJ_dx69J`u=d|5W0{*!yO~#mp49e^ZvozKCsFoxD=xVyio%)+V?bttZQ=?r+f@z%`85-^{w+=l#Gp;61yp{XGEGJCd`CxeunAy2HAj z!Lw=aTj0jHMln z^dy=w*`A)lj=I^NwxjEdo_4^^SC5|h;pX~&qo=3g>e16Pa5dpLv!?+xW3oN%!j8Jx zo}NS37d<@>H(x#ObT{1dvtRvj_AkP%>9^(YM4bH|xW4x|`z6dT12w<7zj(=m%#MG# zD}8&xQQ-TQPe|XxD{%ML0PLX#bb$%H7T?;BK>sXYtp4!*1lM;yFs=^t(T{mQ1O0xZ z5&sKz)Y5OQ?P>h4aO3Mi)c6hVx2$hl8uvTgI{KpSA8_m1hkkpFy8Ge!_ksSj#tHaB zpf75?29MtK+gsehoA9^;f3xBa-h%71Mg{ZRAZqA40OC7-2ksh<&6L=MN8y})?) z67TyY9Cz`38yD|;h&{BRp903Tfp`mL#F^uJ($@i^pPW9>ju!8947R%ezC>Il+}uvE zpVRW2s6ul+zpv5nX!JLE96EoB|39>rxyHKRL3HouD0A%TTe$HJU^j?8W#b3Y<9sF& z>>8e#xz>vJb{wwX5!&}~HRaGoQZ2{4oZa*?{{YN4egqW2V&2&?W_>5AumJoI(bg>X literal 1580 zcmZ9MOHWim5QUo=0g<gw)0G#l;Fl!nrHnoRvPu-VjrNpK_edf5BW>wN$5xwE--N5xcX)hSz&HdLm>s$lw zC=H+J+QeFL_L7?^IBVo8Fvr=ATg>h$^)KR$uvTD!IXsWC`NcGr&gIzNrA54ia-4*^ ziu#ExlH19#cca;Vn$`a9t=?JfC9mGtyOL8s$Ex1Dp6Bm8YX!9E@7-zkt5}`gI`hUY zAh(?F&RoEnHt`r(0oE9=X3jmY;#Xh@tmk+QU$g!itM&FY?~J#Adz$aCn)e)0e-FRx zw?pimm{<1#Xc51xqn}SV$ofX_fSN$?$8c%`4>+@Zy+6&F{=f{g?!x*`Q@}Se_8rax z-{2!E&vXH(k$(^8d<#Hci$06^zD3k6;mehEmx<-I=;e1V=Y90^`L5u*hkEl}_Vu2x z1K(y1xUciw0P@G_Y<)L3GbgXv*E{%~XWzhA;~ri1^L=iE=2AL#)-lmGw# diff --git a/crates/pf-presenter/src/csc.rs b/crates/pf-presenter/src/csc.rs index a5b7ec18..e454abb3 100644 --- a/crates/pf-presenter/src/csc.rs +++ b/crates/pf-presenter/src/csc.rs @@ -11,9 +11,15 @@ use anyhow::{Context as _, Result}; use ash::vk; use pf_client_core::video::ColorDesc; -/// The push-constant block: three vec4 rows, `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`. -/// One layout for BT.601/709/2020 × full/limited; PQ transfer joins in the HDR phase. -pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] { +/// The push-constant block's matrix half: three vec4 rows, +/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. +/// +/// `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.709 SDR default (mirrors the software path's swscale coefficient choice). let (kr, kb) = match desc.matrix { @@ -22,13 +28,22 @@ pub fn csc_rows(desc: ColorDesc) -> [[f32; 4]; 3] { _ => (0.2126, 0.0722), }; 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 { - (1.0f64, 0.0f64, 1.0f64) + (pack, 0.0f64, pack) } 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. - let off = [oy, -0.5, -0.5]; + // rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into + // 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 = [ [sy, 0.0, 2.0 * (1.0 - kr) * sc], [ @@ -58,12 +73,14 @@ pub struct CscPass { } impl CscPass { - pub fn new(device: &ash::Device) -> Result { - // One color attachment: the presenter's R8G8B8A8 video image. Content is fully + /// `attachment_format` = the video image's format: R8G8B8A8 for SDR, a 10-bit + /// 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 { + // One color attachment: the presenter's video image. Content is fully // overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the // existing letterbox blit consumes it with no extra barrier. let attachment = [vk::AttachmentDescription::default() - .format(vk::Format::R8G8B8A8_UNORM) + .format(attachment_format) .samples(vk::SampleCountFlags::TYPE_1) .load_op(vk::AttachmentLoadOp::DONT_CARE) .store_op(vk::AttachmentStoreOp::STORE) @@ -139,7 +156,7 @@ impl CscPass { let set_layouts = [set_layout]; let push = [vk::PushConstantRange::default() .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 { device.create_pipeline_layout( &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 /// — the GL presenter's test, in row form. #[test] 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 black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]); for (w, b) in white.iter().zip(black) { @@ -357,12 +388,12 @@ mod tests { /// matrix-code dispatch), same as the GL presenter's test. #[test] 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]); assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}"); 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:?}"); - 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]); assert!( (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() { for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] { 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. let (kr, kb) = match matrix { 5 | 6 => (0.299f32, 0.114f32), diff --git a/crates/pf-presenter/src/dmabuf.rs b/crates/pf-presenter/src/dmabuf.rs index 02a43863..b718794f 100644 --- a/crates/pf-presenter/src/dmabuf.rs +++ b/crates/pf-presenter/src/dmabuf.rs @@ -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 //! (`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 @@ -9,8 +10,10 @@ use ash::vk; use pf_client_core::video::{DmabufFrame, DrmFrameGuard}; 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; +/// `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; /// `DRM_FORMAT_MOD_LINEAR` — the fallback when the export carried no explicit modifier. const DRM_FORMAT_MOD_LINEAR: u64 = 0; @@ -34,6 +37,8 @@ pub struct HwFrame { pub color: pf_client_core::video::ColorDesc, pub width: u32, pub height: u32, + /// 10-bit MSB-packed (P010) — the CSC picks its depth-exact rows off this. + fourcc: u32, images: [vk::Image; 2], memories: [vk::DeviceMemory; 2], views: [vk::ImageView; 2], @@ -41,6 +46,11 @@ pub struct 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. pub fn luma_image(&self) -> vk::Image { self.images[0] @@ -80,11 +90,13 @@ pub fn import( if std::env::var_os("PUNKTFUNK_HW_FAULT").is_some_and(|v| v == "import") { bail!("injected import failure (PUNKTFUNK_HW_FAULT=import)"); } - if frame.fourcc != DRM_FORMAT_NV12 { - bail!("hw presenter handles NV12 only (got {:#x})", frame.fourcc); - } + let (luma_fmt, chroma_fmt) = match 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 { - 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- // modifier images can't — LINEAR is the only honest guess (debug-visible if wrong). @@ -102,7 +114,7 @@ pub fn import( ext_mem_fd, frame.width, frame.height, - vk::Format::R8_UNORM, + luma_fmt, y.fd, y.offset, y.stride, @@ -114,7 +126,7 @@ pub fn import( ext_mem_fd, frame.width.div_ceil(2), frame.height.div_ceil(2), - vk::Format::R8G8_UNORM, + chroma_fmt, c.fd, c.offset, c.stride, @@ -159,14 +171,14 @@ pub fn import( device.free_memory(luma_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, Err(e) => { destroy_images(&[]); 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, Err(e) => { destroy_images(&[luma_view]); @@ -180,6 +192,7 @@ pub fn import( color: frame.color, width: frame.width, height: frame.height, + fourcc: frame.fourcc, images: [luma_img, chroma_img], memories: [luma_mem, chroma_mem], views: [luma_view, chroma_view], diff --git a/crates/pf-presenter/src/vk.rs b/crates/pf-presenter/src/vk.rs index 66b0aacd..a4c162de 100644 --- a/crates/pf-presenter/src/vk.rs +++ b/crates/pf-presenter/src/vk.rs @@ -336,6 +336,12 @@ pub struct Presenter { /// gone through a fence cycle. retired_display: Vec, format: vk::SurfaceFormatKHR, + /// The surface's HDR10/ST.2084 pairing, when the stack offers one. + hdr10_format: Option, + /// 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, swapchain: vk::SwapchainKHR, images: Vec, @@ -368,6 +374,16 @@ impl Presenter { let app_info = vk::ApplicationInfo::default() .application_name(&app_name) .api_version(vk::API_VERSION_1_3); + // HDR10 presentation needs the extended colorspaces at the INSTANCE level. + let mut instance_extensions: Vec = 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 = instance_extensions .iter() .map(|e| CString::new(e.as_str()).unwrap()) @@ -551,7 +567,7 @@ impl Presenter { } else { 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 // decoder chain skips straight to VAAPI/software). Extension lists must mirror @@ -587,9 +603,10 @@ impl Presenter { 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)?; - 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 cmd_pool = unsafe { @@ -635,6 +652,9 @@ impl Presenter { retired_hw: None, retired_display: Vec::new(), format, + hdr10_format, + hdr_active: false, + video_format: vk::Format::R8G8B8A8_UNORM, present_mode, swapchain: vk::SwapchainKHR::null(), 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 /// 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 @@ -791,6 +859,22 @@ impl Presenter { if self.extent.width == 0 || self.extent.height == 0 { 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 // driver rejects must fail out here, before this present consumed the acquire // semaphore. @@ -910,7 +994,8 @@ impl Presenter { width: v.width, 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 @@ -932,7 +1017,9 @@ impl Presenter { width: v.width, 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); } @@ -1168,6 +1255,8 @@ impl Presenter { framebuffer: vk::Framebuffer, extent: vk::Extent2D, color: pf_client_core::video::ColorDesc, + depth: u8, + msb_packed: bool, ) { unsafe { self.device.cmd_begin_render_pass( @@ -1214,8 +1303,19 @@ impl Presenter { &[self.csc.desc_set], &[], ); - let rows = csc_rows(color); - let bytes = std::slice::from_raw_parts(rows.as_ptr().cast::(), 48); + let rows = csc_rows(color, depth, msb_packed); + // 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::().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::(), 64); self.device.cmd_push_constants( self.cmd_buf, self.csc.pipeline_layout, @@ -1228,16 +1328,25 @@ impl Presenter { } } - /// Per-plane views over a Vulkan-Video frame's multiplanar image (R8 luma + - /// R8G8 chroma — the CSC pass's exact sampling contract; the frames pool was - /// created MUTABLE_FORMAT for this). NV12 only, like the rest of the pipeline. + /// Per-plane views over a Vulkan-Video frame's multiplanar image — the CSC pass's + /// exact sampling contract (the frames pool was created MUTABLE_FORMAT for this). + /// 8-bit NV12 (R8 + R8G8) and 10-bit P010/X6 (R10X6 + R10X6G10X6). 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!( - "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 ); - } + }; // img[0] is creation-constant (only the sync fields need the frames lock). let image = vk::Image::from_raw( unsafe { (*(f.vkframe as *const pf_ffvk::AVVkFrame)).img[0] } as u64, @@ -1260,8 +1369,8 @@ impl Presenter { } .context("vk-frame plane view") }; - let luma = make(vk::ImageAspectFlags::PLANE_0, vk::Format::R8_UNORM)?; - let chroma = match make(vk::ImageAspectFlags::PLANE_1, vk::Format::R8G8_UNORM) { + let luma = make(vk::ImageAspectFlags::PLANE_0, luma_fmt)?; + let chroma = match make(vk::ImageAspectFlags::PLANE_1, chroma_fmt) { Ok(v) => v, Err(e) => { unsafe { self.device.destroy_image_view(luma, None) }; @@ -1318,7 +1427,7 @@ impl Presenter { self.device.create_image( &vk::ImageCreateInfo::default() .image_type(vk::ImageType::TYPE_2D) - .format(vk::Format::R8G8B8A8_UNORM) + .format(self.video_format) .extent(vk::Extent3D { width, height, @@ -1347,7 +1456,7 @@ impl Presenter { &vk::ImageViewCreateInfo::default() .image(image) .view_type(vk::ImageViewType::TYPE_2D) - .format(vk::Format::R8G8B8A8_UNORM) + .format(self.video_format) .subresource_range(subresource_range()), None, ) @@ -1515,24 +1624,47 @@ fn pick_device( 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 -/// display-referred, the blit must not re-encode it. -fn pick_format( +/// display-referred, the blit must not re-encode it. HDR: a 10-bit UNORM format paired +/// 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, pdev: vk::PhysicalDevice, surface: vk::SurfaceKHR, -) -> Result { + colorspace_ext: bool, +) -> Result<(vk::SurfaceFormatKHR, Option)> { 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] { - if let Some(f) = formats.iter().find(|f| f.format == want) { - return Ok(*f); + if let Some(f) = formats + .iter() + .find(|f| f.format == want && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR) + { + sdr = Some(*f); + break; } } - formats - .first() - .copied() - .ok_or_else(|| anyhow!("surface offers no formats")) + let sdr = sdr + .or_else(|| formats.first().copied()) + .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 — @@ -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::(), 12) } +} + /// 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) { let (ew, eh) = (f64::from(extent.width), f64::from(extent.height)); diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs index 93d84a45..92c0a4cf 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs @@ -8,15 +8,15 @@ //! 1. `hyprctl output create headless PF-` adds a named headless output — Hyprland supports //! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll //! `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 → -//! Lua), so [`set_monitor_rule`] tries both eras (D5): `hyprctl keyword monitor NAME,WxH@Hz,…` -//! (≤0.54, still loads on 0.55 during the deprecation window) and `hyprctl eval 'hl.monitor{…}'` -//! (≥0.55), ordered by the detected version and falling back to the other. +//! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword +//! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current +//! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a +//! 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 //! 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 -//! shim that cats a per-session selection file we write (`screen:`) right before the -//! handshake — byte-for-byte the xdpw pattern, different picker wire format. +//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny +//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:`) +//! 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 //! `hyprctl output remove NAME`. //! @@ -25,10 +25,12 @@ //! `$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`). //! -//! ⚠ **Spike-pending contract** (`design/hyprland-support.md` Phase 0): the xdph `custom_picker_binary` -//! stdout format and config path, and the `hl.monitor{}` Lua signature, are documented best-guesses -//! here. A wrong picker line degrades to the interactive picker (per the plan's risk table), and -//! `set_monitor_rule` tries both eras, so a wrong guess self-heals rather than breaking capture. +//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0): +//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:` picker format, the +//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs +//! 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 anyhow::{anyhow, bail, Context, Result}; @@ -36,7 +38,7 @@ use std::os::fd::OwnedFd; use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::Sender; -use std::sync::{Arc, Once, OnceLock}; +use std::sync::{Arc, Once}; use std::thread; use std::time::{Duration, Instant}; @@ -58,12 +60,13 @@ fn picker_shim_path() -> String { format!("{dir}/punktfunk-xdph-picker.sh") } -/// The picker line for output `name`. ⚠ Spike-pending: xdph's custom-picker stdout contract is -/// `screen:` (vs `window:` / `region:…`) to the best of current knowledge — this -/// is the single place to correct once the Phase-0 spike pins it. A wrong value just means xdph -/// falls back to its interactive picker (degraded, not broken). +/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on +/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker +/// followed by `screen:` (or `window:` / `region:…`); anything else is rejected as +/// "strange output" and falls back to the interactive picker. So a monitor selection is +/// `[SELECTION]screen:`. 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 @@ -126,6 +129,9 @@ pub fn probe() -> Result<()> { "hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \ 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(); Ok(()) } @@ -261,6 +267,10 @@ fn hyprctl_dispatch(args: &[&str]) -> Result<()> { || lc.contains("unknown") || lc.contains("no such") || 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); } @@ -297,10 +307,17 @@ fn monitor_exists(name: &str) -> Result { } /// 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) -/// prefers `hyprctl eval 'hl.monitor{…}'`. Whichever era we detect, we try its form first and fall -/// back to the other — hyprlang configs still load during the 0.55 deprecation window, so `keyword` -/// keeps working for a while, and the fallback makes a wrong version guess self-heal. +/// era split (D5). `hyprctl keyword monitor NAME,WxH@Hz,auto,1` is the hyprlang path — the default +/// config manager on **every** current release, including ≥0.55 (verified on 0.55.4: version does +/// NOT imply the Lua era — a stock 0.55.4 rejects `eval` with "only supported with the lua config +/// 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<()> { let hz = mode.refresh_hz.max(1); 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 eval: Vec<&str> = vec!["eval", &lua]; - let attempts: [&Vec<&str>; 2] = if hyprland_version_at_least(0, 55) { - [&eval, &keyword] - } else { - [&keyword, &eval] - }; - let mut errs = Vec::new(); - for a in attempts { - if let Err(e) = hyprctl_dispatch(a) { - errs.push(format!("{a:?}: {e:#}")); + for a in [&keyword, &eval] { + // A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to + // the other form. A command that's accepted then has up to the timeout to take effect. + if hyprctl_dispatch(a).is_err() { continue; } - // Confirm the monitor actually adopted the mode — some versions print `ok` for a command - // they silently ignored (e.g. a Lua form the era doesn't accept), which would otherwise - // 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)) { + if wait_exact_mode(name, mode, Duration::from_millis(1500)) { + tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode"); return Ok(()); } - errs.push(format!( - "{a:?}: dispatched but monitor never adopted {}x{}", - mode.width, mode.height - )); } - bail!( - "hyprctl monitor rule failed on both config eras: {}", - errs.join(" | ") - ) + // Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a + // warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all). + 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), -/// up to `timeout`. Logs which command form finally took. Returns `false` on timeout. -fn wait_mode_applied(attempt: &[&str], name: &str, mode: Mode, timeout: Duration) -> bool { +/// Poll until monitor `name` reports exactly `mode`'s width×height (the rule applies asynchronously), +/// up to `timeout`. Returns `false` on timeout. +fn wait_exact_mode(name: &str, mode: Mode, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { - if monitor_has_mode(name, mode).unwrap_or(false) { - tracing::debug!(output = %name, cmd = ?attempt, "monitor adopted the requested mode"); + if matches!(monitor_size(name), Ok(Some((w, h))) if w == mode.width as u64 && h == mode.height as u64) + { return true; } 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 -/// width×height (the capture-critical dimension); the fractional `refreshRate` isn't compared. -fn monitor_has_mode(name: &str, mode: Mode) -> Result { - let out = hyprctl(&["-j", "monitors"])?; +/// Monitor `name`'s current `(width, height)` from `hyprctl -j monitors all` (includes a disabled +/// output), or `None` if it isn't present. A freshly-created headless output reports `0×0` until a +/// mode commits. +fn monitor_size(name: &str) -> Result> { + let out = hyprctl(&["-j", "monitors", "all"])?; let monitors: serde_json::Value = serde_json::from_str(&out).context("parse hyprctl -j monitors")?; let Some(arr) = monitors.as_array() else { - return Ok(false); + return Ok(None); }; for m in arr { 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 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`), -/// cached for the process. A compositor upgrade + restart mid-host-life would leave this stale, but -/// [`set_monitor_rule`] tries both eras regardless, so the cache is an optimization, not correctness. +/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.4`), +/// for a diagnostic log — the mode-rule path is version-independent (see [`set_monitor_rule`]). fn hyprland_version() -> Option<(u16, u16, u16)> { - static V: OnceLock> = OnceLock::new(); - *V.get_or_init(|| { - let out = hyprctl(&["-j", "version"]).ok()?; - let json: serde_json::Value = serde_json::from_str(&out).ok()?; - parse_version_tag(json.get("tag").and_then(|t| t.as_str())?) - }) + let out = hyprctl(&["-j", "version"]).ok()?; + 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)> { let t = tag.trim().trim_start_matches(['v', 'V']); let mut it = t.split(['.', '-', '_', '+']); @@ -395,12 +414,6 @@ fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> { 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 /// `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. @@ -579,7 +592,8 @@ mod tests { } #[test] - fn picker_line_is_screen_scoped() { - assert_eq!(picker_selection_line("PF-1"), "screen:PF-1\n"); + fn picker_line_carries_the_selection_marker() { + // 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"); } } diff --git a/docs-site/content/docs/hyprland.md b/docs-site/content/docs/hyprland.md index 2752db7c..fa6fcd06 100644 --- a/docs-site/content/docs/hyprland.md +++ b/docs-site/content/docs/hyprland.md @@ -34,9 +34,10 @@ See [Configuration](/docs/configuration) for the full reference. ## How it works - **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 - are supported: `hyprctl keyword monitor …` (≤ 0.54) and the Lua `hyprctl eval 'hl.monitor{…}'` - (≥ 0.55), selected from `hyprctl version`. + client's exact mode. Outputs are **named**, so there's no before/after diffing. The rule uses + `hyprctl keyword monitor …` (the hyprlang config manager — the default on every release, 0.55 + 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 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