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:
@@ -497,25 +497,127 @@ float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
|||||||
}
|
}
|
||||||
";
|
";
|
||||||
|
|
||||||
/// scRGB/BGRA → **separate** BT.709-limited YUV planes for the PyroWave wavelet encoder: a full-res
|
/// PyroWave 4:4:4 CHROMA pass PS — FULL-res, per-pixel (no box filter, no siting), the Windows twin
|
||||||
/// `R8_UNORM` Y texture + a half-res `R8G8_UNORM` interleaved CbCr texture (design/pyrowave-windows-
|
/// of the Linux `rgb2yuv444.comp` chroma math.
|
||||||
/// host-zerocopy.md). The wavelet encoder imports the two SEPARATE textures into its own Vulkan
|
const PYRO_UV444_PS: &str = r"
|
||||||
/// device — the NVIDIA D3D11→Vulkan import of a single *planar* NV12 texture is unreliable at
|
Texture2D<float4> tx : register(t0);
|
||||||
/// arbitrary sizes (the vendored interop test: "only very specific resource sizes"), whereas simple
|
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||||
/// single/two-component textures import reliably. Matches the validated Linux `rgb2yuv.comp` layout
|
float3 c = tx.Load(int3(int2(pos.xy), 0)).rgb;
|
||||||
/// (R8 Y + RG8 CbCr) + colour math exactly, so the wavelet clients decode identically. The caller
|
float u = 128.0/255.0 - 0.1006*c.r - 0.3386*c.g + 0.4392*c.b;
|
||||||
/// owns the two textures + their RTVs (shareable, per out-ring slot); this only records the passes.
|
float v = 128.0/255.0 + 0.4392*c.r - 0.3989*c.g - 0.0403*c.b;
|
||||||
|
return float2(u, v);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Shared HLSL for the PyroWave **HDR** passes: scRGB FP16 → PQ-encoded BT.2020 → 10-bit studio
|
||||||
|
/// codes MSB-packed into 16-bit UNORM — the SAME colour math as [`HDR_P010_COMMON`] (verified by
|
||||||
|
/// `hdr_p010_selftest`), restated over `Load`ed texels so the pyrowave passes stay texel-exact like
|
||||||
|
/// their SDR twins. The wavelet client decodes these planes with the same CSC rows as the P010 path.
|
||||||
|
const PYRO_HDR_COMMON: &str = r"
|
||||||
|
Texture2D<float4> tx : register(t0);
|
||||||
|
static const float3x3 BT709_TO_BT2020 = {
|
||||||
|
0.627403914, 0.329283038, 0.043313048,
|
||||||
|
0.069097292, 0.919540405, 0.011362303,
|
||||||
|
0.016391439, 0.088013308, 0.895595253
|
||||||
|
};
|
||||||
|
float3 pq_oetf(float3 L) {
|
||||||
|
const float m1 = 0.1593017578125;
|
||||||
|
const float m2 = 78.84375;
|
||||||
|
const float c1 = 0.8359375;
|
||||||
|
const float c2 = 18.8515625;
|
||||||
|
const float c3 = 18.6875;
|
||||||
|
float3 Lp = pow(saturate(L), m1);
|
||||||
|
return pow((c1 + c2 * Lp) / (1.0 + c3 * Lp), m2);
|
||||||
|
}
|
||||||
|
float3 scrgb_to_pq2020_rgb(float3 scrgb) {
|
||||||
|
float3 nits = max(scrgb, 0.0) * 80.0;
|
||||||
|
return pq_oetf(mul(BT709_TO_BT2020, nits) / 10000.0);
|
||||||
|
}
|
||||||
|
static const float KR = 0.2627;
|
||||||
|
static const float KG = 0.6780;
|
||||||
|
static const float KB = 0.0593;
|
||||||
|
float y_unorm(float3 pq) {
|
||||||
|
float y = KR * pq.r + KG * pq.g + KB * pq.b;
|
||||||
|
float code = clamp(64.0 + 876.0 * y, 64.0, 940.0);
|
||||||
|
return (code * 64.0) / 65535.0;
|
||||||
|
}
|
||||||
|
float2 cbcr_unorm(float3 pq) {
|
||||||
|
float y = KR * pq.r + KG * pq.g + KB * pq.b;
|
||||||
|
float cbc = clamp(512.0 + 896.0 * (pq.b - y) / 1.8814, 64.0, 960.0);
|
||||||
|
float crc = clamp(512.0 + 896.0 * (pq.r - y) / 1.4746, 64.0, 960.0);
|
||||||
|
return float2((cbc * 64.0) / 65535.0, (crc * 64.0) / 65535.0);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
/// PyroWave HDR LUMA pass PS — full-res, writes PQ Y′ studio codes to an `R16_UNORM` texture.
|
||||||
|
const PYRO_HDR_Y_PS: &str = r"
|
||||||
|
#include_common
|
||||||
|
float main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||||
|
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
|
||||||
|
return y_unorm(pq);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
/// PyroWave HDR 4:2:0 CHROMA pass PS — half-res, centre-sited 2×2 box in scRGB-LINEAR space (the
|
||||||
|
/// pyrowave family's siting, matching the SDR pass + `rgb2yuv.comp`, NOT the P010 path's
|
||||||
|
/// left-cositing), then PQ + studio Cb/Cr into an `R16G16_UNORM` texture.
|
||||||
|
const PYRO_HDR_UV_PS: &str = r"
|
||||||
|
#include_common
|
||||||
|
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||||
|
int2 p = int2(pos.xy) * 2;
|
||||||
|
float3 a = max(tx.Load(int3(p, 0)).rgb, 0.0);
|
||||||
|
float3 b = max(tx.Load(int3(p + int2(1,0), 0)).rgb, 0.0);
|
||||||
|
float3 c = max(tx.Load(int3(p + int2(0,1), 0)).rgb, 0.0);
|
||||||
|
float3 d = max(tx.Load(int3(p + int2(1,1), 0)).rgb, 0.0);
|
||||||
|
float3 pq = scrgb_to_pq2020_rgb((a + b + c + d) * 0.25);
|
||||||
|
return cbcr_unorm(pq);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
/// PyroWave HDR 4:4:4 CHROMA pass PS — full-res, per-pixel.
|
||||||
|
const PYRO_HDR_UV444_PS: &str = r"
|
||||||
|
#include_common
|
||||||
|
float2 main(float4 pos : SV_POSITION) : SV_TARGET {
|
||||||
|
float3 pq = scrgb_to_pq2020_rgb(tx.Load(int3(int2(pos.xy), 0)).rgb);
|
||||||
|
return cbcr_unorm(pq);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
/// scRGB/BGRA → **separate** YUV planes for the PyroWave wavelet encoder: a full-res Y texture + a
|
||||||
|
/// (half- or full-res) interleaved CbCr texture (design/pyrowave-windows-host-zerocopy.md +
|
||||||
|
/// design/pyrowave-444-hdr.md). SDR mode reads the BGRA slot and writes BT.709-limited 8-bit planes
|
||||||
|
/// (`R8_UNORM`/`R8G8_UNORM`), byte-identical to the Linux `rgb2yuv(444).comp`; HDR mode reads the
|
||||||
|
/// scRGB FP16 slot and writes P010-style 10-bit studio codes MSB-packed into 16-bit planes
|
||||||
|
/// (`R16_UNORM`/`R16G16_UNORM`), colour math identical to [`HdrP010Converter`]. 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, whereas simple single/
|
||||||
|
/// two-component textures import reliably. The caller owns the two textures + their RTVs (shareable,
|
||||||
|
/// per out-ring slot); this only records the passes.
|
||||||
pub(crate) struct BgraToYuvPlanes {
|
pub(crate) struct BgraToYuvPlanes {
|
||||||
vs: ID3D11VertexShader,
|
vs: ID3D11VertexShader,
|
||||||
ps_y: ID3D11PixelShader,
|
ps_y: ID3D11PixelShader,
|
||||||
ps_uv: ID3D11PixelShader,
|
ps_uv: ID3D11PixelShader,
|
||||||
|
/// Full-res chroma pass (4:4:4) — the chroma viewport skips the /2.
|
||||||
|
chroma444: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BgraToYuvPlanes {
|
impl BgraToYuvPlanes {
|
||||||
pub(crate) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
pub(crate) unsafe fn new(device: &ID3D11Device, hdr: bool, chroma444: bool) -> Result<Self> {
|
||||||
|
let (y_src, uv_src) = match (hdr, chroma444) {
|
||||||
|
(false, false) => (PYRO_Y_PS.to_string(), PYRO_UV_PS.to_string()),
|
||||||
|
(false, true) => (PYRO_Y_PS.to_string(), PYRO_UV444_PS.to_string()),
|
||||||
|
(true, false) => (
|
||||||
|
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||||
|
PYRO_HDR_UV_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||||
|
),
|
||||||
|
(true, true) => (
|
||||||
|
PYRO_HDR_Y_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||||
|
PYRO_HDR_UV444_PS.replace("#include_common", PYRO_HDR_COMMON),
|
||||||
|
),
|
||||||
|
};
|
||||||
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
|
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 yb = compile_shader(&y_src, s!("main"), s!("ps_5_0"))?;
|
||||||
let uvb = compile_shader(PYRO_UV_PS, s!("main"), s!("ps_5_0"))?;
|
let uvb = compile_shader(&uv_src, s!("main"), s!("ps_5_0"))?;
|
||||||
let mut vs = None;
|
let mut vs = None;
|
||||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||||
let mut ps_y = None;
|
let mut ps_y = None;
|
||||||
@@ -526,11 +628,13 @@ impl BgraToYuvPlanes {
|
|||||||
vs: vs.context("pyro vs")?,
|
vs: vs.context("pyro vs")?,
|
||||||
ps_y: ps_y.context("pyro y ps")?,
|
ps_y: ps_y.context("pyro y ps")?,
|
||||||
ps_uv: ps_uv.context("pyro uv ps")?,
|
ps_uv: ps_uv.context("pyro uv ps")?,
|
||||||
|
chroma444,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert `src_srv` (BGRA slot, WxH) → `y_rtv` (a full-res `R8_UNORM` texture) + `cbcr_rtv` (a
|
/// Convert `src_srv` (BGRA slot for SDR / scRGB FP16 slot for HDR, WxH) → `y_rtv` (full-res Y
|
||||||
/// half-res `R8G8_UNORM` texture). Two opaque passes; `w`/`h` are the full luma dims (even).
|
/// texture) + `cbcr_rtv` (half- or full-res CbCr texture per the constructed mode). Two opaque
|
||||||
|
/// passes; `w`/`h` are the full luma dims (even for 4:2:0).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) unsafe fn convert(
|
pub(crate) unsafe fn convert(
|
||||||
&self,
|
&self,
|
||||||
@@ -561,12 +665,17 @@ impl BgraToYuvPlanes {
|
|||||||
ctx.Draw(3, 0);
|
ctx.Draw(3, 0);
|
||||||
ctx.OMSetRenderTargets(Some(&[None]), None);
|
ctx.OMSetRenderTargets(Some(&[None]), None);
|
||||||
|
|
||||||
// CHROMA pass: half-res → the R8G8 CbCr texture.
|
// CHROMA pass: half-res (4:2:0) or full-res (4:4:4) → the CbCr texture.
|
||||||
|
let (cw, ch) = if self.chroma444 {
|
||||||
|
(w, h)
|
||||||
|
} else {
|
||||||
|
(w / 2, h / 2)
|
||||||
|
};
|
||||||
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
ctx.RSSetViewports(Some(&[D3D11_VIEWPORT {
|
||||||
TopLeftX: 0.0,
|
TopLeftX: 0.0,
|
||||||
TopLeftY: 0.0,
|
TopLeftY: 0.0,
|
||||||
Width: (w / 2) as f32,
|
Width: cw as f32,
|
||||||
Height: (h / 2) as f32,
|
Height: ch as f32,
|
||||||
MinDepth: 0.0,
|
MinDepth: 0.0,
|
||||||
MaxDepth: 1.0,
|
MaxDepth: 1.0,
|
||||||
}]));
|
}]));
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16_UNORM, DXGI_FORMAT_R16_UNORM,
|
||||||
|
DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4, IDXGIKeyedMutex, IDXGIResource1,
|
||||||
@@ -408,11 +409,14 @@ pub struct IddPushCapturer {
|
|||||||
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
|
/// 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.
|
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||||
want_444: bool,
|
want_444: bool,
|
||||||
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md). When set the out-ring
|
/// A PyroWave (wavelet) session (design/pyrowave-windows-host-zerocopy.md +
|
||||||
/// is created **shareable** (`SHARED | SHARED_NTHANDLE`) and a **shared fence** is signalled after
|
/// design/pyrowave-444-hdr.md). When set, frames come from the separate-plane `pyro_ring`
|
||||||
/// each convert/copy, so the pyrowave encoder can zero-copy-import the NV12 texture into its own
|
/// (shareable Y + CbCr textures the mode-aware [`BgraToYuvPlanes`] CSC writes) and a **shared
|
||||||
/// Vulkan device and order the read after the D3D11 convert. Also forces the NV12 4:2:0 SDR convert
|
/// fence** is signalled after each convert, so the pyrowave encoder zero-copy-imports the two
|
||||||
/// (never P010 / BGRA-passthrough) regardless of `display_hdr` / `want_444`.
|
/// textures into its own Vulkan device ordered after the D3D11 convert. The composition is
|
||||||
|
/// PINNED to the negotiated depth: SDR sessions force advanced color OFF (8-bit BGRA → R8
|
||||||
|
/// planes), 10-bit sessions enable it like H.26x (scRGB FP16 → R16 studio-code planes);
|
||||||
|
/// `want_444` sizes the chroma plane full-res.
|
||||||
pyrowave: bool,
|
pyrowave: bool,
|
||||||
/// PyroWave: the shared D3D11 timeline fence (created lazily on the first frame, `SHARED` flag).
|
/// 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.
|
/// The capturer `Signal`s it after each frame's GPU convert; the encoder's Vulkan side waits it.
|
||||||
@@ -746,13 +750,14 @@ impl IddPushCapturer {
|
|||||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||||
unsafe {
|
unsafe {
|
||||||
// PyroWave is an 8-bit SDR wavelet codec with no 10-bit path, and the NVIDIA D3D11
|
// An SDR-negotiated PyroWave session must run on an SDR (BGRA) composition — its CSC
|
||||||
// VideoProcessor cannot ingest the FP16 HDR ring (CreateVideoProcessorInputView rejects
|
// reads 8-bit BGRA (and the NVIDIA D3D11 VideoProcessor can't ingest the FP16 ring
|
||||||
// R16G16B16A16_FLOAT) — so a pyrowave session must run on an SDR (BGRA) composition.
|
// anyway). Actively turn advanced color OFF (undoing any leftover HDR state from a
|
||||||
// Actively turn advanced color OFF on the virtual display (undoing any leftover HDR state
|
// prior session on a reused/lingering monitor) and settle before sizing the ring. An
|
||||||
// from a prior session on a reused/lingering monitor) and settle before sizing the ring,
|
// HDR-negotiated (10-bit) PyroWave session instead enables HDR below exactly like the
|
||||||
// mirroring the enable path's settle so the driver composes BGRA before we size BGRA.
|
// H.26x path and rides the FP16 scRGB ring through the pyro HDR CSC
|
||||||
if pyrowave {
|
// (design/pyrowave-444-hdr.md Phase 3).
|
||||||
|
if pyrowave && !client_10bit {
|
||||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||||
let settle = Instant::now();
|
let settle = Instant::now();
|
||||||
while settle.elapsed() < Duration::from_millis(250) {
|
while settle.elapsed() < Duration::from_millis(250) {
|
||||||
@@ -785,7 +790,6 @@ impl IddPushCapturer {
|
|||||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
// 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).
|
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||||
let enabled_hdr = client_10bit
|
let enabled_hdr = client_10bit
|
||||||
&& !pyrowave
|
|
||||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||||
if enabled_hdr {
|
if enabled_hdr {
|
||||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||||
@@ -811,8 +815,9 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
// 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.
|
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||||
// PyroWave forced advanced color OFF above, so it is always SDR (never the FP16 ring).
|
// An SDR PyroWave session forced advanced color OFF above; guard against a physical
|
||||||
let display_hdr = !pyrowave
|
// display forcing HDR anyway (the wavelet SDR CSC can't read FP16).
|
||||||
|
let display_hdr = (!pyrowave || client_10bit)
|
||||||
&& (enabled_hdr
|
&& (enabled_hdr
|
||||||
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
|| pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||||
.unwrap_or(false));
|
.unwrap_or(false));
|
||||||
@@ -1227,12 +1232,15 @@ impl IddPushCapturer {
|
|||||||
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
|
/// 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.
|
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||||
// PyroWave is an 8-bit SDR wavelet codec: always NV12 (BT.709 limited), never P010 /
|
// PyroWave never uses this out-ring (it has its own separate-plane `pyro_ring`); the
|
||||||
// BGRA-passthrough — an HDR desktop is tone-mapped down by the NV12 converter, a 4:4:4
|
// format here only labels the frame. SDR sessions label NV12 (BT.709 limited), HDR
|
||||||
// negotiation is moot (pyrowave is 4:2:0). The client strips HDR/10-bit/444 when it selects
|
// (negotiated 10-bit) sessions P010 — matching the studio-code planes the pyro CSC writes.
|
||||||
// PyroWave, so this is the honest match.
|
|
||||||
if self.pyrowave {
|
if self.pyrowave {
|
||||||
return (DXGI_FORMAT_NV12, PixelFormat::Nv12);
|
return if self.display_hdr {
|
||||||
|
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||||
|
} else {
|
||||||
|
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if self.display_hdr {
|
if self.display_hdr {
|
||||||
if self.want_444 {
|
if self.want_444 {
|
||||||
@@ -1341,16 +1349,20 @@ impl IddPushCapturer {
|
|||||||
return; // no new sample since last consume
|
return; // no new sample since last consume
|
||||||
}
|
}
|
||||||
self.desc_seq = seq;
|
self.desc_seq = seq;
|
||||||
// PyroWave forced advanced color OFF at open and never uses the FP16 ring. If a leftover or
|
// A PyroWave session's composition is PINNED to the NEGOTIATED depth: its encoder was
|
||||||
// late CCD sample reports the display as HDR, re-assert the disable and treat it as SDR — so
|
// opened for fixed plane formats (R8 SDR / R16 HDR), so a mid-session "Use HDR" flip
|
||||||
// we never recreate the ring FP16 (which the wavelet encoder cannot feed).
|
// can't be followed like H.26x does — re-assert the negotiated state instead and treat
|
||||||
if self.pyrowave && now.hdr {
|
// the descriptor accordingly (the ring is never recreated at the wrong format).
|
||||||
|
if self.pyrowave && now.hdr != self.client_10bit {
|
||||||
// SAFETY: `set_advanced_color` is `unsafe` (CCD DisplayConfig calls); it takes a plain
|
// 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.
|
// `u32` target id + bool, forms no lasting borrow, and returns a bool.
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = pf_win_display::win_display::set_advanced_color(self.target_id, false);
|
let _ = pf_win_display::win_display::set_advanced_color(
|
||||||
|
self.target_id,
|
||||||
|
self.client_10bit,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
now.hdr = false;
|
now.hdr = self.client_10bit;
|
||||||
}
|
}
|
||||||
let current = DisplayDescriptor {
|
let current = DisplayDescriptor {
|
||||||
hdr: self.display_hdr,
|
hdr: self.display_hdr,
|
||||||
@@ -1465,9 +1477,22 @@ impl IddPushCapturer {
|
|||||||
.context("CreateRenderTargetView(pyro plane)")?;
|
.context("CreateRenderTargetView(pyro plane)")?;
|
||||||
Ok((tex, rtv.context("null pyro plane rtv")?))
|
Ok((tex, rtv.context("null pyro plane rtv")?))
|
||||||
};
|
};
|
||||||
|
// Plane formats/geometry follow the negotiated session: 16-bit UNORM planes for an
|
||||||
|
// HDR (10-bit) session (P010-style studio codes from the pyro HDR CSC), full-res
|
||||||
|
// chroma for 4:4:4 (design/pyrowave-444-hdr.md Phase 3).
|
||||||
|
let (yf, cf) = if self.display_hdr {
|
||||||
|
(DXGI_FORMAT_R16_UNORM, DXGI_FORMAT_R16G16_UNORM)
|
||||||
|
} else {
|
||||||
|
(DXGI_FORMAT_R8_UNORM, DXGI_FORMAT_R8G8_UNORM)
|
||||||
|
};
|
||||||
|
let (cw, ch) = if self.want_444 {
|
||||||
|
(w, h)
|
||||||
|
} else {
|
||||||
|
(w / 2, h / 2)
|
||||||
|
};
|
||||||
for _ in 0..OUT_RING {
|
for _ in 0..OUT_RING {
|
||||||
let (y, y_rtv) = make(&self.device, DXGI_FORMAT_R8_UNORM, w, h)?;
|
let (y, y_rtv) = make(&self.device, yf, w, h)?;
|
||||||
let (cbcr, cbcr_rtv) = make(&self.device, DXGI_FORMAT_R8G8_UNORM, w / 2, h / 2)?;
|
let (cbcr, cbcr_rtv) = make(&self.device, cf, cw, ch)?;
|
||||||
self.pyro_ring.push(PyroOutSlot {
|
self.pyro_ring.push(PyroOutSlot {
|
||||||
y,
|
y,
|
||||||
y_rtv,
|
y_rtv,
|
||||||
@@ -1479,12 +1504,17 @@ impl IddPushCapturer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PyroWave: build the BGRA→YUV-planes CSC if not yet built.
|
/// PyroWave: build the (mode-aware) RGB→YUV-planes CSC if not yet built. The mode is
|
||||||
|
/// session-fixed: SDR/BGRA vs HDR/scRGB input, half- vs full-res chroma — the composition
|
||||||
|
/// is pinned to the negotiated depth (`poll_display_hdr`), so the converter never needs a
|
||||||
|
/// mid-session mode swap.
|
||||||
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
fn ensure_pyro_conv(&mut self) -> Result<()> {
|
||||||
if self.pyro_conv.is_none() {
|
if self.pyro_conv.is_none() {
|
||||||
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
// SAFETY: `BgraToYuvPlanes::new` compiles D3D11 shaders on `self.device`; `?` propagates
|
||||||
// failure before it is stored.
|
// failure before it is stored.
|
||||||
self.pyro_conv = Some(unsafe { BgraToYuvPlanes::new(&self.device)? });
|
self.pyro_conv = Some(unsafe {
|
||||||
|
BgraToYuvPlanes::new(&self.device, self.display_hdr, self.want_444)?
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1678,10 +1708,10 @@ impl IddPushCapturer {
|
|||||||
// the slot back to the driver.
|
// the slot back to the driver.
|
||||||
unsafe {
|
unsafe {
|
||||||
if self.pyrowave {
|
if self.pyrowave {
|
||||||
// PyroWave: BGRA slot SRV → separate R8 Y + R8G8 CbCr planes (BT.709 SDR) via the
|
// PyroWave: ring slot SRV (BGRA for SDR, scRGB FP16 for HDR) → the two separate
|
||||||
// CSC shader; the shared fence signalled just after (`pyro_fence_signal`) orders
|
// plane textures via the mode-aware CSC; the shared fence signalled just after
|
||||||
// the encoder's cross-device Vulkan read after this convert. (The pyrowave session
|
// (`pyro_fence_signal`) orders the encoder's cross-device Vulkan read after this
|
||||||
// forced the display SDR, so the slot is BGRA.)
|
// convert. The composition format is pinned to the negotiated depth.
|
||||||
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
let (_, y_rtv, _, cbcr_rtv) = pyro_slot.as_ref().expect("pyro slot");
|
||||||
if let Some(conv) = self.pyro_conv.as_ref() {
|
if let Some(conv) = self.pyro_conv.as_ref() {
|
||||||
conv.convert(
|
conv.convert(
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ fn pump(
|
|||||||
connector.shard_payload as usize,
|
connector.shard_payload as usize,
|
||||||
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444,
|
||||||
color,
|
color,
|
||||||
|
connector.bit_depth >= 10,
|
||||||
),
|
),
|
||||||
None => Err(anyhow::anyhow!(
|
None => Err(anyhow::anyhow!(
|
||||||
"pyrowave session without a presenter device"
|
"pyrowave session without a presenter device"
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ impl Decoder {
|
|||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
chroma444: bool,
|
||||||
color: ColorDesc,
|
color: ColorDesc,
|
||||||
|
hdr16: bool,
|
||||||
) -> Result<Decoder> {
|
) -> Result<Decoder> {
|
||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||||||
@@ -500,6 +501,7 @@ impl Decoder {
|
|||||||
shard_payload,
|
shard_payload,
|
||||||
chroma444,
|
chroma444,
|
||||||
color,
|
color,
|
||||||
|
hdr16,
|
||||||
)?)),
|
)?)),
|
||||||
codec_id: ffmpeg::codec::Id::HEVC,
|
codec_id: ffmpeg::codec::Id::HEVC,
|
||||||
vaapi_fails: 0,
|
vaapi_fails: 0,
|
||||||
|
|||||||
@@ -258,11 +258,12 @@ unsafe fn make_plane(
|
|||||||
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
mem_props: &vk::PhysicalDeviceMemoryProperties,
|
||||||
w: u32,
|
w: u32,
|
||||||
h: u32,
|
h: u32,
|
||||||
|
fmt: vk::Format,
|
||||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||||
let img = device.create_image(
|
let img = device.create_image(
|
||||||
&vk::ImageCreateInfo::default()
|
&vk::ImageCreateInfo::default()
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
.image_type(vk::ImageType::TYPE_2D)
|
||||||
.format(vk::Format::R8_UNORM)
|
.format(fmt)
|
||||||
.extent(vk::Extent3D {
|
.extent(vk::Extent3D {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
@@ -306,7 +307,7 @@ unsafe fn make_plane(
|
|||||||
&vk::ImageViewCreateInfo::default()
|
&vk::ImageViewCreateInfo::default()
|
||||||
.image(img)
|
.image(img)
|
||||||
.view_type(vk::ImageViewType::TYPE_2D)
|
.view_type(vk::ImageViewType::TYPE_2D)
|
||||||
.format(vk::Format::R8_UNORM)
|
.format(fmt)
|
||||||
.subresource_range(vk::ImageSubresourceRange {
|
.subresource_range(vk::ImageSubresourceRange {
|
||||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||||
base_mip_level: 0,
|
base_mip_level: 0,
|
||||||
@@ -348,6 +349,7 @@ unsafe fn build_ring(
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
chroma444: bool,
|
chroma444: bool,
|
||||||
|
fmt: vk::Format,
|
||||||
) -> Result<Vec<PlaneSet>> {
|
) -> Result<Vec<PlaneSet>> {
|
||||||
// 4:2:0 = half-res chroma; 4:4:4 = full-res. The presenter's planar CSC samples with
|
// 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.
|
// 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);
|
let mut ring: Vec<PlaneSet> = Vec::with_capacity(RING);
|
||||||
for _ in 0..RING {
|
for _ in 0..RING {
|
||||||
let built = (|| -> Result<PlaneSet> {
|
let built = (|| -> Result<PlaneSet> {
|
||||||
let (y, ym, yv) = make_plane(device, mem_props, width, height)?;
|
let (y, ym, yv) = make_plane(device, mem_props, width, height, fmt)?;
|
||||||
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch) {
|
let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch, fmt) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
device.destroy_image_view(yv, None);
|
device.destroy_image_view(yv, None);
|
||||||
@@ -369,7 +371,7 @@ unsafe fn build_ring(
|
|||||||
return Err(e);
|
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,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] {
|
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,
|
/// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI,
|
||||||
/// so the negotiated `ColorInfo` is the contract the presenter CSC configures from.
|
/// so the negotiated `ColorInfo` is the contract the presenter CSC configures from.
|
||||||
color: ColorDesc,
|
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
|
/// 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.
|
/// window holds whole self-delimiting codec packets, zero-padded to the window.
|
||||||
wire_window: usize,
|
wire_window: usize,
|
||||||
@@ -441,6 +446,7 @@ impl PyroWaveDecoder {
|
|||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
chroma444: bool,
|
||||||
color: ColorDesc,
|
color: ColorDesc,
|
||||||
|
hdr16: bool,
|
||||||
) -> Result<PyroWaveDecoder> {
|
) -> Result<PyroWaveDecoder> {
|
||||||
if !vkd.pyrowave_decode {
|
if !vkd.pyrowave_decode {
|
||||||
bail!("presenter device lacks the PyroWave compute feature set");
|
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
|
// SAFETY: the handles in `vkd` are the presenter's live instance/device (it
|
||||||
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
// outlives the decoder — same contract the FFmpeg Vulkan backend relies on);
|
||||||
// `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime.
|
// `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(
|
unsafe fn new_inner(
|
||||||
@@ -461,6 +467,7 @@ impl PyroWaveDecoder {
|
|||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
chroma444: bool,
|
chroma444: bool,
|
||||||
color: ColorDesc,
|
color: ColorDesc,
|
||||||
|
hdr16: bool,
|
||||||
) -> Result<PyroWaveDecoder> {
|
) -> Result<PyroWaveDecoder> {
|
||||||
let static_fn = ash::StaticFn {
|
let static_fn = ash::StaticFn {
|
||||||
get_instance_proc_addr: std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
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(
|
let mem_props = instance.get_physical_device_memory_properties(
|
||||||
vk::PhysicalDevice::from_raw(vkd.physical_device as u64),
|
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,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
pw::pyrowave_decoder_destroy(pw_dec);
|
pw::pyrowave_decoder_destroy(pw_dec);
|
||||||
@@ -582,6 +609,7 @@ impl PyroWaveDecoder {
|
|||||||
height,
|
height,
|
||||||
chroma444,
|
chroma444,
|
||||||
color,
|
color,
|
||||||
|
hdr16,
|
||||||
wire_window: shard_payload.max(64),
|
wire_window: shard_payload.max(64),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -614,8 +642,18 @@ impl PyroWaveDecoder {
|
|||||||
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
|
pw::pyrowave_decoder_create(&dinfo, &mut new_dec),
|
||||||
"decoder_create (mid-stream resize)",
|
"decoder_create (mid-stream resize)",
|
||||||
)?;
|
)?;
|
||||||
let new_ring =
|
let new_ring = match build_ring(
|
||||||
match build_ring(&self.device, &self.mem_props, width, height, self.chroma444) {
|
&self.device,
|
||||||
|
&self.mem_props,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.chroma444,
|
||||||
|
if self.hdr16 {
|
||||||
|
vk::Format::R16_UNORM
|
||||||
|
} else {
|
||||||
|
vk::Format::R8_UNORM
|
||||||
|
},
|
||||||
|
) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
pw::pyrowave_decoder_destroy(new_dec);
|
pw::pyrowave_decoder_destroy(new_dec);
|
||||||
|
|||||||
@@ -104,13 +104,14 @@ impl Codec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
|
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit;
|
||||||
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
|
/// PyroWave rides 16-bit UNORM planes carrying P010-style studio codes — the wavelet is
|
||||||
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
|
/// depth-agnostic, design/pyrowave-444-hdr.md). H.264 is always 8-bit (High10 is neither an
|
||||||
|
/// NVENC nor a VCN encode mode — negotiation never asks). `true` here is only the
|
||||||
/// *codec-level* gate: the active GPU/backend must still pass
|
/// *codec-level* gate: the active GPU/backend must still pass
|
||||||
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
/// [`can_encode_10bit`](crate::can_encode_10bit) before the host negotiates 10-bit.
|
||||||
pub fn supports_10bit(self) -> bool {
|
pub fn supports_10bit(self) -> bool {
|
||||||
matches!(self, Codec::H265 | Codec::Av1)
|
matches!(self, Codec::H265 | Codec::Av1 | Codec::PyroWave)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||||
|
|||||||
@@ -1134,9 +1134,10 @@ impl PyroWaveEncoder {
|
|||||||
)?;
|
)?;
|
||||||
packets.truncate(out_n.max(1));
|
packets.truncate(out_n.max(1));
|
||||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
||||||
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
|
// emits BT.709 LIMITED — patch the bits HONEST so VUI-honoring clients don't wash out
|
||||||
|
// blacks. (Linux capture has no HDR path, so this side never stamps BT.2020/PQ.)
|
||||||
if let Some(p) = packets.first() {
|
if let Some(p) = packets.first() {
|
||||||
crate::pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
|
crate::pyrowave_wire::stamp_color_bits(&mut self.bitstream, p.offset, false);
|
||||||
}
|
}
|
||||||
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense
|
// 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).
|
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||||
@@ -1678,5 +1679,19 @@ mod tests {
|
|||||||
dump("ref-chunked-y.bin", &y);
|
dump("ref-chunked-y.bin", &y);
|
||||||
dump("ref-chunked-cb.bin", &cb);
|
dump("ref-chunked-cb.bin", &cb);
|
||||||
dump("ref-chunked-cr.bin", &cr);
|
dump("ref-chunked-cr.bin", &cr);
|
||||||
|
|
||||||
|
// 4:4:4 dense AU + its reference (full-res chroma planes) — the Apple 4:4:4 layout's
|
||||||
|
// golden (design/pyrowave-444-hdr.md Phase 4). Same odd-block geometry.
|
||||||
|
let mut enc =
|
||||||
|
PyroWaveEncoder::open(w, h, 60, 6_500_000, crate::ChromaFormat::Yuv444).expect("open");
|
||||||
|
enc.submit(&test_card(w, h, 13)).expect("444 submit");
|
||||||
|
let au = enc.poll().expect("poll").expect("444 AU");
|
||||||
|
assert!(!au.chunk_aligned);
|
||||||
|
dump("au-dense444.bin", &au.data);
|
||||||
|
// SAFETY: test-only FFI with locally-owned buffers.
|
||||||
|
let (y, cb, cr) = unsafe { decode_planes_chroma(w, h, &au.data, true) };
|
||||||
|
dump("ref-dense444-y.bin", &y);
|
||||||
|
dump("ref-dense444-cb.bin", &cb);
|
||||||
|
dump("ref-dense444-cr.bin", &cr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,19 @@ pub(crate) fn packet_boundary(wire_chunk: Option<usize>, dense_cap: usize) -> us
|
|||||||
/// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct.
|
/// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct.
|
||||||
///
|
///
|
||||||
/// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` —
|
/// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` —
|
||||||
/// the SOF packet's offset. `ycbcr_range` is bit 30 of the little-endian second word, i.e. bit 6 of
|
/// the SOF packet's offset. The colour bits live in the little-endian second word's top byte
|
||||||
/// byte `seq_offset + 7` (`0x40`).
|
/// (`seq_offset + 7`): `color_primaries` bit 27 (`0x08`), `transfer_function` bit 28 (`0x10`),
|
||||||
pub(crate) fn mark_limited_range(bitstream: &mut [u8], seq_offset: usize) {
|
/// `ycbcr_transform` bit 29 (`0x20`), `ycbcr_range` bit 30 (`0x40`); `chroma_siting` bit 31 stays 0
|
||||||
|
/// (CENTER — the pyrowave CSCs use the centre-sited 2×2 box, unlike the left-cosited P010 path).
|
||||||
|
/// Range is ALWAYS stamped LIMITED (both CSCs emit studio range); `bt2020_pq` additionally stamps
|
||||||
|
/// BT.2020 primaries + PQ transfer + BT.2020 matrix — upstream's own enum semantics
|
||||||
|
/// (`pyrowave_common.hpp`), matching the session's negotiated `ColorInfo`.
|
||||||
|
pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_pq: bool) {
|
||||||
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
|
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
|
||||||
*b |= 0x40;
|
*b |= 0x40;
|
||||||
|
if bt2020_pq {
|
||||||
|
*b |= 0x08 | 0x10 | 0x20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,16 +190,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mark_limited_range_sets_only_the_range_bit() {
|
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||||
let mut bs = vec![0u8; 16];
|
let mut bs = vec![0u8; 16];
|
||||||
mark_limited_range(&mut bs, 0);
|
stamp_color_bits(&mut bs, 0, false);
|
||||||
// ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched.
|
// ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched.
|
||||||
assert_eq!(bs[7], 0x40);
|
assert_eq!(bs[7], 0x40);
|
||||||
assert!(bs[..7].iter().all(|&b| b == 0));
|
assert!(bs[..7].iter().all(|&b| b == 0));
|
||||||
assert!(bs[8..].iter().all(|&b| b == 0));
|
assert!(bs[8..].iter().all(|&b| b == 0));
|
||||||
// Idempotent; an out-of-range offset is a silent no-op (never panics).
|
// Idempotent; an out-of-range offset is a silent no-op (never panics).
|
||||||
mark_limited_range(&mut bs, 0);
|
stamp_color_bits(&mut bs, 0, false);
|
||||||
assert_eq!(bs[7], 0x40);
|
assert_eq!(bs[7], 0x40);
|
||||||
mark_limited_range(&mut bs, 100);
|
stamp_color_bits(&mut bs, 100, false);
|
||||||
|
// HDR adds BT.2020 primaries (0x08) + PQ transfer (0x10) + BT.2020 matrix (0x20);
|
||||||
|
// chroma_siting (0x80) stays CENTER.
|
||||||
|
stamp_color_bits(&mut bs, 0, true);
|
||||||
|
assert_eq!(bs[7], 0x78);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ pub struct PyroWaveEncoder {
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
fps: 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)`.
|
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
|
||||||
frame_budget: usize,
|
frame_budget: usize,
|
||||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||||
@@ -104,14 +110,14 @@ impl PyroWaveEncoder {
|
|||||||
fps: u32,
|
fps: u32,
|
||||||
bitrate_bps: u64,
|
bitrate_bps: u64,
|
||||||
chroma: crate::ChromaFormat,
|
chroma: crate::ChromaFormat,
|
||||||
|
bit_depth: u8,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
if chroma.is_444() {
|
let chroma444 = chroma.is_444();
|
||||||
// Negotiation can't reach here yet: `can_encode_444` returns false for PyroWave
|
// A negotiated 10-bit session rides 16-bit UNORM planes carrying the P010-style
|
||||||
// until the full-res-chroma BgraToYuvPlanes variant lands
|
// studio codes the capturer's HDR CSC writes (design/pyrowave-444-hdr.md §2.2) —
|
||||||
// (design/pyrowave-444-hdr.md Phase 3). Threaded now so that flip is one-file.
|
// the wire is depth-agnostic, only the plane formats and the CSC change.
|
||||||
bail!("pyrowave 4:4:4 encode not implemented yet (Phase 3)");
|
let hdr16 = bit_depth >= 10;
|
||||||
}
|
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
|
||||||
if width % 2 != 0 || height % 2 != 0 {
|
|
||||||
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
|
||||||
}
|
}
|
||||||
let fps = fps.max(1);
|
let fps = fps.max(1);
|
||||||
@@ -163,7 +169,11 @@ impl PyroWaveEncoder {
|
|||||||
device: pw_dev,
|
device: pw_dev,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
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();
|
let mut pw_enc: pw::pyrowave_encoder = std::ptr::null_mut();
|
||||||
if let Err(e) = pw_check(
|
if let Err(e) = pw_check(
|
||||||
@@ -179,7 +189,9 @@ impl PyroWaveEncoder {
|
|||||||
gpu = format!("{vid:04x}:{pid:04x}"),
|
gpu = format!("{vid:04x}:{pid:04x}"),
|
||||||
mode = %format!("{width}x{height}@{fps}"),
|
mode = %format!("{width}x{height}@{fps}"),
|
||||||
budget_kib = frame_budget / 1024,
|
budget_kib = frame_budget / 1024,
|
||||||
"PyroWave encoder open (Windows NV12 zero-copy, intra-only wavelet, BT.709 limited 4:2:0)"
|
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 {
|
Ok(Self {
|
||||||
@@ -191,6 +203,8 @@ impl PyroWaveEncoder {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
|
chroma444,
|
||||||
|
hdr16,
|
||||||
frame_budget,
|
frame_budget,
|
||||||
wire_chunk: None,
|
wire_chunk: None,
|
||||||
bitstream: Vec::new(),
|
bitstream: Vec::new(),
|
||||||
@@ -355,13 +369,31 @@ impl PyroWaveEncoder {
|
|||||||
// full-res R8 Y on `d3d.texture`, the half-res R8G8 CbCr on `share.cbcr`. `pw_dev` is a Copy
|
// 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`.
|
// handle so the cache closures don't borrow `self` alongside `&mut self.*_images`.
|
||||||
let (w, h) = (self.width, self.height);
|
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 pw_dev = self.pw_dev;
|
||||||
let y_img = {
|
let y_img = {
|
||||||
let key = d3d.texture.as_raw() as isize;
|
let key = d3d.texture.as_raw() as isize;
|
||||||
let tex = &d3d.texture;
|
let tex = &d3d.texture;
|
||||||
Self::cached_plane(
|
Self::cached_plane(
|
||||||
&mut self.y_images,
|
&mut self.y_images,
|
||||||
|| Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8_UNORM, w, h),
|
|| Self::import_plane(pw_dev, tex, yf, w, h),
|
||||||
key,
|
key,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
@@ -370,7 +402,7 @@ impl PyroWaveEncoder {
|
|||||||
let tex = &share.cbcr;
|
let tex = &share.cbcr;
|
||||||
Self::cached_plane(
|
Self::cached_plane(
|
||||||
&mut self.cbcr_images,
|
&mut self.cbcr_images,
|
||||||
|| Self::import_plane(pw_dev, tex, pw::VkFormat_VK_FORMAT_R8G8_UNORM, w / 2, h / 2),
|
|| Self::import_plane(pw_dev, tex, cf, cw, ch),
|
||||||
key,
|
key,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
@@ -393,29 +425,27 @@ impl PyroWaveEncoder {
|
|||||||
swizzle,
|
swizzle,
|
||||||
layout: pw::VkImageLayout_VK_IMAGE_LAYOUT_GENERAL,
|
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 {
|
let buffers = pw::pyrowave_gpu_buffers {
|
||||||
planes: [
|
planes: [
|
||||||
plane(
|
plane(
|
||||||
y_vk,
|
y_vk,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
r8,
|
yf,
|
||||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY,
|
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_IDENTITY,
|
||||||
),
|
),
|
||||||
plane(
|
plane(
|
||||||
cbcr_vk,
|
cbcr_vk,
|
||||||
w / 2,
|
cw,
|
||||||
h / 2,
|
ch,
|
||||||
rg8,
|
cf,
|
||||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_R,
|
||||||
),
|
),
|
||||||
plane(
|
plane(
|
||||||
cbcr_vk,
|
cbcr_vk,
|
||||||
w / 2,
|
cw,
|
||||||
h / 2,
|
ch,
|
||||||
rg8,
|
cf,
|
||||||
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
pw::VkComponentSwizzle_VK_COMPONENT_SWIZZLE_G,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -491,9 +521,11 @@ impl PyroWaveEncoder {
|
|||||||
)?;
|
)?;
|
||||||
packets.truncate(out_n.max(1));
|
packets.truncate(out_n.max(1));
|
||||||
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
|
||||||
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
|
// 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() {
|
if let Some(p) = packets.first() {
|
||||||
pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
|
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 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);
|
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||||
@@ -611,7 +643,8 @@ mod tests {
|
|||||||
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING,
|
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_FORMAT, DXGI_FORMAT_R8G8_UNORM, DXGI_FORMAT_R8_UNORM, DXGI_SAMPLE_DESC,
|
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
|
/// Decode a dense PyroWave AU with upstream's own decoder → YUV420P plane means (the golden
|
||||||
@@ -619,7 +652,7 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `au` must be a complete dense PyroWave AU for a `w`×`h` 4:2:0 frame.
|
/// `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) {
|
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();
|
let mut dev: pw::pyrowave_device = std::ptr::null_mut();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pw::pyrowave_create_default_device(&mut dev),
|
pw::pyrowave_create_default_device(&mut dev),
|
||||||
@@ -629,7 +662,11 @@ mod tests {
|
|||||||
device: dev,
|
device: dev,
|
||||||
width: w as i32,
|
width: w as i32,
|
||||||
height: h as i32,
|
height: h as i32,
|
||||||
chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420,
|
chroma: if chroma444 {
|
||||||
|
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444
|
||||||
|
} else {
|
||||||
|
pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420
|
||||||
|
},
|
||||||
fragment_path: false,
|
fragment_path: false,
|
||||||
};
|
};
|
||||||
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
let mut dec: pw::pyrowave_decoder = std::ptr::null_mut();
|
||||||
@@ -642,11 +679,16 @@ mod tests {
|
|||||||
pw::pyrowave_result_PYROWAVE_SUCCESS
|
pw::pyrowave_result_PYROWAVE_SUCCESS
|
||||||
);
|
);
|
||||||
assert!(pw::pyrowave_decoder_decode_is_ready(dec, false));
|
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 y = vec![0u8; (w * h) as usize];
|
||||||
let mut cb = vec![0u8; (w * h / 4) as usize];
|
let mut cb = vec![0u8; (cw2 * ch2) as usize];
|
||||||
let mut cr = vec![0u8; (w * h / 4) as usize];
|
let mut cr = vec![0u8; (cw2 * ch2) as usize];
|
||||||
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
let mut buf: pw::pyrowave_cpu_buffer = std::mem::zeroed();
|
||||||
buf.format = pw::pyrowave_cpu_buffer_format_PYROWAVE_CPU_BUFFER_FORMAT_YUV420P;
|
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.width = w as i32;
|
||||||
buf.height = h as i32;
|
buf.height = h as i32;
|
||||||
buf.data = [
|
buf.data = [
|
||||||
@@ -654,7 +696,7 @@ mod tests {
|
|||||||
cb.as_mut_ptr() as *mut _,
|
cb.as_mut_ptr() as *mut _,
|
||||||
cr.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.row_stride_in_bytes = [w as usize, cw2 as usize, cw2 as usize];
|
||||||
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
|
||||||
@@ -738,7 +780,7 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Runs on a real D3D11 + Vulkan-1.3 GPU; all COM/FFI handles are locally owned.
|
/// 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) {
|
unsafe fn run_case(w: u32, h: u32, hdr: bool, chroma444: bool) -> (f64, f64, f64) {
|
||||||
// A fresh D3D11 device on the default hardware adapter.
|
// A fresh D3D11 device on the default hardware adapter.
|
||||||
let mut device: Option<ID3D11Device> = None;
|
let mut device: Option<ID3D11Device> = None;
|
||||||
let mut context: Option<ID3D11DeviceContext> = None;
|
let mut context: Option<ID3D11DeviceContext> = None;
|
||||||
@@ -757,17 +799,45 @@ mod tests {
|
|||||||
let device = device.unwrap();
|
let device = device.unwrap();
|
||||||
let context = context.unwrap();
|
let context = context.unwrap();
|
||||||
|
|
||||||
// Full-res R8 Y (=100) + half-res R8G8 CbCr (=180,60) — the exact layout the encoder ingests.
|
// Distinct plane fills at the session's plane formats/geometry. 16-bit fills use
|
||||||
let y_tex = make_plane(&device, &context, w, h, DXGI_FORMAT_R8_UNORM, 1, &[100]);
|
// v16 = v8 * 257 (0xVV,0xVV LE), whose UNORM value equals v8/255 EXACTLY — so the
|
||||||
let cbcr_tex = make_plane(
|
// 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,
|
&device,
|
||||||
&context,
|
&context,
|
||||||
w / 2,
|
w,
|
||||||
h / 2,
|
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,
|
DXGI_FORMAT_R8G8_UNORM,
|
||||||
2,
|
2,
|
||||||
&[180, 60],
|
&[180, 60],
|
||||||
);
|
),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering).
|
// Shared fence signalled after the fills (mirrors the capturer's convert→signal ordering).
|
||||||
let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5");
|
let dev5: ID3D11Device5 = device.cast().expect("ID3D11Device5");
|
||||||
@@ -783,7 +853,18 @@ mod tests {
|
|||||||
context.Flush();
|
context.Flush();
|
||||||
|
|
||||||
// Encode the shared textures through the real backend.
|
// Encode the shared textures through the real backend.
|
||||||
let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000, crate::ChromaFormat::Yuv420)
|
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");
|
.expect("PyroWaveEncoder::open");
|
||||||
let frame = CapturedFrame {
|
let frame = CapturedFrame {
|
||||||
width: w,
|
width: w,
|
||||||
@@ -813,7 +894,14 @@ mod tests {
|
|||||||
0x40,
|
0x40,
|
||||||
"sequence header must signal ycbcr_range=LIMITED"
|
"sequence header must signal ycbcr_range=LIMITED"
|
||||||
);
|
);
|
||||||
decode_plane_means(w, h, &au.data)
|
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
|
/// The Windows NV12 zero-copy path end-to-end on a real GPU. `#[ignore]`d (needs D3D11 + a
|
||||||
@@ -825,16 +913,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "needs a real D3D11 + Vulkan-1.3 GPU (run on the Windows host, not the build box)"]
|
#[ignore = "needs a real D3D11 + Vulkan-1.3 GPU (run on the Windows host, not the build box)"]
|
||||||
fn pyrowave_win_smoke() {
|
fn pyrowave_win_smoke() {
|
||||||
for (w, h) in [(1024u32, 1024u32), (1280, 720), (1920, 1080), (2560, 1440)] {
|
// 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.
|
// SAFETY: single-threaded test; `run_case` owns every COM/FFI handle it touches.
|
||||||
let (ym, cbm, crm) = unsafe { run_case(w, h) };
|
let (ym, cbm, crm) = unsafe { run_case(w, h, hdr, c444) };
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{w}x{h}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} (expect 100/180/60)"
|
"{w}x{h} hdr={hdr} 444={c444}: decoded means Y={ym:.1} Cb={cbm:.1} Cr={crm:.1} \
|
||||||
|
(expect 100/180/60)"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
(ym - 100.0).abs() < 6.0 && (cbm - 180.0).abs() < 6.0 && (crm - 60.0).abs() < 6.0,
|
(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 \
|
"{w}x{h} hdr={hdr} 444={c444}: round-trip means (Y {ym:.1}, Cb {cbm:.1}, \
|
||||||
the filled 100/180/60 — chroma plane mapping wrong (swap? wrong plane?)"
|
Cr {crm:.1}) drifted from the filled 100/180/60 — plane mapping/format wrong"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,8 +428,15 @@ fn open_video_backend(
|
|||||||
if codec == Codec::PyroWave {
|
if codec == Codec::PyroWave {
|
||||||
#[cfg(feature = "pyrowave")]
|
#[cfg(feature = "pyrowave")]
|
||||||
{
|
{
|
||||||
let _ = (format, cuda, bit_depth);
|
let _ = (format, cuda);
|
||||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
return pyrowave::PyroWaveEncoder::open(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
bitrate_bps,
|
||||||
|
chroma,
|
||||||
|
bit_depth,
|
||||||
|
)
|
||||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "pyrowave"))]
|
#[cfg(not(feature = "pyrowave"))]
|
||||||
@@ -872,9 +879,9 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
|||||||
if codec == Codec::PyroWave {
|
if codec == Codec::PyroWave {
|
||||||
// PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source),
|
// PyroWave does its own RGB→YCbCr CSC (capture always hands it a full-chroma source),
|
||||||
// so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant:
|
// so 4:4:4 needs no GPU encode probe — only the full-res-chroma CSC variant:
|
||||||
// `rgb2yuv444.comp` on Linux (landed, design/pyrowave-444-hdr.md Phase 2); the
|
// `rgb2yuv444.comp` on Linux (Phase 2) and the mode-aware `BgraToYuvPlanes` on
|
||||||
// Windows `BgraToYuvPlanes` twin is Phase 3.
|
// Windows (Phase 3) — both landed (design/pyrowave-444-hdr.md).
|
||||||
return cfg!(target_os = "linux");
|
return true;
|
||||||
}
|
}
|
||||||
if codec != Codec::H265 {
|
if codec != Codec::H265 {
|
||||||
return false;
|
return false;
|
||||||
@@ -961,6 +968,12 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
|||||||
if !codec.supports_10bit() {
|
if !codec.supports_10bit() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if codec == Codec::PyroWave {
|
||||||
|
// PyroWave needs no GPU encode probe (the wavelet is depth-agnostic) — only the HDR
|
||||||
|
// capture CSC (scRGB FP16 → 16-bit studio-code planes), which exists on the Windows
|
||||||
|
// IDD-push path only (design/pyrowave-444-hdr.md Phase 3; Linux capture has no HDR).
|
||||||
|
return cfg!(target_os = "windows");
|
||||||
|
}
|
||||||
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
|
||||||
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
// selected adapter before the next Welcome, mirroring `can_encode_444`.
|
||||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||||
|
|||||||
@@ -1009,7 +1009,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
// else decodes the codec); only device loss ends the session.
|
// else decodes the codec); only device loss ends the session.
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
DecodedImage::PyroWave(f) => {
|
DecodedImage::PyroWave(f) => {
|
||||||
st.hdr = false; // 8-bit SDR codec
|
// The wavelet stream carries the negotiated ColorInfo (no VUI): an
|
||||||
|
// HDR (PQ) pyrowave session presents through the HDR10 path exactly
|
||||||
|
// like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3).
|
||||||
|
st.hdr = f.color.is_pq();
|
||||||
match presenter.present(
|
match presenter.present(
|
||||||
&window,
|
&window,
|
||||||
FrameInput::PyroWave(f),
|
FrameInput::PyroWave(f),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl Presenter {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
FrameInput::D3d11(d) => Some(d.color.is_pq()),
|
FrameInput::D3d11(d) => Some(d.color.is_pq()),
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
FrameInput::PyroWave(f) => Some(f.color.is_pq()), // always SDR today
|
FrameInput::PyroWave(f) => Some(f.color.is_pq()),
|
||||||
};
|
};
|
||||||
if let Some(pq) = frame_pq {
|
if let Some(pq) = frame_pq {
|
||||||
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
|
// A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind
|
||||||
@@ -750,11 +750,31 @@ impl Presenter {
|
|||||||
&[planar.desc_set],
|
&[planar.desc_set],
|
||||||
&[],
|
&[],
|
||||||
);
|
);
|
||||||
let rows = csc_rows(color, 8, false);
|
// An HDR (PQ) pyrowave session carries P010-style 10-bit studio codes MSB-packed
|
||||||
|
// into 16-bit planes (design/pyrowave-444-hdr.md §2.2) — same sampling scale as
|
||||||
|
// the P010 path; SDR sessions are plain 8-bit BT.709 limited. Depth follows the
|
||||||
|
// colour contract (negotiation couples 10-bit ⟺ PQ for this codec).
|
||||||
|
let (depth, msb_packed) = if color.is_pq() {
|
||||||
|
(10, true)
|
||||||
|
} else {
|
||||||
|
(8, false)
|
||||||
|
};
|
||||||
|
let rows = csc_rows(color, depth, msb_packed);
|
||||||
|
// Mode 1 = PQ→SDR tonemap (PQ stream without an HDR10 surface); mode 0 passes
|
||||||
|
// the transfer through — identical to the NV12 arm above.
|
||||||
|
let mode = if color.is_pq() && !self.hdr_active {
|
||||||
|
1.0f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let peak = std::env::var("PUNKTFUNK_TONEMAP_PEAK")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<f32>().ok())
|
||||||
|
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
|
||||||
let mut pc = [0f32; 16];
|
let mut pc = [0f32; 16];
|
||||||
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
pc[..12].copy_from_slice(bytemuck_rows(&rows));
|
||||||
pc[12] = 0.0; // SDR passthrough — PyroWave has no PQ path
|
pc[12] = mode;
|
||||||
pc[13] = 0.0;
|
pc[13] = peak;
|
||||||
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
|
||||||
self.device.cmd_push_constants(
|
self.device.cmd_push_constants(
|
||||||
self.cmd_buf,
|
self.cmd_buf,
|
||||||
|
|||||||
@@ -203,11 +203,15 @@ impl Presenter {
|
|||||||
vk::Format::R8G8B8A8_UNORM
|
vk::Format::R8G8B8A8_UNORM
|
||||||
};
|
};
|
||||||
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
|
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
|
||||||
if let Some(p) = &self.csc_planar {
|
|
||||||
p.destroy(&self.device);
|
|
||||||
}
|
|
||||||
self.csc = CscPass::new(&self.device, self.video_format)?;
|
self.csc = CscPass::new(&self.device, self.video_format)?;
|
||||||
|
// The planar (PyroWave) pass renders to the same intermediate — rebuild it at the
|
||||||
|
// new format too (an HDR pyrowave session needs the 10-bit intermediate exactly
|
||||||
|
// like the H.26x path; 8-bit PQ bands visibly).
|
||||||
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
|
if let Some(p) = self.csc_planar.take() {
|
||||||
|
p.destroy(&self.device);
|
||||||
|
self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?);
|
||||||
|
}
|
||||||
if let Some(v) = self.video.take() {
|
if let Some(v) = self.video.take() {
|
||||||
unsafe {
|
unsafe {
|
||||||
self.device.destroy_framebuffer(v.framebuffer, None);
|
self.device.destroy_framebuffer(v.framebuffer, None);
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ impl Presenter {
|
|||||||
ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device),
|
ext_mem_win32: ash::khr::external_memory_win32::Device::new(&instance, &device),
|
||||||
});
|
});
|
||||||
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
|
let csc = CscPass::new(&device, vk::Format::R8G8B8A8_UNORM)?;
|
||||||
// PyroWave is 8-bit SDR only, so the planar pass never needs the HDR10 rebuild.
|
// Starts SDR like `csc`; an HDR (PQ) pyrowave session rebuilds it at the 10-bit
|
||||||
|
// intermediate via `set_hdr_mode`, exactly like the H.26x pass.
|
||||||
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
|
||||||
let csc_planar = if pyrowave_ok {
|
let csc_planar = if pyrowave_ok {
|
||||||
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
|
Some(CscPass::new_planar(&device, vk::Format::R8G8B8A8_UNORM)?)
|
||||||
|
|||||||
Reference in New Issue
Block a user