From 188edde2b3d7159905d123e3a16a4ed14f776de1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 13:21:06 +0200 Subject: [PATCH] feat(pyrowave): Windows host HDR + 4:4:4, Rust client HDR present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of design/pyrowave-444-hdr.md. A PyroWave session now negotiates HDR (10-bit) and 4:4:4 on a Windows host exactly like HEVC/AV1, and the Linux client presents it through the real HDR10 path. Host (Windows): BgraToYuvPlanes becomes mode-aware — SDR/BGRA and HDR/scRGB variants at half- or full-res chroma. The HDR passes reuse HdrP010Converter's exact colour math (scRGB -> PQ BT.2020 limited studio codes, verified by hdr_p010_selftest) but write P010-style MSB-packed codes into two separate shareable R16_UNORM/R16G16_UNORM textures; chroma keeps the pyrowave family's centre-sited 2x2 box. idd_push pins the composition to the NEGOTIATED depth (SDR sessions force advanced color off as before; 10-bit sessions enable it and ride the FP16 ring), and the descriptor poller re-asserts that state instead of following display flips the fixed-format encoder can't. The encoder imports 8/16-bit planes per session and stamps the sequence header's BT.2020/PQ/matrix bits on HDR (stamp_color_bits, extending 574e3e4e's range stamp); supports_10bit/can_encode_10bit/can_encode_444 gates open (HDR Windows-only — Linux capture has no HDR source). Client: the plane ring becomes R16_UNORM for 10-bit sessions (with a STORAGE_IMAGE format probe), the planar CSC pass joins the HDR10 swapchain rebuild (set_hdr_mode previously destroyed it without rebuilding — latent), st.hdr follows frame.color.is_pq(), and the planar push constants carry depth-10 MSB-packed rows + the PQ tonemap mode, identical to the NV12 arm. Verified: .173 (RTX 4090) deploy-config clippy + fmt + wire tests + the extended pyrowave_win_smoke (10-case {SDR,HDR}x{420,444} matrix incl. R16 imports and header stamps); .21 (RTX 5070 Ti) clippy across 4 crates, host 186 tests, client/presenter/encode tests, both Linux GPU smokes. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/windows/dxgi.rs | 141 +++++++++++-- crates/pf-capture/src/windows/idd_push.rs | 100 ++++++---- crates/pf-client-core/src/session.rs | 1 + crates/pf-client-core/src/video.rs | 2 + crates/pf-client-core/src/video_pyrowave.rs | 68 +++++-- crates/pf-encode/src/enc/codec.rs | 9 +- crates/pf-encode/src/enc/linux/pyrowave.rs | 19 +- crates/pf-encode/src/enc/pyrowave_wire.rs | 26 ++- crates/pf-encode/src/enc/windows/pyrowave.rs | 200 ++++++++++++++----- crates/pf-encode/src/lib.rs | 25 ++- crates/pf-presenter/src/run.rs | 5 +- crates/pf-presenter/src/vk/present.rs | 28 ++- crates/pf-presenter/src/vk/reconfig.rs | 12 +- crates/pf-presenter/src/vk/setup.rs | 3 +- 14 files changed, 495 insertions(+), 144 deletions(-) diff --git a/crates/pf-capture/src/windows/dxgi.rs b/crates/pf-capture/src/windows/dxgi.rs index 9ebc3756..0e101624 100644 --- a/crates/pf-capture/src/windows/dxgi.rs +++ b/crates/pf-capture/src/windows/dxgi.rs @@ -497,25 +497,127 @@ float2 main(float4 pos : SV_POSITION) : SV_TARGET { } "; -/// scRGB/BGRA → **separate** BT.709-limited YUV planes for the PyroWave wavelet encoder: a full-res -/// `R8_UNORM` Y texture + a half-res `R8G8_UNORM` interleaved CbCr texture (design/pyrowave-windows- -/// host-zerocopy.md). The wavelet encoder imports the two SEPARATE textures into its own Vulkan -/// device — the NVIDIA D3D11→Vulkan import of a single *planar* NV12 texture is unreliable at -/// arbitrary sizes (the vendored interop test: "only very specific resource sizes"), whereas simple -/// single/two-component textures import reliably. Matches the validated Linux `rgb2yuv.comp` layout -/// (R8 Y + RG8 CbCr) + colour math exactly, so the wavelet clients decode identically. The caller -/// owns the two textures + their RTVs (shareable, per out-ring slot); this only records the passes. +/// PyroWave 4:4:4 CHROMA pass PS — FULL-res, per-pixel (no box filter, no siting), the Windows twin +/// of the Linux `rgb2yuv444.comp` chroma math. +const PYRO_UV444_PS: &str = r" +Texture2D tx : register(t0); +float2 main(float4 pos : SV_POSITION) : SV_TARGET { + float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb; + float u = 128.0/255.0 - 0.1006*c.r - 0.3386*c.g + 0.4392*c.b; + float v = 128.0/255.0 + 0.4392*c.r - 0.3989*c.g - 0.0403*c.b; + return float2(u, v); +} +"; + +/// Shared HLSL for the PyroWave **HDR** passes: scRGB FP16 → PQ-encoded BT.2020 → 10-bit studio +/// codes MSB-packed into 16-bit UNORM — the SAME colour math as [`HDR_P010_COMMON`] (verified by +/// `hdr_p010_selftest`), restated over `Load`ed texels so the pyrowave passes stay texel-exact like +/// their SDR twins. The wavelet client decodes these planes with the same CSC rows as the P010 path. +const PYRO_HDR_COMMON: &str = r" +Texture2D tx : register(t0); +static const float3x3 BT709_TO_BT2020 = { + 0.627403914, 0.329283038, 0.043313048, + 0.069097292, 0.919540405, 0.011362303, + 0.016391439, 0.088013308, 0.895595253 +}; +float3 pq_oetf(float3 L) { + const float m1 = 0.1593017578125; + const float m2 = 78.84375; + const float c1 = 0.8359375; + const float c2 = 18.8515625; + const float c3 = 18.6875; + float3 Lp = pow(saturate(L), m1); + return pow((c1 + c2 * Lp) / (1.0 + c3 * Lp), m2); +} +float3 scrgb_to_pq2020_rgb(float3 scrgb) { + float3 nits = max(scrgb, 0.0) * 80.0; + return pq_oetf(mul(BT709_TO_BT2020, nits) / 10000.0); +} +static const float KR = 0.2627; +static const float KG = 0.6780; +static const float KB = 0.0593; +float y_unorm(float3 pq) { + float y = KR * pq.r + KG * pq.g + KB * pq.b; + float code = clamp(64.0 + 876.0 * y, 64.0, 940.0); + return (code * 64.0) / 65535.0; +} +float2 cbcr_unorm(float3 pq) { + float y = KR * pq.r + KG * pq.g + KB * pq.b; + float cbc = clamp(512.0 + 896.0 * (pq.b - y) / 1.8814, 64.0, 960.0); + float crc = clamp(512.0 + 896.0 * (pq.r - y) / 1.4746, 64.0, 960.0); + return float2((cbc * 64.0) / 65535.0, (crc * 64.0) / 65535.0); +} +"; + +/// PyroWave HDR LUMA pass PS — full-res, writes PQ Y′ studio codes to an `R16_UNORM` texture. +const PYRO_HDR_Y_PS: &str = r" +#include_common +float main(float4 pos : SV_POSITION) : SV_TARGET { + float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb); + return y_unorm(pq); +} +"; + +/// PyroWave HDR 4:2:0 CHROMA pass PS — half-res, centre-sited 2×2 box in scRGB-LINEAR space (the +/// pyrowave family's siting, matching the SDR pass + `rgb2yuv.comp`, NOT the P010 path's +/// left-cositing), then PQ + studio Cb/Cr into an `R16G16_UNORM` texture. +const PYRO_HDR_UV_PS: &str = r" +#include_common +float2 main(float4 pos : SV_POSITION) : SV_TARGET { + int2 p = int2(pos.xy) * 2; + float3 a = max(tx.Load(int3(p, 0)).rgb, 0.0); + float3 b = max(tx.Load(int3(p + int2(1,0), 0)).rgb, 0.0); + float3 c = max(tx.Load(int3(p + int2(0,1), 0)).rgb, 0.0); + float3 d = max(tx.Load(int3(p + int2(1,1), 0)).rgb, 0.0); + float3 pq = scrgb_to_pq2020_rgb((a + b + c + d) * 0.25); + return cbcr_unorm(pq); +} +"; + +/// PyroWave HDR 4:4:4 CHROMA pass PS — full-res, per-pixel. +const PYRO_HDR_UV444_PS: &str = r" +#include_common +float2 main(float4 pos : SV_POSITION) : SV_TARGET { + float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb); + return cbcr_unorm(pq); +} +"; + +/// scRGB/BGRA → **separate** YUV planes for the PyroWave wavelet encoder: a full-res Y texture + a +/// (half- or full-res) interleaved CbCr texture (design/pyrowave-windows-host-zerocopy.md + +/// design/pyrowave-444-hdr.md). SDR mode reads the BGRA slot and writes BT.709-limited 8-bit planes +/// (`R8_UNORM`/`R8G8_UNORM`), byte-identical to the Linux `rgb2yuv(444).comp`; HDR mode reads the +/// scRGB FP16 slot and writes P010-style 10-bit studio codes MSB-packed into 16-bit planes +/// (`R16_UNORM`/`R16G16_UNORM`), colour math identical to [`HdrP010Converter`]. The wavelet encoder +/// imports the two SEPARATE textures into its own Vulkan device — the NVIDIA D3D11→Vulkan import of +/// a single *planar* NV12 texture is unreliable at arbitrary sizes, whereas simple single/ +/// two-component textures import reliably. The caller owns the two textures + their RTVs (shareable, +/// per out-ring slot); this only records the passes. pub(crate) struct BgraToYuvPlanes { vs: ID3D11VertexShader, ps_y: ID3D11PixelShader, ps_uv: ID3D11PixelShader, + /// Full-res chroma pass (4:4:4) — the chroma viewport skips the /2. + chroma444: bool, } impl BgraToYuvPlanes { - pub(crate) unsafe fn new(device: &ID3D11Device) -> Result { + pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result { + let (y_src, uv_src) = match (hdr, chroma444) { + (false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()), + (false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()), + (true, false) => ( + PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON), + PYRO_HDR_UV_PS.replace("#include_common", PYRO_HDR_COMMON), + ), + (true, true) => ( + PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON), + PYRO_HDR_UV444_PS.replace("#include_common", PYRO_HDR_COMMON), + ), + }; let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?; - let yb = compile_shader(PYRO_Y_PS, s!("main"), s!("ps_5_0"))?; - let uvb = compile_shader(PYRO_UV_PS, s!("main"), s!("ps_5_0"))?; + let yb = compile_shader(&y_src, s!("main"), s!("ps_5_0"))?; + let uvb = compile_shader(&uv_src, s!("main"), s!("ps_5_0"))?; let mut vs = None; device.CreateVertexShader(&vsb, None, Some(&mut vs))?; let mut ps_y = None; @@ -526,11 +628,13 @@ impl BgraToYuvPlanes { vs: vs.context("pyro vs")?, ps_y: ps_y.context("pyro y ps")?, ps_uv: ps_uv.context("pyro uv ps")?, + chroma444, }) } - /// Convert `src_srv` (BGRA slot, WxH) → `y_rtv` (a full-res `R8_UNORM` texture) + `cbcr_rtv` (a - /// half-res `R8G8_UNORM` texture). Two opaque passes; `w`/`h` are the full luma dims (even). + /// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y + /// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque + /// passes; `w`/`h` are the full luma dims (even for 4:2:0). #[allow(clippy::too_many_arguments)] pub(crate) unsafe fn convert( &self, @@ -561,12 +665,17 @@ impl BgraToYuvPlanes { ctx.Draw(3, 0); ctx.OMSetRenderTargets(Some(&[None]), None); - // CHROMA pass: half-res → the R8G8 CbCr texture. + // CHROMA pass: half-res (4:2:0) or full-res (4:4:4) → the CbCr texture. + let (cw, ch) = if self.chroma444 { + (w, h) + } else { + (w / 2, h / 2) + }; ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { TopLeftX: 0.0, TopLeftY: 0.0, - Width: (w / 2) as f32, - Height: (h / 2) as f32, + Width: cw as f32, + Height: ch as f32, MinDepth: 0.0, MaxDepth: 1.0, }])); diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 2e6a7ad1..84a3336a 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -44,7 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{ }; use windows::Win32::Graphics::Dxgi::Common::{ DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, - DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC, + DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, + DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC, }; use windows::Win32::Graphics::Dxgi::{ CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1, @@ -408,11 +409,14 @@ pub struct IddPushCapturer { /// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source): /// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it. want_444: bool, - /// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md). When set the out-ring - /// is created **shareable** (`SHARED | SHARED_NTHANDLE`) and a **shared fence** is signalled after - /// each convert/copy, so the pyrowave encoder can zero-copy-import the NV12 texture into its own - /// Vulkan device and order the read after the D3D11 convert. Also forces the NV12 4:2:0 SDR convert - /// (never P010 / BGRA-passthrough) regardless of `display_hdr` / `want_444`. + /// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md + + /// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring` + /// (shareable Y + CbCr textures the mode-aware [`BgraToYuvPlanes`] CSC writes) and a **shared + /// fence** is signalled after each convert, so the pyrowave encoder zero-copy-imports the two + /// textures into its own Vulkan device ordered after the D3D11 convert. The composition is + /// PINNED to the negotiated depth: SDR sessions force advanced color OFF (8-bit BGRA → R8 + /// planes), 10-bit sessions enable it like H.26x (scRGB FP16 → R16 studio-code planes); + /// `want_444` sizes the chroma plane full-res. pyrowave: bool, /// PyroWave: the shared D3D11 timeline fence (created lazily on the first frame, `SHARED` flag). /// The capturer `Signal`s it after each frame's GPU convert; the encoder's Vulkan side waits it. @@ -746,13 +750,14 @@ impl IddPushCapturer { // - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section` // into `me` leaves it valid (see the `MappedSection` doc comment). unsafe { - // PyroWave is an 8-bit SDR wavelet codec with no 10-bit path, and the NVIDIA D3D11 - // VideoProcessor cannot ingest the FP16 HDR ring (CreateVideoProcessorInputView rejects - // R16G16B16A16_FLOAT) — so a pyrowave session must run on an SDR (BGRA) composition. - // Actively turn advanced color OFF on the virtual display (undoing any leftover HDR state - // from a prior session on a reused/lingering monitor) and settle before sizing the ring, - // mirroring the enable path's settle so the driver composes BGRA before we size BGRA. - if pyrowave { + // An SDR-negotiated PyroWave session must run on an SDR (BGRA) composition — its CSC + // reads 8-bit BGRA (and the NVIDIA D3D11 VideoProcessor can't ingest the FP16 ring + // anyway). Actively turn advanced color OFF (undoing any leftover HDR state from a + // prior session on a reused/lingering monitor) and settle before sizing the ring. An + // HDR-negotiated (10-bit) PyroWave session instead enables HDR below exactly like the + // H.26x path and rides the FP16 scRGB ring through the pyro HDR CSC + // (design/pyrowave-444-hdr.md Phase 3). + if pyrowave && !client_10bit { let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false); let settle = Instant::now(); while settle.elapsed() < Duration::from_millis(250) { @@ -785,7 +790,6 @@ impl IddPushCapturer { // settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format // mismatch → an immediate ring recreate + dropped first frames (audit §5.4). let enabled_hdr = client_10bit - && !pyrowave && pf_win_display::win_display::set_advanced_color(target.target_id, true); if enabled_hdr { // Let the colorspace change settle before the driver composes + we size the ring: @@ -811,8 +815,9 @@ impl IddPushCapturer { } // A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) — // there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session. - // PyroWave forced advanced color OFF above, so it is always SDR (never the FP16 ring). - let display_hdr = !pyrowave + // An SDR PyroWave session forced advanced color OFF above; guard against a physical + // display forcing HDR anyway (the wavelet SDR CSC can't read FP16). + let display_hdr = (!pyrowave || client_10bit) && (enabled_hdr || pf_win_display::win_display::advanced_color_enabled(target.target_id) .unwrap_or(false)); @@ -1227,12 +1232,15 @@ impl IddPushCapturer { /// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit /// full-chroma source): the stream downgrades to 4:2:0 with a warning. fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) { - // PyroWave is an 8-bit SDR wavelet codec: always NV12 (BT.709 limited), never P010 / - // BGRA-passthrough — an HDR desktop is tone-mapped down by the NV12 converter, a 4:4:4 - // negotiation is moot (pyrowave is 4:2:0). The client strips HDR/10-bit/444 when it selects - // PyroWave, so this is the honest match. + // PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the + // format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR + // (negotiated 10-bit) sessions P010 — matching the studio-code planes the pyro CSC writes. if self.pyrowave { - return (DXGI_FORMAT_NV12, PixelFormat::Nv12); + return if self.display_hdr { + (DXGI_FORMAT_P010, PixelFormat::P010) + } else { + (DXGI_FORMAT_NV12, PixelFormat::Nv12) + }; } if self.display_hdr { if self.want_444 { @@ -1341,16 +1349,20 @@ impl IddPushCapturer { return; // no new sample since last consume } self.desc_seq = seq; - // PyroWave forced advanced color OFF at open and never uses the FP16 ring. If a leftover or - // late CCD sample reports the display as HDR, re-assert the disable and treat it as SDR — so - // we never recreate the ring FP16 (which the wavelet encoder cannot feed). - if self.pyrowave && now.hdr { + // A PyroWave session's composition is PINNED to the NEGOTIATED depth: its encoder was + // opened for fixed plane formats (R8 SDR / R16 HDR), so a mid-session "Use HDR" flip + // can't be followed like H.26x does — re-assert the negotiated state instead and treat + // the descriptor accordingly (the ring is never recreated at the wrong format). + if self.pyrowave && now.hdr != self.client_10bit { // SAFETY: `set_advanced_color` is `unsafe` (CCD DisplayConfig calls); it takes a plain // `u32` target id + bool, forms no lasting borrow, and returns a bool. unsafe { - let _ = pf_win_display::win_display::set_advanced_color(self.target_id, false); + let _ = pf_win_display::win_display::set_advanced_color( + self.target_id, + self.client_10bit, + ); } - now.hdr = false; + now.hdr = self.client_10bit; } let current = DisplayDescriptor { hdr: self.display_hdr, @@ -1465,9 +1477,22 @@ impl IddPushCapturer { .context("CreateRenderTargetView(pyro plane)")?; Ok((tex, rtv.context("null pyro plane rtv")?)) }; + // Plane formats/geometry follow the negotiated session: 16-bit UNORM planes for an + // HDR (10-bit) session (P010-style studio codes from the pyro HDR CSC), full-res + // chroma for 4:4:4 (design/pyrowave-444-hdr.md Phase 3). + let (yf, cf) = if self.display_hdr { + (DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM) + } else { + (DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM) + }; + let (cw, ch) = if self.want_444 { + (w, h) + } else { + (w / 2, h / 2) + }; for _ in 0..OUT_RING { - let (y, y_rtv) = make(&self.device, DXGI_FORMAT_R8_UNORM, w, h)?; - let (cbcr, cbcr_rtv) = make(&self.device, DXGI_FORMAT_R8G8_UNORM, w / 2, h / 2)?; + let (y, y_rtv) = make(&self.device, yf, w, h)?; + let (cbcr, cbcr_rtv) = make(&self.device, cf, cw, ch)?; self.pyro_ring.push(PyroOutSlot { y, y_rtv, @@ -1479,12 +1504,17 @@ impl IddPushCapturer { Ok(()) } - /// PyroWave: build the BGRA→YUV-planes CSC if not yet built. + /// PyroWave: build the (mode-aware) RGB→YUV-planes CSC if not yet built. The mode is + /// session-fixed: SDR/BGRA vs HDR/scRGB input, half- vs full-res chroma — the composition + /// is pinned to the negotiated depth (`poll_display_hdr`), so the converter never needs a + /// mid-session mode swap. fn ensure_pyro_conv(&mut self) -> Result<()> { if self.pyro_conv.is_none() { // SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates // failure before it is stored. - self.pyro_conv = Some(unsafe { BgraToYuvPlanes::new(&self.device)? }); + self.pyro_conv = Some(unsafe { + BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)? + }); } Ok(()) } @@ -1678,10 +1708,10 @@ impl IddPushCapturer { // the slot back to the driver. unsafe { if self.pyrowave { - // PyroWave: BGRA slot SRV → separate R8 Y + R8G8 CbCr planes (BT.709 SDR) via the - // CSC shader; the shared fence signalled just after (`pyro_fence_signal`) orders - // the encoder's cross-device Vulkan read after this convert. (The pyrowave session - // forced the display SDR, so the slot is BGRA.) + // PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate + // plane textures via the mode-aware CSC; the shared fence signalled just after + // (`pyro_fence_signal`) orders the encoder's cross-device Vulkan read after this + // convert. The composition format is pinned to the negotiated depth. let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot"); if let Some(conv) = self.pyro_conv.as_ref() { conv.convert( diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c120685f..99cd8151 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -316,6 +316,7 @@ fn pump( connector.shard_payload as usize, connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444, color, + connector.bit_depth >= 10, ), None => Err(anyhow::anyhow!( "pyrowave session without a presenter device" diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index e9d4a62b..08508c85 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -491,6 +491,7 @@ impl Decoder { shard_payload: usize, chroma444: bool, color: ColorDesc, + hdr16: bool, ) -> Result { Ok(Decoder { backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new( @@ -500,6 +501,7 @@ impl Decoder { shard_payload, chroma444, color, + hdr16, )?)), codec_id: ffmpeg::codec::Id::HEVC, vaapi_fails: 0, diff --git a/crates/pf-client-core/src/video_pyrowave.rs b/crates/pf-client-core/src/video_pyrowave.rs index e52660b4..50cf3bb2 100644 --- a/crates/pf-client-core/src/video_pyrowave.rs +++ b/crates/pf-client-core/src/video_pyrowave.rs @@ -258,11 +258,12 @@ unsafe fn make_plane( mem_props: &vk::PhysicalDeviceMemoryProperties, w: u32, h: u32, + fmt: vk::Format, ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { let img = device.create_image( &vk::ImageCreateInfo::default() .image_type(vk::ImageType::TYPE_2D) - .format(vk::Format::R8_UNORM) + .format(fmt) .extent(vk::Extent3D { width: w, height: h, @@ -306,7 +307,7 @@ unsafe fn make_plane( &vk::ImageViewCreateInfo::default() .image(img) .view_type(vk::ImageViewType::TYPE_2D) - .format(vk::Format::R8_UNORM) + .format(fmt) .subresource_range(vk::ImageSubresourceRange { aspect_mask: vk::ImageAspectFlags::COLOR, base_mip_level: 0, @@ -348,6 +349,7 @@ unsafe fn build_ring( width: u32, height: u32, chroma444: bool, + fmt: vk::Format, ) -> Result> { // 4:2:0 = half-res chroma; 4:4:4 = full-res. The presenter's planar CSC samples with // normalized UVs, so the chroma plane resolution is transparent to it. @@ -359,8 +361,8 @@ unsafe fn build_ring( let mut ring: Vec = Vec::with_capacity(RING); for _ in 0..RING { let built = (|| -> Result { - let (y, ym, yv) = make_plane(device, mem_props, width, height)?; - let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch) { + let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?; + let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) { Ok(p) => p, Err(e) => { device.destroy_image_view(yv, None); @@ -369,7 +371,7 @@ unsafe fn build_ring( return Err(e); } }; - let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch) { + let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch, fmt) { Ok(p) => p, Err(e) => { for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] { @@ -424,6 +426,9 @@ pub struct PyroWaveDecoder { /// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI, /// so the negotiated `ColorInfo` is the contract the presenter CSC configures from. color: ColorDesc, + /// Session-fixed negotiated depth ≥10: the planes are `R16_UNORM` carrying the host's + /// P010-style studio codes (the presenter samples them with depth-10 MSB-packed rows). + hdr16: bool, /// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each /// window holds whole self-delimiting codec packets, zero-padded to the window. wire_window: usize, @@ -441,6 +446,7 @@ impl PyroWaveDecoder { shard_payload: usize, chroma444: bool, color: ColorDesc, + hdr16: bool, ) -> Result { if !vkd.pyrowave_decode { bail!("presenter device lacks the PyroWave compute feature set"); @@ -451,7 +457,7 @@ impl PyroWaveDecoder { // SAFETY: the handles in `vkd` are the presenter's live instance/device (it // outlives the decoder — same contract the FFmpeg Vulkan backend relies on); // `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime. - unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color) } + unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color, hdr16) } } unsafe fn new_inner( @@ -461,6 +467,7 @@ impl PyroWaveDecoder { shard_payload: usize, chroma444: bool, color: ColorDesc, + hdr16: bool, ) -> Result { let static_fn = ash::StaticFn { get_instance_proc_addr: std::mem::transmute::( @@ -536,7 +543,27 @@ impl PyroWaveDecoder { let mem_props = instance.get_physical_device_memory_properties( vk::PhysicalDevice::from_raw(vkd.physical_device as u64), ); - let ring = match build_ring(&device, &mem_props, width, height, chroma444) { + // 16-bit sessions decode into R16_UNORM storage planes; STORAGE_IMAGE support for + // R16_UNORM is optional in Vulkan (universal on desktop) — probe it so an exotic + // device fails with a clear message instead of a validation error. + let plane_fmt = if hdr16 { + let props = instance.get_physical_device_format_properties( + vk::PhysicalDevice::from_raw(vkd.physical_device as u64), + vk::Format::R16_UNORM, + ); + if !props + .optimal_tiling_features + .contains(vk::FormatFeatureFlags::STORAGE_IMAGE) + { + pw::pyrowave_decoder_destroy(pw_dec); + pw::pyrowave_device_destroy(pw_dev); + bail!("this GPU lacks R16_UNORM STORAGE_IMAGE — cannot decode a 10-bit PyroWave session"); + } + vk::Format::R16_UNORM + } else { + vk::Format::R8_UNORM + }; + let ring = match build_ring(&device, &mem_props, width, height, chroma444, plane_fmt) { Ok(r) => r, Err(e) => { pw::pyrowave_decoder_destroy(pw_dec); @@ -582,6 +609,7 @@ impl PyroWaveDecoder { height, chroma444, color, + hdr16, wire_window: shard_payload.max(64), }) } @@ -614,14 +642,24 @@ impl PyroWaveDecoder { pw::pyrowave_decoder_create(&dinfo, &mut new_dec), "decoder_create (mid-stream resize)", )?; - let new_ring = - match build_ring(&self.device, &self.mem_props, width, height, self.chroma444) { - Ok(r) => r, - Err(e) => { - pw::pyrowave_decoder_destroy(new_dec); - return Err(e).context("plane ring (mid-stream resize)"); - } - }; + let new_ring = match build_ring( + &self.device, + &self.mem_props, + width, + height, + self.chroma444, + if self.hdr16 { + vk::Format::R16_UNORM + } else { + vk::Format::R8_UNORM + }, + ) { + Ok(r) => r, + Err(e) => { + pw::pyrowave_decoder_destroy(new_dec); + return Err(e).context("plane ring (mid-stream resize)"); + } + }; // Our own decode work is fence-synchronous (never in flight here), so the old // pyrowave decoder can go immediately; only the plane images wait (retired). pw::pyrowave_decoder_destroy(self.pw_dec); diff --git a/crates/pf-encode/src/enc/codec.rs b/crates/pf-encode/src/enc/codec.rs index cf2d970d..0be52010 100644 --- a/crates/pf-encode/src/enc/codec.rs +++ b/crates/pf-encode/src/enc/codec.rs @@ -104,13 +104,14 @@ impl Codec { } } - /// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit). - /// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation - /// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the + /// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit; + /// PyroWave rides 16-bit UNORM planes carrying P010-style studio codes — the wavelet is + /// depth-agnostic, design/pyrowave-444-hdr.md). H.264 is always 8-bit (High10 is neither an + /// NVENC nor a VCN encode mode — negotiation never asks). `true` here is only the /// *codec-level* gate: the active GPU/backend must still pass /// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit. pub fn supports_10bit(self) -> bool { - matches!(self, Codec::H265 | Codec::Av1) + matches!(self, Codec::H265 | Codec::Av1 | Codec::PyroWave) } /// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index c8ea8ed6..22007bec 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -1134,9 +1134,10 @@ impl PyroWaveEncoder { )?; packets.truncate(out_n.max(1)); // Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC - // emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks. + // emits BT.709 LIMITED — patch the bits HONEST so VUI-honoring clients don't wash out + // blacks. (Linux capture has no HDR path, so this side never stamps BT.2020/PQ.) if let Some(p) = packets.first() { - crate::pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset); + crate::pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, false); } // Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense // single packet, or the datagram-aligned windowed AU (§4.4). @@ -1678,5 +1679,19 @@ mod tests { dump("ref-chunked-y.bin", &y); dump("ref-chunked-cb.bin", &cb); dump("ref-chunked-cr.bin", &cr); + + // 4:4:4 dense AU + its reference (full-res chroma planes) — the Apple 4:4:4 layout's + // golden (design/pyrowave-444-hdr.md Phase 4). Same odd-block geometry. + let mut enc = + PyroWaveEncoder::open(w, h, 60, 6_500_000, crate::ChromaFormat::Yuv444).expect("open"); + enc.submit(&test_card(w, h, 13)).expect("444 submit"); + let au = enc.poll().expect("poll").expect("444 AU"); + assert!(!au.chunk_aligned); + dump("au-dense444.bin", &au.data); + // SAFETY: test-only FFI with locally-owned buffers. + let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, &au.data, true) }; + dump("ref-dense444-y.bin", &y); + dump("ref-dense444-cb.bin", &cb); + dump("ref-dense444-cr.bin", &cr); } } diff --git a/crates/pf-encode/src/enc/pyrowave_wire.rs b/crates/pf-encode/src/enc/pyrowave_wire.rs index d648e280..34b9f148 100644 --- a/crates/pf-encode/src/enc/pyrowave_wire.rs +++ b/crates/pf-encode/src/enc/pyrowave_wire.rs @@ -37,11 +37,19 @@ pub(crate) fn packet_boundary(wire_chunk: Option, dense_cap: usize) -> us /// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct. /// /// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` — -/// the SOF packet's offset. `ycbcr_range` is bit 30 of the little-endian second word, i.e. bit 6 of -/// byte `seq_offset + 7` (`0x40`). -pub(crate) fn mark_limited_range(bitstream: &mut [u8], seq_offset: usize) { +/// the SOF packet's offset. The colour bits live in the little-endian second word's top byte +/// (`seq_offset + 7`): `color_primaries` bit 27 (`0x08`), `transfer_function` bit 28 (`0x10`), +/// `ycbcr_transform` bit 29 (`0x20`), `ycbcr_range` bit 30 (`0x40`); `chroma_siting` bit 31 stays 0 +/// (CENTER — the pyrowave CSCs use the centre-sited 2×2 box, unlike the left-cosited P010 path). +/// Range is ALWAYS stamped LIMITED (both CSCs emit studio range); `bt2020_pq` additionally stamps +/// BT.2020 primaries + PQ transfer + BT.2020 matrix — upstream's own enum semantics +/// (`pyrowave_common.hpp`), matching the session's negotiated `ColorInfo`. +pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_pq: bool) { if let Some(b) = bitstream.get_mut(seq_offset + 7) { *b |= 0x40; + if bt2020_pq { + *b |= 0x08 | 0x10 | 0x20; + } } } @@ -182,16 +190,20 @@ mod tests { } #[test] - fn mark_limited_range_sets_only_the_range_bit() { + fn stamp_color_bits_sets_range_and_hdr_bits() { let mut bs = vec![0u8; 16]; - mark_limited_range(&mut bs, 0); + stamp_color_bits(&mut bs, 0, false); // ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched. assert_eq!(bs[7], 0x40); assert!(bs[..7].iter().all(|&b| b == 0)); assert!(bs[8..].iter().all(|&b| b == 0)); // Idempotent; an out-of-range offset is a silent no-op (never panics). - mark_limited_range(&mut bs, 0); + stamp_color_bits(&mut bs, 0, false); assert_eq!(bs[7], 0x40); - mark_limited_range(&mut bs, 100); + stamp_color_bits(&mut bs, 100, false); + // HDR adds BT.2020 primaries (0x08) + PQ transfer (0x10) + BT.2020 matrix (0x20); + // chroma_siting (0x80) stays CENTER. + stamp_color_bits(&mut bs, 0, true); + assert_eq!(bs[7], 0x78); } } diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs index 80b8c6bd..47581919 100644 --- a/crates/pf-encode/src/enc/windows/pyrowave.rs +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -83,6 +83,12 @@ pub struct PyroWaveEncoder { width: u32, height: u32, fps: u32, + /// Session-fixed negotiated chroma: 4:4:4 = full-res CbCr plane + `Chroma444` pyrowave objects. + chroma444: bool, + /// Session-fixed negotiated depth ≥10: the capturer's HDR CSC writes P010-style studio codes + /// into 16-bit UNORM planes (`R16_UNORM` Y + `R16G16_UNORM` CbCr) and the sequence header is + /// stamped BT.2020/PQ. + hdr16: bool, /// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`. frame_budget: usize, /// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU. @@ -104,14 +110,14 @@ impl PyroWaveEncoder { fps: u32, bitrate_bps: u64, chroma: crate::ChromaFormat, + bit_depth: u8, ) -> Result { - if chroma.is_444() { - // Negotiation can't reach here yet: `can_encode_444` returns false for PyroWave - // until the full-res-chroma BgraToYuvPlanes variant lands - // (design/pyrowave-444-hdr.md Phase 3). Threaded now so that flip is one-file. - bail!("pyrowave 4:4:4 encode not implemented yet (Phase 3)"); - } - if width % 2 != 0 || height % 2 != 0 { + let chroma444 = chroma.is_444(); + // A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style + // studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) — + // the wire is depth-agnostic, only the plane formats and the CSC change. + let hdr16 = bit_depth >= 10; + if !chroma444 && (width % 2 != 0 || height % 2 != 0) { bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); } let fps = fps.max(1); @@ -163,7 +169,11 @@ impl PyroWaveEncoder { device: pw_dev, width: width as i32, height: height as i32, - chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420, + chroma: if chroma444 { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444 + } else { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420 + }, }; let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut(); if let Err(e) = pw_check( @@ -179,7 +189,9 @@ impl PyroWaveEncoder { gpu = format!("{vid:04x}:{pid:04x}"), mode = %format!("{width}x{height}@{fps}"), budget_kib = frame_budget / 1024, - "PyroWave encoder open (Windows NV12 zero-copy, intra-only wavelet, BT.709 limited 4:2:0)" + chroma = if chroma444 { "4:4:4" } else { "4:2:0" }, + hdr = hdr16, + "PyroWave encoder open (Windows separate-plane zero-copy, intra-only wavelet)" ); Ok(Self { @@ -191,6 +203,8 @@ impl PyroWaveEncoder { width, height, fps, + chroma444, + hdr16, frame_budget, wire_chunk: None, bitstream: Vec::new(), @@ -355,13 +369,31 @@ impl PyroWaveEncoder { // full-res R8 Y on `d3d.texture`, the half-res R8G8 CbCr on `share.cbcr`. `pw_dev` is a Copy // handle so the cache closures don't borrow `self` alongside `&mut self.*_images`. let (w, h) = (self.width, self.height); + // Plane geometry/formats follow the negotiated session: chroma half- or full-res, + // 8-bit (SDR BT.709) or 16-bit UNORM (HDR: P010-style studio codes from the CSC). + let (cw, ch) = if self.chroma444 { + (w, h) + } else { + (w / 2, h / 2) + }; + let (yf, cf) = if self.hdr16 { + ( + pw::VkFormat_VK_FORMAT_R16_UNORM, + pw::VkFormat_VK_FORMAT_R16G16_UNORM, + ) + } else { + ( + pw::VkFormat_VK_FORMAT_R8_UNORM, + pw::VkFormat_VK_FORMAT_R8G8_UNORM, + ) + }; let pw_dev = self.pw_dev; let y_img = { let key = d3d.texture.as_raw() as isize; let tex = &d3d.texture; Self::cached_plane( &mut self.y_images, - || Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8_UNORM, w, h), + || Self::import_plane(pw_dev, tex, yf, w, h), key, )? }; @@ -370,7 +402,7 @@ impl PyroWaveEncoder { let tex = &share.cbcr; Self::cached_plane( &mut self.cbcr_images, - || Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8G8_UNORM, w / 2, h / 2), + || Self::import_plane(pw_dev, tex, cf, cw, ch), key, )? }; @@ -393,29 +425,27 @@ impl PyroWaveEncoder { swizzle, layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL, }; - let r8 = pw::VkFormat_VK_FORMAT_R8_UNORM; - let rg8 = pw::VkFormat_VK_FORMAT_R8G8_UNORM; let buffers = pw::pyrowave_gpu_buffers { planes: [ plane( y_vk, w, h, - r8, + yf, pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY, ), plane( cbcr_vk, - w / 2, - h / 2, - rg8, + cw, + ch, + cf, pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R, ), plane( cbcr_vk, - w / 2, - h / 2, - rg8, + cw, + ch, + cf, pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G, ), ], @@ -491,9 +521,11 @@ impl PyroWaveEncoder { )?; packets.truncate(out_n.max(1)); // Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC - // emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks. + // emits studio range — patch the bits HONEST so VUI-honoring clients don't wash out + // blacks; an HDR session additionally stamps BT.2020 primaries + PQ + BT.2020 matrix + // (matching the negotiated ColorInfo). if let Some(p) = packets.first() { - pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset); + pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, self.hdr16); } let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); @@ -611,7 +643,8 @@ mod tests { D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, }; use windows::Win32::Graphics::Dxgi::Common::{ - DXGI_FORMAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC, + DXGI_FORMAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R8G8_UNORM, + DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC, }; /// Decode a dense PyroWave AU with upstream's own decoder → YUV420P plane means (the golden @@ -619,7 +652,7 @@ mod tests { /// /// # Safety /// `au` must be a complete dense PyroWave AU for a `w`×`h` 4:2:0 frame. - unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8]) -> (f64, f64, f64) { + unsafe fn decode_plane_means(w: u32, h: u32, au: &[u8], chroma444: bool) -> (f64, f64, f64) { let mut dev: pw::pyrowave_device = std::ptr::null_mut(); assert_eq!( pw::pyrowave_create_default_device(&mut dev), @@ -629,7 +662,11 @@ mod tests { device: dev, width: w as i32, height: h as i32, - chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420, + chroma: if chroma444 { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444 + } else { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420 + }, fragment_path: false, }; let mut dec: pw::pyrowave_decoder = std::ptr::null_mut(); @@ -642,11 +679,16 @@ mod tests { pw::pyrowave_result_PYROWAVE_SUCCESS ); assert!(pw::pyrowave_decoder_decode_is_ready(dec, false)); + let (cw2, ch2) = if chroma444 { (w, h) } else { (w / 2, h / 2) }; let mut y = vec![0u8; (w * h) as usize]; - let mut cb = vec![0u8; (w * h / 4) as usize]; - let mut cr = vec![0u8; (w * h / 4) as usize]; + let mut cb = vec![0u8; (cw2 * ch2) as usize]; + let mut cr = vec![0u8; (cw2 * ch2) as usize]; let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed(); - buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P; + buf.format = if chroma444 { + pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV444P + } else { + pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P + }; buf.width = w as i32; buf.height = h as i32; buf.data = [ @@ -654,7 +696,7 @@ mod tests { cb.as_mut_ptr() as *mut _, cr.as_mut_ptr() as *mut _, ]; - buf.row_stride_in_bytes = [w as usize, (w / 2) as usize, (w / 2) as usize]; + buf.row_stride_in_bytes = [w as usize, cw2 as usize, cw2 as usize]; buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()]; assert_eq!( pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf), @@ -738,7 +780,7 @@ mod tests { /// /// # Safety /// Runs on a real D3D11 + Vulkan-1.3 GPU; all COM/FFI handles are locally owned. - unsafe fn run_case(w: u32, h: u32) -> (f64, f64, f64) { + unsafe fn run_case(w: u32, h: u32, hdr: bool, chroma444: bool) -> (f64, f64, f64) { // A fresh D3D11 device on the default hardware adapter. let mut device: Option = None; let mut context: Option = None; @@ -757,17 +799,45 @@ mod tests { let device = device.unwrap(); let context = context.unwrap(); - // Full-res R8 Y (=100) + half-res R8G8 CbCr (=180,60) — the exact layout the encoder ingests. - let y_tex = make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]); - let cbcr_tex = make_plane( - &device, - &context, - w / 2, - h / 2, - DXGI_FORMAT_R8G8_UNORM, - 2, - &[180, 60], - ); + // Distinct plane fills at the session's plane formats/geometry. 16-bit fills use + // v16 = v8 * 257 (0xVV,0xVV LE), whose UNORM value equals v8/255 EXACTLY — so the + // 8-bit decode means expect the same 100/180/60 in every mode. + let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) }; + let (y_tex, cbcr_tex) = if hdr { + ( + make_plane( + &device, + &context, + w, + h, + DXGI_FORMAT_R16_UNORM, + 2, + &[0x64, 0x64], + ), + make_plane( + &device, + &context, + cw, + ch, + DXGI_FORMAT_R16G16_UNORM, + 4, + &[0xB4, 0xB4, 0x3C, 0x3C], + ), + ) + } else { + ( + make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]), + make_plane( + &device, + &context, + cw, + ch, + DXGI_FORMAT_R8G8_UNORM, + 2, + &[180, 60], + ), + ) + }; // Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering). let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5"); @@ -783,8 +853,19 @@ mod tests { context.Flush(); // Encode the shared textures through the real backend. - let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000, crate::ChromaFormat::Yuv420) - .expect("PyroWaveEncoder::open"); + let mut enc = PyroWaveEncoder::open( + w, + h, + 60, + 100_000_000, + if chroma444 { + crate::ChromaFormat::Yuv444 + } else { + crate::ChromaFormat::Yuv420 + }, + if hdr { 10 } else { 8 }, + ) + .expect("PyroWaveEncoder::open"); let frame = CapturedFrame { width: w, height: h, @@ -813,7 +894,14 @@ mod tests { 0x40, "sequence header must signal ycbcr_range=LIMITED" ); - decode_plane_means(w, h, &au.data) + if hdr { + assert_eq!( + au.data[7] & 0x78, + 0x78, + "HDR sequence header must signal BT.2020 primaries + PQ + BT.2020 matrix" + ); + } + decode_plane_means(w, h, &au.data, chroma444) } /// The Windows NV12 zero-copy path end-to-end on a real GPU. `#[ignore]`d (needs D3D11 + a @@ -825,16 +913,30 @@ mod tests { #[test] #[ignore = "needs a real D3D11 + Vulkan-1.3 GPU (run on the Windows host, not the build box)"] fn pyrowave_win_smoke() { - for (w, h) in [(1024u32, 1024u32), (1280, 720), (1920, 1080), (2560, 1440)] { + // The SDR 4:2:0 base case across real streaming sizes (the NVIDIA import + // size-sensitivity check), then every other (hdr, chroma) mode at two sizes — + // the R16/R16G16 and full-res-chroma imports are new surface for the same quirk. + let mut cases = vec![ + (1024u32, 1024u32, false, false), + (1280, 720, false, false), + (1920, 1080, false, false), + (2560, 1440, false, false), + ]; + for &(hdr, c444) in &[(false, true), (true, false), (true, true)] { + cases.push((1280, 720, hdr, c444)); + cases.push((1920, 1080, hdr, c444)); + } + for (w, h, hdr, c444) in cases { // SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches. - let (ym, cbm, crm) = unsafe { run_case(w, h) }; + let (ym, cbm, crm) = unsafe { run_case(w, h, hdr, c444) }; eprintln!( - "{w}x{h}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} (expect 100/180/60)" + "{w}x{h} hdr={hdr} 444={c444}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} \ + (expect 100/180/60)" ); assert!( (ym - 100.0).abs() < 6.0 && (cbm - 180.0).abs() < 6.0 && (crm - 60.0).abs() < 6.0, - "{w}x{h}: NV12 round-trip means (Y {ym:.1}, Cb {cbm:.1}, Cr {crm:.1}) drifted from \ - the filled 100/180/60 — chroma plane mapping wrong (swap? wrong plane?)" + "{w}x{h} hdr={hdr} 444={c444}: round-trip means (Y {ym:.1}, Cb {cbm:.1}, \ + Cr {crm:.1}) drifted from the filled 100/180/60 — plane mapping/format wrong" ); } } diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 8ca47791..9fd0ef29 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -428,9 +428,16 @@ fn open_video_backend( if codec == Codec::PyroWave { #[cfg(feature = "pyrowave")] { - let _ = (format, cuda, bit_depth); - return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma) - .map(|e| (Box::new(e) as Box, "pyrowave")); + let _ = (format, cuda); + return pyrowave::PyroWaveEncoder::open( + width, + height, + fps, + bitrate_bps, + chroma, + bit_depth, + ) + .map(|e| (Box::new(e) as Box, "pyrowave")); } #[cfg(not(feature = "pyrowave"))] anyhow::bail!( @@ -872,9 +879,9 @@ pub fn can_encode_444(codec: Codec) -> bool { if codec == Codec::PyroWave { // PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source), // so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant: - // `rgb2yuv444.comp` on Linux (landed, design/pyrowave-444-hdr.md Phase 2); the - // Windows `BgraToYuvPlanes` twin is Phase 3. - return cfg!(target_os = "linux"); + // `rgb2yuv444.comp` on Linux (Phase 2) and the mode-aware `BgraToYuvPlanes` on + // Windows (Phase 3) — both landed (design/pyrowave-444-hdr.md). + return true; } if codec != Codec::H265 { return false; @@ -961,6 +968,12 @@ pub fn can_encode_10bit(codec: Codec) -> bool { if !codec.supports_10bit() { return false; } + if codec == Codec::PyroWave { + // PyroWave needs no GPU encode probe (the wavelet is depth-agnostic) — only the HDR + // capture CSC (scRGB FP16 → 16-bit studio-code planes), which exists on the Windows + // IDD-push path only (design/pyrowave-444-hdr.md Phase 3; Linux capture has no HDR). + return cfg!(target_os = "windows"); + } // Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly // selected adapter before the next Welcome, mirroring `can_encode_444`. static CACHE: OnceLock>> = OnceLock::new(); diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 02b8578b..22c957c2 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1009,7 +1009,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // else decodes the codec); only device loss ends the session. #[cfg(all(target_os = "linux", feature = "pyrowave"))] DecodedImage::PyroWave(f) => { - st.hdr = false; // 8-bit SDR codec + // The wavelet stream carries the negotiated ColorInfo (no VUI): an + // HDR (PQ) pyrowave session presents through the HDR10 path exactly + // like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3). + st.hdr = f.color.is_pq(); match presenter.present( &window, FrameInput::PyroWave(f), diff --git a/crates/pf-presenter/src/vk/present.rs b/crates/pf-presenter/src/vk/present.rs index 710daaa9..af49c44e 100644 --- a/crates/pf-presenter/src/vk/present.rs +++ b/crates/pf-presenter/src/vk/present.rs @@ -39,7 +39,7 @@ impl Presenter { #[cfg(windows)] FrameInput::D3d11(d) => Some(d.color.is_pq()), #[cfg(all(target_os = "linux", feature = "pyrowave"))] - FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today + FrameInput::PyroWave(f) => Some(f.color.is_pq()), }; if let Some(pq) = frame_pq { // A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind @@ -750,11 +750,31 @@ impl Presenter { &[planar.desc_set], &[], ); - let rows = csc_rows(color, 8, false); + // An HDR (PQ) pyrowave session carries P010-style 10-bit studio codes MSB-packed + // into 16-bit planes (design/pyrowave-444-hdr.md §2.2) — same sampling scale as + // the P010 path; SDR sessions are plain 8-bit BT.709 limited. Depth follows the + // colour contract (negotiation couples 10-bit ⟺ PQ for this codec). + let (depth, msb_packed) = if color.is_pq() { + (10, true) + } else { + (8, false) + }; + let rows = csc_rows(color, depth, msb_packed); + // Mode 1 = PQ→SDR tonemap (PQ stream without an HDR10 surface); mode 0 passes + // the transfer through — identical to the NV12 arm above. + 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::().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] = 0.0; // SDR passthrough — PyroWave has no PQ path - pc[13] = 0.0; + pc[12] = mode; + pc[13] = peak; let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); self.device.cmd_push_constants( self.cmd_buf, diff --git a/crates/pf-presenter/src/vk/reconfig.rs b/crates/pf-presenter/src/vk/reconfig.rs index f1b4b807..ac3e37ea 100644 --- a/crates/pf-presenter/src/vk/reconfig.rs +++ b/crates/pf-presenter/src/vk/reconfig.rs @@ -203,11 +203,15 @@ impl Presenter { vk::Format::R8G8B8A8_UNORM }; self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it - #[cfg(all(target_os = "linux", feature = "pyrowave"))] - if let Some(p) = &self.csc_planar { - p.destroy(&self.device); - } self.csc = CscPass::new(&self.device, self.video_format)?; + // The planar (PyroWave) pass renders to the same intermediate — rebuild it at the + // new format too (an HDR pyrowave session needs the 10-bit intermediate exactly + // like the H.26x path; 8-bit PQ bands visibly). + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + if let Some(p) = self.csc_planar.take() { + p.destroy(&self.device); + self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?); + } if let Some(v) = self.video.take() { unsafe { self.device.destroy_framebuffer(v.framebuffer, None); diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index fb28a7be..642b1126 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -315,7 +315,8 @@ impl Presenter { ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device), }); let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?; - // PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild. + // Starts SDR like `csc`; an HDR (PQ) pyrowave session rebuilds it at the 10-bit + // intermediate via `set_hdr_mode`, exactly like the H.26x pass. #[cfg(all(target_os = "linux", feature = "pyrowave"))] let csc_planar = if pyrowave_ok { Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)