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
+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,