From 5eb930e71d192e45c7849446e9b3def2d32d36fd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 12:37:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(pyrowave):=20negotiation=20plumbing=20for?= =?UTF-8?q?=204:4:4=20+=20HDR=20=E2=80=94=20thread=20chroma/depth/ColorInf?= =?UTF-8?q?o=20end=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of design/pyrowave-444-hdr.md. No behavior change yet: the handshake's 4:4:4 gate now admits PyroWave (probe = can_encode_444(codec), capture gate inherently satisfied — the wavelet path always ingests an RGB source and does its own CSC), but can_encode_444 stays false for PyroWave until the per-OS full-res-chroma CSC variants land (Phase 2 Linux, Phase 3 Windows), so every session still resolves 4:2:0/8-bit. - Both host encoders take the negotiated ChromaFormat (bail on 444 for now); the PUNKTFUNK_ENCODER=pyrowave lab override pins 4:2:0. - Bitrate: the automatic ~1.6 bpp pin resolves AFTER depth+chroma and scales x1.625 for 4:4:4 / x1.15 for 10-bit (factors from the Phase-0 fixture matrix); the mid-stream mode-switch re-resolve threads the session's values. - Client: PyroWaveDecoder builds its plane ring (full-res chroma when 444) and creates the upstream decoder from the negotiated chroma, keeps chroma fixed across mid-stream resizes, drops the even-dims requirement for 444, and returns the negotiated Welcome ColorInfo as the frame colour contract instead of hardcoded BT.709 (the wavelet bitstream has no VUI). Verified on .21 (RTX 5070 Ti): clippy -D warnings (host+client+encode), host 186 tests, client + pf-encode tests, fmt, and the pyrowave_smoke GPU round-trip through the patched vendored lib (97cf15e3). Co-Authored-By: Claude Fable 5 --- crates/pf-client-core/src/session.rs | 11 +++ crates/pf-client-core/src/video.rs | 4 + crates/pf-client-core/src/video_pyrowave.rs | 74 +++++++++++++------ crates/pf-encode/src/enc/linux/pyrowave.rs | 20 ++++- crates/pf-encode/src/enc/windows/pyrowave.rs | 17 ++++- crates/pf-encode/src/lib.rs | 35 ++++++--- crates/punktfunk-host/src/native.rs | 60 +++++++++++++-- crates/punktfunk-host/src/native/handshake.rs | 36 +++++---- crates/punktfunk-host/src/native/stream.rs | 2 +- 9 files changed, 202 insertions(+), 57 deletions(-) diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 8d2a1e4a..c120685f 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -299,12 +299,23 @@ fn pump( #[cfg(all(target_os = "linux", feature = "pyrowave"))] let built = if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE { let mode = connector.mode(); + // The wavelet bitstream has no VUI: the negotiated Welcome colour signalling IS + // the session's colour contract (BT.709 limited SDR today, BT.2020 PQ once the + // HDR leg lands), and the chroma the host resolved sizes the plane ring. + let color = crate::video::ColorDesc { + primaries: connector.color.primaries, + transfer: connector.color.transfer, + matrix: connector.color.matrix, + full_range: connector.color.full_range != 0, + }; match params.vulkan.as_ref() { Some(vk) => Decoder::new_pyrowave( vk, mode.width, mode.height, connector.shard_payload as usize, + connector.chroma_format == punktfunk_core::quic::CHROMA_IDC_444, + color, ), None => Err(anyhow::anyhow!( "pyrowave session without a presenter device" diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 50ac8915..e9d4a62b 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -489,6 +489,8 @@ impl Decoder { width: u32, height: u32, shard_payload: usize, + chroma444: bool, + color: ColorDesc, ) -> Result { Ok(Decoder { backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new( @@ -496,6 +498,8 @@ impl Decoder { width, height, shard_payload, + chroma444, + color, )?)), codec_id: ffmpeg::codec::Id::HEVC, vaapi_fails: 0, diff --git a/crates/pf-client-core/src/video_pyrowave.rs b/crates/pf-client-core/src/video_pyrowave.rs index 5e926472..e52660b4 100644 --- a/crates/pf-client-core/src/video_pyrowave.rs +++ b/crates/pf-client-core/src/video_pyrowave.rs @@ -347,12 +347,20 @@ unsafe fn build_ring( mem_props: &vk::PhysicalDeviceMemoryProperties, width: u32, height: u32, + chroma444: bool, ) -> Result> { + // 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. + let (cw, ch) = if chroma444 { + (width, height) + } else { + (width / 2, height / 2) + }; let mut ring: Vec = Vec::with_capacity(RING); for _ in 0..RING { let built = (|| -> Result { let (y, ym, yv) = make_plane(device, mem_props, width, height)?; - let (cb, cbm, cbv) = match make_plane(device, mem_props, width / 2, height / 2) { + let (cb, cbm, cbv) = match make_plane(device, mem_props, cw, ch) { Ok(p) => p, Err(e) => { device.destroy_image_view(yv, None); @@ -361,7 +369,7 @@ unsafe fn build_ring( return Err(e); } }; - let (cr, crm, crv) = match make_plane(device, mem_props, width / 2, height / 2) { + let (cr, crm, crv) = match make_plane(device, mem_props, cw, ch) { Ok(p) => p, Err(e) => { for (v, i, m) in [(yv, y, ym), (cbv, cb, cbm)] { @@ -409,6 +417,13 @@ pub struct PyroWaveDecoder { mem_props: vk::PhysicalDeviceMemoryProperties, width: u32, height: u32, + /// Session-fixed negotiated chroma ([`Welcome::chroma_format`]): 4:4:4 = full-res + /// chroma planes + `Chroma444` pyrowave decoders (the seq-header bit is + /// decoder-enforced upstream, so a mismatch fails loudly, never silently). + chroma444: bool, + /// Session colour signalling ([`Welcome::color`]): the wavelet bitstream has no VUI, + /// so the negotiated `ColorInfo` is the contract the presenter CSC configures from. + color: ColorDesc, /// The wire shard payload — the parse-window size for chunk-aligned AUs (§4.4): each /// window holds whole self-delimiting codec packets, zero-padded to the window. wire_window: usize, @@ -424,17 +439,19 @@ impl PyroWaveDecoder { width: u32, height: u32, shard_payload: usize, + chroma444: bool, + color: ColorDesc, ) -> Result { if !vkd.pyrowave_decode { bail!("presenter device lacks the PyroWave compute feature set"); } - if width % 2 != 0 || height % 2 != 0 { + if !chroma444 && (width % 2 != 0 || height % 2 != 0) { bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); } // SAFETY: the handles in `vkd` are the presenter's live instance/device (it // outlives the decoder — same contract the FFmpeg Vulkan backend relies on); // `Hold` pins the reconstructed create-infos for the pyrowave device's lifetime. - unsafe { Self::new_inner(vkd, width, height, shard_payload) } + unsafe { Self::new_inner(vkd, width, height, shard_payload, chroma444, color) } } unsafe fn new_inner( @@ -442,6 +459,8 @@ impl PyroWaveDecoder { width: u32, height: u32, shard_payload: usize, + chroma444: bool, + color: ColorDesc, ) -> Result { let static_fn = ash::StaticFn { get_instance_proc_addr: std::mem::transmute::( @@ -496,7 +515,11 @@ impl PyroWaveDecoder { device: pw_dev, width: width 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 + }, // The fragment-iDWT path is for Mali/Adreno-class mobile GPUs only. fragment_path: false, }; @@ -513,7 +536,7 @@ impl PyroWaveDecoder { let mem_props = instance.get_physical_device_memory_properties( vk::PhysicalDevice::from_raw(vkd.physical_device as u64), ); - let ring = match build_ring(&device, &mem_props, width, height) { + let ring = match build_ring(&device, &mem_props, width, height, chroma444) { Ok(r) => r, Err(e) => { pw::pyrowave_decoder_destroy(pw_dec); @@ -557,6 +580,8 @@ impl PyroWaveDecoder { mem_props, width, height, + chroma444, + color, wire_window: shard_payload.max(64), }) } @@ -569,14 +594,19 @@ impl PyroWaveDecoder { /// The old ring is RETIRED, not destroyed: the presenter / frame channel may still /// reference its views (see [`RETIRE_HANDOVERS`]). unsafe fn reconfigure(&mut self, width: u32, height: u32) -> Result<()> { - if width % 2 != 0 || height % 2 != 0 { + if !self.chroma444 && (width % 2 != 0 || height % 2 != 0) { bail!("pyrowave 4:2:0 needs even dimensions (resize to {width}x{height})"); } let dinfo = pw::pyrowave_decoder_create_info { device: self.pw_dev, width: width as i32, height: height as i32, - chroma: pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420, + // Chroma is session-fixed (negotiated); a resize never changes it. + chroma: if self.chroma444 { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_444 + } else { + pw::pyrowave_chroma_subsampling_PYROWAVE_CHROMA_SUBSAMPLING_420 + }, fragment_path: false, }; let mut new_dec: pw::pyrowave_decoder = std::ptr::null_mut(); @@ -584,13 +614,14 @@ impl PyroWaveDecoder { pw::pyrowave_decoder_create(&dinfo, &mut new_dec), "decoder_create (mid-stream resize)", )?; - let new_ring = match build_ring(&self.device, &self.mem_props, width, height) { - Ok(r) => r, - Err(e) => { - pw::pyrowave_decoder_destroy(new_dec); - return Err(e).context("plane ring (mid-stream resize)"); - } - }; + let new_ring = + match build_ring(&self.device, &self.mem_props, width, height, self.chroma444) { + Ok(r) => r, + Err(e) => { + pw::pyrowave_decoder_destroy(new_dec); + return Err(e).context("plane ring (mid-stream resize)"); + } + }; // Our own decode work is fence-synchronous (never in flight here), so the old // pyrowave decoder can go immediately; only the plane images wait (retired). pw::pyrowave_decoder_destroy(self.pw_dec); @@ -886,15 +917,10 @@ impl PyroWaveDecoder { ], width: w, height: h, - // No VUI in the bitstream: BT.709 limited is the fixed contract with the - // host's CSC (plan §4.7 CscRows note; sequence-header signaling is a - // follow-up once the C API exposes it). - color: ColorDesc { - primaries: 1, - transfer: 1, - matrix: 1, - full_range: false, - }, + // No VUI in the bitstream: the negotiated Welcome `ColorInfo` is the contract + // with the host's CSC (BT.709 limited for SDR sessions; BT.2020 PQ once the + // HDR leg lands — design/pyrowave-444-hdr.md). + color: self.color, keyframe: true, })) } diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index f315375b..a503440c 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -211,7 +211,19 @@ fn budget_for(bitrate_bps: u64, fps: u32) -> usize { } impl PyroWaveEncoder { - pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result { + pub fn open( + width: u32, + height: u32, + fps: u32, + bitrate_bps: u64, + chroma: crate::ChromaFormat, + ) -> Result { + if chroma.is_444() { + // Negotiation can't reach here yet: `can_encode_444` returns false for PyroWave + // until the full-res-chroma rgb2yuv variant lands (design/pyrowave-444-hdr.md + // Phase 2). The parameter is threaded now so that flip is one-file. + bail!("pyrowave 4:4:4 encode not implemented yet (Phase 2)"); + } if width % 2 != 0 || height % 2 != 0 { bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); } @@ -1335,7 +1347,8 @@ mod tests { #[ignore = "needs a real Vulkan 1.3 compute device (run on a GPU host, not the build box)"] fn pyrowave_smoke() { let (w, h) = (256u32, 256u32); - let mut enc = PyroWaveEncoder::open(w, h, 60, 40_000_000).expect("open"); + let mut enc = + PyroWaveEncoder::open(w, h, 60, 40_000_000, crate::ChromaFormat::Yuv420).expect("open"); assert!(!enc.caps().supports_rfi); let colors = [ @@ -1499,7 +1512,8 @@ mod tests { // Odd-block geometry on purpose: 256 aligns clean, 144 → aligned 160 exercises the // block-grid overhang. ~1.6 bpp at 60 fps. let (w, h) = (256u32, 144u32); - let mut enc = PyroWaveEncoder::open(w, h, 60, 4_000_000).expect("open"); + let mut enc = + PyroWaveEncoder::open(w, h, 60, 4_000_000, crate::ChromaFormat::Yuv420).expect("open"); let dump = |name: &str, bytes: &[u8]| { std::fs::write(dir.join(name), bytes).expect("write fixture"); diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs index fa9c1b17..80b8c6bd 100644 --- a/crates/pf-encode/src/enc/windows/pyrowave.rs +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -98,7 +98,19 @@ pub struct PyroWaveEncoder { unsafe impl Send for PyroWaveEncoder {} impl PyroWaveEncoder { - pub fn open(width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result { + pub fn open( + width: u32, + height: u32, + fps: u32, + bitrate_bps: u64, + chroma: crate::ChromaFormat, + ) -> Result { + if chroma.is_444() { + // Negotiation can't reach here yet: `can_encode_444` returns false for PyroWave + // until the full-res-chroma BgraToYuvPlanes variant lands + // (design/pyrowave-444-hdr.md Phase 3). Threaded now so that flip is one-file. + bail!("pyrowave 4:4:4 encode not implemented yet (Phase 3)"); + } if width % 2 != 0 || height % 2 != 0 { bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})"); } @@ -771,7 +783,8 @@ mod tests { context.Flush(); // Encode the shared textures through the real backend. - let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000).expect("PyroWaveEncoder::open"); + let mut enc = PyroWaveEncoder::open(w, h, 60, 100_000_000, crate::ChromaFormat::Yuv420) + .expect("PyroWaveEncoder::open"); let frame = CapturedFrame { width: w, height: h, diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index d3013208..ce3d4ad8 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -248,10 +248,11 @@ fn open_video_backend( if fps == 0 || fps > 1000 { anyhow::bail!("invalid refresh/fps {fps}: must be 1..=1000 Hz"); } - // 4:4:4 is HEVC-only. The negotiator should never pass `Yuv444` for another codec (it gates on - // `codec == H265`), but defend the contract here so a future caller can't silently emit a stream - // no decoder expects: a non-HEVC 4:4:4 request degrades to 4:2:0 with a warning. - let chroma = if chroma.is_444() && codec != Codec::H265 { + // 4:4:4 is HEVC- and PyroWave-only. The negotiator should never pass `Yuv444` for another + // codec (it gates on the codec + `can_encode_444`), but defend the contract here so a future + // caller can't silently emit a stream no decoder expects: an unsupported 4:4:4 request + // degrades to 4:2:0 with a warning. + let chroma = if chroma.is_444() && codec != Codec::H265 && codec != Codec::PyroWave { tracing::warn!( ?codec, "4:4:4 requested for a non-HEVC codec — encoding 4:2:0" @@ -267,7 +268,7 @@ fn open_video_backend( if codec == Codec::PyroWave { #[cfg(feature = "pyrowave")] { - return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps) + return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma) .map(|e| (Box::new(e) as Box, "pyrowave")); } #[cfg(not(feature = "pyrowave"))] @@ -369,8 +370,17 @@ fn open_video_backend( that ALSO preferred CODEC_PYROWAVE can display it (lab override; \ normal sessions negotiate it instead)" ); - pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps) - .map(|e| (Box::new(e) as Box, "pyrowave")) + // The lab override forces the wavelet stream onto a session negotiated for + // another codec — that session's chroma may be HEVC-4:4:4, which the + // pyrowave encoder doesn't do yet, so pin the override to 4:2:0. + pyrowave::PyroWaveEncoder::open( + width, + height, + fps, + bitrate_bps, + ChromaFormat::Yuv420, + ) + .map(|e| (Box::new(e) as Box, "pyrowave")) } #[cfg(not(feature = "pyrowave"))] { @@ -418,8 +428,8 @@ fn open_video_backend( if codec == Codec::PyroWave { #[cfg(feature = "pyrowave")] { - let _ = (format, cuda, bit_depth, chroma); - return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps) + let _ = (format, cuda, bit_depth); + return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma) .map(|e| (Box::new(e) as Box, "pyrowave")); } #[cfg(not(feature = "pyrowave"))] @@ -859,6 +869,13 @@ pub fn vaapi_codec_support() -> CodecSupport { pub fn can_encode_444(codec: Codec) -> bool { use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; + if codec == Codec::PyroWave { + // 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, which + // hasn't landed yet (design/pyrowave-444-hdr.md: Phase 2 Linux, Phase 3 Windows). + // Flip per-OS when it does. + return false; + } if codec != Codec::H265 { return false; } diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 332a4bed..8438e5e6 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -521,10 +521,20 @@ fn resolve_bitrate_kbps_for( codec: crate::encode::Codec, requested: u32, mode: &punktfunk_core::config::Mode, + chroma: crate::encode::ChromaFormat, + bit_depth: u8, ) -> u32 { if requested == 0 && codec == crate::encode::Codec::PyroWave { - let bps = - mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10; + // ~1.6 bpp for 4:2:0. 4:4:4 doubles the samples per pixel (3 vs 1.5) but chroma + // compresses better than luma → ×1.625 ≈ 2.6 bpp; 16-bit planes add ~15 % (both + // factors measured against the Phase-0 fixture matrix, design/pyrowave-444-hdr.md). + let bpp_x10: u64 = if chroma.is_444() { 26 } else { 16 }; + let mut bps = + mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * bpp_x10 + / 10; + if bit_depth >= 10 { + bps = bps * 115 / 100; + } return u32::try_from(bps / 1000) .unwrap_or(MAX_BITRATE_KBPS) .clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS); @@ -1458,18 +1468,58 @@ mod tests { height: 1080, refresh_hz: 60, }; + use crate::encode::ChromaFormat; // Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x // default (which would turn wavelets to mush — plan §4.6). - let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode); + let kbps = resolve_bitrate_kbps_for( + crate::encode::Codec::PyroWave, + 0, + &mode, + ChromaFormat::Yuv420, + 8, + ); assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000); + // 4:4:4 scales the pin to ~2.6 bpp, 10-bit adds 15 % (design/pyrowave-444-hdr.md §2.5). + assert_eq!( + resolve_bitrate_kbps_for( + crate::encode::Codec::PyroWave, + 0, + &mode, + ChromaFormat::Yuv444, + 8 + ), + 1920 * 1080 * 60 * 26 / 10 / 1000 + ); + assert_eq!( + resolve_bitrate_kbps_for( + crate::encode::Codec::PyroWave, + 0, + &mode, + ChromaFormat::Yuv444, + 10 + ), + (1920u64 * 1080 * 60 * 26 / 10 * 115 / 100 / 1000) as u32 + ); // An explicit client rate is honored (clamped like any other codec)... assert_eq!( - resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode), + resolve_bitrate_kbps_for( + crate::encode::Codec::PyroWave, + 130_000, + &mode, + ChromaFormat::Yuv420, + 8 + ), 130_000 ); // ...and the H.26x codecs keep the legacy default. assert_eq!( - resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode), + resolve_bitrate_kbps_for( + crate::encode::Codec::H265, + 0, + &mode, + ChromaFormat::Yuv420, + 8 + ), DEFAULT_BITRATE_KBPS ); } diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 64614c25..c38ac94c 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -187,14 +187,8 @@ pub(super) async fn negotiate( // needed; the actual pads are created lazily by the input thread). let gamepad = resolve_gamepad(hello.gamepad); - // Resolve the encoder bitrate (client request clamped to a sane range, or a - // codec-aware host default — PyroWave pins ~1.6 bpp for the mode). - let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode); - tracing::info!( - requested_kbps = hello.bitrate_kbps, - resolved_kbps = bitrate_kbps, - "encoder bitrate" - ); + // (The encoder bitrate is resolved below, AFTER bit depth + chroma: PyroWave's automatic + // ~bpp pin scales with both — design/pyrowave-444-hdr.md §2.5.) // Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens // at this count: PipeWire synthesizes the requested positions (padding with silence when the @@ -255,19 +249,24 @@ pub(super) async fn negotiate( // today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host // negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only // topology, and 4:4:4 routed to DDA, which was removed.) - let capture_supports_444 = - crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444()); + // PyroWave does its own RGB→YCbCr CSC and its capture mode always delivers a full-chroma + // (RGB/BGRA) source on both OSes — the capturer gate is inherently satisfied; the real + // gate is `can_encode_444` (the full-res-chroma CSC variant existing on this OS). + let capture_supports_444 = codec == crate::encode::Codec::PyroWave + || crate::capture::capturer_supports_444(crate::encode::resolved_backend_ingests_rgb_444()); // The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the // compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when // the cheap gates already pass. The result is cached process-wide (a negative latches until // restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open // failure here is rare since the session's own encoder isn't open yet). - let gpu_supports_444 = if codec == crate::encode::Codec::H265 - && host_wants_444 + let gpu_supports_444 = if matches!( + codec, + crate::encode::Codec::H265 | crate::encode::Codec::PyroWave + ) && host_wants_444 && client_supports_444 && capture_supports_444 { - tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265)) + tokio::task::spawn_blocking(move || crate::encode::can_encode_444(codec)) .await .context("4:4:4 capability probe task")? } else { @@ -299,6 +298,17 @@ pub(super) async fn negotiate( bit_depth }; + // Resolve the encoder bitrate (client request clamped to a sane range, or a codec-aware + // host default). Resolved AFTER depth + chroma: PyroWave's Automatic rate is a ~bpp pin + // for the negotiated mode that scales with both (design/pyrowave-444-hdr.md §2.5). + let bitrate_kbps = + resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode, chroma, bit_depth); + tracing::info!( + requested_kbps = hello.bitrate_kbps, + resolved_kbps = bitrate_kbps, + "encoder bitrate" + ); + // Reserve the data-plane UDP socket up front and HOLD it through streaming (no // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed // `--data-port` yields `direct = true` (stream straight to the client's reported address, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 9d034c2d..a2eda7ac 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1262,7 +1262,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option