fix(client): HDR stops leaking out of the stream and blowing out the console UI
ci / web (push) Successful in 1m3s
ci / rust-arm64 (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m29s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 3m40s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
deb / build-publish (push) Successful in 5m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m7s
docker / builders-arm64cross (push) Successful in 4s
docker / deploy-docs (push) Successful in 27s
android / android (push) Successful in 8m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m12s
arch / build-publish (push) Successful in 9m35s
flatpak / build-publish (push) Successful in 6m57s
ci / rust (push) Successful in 10m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m17s
apple / swift (push) Successful in 5m10s
apple / screenshots (push) Canceled after 19m1s

The gamepad/console UI looked right on launch, and wrong forever after the first HDR
session: connect to an HDR host, disconnect, and the UI came back overblown with wrong
colours.

The UI is not its own renderer. It is a `pf_presenter::overlay::Overlay` composited into
the SAME swapchain the stream used, and it draws plain sRGB with no HDR awareness at all —
so the swapchain's colorspace decides how its pixels are read. `present` switches SDR↔HDR10
from the FRAME's colour signalling, and a UI-only present is `FrameInput::Redraw`, which
carries none: the mode block is skipped entirely and nothing ever hands HDR10 back. The
UI's sRGB mid-tones were then emitted as PQ code points, i.e. near-peak nits.

`leave_hdr` drops back to SDR, called where the UI-only present already happens and gated
on the existing `browse_idle` — Browse mode with no live connector, i.e. the UI owns the
screen. That covers every route back to the UI rather than just the Ended/Failed arms, and
it is guarded internally so idle iterations stay free.

It also bails when minimized, which is load-bearing rather than an optimization:
`recreate_swapchain` keeps the old swapchain at a zero extent, but `set_hdr_mode` would by
then have rebuilt the CSC and overlay pipes against the SDR format — mismatched against
live HDR10 images. `present` early-returns on a zero extent above its HDR block, so this
was unreachable until a caller outside `present` existed.

Deliberately not applied to the `resize_scrim` arm of the same present: that scrim is a
mid-stream gap in a session that is still HDR, and flipping there would rebuild the
swapchain twice per resize.

Does not address the adjacent case: an overlay drawn DURING a live HDR session (the stats
HUD, the resize scrim) is blown out the same way. That needs the overlay to PQ-encode when
`hdr_active`; dropping to SDR is only correct here because the console UI shows exactly
when no stream is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:08:38 +02:00
co-authored by Claude Opus 5
parent 43a631ea9c
commit 6a4ffcb15c
2 changed files with 38 additions and 0 deletions
+10
View File
@@ -1520,6 +1520,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
let browse_idle = matches!(mode, ModeCtl::Browse(_))
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
if !presented_video && (resize_scrim || browse_idle) {
// The UI owns the screen again: hand the swapchain back to SDR before drawing
// it. A finished PQ stream leaves HDR10 live, and nothing else would ever turn
// it off — `present` re-evaluates the mode only from a frame's colour
// signalling, and these UI presents carry no frame. Guarded inside, so this is
// free on every ordinary idle iteration. (Deliberately NOT applied to
// `resize_scrim`: that scrim is a mid-stream gap in a session that is still
// HDR, and flipping there would rebuild the swapchain twice per resize.)
if browse_idle {
presenter.leave_hdr(&window)?;
}
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
};
+28
View File
@@ -171,6 +171,34 @@ impl Presenter {
self.hdr_active
}
/// Drop back to the SDR swapchain. A no-op unless HDR10 is actually live, so it is
/// cheap to call on every idle iteration (the flip itself rebuilds the CSC pass, the
/// video image, the overlay pipe and the swapchain).
///
/// The console/gamepad UI is SDR content, and it is composited into whatever swapchain
/// the last STREAM left behind. [`Presenter::present`] only re-evaluates the mode when a
/// frame carries colour signalling, and a UI-only present is `FrameInput::Redraw`, which
/// carries none — so once a PQ session ended, the UI kept being written into the HDR10
/// swapchain and its sRGB mid-tones were emitted as PQ code points, i.e. near-peak nits.
/// That is the "gamepad UI is blown out after disconnecting from an HDR host" report:
/// the UI looks right until the first HDR session, and wrong forever after.
pub fn leave_hdr(&mut self, window: &sdl3::video::Window) -> Result<()> {
if !self.hdr_active {
return Ok(());
}
// Minimized is not just "pointless work": `recreate_swapchain` deliberately keeps
// the old swapchain at a zero extent, but `set_hdr_mode` would already have rebuilt
// the CSC and overlay pipes against the SDR format — leaving them mismatched against
// the live HDR10 swapchain images. [`Presenter::present`] carries the same guard,
// which is why the flip could never reach this state before; the mode change just
// waits for the window to have a size again.
if self.extent.width == 0 || self.extent.height == 0 {
return Ok(());
}
tracing::info!("stream over — leaving HDR10 so the console UI composites as SDR");
self.set_hdr_mode(window, false)
}
/// 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.