From 479f0965ee72471f456113a22fa88bbe568c23c1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 15:00:37 +0200 Subject: [PATCH] feat(encode/vulkan): Vulkan Video encodes 10-bit, so AMD/Intel HDR keeps the good path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vulkan Video backend was 8-bit for no structural reason — the API has `VK_VIDEO_COMPONENT_BIT_DEPTH_10_BIT` and `PROFILE_IDC_MAIN_10` in the very fields this pinned to 8 and MAIN, and AMD VCN and Intel both encode Main10. It was six hardcoded sites, and the cost of leaving them was paid twice over: an HDR session had to take libav VAAPI, losing real RFI loss recovery AND the compute CSC's cursor blend — which on gamescope is the only way the pointer reaches the stream at all, since gamescope has no embedded-cursor mode. An HDR session now opens a Main10 profile with 10-bit component depths, a `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying `bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet instead of BT.709. `rgb2yuv10.comp` is the CSC's twin, and the two interesting parts of it are: * it is a PURE 3x3 matrix. The samples arrive already PQ-encoded (gamescope composites into the PQ container), so BT.2020 NCL applies to the code values as they are — there is no transfer function to apply here and applying one would be wrong; * the scratch planes are `R16`/`RG16`, not the picture's plane formats. The 10-bit ycbcr plane formats are not storage-image formats, so the shader writes the value into the HIGH bits by hand (`code10 << 6`, hence the `64/65535` factor and not `1/1023`) into planes that are merely SIZE-compatible with the picture's — which is all `vkCmdCopyImage` requires. Scope and safety: * HEVC only. AV1 10-bit encode has far thinner driver coverage, and a session open is not the place to gamble on it — those stay on VAAPI, as does a device that fails the Main10 profile query inside the open (the pre-existing "failed Vulkan open falls back to VAAPI" net, no new probe needed). * HDR pins the compute-CSC arm over the EFC RGB-direct one, which the EFC could not serve anyway: its fixed-function conversion is 8-bit BT.709 narrow with no knob for BT.2020. * `open_inner` binds `hdr` to the parameter-set HEADER bytes, so the depth flag is `ten_bit` there — the one name collision this change had to route around. --- crates/pf-encode/src/enc/linux/rgb2yuv10.comp | 70 +++++++++++ crates/pf-encode/src/enc/linux/rgb2yuv10.spv | Bin 0 -> 7008 bytes crates/pf-encode/src/enc/linux/vk_build.rs | 66 ++++++++--- crates/pf-encode/src/enc/linux/vk_util.rs | 18 +++ .../pf-encode/src/enc/linux/vulkan_video.rs | 112 ++++++++++++++---- crates/pf-encode/src/lib.rs | 102 +++++++--------- docs-site/content/docs/gamescope.md | 7 +- docs/releases/v0.21.0.md | 8 +- 8 files changed, 278 insertions(+), 105 deletions(-) create mode 100644 crates/pf-encode/src/enc/linux/rgb2yuv10.comp create mode 100644 crates/pf-encode/src/enc/linux/rgb2yuv10.spv diff --git a/crates/pf-encode/src/enc/linux/rgb2yuv10.comp b/crates/pf-encode/src/enc/linux/rgb2yuv10.comp new file mode 100644 index 00000000..9efa1750 --- /dev/null +++ b/crates/pf-encode/src/enc/linux/rgb2yuv10.comp @@ -0,0 +1,70 @@ +#version 450 +// The 10-bit HDR twin of `rgb2yuv.comp`: packed 2:10:10:10 RGB -> 10-bit 4:2:0 (BT.2020 NCL, +// limited range), for an HEVC Main10 session off a gamescope HDR capture. +// +// The source samples are ALREADY PQ-encoded BT.2020 R'G'B' (gamescope composites into the PQ +// container; see packaging/gamescope), so this is a pure 3x3 matrix on the code values — there is +// no transfer function to apply here, and applying one would be wrong. That is the whole +// difference from the SDR shader besides the coefficients and the store width. +// +// STORE LAYOUT. The scratch planes are `r16`/`rg16` and get `vkCmdCopyImage`d into the +// `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture, whose plane texels put the 10-bit value in +// the HIGH bits of a 16-bit word (`X6` = 6 unused low bits). So a written normalized value `f` +// lands as `round(f * 65535)` and must equal `code10 << 6` — hence the `CODE10` factor below, +// NOT `1/1023`. Getting this wrong is a ~1.6% luminance error: invisible on a test pattern, +// visible as crushed blacks on real content. +// +// Rebuild: glslangValidator -V rgb2yuv10.comp -o rgb2yuv10.spv (vendored beside this file) +layout(local_size_x = 8, local_size_y = 8) in; +layout(binding = 0) uniform sampler2D rgb; // packed 2:10:10:10 input (sampled) +layout(binding = 1, r16) uniform writeonly image2D yImg; // full-res Y (10-bit in the high bits) +layout(binding = 2, rg16) uniform writeonly image2D uvImg; // half-res UV (interleaved) +layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left) + +layout(push_constant) uniform Push { + ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot) + ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled +} pc; + +// 10-bit code value -> the normalized float that stores it in the high bits of a 16-bit texel. +const float CODE10 = 64.0 / 65535.0; + +// BT.2020 non-constant-luminance, limited range, expressed directly in 10-bit code values: +// Y' = 64 + 876*(0.2627R + 0.6780G + 0.0593B) +// Cb = 512 + 896*(B' - Y')/1.8814 +// Cr = 512 + 896*(R' - Y')/1.4746 +float lumaY(vec3 c) { return 64.0 + 230.1252*c.r + 593.9280*c.g + 51.9468*c.b; } + +// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle. +// The cursor bitmap is 8-bit sRGB and the frame is PQ, so this is a display-referred +// approximation — the same one the CPU path (`pw_cursor.rs::composite_cursor_rgb10`) and the +// CUDA path (`cursor_blend.comp` MODE 3/4) already make. Fine for a pointer. +vec3 withCursor(ivec2 p, vec3 col) { + if (pc.curSize.x <= 0) return col; + ivec2 cp = p - pc.curOrigin; + if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col; + vec4 c = texelFetch(cursorTex, cp, 0); + return mix(col, c.rgb, c.a); +} + +// Source may be SMALLER than the coded (16-aligned) Y plane — clamp every fetch to the source +// edge so the alignment-padding rows duplicate the last real row (see the SDR shader). +void main() { + ivec2 sz = imageSize(yImg); + ivec2 rmax = textureSize(rgb, 0) - 1; + ivec2 uvc = ivec2(gl_GlobalInvocationID.xy); + ivec2 p = uvc * 2; + if (p.x >= sz.x || p.y >= sz.y) return; + vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb); + vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb); + vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb); + vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb); + imageStore(yImg, p, vec4(lumaY(c00) * CODE10, 0, 0, 1)); + imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10) * CODE10, 0, 0, 1)); + imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01) * CODE10, 0, 0, 1)); + imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11) * CODE10, 0, 0, 1)); + vec3 a = (c00 + c10 + c01 + c11) * 0.25; + float U = 512.0 - 125.1085*a.r - 322.8915*a.g + 448.0000*a.b; + float V = 512.0 + 448.0000*a.r - 411.9680*a.g - 36.0320*a.b; + imageStore(uvImg, uvc, vec4(U * CODE10, V * CODE10, 0, 1)); +} diff --git a/crates/pf-encode/src/enc/linux/rgb2yuv10.spv b/crates/pf-encode/src/enc/linux/rgb2yuv10.spv new file mode 100644 index 0000000000000000000000000000000000000000..8df0a6b38f0cf326fcb3a3bbf30c530bbf296ff4 GIT binary patch literal 7008 zcmZ{n37A)96~-^KvG2QVGc1CM!wjG(7$Q(eQBbI58_zJnFg3%_zzm^QqbRm%`@TKX zN{f-%B1L7Eff4OV3nkb>J8gy{TEE|&d+?k8V|SkOeDC|7^PTT}=YIG8=a!dE>zieL zvVmE@Y;K<{zlLXJC|TxMUyO4Z2WC~dzGBtdRkho;Z>XJrVVx6(WaSRIZzwU9;0mm@ zp?Qn?ZfqF3+HkC2VXjfmUm4>)kV+!bd6g&w@wIfcHe55SbJM&<#*}9R&>FGItZz1w zaYys^&C5F4x3#s;>TI67C=WEwwYKJQ<=F_v#*6&<-I=3F~T;oR)Wn2yQS+{r$sEV-~8_WE@jB@?e zaQCaG)xi5R>Q^5PAIw;du}nUH2&0<0oj;V(x#~6acUZyI&2c_4<{OP=S=Z9wV_<`; zH!X9GvEXXVOTT3s`ehS%L;C+ zIpv;juUNzL?B$+r%Eu?p*UfP2RX1L~yU>ik1?>HC&FJN=1)t4%vW~r&{kO-VeXZc? zp&bUxHFLSQ2e7WJYhm!?aIfGG=B(?o;1zjuyA}uUoAWF`XXfh%zjeXsv*&m)d{TS& zvWOp%#~(Y(C&EuWdiX4_f!|v_Y^nV}7ySSFKZv!wr!$H1GS>ddeFu2CroF3!`>f3- zn|=13pr-j;Ttpx3Zw2P%{eKvxlF^()8U5Yz>;=ERgzqZh{su+7Yj-Eye0vhE-`}D% z{`Q0$@9$CS-&ex-C*1f02{-@267FwO2n?Lv6}06ZeD}=Y_6u#TNqzkXzE^UfjK?(z7DM4Jf01?dECnz zFtw=VHn7jMc2iNy8^OluGl%C%?Q;A}7~h1c`8#0V!HjRl)Fb9CV0)FuycMp#mps0Q z--bC)eGhrm-j1oCV%>K#dY#6n{tisd=R+K8y$ftTK2K54yTR%a^B%DEq%rS>t4BTW z13ORs5!Q&c93P_Z*7ttQ`g}f(w+?IlAfuPH8uI~6&058eafife*67*!5a!v5d-Gwi z`w0FKu;*Y9YkTiLig}-BV#ex^bN4Z@`PLEJ!1!@Y%{gV5=P&Yp0_-~8+dYi()x4#A z20n#3SMzc`=Ns#LLjNVa%Z&XrHWD+3K4ZEW)nl#CfL*JrI9s0utL?>%u@84(X&*iZ zPv_hTSBp9J;`3PAn=inlH^%Fa-h2`48qu3Cfz_N7y|MPY;MNzt_%hg7%`5H2KDhqq z#aF=g!W{a{=X+E=dhu0oy05Rp)%?ATUfc~%d+`l;I_Dm^TFkK*-^9{hdgF~?r~2upkMV|et!c>U3fpMYH>dht`Rnsd@#+z+?D=*7># z#%f+^FOI}9T~B}1^ILFQ&+p)A-+vERi|5fA|A2Yk zt?@*`qsBkNtx-K{{1aF`t?|!r_0k%x$2Ijwjeh~BHU1Tz*7!HLT0AG#_;<{6XpJWe z9yR_0ZjI_u<3GXbX^sDatC!YjJ+7%gYWz1it??mvTH}AAk@!@=g$7r91)%{3#*H405Va*YQ2y`gRotbGjFeEQt4 z|A(qY-f`g4{ffXFblt z1#t65Of6W=n0WWifjd{>BAAt%0jYuBqV3n0n-z1~#9*$aOB*TU|L0!7=;i<1`YyuMTwm-NaovkC>vBJ0 z{l>+)TLg9w_U_VL@2SUiB4%-+$M@Q0aObIew%wzeIhN#OmTT#LmKORQ#eSB-)m$gO zf0l#Q-QUw0z1*Mr(=avb7spyFz^)(X#@=0lsmEF?3(d9swuwBB@on=Auxr~J<6R@_ zdL~%Ebv=vG%evH8VQSVTj=G)=wyv}XtKsT#@7I8z?A|{IU0-~gJs0d;b?cAy9HaiV zU~69&ex=qd2zIWz z^~ZXSQU8m;*65y$w-)Pt38RRx>?^Vw&A=eJ_Uw_)a} kVthHKz6Rzq++Jwv+MwclbsN}x-UI7!En|Jw_4)4lAIe;LkN^Mx literal 0 HcmV?d00001 diff --git a/crates/pf-encode/src/enc/linux/vk_build.rs b/crates/pf-encode/src/enc/linux/vk_build.rs index 8cd5443b..159b3ef3 100644 --- a/crates/pf-encode/src/enc/linux/vk_build.rs +++ b/crates/pf-encode/src/enc/linux/vk_build.rs @@ -225,6 +225,7 @@ pub(super) unsafe fn make_frame( with_ts: bool, csc: bool, pad_fmt: Option, + hdr: bool, f: &mut Frame, ) -> Result<()> { // "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`). @@ -263,6 +264,7 @@ pub(super) unsafe fn make_frame( csc_dsl, csc_pool, sampler, + hdr, f, )?; } @@ -291,13 +293,15 @@ unsafe fn make_frame_csc( csc_dsl: vk::DescriptorSetLayout, csc_pool: vk::DescriptorPool, sampler: vk::Sampler, + hdr: bool, f: &mut Frame, ) -> Result<()> { - // NV12 encode-src (filled by the CSC copy) — concurrent compute+encode. + // 4:2:0 encode-src (filled by the CSC copy) — concurrent compute+encode. + let pic = yuv_format(hdr); (f.nv12_src, f.nv12_mem) = make_video_image( device, mem_props, - NV12, + pic, w, h, 1, @@ -305,12 +309,22 @@ unsafe fn make_frame_csc( profile_list, fams, )?; - f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?; - // CSC scratch (Y R8 full-res, UV RG8 half-res). + f.nv12_view = make_view(device, f.nv12_src, pic, 0)?; + // CSC scratch: Y full-res + UV half-res, in a single-plane format the shader can declare as a + // storage image AND that is SIZE-COMPATIBLE with the picture's planes (`vkCmdCopyImage` + // between differing formats requires equal texel-block size). 8-bit: R8/RG8 vs the NV12 + // planes' 1/2 bytes. 10-bit: R16/RG16 vs the 3PACK16 planes' 2/4 bytes — the 10-bit ycbcr + // plane formats themselves are not storage-image formats, which is why the scratch is 16-bit + // and `rgb2yuv10.comp` writes the value into the high bits by hand. + let (y_fmt, uv_fmt) = if hdr { + (vk::Format::R16_UNORM, vk::Format::R16G16_UNORM) + } else { + (vk::Format::R8_UNORM, vk::Format::R8G8_UNORM) + }; (f.y_img, f.y_mem, f.y_view) = make_plain_image( device, mem_props, - vk::Format::R8_UNORM, + y_fmt, w, h, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC, @@ -318,7 +332,7 @@ unsafe fn make_frame_csc( (f.uv_img, f.uv_mem, f.uv_view) = make_plain_image( device, mem_props, - vk::Format::R8G8_UNORM, + uv_fmt, w / 2, h / 2, vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC, @@ -479,12 +493,20 @@ pub(super) unsafe fn build_parameters_h265( rw: u32, rh: u32, quality_level: u32, + // 10-bit HDR session: Main10 + BT.2020/PQ colour signalling. Must agree with the video + // profile the session was CREATED with (`open_inner`'s `ten_bit`) — a Main SPS on a Main10 + // session is a bitstream that says one thing and carries another. + ten_bit: bool, ) -> Result<(vk::VideoSessionParametersKHR, Vec)> { use ash::vk::native as hh; let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed(); ptl.flags.set_general_progressive_source_flag(1); ptl.flags.set_general_frame_only_constraint_flag(1); - ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN; + ptl.general_profile_idc = if ten_bit { + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10 + } else { + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN + }; ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0; let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed(); @@ -505,6 +527,11 @@ pub(super) unsafe fn build_parameters_h265( sps.pic_width_in_luma_samples = w; sps.pic_height_in_luma_samples = h; sps.log2_max_pic_order_cnt_lsb_minus4 = 4; + // Main10's `bit_depth_*_minus8 = 2`. Zeroed (= 8-bit) for Main, as before. + if ten_bit { + sps.bit_depth_luma_minus8 = 2; + sps.bit_depth_chroma_minus8 = 2; + } sps.log2_diff_max_min_luma_coding_block_size = 3; sps.log2_diff_max_min_luma_transform_block_size = 3; sps.max_transform_hierarchy_depth_inter = 4; @@ -517,25 +544,26 @@ pub(super) unsafe fn build_parameters_h265( sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2 } - // Colour signalling. This backend's CSC (`rgb2yuv.comp`) is BT.709 LIMITED 8-bit and nothing - // else — `open_amd_intel` routes every HDR session to VAAPI precisely because this path - // hardcodes it — so the SPS can state it as a constant. Without the VUI the stream is - // "unspecified" and each decoder applies its own default: the punktfunk clients fall back to - // BT.709 (`pf_client_core::video_color::csc_rows`), but vendor TV decoders guess from - // RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly - // washed out. Every sibling backend (NVENC `nvenc_core.rs`, VAAPI, QSV, the Windows libav - // path) already signals this triplet; this one was the hole. + // Colour signalling, exactly what this session's CSC produced: `rgb2yuv.comp` is BT.709 + // limited 8-bit, `rgb2yuv10.comp` is BT.2020 NCL limited 10-bit with a PQ transfer (the + // samples arrive PQ-encoded from the compositor and the matrix does not touch the transfer). + // Without the VUI the stream is "unspecified" and each decoder applies its own default: the + // punktfunk clients fall back to BT.709 (`pf_client_core::video_color::csc_rows`), but vendor + // TV decoders guess from RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and + // renders it visibly washed out. // // `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only // copies the pointer and both live to the end of this function. let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed(); vui.flags.set_video_signal_type_present_flag(1); - vui.flags.set_video_full_range_flag(0); // limited/studio swing (16-235 luma) + vui.flags.set_video_full_range_flag(0); // limited/studio swing vui.flags.set_colour_description_present_flag(1); vui.video_format = 5; // unspecified — the CICP triplet below is what matters - vui.colour_primaries = 1; // BT.709 - vui.transfer_characteristics = 1; // BT.709 - vui.matrix_coeffs = 1; // BT.709 + // CICP code points: 1 = BT.709, 9 = BT.2020 primaries / BT.2020 NCL matrix, 16 = SMPTE 2084. + let (prim, trc, mat) = if ten_bit { (9, 16, 9) } else { (1, 1, 1) }; + vui.colour_primaries = prim; + vui.transfer_characteristics = trc; + vui.matrix_coeffs = mat; sps.flags.set_vui_parameters_present_flag(1); sps.pSequenceParameterSetVui = &vui; diff --git a/crates/pf-encode/src/enc/linux/vk_util.rs b/crates/pf-encode/src/enc/linux/vk_util.rs index a46d9335..81b6256f 100644 --- a/crates/pf-encode/src/enc/linux/vk_util.rs +++ b/crates/pf-encode/src/enc/linux/vk_util.rs @@ -50,9 +50,23 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option { const XB24: u32 = 0x3432_4258; // XBGR8888 const AB24: u32 = 0x3432_4241; // ABGR8888 const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12 + // The 10-bit HDR capture formats. DRM packs these as a little-endian 32-bit word — XR30 is + // x:R:G:B with B in bits 0-9 — which is exactly Vulkan's `A2R10G10B10_UNORM_PACK32` bit + // layout, so the mapping is an identity on the word and not a byte swizzle like the 8-bit + // pair above. + // + // ⚠ Only `A2B10G10R10_UNORM_PACK32` is a Vulkan-mandatory SAMPLED format; `A2R10G10B10` is + // optional (widely supported on RADV/ANV, and we only ever `texelFetch` it — no filtering). + // Both are offered to the producer, so which one a session lands on is the producer's pick; + // if a device ever rejects the XR30 import, the fix is to drop that format from the capture + // offer rather than to convert here. + const XR30: u32 = 0x3033_5258; // DRM_FORMAT_XRGB2101010 + const XB30: u32 = 0x3033_4258; // DRM_FORMAT_XBGR2101010 match fourcc { XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM), XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM), + XR30 => Some(vk::Format::A2R10G10B10_UNORM_PACK32), + XB30 => Some(vk::Format::A2B10G10R10_UNORM_PACK32), NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM), _ => None, } @@ -62,6 +76,10 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option { match fmt { PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM), PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM), + // The packed 10-bit PQ/BT.2020 capture formats (an HDR gamescope output). Sampling one + // yields the PQ code values normalized to [0,1] — which is what `rgb2yuv10.comp` wants. + PixelFormat::X2Rgb10 => Some(vk::Format::A2R10G10B10_UNORM_PACK32), + PixelFormat::X2Bgr10 => Some(vk::Format::A2B10G10R10_UNORM_PACK32), _ => None, } } diff --git a/crates/pf-encode/src/enc/linux/vulkan_video.rs b/crates/pf-encode/src/enc/linux/vulkan_video.rs index 51ee8d90..0dd57083 100644 --- a/crates/pf-encode/src/enc/linux/vulkan_video.rs +++ b/crates/pf-encode/src/enc/linux/vulkan_video.rs @@ -4,8 +4,10 @@ //! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a //! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain. //! -//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→NV12 -//! BT.709 compute CSC, then encodes. Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`. +//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→4:2:0 +//! compute CSC, then encodes — 8-bit BT.709 for an SDR session (`rgb2yuv.comp`), 10-bit BT.2020 +//! for an HDR one (`rgb2yuv10.comp` + an HEVC Main10 session; a 10-bit AV1 session is routed to +//! VAAPI instead). Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`. //! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op. //! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`. #![allow(clippy::too_many_arguments)] @@ -23,12 +25,31 @@ use std::ffi::c_void; use std::os::fd::AsRawFd; const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM; +/// The 10-bit 4:2:0 picture/DPB format an HDR (HEVC Main10) session encodes from. `3PACK16` +/// stores each 10-bit sample in the HIGH bits of a 16-bit word — see `rgb2yuv10.comp`, whose +/// scratch planes are the size-compatible `R16`/`RG16` this gets `vkCmdCopyImage`d from. +const P010: vk::Format = vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16; + +/// The session's 4:2:0 picture + DPB format for its bit depth. One function so the session +/// create-info, the DPB image, its views and the per-frame encode source cannot drift apart — +/// they must all name the SAME format or session creation fails. +const fn yuv_format(hdr: bool) -> vk::Format { + if hdr { + P010 + } else { + NV12 + } +} /// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing /// buffers so this holds handles, not new allocations). const IMPORT_CACHE_CAP: usize = 16; // Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file; // regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader. const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv"); +/// The 10-bit HDR twin (`rgb2yuv10.comp`): packed 2:10:10:10 PQ/BT.2020 RGB → 10-bit 4:2:0, +/// BT.2020 NCL limited. Separate module rather than a spec constant because a shader's storage +/// image FORMAT (`r8`/`rg8` vs `r16`/`rg16`) is part of its layout, not specializable. +const CSC10_SPV: &[u8] = include_bytes!("rgb2yuv10.spv"); /// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads /// into the top-left and the shader's push constant bounds sampling, so one allocation fits every /// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant. @@ -463,6 +484,9 @@ struct Frame { uv_img: vk::Image, uv_mem: vk::DeviceMemory, uv_view: vk::ImageView, + /// The CSC's output picture and this frame's encode source: NV12, or the 10-bit + /// `P010`/3PACK16 twin on an HDR session ([`yuv_format`]). The name predates the second + /// depth; every use goes through the format the session was created with. nv12_src: vk::Image, nv12_mem: vk::DeviceMemory, nv12_view: vk::ImageView, @@ -669,16 +693,30 @@ impl VulkanVideoEncoder { cursor_blend: bool, ) -> Result { let native_nv12 = format == PixelFormat::Nv12; - // A cursor-blend session must keep the compute-CSC path — the only arm with the cursor - // blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the - // negotiation promised the client a composited pointer). - if cursor_blend && rgb_request() == Some(true) { - tracing::info!( - "PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \ - pointer, which the EFC front-end cannot; using the compute-CSC path" + // HDR: a packed 10-bit PQ/BT.2020 capture (a gamescope output off our `pipewire-hdr` + // build) encodes HEVC Main10 through the 10-bit compute CSC. HEVC only — AV1 10-bit + // encode has far thinner driver coverage, and `open_amd_intel` keeps those sessions on + // libav VAAPI rather than gambling a session open on it. + let hdr = format.is_hdr_rgb10(); + if hdr && codec != Codec::H265 { + bail!( + "vulkan-encode: 10-bit HDR is HEVC Main10 only here (got {codec:?}) — the \ + dispatcher should have routed this session to VAAPI" ); } - let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true); + // A cursor-blend session must keep the compute-CSC path — the only arm with the cursor + // blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the + // negotiation promised the client a composited pointer). HDR pins the same arm for a + // different reason: the EFC's fixed-function conversion is 8-bit BT.709 narrow with no + // knob for BT.2020, so it cannot express this session's colourimetry at all. + if (cursor_blend || hdr) && rgb_request() == Some(true) { + tracing::info!( + hdr, + "PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — the EFC front-end can \ + neither composite a pointer nor convert BT.2020 10-bit; using the compute-CSC path" + ); + } + let want_rgb = !native_nv12 && !cursor_blend && !hdr && rgb_request().unwrap_or(true); Self::open_opts_inner( codec, width, @@ -687,6 +725,7 @@ impl VulkanVideoEncoder { bitrate_bps, want_rgb, native_nv12, + hdr, ) } @@ -704,9 +743,19 @@ impl VulkanVideoEncoder { bitrate_bps: u64, want_rgb: bool, ) -> Result { - Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false) + Self::open_opts_inner( + codec, + width, + height, + fps, + bitrate_bps, + want_rgb, + false, + false, + ) } + #[allow(clippy::too_many_arguments)] fn open_opts_inner( codec: Codec, width: u32, @@ -715,6 +764,7 @@ impl VulkanVideoEncoder { bitrate_bps: u64, want_rgb: bool, native_nv12: bool, + hdr: bool, ) -> Result { if !matches!(codec, Codec::H265 | Codec::Av1) { bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})"); @@ -737,6 +787,7 @@ impl VulkanVideoEncoder { bitrate_bps.max(1_000_000), want_rgb, native_nv12, + hdr, ) } } @@ -752,6 +803,8 @@ impl VulkanVideoEncoder { bitrate: u64, want_rgb: bool, native_nv12: bool, + // NOT `hdr`: this fn already binds that name to the parameter-set HEADER bytes below. + ten_bit: bool, ) -> Result { use super::vk_av1_encode as av1b; use super::vk_valve_rgb as vrgb; @@ -871,8 +924,12 @@ impl VulkanVideoEncoder { p_next: std::ptr::null(), perform_encode_rgb_conversion: vk::TRUE, }; - let mut h265_profile = vk::VideoEncodeH265ProfileInfoKHR::default() - .std_profile_idc(vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN); + let mut h265_profile = + vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(if ten_bit { + vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10 + } else { + vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN + }); let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR { s_type: av1b::stype(av1b::ST_PROFILE_INFO), p_next: std::ptr::null(), @@ -885,11 +942,20 @@ impl VulkanVideoEncoder { if rgb_cfg.is_some() { usage.p_next = &rgb_info as *const _ as *const c_void; } + // A device that cannot encode 10-bit fails `get_physical_device_video_capabilities` + // below with VIDEO_PROFILE_FORMAT_NOT_SUPPORTED, which fails the open — and a failed + // Vulkan open falls back to libav VAAPI in `open_amd_intel`. That is the whole 10-bit + // capability gate: no separate probe, and no way to reach a half-configured session. + let depth = if ten_bit { + vk::VideoComponentBitDepthFlagsKHR::TYPE_10 + } else { + vk::VideoComponentBitDepthFlagsKHR::TYPE_8 + }; let mut profile = vk::VideoProfileInfoKHR::default() .video_codec_operation(codec_op) .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) - .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) - .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8); + .luma_bit_depth(depth) + .chroma_bit_depth(depth); if av1 { av1_profile.p_next = &usage as *const _ as *const c_void; profile.p_next = &av1_profile as *const _ as *const c_void; @@ -1120,13 +1186,13 @@ impl VulkanVideoEncoder { .picture_format(if rgb_cfg.is_some() { vk::Format::B8G8R8A8_UNORM } else { - NV12 + yuv_format(ten_bit) }) .max_coded_extent(vk::Extent2D { width: w, height: h, }) - .reference_picture_format(NV12) + .reference_picture_format(yuv_format(ten_bit)) .max_dpb_slots(DPB_SLOTS + 1) .max_active_reference_pictures(1) .std_header_version(&std_hdr); @@ -1236,6 +1302,7 @@ impl VulkanVideoEncoder { rw, rh, quality_level, + ten_bit, )?; (p, hdr, Vec::new()) }; @@ -1247,7 +1314,7 @@ impl VulkanVideoEncoder { let (dpb_image, dpb_mem) = make_video_image( &device, &mem_props, - NV12, + yuv_format(ten_bit), w, h, DPB_SLOTS, @@ -1260,7 +1327,7 @@ impl VulkanVideoEncoder { for slot in 0..DPB_SLOTS { guard .dpb_views - .push(make_view(&device, dpb_image, NV12, slot)?); + .push(make_view(&device, dpb_image, yuv_format(ten_bit), slot)?); } // NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per @@ -1281,7 +1348,11 @@ impl VulkanVideoEncoder { None, )?; guard.sampler = sampler; - let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?; + let spv = ash::util::read_spv(&mut std::io::Cursor::new(if ten_bit { + CSC10_SPV + } else { + CSC_SPV + }))?; let shader = device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?; guard.shader = shader; @@ -1396,6 +1467,7 @@ impl VulkanVideoEncoder { .as_ref() .is_some_and(|c| c.padded) .then_some(vk::Format::B8G8R8A8_UNORM), + ten_bit, guard.frames.last_mut().expect("frame just pushed"), )?; } diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index c4432332..60ead021 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -357,24 +357,25 @@ fn open_video_backend_linux( // Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces // its errors crisply instead of silently trying the other). // AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` + - // PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real - // RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the - // stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI — - // the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC. + // PUNKTFUNK_VULKAN_ENCODE, an HEVC/AV1 session instead opens the raw Vulkan Video backend + // (real RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so + // the stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI + // — the Vulkan backend imports the dmabuf and does its own CSC. let open_amd_intel = || -> Result<(Box, &'static str)> { - // An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video - // backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path, - // which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default. + // HDR (10-bit + a PQ/BT.2020 capture format) takes the Vulkan backend for **HEVC**: its + // compute CSC has a 10-bit BT.2020 variant and the session opens Main10. That keeps the + // two things an HDR session would otherwise lose here — real RFI recovery and the + // compute-CSC cursor blend, which is the only way a gamescope pointer reaches the stream + // (gamescope has no embedded-cursor mode to fall back to). // - // Two things ride this switch, and both are the accepted cost of AMD/Intel HDR until - // Vulkan Video learns 10-bit: the Vulkan backend's real RFI loss recovery, and its - // compute-CSC **cursor blend**. A gamescope HDR session therefore streams without the - // host-composited XFixes pointer (gamescope has no embedded-cursor mode to fall back - // to) — `open_video`'s `blends_cursor` backstop logs it per session. + // AV1 10-bit stays on libav VAAPI: encode-side driver coverage for it is far thinner than + // HEVC Main10, and this is not the place to gamble a session open. A device that cannot + // do Main10 either fails `get_physical_device_video_capabilities` inside the open and + // lands on the VAAPI fallback below — the same net as any other unsupported config. #[cfg(feature = "vulkan-encode")] if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() - && !(bit_depth == 10 && format.is_hdr_rgb10()) + && !(bit_depth == 10 && format.is_hdr_rgb10() && codec != Codec::H265) { match vulkan_video::VulkanVideoEncoder::open( codec, @@ -1045,8 +1046,14 @@ pub fn linux_hdr_cuda_ok() -> bool { /// /// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the /// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the -/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session -/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend). +/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit **AV1** session +/// skips Vulkan Video for libav VAAPI (no blend) — 10-bit HEVC does not, its compute CSC has a +/// BT.2020 Main10 variant that blends like the 8-bit one. +/// +/// Whether the DEVICE can encode Main10 is only settled when the session opens (the profile query +/// is what answers it); a device that cannot falls back to VAAPI there, and `open_video`'s +/// `blends_cursor` backstop logs the divergence. Same shape as every other open-time fallback +/// this prediction cannot see. #[cfg(target_os = "linux")] pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool { // A negotiated PyroWave session routes to that backend before the pref is consulted @@ -1071,6 +1078,8 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> { matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() + // 10-bit is HEVC Main10 only on this backend — mirrors `open_amd_intel`. + && !(ten_bit && codec != Codec::H265) && vulkan_encode_available(codec) } #[cfg(not(feature = "vulkan-encode"))] @@ -1083,7 +1092,7 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> linux_auto_is_vaapi, cuda_planned, ); - cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc) + cursor_blend_capable_for(backend, cuda_planned, direct_nvenc, vulkan_csc) } /// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests. @@ -1094,7 +1103,6 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> fn cursor_blend_capable_for( backend: Option, cuda_planned: bool, - ten_bit: bool, direct_nvenc: bool, vulkan_csc: bool, ) -> bool { @@ -1104,11 +1112,11 @@ fn cursor_blend_capable_for( // Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads — // a CPU-payload session stays on libav NVENC, which cannot blend. Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc, - // The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav - // VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12 - // and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), - // so CSC eligibility IS the answer. - Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc, + // The Vulkan Video compute-CSC path blends, at either depth. The session plan keeps a + // cursor-blend session off the native-NV12 and RGB-direct shapes + // (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), so CSC eligibility IS the + // answer — including the depth term, which `vulkan_csc` already carries. + Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => vulkan_csc, // CPU frames: the capturer composites the metadata cursor inline before the encoder // runs, but the ENCODER blends nothing — the cursor channel's on-demand composite // contract can't be honored. Report the encoder's truth. @@ -2081,58 +2089,32 @@ mod tests { Some(Pyrowave), false, false, - false, false )); // NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads. - assert!(cursor_blend_capable_for( - Some(Nvenc), - true, - false, - true, - false - )); + assert!(cursor_blend_capable_for(Some(Nvenc), true, true, false)); assert!( - !cursor_blend_capable_for(Some(Nvenc), false, false, true, false), + !cursor_blend_capable_for(Some(Nvenc), false, true, false), "a CPU payload stays on libav NVENC, which cannot blend" ); assert!( - !cursor_blend_capable_for(Some(Nvenc), true, false, false, false), + !cursor_blend_capable_for(Some(Nvenc), true, false, false), "PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path" ); - // AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does. - assert!(cursor_blend_capable_for( - Some(AmdIntel), - false, - false, - false, - true - )); + // AMD/Intel: the Vulkan Video compute-CSC arm blends — at 8 AND 10 bits, since its CSC + // has a BT.2020 Main10 variant. VAAPI never does. The depth term lives in `vulkan_csc` + // (10-bit AV1 is the one combination that still falls through to VAAPI), so this arm is + // now exactly "did an eligible CSC arm resolve". + assert!(cursor_blend_capable_for(Some(AmdIntel), false, false, true)); assert!( - !cursor_blend_capable_for(Some(AmdIntel), false, false, false, false), + !cursor_blend_capable_for(Some(AmdIntel), false, false, false), "no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \ device) resolves to libav VAAPI, which cannot blend" ); - assert!( - !cursor_blend_capable_for(Some(AmdIntel), false, true, false, true), - "a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend" - ); - assert!(cursor_blend_capable_for( - Some(Vulkan), - false, - false, - false, - true - )); + assert!(cursor_blend_capable_for(Some(Vulkan), false, false, true)); // Software / unknown pref: CPU frames; the encoder blends nothing. - assert!(!cursor_blend_capable_for( - Some(Software), - false, - false, - true, - true - )); - assert!(!cursor_blend_capable_for(None, false, false, true, true)); + assert!(!cursor_blend_capable_for(Some(Software), false, true, true)); + assert!(!cursor_blend_capable_for(None, false, true, true)); } /// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index 6c3493a7..836f9cd1 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -158,9 +158,10 @@ Two things to know: - **SDR content rides the same PQ stream.** The desktop, the Steam overlay and SDR games are mapped into the HDR container at `PUNKTFUNK_GAMESCOPE_SDR_NITS` (400 by default). If white looks too bright or too dim on your TV, that is the knob. -- **On AMD and Intel the composited mouse pointer is currently missing from HDR sessions.** HDR - routes the encode through a path that cannot blend the cursor gamescope leaves out of its - capture. SDR sessions are unaffected, as are NVIDIA hosts. +- **HDR picks HEVC on AMD and Intel.** A 10-bit AV1 session falls back to a slower encode path + that also can't draw the mouse pointer gamescope leaves out of its capture — so if you stream + Gaming Mode in HDR from an AMD or Intel box, leave the codec on HEVC. NVIDIA is unaffected + either way. ## Known limits diff --git a/docs/releases/v0.21.0.md b/docs/releases/v0.21.0.md index 1bd52861..6563c400 100644 --- a/docs/releases/v0.21.0.md +++ b/docs/releases/v0.21.0.md @@ -27,8 +27,9 @@ the Punktfunk sysext; there's an Arch package, a NixOS option, and a build scrip else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place. Opt-in for this release while it soaks: without the knob, or without the extra package, the -gamescope path streams SDR exactly as before. One known gap on AMD and Intel: the on-screen mouse -pointer is missing from HDR sessions (NVIDIA is unaffected) — SDR sessions still have it. +gamescope path streams SDR exactly as before. One thing to know on AMD and Intel: keep the codec on +HEVC — a 10-bit AV1 session falls back to a slower encode path that also loses the on-screen mouse +pointer. NVIDIA is unaffected either way. ## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays @@ -45,7 +46,8 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the - **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier. - **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode. - **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render). -- **AMD/Intel HDR needs no new encoder code**; NVIDIA gained a zero-copy leg. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill. +- **Vulkan Video learned 10-bit** (HEVC Main10), which is what keeps AMD/Intel HDR on the good path: an HDR session opens a Main10 video profile with 10-bit component depths, a `G10X6…3PACK16` picture and DPB, an SPS carrying `bit_depth_*_minus8 = 2` and the BT.2020/PQ CICP triplet, and a new `rgb2yuv10.comp` — the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all. 10-bit **AV1** still routes to libav VAAPI: encode-side driver coverage for it is far thinner than Main10. A device that can't do Main10 fails the profile query inside the open and lands on the same VAAPI fallback as any other unsupported config. +- **AMD/Intel HDR needs no new encoder code on the VAAPI fallback**; NVIDIA gained a zero-copy leg. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill. - **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere. See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it.