fix(presenter): MAILBOX present; push HDR10 metadata; honest HDR tag
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m43s
arch / build-publish (push) Successful in 5m33s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m9s
windows-host / package (push) Successful in 7m54s
ci / rust (push) Successful in 3m33s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m12s
release / apple (push) Successful in 8m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 51s
deb / build-publish (push) Successful in 3m59s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
ci / bench (push) Successful in 5m1s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Successful in 5m52s
flatpak / build-publish (push) Successful in 4m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m50s

Three Deck-test findings in the Vulkan presenter:

MAILBOX over FIFO when the surface offers it (PUNKTFUNK_PRESENT_MODE=
fifo restores the old default): both are tear-free, but an arrival-
paced presenter blocking in FIFO's present queue eats most of a vblank
whenever gamescope holds images for its composite pass or cadence
drifts against refresh — the display stat flipping 1 ms → 11-13 ms.

VK_EXT_hdr_metadata wired up: the host's 0xCE mastering metadata (or
the Windows presenter's generic HDR10 baseline until it arrives) is
pushed via vkSetHdrMetadataEXT whenever an HDR10 swapchain is (re)-
created. Compositors key their HDR-app signaling on this — colorspace
alone leaves gamescope treating the app as SDR (no SteamOS badge).

And the OSD's HDR tag now reports the display path, not the stream: a
PQ stream tone-mapped onto an SDR surface shows HDR→SDR, so the next
test distinguishes "badge missing" from "HDR never engaged".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 12:29:44 +02:00
parent dc834ea478
commit ade4266336
2 changed files with 139 additions and 12 deletions
+30 -3
View File
@@ -530,7 +530,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
SessionEvent::Stats(s) => {
st.osd_text = stats_text(&st.mode_line, &s, &st.presented, st.hdr);
st.osd_text = stats_text(
&st.mode_line,
&s,
&st.presented,
st.hdr,
presenter.hdr_active(),
);
if print_stats {
println!("stats: {}", st.osd_text.replace('\n', " | "));
}
@@ -627,6 +633,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// --- Frames: drain to the newest, upload + present -------------------------------
let mut presented_video = false;
if let Some(st) = &mut stream {
// Mastering metadata (0xCE) → the presentation engine, ahead of the frame
// that needs it. Low-rate (session start + mastering changes / keyframes).
if let Some(c) = &st.connector {
while let Ok(m) = c.next_hdr_meta(Duration::ZERO) {
presenter.set_hdr_metadata(m);
}
}
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = st.handle.frames.try_recv() {
newest = Some(f);
@@ -785,13 +798,27 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
fn stats_text(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) -> String {
///
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
fn stats_text(
mode_line: &str,
s: &Stats,
p: &PresentedWindow,
hdr_stream: bool,
hdr_display: bool,
) -> String {
let mut text = format!(
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
if hdr { " · HDR" } else { "" },
match (hdr_stream, hdr_display) {
(true, true) => " · HDR",
(true, false) => " · HDR→SDR",
_ => "",
},
);
text.push_str(&format!(
"\ne2e {:.1}/{:.1} ms (p50/p95)",
+109 -9
View File
@@ -9,10 +9,12 @@
//! proprietary by design) report `supports_dmabuf() == false` and the caller keeps the
//! decoder on software.
//!
//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by
//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is
//! arrival-paced by the caller: a frame input on each decoded frame,
//! `FrameInput::Redraw` re-blits the retained video image (expose/resize redraws).
//! Pacing: one frame in flight (the submit fence is waited before each record), MAILBOX
//! when available, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=fifo|mailbox|immediate`
//! overrides — see `pick_present_mode` for why an arrival-paced presenter must not
//! block in FIFO's present queue). Present is arrival-paced by the caller: a frame
//! input on each decoded frame, `FrameInput::Redraw` re-blits the retained video image
//! (expose/resize redraws).
use crate::csc::{build_fullscreen_pipeline, csc_rows, CscPass};
use crate::dmabuf::{self, HwFrame};
@@ -340,6 +342,12 @@ pub struct Presenter {
hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool,
/// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do).
hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>,
/// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the
/// swapchain whenever HDR10 mode is live; `None` until the first datagram lands
/// (a generic HDR10 baseline is pushed meanwhile).
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
/// The video image / CSC attachment format for the current mode.
video_format: vk::Format,
present_mode: vk::PresentModeKHR,
@@ -432,6 +440,15 @@ impl Presenter {
unavailable"
);
}
// Static HDR metadata (ST.2086 mastering + CLL) to the presentation engine.
// Compositors key their "this app is HDR" signaling on the client pushing
// metadata via vkSetHdrMetadataEXT in addition to picking the HDR10 colorspace
// (gamescope's SteamOS HDR badge and per-app tone-map targets among them) —
// the colorspace alone leaves the app looking SDR to the shell.
let has_hdr_metadata = has(ash::ext::hdr_metadata::NAME);
if has_hdr_metadata {
dev_exts.push(ash::ext::hdr_metadata::NAME.as_ptr());
}
// --- Vulkan Video decode (the FFmpeg-on-our-device path) ---------------------
// Probed, never required: a capable stack gets the video extensions, a second
@@ -557,6 +574,8 @@ impl Presenter {
}
.context("vkCreateDevice")?;
let swap_d = ash::khr::swapchain::Device::new(&instance, &device);
let hdr_metadata_d =
has_hdr_metadata.then(|| ash::ext::hdr_metadata::Device::new(&instance, &device));
let queue = unsafe { device.get_device_queue(qfi, 0) };
let hw = if hw_capable {
Some(HwCtx {
@@ -578,6 +597,9 @@ impl Presenter {
device_extensions
.extend(dmabuf::DEVICE_EXTENSIONS.iter().map(|n| CString::from(*n)));
}
if has_hdr_metadata {
device_extensions.push(CString::from(ash::ext::hdr_metadata::NAME));
}
device_extensions.extend(video_ext_names.iter().map(|n| CString::from(*n)));
Some(pf_client_core::video::VulkanDecodeDevice {
get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize,
@@ -603,7 +625,13 @@ impl Presenter {
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, ?hdr10_format, ?present_mode, "swapchain config");
tracing::info!(
?format,
?hdr10_format,
?present_mode,
hdr_metadata = has_hdr_metadata,
"swapchain config"
);
let overlay_pipe = OverlayPipe::new(&device, format.format)?;
let cmd_pool = unsafe {
@@ -651,6 +679,8 @@ impl Presenter {
format,
hdr10_format,
hdr_active: false,
hdr_metadata_d,
hdr_meta: None,
video_format: vk::Format::R8G8B8A8_UNORM,
present_mode,
swapchain: vk::SwapchainKHR::null(),
@@ -762,9 +792,74 @@ impl Presenter {
images = self.images.len(),
"swapchain (re)created"
);
// HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed
// again (this also covers set_hdr_mode's entry into HDR10, which lands here).
if self.hdr_active {
self.apply_hdr_metadata();
}
Ok(())
}
/// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream
/// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is
/// what user-facing "HDR" indicators should report.
pub fn hdr_active(&self) -> bool {
self.hdr_active
}
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
/// idempotent per distinct value — callers just drain the plane into it.
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
if self.hdr_meta == Some(meta) {
return;
}
self.hdr_meta = Some(meta);
if self.hdr_active {
self.apply_hdr_metadata();
}
}
/// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE
/// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate
/// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves
/// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map
/// target). No-op where the driver lacks the extension.
fn apply_hdr_metadata(&self) {
let Some(ext) = &self.hdr_metadata_d else {
return;
};
// Same generic baseline as the Windows presenter: BT.2020 primaries + D65
// white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400.
let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta {
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
white_point: [15635, 16450],
max_display_mastering_luminance: 10_000_000,
min_display_mastering_luminance: 1,
max_cll: 1000,
max_fall: 400,
});
// Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance
// 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in
// 0..1 chromaticity and whole nits, primaries named R/G/B.
let xy = |p: [u16; 2]| vk::XYColorEXT {
x: p[0] as f32 / 50_000.0,
y: p[1] as f32 / 50_000.0,
};
let [g, b, r] = m.display_primaries;
let md = vk::HdrMetadataEXT::default()
.display_primary_red(xy(r))
.display_primary_green(xy(g))
.display_primary_blue(xy(b))
.white_point(xy(m.white_point))
.max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0)
.min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0)
.max_content_light_level(m.max_cll as f32)
.max_frame_average_light_level(m.max_fall as f32);
unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) };
tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed");
}
/// Whether the hardware (dmabuf) path exists on this device — callers keep the
/// decoder on software when it doesn't.
pub fn supports_dmabuf(&self) -> bool {
@@ -1687,8 +1782,13 @@ fn pick_formats(
Ok((sdr, hdr10))
}
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
/// a streaming client defaults to tear-free.
/// MAILBOX when the surface offers it, FIFO otherwise (`PUNKTFUNK_PRESENT_MODE=
/// fifo|mailbox|immediate` overrides). Both are tear-free, but an arrival-paced
/// presenter must not block in FIFO's present queue: when the compositor holds images
/// for a vblank pass (gamescope's composite path) or arrival cadence drifts against
/// refresh, `acquire_next_image` stalls most of a refresh — a standing 11-13 ms added
/// to every frame at 60 Hz. MAILBOX never queues more than the newest frame, so the
/// pipeline stays at decode latency and a late frame is replaced, not waited for.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
@@ -1696,9 +1796,9 @@ fn pick_present_mode(
) -> Result<vk::PresentModeKHR> {
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("mailbox") => vk::PresentModeKHR::MAILBOX,
Some("fifo") => vk::PresentModeKHR::FIFO,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
_ => vk::PresentModeKHR::FIFO,
_ => vk::PresentModeKHR::MAILBOX,
};
Ok(if modes.contains(&want) {
want