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