feat(host): PyroWave encoder — Phase 1 of the LAN low-latency codec plan

PyroWaveEncoder behind --features pyrowave + an explicit
PUNKTFUNK_ENCODER=pyrowave (loud EXPERIMENTAL warning: no client can
decode the stream until CODEC_PYROWAVE negotiation lands, plan Phase 2).

Design (plan §4.3): a private ash Vulkan-1.3 device shared with pyrowave
via pyrowave_create_device — DeviceHold pins the instance/device
create-infos the 0.4.0 API requires alive for the device's lifetime.
Capture dmabufs pass straight through on ANY vendor
(linux_zero_copy_is_vaapi → true for pyrowave; NVIDIA dmabuf→Vulkan
import validated by upstream's interop test on .21) with the same
per-buffer import cache as the Vulkan Video backend; the shared
rgb2yuv.comp BT.709-limited CSC writes R8+RG8 images pyrowave samples
directly (R/G view swizzles synthesize Cb/Cr — no NV12 copy). Encode
records into OUR command buffer (pyrowave_device_set_command_buffer), so
ingest + CSC + encode are one submission with a sub-ms fence wait; the
AU is exactly one pyrowave packet, keyframe=true on every frame.
reconfigure_bitrate is a free in-place budget change (Phase 3 pins the
session rate); reset() recreates only the pyrowave encoder object.

Shared ash leaf helpers (dmabuf import, image/memory utils) extracted
from vulkan_video.rs into encode/linux/vk_util.rs — vulkan-encode
builds unchanged.

Validated on .21 (RTX 5070 Ti): pyrowave_smoke green — encodes CPU
fills through the full open→CSC→GPU-encode→packetize path, decodes
every AU with upstream's own decoder, checks BT.709 plane means ±3;
rate retarget + rebuild covered. clippy clean, 308 host tests green
with the feature on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:58:02 +02:00
parent 4c3b11445c
commit 767f028bdf
6 changed files with 1326 additions and 178 deletions
Generated
+1
View File
@@ -3120,6 +3120,7 @@ dependencies = [
"pf-driver-proto",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
"quinn",
"rand 0.8.6",
"rcgen",
+8
View File
@@ -80,6 +80,9 @@ log = "0.4"
# crate vendors libopus (cmake-built from source — no system lib, no vcpkg), so it builds on Windows
# MSVC too (needs CMake + NASM, both on the box). Both platforms that have an audio-capture backend.
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
# PyroWave (opt-in wired-LAN wavelet codec) — vendored codec + bindgen'd C API, only compiled
# under `--features pyrowave`. Stub-empty on other targets, so the cfg here is belt-and-braces.
pyrowave-sys = { path = "../pyrowave-sys", optional = true }
opus = "0.3"
# Software H.264 encoder — the GPU-less encode path on both Linux and Windows (and a fallback when no
# hardware encoder is available). The default `source` feature statically compiles OpenH264 (BSD-2) —
@@ -265,6 +268,11 @@ amf-qsv = ["dep:ffmpeg-next"]
# bindings already carried for the dmabuf zero-copy bridge). Runtime-gated further by
# PUNKTFUNK_VULKAN_ENCODE (opt-in for now). Build the AMD/Intel RFI host with `--features vulkan-encode`.
vulkan-encode = []
# PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md).
# Builds the vendored codec from source (crates/pyrowave-sys, CMake + bindgen; Linux/Windows).
# OFF by default; runtime-gated further behind an explicit PUNKTFUNK_ENCODER=pyrowave.
# EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2).
pyrowave = ["dep:pyrowave-sys"]
# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging
# builds of this crate never execute the winresource block).
+42 -1
View File
@@ -601,6 +601,30 @@ fn open_video_backend(
)
}
}
// PyroWave — the opt-in wired-LAN intra-only wavelet codec. Explicit-only, and
// EXPERIMENTAL until CODEC_PYROWAVE negotiation lands (plan Phase 2): no shipping
// client can decode the stream yet, so this arm exists for host-side bring-up and
// latency work only. Vendor-agnostic (any Vulkan 1.3 GPU); ignores the negotiated
// codec — every AU is an independently-decodable wavelet frame.
"pyrowave" => {
#[cfg(feature = "pyrowave")]
{
tracing::warn!(
?codec,
"PUNKTFUNK_ENCODER=pyrowave: EXPERIMENTAL all-intra wavelet stream — \
clients without a PyroWave decoder (all of them until CODEC_PYROWAVE \
lands) cannot display it"
);
pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
}
#[cfg(not(feature = "pyrowave"))]
{
anyhow::bail!(
"PUNKTFUNK_ENCODER=pyrowave requires a build with --features punktfunk-host/pyrowave"
)
}
}
// GPU-less software H.264 (openh264) — for a headless / GPU-lost box. Explicit-only:
// `auto` never picks it (a box with `/dev/nvidiactl` present but a dead driver would
// otherwise wrongly resolve to NVENC). Needs H.264 (openh264 emits only that) and a CPU
@@ -627,7 +651,7 @@ fn open_video_backend(
}
}
other => anyhow::bail!(
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, or software"
"unknown PUNKTFUNK_ENCODER={other:?} — use auto (default), nvenc, vaapi, vulkan, pyrowave, or software"
),
}
}
@@ -938,6 +962,9 @@ pub fn linux_zero_copy_is_vaapi() -> bool {
match crate::config::config().encoder_pref.as_str() {
"nvenc" | "nvidia" | "cuda" => false,
"vaapi" | "amd" | "intel" => true,
// PyroWave ingests the raw capture dmabuf itself (Vulkan import + compute CSC) on ANY
// vendor — it must get the passthrough payload, never the EGL→CUDA import.
"pyrowave" => true,
_ => linux_auto_is_vaapi(),
}
}
@@ -1274,6 +1301,20 @@ mod vulkan_video;
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
#[path = "encode/linux/vk_av1_encode.rs"]
mod vk_av1_encode;
// Small ash leaf helpers shared by the Linux Vulkan encode backends (dmabuf import, image/memory
// utilities) — extracted from `vulkan_video.rs` when the PyroWave backend arrived.
#[cfg(all(
target_os = "linux",
any(feature = "vulkan-encode", feature = "pyrowave")
))]
#[path = "encode/linux/vk_util.rs"]
mod vk_util;
// PyroWave — the opt-in wired-LAN intra-only wavelet codec (design/pyrowave-codec-plan.md §4.3):
// pure Vulkan compute via the vendored `pyrowave-sys`, sub-ms encode, every frame a keyframe.
// Explicit-only behind PUNKTFUNK_ENCODER=pyrowave; EXPERIMENTAL until CODEC_PYROWAVE lands.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
#[path = "encode/linux/pyrowave.rs"]
mod pyrowave;
#[cfg(test)]
mod tests {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
//! Small ash/Vulkan leaf helpers shared by the Linux Vulkan encode backends
//! (`vulkan_video.rs`, `pyrowave.rs`) — extracted verbatim from `vulkan_video.rs`
//! when the PyroWave backend arrived so the two don't fork copies.
// Every unsafe block carries a `// SAFETY:` proof (parent module enforces it).
use crate::capture::PixelFormat;
use anyhow::Result;
use ash::vk;
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: layer,
layer_count: 1,
}
}
pub(crate) unsafe fn find_mem(
mp: &vk::PhysicalDeviceMemoryProperties,
bits: u32,
want: vk::MemoryPropertyFlags,
) -> u32 {
for i in 0..mp.memory_type_count {
if (bits & (1 << i)) != 0 && mp.memory_types[i as usize].property_flags.contains(want) {
return i;
}
}
0
}
/// DRM fourcc -> the VkFormat whose *color* components match (Vulkan handles the byte swizzle).
pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
// fourcc_code(a,b,c,d) = a | b<<8 | c<<16 | d<<24
const XR24: u32 = 0x3432_5258; // XRGB8888
const AR24: u32 = 0x3432_5241; // ARGB8888
const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888
match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
pub(crate) unsafe fn make_view(
device: &ash::Device,
image: vk::Image,
fmt: vk::Format,
layer: u32,
) -> Result<vk::ImageView> {
Ok(device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(layer)),
None,
)?)
}
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys all
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
pub(crate) unsafe fn import_rgb_dmabuf(
device: &ash::Device,
ext_fd: &ash::khr::external_memory_fd::Device,
mem_props: &vk::PhysicalDeviceMemoryProperties,
d: &crate::capture::DmabufFrame,
cw: u32,
ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
use anyhow::Context;
use std::os::fd::IntoRawFd;
let fmt = fourcc_to_vk(d.fourcc)
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
let plane = [vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64)];
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(d.modifier)
.plane_layouts(&plane);
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
.usage(vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut ext)
.push_next(&mut drm),
None,
)?;
// dup the fd; Vulkan takes ownership of the dup on a successful import.
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
let fd_props = {
let mut p = vk::MemoryFdPropertiesKHR::default();
let _ = (ext_fd.fp().get_memory_fd_properties_khr)(
device.handle(),
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup,
&mut p,
);
p.memory_type_bits
};
let req = device.get_image_memory_requirements(img);
let bits = req.memory_type_bits & fd_props;
let ti = find_mem(
mem_props,
if bits != 0 {
bits
} else {
req.memory_type_bits
},
vk::MemoryPropertyFlags::empty(),
);
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
let mut import = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(dup);
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(ti)
.push_next(&mut ded)
.push_next(&mut import),
None,
)?;
device.bind_image_memory(img, mem, 0)?;
let view = device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(img)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(0)),
None,
)?;
Ok((img, mem, view))
}
pub(crate) unsafe fn make_plain_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,
fmt: vk::Format,
w: u32,
h: u32,
usage: vk::ImageUsageFlags,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: w,
height: h,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(usage)
.initial_layout(vk::ImageLayout::UNDEFINED),
None,
)?;
let req = device.get_image_memory_requirements(img);
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
mp,
req.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
)?;
device.bind_image_memory(img, mem, 0)?;
let view = make_view(device, img, fmt, 0)?;
Ok((img, mem, view))
}
@@ -10,6 +10,9 @@
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
#![allow(clippy::too_many_arguments)]
use super::vk_util::{
color_range, find_mem, fourcc_to_vk, make_plain_image, make_view, pixel_to_vk,
};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use crate::encode::{Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{bail, Context, Result};
@@ -700,81 +703,7 @@ impl VulkanVideoEncoder {
cw: u32,
ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let fmt = fourcc_to_vk(d.fourcc)
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
let plane = [vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64)];
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(d.modifier)
.plane_layouts(&plane);
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let img = self.device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
.usage(vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut ext)
.push_next(&mut drm),
None,
)?;
// dup the fd; Vulkan takes ownership of the dup on a successful import.
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
let fd_props = {
let mut p = vk::MemoryFdPropertiesKHR::default();
let _ = (self.ext_fd.fp().get_memory_fd_properties_khr)(
self.device.handle(),
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup,
&mut p,
);
p.memory_type_bits
};
let req = self.device.get_image_memory_requirements(img);
let bits = req.memory_type_bits & fd_props;
let ti = find_mem(
&self.mem_props,
if bits != 0 {
bits
} else {
req.memory_type_bits
},
vk::MemoryPropertyFlags::empty(),
);
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
let mut import = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(dup);
let mem = self.device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(ti)
.push_next(&mut ded)
.push_next(&mut import),
None,
)?;
self.device.bind_image_memory(img, mem, 0)?;
let view = self.device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(img)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(0)),
None,
)?;
Ok((img, mem, view))
super::vk_util::import_rgb_dmabuf(&self.device, &self.ext_fd, &self.mem_props, d, cw, ch)
}
/// Import a dmabuf, reusing a cached per-buffer import when the same underlying buffer recurs
@@ -1998,112 +1927,10 @@ impl Drop for VulkanVideoEncoder {
// ---------- free helpers ----------
fn color_range(layer: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: layer,
layer_count: 1,
}
}
fn align_up(v: u64, a: u64) -> u64 {
v.div_ceil(a) * a
}
unsafe fn find_mem(
mp: &vk::PhysicalDeviceMemoryProperties,
bits: u32,
want: vk::MemoryPropertyFlags,
) -> u32 {
for i in 0..mp.memory_type_count {
if (bits & (1 << i)) != 0 && mp.memory_types[i as usize].property_flags.contains(want) {
return i;
}
}
0
}
/// DRM fourcc -> the VkFormat whose *color* components match (Vulkan handles the byte swizzle).
fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
// fourcc_code(a,b,c,d) = a | b<<8 | c<<16 | d<<24
const XR24: u32 = 0x3432_5258; // XRGB8888
const AR24: u32 = 0x3432_5241; // ARGB8888
const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888
match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
unsafe fn make_view(
device: &ash::Device,
image: vk::Image,
fmt: vk::Format,
layer: u32,
) -> Result<vk::ImageView> {
Ok(device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(layer)),
None,
)?)
}
unsafe fn make_plain_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,
fmt: vk::Format,
w: u32,
h: u32,
usage: vk::ImageUsageFlags,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: w,
height: h,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(usage)
.initial_layout(vk::ImageLayout::UNDEFINED),
None,
)?;
let req = device.get_image_memory_requirements(img);
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
mp,
req.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
)?;
device.bind_image_memory(img, mem, 0)?;
let view = make_view(device, img, fmt, 0)?;
Ok((img, mem, view))
}
unsafe fn make_video_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,