feat(client,host): PyroWave Apple Metal decoder + per-mode bitrate pin

- clients/apple: native Metal wavelet decoder + compute shaders (Phase 5),
  decoding PyroWave without embedding MoltenVK.
- pf-client-core: plumb user_flags/completeness through Decoder::decode_frame
  so the PyroWave backend parses chunk-aligned + partial AUs; gate the param's
  unused-warning to exactly the non-pyrowave builds (fixes -D warnings on the
  featureless Linux client build).
- punktfunk-host: on a mid-stream mode switch, re-resolve the "Automatic"
  PyroWave bitrate for the new mode's ~1.6 bpp operating point (explicit rates
  and H.26x ABR stay put); reject sub-128px PyroWave modes before the encoder
  rebuild instead of after the ack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:47:42 +02:00
parent 2621b6e6b1
commit 9127c3465f
8 changed files with 1713 additions and 83 deletions
+9
View File
@@ -428,6 +428,15 @@ pub fn validate_dimensions(codec: Codec, width: u32, height: u32) -> Result<()>
if width % 2 != 0 || height % 2 != 0 {
anyhow::bail!("invalid encode resolution {width}x{height}: dimensions must be even");
}
// PyroWave's 5-level wavelet decomposition needs ≥ 4·2⁵ px per axis (upstream
// `MinimumImageSize` — the band mirroring breaks below it); reject a tiny mode here
// (e.g. a match-window resize dragged to a sliver) instead of failing the encoder
// rebuild after the switch was acked.
if codec == Codec::PyroWave && (width < 128 || height < 128) {
anyhow::bail!(
"invalid PyroWave resolution {width}x{height}: the wavelet needs at least 128px per axis"
);
}
let max = codec.max_dimension();
if width > max || height > max {
anyhow::bail!(
@@ -1172,7 +1172,7 @@ mod tests {
buf.row_stride_in_bytes = [w as usize, (w / 2) as usize, (w / 2) as usize];
buf.plane_size_in_bytes = [y.len(), cb.len(), cr.len()];
assert_eq!(
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &mut buf),
pw::pyrowave_decoder_decode_cpu_buffer_synchronous(dec, &buf),
pw::pyrowave_result_PYROWAVE_SUCCESS
);
pw::pyrowave_decoder_destroy(dec);
+29 -1
View File
@@ -1606,6 +1606,9 @@ async fn serve_session(
}
});
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
// "Automatic" request: the resolved rate is a host default — for PyroWave a per-mode
// bpp pin the data plane re-resolves on a mid-stream mode switch.
let bitrate_auto = hello.bitrate_kbps == 0;
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
@@ -1703,6 +1706,7 @@ async fn serve_session(
bitrate_rx,
compositor,
bitrate_kbps,
bitrate_auto,
bit_depth,
chroma,
codec,
@@ -3948,6 +3952,11 @@ struct SessionContext {
compositor: crate::vdisplay::Compositor,
/// Negotiated encoder bitrate (kbps).
bitrate_kbps: u32,
/// The client asked for "Automatic" (`Hello::bitrate_kbps == 0`), so `bitrate_kbps` came from
/// the host's codec-aware default. For PyroWave that default is the ~1.6 bpp operating point of
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
/// for the new mode (the pin follows the resolution; an explicit client rate stays put).
bitrate_auto: bool,
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
bit_depth: u8,
/// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it).
@@ -4027,6 +4036,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_rx,
compositor,
mut bitrate_kbps,
bitrate_auto,
bit_depth,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
chroma: _,
@@ -4363,11 +4373,29 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
}
if let Some(new_mode) = want {
tracing::info!(?new_mode, "rebuilding pipeline for mode switch");
// PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) —
// a resolution change moves the operating point (1080p→4K quadruples the pixel rate),
// 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)
} else {
bitrate_kbps
};
// Build the new pipeline BEFORE dropping the old one: the host already acked
// the switch as accepted, so a rebuild failure must not kill an otherwise
// healthy session — keep streaming the current mode and log instead.
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
match build_pipeline(&mut vd, new_mode, mode_bitrate, bit_depth, plan, &quit) {
Ok(next_pipe) => {
if mode_bitrate != bitrate_kbps {
tracing::info!(
from_kbps = bitrate_kbps,
to_kbps = mode_bitrate,
"pinned PyroWave bitrate re-resolved for the new mode"
);
bitrate_kbps = mode_bitrate;
live_bitrate.store(mode_bitrate, Ordering::Relaxed);
}
let old_display_gen = cur_display_gen;
// The destructuring assignment drops the OLD capturer (→ its display lease) as
// each binding is replaced — the new pipeline is already up (create-before-drop).