From e71cb9b7bdaa010e5adb79143883e7ab80fac905 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 15 Jul 2026 01:14:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(core,host):=20CODEC=5FPYROWAVE=20negotiati?= =?UTF-8?q?on=20=E2=80=94=20opt-in=20only,=20host=20dispatch=20wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a+2c of design/pyrowave-codec-plan.md. Core: CODEC_PYROWAVE = 0x08 on Hello::video_codecs/Welcome::codec. Deliberately absent from resolve_codec's precedence ladder (plan §3 — a 100-400 Mbps codec must never win a negotiation by mere mutual support): reachable exclusively through the client's explicit preferred_codec. Invariant tests cover never-auto-selected (even as the only shared codec), preferred-path selection, and graceful fallback. ABI mirror PUNKTFUNK_CODEC_PYROWAVE + lockstep assert for the Apple/Android embedders. Host: Codec::PyroWave variant threaded through the wire mappings; a negotiated PyroWave session routes straight to the backend ahead of the PUNKTFUNK_ENCODER pref dispatch (which stays a lab override). The advertisement bit rides host_wire_caps only when the capture side would actually deliver ingestible frames — linux_zero_copy_is_vaapi(), i.e. AMD/Intel auto or an explicit operator pref on NVIDIA; per-session raw-dmabuf OutputFormat plumbing is recorded as the Phase-3 item. The libavcodec name helpers are dispatch-guarded unreachable; the web console gains ApiCodec::PyroWave (api/openapi.json regenerated). Validated on .21: 308 host tests green with and without the feature, 145 core tests green with quic, clippy clean. Co-Authored-By: Claude Fable 5 --- api/openapi.json | 5 +- crates/punktfunk-core/src/abi.rs | 5 + crates/punktfunk-core/src/quic/msgs.rs | 19 ++- crates/punktfunk-core/src/quic/tests.rs | 24 +++ crates/punktfunk-host/src/encode.rs | 144 +++++++++++++----- .../punktfunk-host/src/encode/linux/vaapi.rs | 2 + crates/punktfunk-host/src/main.rs | 2 + crates/punktfunk-host/src/mgmt.rs | 3 + 8 files changed, 161 insertions(+), 43 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index c119d4c6..2277457a 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.9.1" + "version": "0.11.0" }, "paths": { "/api/v1/clients": { @@ -2047,7 +2047,8 @@ "enum": [ "h264", "h265", - "av1" + "av1", + "pyrowave" ] }, "ApiDisplayInfo": { diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f13a341d..d995595d 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -928,6 +928,10 @@ pub const PUNKTFUNK_CODEC_H264: u8 = 0x01; pub const PUNKTFUNK_CODEC_HEVC: u8 = 0x02; /// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.) pub const PUNKTFUNK_CODEC_AV1: u8 = 0x04; +/// Codec bit: PyroWave — the opt-in wired-LAN intra-only wavelet codec. Never auto-selected: +/// the host picks it ONLY when the client also passes it as `preferred_codec` +/// (design/pyrowave-codec-plan.md §3). (Mirrors `quic::CODEC_PYROWAVE`.) +pub const PUNKTFUNK_CODEC_PYROWAVE: u8 = 0x08; // Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift). #[cfg(feature = "quic")] @@ -938,6 +942,7 @@ const _: () = { assert!(PUNKTFUNK_CODEC_H264 == crate::quic::CODEC_H264); assert!(PUNKTFUNK_CODEC_HEVC == crate::quic::CODEC_HEVC); assert!(PUNKTFUNK_CODEC_AV1 == crate::quic::CODEC_AV1); + assert!(PUNKTFUNK_CODEC_PYROWAVE == crate::quic::CODEC_PYROWAVE); }; // Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). diff --git a/crates/punktfunk-core/src/quic/msgs.rs b/crates/punktfunk-core/src/quic/msgs.rs index b1fe362f..edd957d4 100644 --- a/crates/punktfunk-core/src/quic/msgs.rs +++ b/crates/punktfunk-core/src/quic/msgs.rs @@ -151,15 +151,28 @@ pub const CODEC_H264: u8 = 0x01; pub const CODEC_HEVC: u8 = 0x02; /// [`Hello::video_codecs`] bit: the client can decode AV1. pub const CODEC_AV1: u8 = 0x04; +/// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN +/// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR, +/// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s +/// precedence ladder**: it is selected only when the client also names it +/// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec +/// that needs a wired-LAN bitrate must never win a negotiation just because both ends support +/// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin" +/// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream +/// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol +/// version instead (plan §4.2). +pub const CODEC_PYROWAVE: u8 = 0x08; /// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`] /// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen /// encoder can produce (`host_capable`, also a bitfield). `preferred` is the client's soft preference /// ([`Hello::preferred_codec`], `0` = none): when it's in the shared set it wins; otherwise the tie is /// broken by **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the -/// compatibility / software floor). Returns the single-bit codec value, or `None` when client and host -/// share nothing — the caller then refuses the session with a clear error rather than emitting a -/// stream the client can't decode. +/// compatibility / software floor). [`CODEC_PYROWAVE`] is intentionally NOT in that ladder — it can +/// only be returned via the `preferred` path (plan §3: opt-in, pinned, honest). Returns the +/// single-bit codec value, or `None` when client and host share nothing the ladder may pick — the +/// caller then refuses the session with a clear error rather than emitting a stream the client +/// can't decode. pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Option { // An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent. let client = if client_codecs == 0 { diff --git a/crates/punktfunk-core/src/quic/tests.rs b/crates/punktfunk-core/src/quic/tests.rs index a2e5e6bf..4ce2f90d 100644 --- a/crates/punktfunk-core/src/quic/tests.rs +++ b/crates/punktfunk-core/src/quic/tests.rs @@ -101,6 +101,30 @@ fn codec_negotiation_and_back_compat() { // A preference the host can't emit still can't rescue a no-shared-codec case. assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None); + // PyroWave is opt-in ONLY (plan §3): mutual support NEVER auto-selects it — the ladder + // ignores it entirely... + assert_eq!( + resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC | CODEC_PYROWAVE, 0), + Some(CODEC_HEVC) + ); + // ...even when it is the ONLY shared codec (an all-intra 200 Mbps stream must never be a + // silent fallback)... + assert_eq!(resolve_codec(CODEC_PYROWAVE, CODEC_PYROWAVE, 0), None); + // ...it is reachable exclusively through the client's explicit preference. + assert_eq!( + resolve_codec( + CODEC_HEVC | CODEC_PYROWAVE, + CODEC_HEVC | CODEC_PYROWAVE, + CODEC_PYROWAVE + ), + Some(CODEC_PYROWAVE) + ); + // A pyrowave preference against a host without the backend falls back to the ladder. + assert_eq!( + resolve_codec(CODEC_HEVC | CODEC_PYROWAVE, CODEC_HEVC, CODEC_PYROWAVE), + Some(CODEC_HEVC) + ); + // A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on // a build that ignores the trailing byte (back-compat: extra bytes are skipped). let h = Hello { diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index 93ad82b1..d4d77be3 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -37,6 +37,10 @@ pub enum Codec { H264, H265, Av1, + /// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md). + /// Only ever negotiated via the client's explicit `preferred_codec` (never the precedence + /// ladder) and only emitted by the `pyrowave`-feature backend; every AU is a keyframe. + PyroWave, } /// Chroma subsampling the encoder emits, negotiated with the client (the `PUNKTFUNK_444` gate + the @@ -73,6 +77,7 @@ impl Codec { match bit { punktfunk_core::quic::CODEC_H264 => Codec::H264, punktfunk_core::quic::CODEC_AV1 => Codec::Av1, + punktfunk_core::quic::CODEC_PYROWAVE => Codec::PyroWave, _ => Codec::H265, } } @@ -83,6 +88,7 @@ impl Codec { Codec::H264 => punktfunk_core::quic::CODEC_H264, Codec::H265 => punktfunk_core::quic::CODEC_HEVC, Codec::Av1 => punktfunk_core::quic::CODEC_AV1, + Codec::PyroWave => punktfunk_core::quic::CODEC_PYROWAVE, } } @@ -97,49 +103,74 @@ impl Codec { /// still lands on HEVC for an auto client, exactly the pre-probe behaviour. Fed to /// [`punktfunk_core::quic::resolve_codec`] against the client's advertised codecs. pub fn host_wire_caps() -> u8 { - /// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream - /// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends. - const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264 - | punktfunk_core::quic::CODEC_HEVC - | punktfunk_core::quic::CODEC_AV1; - #[cfg(target_os = "linux")] - { - if matches!( + // PyroWave rides ON TOP of whatever H.26x set resolves below: feature-gated, Linux-only + // for now (the Windows host leg is blocked on the .173 D3D11 interop debt), and inert in + // negotiation unless the client explicitly prefers it (resolve_codec ignores the bit in + // its ladder). Advertised only when the capture side would actually deliver frames the + // backend ingests (raw-dmabuf passthrough / CPU RGB): `linux_zero_copy_is_vaapi()` — + // true on AMD/Intel auto and under an explicit PUNKTFUNK_ENCODER=pyrowave. On an NVIDIA + // host with `auto`, capture resolves to the EGL→CUDA import the backend can't consume, + // so the bit stays off until the OutputFormat plumbing carries a per-session + // raw-dmabuf decision (Phase 3); the operator opts in with the env instead (plan §3). + #[cfg(all(target_os = "linux", feature = "pyrowave"))] + let pyro = if linux_zero_copy_is_vaapi() + && !matches!( crate::config::config().encoder_pref.as_str(), + // A software pref usually means a GPU-less box — no Vulkan device to open. "software" | "sw" | "openh264" ) { - return punktfunk_core::quic::CODEC_H264; + punktfunk_core::quic::CODEC_PYROWAVE + } else { + 0u8 + }; + #[cfg(not(all(target_os = "linux", feature = "pyrowave")))] + let pyro = 0u8; + let base = (|| { + /// The static GPU superset (H.264 | HEVC | AV1) — mirrors the GameStream + /// `SERVER_CODEC_MODE_SUPPORT` advertisement for the unprobed backends. + const GPU_SUPERSET: u8 = punktfunk_core::quic::CODEC_H264 + | punktfunk_core::quic::CODEC_HEVC + | punktfunk_core::quic::CODEC_AV1; + #[cfg(target_os = "linux")] + { + if matches!( + crate::config::config().encoder_pref.as_str(), + "software" | "sw" | "openh264" + ) { + return punktfunk_core::quic::CODEC_H264; + } + if linux_zero_copy_is_vaapi() { + if let Some(m) = vaapi_codec_support().wire_mask() { + return m; + } + } + // NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above). + GPU_SUPERSET } - if linux_zero_copy_is_vaapi() { - if let Some(m) = vaapi_codec_support().wire_mask() { - return m; + #[cfg(target_os = "windows")] + { + if windows_resolved_backend() == WindowsBackend::Software { + return punktfunk_core::quic::CODEC_H264; + } + if windows_backend_is_probed() { + if let Some(m) = windows_codec_support().wire_mask() { + return m; + } + } + // NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above). + GPU_SUPERSET + } + // The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement. + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = GPU_SUPERSET; + match crate::config::config().encoder_pref.as_str() { + "software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264, + _ => punktfunk_core::quic::CODEC_HEVC, } } - // NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above). - GPU_SUPERSET - } - #[cfg(target_os = "windows")] - { - if windows_resolved_backend() == WindowsBackend::Software { - return punktfunk_core::quic::CODEC_H264; - } - if windows_backend_is_probed() { - if let Some(m) = windows_codec_support().wire_mask() { - return m; - } - } - // NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above). - GPU_SUPERSET - } - // The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement. - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - let _ = GPU_SUPERSET; - match crate::config::config().encoder_pref.as_str() { - "software" | "sw" | "openh264" => punktfunk_core::quic::CODEC_H264, - _ => punktfunk_core::quic::CODEC_HEVC, - } - } + })(); + base | pyro } /// Lowercase stats/console label (`"h264"` / `"hevc"` / `"av1"`) — the codec string seeded into @@ -149,6 +180,7 @@ impl Codec { Codec::H264 => "h264", Codec::H265 => "hevc", Codec::Av1 => "av1", + Codec::PyroWave => "pyrowave", } } @@ -159,6 +191,9 @@ impl Codec { Codec::H264 => "h264_nvenc", Codec::H265 => "hevc_nvenc", Codec::Av1 => "av1_nvenc", + // Guarded by the open_video dispatch: a PyroWave session never reaches a + // libavcodec backend. + Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"), } } @@ -172,6 +207,9 @@ impl Codec { Codec::H264 => "h264_vaapi", Codec::H265 => "hevc_vaapi", Codec::Av1 => "av1_vaapi", + // Guarded by the open_video dispatch: a PyroWave session never reaches a + // libavcodec backend. + Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"), } } @@ -182,6 +220,9 @@ impl Codec { Codec::H264 => "h264_amf", Codec::H265 => "hevc_amf", Codec::Av1 => "av1_amf", + // Guarded by the open_video dispatch: a PyroWave session never reaches a + // libavcodec backend. + Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"), } } @@ -192,6 +233,9 @@ impl Codec { Codec::H264 => "h264_qsv", Codec::H265 => "hevc_qsv", Codec::Av1 => "av1_qsv", + // Guarded by the open_video dispatch: a PyroWave session never reaches a + // libavcodec backend. + Codec::PyroWave => unreachable!("PyroWave has no FFmpeg encoder"), } } } @@ -324,7 +368,9 @@ impl Codec { pub fn max_dimension(self) -> u32 { match self { Codec::H264 => 4096, - Codec::H265 | Codec::Av1 => 8192, + // PyroWave has no codec-level dimension cap (arbitrary even sizes); 8192 matches the + // buffer-math guard the other codecs get. + Codec::H265 | Codec::Av1 | Codec::PyroWave => 8192, } } @@ -339,6 +385,9 @@ impl Codec { Codec::H264 => 480_000_000, Codec::H265 => 800_000_000, Codec::Av1 => 1_200_000_000, + // No spec level/tier: the rate is a plain per-frame byte budget. Use the protocol's + // own bitrate clamp so the step-down probe logic never binds below it. + Codec::PyroWave => 8_000_000_000, } } } @@ -523,6 +572,20 @@ fn open_video_backend( }; #[cfg(target_os = "linux")] { + // A NEGOTIATED PyroWave session (client advertised + preferred it, plan §3) routes + // straight to that backend — the PUNKTFUNK_ENCODER pref below stays a lab override. + if codec == Codec::PyroWave { + #[cfg(feature = "pyrowave")] + { + return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps) + .map(|e| (Box::new(e) as Box, "pyrowave")); + } + #[cfg(not(feature = "pyrowave"))] + anyhow::bail!( + "session negotiated PyroWave but this host was built without --features \ + punktfunk-host/pyrowave (the advertisement bit should not have been set)" + ); + } // Pick the GPU encode backend. NVIDIA → NVENC/CUDA (the original path, unchanged); // AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single // Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces @@ -657,6 +720,11 @@ fn open_video_backend( } #[cfg(target_os = "windows")] { + // The Windows host leg is blocked on the .173 D3D11-interop debt (plan Phase 0 §3); + // host_wire_caps never advertises the bit here, so this only guards a forged preference. + if codec == Codec::PyroWave { + anyhow::bail!("PyroWave host encode is not available on Windows yet"); + } let _ = cuda; // always false on Windows (no Cuda payload) // NVIDIA → NVENC (direct SDK), AMD → AMF, Intel → QSV (both libavcodec), else → software // H.264. `auto` (the default) resolves from the selected render adapter's vendor. diff --git a/crates/punktfunk-host/src/encode/linux/vaapi.rs b/crates/punktfunk-host/src/encode/linux/vaapi.rs index a696ca0a..eeb6d03d 100644 --- a/crates/punktfunk-host/src/encode/linux/vaapi.rs +++ b/crates/punktfunk-host/src/encode/linux/vaapi.rs @@ -88,6 +88,8 @@ fn lp_idx(codec: Codec) -> usize { Codec::H264 => 0, Codec::H265 => 1, Codec::Av1 => 2, + // Guarded by the open_video dispatch: PyroWave never opens the VAAPI backend. + Codec::PyroWave => unreachable!("PyroWave has no VAAPI encoder"), } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index c814f601..557cd496 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -873,6 +873,8 @@ fn parse_spike(args: &[String]) -> Result { Codec::H264 => "h264", Codec::H265 => "h265", Codec::Av1 => "obu", + // Raw concatenated PyroWave packets — a lab dump, not an FFmpeg-playable stream. + Codec::PyroWave => "pyrowave", }; PathBuf::from(format!("/tmp/punktfunk-spike.{ext}")) }); diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index eb3988a7..312a0da8 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -310,6 +310,8 @@ enum ApiCodec { H264, H265, Av1, + /// PyroWave — the opt-in wired-LAN intra-only wavelet codec. + PyroWave, } impl From for ApiCodec { @@ -318,6 +320,7 @@ impl From for ApiCodec { Codec::H264 => ApiCodec::H264, Codec::H265 => ApiCodec::H265, Codec::Av1 => ApiCodec::Av1, + Codec::PyroWave => ApiCodec::PyroWave, } } }