feat(pyrowave): Windows host encoder — separate-plane zero-copy D3D11→Vulkan

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 02:38:48 +02:00
parent 1e7c18b2c8
commit ebd9967547
16 changed files with 1595 additions and 120 deletions
+11 -2
View File
@@ -365,9 +365,18 @@ pub fn open_idd_push(
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
sender: FrameChannelSender,
) -> std::result::Result<Box<dyn Capturer>, (anyhow::Error, Box<dyn Send>)> {
idd_push::IddPushCapturer::open(target, preferred, client_10bit, want_444, keepalive, sender)
.map(|c| Box::new(c) as Box<dyn Capturer>)
idd_push::IddPushCapturer::open(
target,
preferred,
client_10bit,
want_444,
pyrowave,
keepalive,
sender,
)
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
+129 -15
View File
@@ -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<float4> 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<float4> 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<Self> {
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<Self> {
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.
+368 -26
View File
@@ -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<ID3D11Fence>,
/// 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<isize>,
/// 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<PyroOutSlot>,
/// PyroWave: the BGRA→YUV-planes CSC (BT.709 limited, matching `rgb2yuv.comp`). Built lazily.
pyro_conv: Option<BgraToYuvPlanes>,
/// 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<dyn Send>,
sender: crate::FrameChannelSender,
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
// 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<Self> {
// 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<Self> {
@@ -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<ID3D11Texture2D> = None;
dev.CreateTexture2D(&desc, None, Some(&mut tex))
.context("CreateTexture2D(pyro plane)")?;
let tex = tex.context("null pyro plane texture")?;
let mut rtv: Option<ID3D11RenderTargetView> = 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<Option<(Option<isize>, 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<ID3D11Fence> = 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<Option<CapturedFrame>> {
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,
})
@@ -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,
})
+5
View File
@@ -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",
+6 -70
View File
@@ -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<u8> = 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<u8>, 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,
+164
View File
@@ -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<usize>, 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<usize>,
) -> Vec<u8> {
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<u8> = 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<u8>, 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<u8> {
assert_eq!(au.len() % chunk, 0, "AU is a whole number of windows");
let mut out = Vec::new();
let mut frag: Vec<u8> = 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::<Vec<u8>>();
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<u8> = (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<u8> = (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);
}
}
+4
View File
@@ -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,
};
@@ -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,
};
@@ -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<usize>,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
}
// 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<Self> {
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 \
(D3D11Vulkan 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<pw::pyrowave_image> {
// 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<pw::pyrowave_image>,
key: isize,
) -> Result<pw::pyrowave_image> {
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<Option<EncodedFrame>> {
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::<f64>() / 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<ID3D11Texture2D> = 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<ID3D11Texture2D> = 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<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = 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<ID3D11Fence> = 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
/// <bin> --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?)"
);
}
}
}
+1
View File
@@ -1746,6 +1746,7 @@ mod tests {
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
texture: tex.clone(),
device: device.clone(),
pyro: None,
}),
cursor: None,
};
+39 -4
View File
@@ -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<dyn Encoder>, "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 {
+26 -1
View File
@@ -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<isize>,
/// 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<PyroFrameShare>,
}
// 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
+9
View File
@@ -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,
}
}
}
+10 -2
View File
@@ -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")))]
@@ -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,
}
}
}