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