feat(pyrowave): Windows host HDR + 4:4:4, Rust client HDR present

Phase 3 of design/pyrowave-444-hdr.md. A PyroWave session now negotiates HDR
(10-bit) and 4:4:4 on a Windows host exactly like HEVC/AV1, and the Linux
client presents it through the real HDR10 path.

Host (Windows): BgraToYuvPlanes becomes mode-aware — SDR/BGRA and HDR/scRGB
variants at half- or full-res chroma. The HDR passes reuse HdrP010Converter's
exact colour math (scRGB -> PQ BT.2020 limited studio codes, verified by
hdr_p010_selftest) but write P010-style MSB-packed codes into two separate
shareable R16_UNORM/R16G16_UNORM textures; chroma keeps the pyrowave family's
centre-sited 2x2 box. idd_push pins the composition to the NEGOTIATED depth
(SDR sessions force advanced color off as before; 10-bit sessions enable it
and ride the FP16 ring), and the descriptor poller re-asserts that state
instead of following display flips the fixed-format encoder can't. The
encoder imports 8/16-bit planes per session and stamps the sequence header's
BT.2020/PQ/matrix bits on HDR (stamp_color_bits, extending 574e3e4e's range
stamp); supports_10bit/can_encode_10bit/can_encode_444 gates open (HDR
Windows-only — Linux capture has no HDR source).

Client: the plane ring becomes R16_UNORM for 10-bit sessions (with a
STORAGE_IMAGE format probe), the planar CSC pass joins the HDR10 swapchain
rebuild (set_hdr_mode previously destroyed it without rebuilding — latent),
st.hdr follows frame.color.is_pq(), and the planar push constants carry
depth-10 MSB-packed rows + the PQ tonemap mode, identical to the NV12 arm.

Verified: .173 (RTX 4090) deploy-config clippy + fmt + wire tests + the
extended pyrowave_win_smoke (10-case {SDR,HDR}x{420,444} matrix incl. R16
imports and header stamps); .21 (RTX 5070 Ti) clippy across 4 crates, host
186 tests, client/presenter/encode tests, both Linux GPU smokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:21:06 +02:00
parent 4861824e7d
commit 188edde2b3
14 changed files with 495 additions and 144 deletions
+1
View File
@@ -316,6 +316,7 @@ fn pump(
connector.shard_payload as usize,
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
color,
connector.bit_depth >= 10,
),
None => Err(anyhow::anyhow!(
"pyrowave session without a presenter device"
+2
View File
@@ -491,6 +491,7 @@ impl Decoder {
shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<Decoder> {
Ok(Decoder {
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
@@ -500,6 +501,7 @@ impl Decoder {
shard_payload,
chroma444,
color,
hdr16,
)?)),
codec_id: ffmpeg::codec::Id::HEVC,
vaapi_fails: 0,
+53 -15
View File
@@ -258,11 +258,12 @@ unsafe fn make_plane(
mem_props: &vk::PhysicalDeviceMemoryProperties,
w: u32,
h: u32,
fmt: vk::Format,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8_UNORM)
.format(fmt)
.extent(vk::Extent3D {
width: w,
height: h,
@@ -306,7 +307,7 @@ unsafe fn make_plane(
&vk::ImageViewCreateInfo::default()
.image(img)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8_UNORM)
.format(fmt)
.subresource_range(vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
@@ -348,6 +349,7 @@ unsafe fn build_ring(
width: u32,
height: u32,
chroma444: bool,
fmt: vk::Format,
) -> Result<Vec<PlaneSet>> {
// 4:2:0 = half-res chroma; 4:4:4 = full-res. The presenter's planar CSC samples with
// normalized UVs, so the chroma plane resolution is transparent to it.
@@ -359,8 +361,8 @@ unsafe fn build_ring(
let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
for _ in 0..RING {
let built = (|| -> Result<PlaneSet> {
let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch) {
let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?;
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) {
Ok(p) => p,
Err(e) => {
device.destroy_image_view(yv, None);
@@ -369,7 +371,7 @@ unsafe fn build_ring(
return Err(e);
}
};
let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch) {
let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch, fmt) {
Ok(p) => p,
Err(e) => {
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
@@ -424,6 +426,9 @@ pub struct PyroWaveDecoder {
/// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI,
/// so the negotiated `ColorInfo` is the contract the presenter CSC configures from.
color: ColorDesc,
/// Session-fixed negotiated depth ≥10: the planes are `R16_UNORM` carrying the host's
/// P010-style studio codes (the presenter samples them with depth-10 MSB-packed rows).
hdr16: bool,
/// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each
/// window holds whole self-delimiting codec packets, zero-padded to the window.
wire_window: usize,
@@ -441,6 +446,7 @@ impl PyroWaveDecoder {
shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<PyroWaveDecoder> {
if !vkd.pyrowave_decode {
bail!("presenter device lacks the PyroWave compute feature set");
@@ -451,7 +457,7 @@ impl PyroWaveDecoder {
// SAFETY: the handles in `vkd` are the presenter's live instance/device (it
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color) }
unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color, hdr16) }
}
unsafe fn new_inner(
@@ -461,6 +467,7 @@ impl PyroWaveDecoder {
shard_payload: usize,
chroma444: bool,
color: ColorDesc,
hdr16: bool,
) -> Result<PyroWaveDecoder> {
let static_fn = ash::StaticFn {
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
@@ -536,7 +543,27 @@ impl PyroWaveDecoder {
let mem_props = instance.get_physical_device_memory_properties(
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
);
let ring = match build_ring(&device, &mem_props, width, height, chroma444) {
// 16-bit sessions decode into R16_UNORM storage planes; STORAGE_IMAGE support for
// R16_UNORM is optional in Vulkan (universal on desktop) — probe it so an exotic
// device fails with a clear message instead of a validation error.
let plane_fmt = if hdr16 {
let props = instance.get_physical_device_format_properties(
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
vk::Format::R16_UNORM,
);
if !props
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::STORAGE_IMAGE)
{
pw::pyrowave_decoder_destroy(pw_dec);
pw::pyrowave_device_destroy(pw_dev);
bail!("this GPU lacks R16_UNORM STORAGE_IMAGE — cannot decode a 10-bit PyroWave session");
}
vk::Format::R16_UNORM
} else {
vk::Format::R8_UNORM
};
let ring = match build_ring(&device, &mem_props, width, height, chroma444, plane_fmt) {
Ok(r) => r,
Err(e) => {
pw::pyrowave_decoder_destroy(pw_dec);
@@ -582,6 +609,7 @@ impl PyroWaveDecoder {
height,
chroma444,
color,
hdr16,
wire_window: shard_payload.max(64),
})
}
@@ -614,14 +642,24 @@ impl PyroWaveDecoder {
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
"decoder_create (mid-stream resize)",
)?;
let new_ring =
match build_ring(&self.device, &self.mem_props, width, height, self.chroma444) {
Ok(r) => r,
Err(e) => {
pw::pyrowave_decoder_destroy(new_dec);
return Err(e).context("plane ring (mid-stream resize)");
}
};
let new_ring = match build_ring(
&self.device,
&self.mem_props,
width,
height,
self.chroma444,
if self.hdr16 {
vk::Format::R16_UNORM
} else {
vk::Format::R8_UNORM
},
) {
Ok(r) => r,
Err(e) => {
pw::pyrowave_decoder_destroy(new_dec);
return Err(e).context("plane ring (mid-stream resize)");
}
};
// Our own decode work is fence-synchronous (never in flight here), so the old
// pyrowave decoder can go immediately; only the plane images wait (retired).
pw::pyrowave_decoder_destroy(self.pw_dec);