From ebd9967547de82145f3bbe99df7ccdcb80becfb3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 02:38:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(pyrowave):=20Windows=20host=20encoder=20?= =?UTF-8?q?=E2=80=94=20separate-plane=20zero-copy=20D3D11=E2=86=92Vulkan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire PyroWave into the Windows host (design/pyrowave-windows-host-zerocopy.md). Before this a macOS client + Windows host that both selected PyroWave silently ran HEVC: the host never advertised CODEC_PYROWAVE and open_video_backend bailed. Approach (zero-copy, no GPU→CPU→GPU): pyrowave owns its own Vulkan device (create_device_by_compat, by render-GPU vendor/device-id — NOT LUID, invalid in Session 0). The capturer runs a BGRA→YUV BT.709-limited CSC (matching rgb2yuv.comp) into TWO SEPARATE shareable plane textures — full-res R8 Y + half-res R8G8 CbCr — which the encoder imports into pyrowave's device. Separate single/two-component textures import reliably on NVIDIA at any size; a single planar NV12 import does NOT (the vendored interop test: "only very specific resource sizes" — confirmed on-glass: 1024² fine, 720p/1080p/1440p garbage). A shared D3D11 fence, signalled after the CSC, is imported as a Vulkan timeline semaphore so the wavelet read is ordered after it. - pf-encode: enc/windows/pyrowave.rs (Encoder impl, two-plane import + Linux-style plane views); host_wire_caps advertises CODEC_PYROWAVE on Windows when the backend isn't Software; open_video_backend routes a negotiated PyroWave session first; pyrowave-sys on the Windows target; interop confirmed at open → clean HEVC fallback. - pf-encode: shared, unit-tested enc/pyrowave_wire.rs (single source of truth for the client-facing AU framing); Linux encoder uses it too. - pf-capture: dxgi.rs BgraToYuvPlanes CSC; idd_push.rs pyrowave mode — forces the virtual display SDR (the VideoProcessor can't ingest the FP16 HDR ring), a two-plane shareable out-ring, a shared fence passed every frame (so a rebuilt encoder re-imports it). Threaded via OutputFormat::pyrowave. - pf-frame: D3d11Frame::pyro carries the CbCr plane + fence; OutputFormat::pyrowave. Verified on .173 (RTX 4090): full-host build + clippy -D warnings (nvenc,amf-qsv) + fmt --all --check; pyrowave_wire unit tests; pyrowave_win_smoke GPU test round-trips distinct Y/Cb/Cr (100/180/60) exactly at 1024²/720p/1080p/1440p; Stage-0 interop validated in the real Session-0 service context on-glass. Deployed to the box. Owed: final on-glass picture/latency confirmation. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-capture/src/lib.rs | 13 +- crates/pf-capture/src/windows/dxgi.rs | 144 +++- crates/pf-capture/src/windows/idd_push.rs | 394 ++++++++- .../pf-capture/src/windows/synthetic_nv12.rs | 1 + crates/pf-encode/Cargo.toml | 5 + crates/pf-encode/src/enc/linux/pyrowave.rs | 76 +- crates/pf-encode/src/enc/pyrowave_wire.rs | 164 ++++ crates/pf-encode/src/enc/windows/amf.rs | 4 + crates/pf-encode/src/enc/windows/nvenc.rs | 2 + crates/pf-encode/src/enc/windows/pyrowave.rs | 815 ++++++++++++++++++ crates/pf-encode/src/enc/windows/qsv.rs | 1 + crates/pf-encode/src/lib.rs | 43 +- crates/pf-frame/src/dxgi.rs | 27 +- crates/pf-frame/src/lib.rs | 9 + crates/punktfunk-host/src/capture.rs | 12 +- crates/punktfunk-host/src/session_plan.rs | 5 + 16 files changed, 1595 insertions(+), 120 deletions(-) create mode 100644 crates/pf-encode/src/enc/pyrowave_wire.rs create mode 100644 crates/pf-encode/src/enc/windows/pyrowave.rs diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 07f39ab8..ea3af07d 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -365,9 +365,18 @@ pub fn open_idd_push( preferred: Option<(u32, u32, u32)>, client_10bit: bool, want_444: bool, + pyrowave: bool, keepalive: Box, sender: FrameChannelSender, ) -> std::result::Result, (anyhow::Error, Box)> { - idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender) - .map(|c| Box::new(c) as Box) + idd_push::IddPushCapturer::open( + target, + preferred, + client_10bit, + want_444, + pyrowave, + keepalive, + sender, + ) + .map(|c| Box::new(c) as Box) } diff --git a/crates/pf-capture/src/windows/dxgi.rs b/crates/pf-capture/src/windows/dxgi.rs index 6fcc04dd..9ebc3756 100644 --- a/crates/pf-capture/src/windows/dxgi.rs +++ b/crates/pf-capture/src/windows/dxgi.rs @@ -12,7 +12,7 @@ // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] -pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, WinCaptureTarget}; +pub use pf_frame::dxgi::{make_device, pack_luid, D3d11Frame, PyroFrameShare, WinCaptureTarget}; use anyhow::{bail, Context, Result}; use std::ffi::c_void; @@ -466,6 +466,120 @@ impl HdrP010Converter { } } +/// PyroWave LUMA pass PS — full-res, writes Y′ to a separate `R8_UNORM` texture. BT.709 limited from +/// the 8-bit sRGB (gamma) BGRA slot, BYTE-IDENTICAL to the Linux `rgb2yuv.comp` `lumaY` (so the +/// wavelet client — whose golden fixtures come from that shader — decodes the same colours). `Load` +/// (texelFetch) reads the exact source texel: RTV pixel (x,y) → source texel (x,y). +const PYRO_Y_PS: &str = r" +Texture2D tx : register(t0); +float main(float4 pos : SV_POSITION) : SV_TARGET { + float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb; + return 16.0/255.0 + 0.1826*c.r + 0.6142*c.g + 0.0620*c.b; +} +"; + +/// PyroWave CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to a separate `R8G8_UNORM` texture. +/// **2×2 box average** (centre-sited) of the four luma-block RGB texels, then BT.709 limited Cb/Cr — +/// BYTE-IDENTICAL to `rgb2yuv.comp` (which averages `(c00+c10+c01+c11)*0.25` then U/V), so the chroma +/// siting matches the client's decoder. Even dimensions guarantee the 2×2 block is in-bounds. +const PYRO_UV_PS: &str = r" +Texture2D tx : register(t0); +float2 main(float4 pos : SV_POSITION) : SV_TARGET { + int2 p = int2(pos.xy) * 2; + float3 c00 = tx.Load(int3(p, 0)).rgb; + float3 c10 = tx.Load(int3(p + int2(1,0), 0)).rgb; + float3 c01 = tx.Load(int3(p + int2(0,1), 0)).rgb; + float3 c11 = tx.Load(int3(p + int2(1,1), 0)).rgb; + float3 a = (c00 + c10 + c01 + c11) * 0.25; + float u = 128.0/255.0 - 0.1006*a.r - 0.3386*a.g + 0.4392*a.b; + float v = 128.0/255.0 + 0.4392*a.r - 0.3989*a.g - 0.0403*a.b; + return float2(u, v); +} +"; + +/// 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. +pub(crate) struct BgraToYuvPlanes { + vs: ID3D11VertexShader, + ps_y: ID3D11PixelShader, + ps_uv: ID3D11PixelShader, +} + +impl BgraToYuvPlanes { + pub(crate) unsafe fn new(device: &ID3D11Device) -> Result { + 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 mut vs = None; + device.CreateVertexShader(&vsb, None, Some(&mut vs))?; + let mut ps_y = None; + device.CreatePixelShader(&yb, None, Some(&mut ps_y))?; + let mut ps_uv = None; + device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?; + Ok(Self { + vs: vs.context("pyro vs")?, + ps_y: ps_y.context("pyro y ps")?, + ps_uv: ps_uv.context("pyro uv ps")?, + }) + } + + /// 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). + #[allow(clippy::too_many_arguments)] + pub(crate) unsafe fn convert( + &self, + ctx: &ID3D11DeviceContext, + src_srv: &ID3D11ShaderResourceView, + y_rtv: &ID3D11RenderTargetView, + cbcr_rtv: &ID3D11RenderTargetView, + w: u32, + h: u32, + ) -> Result<()> { + ctx.OMSetBlendState(None, None, 0xffff_ffff); // opaque overwrite + ctx.VSSetShader(&self.vs, None); + ctx.PSSetShaderResources(0, Some(&[Some(src_srv.clone())])); + ctx.IASetInputLayout(None); + ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + + // LUMA pass: full-res → the R8 Y texture. + ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { + TopLeftX: 0.0, + TopLeftY: 0.0, + Width: w as f32, + Height: h as f32, + MinDepth: 0.0, + MaxDepth: 1.0, + }])); + ctx.OMSetRenderTargets(Some(&[Some(y_rtv.clone())]), None); + ctx.PSSetShader(&self.ps_y, None); + ctx.Draw(3, 0); + ctx.OMSetRenderTargets(Some(&[None]), None); + + // CHROMA pass: half-res → the R8G8 CbCr texture. + ctx.RSSetViewports(Some(&[D3D11_VIEWPORT { + TopLeftX: 0.0, + TopLeftY: 0.0, + Width: (w / 2) as f32, + Height: (h / 2) as f32, + MinDepth: 0.0, + MaxDepth: 1.0, + }])); + ctx.OMSetRenderTargets(Some(&[Some(cbcr_rtv.clone())]), None); + ctx.PSSetShader(&self.ps_uv, None); + ctx.Draw(3, 0); + + ctx.OMSetRenderTargets(Some(&[None]), None); + ctx.PSSetShaderResources(0, Some(&[None])); + Ok(()) + } +} + /// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`]. /// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is /// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block. @@ -829,8 +943,7 @@ use windows::Win32::Graphics::Direct3D11::{ }; use windows::Win32::Graphics::Dxgi::Common::{ DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, - DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, - DXGI_RATIONAL, + DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_RATIONAL, }; /// D3D11 **Video Processor** colour/format converter — runs on the GPU's dedicated VIDEO engine, NOT @@ -846,12 +959,17 @@ pub(crate) struct VideoConverter { } impl VideoConverter { + /// A BGRA/FP16-RGB → **NV12 (BT.709 limited SDR)** video-engine converter. `scrgb_input` picks + /// the input colour space: `false` = 8-bit sRGB `BGRA` (the SDR ring); `true` = FP16 scRGB + /// linear (the HDR ring, used by a PyroWave session that tone-maps the HDR desktop down to the + /// 8-bit wavelet stream). The output is always studio-range BT.709 NV12 — the P010/BT.2020 HDR + /// path is [`HdrP010Converter`]'s job, never this one. pub(crate) unsafe fn new( device: &ID3D11Device, context: &ID3D11DeviceContext, width: u32, height: u32, - hdr: bool, + scrgb_input: bool, ) -> Result { let vdev: ID3D11VideoDevice = device.cast().context("device -> ID3D11VideoDevice")?; let vctx: ID3D11VideoContext1 = context.cast().context("context -> ID3D11VideoContext1")?; @@ -876,19 +994,15 @@ impl VideoConverter { .CreateVideoProcessor(&enumr, 0) .context("CreateVideoProcessor")?; - // Full-range RGB in → studio-range YUV out. HDR: scRGB linear (G10) → BT.2020 PQ (G2084). - // SDR: sRGB (G22) → BT.709 (G22). - let (in_cs, out_cs) = if hdr { - ( - DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709, - DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, - ) + // Full-range RGB in → studio-range BT.709 NV12 out. Input gamma follows the ring format: + // scRGB linear (G10) for the FP16 HDR ring, sRGB (G22) for the 8-bit BGRA SDR ring. The + // output is always BT.709 SDR (the video processor tone-maps the scRGB case). + let in_cs = if scrgb_input { + DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709 } else { - ( - DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, - DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, - ) + DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 }; + let out_cs = DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709; vctx.VideoProcessorSetStreamColorSpace1(&vp, 0, in_cs); vctx.VideoProcessorSetOutputColorSpace1(&vp, out_cs); // One frame in, one frame out — no interpolation/auto-processing. diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 3dfa7402..2e6a7ad1 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -19,7 +19,10 @@ // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] -use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget}; +use super::dxgi::{ + make_device, BgraToYuvPlanes, D3d11Frame, HdrP010Converter, PyroFrameShare, VideoConverter, + WinCaptureTarget, +}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use anyhow::{bail, Context, Result}; use pf_driver_proto::{control, frame}; @@ -33,13 +36,15 @@ use windows::Win32::Foundation::{ HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0, }; use windows::Win32::Graphics::Direct3D11::{ - ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, - D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, - D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, + ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, ID3D11Fence, + ID3D11RenderTargetView, ID3D11ShaderResourceView, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, + D3D11_BIND_SHADER_RESOURCE, D3D11_FENCE_FLAG_SHARED, D3D11_RESOURCE_MISC_SHARED, + D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, + D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, }; use windows::Win32::Graphics::Dxgi::Common::{ DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, - DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC, + DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC, }; use windows::Win32::Graphics::Dxgi::{ CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1, @@ -142,6 +147,18 @@ struct HostSlot { srv: ID3D11ShaderResourceView, } +/// One PyroWave output-ring slot: the two SEPARATE shareable plane textures the wavelet encoder +/// imports (design/pyrowave-windows-host-zerocopy.md) plus their RTVs (the [`BgraToYuvPlanes`] CSC +/// renders into them). Y is full-res `R8_UNORM`, CbCr is half-res `R8G8_UNORM`; both are +/// `SHARED | SHARED_NTHANDLE`. Rotated per frame like `out_ring` so encode N and convert N+1 touch +/// different textures. +struct PyroOutSlot { + y: ID3D11Texture2D, + y_rtv: ID3D11RenderTargetView, + cbcr: ID3D11Texture2D, + cbcr_rtv: ID3D11RenderTargetView, +} + /// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`, /// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end /// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on @@ -391,6 +408,29 @@ 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`. + 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. + pyro_fence: Option, + /// PyroWave: the fence's persistent shared NT handle (raw), passed on EVERY frame. The encoder + /// DUPLICATEs + imports it as a Vulkan timeline semaphore whenever it has none (first frame or + /// after an encoder rebuild), so this original stays valid across rebuilds. + pyro_fence_handle: Option, + /// PyroWave: the monotonically increasing fence value (one `Signal` per emitted frame). + pyro_fence_value: u64, + /// PyroWave: the separate-plane output ring (Y R8 + CbCr R8G8 shareable textures + RTVs), used + /// INSTEAD of `out_ring` for a pyrowave session. Built lazily; rebuilt on a mode change. + pyro_ring: Vec, + /// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily. + pyro_conv: Option, + /// PyroWave: the last presented (Y, CbCr) textures — the repeat source (analogue of + /// `last_present` for the two-plane path). + pyro_last: Option<(ID3D11Texture2D, ID3D11Texture2D)>, /// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads /// its snapshot instead of running CCD queries inline on the frame path. desc_poller: DescriptorPoller, @@ -556,18 +596,20 @@ impl IddPushCapturer { /// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA /// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the /// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch). + #[allow(clippy::too_many_arguments)] pub fn open( target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, client_10bit: bool, want_444: bool, + pyrowave: bool, keepalive: Box, sender: crate::FrameChannelSender, ) -> std::result::Result)> { // The stall-attribution listener (idempotent): started with the first IDD-push capturer so // the stall log can correlate DWM holes with OS display events for the session's lifetime. pf_win_display::display_events::spawn_once(); - match Self::open_inner(target, preferred, client_10bit, want_444, sender) { + match Self::open_inner(target, preferred, client_10bit, want_444, pyrowave, sender) { Ok(mut me) => { me._keepalive = keepalive; Ok(me) @@ -576,11 +618,13 @@ impl IddPushCapturer { } } + #[allow(clippy::too_many_arguments)] fn open_inner( target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, client_10bit: bool, want_444: bool, + pyrowave: bool, sender: crate::FrameChannelSender, ) -> Result { // The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the @@ -601,6 +645,7 @@ impl IddPushCapturer { preferred, client_10bit, want_444, + pyrowave, luid, sender.clone(), ) { @@ -628,17 +673,27 @@ impl IddPushCapturer { "IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \ driver's reported adapter" ); - Self::open_on(target, preferred, client_10bit, want_444, drv, sender) - .context("IDD-push rebind to the driver's reported render adapter") + Self::open_on( + target, + preferred, + client_10bit, + want_444, + pyrowave, + drv, + sender, + ) + .context("IDD-push rebind to the driver's reported render adapter") } } } + #[allow(clippy::too_many_arguments)] fn open_on( target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>, client_10bit: bool, want_444: bool, + pyrowave: bool, luid: LUID, sender: crate::FrameChannelSender, ) -> Result { @@ -691,11 +746,46 @@ 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 { + let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false); + let settle = Instant::now(); + while settle.elapsed() < Duration::from_millis(250) { + if pf_win_display::win_display::advanced_color_enabled(target.target_id) + == Some(false) + { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + if pf_win_display::win_display::advanced_color_enabled(target.target_id) + == Some(true) + { + tracing::error!( + target = target.target_id, + "IDD push: PyroWave session but advanced color (HDR) could NOT be turned off \ + on the virtual display — the FP16 ring can't feed the wavelet encoder (a \ + physical display forcing HDR?); the session will likely fail its first frame" + ); + } else { + tracing::info!( + target = target.target_id, + settle_ms = settle.elapsed().as_millis() as u64, + "IDD push: PyroWave — advanced color forced OFF (SDR/BGRA composition)" + ); + } + } // If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have // 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: @@ -721,9 +811,11 @@ 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. - let display_hdr = enabled_hdr - || pf_win_display::win_display::advanced_color_enabled(target.target_id) - .unwrap_or(false); + // PyroWave forced advanced color OFF above, so it is always SDR (never the FP16 ring). + let display_hdr = !pyrowave + && (enabled_hdr + || pf_win_display::win_display::advanced_color_enabled(target.target_id) + .unwrap_or(false)); // Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was // NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display // could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit @@ -853,6 +945,13 @@ impl IddPushCapturer { client_10bit, display_hdr, want_444, + pyrowave, + pyro_fence: None, + pyro_fence_handle: None, + pyro_fence_value: 0, + pyro_ring: Vec::new(), + pyro_conv: None, + pyro_last: None, desc_poller: DescriptorPoller::spawn( target.target_id, DisplayDescriptor { @@ -1128,6 +1227,13 @@ 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. + if self.pyrowave { + return (DXGI_FORMAT_NV12, PixelFormat::Nv12); + } if self.display_hdr { if self.want_444 { warn_444_hdr_downgrade_once(); @@ -1215,6 +1321,8 @@ impl IddPushCapturer { self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode self.hdr_p010_conv = None; + self.pyro_ring.clear(); // PyroWave two-plane ring is sized → rebuild at the new mode + self.pyro_last = None; self.out_idx = 0; self.last_present = None; Ok(()) @@ -1228,11 +1336,22 @@ impl IddPushCapturer { /// only when TWO consecutive samples agree on the same new descriptor (~½ s), so a /// single-sample transient during a topology re-probe never costs a ring recreate. fn poll_display_hdr(&mut self) { - let (now, seq) = self.desc_poller.snapshot(); + let (mut now, seq) = self.desc_poller.snapshot(); if seq == self.desc_seq { 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 { + // 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); + } + now.hdr = false; + } let current = DisplayDescriptor { hdr: self.display_hdr, width: self.width, @@ -1281,7 +1400,8 @@ impl IddPushCapturer { }, Usage: D3D11_USAGE_DEFAULT, // RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and - // NVENC registers it as encode input — matching the WGC YUV ring. + // NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own + // shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.) BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, CPUAccessFlags: 0, MiscFlags: 0, @@ -1302,6 +1422,73 @@ impl IddPushCapturer { Ok(()) } + /// PyroWave: build the separate-plane output ring (`OUT_RING` × {full-res R8 Y, half-res R8G8 + /// CbCr}, both `SHARED | SHARED_NTHANDLE` + RTV) if not yet built. The wavelet encoder imports the + /// two SEPARATE textures (a single planar NV12 import is unreliable on NVIDIA); the + /// [`BgraToYuvPlanes`] CSC renders into their RTVs. + fn ensure_pyro_ring(&mut self) -> Result<()> { + if !self.pyro_ring.is_empty() { + return Ok(()); + } + let (w, h) = (self.width, self.height); + // SAFETY: all D3D11 calls target `self.device`; every `&desc` is a fully-initialized stack + // struct and every `Some(&mut _)` a live out-param; `?` rejects a failed HRESULT before use. + // The created textures/RTVs belong to `self.device`. + unsafe { + let make = |dev: &ID3D11Device, + fmt: DXGI_FORMAT, + w: u32, + h: u32| + -> Result<(ID3D11Texture2D, ID3D11RenderTargetView)> { + let desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: fmt, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 + | D3D11_RESOURCE_MISC_SHARED.0) as u32, + }; + let mut tex: Option = None; + dev.CreateTexture2D(&desc, None, Some(&mut tex)) + .context("CreateTexture2D(pyro plane)")?; + let tex = tex.context("null pyro plane texture")?; + let mut rtv: Option = None; + dev.CreateRenderTargetView(&tex, None, Some(&mut rtv)) + .context("CreateRenderTargetView(pyro plane)")?; + Ok((tex, rtv.context("null pyro plane rtv")?)) + }; + 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)?; + self.pyro_ring.push(PyroOutSlot { + y, + y_rtv, + cbcr, + cbcr_rtv, + }); + } + } + Ok(()) + } + + /// PyroWave: build the BGRA→YUV-planes CSC if not yet built. + 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)? }); + } + Ok(()) + } + /// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an /// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM. /// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`). @@ -1327,6 +1514,61 @@ impl IddPushCapturer { Ok(()) } + /// PyroWave: after this frame's GPU convert, `Signal` the shared fence and return the fence + /// `(handle, value)` for the encoder — the persistent shared handle EVERY frame (the encoder + /// imports it whenever it has no timeline yet, e.g. after a mode-switch rebuild) + the + /// incrementing value. `None` for a non-PyroWave session. The fence + its shared handle are + /// created lazily on the first call. `Flush` submits the queued convert + signal so the encoder's + /// cross-API Vulkan timeline wait resolves promptly instead of blocking on a still-unsubmitted + /// signal. The caller pairs the returned fence with the frame's CbCr texture into a + /// [`PyroFrameShare`]. + /// + /// # Safety + /// Runs on the owning capture/encode thread that holds the immediate context; forms no lasting + /// borrow of `self`'s COM objects. + unsafe fn pyro_fence_signal(&mut self) -> Result, u64)>> { + if !self.pyrowave { + return Ok(None); + } + if self.pyro_fence.is_none() { + let dev5: ID3D11Device5 = self + .device + .cast() + .context("ID3D11Device -> ID3D11Device5 (shared fence)")?; + // windows-rs returns COM interfaces via an out-param (unlike the HANDLE-returning + // CreateSharedHandle below). + let mut fence_out: Option = None; + dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence_out) + .context("CreateFence(D3D11_FENCE_FLAG_SHARED)")?; + let fence = fence_out.context("null D3D11 fence")?; + // GENERIC_ALL (0x1000_0000) — the access the pyrowave interop test hands the handle. + let handle: HANDLE = fence + .CreateSharedHandle(None, 0x1000_0000, PCWSTR::null()) + .context("ID3D11Fence::CreateSharedHandle")?; + self.pyro_fence = Some(fence); + self.pyro_fence_handle = Some(handle.0 as isize); + self.pyro_fence_value = 0; + } + self.pyro_fence_value += 1; + let value = self.pyro_fence_value; + let ctx4: ID3D11DeviceContext4 = self + .context + .cast() + .context("ID3D11DeviceContext -> ID3D11DeviceContext4 (fence signal)")?; + { + let fence = self.pyro_fence.as_ref().expect("fence just created"); + ctx4.Signal(fence, value) + .context("ID3D11 fence Signal after convert")?; + } + // Submit the queued convert + signal so the encoder's Vulkan timeline wait can resolve. + self.context.Flush(); + // Pass the persistent shared handle EVERY frame (not once): the encoder can be rebuilt on a + // client mode-switch, and a rebuilt encoder needs to re-import the fence into its fresh Vulkan + // device. The encoder imports only when it has no timeline yet (and DUPLICATES the handle so + // this original stays valid for the next rebuild). + Ok(Some((self.pyro_fence_handle, value))) + } + fn try_consume(&mut self) -> Result> { self.log_driver_status_once(); // Follow the display: a "Use HDR" flip recreates the ring at the matching format. @@ -1391,13 +1633,34 @@ impl IddPushCapturer { if seq == self.last_seq || slot >= self.slots.len() { return Ok(None); } - self.ensure_out_ring()?; - // Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can - // `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot). - self.ensure_converter()?; + // Build the ring + converter BEFORE acquiring the slot so nothing between Acquire and Release + // can `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot). + // PyroWave uses its OWN two-plane ring (`pyro_ring`); everything else the single NV12/BGRA ring. let i = self.out_idx; - let out = self.out_ring[i].clone(); + let (out, pyro_slot) = if self.pyrowave { + self.ensure_pyro_ring()?; + self.ensure_pyro_conv()?; + let s = &self.pyro_ring[i]; + ( + None, + Some(( + s.y.clone(), + s.y_rtv.clone(), + s.cbcr.clone(), + s.cbcr_rtv.clone(), + )), + ) + } else { + self.ensure_out_ring()?; + self.ensure_converter()?; + (Some(self.out_ring[i].clone()), None) + }; let (_, pf) = self.out_format(); + let ring_len = if self.pyrowave { + self.pyro_ring.len() + } else { + self.out_ring.len() + }; // Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the // ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets @@ -1414,14 +1677,30 @@ impl IddPushCapturer { // A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing // the slot back to the driver. unsafe { - if self.display_hdr { + 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.) + let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot"); + if let Some(conv) = self.pyro_conv.as_ref() { + conv.convert( + &self.context, + &s.srv, + y_rtv, + cbcr_rtv, + self.width, + self.height, + )?; + } + } else if self.display_hdr { // HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010. if let Some(conv) = self.hdr_p010_conv.as_ref() { conv.convert( &self.device, &self.context, &s.srv, - &out, + out.as_ref().expect("out ring"), self.width, self.height, )?; @@ -1430,19 +1709,24 @@ impl IddPushCapturer { // SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma // RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain // copy-engine move; the slot releases back to the driver immediately. - self.context.CopyResource(&out, &s.tex); + self.context + .CopyResource(out.as_ref().expect("out ring"), &s.tex); } else { // SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC. if let Some(conv) = self.video_conv.as_ref() { - conv.convert(&s.tex, &out)?; + conv.convert(&s.tex, out.as_ref().expect("out ring"))?; } } } // `_lock` drops here → `ReleaseSync(0)`. } - self.out_idx = (i + 1) % self.out_ring.len(); + self.out_idx = (i + 1) % ring_len; self.last_seq = seq; - self.last_present = Some((out.clone(), pf)); + if let Some((y, _, cbcr, _)) = pyro_slot.as_ref() { + self.pyro_last = Some((y.clone(), cbcr.clone())); + } else { + self.last_present = Some((out.as_ref().expect("out ring").clone(), pf)); + } let now = Instant::now(); if self.recovering_since.take().is_some() { // A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring @@ -1517,14 +1801,33 @@ impl IddPushCapturer { } } self.last_fresh = now; // feeds the driver-death watch + // Build the frame. For PyroWave the encode input is the Y plane + // (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence + // after the convert above. SAFETY: on the owning capture/encode thread. + let (texture, pyro) = if let Some((y, _, cbcr, _)) = pyro_slot { + // SAFETY: on the owning capture/encode thread holding the immediate context. + let (fence_handle, fence_value) = + unsafe { self.pyro_fence_signal() }?.expect("pyrowave session signals its fence"); + ( + y, + Some(PyroFrameShare { + cbcr, + fence_handle, + fence_value, + }), + ) + } else { + (out.expect("out ring texture"), None) + }; Ok(Some(CapturedFrame { width: self.width, height: self.height, pts_ns: now_ns(), format: pf, payload: FramePayload::D3d11(D3d11Frame { - texture: out, + texture, device: self.device.clone(), + pyro, }), cursor: None, })) @@ -1535,8 +1838,46 @@ impl IddPushCapturer { // new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the // out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3). // OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight. - let (src, pf) = self.last_present.clone()?; let i = self.out_idx; + // PyroWave: copy the last Y+CbCr into a fresh two-plane slot; texture = Y, CbCr + fence in `pyro`. + if self.pyrowave { + let (src_y, src_cbcr) = self.pyro_last.clone()?; + let slot = self.pyro_ring.get(i)?; + let (dst_y, dst_cbcr) = (slot.y.clone(), slot.cbcr.clone()); + // SAFETY: GPU copies on the owning thread's immediate context; src/dst are our own pyro-ring + // plane textures of identical format/size. + unsafe { + self.context.CopyResource(&dst_y, &src_y); + self.context.CopyResource(&dst_cbcr, &src_cbcr); + } + self.out_idx = (i + 1) % self.pyro_ring.len(); + self.pyro_last = Some((dst_y.clone(), dst_cbcr.clone())); + // Fence the copies above so the encoder reads completed textures. SAFETY: owning thread. + let (fence_handle, fence_value) = match unsafe { self.pyro_fence_signal() } { + Ok(Some(f)) => f, + _ => { + tracing::warn!("pyrowave: fence signal failed on a repeat frame — dropping it"); + return None; + } + }; + return Some(CapturedFrame { + width: self.width, + height: self.height, + pts_ns: now_ns(), + format: self.out_format().1, + payload: FramePayload::D3d11(D3d11Frame { + texture: dst_y, + device: self.device.clone(), + pyro: Some(PyroFrameShare { + cbcr: dst_cbcr, + fence_handle, + fence_value, + }), + }), + cursor: None, + }); + } + let (src, pf) = self.last_present.clone()?; let dst = self.out_ring.get(i)?.clone(); // SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of // identical format/size (src is a previous out-ring slot; dst the next). @@ -1553,6 +1894,7 @@ impl IddPushCapturer { payload: FramePayload::D3d11(D3d11Frame { texture: dst, device: self.device.clone(), + pyro: None, }), cursor: None, }) diff --git a/crates/pf-capture/src/windows/synthetic_nv12.rs b/crates/pf-capture/src/windows/synthetic_nv12.rs index b1b687f6..f3276d3a 100644 --- a/crates/pf-capture/src/windows/synthetic_nv12.rs +++ b/crates/pf-capture/src/windows/synthetic_nv12.rs @@ -127,6 +127,7 @@ impl Capturer for SyntheticNv12Capturer { payload: FramePayload::D3d11(D3d11Frame { texture: self.default_tex.clone(), device: self.device.clone(), + pyro: None, }), cursor: None, }) diff --git a/crates/pf-encode/Cargo.toml b/crates/pf-encode/Cargo.toml index 80ad2824..3761e095 100644 --- a/crates/pf-encode/Cargo.toml +++ b/crates/pf-encode/Cargo.toml @@ -53,12 +53,17 @@ ffmpeg-next = { version = "8", optional = true } libloading = "0.8" # Native Intel QSV (VPL): vendored static MIT dispatcher + bindgen'd C API, only under `qsv`. libvpl-sys = { path = "../libvpl-sys", optional = true } +# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only under +# `pyrowave`. The Windows backend is the NV12 zero-copy D3D11→Vulkan encoder; same crate as Linux. +pyrowave-sys = { path = "../pyrowave-sys", optional = true } windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", + # SECURITY_ATTRIBUTES — the PyroWave backend's IDXGIResource1::CreateSharedHandle signature. + "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Threading", diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index 42b6c929..a082bfa5 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -46,13 +46,6 @@ const IMPORT_CACHE_CAP: usize = 16; /// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta; /// the rate controller itself never exceeds the budget). const BS_SLACK: usize = 256 * 1024; -/// Chunked-mode window framing (§4.4): 4-byte prefix per shard-sized window. -const WINDOW_PREFIX: usize = 4; -/// Window kinds: whole packets / an oversized packet's fragments. -const WIN_PACKED: u16 = 0; -const WIN_FRAG_FIRST: u16 = 1; -const WIN_FRAG_CONT: u16 = 2; -const WIN_FRAG_LAST: u16 = 3; /// The DRM modifiers the PyroWave device can import as a SAMPLED image of the capture's /// packed-RGB format. The capture advertises these for the pyrowave passthrough instead of @@ -1077,8 +1070,8 @@ impl PyroWaveEncoder { // boundary by design. let cap = self.frame_budget + BS_SLACK; self.bitstream.resize(cap, 0); - // Chunked mode reserves 4 bytes per window for the framing prefix. - let boundary = self.wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(cap); + // Chunked mode reserves the 4-byte window prefix from the packetize boundary (shared helper). + let boundary = crate::pyrowave_wire::packet_boundary(self.wire_chunk, cap); let mut n: usize = 0; pw_check( pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n), @@ -1101,67 +1094,10 @@ impl PyroWaveEncoder { "packetize", )?; packets.truncate(out_n.max(1)); - let au = if let Some(chunk) = self.wire_chunk { - // Window framing (§4.4): each `chunk`-sized window opens with a 4-byte prefix - // (u16 used-length + u16 kind) and carries either WHOLE self-delimiting codec - // packets (PACKED — several small ones share a window) or one fragment of an - // oversized packet (FRAG chain — pyrowave 32×32 blocks are atomic and may - // exceed a shard). A lost shard zeroes its window (used = 0) — the receiver - // skips it and drops any fragment chain it interrupts. - let payload_max = chunk - WINDOW_PREFIX; - let mut au: Vec = Vec::with_capacity((packets.len() + 1) * chunk); - // The currently-open PACKED window: (start offset of its prefix, bytes used). - let mut open: Option<(usize, usize)> = None; - let close = |au: &mut Vec, open: &mut Option<(usize, usize)>, chunk: usize| { - if let Some((start, used)) = open.take() { - au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes()); - au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes()); - au.resize(start + chunk, 0); - } - }; - for p in &packets { - let bytes = &self.bitstream[p.offset..p.offset + p.size]; - if p.size <= payload_max { - let fits = open.is_some_and(|(_, used)| used + p.size <= payload_max); - if !fits { - close(&mut au, &mut open, chunk); - let start = au.len(); - au.resize(start + WINDOW_PREFIX, 0); - open = Some((start, 0)); - } - au.extend_from_slice(bytes); - if let Some((_, used)) = open.as_mut() { - *used += p.size; - } - } else { - // Oversized packet: its own FRAG chain of full windows. - close(&mut au, &mut open, chunk); - let mut off = 0usize; - while off < p.size { - let take = (p.size - off).min(payload_max); - let kind = if off == 0 { - WIN_FRAG_FIRST - } else if off + take == p.size { - WIN_FRAG_LAST - } else { - WIN_FRAG_CONT - }; - let start = au.len(); - au.resize(start + WINDOW_PREFIX, 0); - au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes()); - au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes()); - au.extend_from_slice(&bytes[off..off + take]); - au.resize(start + chunk, 0); - off += take; - } - } - } - close(&mut au, &mut open, chunk); - au - } else { - let p = &packets[0]; - self.bitstream[p.offset..p.offset + p.size].to_vec() - }; + // 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). + let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); + let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); self.frame_count += 1; self.pending.push_back(EncodedFrame { data: au, diff --git a/crates/pf-encode/src/enc/pyrowave_wire.rs b/crates/pf-encode/src/enc/pyrowave_wire.rs new file mode 100644 index 00000000..d7fbd553 --- /dev/null +++ b/crates/pf-encode/src/enc/pyrowave_wire.rs @@ -0,0 +1,164 @@ +//! Shared PyroWave AU wire-framing (design/pyrowave-codec-plan.md §4.4) — the single source of +//! truth for the on-wire access-unit shape, used by BOTH the Linux (dmabuf/CSC) and Windows (NV12 +//! zero-copy) host encoders. It turns pyrowave's packetized bitstream into either the **dense** +//! single-packet AU or the **datagram-aligned** windowed AU. Pure (no GPU/FFI) so it is unit-tested +//! on any platform and both encoders emit byte-identical framing — the clients parse this exact +//! layout, so it must stay in ONE place. +//! +//! Datagram-aligned AU: each `chunk`-sized window opens with a 4-byte prefix (`u16` used-length + +//! `u16` kind) and carries either WHOLE self-delimiting codec packets (`WIN_PACKED` — several small +//! ones share a window) or one fragment of an oversized ATOMIC packet (a `FRAG` chain — pyrowave's +//! 32×32 blocks are atomic and can exceed a shard). A lost shard zeroes its window (`used = 0`) so +//! the receiver skips it and drops any fragment chain it interrupts. Padding after `used` is zeroed. + +/// The 4-byte per-window framing prefix (`u16` used-length + `u16` kind). +pub(crate) const WINDOW_PREFIX: usize = 4; +/// Window kinds: whole packets / an oversized packet's fragments. +const WIN_PACKED: u16 = 0; +const WIN_FRAG_FIRST: u16 = 1; +const WIN_FRAG_CONT: u16 = 2; +const WIN_FRAG_LAST: u16 = 3; + +/// The packetize boundary to request from pyrowave: for a `wire_chunk` shard it is the shard payload +/// minus the 4-byte window prefix (so a whole codec packet + its prefix fits one shard); for the +/// dense case it is the whole-bitstream cap (one packet per AU). +pub(crate) fn packet_boundary(wire_chunk: Option, dense_cap: usize) -> usize { + wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(dense_cap) +} + +/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU. +/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed +/// datagram-aligned AU (a whole number of `chunk`-sized windows). +pub(crate) fn build_au( + packets: &[(usize, usize)], + bitstream: &[u8], + wire_chunk: Option, +) -> Vec { + let Some(chunk) = wire_chunk else { + // Dense (default): boundary == whole buffer → the AU is exactly one pyrowave packet. + let (off, size) = packets[0]; + return bitstream[off..off + size].to_vec(); + }; + let payload_max = chunk - WINDOW_PREFIX; + let mut au: Vec = Vec::with_capacity((packets.len() + 1) * chunk); + // The currently-open PACKED window: (start offset of its prefix, bytes used). + let mut open: Option<(usize, usize)> = None; + let close = |au: &mut Vec, open: &mut Option<(usize, usize)>, chunk: usize| { + if let Some((start, used)) = open.take() { + au[start..start + 2].copy_from_slice(&(used as u16).to_le_bytes()); + au[start + 2..start + 4].copy_from_slice(&WIN_PACKED.to_le_bytes()); + au.resize(start + chunk, 0); + } + }; + for &(off, size) in packets { + let bytes = &bitstream[off..off + size]; + if size <= payload_max { + let fits = open.is_some_and(|(_, used)| used + size <= payload_max); + if !fits { + close(&mut au, &mut open, chunk); + let start = au.len(); + au.resize(start + WINDOW_PREFIX, 0); + open = Some((start, 0)); + } + au.extend_from_slice(bytes); + if let Some((_, used)) = open.as_mut() { + *used += size; + } + } else { + // Oversized packet: its own FRAG chain of full windows. + close(&mut au, &mut open, chunk); + let mut o = 0usize; + while o < size { + let take = (size - o).min(payload_max); + let kind = if o == 0 { + WIN_FRAG_FIRST + } else if o + take == size { + WIN_FRAG_LAST + } else { + WIN_FRAG_CONT + }; + let start = au.len(); + au.resize(start + WINDOW_PREFIX, 0); + au[start..start + 2].copy_from_slice(&(take as u16).to_le_bytes()); + au[start + 2..start + 4].copy_from_slice(&kind.to_le_bytes()); + au.extend_from_slice(&bytes[o..o + take]); + au.resize(start + chunk, 0); + o += take; + } + } + } + close(&mut au, &mut open, chunk); + au +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Walk a windowed AU back into the flat codec-packet stream (the client's parse), asserting the + /// framing invariants the encoder promises: whole windows, in-bounds `used`, zeroed padding. + fn walk(au: &[u8], chunk: usize) -> Vec { + assert_eq!(au.len() % chunk, 0, "AU is a whole number of windows"); + let mut out = Vec::new(); + let mut frag: Vec = Vec::new(); + for win in au.chunks(chunk) { + let used = u16::from_le_bytes([win[0], win[1]]) as usize; + let kind = u16::from_le_bytes([win[2], win[3]]); + assert!(WINDOW_PREFIX + used <= win.len(), "window overrun"); + assert!( + win[WINDOW_PREFIX + used..].iter().all(|&b| b == 0), + "non-zero padding after used" + ); + let body = &win[WINDOW_PREFIX..WINDOW_PREFIX + used]; + match kind { + 0 => out.extend_from_slice(body), + 1 => frag = body.to_vec(), + 2 => frag.extend_from_slice(body), + 3 => { + frag.extend_from_slice(body); + out.extend_from_slice(&frag); + frag.clear(); + } + k => panic!("unknown window kind {k}"), + } + } + out + } + + #[test] + fn dense_is_the_single_packet() { + let bs = (0u8..=200).collect::>(); + let au = build_au(&[(10, 50)], &bs, None); + assert_eq!(au, bs[10..60]); + } + + #[test] + fn packed_windows_pack_small_packets_and_reconstruct() { + // Three small packets that share windows; walking must reproduce them concatenated in order. + let bs: Vec = (0..255u32).map(|i| i as u8).collect(); + let packets = [(0, 20), (20, 20), (40, 100)]; + let chunk = 64; // payload_max = 60 + let au = build_au(&packets, &bs, Some(chunk)); + let flat = walk(&au, chunk); + let mut expect = Vec::new(); + for &(o, s) in &packets { + expect.extend_from_slice(&bs[o..o + s]); + } + assert_eq!(flat, expect); + } + + #[test] + fn oversized_packet_fragments_and_reassembles() { + // One atomic packet larger than a window → a FRAG chain the walk reassembles exactly. + let bs: Vec = (0..1000u32).map(|i| i as u8).collect(); + let chunk = 64; // payload_max = 60 + let au = build_au(&[(0, 500)], &bs, Some(chunk)); + assert_eq!(walk(&au, chunk), bs[0..500]); + } + + #[test] + fn boundary_reserves_the_window_prefix() { + assert_eq!(packet_boundary(Some(1408), 999_999), 1404); + assert_eq!(packet_boundary(None, 777), 777); + } +} diff --git a/crates/pf-encode/src/enc/windows/amf.rs b/crates/pf-encode/src/enc/windows/amf.rs index 1c4f3484..c4cc568f 100644 --- a/crates/pf-encode/src/enc/windows/amf.rs +++ b/crates/pf-encode/src/enc/windows/amf.rs @@ -2788,6 +2788,7 @@ mod tests { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; @@ -2973,6 +2974,7 @@ mod tests { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; @@ -3114,6 +3116,7 @@ mod tests { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; @@ -3261,6 +3264,7 @@ mod tests { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs index d0b36131..d3edc23b 100644 --- a/crates/pf-encode/src/enc/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -1811,6 +1811,7 @@ mod tests { payload: FramePayload::D3d11(D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; @@ -1913,6 +1914,7 @@ mod tests { payload: FramePayload::D3d11(D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs new file mode 100644 index 00000000..7bd1ff4a --- /dev/null +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -0,0 +1,815 @@ +//! PyroWave host encoder (Windows) — **separate-plane zero-copy D3D11→Vulkan** via pyrowave's own +//! compat device (design/pyrowave-windows-host-zerocopy.md). The opt-in wired-LAN intra-only wavelet +//! codec, the Windows twin of `enc/linux/pyrowave.rs`. +//! +//! Shape (deliberately minimal — no `ash`, no hand-rolled external-memory import): pyrowave owns its +//! OWN Vulkan device, selected by the render GPU's vendor/device-id +//! (`pyrowave_create_device_by_compat`). The capturer's CSC produces TWO SEPARATE D3D11 plane +//! textures — a full-res `R8` **Y** + a half-res `R8G8` **CbCr** (BT.709 limited, matching the Linux +//! `rgb2yuv.comp` layout the wavelet clients decode) — each shared to that device as an NT handle +//! (`VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT`) via `pyrowave_image_create`. Separate +//! single/two-component textures import reliably on NVIDIA at any size, unlike a single planar NV12 +//! texture (the vendored interop test: "only very specific resource sizes"). A shared +//! D3D11/D3D12 fence — signalled by the capturer *after* the convert — is imported as a Vulkan +//! timeline semaphore (`pyrowave_sync_object_create`) so the wavelet read is ordered after the +//! D3D11 convert. `pyrowave_encoder_encode_gpu_synchronous` performs the acquire (waiting the fence +//! value), the encode, and the release in ONE pyrowave-owned submission, referencing the external +//! image with `VK_QUEUE_FAMILY_EXTERNAL`. The dangerous cross-API import (incl. the NVIDIA +//! video-layout workaround) stays entirely inside validated pyrowave/Granite. Every AU is a +//! keyframe; the AU/wire-chunk framing is the shared [`crate::pyrowave_wire`] helper (byte-identical +//! to Linux). +//! +//! The capture side (a BGRA→YUV CSC into two shareable plane textures + a shared fence, gated on the +//! pyrowave session flag) lives in `pf-capture` (`windows/idd_push.rs`); the CbCr plane + fence ride +//! the frame on [`pf_frame::dxgi::D3d11Frame::pyro`], the Y plane on `D3d11Frame::texture`. +// Every `unsafe` block in this module carries a `// SAFETY:` proof (the crate root enforces it). + +use crate::pyrowave_wire; +use crate::{EncodedFrame, Encoder, EncoderCaps}; +use anyhow::{bail, Context, Result}; +use pf_frame::{CapturedFrame, FramePayload}; +use pyrowave_sys as pw; +use std::collections::VecDeque; +use windows::core::{Interface, PCWSTR}; +use windows::Win32::Foundation::{CloseHandle, DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE}; +use windows::Win32::Graphics::Direct3D11::ID3D11Texture2D; +use windows::Win32::Graphics::Dxgi::IDXGIResource1; +use windows::Win32::System::Threading::GetCurrentProcess; + +/// Headroom over the per-frame rate budget for the packetized bitstream (block headers + meta). +const BS_SLACK: usize = 256 * 1024; +/// Bound the per-texture image-import cache. The IDD out-ring is a small fixed set (OUT_RING=3); +/// this only ever grows past it if the capturer recreates its out-ring within one encoder's life +/// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed. +const IMPORT_CACHE_CAP: usize = 8; + +// --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the +// pyrowave C API are generated; these plain #define / flags-typedef values are stable spec +// constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32 +// literals assign straight into the generated struct fields. --- +// The usage the validated interop helper (`create_pyrowave_image_from_d3d11`) requests. +const VK_IMAGE_USAGE_TRANSFER_SRC_BIT: u32 = 0x0000_0001; +const VK_IMAGE_USAGE_TRANSFER_DST_BIT: u32 = 0x0000_0002; +const VK_IMAGE_USAGE_SAMPLED_BIT: u32 = 0x0000_0004; +/// `VK_QUEUE_FAMILY_EXTERNAL` (`~0u32 - 1`): the image is owned by an external (D3D11) queue family; +/// pyrowave's acquire/release transitions ownership in/out across the interop boundary. +const VK_QUEUE_FAMILY_EXTERNAL: u32 = 0xFFFF_FFFE; + +fn pw_check(r: pw::pyrowave_result, what: &str) -> Result<()> { + if r == pw::pyrowave_result_PYROWAVE_SUCCESS { + Ok(()) + } else { + bail!("pyrowave {what} failed: result {r}") + } +} + +fn budget_for(bitrate_bps: u64, fps: u32) -> usize { + ((bitrate_bps / (8 * fps.max(1) as u64)) as usize).max(64 * 1024) +} + +pub struct PyroWaveEncoder { + // pyrowave owns the whole Vulkan device (create_device_by_compat) — no ash on this side. + pw_dev: pw::pyrowave_device, + pw_enc: pw::pyrowave_encoder, + // The imported shared fence (a Vulkan timeline semaphore aliasing the capturer's D3D11 fence). + // Null until the capturer delivers the fence handle on the first frame (or after a rebuild). + sync: pw::pyrowave_sync_object, + // Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot): + // the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar + // NV12 import is unreliable on NVIDIA at arbitrary sizes). + y_images: Vec<(isize, pw::pyrowave_image)>, + cbcr_images: Vec<(isize, pw::pyrowave_image)>, + + width: u32, + height: u32, + fps: u32, + /// 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. + wire_chunk: Option, + bitstream: Vec, + pending: VecDeque, +} + +// SAFETY: used only from the single encode thread; the pyrowave handles are owned and only touched +// from that thread, and pyrowave only submits GPU work inside the API calls we make (mirrors the +// Linux `PyroWaveEncoder`'s `unsafe impl Send`). The D3D11 texture pointers travel as plain `isize` +// cache keys, never dereferenced here. +unsafe impl Send for PyroWaveEncoder {} + +impl PyroWaveEncoder { + pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result { + if width % 2 != 0 || height % 2 != 0 { + bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); + } + let fps = fps.max(1); + // Select pyrowave's device by the SELECTED render adapter's vendor/device-id — NOT by LUID: + // in Session 0 (the host service context) the Vulkan ICD reports `deviceLUIDValid = false`, + // so a by-LUID match would find nothing, while the vendor/device-id match + the external + // import both work (design doc Stage 0; `pyrowave_c.cpp` guards LUID use behind validity). + let (vid, pid) = pf_gpu::selected_gpu() + .map(|s| (s.info.vendor_id, s.info.device_id)) + .unwrap_or((0, 0)); + // SAFETY: `create_device_by_compat` builds pyrowave's own instance/device from the + // vendor/device-id (null uuids/luid = "don't constrain by those"); the out-param is a live + // local. `confirm_interop_support` / `encoder_create` take that just-created non-null + // device; on any failure we destroy what we created before returning. All pointers are + // freshly created and owned by the returned struct (or freed on the error path). + unsafe { + let mut pw_dev: pw::pyrowave_device = std::ptr::null_mut(); + pw_check( + pw::pyrowave_create_device_by_compat( + vid, + pid, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + &mut pw_dev, + ), + "create_device_by_compat", + ) + .with_context(|| { + format!( + "open a PyroWave Vulkan device for GPU {vid:04x}:{pid:04x} (render adapter)" + ) + })?; + + // The make-or-break gate (design doc Risk 1): confirm this device can do the + // external-memory interop the zero-copy import needs. In a service context where the + // import is unavailable this fails HERE (clean HEVC renegotiation) instead of at the + // first frame's import. + if !pw::pyrowave_device_confirm_interop_support(pw_dev) { + pw::pyrowave_device_destroy(pw_dev); + bail!( + "the PyroWave Vulkan device does not confirm external-memory interop support \ + (D3D11→Vulkan zero-copy import unavailable on this GPU / in this session \ + context) — the session should renegotiate to HEVC" + ); + } + + let einfo = pw::pyrowave_encoder_create_info { + device: pw_dev, + width: width as i32, + height: height as i32, + chroma: 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( + pw::pyrowave_encoder_create(&einfo, &mut pw_enc), + "encoder_create", + ) { + pw::pyrowave_device_destroy(pw_dev); + return Err(e); + } + + let frame_budget = budget_for(bitrate_bps.max(1_000_000), fps); + tracing::info!( + 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)" + ); + + Ok(Self { + pw_dev, + pw_enc, + sync: std::ptr::null_mut(), + y_images: Vec::new(), + cbcr_images: Vec::new(), + width, + height, + fps, + frame_budget, + wire_chunk: None, + bitstream: Vec::new(), + pending: VecDeque::new(), + }) + } + } + + /// Import one capturer plane D3D11 texture (`R8_UNORM` Y or `R8G8_UNORM` CbCr) into pyrowave's + /// Vulkan device. Creates a fresh shared NT handle from the texture (the capturer marked the ring + /// `SHARED | SHARED_NTHANDLE`); `pyrowave_image_create` takes ownership of the handle and closes + /// it on import. Single/two-component textures import reliably on NVIDIA at any size — unlike a + /// planar NV12 — so no MUTABLE_FORMAT / planar-layout workaround is involved. + /// + /// # Safety + /// `texture` must be a live `ID3D11Texture2D` of format `vk_format`, sized `w`×`h`, created + /// shareable, on the same physical GPU as `pw_dev`. The returned `pyrowave_image` is owned by the + /// caller (destroyed in `Drop`/eviction). Takes `pw_dev` by value (not `&self`) so the cache + /// closures don't double-borrow the encoder. + unsafe fn import_plane( + pw_dev: pw::pyrowave_device, + texture: &ID3D11Texture2D, + vk_format: pw::VkFormat, + w: u32, + h: u32, + ) -> Result { + // The shared NT handle (mirrors the interop test's `create_pyrowave_image_from_d3d11`). + let res: IDXGIResource1 = texture + .cast() + .context("ID3D11Texture2D -> IDXGIResource1 (plane not created shareable?)")?; + // GENERIC_ALL (0x1000_0000) — the access the interop test hands the shared handle. + let handle: HANDLE = res + .CreateSharedHandle(None, 0x1000_0000, PCWSTR::null()) + .context("IDXGIResource1::CreateSharedHandle(plane texture)")?; + + // Zero-init then set the fields we need (pNext/queue-family/initialLayout stay 0 = null / + // UNDEFINED) — robust against however bindgen renders `Default` for the raw-pointer fields. + let mut ici: pw::VkImageCreateInfo = std::mem::zeroed(); + ici.sType = pw::VkStructureType_VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + ici.imageType = pw::VkImageType_VK_IMAGE_TYPE_2D; + ici.format = vk_format; + ici.extent = pw::VkExtent3D { + width: w, + height: h, + depth: 1, + }; + ici.mipLevels = 1; + ici.arrayLayers = 1; + ici.samples = pw::VkSampleCountFlagBits_VK_SAMPLE_COUNT_1_BIT; + ici.tiling = pw::VkImageTiling_VK_IMAGE_TILING_OPTIMAL; + ici.usage = VK_IMAGE_USAGE_SAMPLED_BIT + | VK_IMAGE_USAGE_TRANSFER_SRC_BIT + | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + ici.sharingMode = pw::VkSharingMode_VK_SHARING_MODE_EXCLUSIVE; + let info = pw::pyrowave_image_create_info { + device: pw_dev, + external_handle: handle.0 as usize as pw::pyrowave_os_handle, + handle_type: + pw::VkExternalMemoryHandleTypeFlagBits_VK_EXTERNAL_MEMORY_HANDLE_TYPE_D3D11_TEXTURE_BIT, + image_create_info: &ici, + }; + let mut image: pw::pyrowave_image = std::ptr::null_mut(); + if let Err(e) = pw_check(pw::pyrowave_image_create(&info, &mut image), "image_create") { + // pyrowave only closes the handle on a SUCCESSFUL import — close it ourselves on failure. + let _ = CloseHandle(handle); + return Err(e); + } + Ok(image) + } + + /// Import (cache) a plane texture by its stable per-slot pointer, evicting the oldest when the + /// cache is over cap (the out-ring is small + fixed; growth only happens on a mid-life ring + /// recreate). Returns the cached-or-fresh `pyrowave_image`. + /// + /// # Safety + /// Same contract as [`import_plane`]. + unsafe fn cached_plane( + cache: &mut Vec<(isize, pw::pyrowave_image)>, + make: impl FnOnce() -> Result, + key: isize, + ) -> Result { + if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) { + return Ok(*img); + } + let img = make()?; + if cache.len() >= IMPORT_CACHE_CAP { + let (_, old) = cache.remove(0); + pw::pyrowave_image_destroy(old); + } + cache.push((key, img)); + Ok(img) + } + + /// Import the capturer's shared fence as a Vulkan timeline semaphore. Called only when this + /// encoder has no timeline yet (the first frame, or a fresh encoder after a mode-switch rebuild). + /// pyrowave takes ownership of the handle and CLOSES it on import, so we hand it a private + /// **duplicate** of the capturer's persistent handle — leaving the original valid for the next + /// rebuild's re-import (the capturer passes the same handle on every frame). + /// + /// # Safety + /// `handle` must be the capturer's live shared D3D11/D3D12 fence NT handle on `self.pw_dev`'s GPU. + unsafe fn import_fence(&mut self, handle: isize) -> Result<()> { + let mut dup = HANDLE::default(); + DuplicateHandle( + GetCurrentProcess(), + HANDLE(handle as *mut core::ffi::c_void), + GetCurrentProcess(), + &mut dup, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .context("DuplicateHandle(shared fence for pyrowave import)")?; + let info = pw::pyrowave_sync_object_create_info { + device: self.pw_dev, + external_handle: dup.0 as usize as pw::pyrowave_os_handle, + // D3D11 fence == D3D12 fence on Windows 10+; must be imported as TIMELINE. + handle_type: + pw::VkExternalSemaphoreHandleTypeFlagBits_VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_D3D12_FENCE_BIT, + semaphore_type: pw::VkSemaphoreType_VK_SEMAPHORE_TYPE_TIMELINE, + import_flags: 0, + }; + let mut sync: pw::pyrowave_sync_object = std::ptr::null_mut(); + if let Err(e) = pw_check( + pw::pyrowave_sync_object_create(&info, &mut sync), + "sync_object_create", + ) { + // pyrowave only closes the handle on a SUCCESSFUL import — close the dup on failure. + let _ = CloseHandle(dup); + return Err(e); + } + self.sync = sync; + Ok(()) + } + + /// One frame, synchronously: import (cache) the two plane textures + fence → encode (pyrowave + /// owns the submission: acquire waits the capturer's fence value, references both images as + /// `QUEUE_FAMILY_EXTERNAL`, release hands them back) → packetize into an `EncodedFrame`. + /// + /// # Safety + /// Runs on the single encode thread; all pyrowave calls take handles this struct owns. + unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { + let FramePayload::D3d11(d3d) = &frame.payload else { + bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)") + }; + let share = d3d.pyro.as_ref().context( + "pyrowave (Windows): the frame carries no PyroWave payload — the capturer was not opened \ + in pyrowave mode (session_plan::output_format must set OutputFormat::pyrowave)", + )?; + + // Import the fence whenever this encoder has no timeline yet — the first frame, OR a fresh + // encoder after a client mode-switch rebuild (the capturer passes the persistent handle on + // every frame precisely so a rebuilt encoder can re-import it). + if self.sync.is_null() { + let h = share + .fence_handle + .context("pyrowave (Windows): frame carried no shared fence handle")?; + self.import_fence(h)?; + } + + // Import (cache) the two SEPARATE plane textures by their stable per-slot pointers: the + // 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); + 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), + key, + )? + }; + let cbcr_img = { + let key = share.cbcr.as_raw() as isize; + 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), + key, + )? + }; + + // Plane views built BY HAND exactly like the Linux encoder (`enc/linux/pyrowave.rs`): Y from + // the R8 image (full-res, IDENTITY), Cb/Cr from the R8G8 image (half-res) with R/G swizzle to + // synthesize the two chroma planes from the interleaved CbCr — the documented NV12-style + // hand-off. All GENERAL layout (pyrowave's GPU-buffer contract accepts it without transitions). + let y_vk = pw::pyrowave_image_get_handle(y_img); + let cbcr_vk = pw::pyrowave_image_get_handle(cbcr_img); + let plane = |image, pw_w, pw_h, fmt, swizzle| pw::pyrowave_image_view { + image, + width: pw_w, + height: pw_h, + image_format: fmt, + view_format: fmt, + mip_level: 0, + layer: 0, + aspect: pw::VkImageAspectFlagBits_VK_IMAGE_ASPECT_COLOR_BIT, + 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, + pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY, + ), + plane( + cbcr_vk, + w / 2, + h / 2, + rg8, + pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R, + ), + plane( + cbcr_vk, + w / 2, + h / 2, + rg8, + pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G, + ), + ], + }; + + // Acquire the two external images (owned by the D3D11 queue family), waiting the capturer's + // fence value so the wavelet read is ordered after the D3D11 CSC; release hands them back. + // pyrowave owns the submission (no explicit command buffer). + let refs = [ + pw::pyrowave_gpu_external_reference { + image: y_img, + queue_family_index: VK_QUEUE_FAMILY_EXTERNAL, + }, + pw::pyrowave_gpu_external_reference { + image: cbcr_img, + queue_family_index: VK_QUEUE_FAMILY_EXTERNAL, + }, + ]; + let acquire = pw::pyrowave_gpu_sync_operation { + images: refs.as_ptr(), + num_images: refs.len(), + sync: pw::pyrowave_sync_point { + semaphore: pw::pyrowave_sync_object_get_semaphore(self.sync), + value: share.fence_value, + }, + }; + let release = pw::pyrowave_gpu_sync_operation { + images: refs.as_ptr(), + num_images: refs.len(), + // No release signal needed (null semaphore): encode is synchronous and the out-ring depth + // guarantees the slot is not reused before the next synchronous encode completes (the same + // contract the NVENC path relies on). + sync: std::mem::zeroed(), + }; + let rc = pw::pyrowave_rate_control { + maximum_bitstream_size: self.frame_budget, + }; + pw_check( + pw::pyrowave_encoder_encode_gpu_synchronous( + self.pw_enc, + &acquire, + &release, + &buffers, + &rc, + ), + "encode_gpu_synchronous", + )?; + + // ---- packetize (shared framing helper — byte-identical to the Linux encoder) ---- + let cap = self.frame_budget + BS_SLACK; + self.bitstream.resize(cap, 0); + let boundary = pyrowave_wire::packet_boundary(self.wire_chunk, cap); + let mut n: usize = 0; + pw_check( + pw::pyrowave_encoder_compute_num_packets(self.pw_enc, boundary, &mut n), + "compute_num_packets", + )?; + if n == 0 || (self.wire_chunk.is_none() && n != 1) { + bail!("pyrowave: unexpected packet count {n} at boundary {boundary}"); + } + let mut packets = vec![pw::pyrowave_packet { offset: 0, size: 0 }; n]; + let mut out_n: usize = 0; + pw_check( + pw::pyrowave_encoder_packetize( + self.pw_enc, + packets.as_mut_ptr(), + boundary, + &mut out_n, + self.bitstream.as_mut_ptr() as *mut std::ffi::c_void, + cap, + ), + "packetize", + )?; + packets.truncate(out_n.max(1)); + 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); + self.pending.push_back(EncodedFrame { + data: au, + pts_ns: frame.pts_ns, + // Every frame is independently decodable — the codec's whole recovery story. + keyframe: true, + recovery_anchor: false, + chunk_aligned: self.wire_chunk.is_some(), + }); + Ok(()) + } +} + +impl Encoder for PyroWaveEncoder { + fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { + // SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this struct + // owns and pyrowave waits its own fence before packetize returns. + unsafe { self.encode_frame(frame) } + } + + fn caps(&self) -> EncoderCaps { + // All defaults: no RFI (every frame is intra), no HDR (8-bit SDR codec), 4:2:0 only. + EncoderCaps::default() + } + + fn poll(&mut self) -> Result> { + Ok(self.pending.pop_front()) + } + + fn reset(&mut self) -> bool { + // Cheap in-place rebuild: recreate only the pyrowave encoder object (no rate-control / + // reference state to preserve). The device, imported textures and fence survive. + // SAFETY: encode is synchronous (no work in flight); the device outlives the swapped encoder. + unsafe { + pw::pyrowave_encoder_destroy(self.pw_enc); + let einfo = pw::pyrowave_encoder_create_info { + device: self.pw_dev, + width: self.width as i32, + height: self.height as i32, + chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420, + }; + let mut enc: pw::pyrowave_encoder = std::ptr::null_mut(); + let r = pw::pyrowave_encoder_create(&einfo, &mut enc); + if r != pw::pyrowave_result_PYROWAVE_SUCCESS { + tracing::error!(result = ?r, "pyrowave: encoder rebuild failed"); + return false; + } + self.pw_enc = enc; + } + self.pending.clear(); + true + } + + fn reconfigure_bitrate(&mut self, bps: u64) -> bool { + // Rate control is a plain per-frame byte budget — an in-place retarget is free (no IDR, + // nothing in flight). Phase 3 pins the session rate and bypasses ABR; this faithfully + // applies whatever the caller asks until then. + self.frame_budget = budget_for(bps.max(1_000_000), self.fps); + tracing::debug!( + mbps = bps / 1_000_000, + budget_kib = self.frame_budget / 1024, + "pyrowave: per-frame rate budget retargeted in place" + ); + true + } + + fn set_wire_chunking(&mut self, shard_payload: usize) { + // Sanity floor: a boundary below one block header + payload word is meaningless. + if shard_payload >= 64 { + self.wire_chunk = Some(shard_payload); + tracing::info!( + shard_payload, + "pyrowave: datagram-aligned packetization on (partial-frame loss mode)" + ); + } + } + + fn flush(&mut self) -> Result<()> { + // Synchronous per-frame encode: nothing buffered beyond `pending`. + Ok(()) + } +} + +impl Drop for PyroWaveEncoder { + fn drop(&mut self) { + // SAFETY: owned handles, destroyed exactly once; pyrowave objects (encoder, images, sync) go + // before the device they borrow (per pyrowave.h). + unsafe { + pw::pyrowave_encoder_destroy(self.pw_enc); + for (_, img) in self.y_images.drain(..).chain(self.cbcr_images.drain(..)) { + pw::pyrowave_image_destroy(img); + } + if !self.sync.is_null() { + pw::pyrowave_sync_object_destroy(self.sync); + } + pw::pyrowave_device_destroy(self.pw_dev); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pf_frame::dxgi::{D3d11Frame, PyroFrameShare}; + use pf_frame::PixelFormat; + use windows::Win32::Foundation::HMODULE; + use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_1}; + use windows::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, ID3D11Device, ID3D11Device5, ID3D11DeviceContext, ID3D11DeviceContext4, + ID3D11Fence, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_CPU_ACCESS_WRITE, + D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_FENCE_FLAG_SHARED, D3D11_MAPPED_SUBRESOURCE, + D3D11_MAP_WRITE, D3D11_RESOURCE_MISC_SHARED, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, + 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, + }; + + /// Decode a dense PyroWave AU with upstream's own decoder → YUV420P plane means (the golden + /// oracle, mirroring the Linux `decode_plane_means`). + /// + /// # 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) { + let mut dev: pw::pyrowave_device = std::ptr::null_mut(); + assert_eq!( + pw::pyrowave_create_default_device(&mut dev), + pw::pyrowave_result_PYROWAVE_SUCCESS + ); + let dinfo = pw::pyrowave_decoder_create_info { + device: dev, + width: w as i32, + height: h as i32, + chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420, + fragment_path: false, + }; + let mut dec: pw::pyrowave_decoder = std::ptr::null_mut(); + assert_eq!( + pw::pyrowave_decoder_create(&dinfo, &mut dec), + pw::pyrowave_result_PYROWAVE_SUCCESS + ); + assert_eq!( + pw::pyrowave_decoder_push_packet(dec, au.as_ptr() as *const _, au.len()), + pw::pyrowave_result_PYROWAVE_SUCCESS + ); + assert!(pw::pyrowave_decoder_decode_is_ready(dec, false)); + 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 buf: pw::pyrowave_cpu_buffer = std::mem::zeroed(); + buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P; + buf.width = w as i32; + buf.height = h as i32; + buf.data = [ + y.as_mut_ptr() as *mut _, + 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.plane_size_in_bytes = [y.len(), cb.len(), cr.len()]; + assert_eq!( + pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf), + pw::pyrowave_result_PYROWAVE_SUCCESS + ); + pw::pyrowave_decoder_destroy(dec); + pw::pyrowave_device_destroy(dev); + let mean = |v: &[u8]| v.iter().map(|&x| x as f64).sum::() / v.len() as f64; + (mean(&y), mean(&cb), mean(&cr)) + } + + /// Create a shareable `format` plane texture (`bpp` bytes/texel), fill each texel with `bytes` + /// via a CPU staging copy, and return it. Mirrors the capturer's SHARED|SHARED_NTHANDLE + + /// RENDER_TARGET out-ring textures. + /// + /// # Safety + /// `bytes.len() == bpp`; runs on a live D3D11 device/context. + unsafe fn make_plane( + device: &ID3D11Device, + context: &ID3D11DeviceContext, + w: u32, + h: u32, + format: DXGI_FORMAT, + bpp: usize, + bytes: &[u8], + ) -> ID3D11Texture2D { + let mut desc = D3D11_TEXTURE2D_DESC { + Width: w, + Height: h, + MipLevels: 1, + ArraySize: 1, + Format: format, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0 | D3D11_RESOURCE_MISC_SHARED.0) + as u32, + }; + let mut tex: Option = None; + device + .CreateTexture2D(&desc, None, Some(&mut tex)) + .expect("CreateTexture2D(plane default)"); + let tex = tex.unwrap(); + desc.BindFlags = 0; + desc.MiscFlags = 0; + desc.Usage = D3D11_USAGE_STAGING; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE.0 as u32; + let mut staging: Option = None; + device + .CreateTexture2D(&desc, None, Some(&mut staging)) + .expect("CreateTexture2D(plane staging)"); + let staging = staging.unwrap(); + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + context + .Map(&staging, 0, D3D11_MAP_WRITE, 0, Some(&mut mapped)) + .expect("Map(plane staging)"); + let pitch = mapped.RowPitch as usize; + let base = mapped.pData as *mut u8; + for row in 0..(h as usize) { + let r = base.add(row * pitch); + for x in 0..(w as usize) { + for (b, &v) in bytes.iter().enumerate() { + *r.add(x * bpp + b) = v; + } + } + } + context.Unmap(&staging, 0); + context.CopyResource(&tex, &staging); + tex + } + + /// End-to-end zero-copy smoke: distinct solid Y/Cb/Cr filled into SEPARATE shareable plane + /// textures (full-res R8 Y + half-res R8G8 CbCr) → shared to pyrowave's own Vulkan device (the + /// SESSION-0-relevant `create_device_by_compat` + `D3D11_TEXTURE_BIT` import + shared-fence path) + /// → encode → upstream-decode. Returns the decoded plane means. A flat gray can't detect a plane + /// swap / spatial error, so this fills Y≠Cb≠Cr. + /// + /// # 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) { + // A fresh D3D11 device on the default hardware adapter. + let mut device: Option = None; + let mut context: Option = None; + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + Some(&[D3D_FEATURE_LEVEL_11_1]), + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + .expect("D3D11CreateDevice"); + 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], + ); + + // Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering). + let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5"); + let mut fence: Option = None; + dev5.CreateFence(0, D3D11_FENCE_FLAG_SHARED, &mut fence) + .expect("CreateFence"); + let fence = fence.unwrap(); + let fence_handle = fence + .CreateSharedHandle(None, 0x1000_0000, windows::core::PCWSTR::null()) + .expect("fence CreateSharedHandle"); + let ctx4: ID3D11DeviceContext4 = context.cast().expect("ID3D11DeviceContext4"); + ctx4.Signal(&fence, 1).expect("Signal"); + context.Flush(); + + // Encode the shared textures through the real backend. + let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000).expect("PyroWaveEncoder::open"); + let frame = CapturedFrame { + width: w, + height: h, + pts_ns: 0, + format: PixelFormat::Nv12, + payload: FramePayload::D3d11(D3d11Frame { + texture: y_tex, + device: device.clone(), + pyro: Some(PyroFrameShare { + cbcr: cbcr_tex, + fence_handle: Some(fence_handle.0 as isize), + fence_value: 1, + }), + }), + cursor: None, + }; + enc.submit(&frame).expect("submit"); + let au = enc.poll().expect("poll").expect("one AU per frame"); + assert!(au.keyframe, "every pyrowave AU is a keyframe"); + assert!(!au.data.is_empty(), "AU is non-empty"); + decode_plane_means(w, h, &au.data) + } + + /// The Windows NV12 zero-copy path end-to-end on a real GPU. `#[ignore]`d (needs D3D11 + a + /// Vulkan-1.3 device); build anywhere, run on the GPU host: + /// cargo test -p pf-encode --features pyrowave --no-run + /// --ignored --nocapture pyrowave_win_smoke + /// Runs both a known-good square size and real streaming sizes to characterize the documented + /// NVIDIA NV12 D3D11→Vulkan import size sensitivity (design doc Risk 4 / the interop-test note). + #[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)] { + // SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches. + let (ym, cbm, crm) = unsafe { run_case(w, h) }; + eprintln!( + "{w}x{h}: 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?)" + ); + } + } +} diff --git a/crates/pf-encode/src/enc/windows/qsv.rs b/crates/pf-encode/src/enc/windows/qsv.rs index 6c36de0f..c0bf4fbf 100644 --- a/crates/pf-encode/src/enc/windows/qsv.rs +++ b/crates/pf-encode/src/enc/windows/qsv.rs @@ -1746,6 +1746,7 @@ mod tests { payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame { texture: tex.clone(), device: device.clone(), + pyro: None, }), cursor: None, }; diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 2f35e1f8..d3013208 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -48,7 +48,19 @@ impl Codec { } else { 0u8 }; - #[cfg(not(all(target_os = "linux", feature = "pyrowave")))] + // Windows: the wavelet encoder rides on top of whatever GPU backend the box has (NVENC/AMF/ + // QSV) — it opens its OWN Vulkan device by the render GPU's vendor/device-id and + // zero-copy-imports the capturer's NV12 D3D11 texture, so the H.26x backend is irrelevant to + // it. Only a software/GPU-less host keeps the bit off (no Vulkan GPU to open). Whether the + // Session-0 external-memory import actually works is confirmed at encoder open + // (`pyrowave_device_confirm_interop_support`); a failed open renegotiates to HEVC. + #[cfg(all(target_os = "windows", feature = "pyrowave"))] + let pyro = if windows_resolved_backend() != WindowsBackend::Software { + punktfunk_core::quic::CODEC_PYROWAVE + } else { + 0u8 + }; + #[cfg(not(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave")))] let pyro = 0u8; let base = (|| { /// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream @@ -399,10 +411,22 @@ fn open_video_backend( } #[cfg(target_os = "windows")] { - // The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3); - // host_wire_caps never advertises the bit here, so this only guards a forged preference. + // A NEGOTIATED PyroWave session (client advertised + preferred it) routes straight to the + // NV12 zero-copy wavelet backend (design/pyrowave-windows-host-zerocopy.md) — placed FIRST, + // like the Linux branch. It opens its own Vulkan device by the render GPU's vendor/device-id + // and imports the capturer's shared NV12 texture; the H.26x backend selection below is moot. if codec == Codec::PyroWave { - anyhow::bail!("PyroWave host encode is not available on Windows yet"); + #[cfg(feature = "pyrowave")] + { + let _ = (format, cuda, bit_depth, chroma); + return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps) + .map(|e| (Box::new(e) as Box, "pyrowave")); + } + #[cfg(not(feature = "pyrowave"))] + anyhow::bail!( + "session negotiated PyroWave but this host was built without --features \ + punktfunk-host/pyrowave (the advertisement bit should not have been set)" + ); } let _ = cuda; // always false on Windows (no Cuda payload) // NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software @@ -1260,6 +1284,17 @@ mod vk_util; #[cfg(all(target_os = "linux", feature = "pyrowave"))] #[path = "enc/linux/pyrowave.rs"] mod pyrowave; +// The Windows PyroWave encoder — NV12 zero-copy D3D11→Vulkan via pyrowave's own compat device +// (design/pyrowave-windows-host-zerocopy.md). Same module name as the Linux one (per-platform +// `#[path]`, mutually-exclusive cfg) so `crate::pyrowave::*` is flat on both. +#[cfg(all(target_os = "windows", feature = "pyrowave"))] +#[path = "enc/windows/pyrowave.rs"] +mod pyrowave; +// Shared PyroWave AU wire-framing (§4.4) — the single source of truth both platform backends emit, +// so the on-wire access-unit layout the clients parse can never drift between Linux and Windows. +#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "pyrowave"))] +#[path = "enc/pyrowave_wire.rs"] +mod pyrowave_wire; #[cfg(test)] mod tests { diff --git a/crates/pf-frame/src/dxgi.rs b/crates/pf-frame/src/dxgi.rs index 2ebb96da..60531695 100644 --- a/crates/pf-frame/src/dxgi.rs +++ b/crates/pf-frame/src/dxgi.rs @@ -34,10 +34,35 @@ pub struct WinCaptureTarget { pub wudf_pid: u32, } -/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path). +/// The PyroWave (Windows) zero-copy sharing payload attached to a captured frame: the SECOND plane +/// texture + the cross-device fence the wavelet encoder needs (design/pyrowave-windows-host- +/// zerocopy.md). The wavelet encoder ingests **two SEPARATE** shareable plane textures — the full-res +/// `R8_UNORM` **Y** rides [`D3d11Frame::texture`], and the half-res `R8G8_UNORM` **CbCr** rides +/// [`cbcr`](Self::cbcr) — because importing a single *planar* NV12 texture into Vulkan is unreliable +/// on NVIDIA at arbitrary sizes; separate single/two-component textures import reliably. `None` on +/// every non-PyroWave frame (NVENC/AMF/QSV encode the in-place NV12/BGRA and need no cross-device +/// fence). The encoder makes each texture's shared handle on demand. +pub struct PyroFrameShare { + /// The half-res `R8G8_UNORM` interleaved CbCr plane (created `SHARED | SHARED_NTHANDLE`). The + /// full-res Y plane is [`D3d11Frame::texture`]. + pub cbcr: ID3D11Texture2D, + /// The shared D3D11/D3D12 **fence** NT handle (raw), passed on EVERY frame; the encoder imports + /// it (duplicating) whenever it has no timeline yet (first frame or after an encoder rebuild). + pub fence_handle: Option, + /// The fence value the capturer signalled after THIS frame's convert. The encoder's Vulkan + /// acquire waits on it, so the wavelet read is ordered after the D3D11 CSC. + pub fence_value: u64, +} + +/// A GPU-resident captured texture (the Windows zero-copy path: NVENC/AMF/QSV encode it in place; +/// the PyroWave backend imports it — plus the second plane in [`pyro`](Self::pyro) — into its own +/// Vulkan device). For a PyroWave frame, `texture` is the full-res `R8_UNORM` Y plane. pub struct D3d11Frame { pub texture: ID3D11Texture2D, pub device: ID3D11Device, + /// PyroWave zero-copy sharing info (the CbCr plane + fence); `None` unless this is a PyroWave + /// session. See [`PyroFrameShare`]. + pub pyro: Option, } // SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers. // D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is diff --git a/crates/pf-frame/src/lib.rs b/crates/pf-frame/src/lib.rs index 0162e4c9..4e0274b4 100644 --- a/crates/pf-frame/src/lib.rs +++ b/crates/pf-frame/src/lib.rs @@ -115,6 +115,13 @@ pub struct OutputFormat { /// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every /// 4:2:0 session. pub chroma_444: bool, + /// A PyroWave (wavelet) session on Windows: the IDD-push capturer must make its NV12 out-ring + /// **shareable** (`SHARED | SHARED_NTHANDLE`) and signal a **shared fence** after each convert, + /// so the pyrowave encoder can zero-copy-import the texture into its own Vulkan device + /// (design/pyrowave-windows-host-zerocopy.md). Also forces the NV12 4:2:0 SDR convert branch + /// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the + /// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture). + pub pyrowave: bool, } impl OutputFormat { @@ -130,6 +137,8 @@ impl OutputFormat { hdr, // The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only). chroma_444: false, + // GameStream never negotiates PyroWave (native punktfunk/1 only). + pyrowave: false, } } } diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index e6758c40..af9e6d54 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -131,8 +131,16 @@ pub fn capture_virtual_output( // proactively enables advanced color and selects the per-frame conversion. There is NO fallback: // if it can't open or the driver doesn't attach, the session fails cleanly and the client // reconnects. - pf_capture::open_idd_push(target, pref, want.hdr, want.chroma_444, keep, sender) - .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) + pf_capture::open_idd_push( + target, + pref, + want.hdr, + want.chroma_444, + want.pyrowave, + keep, + sender, + ) + .map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)")) } #[cfg(not(any(target_os = "linux", target_os = "windows")))] diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs index 8d86d4ba..4bcd95e1 100644 --- a/crates/punktfunk-host/src/session_plan.rs +++ b/crates/punktfunk-host/src/session_plan.rs @@ -179,6 +179,11 @@ impl SessionPlan { // 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the // default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4. chroma_444: self.chroma.is_444(), + // PyroWave (Windows): the IDD-push capturer makes its NV12 out-ring shareable + signals a + // shared fence so the wavelet encoder can zero-copy-import the texture into its own Vulkan + // device. Inert on Linux (the wavelet backend ingests dmabufs / CPU RGB there — handled + // by the `gpu` flips above, not this flag). + pyrowave: self.codec == crate::encode::Codec::PyroWave, } } }