diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 817499d4..c5703b68 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1520,6 +1520,16 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result 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())?; } }; diff --git a/crates/pf-presenter/src/vk/reconfig.rs b/crates/pf-presenter/src/vk/reconfig.rs index f34d685d..5e9d39a5 100644 --- a/crates/pf-presenter/src/vk/reconfig.rs +++ b/crates/pf-presenter/src/vk/reconfig.rs @@ -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.