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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:31:48 +02:00
parent c299a41a67
commit 4543a3f529
10 changed files with 416 additions and 153 deletions
+5 -1
View File
@@ -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)
+4 -2
View File
@@ -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;
+70 -16
View File
@@ -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);
}
Binary file not shown.
+46 -15
View File
@@ -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<CscPass> {
// 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<CscPass> {
// One color attachment: the presenter's video image. Content is fully
// overwritten (DONT_CARE load), and the pass ends in TRANSFER_SRC so the
// 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),
+23 -10
View File
@@ -1,4 +1,5 @@
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8 luma + GR88 chroma) with the
//! VAAPI dmabuf → Vulkan import: per-plane `VkImage`s (R8/GR88 for NV12, R16/GR1616
//! for 10-bit P010) with the
//! surface's explicit DRM format modifier — the same layer-wise import the EGL presenter
//! (`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],
+165 -27
View File
@@ -336,6 +336,12 @@ pub struct Presenter {
/// gone through a fence cycle.
retired_display: Vec<DisplayGarbage>,
format: vk::SurfaceFormatKHR,
/// The surface's HDR10/ST.2084 pairing, when the stack offers one.
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR,
swapchain: vk::SwapchainKHR,
images: Vec<vk::Image>,
@@ -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<String> = instance_extensions.to_vec();
let inst_available = unsafe { entry.enumerate_instance_extension_properties(None) }
.unwrap_or_default();
let has_colorspace_ext = inst_available.iter().any(|e| {
e.extension_name_as_c_str() == Ok(c"VK_EXT_swapchain_colorspace")
});
if has_colorspace_ext {
instance_extensions.push("VK_EXT_swapchain_colorspace".into());
}
let ext_cstrings: Vec<CString> = instance_extensions
.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::<u8>(), 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::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[12] = mode;
pc[13] = peak;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
self.device.cmd_push_constants(
self.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<vk::SurfaceFormatKHR> {
colorspace_ext: bool,
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
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::<f32>(), 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));
@@ -8,15 +8,15 @@
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
//! `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:<NAME>`) 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:<NAME>`)
//! 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:<name>` 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:<output-name>` (vs `window:<addr>` / `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:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
/// "strange output" and falls back to the interactive picker. So a monitor selection is
/// `[SELECTION]screen:<name>`.
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<bool> {
}
/// 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 eraa 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<bool> {
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<Option<(u64, u64)>> {
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<Option<(u16, u16, u16)>> = 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");
}
}