feat(pyrowave): negotiation plumbing for 4:4:4 + HDR — thread chroma/depth/ColorInfo end to end
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 6m49s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m10s
arch / build-publish (push) Successful in 11m34s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m43s
ci / bench (push) Successful in 5m21s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 8m9s
deb / build-publish-host (push) Successful in 9m20s
android / android (push) Successful in 22m17s
flatpak / build-publish (push) Successful in 6m12s
ci / rust (push) Successful in 19m3s
deb / build-publish (push) Successful in 12m40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 9m8s
apple / swift (push) Successful in 1m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m17s
docker / deploy-docs (push) Successful in 11s
apple / screenshots (push) Successful in 6m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m5s
windows-host / package (push) Successful in 16m27s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 12:37:12 +02:00
parent 97cf15e3b7
commit 5eb930e71d
9 changed files with 202 additions and 57 deletions
+55 -5
View File
@@ -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
);
}
+23 -13
View File
@@ -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,
+1 -1
View File
@@ -1262,7 +1262,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// so re-resolve it for the new mode. Explicit client rates stay put (the operator knows
// the link), and the H.26x codecs keep their mode-independent rate (ABR owns it).
let mode_bitrate = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode)
resolve_bitrate_kbps_for(plan.codec, 0, &new_mode, plan.chroma, plan.bit_depth)
} else {
bitrate_kbps
};