Files
punktfunk/crates/pf-encode/src/enc/windows/pyrowave.rs
T
enricobuehler 04e4394ee0
apple / swift (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m14s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
windows-host / package (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
fix(encode): close the two teardown memory-safety holes in the reset paths
Both surfaced in a post-refactor quality sweep of pf-encode and were then
verified against the source (and, for pyrowave, against the C side).

- PyroWave (BOTH platforms): `reset()` destroyed the encoder and, when the
  rebuild failed, returned `false` leaving `pw_enc` pointing at the freed
  object — `Drop` then destroyed it a second time. `pyrowave_encoder_destroy`
  is a plain `delete` (pyrowave_c.cpp:1184, which also reads `encoder->device`
  afterwards) with no null check, so this is a real double free. The failure
  branch is not vacuous: the rebuild fails when the device is lost/OOM, which
  is exactly the state that makes the stall watchdog call `reset()` in the
  first place, so the host corrupts its heap on the path that runs when things
  are already going wrong. Now nulls `pw_enc` before the fallible create,
  publishes only on success, and null-guards both `Drop` and `encode_frame`
  (the Windows `Drop` already guarded `sync` this way).

- QSV: `reset()` dropped `pending` — each entry owning the `Box<BsBuf>` the
  runtime writes into asynchronously — BEFORE `MFXVideoENCODE_Close` aborted
  those operations, so the VPL runtime could write into freed heap. The
  preceding drain is best-effort and bails on the first `Err`, i.e. precisely
  the wedged-encoder case that triggers the reset. Fixed by ordering: Close,
  then clear. The full-teardown path was already correct (`Inner` declares
  `session` before `pending`, and fields drop in declaration order).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:07:57 +02:00

1042 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)
}
/// Raise this process's WDDM GPU scheduling priority so the wavelet encode isn't starved by a
/// GPU-bound game. PyroWave encodes on the GPU's compute/shader cores — the exact resource a game
/// saturates — so under load `pyrowave_encoder_encode_gpu_synchronous` spikes from ~2 ms to
/// 15-18 ms (measured, RTX 4090 at 95 % game load) and the stream fps collapses; NVENC is immune
/// because it runs on the separate encoder ASIC. HIGH sits above a game's NORMAL/ABOVE_NORMAL so
/// the WDDM scheduler services the encode's short compute bursts ahead of the game's rendering.
/// (REALTIME is deliberately avoided: it needs a privilege and would preempt the desktop
/// compositor too.) Best-effort + once-per-process: if the class isn't grantable we log and run at
/// normal priority — no session-fatal path.
fn raise_process_gpu_priority() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
use windows::Wdk::Graphics::Direct3D::{
D3DKMTSetProcessSchedulingPriorityClass, D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL,
D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME,
};
// `PUNKTFUNK_GPU_PRIORITY` = off | above-normal | high (default) | realtime. REALTIME can
// force finer WDDM preemption than HIGH but needs a privilege and preempts the compositor,
// so it stays opt-in; `off` skips the call entirely.
let (class, label) = match std::env::var("PUNKTFUNK_GPU_PRIORITY")
.ok()
.as_deref()
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("off") => {
tracing::info!("PyroWave: PUNKTFUNK_GPU_PRIORITY=off — leaving GPU scheduling priority at default");
return;
}
Some("above-normal") | Some("above_normal") => {
(D3DKMT_SCHEDULINGPRIORITYCLASS_ABOVE_NORMAL, "ABOVE_NORMAL")
}
Some("realtime") => (D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME, "REALTIME"),
_ => (D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, "HIGH"),
};
// SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle; the D3DKMT call
// only sets this process's GPU scheduling class — it creates/frees nothing.
let status = unsafe {
D3DKMTSetProcessSchedulingPriorityClass(GetCurrentProcess(), class)
};
if status.is_ok() {
tracing::info!(
priority = label,
"PyroWave: raised process GPU scheduling priority (WDDM) so the wavelet encode is \
serviced ahead of game rendering on the shared shader cores"
);
} else {
tracing::warn!(
?status,
priority = label,
"PyroWave: could not raise GPU scheduling priority (not grantable) — the encode \
may be starved under heavy game GPU load"
);
}
});
}
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,
/// Session-fixed negotiated chroma: 4:4:4 = full-res CbCr plane + `Chroma444` pyrowave objects.
chroma444: bool,
/// Session-fixed negotiated depth ≥10: the capturer's HDR CSC writes P010-style studio codes
/// into 16-bit UNORM planes (`R16_UNORM` Y + `R16G16_UNORM` CbCr) and the sequence header is
/// stamped BT.2020/PQ.
hdr16: bool,
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
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,
chroma: crate::ChromaFormat,
bit_depth: u8,
) -> Result<Self> {
// Prioritize the host's GPU work over a running game so the compute-shader encode gets
// scheduled promptly instead of queuing behind a full frame of the game's rendering.
raise_process_gpu_priority();
let chroma444 = chroma.is_444();
// A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style
// studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) —
// the wire is depth-agnostic, only the plane formats and the CSC change.
let hdr16 = bit_depth >= 10;
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
}
if chroma444 && !crate::pyrowave_mode_fits_rdo(width, height, true) {
// The negotiator downgrades these modes to 4:2:0 pre-Welcome; refuse if one
// slips through (e.g. the lab override) rather than wrap the RDO block index.
bail!(
"pyrowave 4:4:4 at {width}x{height} exceeds the rate controller's 16-bit \
block index (see pyrowave-sys patches/0002 note) — use 4:2:0 at this size"
);
}
let fps = fps.max(1);
// Select pyrowave's device by the SELECTED render adapter's vendor/device-id — NOT by LUID:
// in Session 0 (the host service context) the Vulkan ICD reports `deviceLUIDValid = false`,
// so a by-LUID match would find nothing, while the vendor/device-id match + the external
// import both work (design doc Stage 0; `pyrowave_c.cpp` guards LUID use behind validity).
let (vid, pid) = pf_gpu::selected_gpu()
.map(|s| (s.info.vendor_id, s.info.device_id))
.unwrap_or((0, 0));
// SAFETY: `create_device_by_compat` builds pyrowave's own instance/device from the
// vendor/device-id (null uuids/luid = "don't constrain by those"); the out-param is a live
// local. `confirm_interop_support` / `encoder_create` take that just-created non-null
// device; on any failure we destroy what we created before returning. All pointers are
// freshly created and owned by the returned struct (or freed on the error path).
unsafe {
let mut pw_dev: pw::pyrowave_device = std::ptr::null_mut();
pw_check(
pw::pyrowave_create_device_by_compat(
vid,
pid,
std::ptr::null(),
std::ptr::null(),
std::ptr::null(),
&mut pw_dev,
),
"create_device_by_compat",
)
.with_context(|| {
format!(
"open a PyroWave Vulkan device for GPU {vid:04x}:{pid:04x} (render adapter)"
)
})?;
// The make-or-break gate (design doc Risk 1): confirm this device can do the
// external-memory interop the zero-copy import needs. In a service context where the
// import is unavailable this fails HERE (clean HEVC renegotiation) instead of at the
// first frame's import.
if !pw::pyrowave_device_confirm_interop_support(pw_dev) {
pw::pyrowave_device_destroy(pw_dev);
bail!(
"the PyroWave Vulkan device does not confirm external-memory interop support \
(D3D11→Vulkan zero-copy import unavailable on this GPU / in this session \
context) — the session should renegotiate to HEVC"
);
}
let einfo = pw::pyrowave_encoder_create_info {
device: pw_dev,
width: width as i32,
height: height as i32,
chroma: if chroma444 {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
} else {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
},
};
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
if let Err(e) = pw_check(
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,
chroma = if chroma444 { "4:4:4" } else { "4:2:0" },
hdr = hdr16,
"PyroWave encoder open (Windows separate-plane zero-copy, intra-only wavelet)"
);
Ok(Self {
pw_dev,
pw_enc,
sync: std::ptr::null_mut(),
y_images: Vec::new(),
cbcr_images: Vec::new(),
width,
height,
fps,
chroma444,
hdr16,
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<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
anyhow::ensure!(
!self.pw_enc.is_null(),
"pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)"
);
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);
// Plane geometry/formats follow the negotiated session: chroma half- or full-res,
// 8-bit (SDR BT.709) or 16-bit UNORM (HDR: P010-style studio codes from the CSC).
let (cw, ch) = if self.chroma444 {
(w, h)
} else {
(w / 2, h / 2)
};
let (yf, cf) = if self.hdr16 {
(
pw::VkFormat_VK_FORMAT_R16_UNORM,
pw::VkFormat_VK_FORMAT_R16G16_UNORM,
)
} else {
(
pw::VkFormat_VK_FORMAT_R8_UNORM,
pw::VkFormat_VK_FORMAT_R8G8_UNORM,
)
};
let pw_dev = self.pw_dev;
let y_img = {
let key = d3d.texture.as_raw() as isize;
let tex = &d3d.texture;
Self::cached_plane(
&mut self.y_images,
|| Self::import_plane(pw_dev, tex, yf, 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, cf, cw, ch),
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 buffers = pw::pyrowave_gpu_buffers {
planes: [
plane(
y_vk,
w,
h,
yf,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY,
),
plane(
cbcr_vk,
cw,
ch,
cf,
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
),
plane(
cbcr_vk,
cw,
ch,
cf,
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));
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
// emits studio range — patch the bits HONEST so VUI-honoring clients don't wash out
// blacks; an HDR session additionally stamps BT.2020 primaries + PQ + BT.2020 matrix
// (matching the negotiated ColorInfo).
if let Some(p) = packets.first() {
pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, self.hdr16);
}
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
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 {
// No RFI (every frame is intra). Report the real opened chroma so the session glue's
// post-open cross-check stays quiet on a genuine 4:4:4 session (this codec gained 4:4:4
// after the caps() default was written — a hardcoded `default()` here mis-reports a 4:4:4
// open as 4:2:0 and fires a spurious "chroma disagrees with the negotiated Welcome" warn).
EncoderCaps {
chroma_444: self.chroma444,
..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);
// Publish the null IMMEDIATELY — see the Linux twin. The create below is fallible and
// `pyrowave_encoder_destroy` is a plain `delete` with no null check, so leaving the
// freed pointer in the field makes `Drop` a double free.
self.pw_enc = std::ptr::null_mut();
let einfo = pw::pyrowave_encoder_create_info {
device: self.pw_dev,
width: self.width as i32,
height: self.height as i32,
// Rebuild at the session's real chroma — a hardcoded 420 here would leave a 4:4:4
// session's full-res chroma plane + CSC feeding a 4:2:0 pyrowave encoder.
chroma: if self.chroma444 {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
} else {
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");
// `pw_enc` stays null — `Drop` and `encode_frame` both guard on it.
self.pending.clear();
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 {
// Null when a failed `reset()` already destroyed it — `pyrowave_encoder_destroy`
// is not null-safe (same guard `sync` below has had all along).
if !self.pw_enc.is_null() {
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_R16G16_UNORM, DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R8G8_UNORM,
DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
};
/// Decode a dense PyroWave AU with upstream's own decoder → YUV420P plane means (the golden
/// 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], chroma444: bool) -> (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: if chroma444 {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
} else {
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
},
fragment_path: false,
};
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
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 (cw2, ch2) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
let mut y = vec![0u8; (w * h) as usize];
let mut cb = vec![0u8; (cw2 * ch2) as usize];
let mut cr = vec![0u8; (cw2 * ch2) as usize];
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
buf.format = if chroma444 {
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV444P
} else {
pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P
};
buf.width = w as i32;
buf.height = h as i32;
buf.data = [
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, cw2 as usize, cw2 as usize];
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
assert_eq!(
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
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, hdr: bool, chroma444: bool) -> (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();
// Distinct plane fills at the session's plane formats/geometry. 16-bit fills use
// v16 = v8 * 257 (0xVV,0xVV LE), whose UNORM value equals v8/255 EXACTLY — so the
// 8-bit decode means expect the same 100/180/60 in every mode.
let (cw, ch) = if chroma444 { (w, h) } else { (w / 2, h / 2) };
let (y_tex, cbcr_tex) = if hdr {
(
make_plane(
&device,
&context,
w,
h,
DXGI_FORMAT_R16_UNORM,
2,
&[0x64, 0x64],
),
make_plane(
&device,
&context,
cw,
ch,
DXGI_FORMAT_R16G16_UNORM,
4,
&[0xB4, 0xB4, 0x3C, 0x3C],
),
)
} else {
(
make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]),
make_plane(
&device,
&context,
cw,
ch,
DXGI_FORMAT_R8G8_UNORM,
2,
&[180, 60],
),
)
};
// Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering).
let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5");
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,
if chroma444 {
crate::ChromaFormat::Yuv444
} else {
crate::ChromaFormat::Yuv420
},
if hdr { 10 } else { 8 },
)
.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");
// The dense AU starts with the 8-byte BitstreamSequenceHeader; the range VUI must read
// LIMITED (bit 30 = byte 7 bit 6 = 0x40) — `mark_limited_range` corrects pyrowave's zeroed
// default so VUI-honoring clients (Apple) don't wash out blacks.
assert_eq!(
au.data[7] & 0x40,
0x40,
"sequence header must signal ycbcr_range=LIMITED"
);
if hdr {
assert_eq!(
au.data[7] & 0x78,
0x78,
"HDR sequence header must signal BT.2020 primaries + PQ + BT.2020 matrix"
);
}
decode_plane_means(w, h, &au.data, chroma444)
}
/// The Windows NV12 zero-copy path end-to-end on a real GPU. `#[ignore]`d (needs D3D11 + a
/// 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() {
// The SDR 4:2:0 base case across real streaming sizes (the NVIDIA import
// size-sensitivity check), then every other (hdr, chroma) mode at two sizes —
// the R16/R16G16 and full-res-chroma imports are new surface for the same quirk.
let mut cases = vec![
(1024u32, 1024u32, false, false),
(1280, 720, false, false),
(1920, 1080, false, false),
(2560, 1440, false, false),
];
for &(hdr, c444) in &[(false, true), (true, false), (true, true)] {
cases.push((1280, 720, hdr, c444));
cases.push((1920, 1080, hdr, c444));
}
for (w, h, hdr, c444) in cases {
// SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches.
let (ym, cbm, crm) = unsafe { run_case(w, h, hdr, c444) };
eprintln!(
"{w}x{h} hdr={hdr} 444={c444}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} \
(expect 100/180/60)"
);
assert!(
(ym - 100.0).abs() < 6.0 && (cbm - 180.0).abs() < 6.0 && (crm - 60.0).abs() < 6.0,
"{w}x{h} hdr={hdr} 444={c444}: round-trip means (Y {ym:.1}, Cb {cbm:.1}, \
Cr {crm:.1}) drifted from the filled 100/180/60 — plane mapping/format wrong"
);
}
}
}