//! Raw **Vulkan Video** HEVC + AV1 encoder (`VK_KHR_video_encode_h265` / `_av1`) with true //! reference-frame invalidation — the open-stack AMD/Intel-Linux twin of the direct-NVENC RFI path. //! The app owns the DPB, so loss recovery is a clean P-frame that re-references a known-good older //! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a //! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain. //! //! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→NV12 //! BT.709 compute CSC, then encodes. Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`. //! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op. //! 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, import_failure_feeds_latch, make_host_buffer, make_plain_image, make_view, normalize_cpu_rgb, pixel_to_vk, }; use crate::{Codec, EncodedFrame, Encoder, EncoderCaps}; use anyhow::{bail, Context, Result}; use ash::vk; use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use std::collections::VecDeque; use std::ffi::c_void; use std::os::fd::AsRawFd; const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM; /// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing /// buffers so this holds handles, not new allocations). const IMPORT_CACHE_CAP: usize = 16; // Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file; // regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader. const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv"); /// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads /// into the top-left and the shader's push constant bounds sampling, so one allocation fits every /// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant. const CURSOR_MAX: u32 = 256; /// DPB ring depth (well under the RADV `maxDpbSlots=17`); also the RFI recovery window. const DPB_SLOTS: u32 = 8; /// In-flight frame ring: how many captures may have GPU work outstanding at once. 2 overlaps a /// frame's CSC+encode with the next capture (the throughput win) at the lowest possible added /// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default; /// backpressure kicks in at the 2nd unread frame. Distinct from `DPB_SLOTS` (reference pool). const RING_DEFAULT: usize = 2; /// Ceiling on any blocking GPU fence wait on the encode thread (5 s). Generous against a real /// encode (single-digit ms even on a loaded GPU) and against a driver hiccup, but finite: this is /// the thread the stall watchdog's `reset()` runs on, so an unbounded wait would deadlock the very /// path that recovers the session. Matches the Windows NVENC retrieve-thread budget. const ENCODE_FENCE_TIMEOUT_NS: u64 = 5_000_000_000; /// AV1 base quantizer index (0..=255) seeded into every frame. CBR rate control overrides it per /// frame; it only matters as the starting point and for the (rate-control-ignored) constant-Q path. const AV1_BASE_Q_IDX: u8 = 128; /// Resolve the in-flight ring depth: `PUNKTFUNK_VULKAN_INFLIGHT` (clamped 2..=6), else `RING_DEFAULT`. fn ring_depth() -> usize { std::env::var("PUNKTFUNK_VULKAN_INFLIGHT") .ok() .and_then(|v| v.trim().parse::().ok()) .map(|n| n.clamp(2, 6)) .unwrap_or(RING_DEFAULT) } /// Requested Vulkan encode quality level (`PUNKTFUNK_VULKAN_QUALITY`, default 0), clamped to the /// profile's `maxQualityLevels` at open. Levels are ordered fastest→best per the spec; on RADV /// they select the VCN preset (0 SPEED, 1 BALANCE, 2 QUALITY, 3 HIGH_QUALITY — H265 on pre-RDNA4 /// pins 0 to BALANCE in the driver). Without an explicit `ENCODE_QUALITY_LEVEL` control RADV /// never sends the VCN a preset op at all, leaving the firmware default — so the session always /// installs the resolved level explicitly on its first frame. fn quality_request() -> u32 { std::env::var("PUNKTFUNK_VULKAN_QUALITY") .ok() .and_then(|v| v.trim().parse::().ok()) .unwrap_or(0) } /// `PUNKTFUNK_VULKAN_RGB_DIRECT` override for the RGB-direct encode source /// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the /// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one /// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2 /// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor /// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on /// cursor-blend sessions (the pointer will be missing from the stream); unset = the default. /// /// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including /// an empty value and a value that is only whitespace — falls back to the default rather than /// being read as a force-on. The old `v == "0"` test made `"0 "` mean the *opposite* of what the /// operator wrote, and turned a typo into a forced RGB-direct session with no cursor. fn rgb_request() -> Option { parse_rgb_request(std::env::var("PUNKTFUNK_VULKAN_RGB_DIRECT").ok().as_deref()) } /// The pure half of [`rgb_request`], so the accepted spellings are testable without mutating the /// process environment (which no parallel test can do safely). fn parse_rgb_request(raw: Option<&str>) -> Option { match raw?.trim() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } /// True-extent RGB-direct at unaligned modes (default ON; `PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0` /// restores the padded-copy staging): direct-import the visible-size capture with the TRUE-SIZE /// source `codedExtent` — RADV derives nonzero VCN firmware padding from it, so the EFC is told /// the source lacks the alignment rows (see [`RgbDirect::true_extent`]). Guarded-tested on Van /// Gogh 2026-07-21 (kernel clean, and the fastest 1080p encode path measured); the EFC only /// exists on Mesa ≥ 26, where the `codedExtent`-driven `session_init` is guaranteed. fn rgb_true_extent_request() -> bool { std::env::var("PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT").as_deref() != Ok("0") } /// Live RGB-direct session config: the chroma-siting bits the session was created with /// (chosen from what the driver advertises — see [`probe_rgb_direct`]). struct RgbDirect { x_offset: u32, // vk_valve_rgb::CHROMA_OFFSET_* y_offset: u32, /// The mode is not 64x16-aligned, so the captured buffer cannot be the encode source /// under the session's ALIGNED source extent (the EFC read past it — the 2026-07-20 field /// GPU hang, when the source `codedExtent` was the aligned size and RADV therefore derived /// ZERO firmware padding). Each frame is copied into a per-slot ALIGNED BGRA staging image /// with the edge rows/columns duplicated into the padding (transfer-only, no shader) and /// encoded from there. Aligned modes keep the true zero-copy import. padded: bool, /// The default unaligned-mode source strategy (`PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0` falls /// back to `padded`): direct-import the visible-size buffer and pass the TRUE-SIZE source /// `codedExtent` — RADV then programs nonzero firmware padding from it (Mesa ≥ 24.2 /// derives `session_init` padding from `srcPictureResource.codedExtent`; see /// [`VulkanVideoEncoder::native_nv12`]), telling the VCN the source lacks the alignment /// rows, which the hardware edge-extends internally. The session/SPS/DPB stay app-aligned. /// Guarded-tested on Van Gogh (kernel clean; fastest 1080p path measured). true_extent: bool, } /// Stack storage for a complete rgb-chained video profile. Profiled image creation AFTER open /// (the per-PipeWire-buffer dmabuf imports, the CPU staging image) must present a profile /// structurally identical to the session's — profile identity is by value, so rebuilding the /// chain per call is spec-correct. `wire()` links the `p_next` chain into this struct's own /// addresses (profile → codec profile → usage → rgb), so the value must not move between /// `wire()` and the last use of `.profile`. struct RgbProfileStack { rgb: super::vk_valve_rgb::VideoEncodeProfileRgbConversionInfoVALVE, usage: vk::VideoEncodeUsageInfoKHR<'static>, h265: vk::VideoEncodeH265ProfileInfoKHR<'static>, av1: super::vk_av1_encode::VideoEncodeAV1ProfileInfoKHR, profile: vk::VideoProfileInfoKHR<'static>, } impl RgbProfileStack { fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self { use super::vk_av1_encode as av1b; use super::vk_valve_rgb as vrgb; Self { rgb: vrgb::VideoEncodeProfileRgbConversionInfoVALVE { s_type: vrgb::stype(vrgb::ST_PROFILE_INFO), p_next: std::ptr::null(), perform_encode_rgb_conversion: vk::TRUE, }, usage: vk::VideoEncodeUsageInfoKHR::default() .video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING) .video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED) .tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY), h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc( vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN, ), av1: av1b::VideoEncodeAV1ProfileInfoKHR { s_type: av1b::stype(av1b::ST_PROFILE_INFO), p_next: std::ptr::null(), std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN, }, profile: vk::VideoProfileInfoKHR::default() .video_codec_operation(codec_op) .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8), } } /// Wire the `p_next` chain into this value's final address; returns `&self.profile` for use. fn wire(&mut self, av1: bool) -> &vk::VideoProfileInfoKHR<'static> { self.usage.p_next = &self.rgb as *const _ as *const c_void; if av1 { self.av1.p_next = &self.usage as *const _ as *const c_void; self.profile.p_next = &self.av1 as *const _ as *const c_void; } else { self.h265.p_next = &self.usage as *const _ as *const c_void; self.profile.p_next = &self.h265 as *const _ as *const c_void; } &self.profile } } /// Non-RGB video profile rebuilt for native NV12 DMA-BUF imports. Image creation after `open` /// must carry a profile identical by value to the session profile. struct NativeProfileStack { usage: vk::VideoEncodeUsageInfoKHR<'static>, h265: vk::VideoEncodeH265ProfileInfoKHR<'static>, av1: super::vk_av1_encode::VideoEncodeAV1ProfileInfoKHR, profile: vk::VideoProfileInfoKHR<'static>, } impl NativeProfileStack { fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self { use super::vk_av1_encode as av1b; Self { usage: vk::VideoEncodeUsageInfoKHR::default() .video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING) .video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED) .tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY), h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc( vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN, ), av1: av1b::VideoEncodeAV1ProfileInfoKHR { s_type: av1b::stype(av1b::ST_PROFILE_INFO), p_next: std::ptr::null(), std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN, }, profile: vk::VideoProfileInfoKHR::default() .video_codec_operation(codec_op) .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8), } } fn wire(&mut self, av1: bool) -> &vk::VideoProfileInfoKHR<'static> { if av1 { self.av1.p_next = &self.usage as *const _ as *const c_void; self.profile.p_next = &self.av1 as *const _ as *const c_void; } else { self.h265.p_next = &self.usage as *const _ as *const c_void; self.profile.p_next = &self.h265 as *const _ as *const c_void; } &self.profile } } /// The Vulkan codec-operation bit for our codec selection (shared by open and the per-import /// profile rebuilds — the two must agree, profile identity is by value). /// The physical device + encode queue family a session runs on: the FIRST device exposing a /// `VIDEO_ENCODE` queue family that advertises `codec_op` (llvmpipe advertises none, so it drops /// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_support`]. /// /// # Safety /// `instance` must be a live `ash::Instance` and `devices` handles enumerated from it. unsafe fn find_encode_device( instance: &ash::Instance, devices: &[vk::PhysicalDevice], codec_op: vk::VideoCodecOperationFlagsKHR, ) -> Option<(vk::PhysicalDevice, u32)> { for &pd in devices { let qf_len = instance.get_physical_device_queue_family_properties2_len(pd); let mut video = vec![vk::QueueFamilyVideoPropertiesKHR::default(); qf_len]; let mut qf = vec![vk::QueueFamilyProperties2::default(); qf_len]; for i in 0..qf_len { qf[i].p_next = &mut video[i] as *mut _ as *mut c_void; } instance.get_physical_device_queue_family_properties2(pd, &mut qf); for i in 0..qf_len { if qf[i] .queue_family_properties .queue_flags .contains(vk::QueueFlags::VIDEO_ENCODE_KHR) && video[i].video_codec_operations.contains(codec_op) { return Some((pd, i as u32)); } } } None } /// Can this GPU + driver open a Vulkan Video **encode** session for `codec` at all? /// /// This is the verdict the native-NV12 capture negotiation needs BEFORE it commits /// ([`crate::linux_native_nv12_ok`]): once the producer hands over two-plane NV12 there is no VAAPI /// fallback — libav would import it as packed RGB — so [`crate::open_video`] deliberately makes /// that open-failure fatal, and a Mesa built without `VK_KHR_video_encode_h265` therefore kills the /// session at its first frame instead of streaming on VAAPI. /// /// Deliberately the FIRST check `open_inner` performs and hard-fails on, and nothing more: the same /// [`find_encode_device`] scan, run against the same codec op. That makes it provably no stricter /// than the open, so a failure here can only ever name a session that would have died anyway — it /// can never talk a working host out of the fast path. It is also cheap: one instance plus /// physical-device queries, no logical device, no video session, no VRAM. The later stages /// (`create_device`, `create_video_session`, the capability query) can still fail for reasons this /// does not model; those keep the session on the packed-RGB negotiation the ordinary way, because /// the capture format is only committed once this said yes. /// /// Probed PER CODEC, not once: `codec_op_for` selects a different queue-family bit for AV1, and /// HEVC-encode-without-AV1-encode is the common VCN/ANV configuration. pub(crate) fn probe_encode_support(codec: Codec) -> Result<(), &'static str> { if !matches!(codec, Codec::H265 | Codec::Av1) { return Err("the Vulkan Video backend encodes HEVC + AV1 only"); } let codec_op = codec_op_for(codec == Codec::Av1); // SAFETY: creates one Vulkan instance and issues only physical-device queries against it, then // destroys it on EVERY path below before returning — no handle derived from it escapes, and // nothing outside this call observes it. `Entry::load` only dlopens the loader (a missing // libvulkan returns `Err`), touching no process state the rest of the crate relies on. // `find_encode_device` gets a live instance and handles enumerated from it, as it requires. unsafe { let Ok(entry) = ash::Entry::load() else { return Err("no Vulkan loader"); }; let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3); let Ok(instance) = entry.create_instance( &vk::InstanceCreateInfo::default().application_info(&app), None, ) else { return Err("vkCreateInstance failed"); }; let found = match instance.enumerate_physical_devices() { Ok(devices) => find_encode_device(&instance, &devices, codec_op).is_some(), Err(_) => false, }; instance.destroy_instance(None); if found { Ok(()) } else { Err("no VK_KHR_video_encode queue for this codec on any device") } } } fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR { if av1 { vk::VideoCodecOperationFlagsKHR::from_raw( super::vk_av1_encode::VIDEO_CODEC_OPERATION_ENCODE_AV1, ) } else { vk::VideoCodecOperationFlagsKHR::ENCODE_H265 } } /// How `begin_encode_cmd` must acquire this frame's encode-source image. #[derive(Clone, Copy, PartialEq)] enum SrcAcquire { /// CSC path: `nv12_src` was written by this frame's compute batch (GENERAL layout; the /// csc_sem orders the queues). CscGeneral, /// First use of a DMA-BUF imported directly as the video source: acquire from the foreign /// producer (UNDEFINED preserves modifier-backed bytes) with a FOREIGN→encode-family transfer. DmabufFresh, /// Cached direct-source import: already VIDEO_ENCODE_SRC; visibility-only barrier for the /// producer's out-of-band rewrite of the bytes. DmabufCached, /// RGB-direct CPU upload: the compute queue copied the staging buffer in (semaphore /// ordered); transition TRANSFER_DST → VIDEO_ENCODE_SRC. CpuUpload, } /// Persistently mapped base of a frame slot's `bs_mem` (HOST_VISIBLE|HOST_COHERENT, mapped once /// at build): `read_slot` copies an AU out of it every frame, so the previous per-frame /// vkMapMemory/vkUnmapMemory round-trip was pure driver-call overhead. vkFreeMemory implicitly /// unmaps, so teardown needs no explicit unmap. struct BsPtr(*const u8); // SAFETY: a Vulkan host mapping is a property of the allocation, not of the thread that mapped // it (the spec places no thread affinity on mapped pointers); every dereference goes through the // encoder's `&mut self`, so no concurrent access exists. unsafe impl Send for BsPtr {} impl Default for BsPtr { fn default() -> Self { Self(std::ptr::null()) } } /// The trusted-reference view of `slot_wire` for the shared RFI policy ([`crate::rfi`]): resident /// slots only — `-1` marks empty *or distrusted* (blanked by an earlier loss's taint sweep), and /// excluding it here is exact because the loss start is validity-gated non-negative. fn trusted_refs(slot_wire: &[i64]) -> Vec<(usize, i64)> { slot_wire .iter() .enumerate() .filter_map(|(s, &w)| (w >= 0).then_some((s, w))) .collect() } /// The S0 (past-reference) half of an HEVC short-term RPS that **retains every resident DPB /// picture**, not just the one this frame predicts from. The RPS is the decoder's only retention /// signal (HEVC 8.3.2: any DPB picture absent from the current RPS is marked "unused for /// reference" and reclaimed) — an RPS naming only the active reference lets a conforming decoder /// evict the rest, and the RFI recovery anchor then references a picture the client already /// discarded: FFmpeg's HEVC parser (the Linux VAAPI/Vulkan and Windows D3D11VA clients) conceals /// with a generated gray reference and every following frame chains off the corruption — exactly /// at the moment the anchor claims the picture is clean. Listing all residents (with /// `used_by_curr_pic` set only for the real reference) keeps the host and client DPBs in lockstep, /// so any slot the RFI anchor pick ([`crate::rfi::pick_anchor`]) can pick is decodable by /// construction. /// /// `setup_idx` — the slot this frame reconstructs into — is excluded: its old occupant dies with /// this frame on the host, so the decoder must drop it too (also keeping the retained count at /// `DPB_SLOTS - 1` + the current picture = the SPS `max_dec_pic_buffering` budget). /// /// Returns `(num_negative_pics, delta_poc_s0_minus1, used_by_curr_pic_s0_flag)`. fn build_h265_rps_s0( slot_poc: &[i32], setup_idx: usize, ref_poc: i32, cur_poc: i32, ) -> (u8, [u16; 16], u16) { // Residents, newest first — S0 is ordered by descending POC (ascending delta from `cur_poc`). let mut pocs: Vec = slot_poc .iter() .enumerate() .filter(|&(s, &p)| s != setup_idx && p >= 0 && p < cur_poc) .map(|(_, &p)| p) .collect(); pocs.sort_unstable_by(|a, b| b.cmp(a)); pocs.truncate(16); // delta_poc_s0_minus1 capacity (STD_VIDEO_H265_MAX_DPB_SIZE) let mut deltas = [0u16; 16]; let mut used = 0u16; let mut prev = cur_poc; for (i, &p) in pocs.iter().enumerate() { // delta_poc_s0_minus1[i] codes the gap to the PREVIOUS S0 entry (the spec's cumulative // DeltaPocS0 chain), not to the current picture. deltas[i] = (prev - p - 1) as u16; if p == ref_poc { used |= 1 << i; } prev = p; } (pocs.len() as u8, deltas, used) } /// One in-flight frame's private GPU resources. The encoder keeps a small ring of these so a /// frame's GPU work (CSC + encode) overlaps the CPU capturing and submitting the next one: /// `submit()` records into a free slot and returns without blocking; `poll()` reads back the /// oldest slot once its `fence` signals. Everything here is written by one frame and read by the /// next-but-K, so it cannot be shared while a submission is outstanding. /// /// [`Frame::default`] is the all-null placeholder `open_inner` pre-pushes into its unwind guard so /// `make_frame` can build in place; destroying one is a no-op (`vkDestroy*` ignores null handles). #[derive(Default)] struct Frame { compute_cmd: vk::CommandBuffer, // CSC (compute+transfer) cmd: vk::CommandBuffer, // encode queue csc_sem: vk::Semaphore, // compute -> encode ordering (this frame only) fence: vk::Fence, // signaled when this frame's encode completes query_pool: vk::QueryPool, // bitstream offset/bytes feedback // GPU timestamps around the CSC batch (`PUNKTFUNK_PERF` only; null otherwise): [0]=batch // start, [1]=batch end — splits the fence wait into "compute CSC+copies" vs "ASIC encode". ts_pool: vk::QueryPool, // Whether the submission currently in this slot actually reset AND wrote `ts_pool`. A pool // existing is NOT proof that it was written: `with_ts` arms the pool for the CSC and // padded-RGB paths, but the padded-RGB path's CPU-upload arm records its own command buffer // and writes no timestamps. Reading an unreset/unwritten query with `WAIT` is undefined — // RADV happens to fail the read rather than block, another driver may hang — so `read_slot` // consults this instead of `ts_pool != null`. ts_written: bool, bs_buf: vk::Buffer, bs_mem: vk::DeviceMemory, bs_ptr: BsPtr, // persistent mapping of bs_mem (see BsPtr) // Padded-copy RGB staging (RGB-direct on an unaligned mode ONLY, see RgbDirect::padded): // an ALIGNED BGRA encode-src the visible frame is blitted into with edges duplicated. pad_img: vk::Image, pad_mem: vk::DeviceMemory, pad_view: vk::ImageView, csc_set: vk::DescriptorSet, // Y/UV bindings fixed; binding 0 (RGB) rewritten each use y_img: vk::Image, y_mem: vk::DeviceMemory, y_view: vk::ImageView, uv_img: vk::Image, uv_mem: vk::DeviceMemory, uv_view: vk::ImageView, nv12_src: vk::Image, nv12_mem: vk::DeviceMemory, nv12_view: vk::ImageView, // CPU-input staging (lazily sized; only the software-capture / smoke-test path uses it). // Keyed on (format, width, height) — NOT format alone. The CSC arm sizes this image to the // SOURCE frame while `cmd_copy_buffer_to_image` uses the CURRENT frame's extent, so a // same-format size change against a format-only key copies past the allocation (confirmed on // RADV as VUID-vkCmdCopyBufferToImage-imageSubresource-07971, with `submit` still returning Ok). cpu_img: Option<( vk::Image, vk::DeviceMemory, vk::ImageView, vk::Format, u32, u32, )>, cpu_stage: Option<(vk::Buffer, vk::DeviceMemory, u64)>, // Per-slot cursor overlay (cursor-as-metadata): a fixed CURSOR_MAX² RGBA8 sampled image (bound // once at binding 3) + host staging. Re-uploaded only when the bitmap changes (`cursor_serial`); // `cursor_ready` records the one-time UNDEFINED→SHADER_READ_ONLY transition so binding 3 is a // valid layout even with no cursor. Per-slot (not shared) so a shape change never races a prior // frame's in-flight CSC read. cursor_img: vk::Image, cursor_mem: vk::DeviceMemory, cursor_view: vk::ImageView, cursor_stage: vk::Buffer, cursor_stage_mem: vk::DeviceMemory, cursor_serial: u64, cursor_ready: bool, // Frame metadata, set at submit and read back at poll (valid only while this slot is in flight). pts_ns: u64, keyframe: bool, recovery_anchor: bool, } pub struct VulkanVideoEncoder { // --- vulkan core (owned) --- _entry: ash::Entry, instance: ash::Instance, device: ash::Device, ext_fd: ash::khr::external_memory_fd::Device, vq_dev: ash::khr::video_queue::Device, venc_dev: ash::khr::video_encode_queue::Device, encode_queue: vk::Queue, compute_queue: vk::Queue, encode_family: u32, compute_family: u32, mem_props: vk::PhysicalDeviceMemoryProperties, // --- codec --- codec: Codec, // H265 or Av1 — selects the Std-struct authoring + header framing // --- video session --- session: vk::VideoSessionKHR, session_mem: Vec, params: vk::VideoSessionParametersKHR, // Keyframe prefix: HEVC = VPS/SPS/PPS; AV1 = temporal-delimiter OBU + sequence-header OBU. header: Vec, // Per-(non-key)-frame prefix: empty for HEVC (headers ride keyframes only); AV1 = a // temporal-delimiter OBU that opens every temporal unit (Vulkan emits only the frame OBU). frame_prefix: Vec, // --- DPB --- dpb_image: vk::Image, dpb_mem: vk::DeviceMemory, dpb_views: Vec, slot_wire: Vec, // wire index held per slot (-1 = empty) — RFI/loss domain slot_poc: Vec, // HEVC POC held per slot — reference-delta domain prev_slot: usize, // --- CSC (RGB -> NV12), shared across the frame ring --- csc_pipe: vk::Pipeline, csc_layout: vk::PipelineLayout, csc_dsl: vk::DescriptorSetLayout, csc_pool: vk::DescriptorPool, sampler: vk::Sampler, // Per-buffer dmabuf-import cache, keyed by (st_dev, st_ino) — PipeWire cycles a small fixed pool, // so each underlying buffer is imported ONCE and reused (no per-frame VkImage create/import/destroy). // Imports are read-only per frame, so the ring shares them (concurrent frames read distinct buffers). import_cache: Vec, // --- in-flight frame ring (pipelining) --- frames: Vec, // per-slot private resources ring: usize, // next slot to record into (round-robin over `frames`) in_flight: VecDeque, // slots submitted but not yet read back, oldest first bs_size: u64, cmd_pool: vk::CommandPool, compute_pool: vk::CommandPool, // --- rate control, rebuilt-safe --- bitrate: u64, fps: u32, /// Rate-control mode for every RC site, resolved ONCE at open from /// `VkVideoEncodeCapabilitiesKHR::rateControlModes` (previously hardcoded CBR with no /// capability check at all): VBR when the driver advertises it, else CBR. Always paired with /// `average_bitrate == max_bitrate`, so VBR here is not "spend less on average" — it is CBR's /// exact ceiling minus the *stuffing*: CBR must keep the CPB from overflowing on underspent /// frames and Vulkan exposes no filler-suppression control (AMF's `filler_data=false` / /// NVENC's default-off have no VK equivalent), measured on the 780M as 97 % filler NALs under /// a tight CBR window. VBR permits the underspend, so a tight window only ever BOUNDS a /// complex frame (WP6.3). rc_mode: vk::VideoEncodeRateControlModeFlagsKHR, /// HRD window `(virtualBufferSizeInMs, initialVirtualBufferSizeInMs)`: the house ~1-frame /// window ([`crate::vbv_window_ms`], `PUNKTFUNK_VBV_FRAMES` scales it) under VBR, the legacy /// loose `(1000, 500)` under the CBR fallback — where tightening it is the measured 36× /// filler regression, so the status quo is kept deliberately. Latched into a field rather /// than recomputed at each of the four `VideoEncodeRateControlInfoKHR` sites on purpose: two /// of them DECLARE the session's current rate-control state to `vkCmdBeginVideoCodingKHR` and /// two INSTALL it, and the spec requires the declaration to match what is installed at /// execution time (`VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08254`). One latched value /// cannot drift; four independent computations can. vbv_ms: (u32, u32), /// `VkVideoEncodeCapabilitiesKHR::maxBitrate`, the other field this struct's query used to /// drop on the floor. Bounds `bitrate` at open and every retarget (the spec bounds every /// layer's bitrates by it; an ABR climb on a low-cap driver would otherwise sail past it). /// A driver reporting 0 means "not filled in" — treated as unbounded. hw_max_bitrate: u64, /// Resolved encode quality level (see [`quality_request`]) — installed via the first frame's /// `ENCODE_QUALITY_LEVEL` control and baked into the session-parameters object (the spec /// requires the two to match). quality_level: u32, /// `PUNKTFUNK_PERF` pre-encode split: >0 ⇒ per-frame GPU timestamps bracket either the /// RGB→NV12 compute batch or the native-NV12 padded copy. The measured duration is logged /// separately from the host's fence wait; 0.0 means disabled or unsupported. ts_period_ns: f64, perf_at: std::time::Instant, /// Reused 3→4 expansion buffer for 24-bpp CPU payloads (`vk_util::normalize_cpu_rgb`). cpu_expand: Vec, /// RGB-direct (EFC) session config — `Some` ⇒ the session's picture format is BGRA, frames /// are handed to the encoder as RGB (dmabuf import or CPU upload) and the VCN front-end does /// the CSC; `None` ⇒ the compute-CSC path. Fixed per session (the picture format is baked /// into the video session). rgb: Option, /// Producer supplied native NV12 rather than packed RGB. EVERY mode encodes the imported /// visible-size buffer directly — safely, because native sessions use TRUE-SIZE headers: /// the SPS/sequence header is authored at the render size and every picture resource's /// `codedExtent` matches it, so RADV programs the VCN with `session_init` extent = the true /// size and a nonzero `padding_width/height`, and the FIRMWARE edge-extends the alignment /// padding internally (radv_video_enc.c `radv_enc_session_init`; the driver also rounds the /// bitstream SPS up itself and compensates with a conformance window — /// `radv_video_patch_encode_session_parameters`, per the VK_KHR_video_encode_h265 proposal's /// "implementations may override" clause). The source is never read past its extent — unlike /// the app-aligned-SPS convention the CSC/RGB paths use, where the coded extent is 64x16- /// aligned and an undersized direct source is the OOB-read class behind the 2026-07-20 field /// GPU reset (those paths keep their aligned-size sources/staging). native_nv12: bool, /// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session /// (neither has a compositing stage — the cursor will be missing from the stream until the /// CSC path is used). warned_cursor: bool, /// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video /// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it /// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into /// `bitrate` — which must keep naming the session's CURRENT state, because every begin-coding /// declares it (the spec requires the declared state to match). pending_bitrate: Option, // --- state --- width: u32, height: u32, render_w: u32, // real (pre-alignment) dimensions — AV1 render_size / HEVC conformance window render_h: u32, poc: i32, // monotonic HEVC picture-order-count (reused as AV1 order_hint counter) enc_count: u64, // total frames encoded — drives the DPB ring cursor auto_wire: i64, // fallback wire index when submit() (not submit_indexed) is used first_frame: bool, // needs RESET + DPB layout transition + rate-control install + IDR /// Whether the SESSION OBJECT currently has a non-default rate-control mode installed. /// /// Deliberately NOT `!first_frame`. `first_frame` means "this frame must issue RESET + install /// CBR + quality", which is true both at open and after a mid-stream [`Encoder::reset`] — but /// `vkCmdBeginVideoCodingKHR` must declare the rate-control state the session ACTUALLY has at /// that moment, and a `reset()` does not touch the session object: the CBR installed before it /// is still current when the next frame begins its coding scope. Keying the declaration on /// `first_frame` therefore omitted it after every reset, which RADV's validation reports as /// VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253 ("no VkVideoEncodeRateControlInfoKHR ... but /// the currently set mode is CBR"). Set once the install actually happens, and only cleared by /// building a new session. rc_installed: bool, force_kf: bool, // request_keyframe / non-recoverable loss -> next frame is IDR pending_loss: Option, // invalidate_ref_frames(first) -> recover on next frame pending: VecDeque, } // SAFETY: the encoder is used only from the single encode thread; all Vulkan handles are owned and // never shared. Matches `NvencCudaEncoder`'s `unsafe impl Send`. unsafe impl Send for VulkanVideoEncoder {} impl VulkanVideoEncoder { /// Signature mirrors the other Linux backends' `open` plus `cursor_blend`: the session may /// hand this encoder cursor bitmaps to composite (cursor-as-metadata captures — every /// non-gamescope compositor). The EFC cannot blend, so such sessions default to the CSC /// path; everywhere else the RGB-direct source is the DEFAULT wherever the probe passes /// (B2). `PUNKTFUNK_VULKAN_RGB_DIRECT` overrides both ways (see [`rgb_request`]). pub fn open( codec: Codec, format: PixelFormat, width: u32, height: u32, fps: u32, bitrate_bps: u64, cursor_blend: bool, ) -> Result { let native_nv12 = format == PixelFormat::Nv12; let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend); Self::open_opts_inner( codec, width, height, fps, bitrate_bps, want_rgb, native_nv12, ) } /// `open` with the RGB-direct request explicit instead of read from the env — the smoke /// tests use this (env mutation races parallel tests). `want_rgb` engages the RGB-direct /// source only if [`probe_rgb_direct`] also passes; otherwise the session opens on the CSC /// path with the verdict logged. Test-only: the production entry point is [`Self::open`], /// which resolves `want_rgb` from the env + the `cursor_blend` hint. #[cfg(test)] pub(crate) fn open_opts( codec: Codec, width: u32, height: u32, fps: u32, bitrate_bps: u64, want_rgb: bool, ) -> Result { Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false) } fn open_opts_inner( codec: Codec, width: u32, height: u32, fps: u32, bitrate_bps: u64, want_rgb: bool, native_nv12: bool, ) -> Result { if !matches!(codec, Codec::H265 | Codec::Av1) { bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})"); } // align coded extent to the encode granularity (64x16 on RADV). HEVC crops the padding back // to (width,height) via a conformance window; AV1 signals it via render_size (see build). let w = (width + 63) & !63; let h = (height + 15) & !15; // SAFETY: `open_inner` only issues Vulkan calls whose preconditions it establishes itself // (valid instance/device, correctly-chained create-infos); all handles are freshly created // here and owned by the returned `Self`. No aliasing or outside invariants are involved. unsafe { Self::open_inner( codec, w, h, width, height, fps.max(1), bitrate_bps.max(1_000_000), want_rgb, native_nv12, ) } } #[allow(clippy::too_many_arguments)] unsafe fn open_inner( codec: Codec, w: u32, h: u32, rw: u32, rh: u32, fps: u32, bitrate: u64, want_rgb: bool, native_nv12: bool, ) -> Result { use super::vk_av1_encode as av1b; use super::vk_valve_rgb as vrgb; let av1 = codec == Codec::Av1; let codec_op = codec_op_for(av1); let entry = ash::Entry::load().context("load vulkan loader")?; let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3); let instance = entry .create_instance( &vk::InstanceCreateInfo::default().application_info(&app), None, ) .context("create instance")?; // From here on, every created object is mirrored into `guard` the moment it exists, so any // early `?`/`bail!` unwinds exactly what was built (see [`VkTeardown`]). The locals keep // aliasing the handles for the rest of the build; only the `Ok(Self)` hand-off at the // bottom disarms the guard. let mut guard = VkTeardown::new(instance.clone()); let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance); // pick the physical device + encode queue family (skip llvmpipe). The scan itself lives in // [`find_encode_device`] so the negotiation-time probe asks the SAME question this open // asks — a probe that MIRRORS a dispatch goes stale the first time the dispatch grows a // case, which is the failure `open_video`'s backend-label note already records. let (pd, encode_family) = find_encode_device(&instance, &instance.enumerate_physical_devices()?, codec_op) .context("no VK_KHR_video_encode queue for the requested codec on any device")?; let mem_props = instance.get_physical_device_memory_properties(pd); // a compute queue family for the CSC (usually family 0) + its timestamp support (the // PUNKTFUNK_PERF CSC/encode split; valid_bits==0 ⇒ no timestamps on this family) let (compute_family, compute_ts_bits) = { let qf = instance.get_physical_device_queue_family_properties(pd); let fam = qf .iter() .position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE)) .context("no compute queue")?; (fam as u32, qf[fam].timestamp_valid_bits) }; // PUNKTFUNK_PERF (same parse as punktfunk-core: "0" = off) arms the sampled CSC-vs-ASIC // split; ts_period_ns==0.0 keeps every timestamp site compiled out of the hot path. let ts_period_ns = if std::env::var("PUNKTFUNK_PERF").is_ok_and(|v| v != "0") && compute_ts_bits > 0 { instance .get_physical_device_properties(pd) .limits .timestamp_period as f64 } else { 0.0 }; // Resolve the encode source before building the profile: EFC RGB conversion changes // profile identity; producer-native NV12 uses the ordinary 4:2:0 profile. let aligned = rw == w && rh == h; let rgb_probe = if native_nv12 { Err("not-probed(native NV12 source selected)") } else { probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1) }; let rgb_cfg: Option = match (&rgb_probe, want_rgb) { (Ok((x, y)), true) => { let true_extent = !aligned && rgb_true_extent_request(); Some(RgbDirect { x_offset: *x, y_offset: *y, padded: !aligned && !true_extent, true_extent, }) } _ => None, }; if native_nv12 { tracing::info!( native_nv12 = "active(direct-import)", source_width = rw, source_height = rh, fw_padding_width = w - rw, fw_padding_height = h - rh, "vulkan-encode: producer-native NV12 encode source (true-size headers: the \ driver aligns the bitstream SPS itself and the firmware edge-extends the \ padding — the source is never read past its extent)" ); } else { tracing::info!( rgb_direct = match (&rgb_probe, want_rgb, &rgb_cfg) { ( _, _, Some(RgbDirect { true_extent: true, .. }), ) => "active(true-extent: unaligned mode, direct import with the true-size \ source codedExtent — RADV firmware padding covers the alignment rows; \ PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded copy)", (_, _, Some(RgbDirect { padded: false, .. })) => "active", (_, _, Some(RgbDirect { padded: true, .. })) => "active(padded-copy: mode is not 64x16-aligned — staging blit + edge \ duplication instead of the direct import)", (Ok(_), false, None) => "available(off: PUNKTFUNK_VULKAN_RGB_DIRECT=0, or a cursor-blend session \ — =1 forces)", (Err(e), _, None) => e, (Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"), }, "vulkan-encode: EFC RGB-direct encode source (design/vulkan-rgb-direct-encode.md)" ); } // the encode profile — H265 Main, or AV1 Main; chained raw and uniformly (vendored AV1 + // rgb structs can't `push_next`, and the chain must match [`RgbProfileStack::wire`]'s // exactly when rgb is active — profile identity is by value): // profile → codec profile → usage (→ rgb-conversion when active). let rgb_info = vrgb::VideoEncodeProfileRgbConversionInfoVALVE { s_type: vrgb::stype(vrgb::ST_PROFILE_INFO), p_next: std::ptr::null(), perform_encode_rgb_conversion: vk::TRUE, }; let mut h265_profile = vk::VideoEncodeH265ProfileInfoKHR::default() .std_profile_idc(vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN); let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR { s_type: av1b::stype(av1b::ST_PROFILE_INFO), p_next: std::ptr::null(), std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN, }; let mut usage = vk::VideoEncodeUsageInfoKHR::default() .video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING) .video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED) .tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY); if rgb_cfg.is_some() { usage.p_next = &rgb_info as *const _ as *const c_void; } let mut profile = vk::VideoProfileInfoKHR::default() .video_codec_operation(codec_op) .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8); if av1 { av1_profile.p_next = &usage as *const _ as *const c_void; profile.p_next = &av1_profile as *const _ as *const c_void; } else { h265_profile.p_next = &usage as *const _ as *const c_void; profile.p_next = &h265_profile as *const _ as *const c_void; } // capabilities (codec chain required for encode) -> std header version, coded alignment, RC modes let mut h265_caps = vk::VideoEncodeH265CapabilitiesKHR::default(); let mut av1_caps: av1b::VideoEncodeAV1CapabilitiesKHR = std::mem::zeroed(); av1_caps.s_type = av1b::stype(av1b::ST_CAPABILITIES); let mut enc_caps = vk::VideoEncodeCapabilitiesKHR::default(); let mut caps = vk::VideoCapabilitiesKHR::default().push_next(&mut enc_caps); if av1 { av1_caps.p_next = caps.p_next; caps.p_next = &mut av1_caps as *mut _ as *mut c_void; } else { caps = caps.push_next(&mut h265_caps); } let r = (vq_inst.fp().get_physical_device_video_capabilities_khr)(pd, &profile, &mut caps); if r != vk::Result::SUCCESS { bail!("get_physical_device_video_capabilities: {r:?}"); } // Copy every needed capability out now — `caps` holds `&mut` borrows of the chained // codec/encode caps structs, so no field of those may be touched while it lives. let std_hdr = caps.std_header_version; let min_bs_align = caps.min_bitstream_buffer_size_alignment.max(1); let max_quality_levels = enc_caps.max_quality_levels; let rate_control_modes = enc_caps.rate_control_modes; let hw_max_bitrate = match enc_caps.max_bitrate { 0 => u64::MAX, // not filled in — treat as unbounded n => n, }; let av1_superblock128 = av1 && (av1_caps.superblock_sizes & av1b::SUPERBLOCK_SIZE_128 != 0); // Resolve the encode quality level against the profile's cap (spec: valid levels are // 0..maxQualityLevels, ordered fastest→best; maxQualityLevels >= 1 always). INFO so field // logs show which VCN preset tier a session ran — the default is the fastest one. let quality_level = quality_request().min(max_quality_levels.saturating_sub(1)); tracing::info!( quality_level, max_quality_levels, "vulkan-encode: quality level (0 = fastest preset; PUNKTFUNK_VULKAN_QUALITY overrides)" ); // WP6.3: rate-control mode + HRD window, from the capability bits this query used to // ignore. VBR — always with average == max, i.e. capped at the target — is what stops // CBR's bit-stuffing: a calm CBR stream overflows the CPB once the initial fill drains // and the driver must then pad EVERY frame to the exact rate share, forever (measured on // the 780M: 97.5 % HEVC / 99.6 % AV1 filler over a calm 64-frame run — under the shipped // 1000 ms window, not just a tight one; the earlier "loose CBR = no filler" reading came // from an 8-frame smoke shorter than the fill-drain time). Vulkan exposes no // filler-suppression control, so the MODE is the only lever. The tight house window // rides along as the driver's RC input, but measured on RADV it does NOT bound complex // frames (burst A/B byte-identical against 1000 ms CBR — on this silicon the window had // no measurable effect on frame sizes; the MODE alone decided the stuffing), so no // pacing claim is made for it. A CBR-only driver keeps // the legacy loose (1000 ms, 500 ms) window on purpose: tightening it under CBR just // starts the stuffing ~30 frames earlier. let vbr_advertised = rate_control_modes.contains(vk::VideoEncodeRateControlModeFlagsKHR::VBR); if !vbr_advertised && !rate_control_modes.contains(vk::VideoEncodeRateControlModeFlagsKHR::CBR) { // Every real encode driver advertises CBR and/or VBR; one with neither would need // the DEFAULT-mode no-layer shape this backend does not speak. Keep CBR (the // pre-WP6.3 behaviour) but say so — this WARN firing names a driver worth knowing. tracing::warn!( modes = rate_control_modes.as_raw(), "vulkan-encode: driver advertises neither CBR nor VBR — installing CBR anyway \ (pre-existing behaviour; may fail validation on this driver)" ); } // `PUNKTFUNK_VULKAN_RC=cbr|vbr`: field escape hatch + on-box A/B control (two prior // versions of this rate-control shape were withdrawn after review — a rebuild-free // fallback is cheap insurance). `vbr` is a request, honoured only when advertised; // anything else means auto. let vbr = match std::env::var("PUNKTFUNK_VULKAN_RC") .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "cbr" => false, _ => vbr_advertised, }; let (rc_mode, vbv_ms) = if vbr { ( vk::VideoEncodeRateControlModeFlagsKHR::VBR, crate::vbv_window_ms(fps), ) } else { ( vk::VideoEncodeRateControlModeFlagsKHR::CBR, (1000u32, 500u32), ) }; // INFO because the mode and window used to be hardwired constants — a field log has to be // able to say which shape a session actually ran. tracing::info!( rc_mode = if vbr { "VBR (capped at target)" } else { "CBR" }, vbv_window_ms = vbv_ms.0, vbv_initial_ms = vbv_ms.1, fps, hw_max_bitrate, "vulkan-encode: rate control (VBR when the driver offers it — CBR must stuff filler \ on calm content; PUNKTFUNK_VULKAN_RC overrides, PUNKTFUNK_VBV_FRAMES scales the VBR \ window)" ); let bitrate = if bitrate > hw_max_bitrate { tracing::warn!( requested = bitrate, cap = hw_max_bitrate, "vulkan-encode: requested bitrate exceeds the driver's maxBitrate — clamping" ); hw_max_bitrate } else { bitrate }; // logical device: encode + compute queues + video extensions (AV1 ext name is raw — ash lacks it) let mut dev_exts = vec![ ash::khr::video_queue::NAME.as_ptr(), ash::khr::video_encode_queue::NAME.as_ptr(), if av1 { av1b::EXTENSION_NAME.as_ptr() } else { ash::khr::video_encode_h265::NAME.as_ptr() }, ash::khr::external_memory_fd::NAME.as_ptr(), ash::ext::external_memory_dma_buf::NAME.as_ptr(), ash::ext::image_drm_format_modifier::NAME.as_ptr(), ]; if rgb_cfg.is_some() { dev_exts.push(vrgb::EXTENSION_NAME.as_ptr()); } let prio = [1.0f32]; let mut qcis = vec![vk::DeviceQueueCreateInfo::default() .queue_family_index(encode_family) .queue_priorities(&prio)]; if compute_family != encode_family { qcis.push( vk::DeviceQueueCreateInfo::default() .queue_family_index(compute_family) .queue_priorities(&prio), ); } let mut sync2 = vk::PhysicalDeviceSynchronization2Features::default().synchronization2(true); let mut device_ci = vk::DeviceCreateInfo::default() .queue_create_infos(&qcis) .enabled_extension_names(&dev_exts) .push_next(&mut sync2); // The AV1-encode feature gate: `videoEncodeAV1` must be enabled for any ENCODE_AV1 use // (spec requirement; vendored struct since ash 0.38 predates it — chained raw like the // profile above). let mut av1_features = av1b::PhysicalDeviceVideoEncodeAV1FeaturesKHR { s_type: av1b::stype(av1b::ST_PHYSICAL_DEVICE_FEATURES), p_next: std::ptr::null_mut(), video_encode_av1: vk::TRUE, }; if av1 { av1_features.p_next = device_ci.p_next as *mut c_void; device_ci.p_next = &av1_features as *const _ as *const c_void; } // The rgb-conversion feature gate (spec: must be enabled to chain the rgb profile). let mut rgb_features = vrgb::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE { s_type: vrgb::stype(vrgb::ST_PHYSICAL_DEVICE_FEATURES), p_next: std::ptr::null_mut(), video_encode_rgb_conversion: vk::TRUE, }; if rgb_cfg.is_some() { rgb_features.p_next = device_ci.p_next as *mut c_void; device_ci.p_next = &rgb_features as *const _ as *const c_void; } let device = instance .create_device(pd, &device_ci, None) .context("create device")?; let encode_queue = device.get_device_queue(encode_family, 0); let compute_queue = device.get_device_queue(compute_family, 0); let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device); let vq_dev = ash::khr::video_queue::Device::new(&instance, &device); let venc_dev = ash::khr::video_encode_queue::Device::new(&instance, &device); guard.device = Some(device.clone()); guard.vq_dev = Some(vq_dev.clone()); // ---- video session ---- (AV1 pins the max level from caps via a chained create-info) let av1_sci = av1b::VideoEncodeAV1SessionCreateInfoKHR { s_type: av1b::stype(av1b::ST_SESSION_CREATE_INFO), p_next: std::ptr::null(), use_max_level: vk::TRUE, max_level: av1_caps.max_level, }; // RGB-direct: the session's picture (encode-src) format is the captured RGB itself; the // chained create-info selects the conversion EFC performs. The DPB stays NV12 — the // reconstruction is YUV either way. Built unconditionally (function scope outlives the // create call); chained only when active. let mut rgb_sci = vrgb::VideoEncodeSessionRgbConversionCreateInfoVALVE { s_type: vrgb::stype(vrgb::ST_SESSION_CREATE_INFO), p_next: std::ptr::null(), rgb_model: vrgb::MODEL_YCBCR_709, rgb_range: vrgb::RANGE_NARROW, x_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.x_offset), y_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.y_offset), }; let mut session_ci = vk::VideoSessionCreateInfoKHR::default() .queue_family_index(encode_family) .video_profile(&profile) .picture_format(if rgb_cfg.is_some() { vk::Format::B8G8R8A8_UNORM } else { NV12 }) .max_coded_extent(vk::Extent2D { width: w, height: h, }) .reference_picture_format(NV12) .max_dpb_slots(DPB_SLOTS + 1) .max_active_reference_pictures(1) .std_header_version(&std_hdr); if av1 { session_ci.p_next = &av1_sci as *const _ as *const c_void; } if rgb_cfg.is_some() { // chain ahead of whatever is already there (the AV1 create-info keeps its place) rgb_sci.p_next = session_ci.p_next; session_ci.p_next = &rgb_sci as *const _ as *const c_void; } let mut session = vk::VideoSessionKHR::null(); let r = (vq_dev.fp().create_video_session_khr)( device.handle(), &session_ci, std::ptr::null(), &mut session, ); if r != vk::Result::SUCCESS { bail!("create_video_session: {r:?}"); } guard.session = session; // bind session memory let get_mem = vq_dev.fp().get_video_session_memory_requirements_khr; let mut n = 0u32; let _ = get_mem(device.handle(), session, &mut n, std::ptr::null_mut()); let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); n as usize]; let _ = get_mem(device.handle(), session, &mut n, reqs.as_mut_ptr()); let mut binds = Vec::new(); for rq in &reqs { let mr = rq.memory_requirements; let ti = find_mem( &mem_props, mr.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL, ); let m = device.allocate_memory( &vk::MemoryAllocateInfo::default() .allocation_size(mr.size) .memory_type_index(ti), None, )?; guard.session_mem.push(m); binds.push( vk::BindVideoSessionMemoryInfoKHR::default() .memory_bind_index(rq.memory_bind_index) .memory(m) .memory_offset(0) .memory_size(mr.size), ); } let r = (vq_dev.fp().bind_video_session_memory_khr)( device.handle(), session, binds.len() as u32, binds.as_ptr(), ); if r != vk::Result::SUCCESS { bail!("bind_video_session_memory: {r:?}"); } // ---- session parameters + header framing (HEVC: VPS/SPS/PPS on keyframes; AV1: a // temporal-delimiter OBU per frame + a sequence-header OBU on keyframes) ---- // Native NV12 authors TRUE-SIZE headers (SPS/seq at the render size, no app-side // conformance window): RADV rounds the bitstream SPS up itself and, keyed off the // matching true-size codedExtent, programs the VCN with firmware padding so the source // is never read past its extent. The CSC/RGB paths keep the app-aligned convention // (their sources genuinely cover the aligned extent). // AV1 + RGB true-extent joins the true-size camp for the same reason: true-extent shrinks // `srcPictureResource.codedExtent` to the render size, and AV1 forbids the sequence header // (`flags-10324`) or the reference slots (`flags-10325`) disagreeing with the source unless // the GPU advertises FRAME_SIZE_OVERRIDE / MOTION_VECTOR_SCALING — RADV PHOENIX advertises // neither. Rather than give up the EFC fast path, make all three agree at the RENDER size: // the coded frame genuinely IS the visible size (an unpadded coded size is valid on this // hardware), so nothing codes alignment padding at all and the reference slots follow the // source extent below. // // HEVC deliberately stays on the app-aligned convention: it has no equivalent constraint // (its crop rides the conformance window) and that path is the validated one. let (hdr_w, hdr_h) = if native_nv12 || (av1 && rgb_cfg.as_ref().is_some_and(|c| c.true_extent)) { (rw, rh) } else { (w, h) }; let (params, header, frame_prefix) = if av1 { build_parameters_av1( &device, &vq_dev, session, hdr_w, hdr_h, rw, rh, av1_caps.max_level, av1_superblock128, quality_level, )? } else { let (p, hdr) = build_parameters_h265( &device, &vq_dev, &venc_dev, session, hdr_w, hdr_h, rw, rh, quality_level, )?; (p, hdr, Vec::new()) }; guard.params = params; // ---- DPB image (NV12 OPTIMAL, ring of slots) — encode queue only ---- let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(&profile)); let (dpb_image, dpb_mem) = make_video_image( &device, &mem_props, NV12, w, h, DPB_SLOTS, vk::ImageUsageFlags::VIDEO_ENCODE_DPB_KHR, &mut profile_list, &[], )?; guard.dpb_image = dpb_image; guard.dpb_mem = dpb_mem; for slot in 0..DPB_SLOTS { guard .dpb_views .push(make_view(&device, dpb_image, NV12, slot)?); } // NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per // in-flight frame (built in `make_frame` below); only the queue-family list is shared here. let fams = if compute_family == encode_family { vec![] } else { vec![compute_family, encode_family] }; // ---- CSC compute pipeline (shared across the frame ring) ---- let sampler = device.create_sampler( &vk::SamplerCreateInfo::default() .mag_filter(vk::Filter::NEAREST) .min_filter(vk::Filter::NEAREST) .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE) .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE), None, )?; guard.sampler = sampler; let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?; let shader = device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?; guard.shader = shader; let sb = |b: u32, t: vk::DescriptorType| { vk::DescriptorSetLayoutBinding::default() .binding(b) .descriptor_type(t) .descriptor_count(1) .stage_flags(vk::ShaderStageFlags::COMPUTE) }; let bindings = [ sb(0, vk::DescriptorType::COMBINED_IMAGE_SAMPLER), sb(1, vk::DescriptorType::STORAGE_IMAGE), sb(2, vk::DescriptorType::STORAGE_IMAGE), sb(3, vk::DescriptorType::COMBINED_IMAGE_SAMPLER), // cursor overlay ]; let csc_dsl = device.create_descriptor_set_layout( &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings), None, )?; guard.csc_dsl = csc_dsl; let dsls = [csc_dsl]; // Push constant: cursor {ivec2 origin, ivec2 size} = 16 bytes (size.x<=0 disables the blend). let pc_ranges = [vk::PushConstantRange::default() .stage_flags(vk::ShaderStageFlags::COMPUTE) .offset(0) .size(16)]; let csc_layout = device.create_pipeline_layout( &vk::PipelineLayoutCreateInfo::default() .set_layouts(&dsls) .push_constant_ranges(&pc_ranges), None, )?; guard.csc_layout = csc_layout; let stage = vk::PipelineShaderStageCreateInfo::default() .stage(vk::ShaderStageFlags::COMPUTE) .module(shader) .name(c"main"); let csc_pipe = device .create_compute_pipelines( vk::PipelineCache::null(), &[vk::ComputePipelineCreateInfo::default() .layout(csc_layout) .stage(stage)], None, ) .map_err(|(_, e)| e)?[0]; guard.csc_pipe = csc_pipe; device.destroy_shader_module(shader, None); // The shader is gone — null the guard's copy so a later failure doesn't unwind it again. guard.shader = vk::ShaderModule::null(); // One CSC descriptor set + its own Y/UV/NV12/bitstream per in-flight frame. let nframes = ring_depth(); let pool_sizes = [ vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) // binding 0 (RGB) + binding 3 (cursor) per set. .descriptor_count(2 * nframes as u32), vk::DescriptorPoolSize::default() .ty(vk::DescriptorType::STORAGE_IMAGE) .descriptor_count(2 * nframes as u32), ]; let csc_pool = device.create_descriptor_pool( &vk::DescriptorPoolCreateInfo::default() .max_sets(nframes as u32) .pool_sizes(&pool_sizes), None, )?; guard.csc_pool = csc_pool; // ---- bitstream size (shared) + shared command pools ---- let bs_size = align_up(3 * w as u64 * h as u64 + (1 << 16), min_bs_align); let cmd_pool = device.create_command_pool( &vk::CommandPoolCreateInfo::default() .queue_family_index(encode_family) .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER), None, )?; guard.cmd_pool = cmd_pool; let compute_pool = device.create_command_pool( &vk::CommandPoolCreateInfo::default() .queue_family_index(compute_family) .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER), None, )?; guard.compute_pool = compute_pool; // ---- build the in-flight frame ring ---- for _ in 0..nframes { // Pre-push a null Frame and build it in place, so a mid-`make_frame` failure leaves // the partial handles in the guard rather than losing them with the Err. guard.frames.push(Frame::default()); make_frame( &device, &mem_props, w, h, &fams, &profile, &mut profile_list, csc_dsl, csc_pool, cmd_pool, compute_pool, bs_size, sampler, ts_period_ns > 0.0 && ((rgb_cfg.is_none() && !native_nv12) || rgb_cfg.as_ref().is_some_and(|c| c.padded)), rgb_cfg.is_none() && !native_nv12, rgb_cfg .as_ref() .is_some_and(|c| c.padded) .then_some(vk::Format::B8G8R8A8_UNORM), guard.frames.last_mut().expect("frame just pushed"), )?; } // Fully constructed: move the built collections out and disarm the guard — from here every // handle is owned by `Self`, whose own `Drop` is the (only) teardown path. let session_mem = std::mem::take(&mut guard.session_mem); let dpb_views = std::mem::take(&mut guard.dpb_views); let frames = std::mem::take(&mut guard.frames); std::mem::forget(guard); Ok(Self { _entry: entry, instance, device, ext_fd, vq_dev, venc_dev, encode_queue, compute_queue, encode_family, compute_family, mem_props, codec, session, session_mem, params, header, frame_prefix, dpb_image, dpb_mem, dpb_views, slot_wire: vec![-1; DPB_SLOTS as usize], slot_poc: vec![-1; DPB_SLOTS as usize], prev_slot: 0, csc_pipe, csc_layout, csc_dsl, csc_pool, sampler, import_cache: Vec::new(), frames, ring: 0, in_flight: VecDeque::new(), bs_size, cmd_pool, compute_pool, bitrate, fps, rc_mode, vbv_ms, hw_max_bitrate, quality_level, ts_period_ns, perf_at: std::time::Instant::now(), cpu_expand: Vec::new(), rgb: rgb_cfg, native_nv12, warned_cursor: false, pending_bitrate: None, width: w, height: h, render_w: rw, render_h: rh, poc: 0, enc_count: 0, auto_wire: 0, first_frame: true, // No rate control installed on a brand-new session: its mode is DEFAULT until the // first frame's control command, so frame 0 must NOT declare one at begin-coding. rc_installed: false, force_kf: false, pending_loss: None, pending: VecDeque::new(), }) } } impl VulkanVideoEncoder { /// Point a slot's CSC descriptor binding 0 at the current frame's RGB image view. unsafe fn bind_rgb(&self, csc_set: vk::DescriptorSet, rgb_view: vk::ImageView) { let ii0 = [vk::DescriptorImageInfo::default() .sampler(self.sampler) .image_view(rgb_view) .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)]; self.device.update_descriptor_sets( &[vk::WriteDescriptorSet::default() .dst_set(csc_set) .dst_binding(0) .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) .image_info(&ii0)], &[], ); } /// Cursor-as-metadata: bring slot `slot`'s cursor image up to date for this frame and return the /// shader push constant `[origin_x, origin_y, size_w, size_h]` (size 0 ⇒ the CSC skips the blend). /// Records the small upload (only when the bitmap `serial` changed) + layout transition into /// `compute_cmd`, ahead of the CSC dispatch that samples binding 3. Per-slot, so no cross-frame /// race; the first use of a slot always transitions the image to a valid SHADER_READ_ONLY layout. unsafe fn prep_cursor( &mut self, slot: usize, compute_cmd: vk::CommandBuffer, cursor: Option<&pf_frame::CursorOverlay>, ) -> Result<[i32; 4]> { let dev = self.device.clone(); let img = self.frames[slot].cursor_img; let ready = self.frames[slot].cursor_ready; let barrier = |old: vk::ImageLayout, new: vk::ImageLayout, ss, sa, ds, da| { vk::ImageMemoryBarrier2::default() .src_stage_mask(ss) .src_access_mask(sa) .dst_stage_mask(ds) .dst_access_mask(da) .old_layout(old) .new_layout(new) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)) }; match cursor { Some(c) if !c.rgba.is_empty() => { let cw = c.w.min(CURSOR_MAX); let ch = c.h.min(CURSOR_MAX); if self.frames[slot].cursor_serial != c.serial { let stage = self.frames[slot].cursor_stage; let stage_mem = self.frames[slot].cursor_stage_mem; let bytes = (cw as usize) * (ch as usize) * 4; let ptr = dev.map_memory(stage_mem, 0, bytes as u64, vk::MemoryMapFlags::empty())?; std::ptr::copy_nonoverlapping( c.rgba.as_ptr(), ptr as *mut u8, bytes.min(c.rgba.len()), ); dev.unmap_memory(stage_mem); let old = if ready { vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL } else { vk::ImageLayout::UNDEFINED }; dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[barrier( old, vk::ImageLayout::TRANSFER_DST_OPTIMAL, vk::PipelineStageFlags2::NONE, vk::AccessFlags2::NONE, vk::PipelineStageFlags2::ALL_TRANSFER, vk::AccessFlags2::TRANSFER_WRITE, )]), ); dev.cmd_copy_buffer_to_image( compute_cmd, stage, img, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[vk::BufferImageCopy::default() .image_subresource( vk::ImageSubresourceLayers::default() .aspect_mask(vk::ImageAspectFlags::COLOR) .layer_count(1), ) .image_extent(vk::Extent3D { width: cw, height: ch, depth: 1, })], ); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[barrier( vk::ImageLayout::TRANSFER_DST_OPTIMAL, vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, vk::PipelineStageFlags2::ALL_TRANSFER, vk::AccessFlags2::TRANSFER_WRITE, vk::PipelineStageFlags2::COMPUTE_SHADER, vk::AccessFlags2::SHADER_READ, )]), ); self.frames[slot].cursor_serial = c.serial; self.frames[slot].cursor_ready = true; } Ok([c.x, c.y, cw as i32, ch as i32]) } _ => { if !ready { // No cursor uploaded yet — transition UNDEFINED→READ_ONLY once so binding 3 is a // valid layout for the (guarded, never-sampled) shader read. dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[barrier( vk::ImageLayout::UNDEFINED, vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, vk::PipelineStageFlags2::NONE, vk::AccessFlags2::NONE, vk::PipelineStageFlags2::COMPUTE_SHADER, vk::AccessFlags2::SHADER_READ, )]), ); self.frames[slot].cursor_ready = true; } Ok([0, 0, 0, 0]) } } } /// Import a DMA-BUF VkImage with usage/profile matching this session's source mode. Native /// NV12 and aligned RGB-direct are profiled `VIDEO_ENCODE_SRC` images. Padded RGB-direct /// imports the producer allocation as transfer-source only. unsafe fn import_dmabuf( &self, d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { if self.native_nv12 { let mut ps = NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1)); let profile = *ps.wire(self.codec == Codec::Av1); let arr = [profile]; let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr); return super::vk_util::import_rgb_dmabuf_as( &self.device, &self.ext_fd, &self.mem_props, d, cw, ch, vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR, Some(&mut plist), ); } if self.rgb.as_ref().is_some_and(|r| r.padded) { // Padded-copy mode: the import is only ever a transfer SOURCE (the blit into the // aligned staging image) — plain TRANSFER_SRC, no video profile involved. super::vk_util::import_rgb_dmabuf_as( &self.device, &self.ext_fd, &self.mem_props, d, cw, ch, vk::ImageUsageFlags::TRANSFER_SRC, None, ) } else if self.rgb.is_some() { let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1)); let profile = *ps.wire(self.codec == Codec::Av1); let arr = [profile]; let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr); super::vk_util::import_rgb_dmabuf_as( &self.device, &self.ext_fd, &self.mem_props, d, cw, ch, vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR, Some(&mut plist), ) } else { 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 /// (PipeWire cycles a small fixed pool). Keyed by `(st_dev, st_ino)` because each `DmabufFrame` /// owns a fresh *dup* — a new fd number, same inode. Returns `(image, view, fresh)`; `fresh` is /// true only on a first import (caller uses UNDEFINED old-layout to preserve modifier-tiled data). unsafe fn import_cached( &mut self, d: &pf_frame::DmabufFrame, cw: u32, ch: u32, ) -> Result<(vk::Image, vk::ImageView, bool)> { let mut st: libc::stat = std::mem::zeroed(); let key = if libc::fstat(d.fd.as_raw_fd(), &mut st) == 0 { (st.st_dev as u64, st.st_ino as u64) } else { // fstat failed → uncacheable; a per-frame-unique sentinel key never matches, so this // frame imports fresh (as before) but is still owned by the cache and freed on evict/Drop. (u64::MAX, self.enc_count) }; if let Some(pos) = self.import_cache.iter().position(|e| e.key == key) { let e = &self.import_cache[pos]; if e.extent == (cw, ch) { return Ok((e.img, e.view, false)); } // Key hit, wrong extent: the (st_dev, st_ino) now names a different allocation than // the one imported. Unreachable while every submit path guards frame-vs-session // dimensions first — but the cache must never hand out a stale-sized image if a // future path forgets, so evict and fall through to a fresh import. In-flight frames // may still read the old image (same hazard as the FIFO eviction below), so idle the // device before destroying. At most once per reallocation. let _ = self.device.device_wait_idle(); let e = self.import_cache.remove(pos); self.device.destroy_image_view(e.view, None); self.device.destroy_image(e.img, None); self.device.free_memory(e.mem, None); } // Feed pf-zerocopy's raw-dmabuf degrade latch (wired for the libav path in 3efbe416): // a deterministic import refusal repeats identically forever, and the latch — capture // negotiates CPU delivery from the next session — is its only recovery; the CPU path // serves every format capture produces (`normalize_cpu_rgb`). Excluded: transient // allocation OOM (`import_failure_feeds_latch`), and native-NV12 sessions entirely — an // NV12-layout quirk should cost the NV12 preference, not ALL dmabuf capture, and that // narrower response doesn't exist yet (noted in the phase-5 handoff as follow-up). let (img, mem, view) = match self.import_dmabuf(d, cw, ch) { Ok(t) => { if !self.native_nv12 { pf_zerocopy::note_raw_dmabuf_import_ok(); } t } Err(e) => { if !self.native_nv12 && import_failure_feeds_latch(&e) { pf_zerocopy::note_raw_dmabuf_import_failure(&format!("{e:#}")); } return Err(e); } }; // Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state // (all imports resident); it only cycles across a pool change (which also rebuilds the session). // Up to `ring_depth - 1` submitted frames may still be executing against a cached image // (`enqueue` only drains down to `frames.len()`, and `record_submit` imports before it // records), so destroying an evicted import here is a GPU-side use-after-free. `Drop` and // `reset()` both idle the device first; this was the one unguarded destroy. Guarded on the // length test so the steady-state path — where the cache is resident and never evicts — // pays nothing. if self.import_cache.len() >= IMPORT_CACHE_CAP { let _ = self.device.device_wait_idle(); } while self.import_cache.len() >= IMPORT_CACHE_CAP { let e = self.import_cache.remove(0); self.device.destroy_image_view(e.view, None); self.device.destroy_image(e.img, None); self.device.free_memory(e.mem, None); } self.import_cache.push(CachedImport { key, extent: (cw, ch), img, mem, view, }); // Fires once per distinct pool buffer then goes quiet in steady state — the signal the cache // is hitting (a per-frame log here would mean inode keying failed and we're re-importing). tracing::debug!( resident = self.import_cache.len(), "vulkan-encode: imported a new dmabuf buffer" ); Ok((img, view, true)) } /// Reusable RGB image + staging buffer for software (CPU) capture, private to one frame slot; /// (re)created on format change. Only the software-capture / smoke-test path exercises this. unsafe fn ensure_cpu_rgb( &mut self, slot: usize, fmt: vk::Format, bytes: &[u8], src_w: u32, src_h: u32, ) -> Result { let dev = self.device.clone(); let (w, h) = (self.width, self.height); // CSC mode: the image is the SHADER'S sampling source — size it to the real frame so // the clamp-to-edge duplicates true content (the aligned-size image made the clamp land // on unwritten rows: black garbage at unaligned modes, the pre-B2 smoke-baseline bug). // RGB mode: the image IS the encode source — aligned dims, CPU-side padding below. let (iw, ih) = if self.rgb.is_some() { (w, h) } else { (src_w, src_h) }; // Widened BEFORE the multiply, not after: `(iw * ih * 4) as u64` multiplied in u32 and // widened the result, so it agreed with the usize slice length below only while // `iw * ih <= 2^30`. The 8192 dimension cap leaves 16x of margin, but the padded-write arm // now sizes a raw-pointer slice from these numbers, and `read_slot` already applies exactly // this discipline to driver-reported sizes for exactly this reason. let need = iw as u64 * ih as u64 * 4; if self.frames[slot] .cpu_img .map(|(_, _, _, f, iw, ih)| (f, iw, ih)) != Some((fmt, iw, ih)) { if let Some((i, m, v, ..)) = self.frames[slot].cpu_img.take() { dev.destroy_image_view(v, None); dev.destroy_image(i, None); dev.free_memory(m, None); } let (i, m, v) = if self.rgb.is_some() { // RGB-direct: the uploaded RGB image IS the encode source — profiled, encode // usage, and shared with the encode queue (the compute queue only copies into // it; the semaphore orders the hand-off, CONCURRENT avoids a QFOT). let av1 = self.codec == Codec::Av1; let mut ps = RgbProfileStack::new(codec_op_for(av1)); let profile = *ps.wire(av1); let arr = [profile]; let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr); let fams: &[u32] = if self.encode_family == self.compute_family { &[] } else { &[self.encode_family, self.compute_family] }; let fams = fams.to_vec(); let (i, m) = make_video_image( &dev, &self.mem_props, fmt, w, h, 1, vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST, &mut plist, &fams, )?; // `make_video_image` unwinds internally, but a view failure here leaked the // image+memory pair it had just handed over. let v = match make_view(&dev, i, fmt, 0) { Ok(v) => v, Err(e) => { dev.destroy_image(i, None); dev.free_memory(m, None); return Err(e); } }; (i, m, v) } else { make_plain_image( &dev, &self.mem_props, fmt, iw, ih, vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST, )? }; self.frames[slot].cpu_img = Some((i, m, v, fmt, iw, ih)); } if self.frames[slot] .cpu_stage .map(|(_, _, s)| s < need) .unwrap_or(true) { if let Some((b, m, _)) = self.frames[slot].cpu_stage.take() { dev.destroy_buffer(b, None); dev.free_memory(m, None); } let (buf, mem) = make_host_buffer( &dev, &self.mem_props, need, vk::BufferUsageFlags::TRANSFER_SRC, )?; self.frames[slot].cpu_stage = Some((buf, mem, need)); } let (_, m, _) = self.frames[slot].cpu_stage.unwrap(); // RGB-direct sessions upload the image the ENCODER reads directly, so an undersized // source (unaligned mode) must be padded — rows/columns duplicated from the edge, matching // the CSC shader's clamped reads (and `record_pad_blit`'s GPU-side equivalent). The CSC // path keeps the raw copy: its shader clamps at sample time, and it DELIBERATELY supports a // source smaller than the encode extent — so none of the guards below may be hoisted out of // this branch, they are only sound because `self.rgb.is_some()`. // // Every fallible step stays ABOVE the map: an error raised between `map_memory` and // `unmap_memory` would strand the mapping for the life of this slot's staging buffer. let pad = if self.rgb.is_some() && (src_w != w || src_h != h) { let (sw, sh) = (src_w as usize, src_h as usize); // A zero axis makes `sh - 1` below underflow. It cannot arrive from a real capturer, // but refusing it by name is what lets the padding write be infallible under the map. if src_w == 0 || src_h == 0 { bail!("vulkan-encode (rgb-direct): CPU frame has a zero axis ({src_w}x{src_h})"); } // Extent FIRST, deliberately: the payload-length check below multiplies `sw * sh * 4` // in usize on caller-supplied dimensions, so bounding them against the encode extent // first is what keeps that multiply from overflowing on a garbage frame header. // The padding write below only ever GROWS the source into the aligned extent. A source // larger than the encode extent is a contract violation (the Dmabuf arms already bail // on it) and would panic on the row `copy_from_slice`, so refuse it by name instead of // unwinding out of the encode thread with an index message. if src_w > w || src_h > h { bail!( "vulkan-encode (rgb-direct): CPU frame {}x{} exceeds the encode extent {}x{} \ — the RGB-direct source must fit inside the aligned encode size", src_w, src_h, w, h ); } if bytes.len() < sw * sh * 4 { bail!( "vulkan-encode (rgb-direct): CPU frame {}x{} needs {} bytes, got {}", src_w, src_h, sw * sh * 4, bytes.len() ); } // The `unsafe` slice below is sized from `(w, h)` while the staging buffer is sized // from `need`. That equality is the precondition of the whole write, so CHECK it — // here, above the map, in release. It was a `debug_assert!` below the map, which was // wrong twice over: it made the one guard on the slice length vanish in the builds that // ship, and a fired assert would have unwound past `unmap_memory` and stranded the // mapping for the life of the slot (the next frame's `map_memory` then violates // VUID-vkMapMemory-memory-00678). Unreachable under the 8192 dimension cap either way — // but "every fallible step stays above the map" has to be true, not nearly true. let dst_len = (w as usize) .checked_mul(h as usize) .and_then(|n| n.checked_mul(4)) .filter(|&n| n as u64 == need) .context("vulkan-encode (rgb-direct): staging buffer is not the encode extent")?; Some((sw, sh, dst_len)) } else { None }; let p = dev.map_memory(m, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *mut u8; match pad { // WP6.2(a): build the padded frame STRAIGHT into the staging memory. This used to // allocate and zero-fill a whole padded frame every submit (8 MB at 1080p — 4K UHD is // 64x16-aligned and never reaches this branch; the big case is an ultrawide like // 3440x1440 -> 3456x1440, ~20 MB) and then memcpy it in here — with the zero-fill // entirely dead, because the loop writes every byte of every row. Now: no per-frame // allocation, no zero-fill, no page-fault storm over a fresh mapping, and one // full-frame copy instead of two. Nothing reads back from `dst`, so writing into // (possibly write-combined) host memory costs nothing extra — the row source is always // `bytes`, never the destination. Some((sw, sh, dst_len)) => { let (dw, dh) = (w as usize, h as usize); // SAFETY: `dst_len == dw * dh * 4 == need`, checked above the map, and the staging // buffer is only kept when its size is >= `need`, so the mapping covers this slice. // `make_host_buffer` allocates HOST_COHERENT memory, so the writes need no explicit // flush before the transfer submitted below reads them. The guards above make every // index here in-bounds by construction. let dst = std::slice::from_raw_parts_mut(p, dst_len); for y in 0..dh { let sy = y.min(sh - 1); let srow = &bytes[sy * sw * 4..][..sw * 4]; let drow = &mut dst[y * dw * 4..][..dw * 4]; drow[..sw * 4].copy_from_slice(srow); let mut last = [0u8; 4]; last.copy_from_slice(&srow[(sw - 1) * 4..]); for x in sw..dw { drow[x * 4..(x + 1) * 4].copy_from_slice(&last); } } } None => { let n = bytes.len().min(need as usize); std::ptr::copy_nonoverlapping(bytes.as_ptr(), p, n); } } dev.unmap_memory(m); Ok(self.frames[slot].cpu_img.unwrap().2) } /// Record one frame's CSC + HEVC encode (+ RFI) into ring `slot` and submit it WITHOUT waiting. /// The slot's fence is polled later (`read_slot`) so this frame's GPU work overlaps the next /// capture. `slot` must be free (its prior submission already read back). unsafe fn record_submit( &mut self, slot: usize, frame: &CapturedFrame, wire: i64, ) -> Result<()> { let (w, h_px) = (self.width, self.height); // Copy this slot's Vulkan handles into locals (all `vk::*` handles are Copy) so the rest of // the function can still borrow `&mut self` for the import/CSC helpers without aliasing // `self.frames`. Shared objects (csc_pipe/layout, dpb, session) stay on `self`. let compute_cmd = self.frames[slot].compute_cmd; let cmd = self.frames[slot].cmd; let csc_sem = self.frames[slot].csc_sem; let fence = self.frames[slot].fence; let query_pool = self.frames[slot].query_pool; let ts_pool = self.frames[slot].ts_pool; let bs_buf = self.frames[slot].bs_buf; let csc_set = self.frames[slot].csc_set; let y_img = self.frames[slot].y_img; let uv_img = self.frames[slot].uv_img; let nv12_src = self.frames[slot].nv12_src; let nv12_view = self.frames[slot].nv12_view; // ---- 1. decide frame type + reference (RFI) ---- // A pending rate retarget (`reconfigure_bitrate`) stays PENDING through recording on // every path: the record fns declare the session's current state at begin-coding and // install the pending rate via ENCODE_RATE_CONTROL (folded into the first frame's RESET // install, or a standalone mid-stream control), and step 5 promotes it. Promoting it // here instead — which the first-frame path used to do — made the begin declaration name // a rate the session had not installed whenever a `reset()` preceded this frame // (`rc_installed` survives a reset; the session object keeps the OLD rate): a one-frame // VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08254 violation on exactly the frames where // an ABR retarget and the stall watchdog coincide. let mut is_idr = self.first_frame || self.force_kf; let mut ref_slot = self.prev_slot; let mut recovery = false; if let Some(lf) = self.pending_loss.take() { if !is_idr { // Pick-only re-run of the shared policy: the taint sweep already happened in // `invalidate_ref_frames`; the arm carries the loss start, not the slot, so the // anchor is resolved against the table as it stands NOW. match crate::rfi::pick_anchor(&trusted_refs(&self.slot_wire), lf) { Some((s, _)) => { ref_slot = s; recovery = true; tracing::debug!( loss_first = lf, anchor_slot = s, anchor_wire = self.slot_wire[s], "vulkan-encode: emitting clean recovery-anchor P-frame (references a known-good frame older than the loss, no IDR)" ); } None => { is_idr = true; tracing::debug!(loss_first = lf, "vulkan-encode: no resident reference older than the loss — forcing IDR"); } } } } let poc: i32 = if is_idr { 0 } else { self.poc }; let mut setup_idx = (self.enc_count % DPB_SLOTS as u64) as usize; if !is_idr && setup_idx == ref_slot { setup_idx = (setup_idx + 1) % DPB_SLOTS as usize; } // ---- 2..4 diverge by encode source; native NV12 and RGB-direct return through the // shared bookkeeping tail ---- if self.native_nv12 { self.record_submit_nv12(slot, frame, is_idr, recovery, ref_slot, setup_idx, poc)?; self.post_submit_bookkeeping( slot, frame.pts_ns, wire, is_idr, recovery, setup_idx, poc, ); return Ok(()); } if self.rgb.is_some() { self.record_submit_rgb(slot, frame, is_idr, recovery, ref_slot, setup_idx, poc)?; self.post_submit_bookkeeping( slot, frame.pts_ns, wire, is_idr, recovery, setup_idx, poc, ); return Ok(()); } // ---- 2. RGB source -> compute_cmd: prep barriers + CSC + copy into nv12_src ---- // The CSC twin of the sibling guards (native NV12, RGB-direct's two arms, every other // backend's submit): the shader samples the source with clamped 1:1 texelFetch, so a // mismatched frame doesn't fault — it silently streams a cropped/edge-padded picture. // Refuse it into the encoder-rebuild path instead. if frame.width != self.render_w || frame.height != self.render_h { bail!( "vulkan-encode (csc): frame {}x{} != mode {}x{} — refusing a mismatched CSC \ source", frame.width, frame.height, self.render_w, self.render_h ); } let dev = self.device.clone(); // cheap handle clone -> lets us also call &mut self helpers dev.begin_command_buffer( compute_cmd, &vk::CommandBufferBeginInfo::default() .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), )?; // The fallible recording prefix (cursor prep, dmabuf import, CPU staging) runs in one // closure whose error arm resets `compute_cmd`: an early return used to leave the buffer // RECORDING, and the next frame's `begin` on a RECORDING buffer violates // VUID-vkBeginCommandBuffer-commandBuffer-00049 — the pool's implicit reset covers only // INITIAL/EXECUTABLE/INVALID. Never PENDING here: nothing is submitted until stage 4, so // the error-arm reset is legal on every path. let prefix = (|| -> Result<([i32; 4], vk::ImageView)> { // PUNKTFUNK_PERF: bracket the whole compute batch (import barriers + cursor prep + CSC // dispatch + plane copies) with timestamps — read back in `read_slot` as `csc_us`, the // half of the fence wait that is NOT the ASIC encode. self.frames[slot].ts_written = false; if self.ts_period_ns > 0.0 { dev.cmd_reset_query_pool(compute_cmd, ts_pool, 0, 2); dev.cmd_write_timestamp2(compute_cmd, vk::PipelineStageFlags2::NONE, ts_pool, 0); // This batch resets the pool and closes it below, so `read_slot` may read it. self.frames[slot].ts_written = true; } // Cursor-as-metadata: refresh this slot's cursor image (only when the bitmap changed) // and get the shader push constant. Recorded into `compute_cmd` before the CSC // dispatch samples it. let cursor_pc = self.prep_cursor(slot, compute_cmd, frame.cursor.as_ref())?; let rgb_view = match &frame.payload { FramePayload::Dmabuf(d) => { // Reuse the per-buffer import (PipeWire cycles a small pool) — no per-frame VkImage // create/import/destroy. The producer wrote new content out-of-band, so still acquire // from FOREIGN each frame; a fresh import starts UNDEFINED (preserves modifier-tiled // data), a cached one is already SHADER_READ_ONLY_OPTIMAL. let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?; // First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled // bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility // barrier so the shader read sees the content the producer wrote out-of-band this frame // (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off). let (old, src_qf, dst_qf) = if fresh { ( vk::ImageLayout::UNDEFINED, vk::QUEUE_FAMILY_FOREIGN_EXT, self.compute_family, ) } else { ( vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED, ) }; let acq = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER) .dst_access_mask(vk::AccessFlags2::SHADER_READ) .old_layout(old) .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) .src_queue_family_index(src_qf) .dst_queue_family_index(dst_qf) .image(img) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[acq]), ); view } FramePayload::Cpu(bytes) => { // 24-bpp Rgb/Bgr expands 3→4 first (see `normalize_cpu_rgb`) — refusing it here // used to kill the session at its first frame, and the RGB-direct padding branch // in `ensure_cpu_rgb` does 4-bpp index math on the raw bytes, so the expansion // must happen BEFORE any of it sees the payload. let mut scratch = std::mem::take(&mut self.cpu_expand); let (norm_fmt, norm_bytes) = normalize_cpu_rgb(frame.format, bytes, &mut scratch, false); let fmt = pixel_to_vk(norm_fmt).context("unsupported CPU pixel format"); let view = match fmt { Ok(f) => { self.ensure_cpu_rgb(slot, f, norm_bytes, frame.width, frame.height) } Err(e) => Err(e), }; self.cpu_expand = scratch; let view = view?; let (img, ..) = self.frames[slot].cpu_img.unwrap(); let (stage, ..) = self.frames[slot].cpu_stage.unwrap(); let to_dst = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[to_dst]), ); dev.cmd_copy_buffer_to_image( compute_cmd, stage, img, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[vk::BufferImageCopy::default() .image_subresource( vk::ImageSubresourceLayers::default() .aspect_mask(vk::ImageAspectFlags::COLOR) .layer_count(1), ) // The staging buffer holds the REAL frame tightly packed and the image // is source-sized (see ensure_cpu_rgb) — an aligned-size extent here // sheared rows against the packed buffer and left garbage rows at // unaligned modes. .image_extent(vk::Extent3D { width: frame.width, height: frame.height, depth: 1, })], ); let to_read = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .src_access_mask(vk::AccessFlags2::TRANSFER_WRITE) .dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER) .dst_access_mask(vk::AccessFlags2::SHADER_READ) .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[to_read]), ); view } _ => bail!("vulkan-encode: unsupported FramePayload (need Dmabuf or Cpu RGB)"), }; Ok((cursor_pc, rgb_view)) })(); let (cursor_pc, rgb_view) = match prefix { Ok(v) => v, Err(e) => { // SAFETY-ADJACENT: RECORDING (never submitted in this fn yet), pool allows reset. let _ = dev.reset_command_buffer(compute_cmd, vk::CommandBufferResetFlags::empty()); return Err(e); } }; self.bind_rgb(csc_set, rgb_view); // y/uv -> GENERAL (shader write); nv12_src -> GENERAL (transfer dst, discard prior) let to_general = |img, dst_stage, dst_access| { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(dst_stage) .dst_access_mask(dst_access) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::GENERAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)) }; let pre = [ to_general( y_img, vk::PipelineStageFlags2::COMPUTE_SHADER, vk::AccessFlags2::SHADER_WRITE, ), to_general( uv_img, vk::PipelineStageFlags2::COMPUTE_SHADER, vk::AccessFlags2::SHADER_WRITE, ), to_general( nv12_src, vk::PipelineStageFlags2::ALL_TRANSFER, vk::AccessFlags2::TRANSFER_WRITE, ), ]; dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&pre), ); dev.cmd_bind_pipeline(compute_cmd, vk::PipelineBindPoint::COMPUTE, self.csc_pipe); dev.cmd_bind_descriptor_sets( compute_cmd, vk::PipelineBindPoint::COMPUTE, self.csc_layout, 0, &[csc_set], &[], ); let mut pc_bytes = [0u8; 16]; for (i, v) in cursor_pc.iter().enumerate() { pc_bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_ne_bytes()); } dev.cmd_push_constants( compute_cmd, self.csc_layout, vk::ShaderStageFlags::COMPUTE, 0, &pc_bytes, ); dev.cmd_dispatch(compute_cmd, (w / 2).div_ceil(8), (h_px / 2).div_ceil(8), 1); // y/uv shader-write -> transfer-read (stay GENERAL); then copy into nv12 planes let yuv_rd = |img| { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER) .src_access_mask(vk::AccessFlags2::SHADER_WRITE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) .old_layout(vk::ImageLayout::GENERAL) .new_layout(vk::ImageLayout::GENERAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)) }; dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[yuv_rd(y_img), yuv_rd(uv_img)]), ); let plane_copy = |src_aspect, dst_aspect, ew, eh| { vk::ImageCopy::default() .src_subresource( vk::ImageSubresourceLayers::default() .aspect_mask(src_aspect) .layer_count(1), ) .dst_subresource( vk::ImageSubresourceLayers::default() .aspect_mask(dst_aspect) .layer_count(1), ) .extent(vk::Extent3D { width: ew, height: eh, depth: 1, }) }; dev.cmd_copy_image( compute_cmd, y_img, vk::ImageLayout::GENERAL, nv12_src, vk::ImageLayout::GENERAL, &[plane_copy( vk::ImageAspectFlags::COLOR, vk::ImageAspectFlags::PLANE_0, w, h_px, )], ); dev.cmd_copy_image( compute_cmd, uv_img, vk::ImageLayout::GENERAL, nv12_src, vk::ImageLayout::GENERAL, &[plane_copy( vk::ImageAspectFlags::COLOR, vk::ImageAspectFlags::PLANE_1, w / 2, h_px / 2, )], ); if self.ts_period_ns > 0.0 { dev.cmd_write_timestamp2( compute_cmd, vk::PipelineStageFlags2::ALL_COMMANDS, ts_pool, 1, ); } dev.end_command_buffer(compute_cmd)?; // ---- 3. record encode into `cmd`: codec-specific Std authoring + begin/encode/end ---- if self.codec == Codec::Av1 { self.record_coding_av1( &dev, cmd, query_pool, bs_buf, nv12_src, nv12_view, SrcAcquire::CscGeneral, is_idr, recovery, ref_slot, setup_idx, poc, )?; } else { self.record_coding_h265( &dev, cmd, query_pool, bs_buf, nv12_src, nv12_view, SrcAcquire::CscGeneral, is_idr, ref_slot, setup_idx, poc, )?; } // ---- 4. submit compute (signal csc_sem) then encode (wait csc_sem, signal fence). // Non-blocking: `fence` is polled later so this frame's CSC+encode overlaps the next // capture/submit. Per-slot cmd/sem/fence make ring frames independent; the DPB // barrier above orders slot N's reconstruct-write before N+1's reference-read. ---- dev.reset_fences(&[fence])?; let ccmds = [compute_cmd]; let sems = [csc_sem]; dev.queue_submit( self.compute_queue, &[vk::SubmitInfo::default() .command_buffers(&ccmds) .signal_semaphores(&sems)], vk::Fence::null(), )?; let ecmds = [cmd]; let wait_stages = [vk::PipelineStageFlags::ALL_COMMANDS]; dev.queue_submit( self.encode_queue, &[vk::SubmitInfo::default() .command_buffers(&ecmds) .wait_semaphores(&sems) .wait_dst_stage_mask(&wait_stages)], fence, )?; self.post_submit_bookkeeping(slot, frame.pts_ns, wire, is_idr, recovery, setup_idx, poc); Ok(()) } /// Padded-copy blit (unaligned-mode RGB-direct or native NV12): record — into `compute_cmd` /// — the visible frame copy from the imported capture image into the aligned staging image, /// plus the edge duplication into the 64x16 padding (the same edge semantics the CSC shader /// implements with clamped reads). Transfer-only, no shader. The staging image lives in /// GENERAL — it is both copy dst and, for the right-column pass, copy src — and the encode /// acquires it via [`SrcAcquire::CscGeneral`] (content produced on the compute queue, /// csc_sem-ordered). /// /// `planes` lists the copy aspects with their subsampling divisor — `[(COLOR, 1)]` for /// packed RGB, `[(PLANE_0, 1), (PLANE_1, 2)]` for NV12 (multi-planar copy regions are in /// each plane's own coordinate space; barriers on non-disjoint images stay COLOR-aspect). /// Every divisor must divide the visible and aligned extents (4:2:0 frames are even, the /// coded extent is 64x16-aligned). #[allow(clippy::too_many_arguments)] unsafe fn record_pad_blit( &self, dev: &ash::Device, compute_cmd: vk::CommandBuffer, src: vk::Image, src_fresh: bool, pad: vk::Image, ts_pool: vk::QueryPool, planes: &[(vk::ImageAspectFlags, u32)], ) -> Result<()> { let (rw, rh) = (self.render_w, self.render_h); let (w, h) = (self.width, self.height); dev.begin_command_buffer( compute_cmd, &vk::CommandBufferBeginInfo::default() .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), )?; if self.ts_period_ns > 0.0 { dev.cmd_reset_query_pool(compute_cmd, ts_pool, 0, 2); dev.cmd_write_timestamp2(compute_cmd, vk::PipelineStageFlags2::NONE, ts_pool, 0); } // Acquire the imported capture buffer for transfer reads (FOREIGN hand-off on first // import — UNDEFINED preserves the modifier-tiled bytes — visibility-only afterwards), // and the staging image for transfer writes (prior contents discarded). let src_acq = if src_fresh { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) .src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) .dst_queue_family_index(self.compute_family) } else { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) } .image(src) .subresource_range(color_range(0)); let pad_dst = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::GENERAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(pad) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[src_acq, pad_dst]), ); let region = |aspect: vk::ImageAspectFlags, sx: i32, sy: i32, dx: i32, dy: i32, cw: u32, ch: u32| { let layers = vk::ImageSubresourceLayers::default() .aspect_mask(aspect) .layer_count(1); vk::ImageCopy::default() .src_subresource(layers) .dst_subresource(layers) .src_offset(vk::Offset3D { x: sx, y: sy, z: 0 }) .dst_offset(vk::Offset3D { x: dx, y: dy, z: 0 }) .extent(vk::Extent3D { width: cw, height: ch, depth: 1, }) }; // Pass 1 — from the capture, per plane: the visible region, then each bottom padding row // as a copy of the last visible row (1080p: 8 luma + 4 chroma rows). One call, disjoint // regions. let mut regions = Vec::new(); for &(aspect, div) in planes { let (rw, rh, h) = (rw / div, rh / div, h / div); regions.push(region(aspect, 0, 0, 0, 0, rw, rh)); for y in rh..h { regions.push(region(aspect, 0, rh as i32 - 1, 0, y as i32, rw, 1)); } } dev.cmd_copy_image( compute_cmd, src, vk::ImageLayout::TRANSFER_SRC_OPTIMAL, pad, vk::ImageLayout::GENERAL, ®ions, ); // Pass 2 — right padding columns (width alignment, e.g. 1366→1408): duplicate the // staging image's own last visible column over the FULL aligned height (valid after // pass 1 filled the bottom rows). Self-copy in GENERAL, W→R barrier between, regions // disjoint from their source column. if w > rw { let self_dep = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .src_access_mask(vk::AccessFlags2::TRANSFER_WRITE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) .old_layout(vk::ImageLayout::GENERAL) .new_layout(vk::ImageLayout::GENERAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(pad) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[self_dep]), ); let mut cols = Vec::new(); for &(aspect, div) in planes { let (rw, w, h) = (rw / div, w / div, h / div); for x in rw..w { cols.push(region(aspect, rw as i32 - 1, 0, x as i32, 0, 1, h)); } } dev.cmd_copy_image( compute_cmd, pad, vk::ImageLayout::GENERAL, pad, vk::ImageLayout::GENERAL, &cols, ); } if self.ts_period_ns > 0.0 { dev.cmd_write_timestamp2( compute_cmd, vk::PipelineStageFlags2::ALL_COMMANDS, ts_pool, 1, ); } dev.end_command_buffer(compute_cmd)?; Ok(()) } /// Producer-native NV12 submit: import the producer's visible-size buffer directly as the /// encode source. Safe at every mode because native sessions run true-size headers — the /// picture resources' codedExtent equals the source extent and the VCN firmware edge-extends /// the alignment padding internally (see the [`Self::native_nv12`] field docs). #[allow(clippy::too_many_arguments)] unsafe fn record_submit_nv12( &mut self, slot: usize, frame: &CapturedFrame, is_idr: bool, recovery: bool, ref_slot: usize, setup_idx: usize, poc: i32, ) -> Result<()> { if frame.format != PixelFormat::Nv12 { bail!( "vulkan-encode (native NV12): negotiated NV12 but received {:?}", frame.format ); } if frame.width != self.render_w || frame.height != self.render_h { bail!( "vulkan-encode (native NV12): frame {}x{} != mode {}x{}", frame.width, frame.height, self.render_w, self.render_h ); } if frame.width % 2 != 0 || frame.height % 2 != 0 { bail!("vulkan-encode (native NV12): 4:2:0 frame dimensions must be even"); } let FramePayload::Dmabuf(d) = &frame.payload else { bail!("vulkan-encode (native NV12): producer frame is not a DMA-BUF"); }; if d.fourcc != pf_frame::drm_fourcc(PixelFormat::Nv12).expect("NV12 FourCC") { bail!( "vulkan-encode (native NV12): DMA-BUF FourCC {:#x} is not NV12", d.fourcc ); } if d.modifier != 0 { bail!( "vulkan-encode (native NV12): only LINEAR is supported, got modifier {:#x}", d.modifier ); } // No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer // in the produced pixels, but any other NV12 producer's metadata cursor would be lost — // say so once instead of silently. if frame.cursor.is_some() && !self.warned_cursor { self.warned_cursor = true; tracing::warn!( "cursor bitmap on a native-NV12 session — nothing composites it; the cursor \ will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \ metadata-cursor captures)" ); } let dev = self.device.clone(); let cmd = self.frames[slot].cmd; let fence = self.frames[slot].fence; let query_pool = self.frames[slot].query_pool; let bs_buf = self.frames[slot].bs_buf; // The frame-size check above proved the buffer covers the (true-size) coded extent — // the direct import is the encode source at every mode. let (src_img, src_view, fresh) = self.import_cached(d, frame.width, frame.height)?; let acquire = if fresh { SrcAcquire::DmabufFresh } else { SrcAcquire::DmabufCached }; if self.codec == Codec::Av1 { self.record_coding_av1( &dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, recovery, ref_slot, setup_idx, poc, )?; } else { self.record_coding_h265( &dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, ref_slot, setup_idx, poc, )?; } dev.reset_fences(&[fence])?; // The whole frame is one submit: the encoder reads the imported NV12 directly. let ecmds = [cmd]; dev.queue_submit( self.encode_queue, &[vk::SubmitInfo::default().command_buffers(&ecmds)], fence, )?; Ok(()) } /// RGB-direct twin of [`record_submit`]'s steps 2–4 (step 1 and the bookkeeping tail are /// shared): resolve the RGB encode source — the imported capture dmabuf itself, or the CPU /// staging upload — record the encode, and submit. There is no compute CSC: the VCN EFC /// converts inline during the encode; only the CPU path touches the compute queue (for the /// staging copy, semaphore-ordered ahead of the encode). #[allow(clippy::too_many_arguments)] unsafe fn record_submit_rgb( &mut self, slot: usize, frame: &CapturedFrame, is_idr: bool, recovery: bool, ref_slot: usize, setup_idx: usize, poc: i32, ) -> Result<()> { let dev = self.device.clone(); let compute_cmd = self.frames[slot].compute_cmd; let cmd = self.frames[slot].cmd; let csc_sem = self.frames[slot].csc_sem; let fence = self.frames[slot].fence; let query_pool = self.frames[slot].query_pool; let bs_buf = self.frames[slot].bs_buf; let ts_pool = self.frames[slot].ts_pool; // EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so // once instead of silently losing the pointer (gamescope, the flagship, embeds it). if frame.cursor.is_some() && !self.warned_cursor { self.warned_cursor = true; tracing::warn!( "cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \ will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \ metadata-cursor captures)" ); } let padded = self.rgb.as_ref().is_some_and(|r| r.padded); // Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the // CPU-upload arm records its own command buffer and writes none. Default to "not written" // so `read_slot` can never read an unreset query — see `Frame::ts_written`. self.frames[slot].ts_written = false; let (src_img, src_view, acquire, compute_active) = match &frame.payload { FramePayload::Dmabuf(d) if !padded => { // Defense in depth for the OOB class the alignment gate closes at open: the // imported buffer must cover the source extent the encode declares — the FULL // aligned coded extent normally (or the EFC reads past it: VM faults → VCN // ring hang → GPU reset, the 2026-07-20 field report), the render size in // true-extent mode (where the declared source codedExtent shrinks with it and // RADV's firmware padding covers the alignment rows). A mismatched frame // (mid-flight mode change, odd capture) errors out here and takes the // encoder-rebuild path instead of faulting the GPU. let (need_w, need_h) = if self.rgb.as_ref().is_some_and(|r| r.true_extent) { (self.render_w, self.render_h) } else { (self.width, self.height) }; if frame.width != need_w || frame.height != need_h { bail!( "vulkan-encode (rgb-direct): frame {}x{} does not cover the declared \ source extent {}x{} — refusing an out-of-bounds encode source", frame.width, frame.height, need_w, need_h ); } let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?; let acq = if fresh { SrcAcquire::DmabufFresh } else { SrcAcquire::DmabufCached }; (img, view, acq, false) } FramePayload::Dmabuf(d) => { // Padded-copy mode (unaligned mode, e.g. 1080p): blit the visible frame into // the per-slot ALIGNED staging image and duplicate the edge into the 64x16 // padding, all on the transfer path of the compute queue (no shader). The // encode reads the staging image — never the capture buffer — so the EFC can // never read past a producer allocation again. if frame.width != self.render_w || frame.height != self.render_h { bail!( "vulkan-encode (rgb-direct/padded): frame {}x{} != mode {}x{} — \ refusing a mismatched blit source", frame.width, frame.height, self.render_w, self.render_h ); } let (img, _view, fresh) = self.import_cached(d, frame.width, frame.height)?; let pad_img = self.frames[slot].pad_img; let pad_view = self.frames[slot].pad_view; self.record_pad_blit( &dev, compute_cmd, img, fresh, pad_img, ts_pool, &[(vk::ImageAspectFlags::COLOR, 1)], )?; // `record_pad_blit` reset and wrote both timestamps when PERF is armed. self.frames[slot].ts_written = self.ts_period_ns > 0.0; // The staging image ends the blit in GENERAL with the csc_sem ordering the // hand-off — exactly the CscGeneral acquire contract. (pad_img, pad_view, SrcAcquire::CscGeneral, true) } FramePayload::Cpu(bytes) => { // 24-bpp Rgb/Bgr expands 3→4 BEFORE the staging/padding math (which is all // 4-bpp) — see `normalize_cpu_rgb`. BGRA-forced: this image is the ENCODE // source, and the session's `pictureFormat` is B8G8R8A8, so an R-first source // violates VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 (caught on RADV by // `vulkan_smoke_rgb_cpu24`; true for plain `Rgbx` CPU sources before the 24-bpp // work too). This arm's `begin_command_buffer` comes AFTER the fallible steps, // so no reset-on-error wrap is needed here. let mut scratch = std::mem::take(&mut self.cpu_expand); let (norm_fmt, norm_bytes) = normalize_cpu_rgb(frame.format, bytes, &mut scratch, true); let fmt = pixel_to_vk(norm_fmt).context("unsupported CPU pixel format"); let view = match fmt { Ok(f) => self.ensure_cpu_rgb(slot, f, norm_bytes, frame.width, frame.height), Err(e) => Err(e), }; self.cpu_expand = scratch; let view = view?; let (img, ..) = self.frames[slot].cpu_img.expect("ensure_cpu_rgb built it"); let (stage, ..) = self.frames[slot] .cpu_stage .expect("ensure_cpu_rgb built it"); // compute_cmd carries ONLY the staging→image copy; the encode submit waits on // csc_sem exactly like the CSC path's hand-off. dev.begin_command_buffer( compute_cmd, &vk::CommandBufferBeginInfo::default() .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), )?; let to_dst = vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER) .dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(img) .subresource_range(color_range(0)); dev.cmd_pipeline_barrier2( compute_cmd, &vk::DependencyInfo::default().image_memory_barriers(&[to_dst]), ); dev.cmd_copy_buffer_to_image( compute_cmd, stage, img, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[vk::BufferImageCopy::default() .image_subresource( vk::ImageSubresourceLayers::default() .aspect_mask(vk::ImageAspectFlags::COLOR) .layer_count(1), ) .image_extent(vk::Extent3D { width: self.width, height: self.height, depth: 1, })], ); dev.end_command_buffer(compute_cmd)?; (img, view, SrcAcquire::CpuUpload, true) } _ => bail!( "vulkan-encode (rgb-direct): unsupported FramePayload (need Dmabuf or Cpu RGB)" ), }; if self.codec == Codec::Av1 { self.record_coding_av1( &dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, recovery, ref_slot, setup_idx, poc, )?; } else { self.record_coding_h265( &dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, ref_slot, setup_idx, poc, )?; } dev.reset_fences(&[fence])?; let ecmds = [cmd]; if compute_active { let ccmds = [compute_cmd]; let sems = [csc_sem]; dev.queue_submit( self.compute_queue, &[vk::SubmitInfo::default() .command_buffers(&ccmds) .signal_semaphores(&sems)], vk::Fence::null(), )?; let wait_stages = [vk::PipelineStageFlags::ALL_COMMANDS]; dev.queue_submit( self.encode_queue, &[vk::SubmitInfo::default() .command_buffers(&ecmds) .wait_semaphores(&sems) .wait_dst_stage_mask(&wait_stages)], fence, )?; } else { // The whole frame is one submit: EFC reads the imported RGB directly. dev.queue_submit( self.encode_queue, &[vk::SubmitInfo::default().command_buffers(&ecmds)], fence, )?; } Ok(()) } /// Shared tail of both submit paths: stash the metadata `read_slot` needs once the fence /// signals, then advance the DPB/GOP bookkeeping (in submission order). fn post_submit_bookkeeping( &mut self, slot: usize, pts_ns: u64, wire: i64, is_idr: bool, recovery: bool, setup_idx: usize, poc: i32, ) { self.frames[slot].pts_ns = pts_ns; self.frames[slot].keyframe = is_idr; self.frames[slot].recovery_anchor = recovery; if is_idr { self.slot_wire.iter_mut().for_each(|s| *s = -1); self.slot_poc.iter_mut().for_each(|s| *s = -1); } self.slot_wire[setup_idx] = wire; self.slot_poc[setup_idx] = poc; self.prev_slot = setup_idx; self.poc = poc + 1; self.enc_count += 1; // The record fns key the RESET-install fold on `first_frame`, NOT on `is_idr` — a // forced-keyframe or loss-forced IDR mid-stream installs a pending retarget via the // standalone ENCODE_RATE_CONTROL like any other mid-stream frame. Capture the flag // before clearing it so the telemetry below reports which install path actually ran. let was_first_frame = self.first_frame; self.first_frame = false; // The frame just recorded carried the RESET + rate-control install (what `first_frame` // gated), so from here on the session HAS a non-default rate-control mode — and unlike // `first_frame` this survives `reset()`, because a reset does not rebuild the session. self.rc_installed = true; self.force_kf = false; if let Some(nb) = self.pending_bitrate.take() { // The retarget control command is recorded (execution follows submission order): the // session's RC state IS the new rate from this frame on — later begins declare it. self.bitrate = nb; tracing::debug!( mbps = nb / 1_000_000, folded_into_reset = was_first_frame, "vulkan-encode: rate control retargeted" ); } } /// Begin `cmd` and record the pre-encode setup shared by both codecs: the query-pool reset, /// the source-image acquire (mode-specific — see [`SrcAcquire`]), and the DPB transition — /// on the first frame a whole-image UNDEFINED → DPB init; afterwards the /// cross-command-buffer pipelining barrier that orders the previous frame's /// reconstruct-write before this frame's reference read/write (the in-flight ring records /// frame N+1 while N still encodes; the barrier's first scope covers all prior-submitted /// encode work on this queue, spanning the separate command buffers). unsafe fn begin_encode_cmd( &self, dev: &ash::Device, cmd: vk::CommandBuffer, query_pool: vk::QueryPool, src_img: vk::Image, acquire: SrcAcquire, ) -> Result<()> { dev.begin_command_buffer( cmd, &vk::CommandBufferBeginInfo::default() .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), )?; dev.cmd_reset_query_pool(cmd, query_pool, 0, 1); let dpb_barrier = if self.first_frame { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .dst_stage_mask(vk::PipelineStageFlags2::VIDEO_ENCODE_KHR) .dst_access_mask(vk::AccessFlags2::VIDEO_ENCODE_WRITE_KHR) .old_layout(vk::ImageLayout::UNDEFINED) .new_layout(vk::ImageLayout::VIDEO_ENCODE_DPB_KHR) } else { vk::ImageMemoryBarrier2::default() .src_stage_mask(vk::PipelineStageFlags2::VIDEO_ENCODE_KHR) .src_access_mask(vk::AccessFlags2::VIDEO_ENCODE_WRITE_KHR) .dst_stage_mask(vk::PipelineStageFlags2::VIDEO_ENCODE_KHR) .dst_access_mask( vk::AccessFlags2::VIDEO_ENCODE_READ_KHR | vk::AccessFlags2::VIDEO_ENCODE_WRITE_KHR, ) .old_layout(vk::ImageLayout::VIDEO_ENCODE_DPB_KHR) .new_layout(vk::ImageLayout::VIDEO_ENCODE_DPB_KHR) } .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(self.dpb_image) .subresource_range(vk::ImageSubresourceRange { aspect_mask: vk::ImageAspectFlags::COLOR, base_mip_level: 0, level_count: 1, base_array_layer: 0, layer_count: DPB_SLOTS, }); // Source acquire, mode-specific. All variants end at VIDEO_ENCODE_SRC for the encode // read; they differ in where the content came from (see [`SrcAcquire`]). let src_base = vk::ImageMemoryBarrier2::default() .dst_stage_mask(vk::PipelineStageFlags2::VIDEO_ENCODE_KHR) .dst_access_mask(vk::AccessFlags2::VIDEO_ENCODE_READ_KHR) .new_layout(vk::ImageLayout::VIDEO_ENCODE_SRC_KHR) .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) .image(src_img) .subresource_range(color_range(0)); let src_barrier = match acquire { SrcAcquire::CscGeneral => src_base .src_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS) .src_access_mask(vk::AccessFlags2::NONE) .old_layout(vk::ImageLayout::GENERAL), SrcAcquire::DmabufFresh => src_base .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .old_layout(vk::ImageLayout::UNDEFINED) .src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT) .dst_queue_family_index(self.encode_family), SrcAcquire::DmabufCached => src_base .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .old_layout(vk::ImageLayout::VIDEO_ENCODE_SRC_KHR), SrcAcquire::CpuUpload => src_base .src_stage_mask(vk::PipelineStageFlags2::NONE) .src_access_mask(vk::AccessFlags2::NONE) .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL), }; let pre_enc = [src_barrier, dpb_barrier]; dev.cmd_pipeline_barrier2( cmd, &vk::DependencyInfo::default().image_memory_barriers(&pre_enc), ); Ok(()) } /// Author the HEVC Std structs + record begin/encode/end for one frame into `cmd` — the HEVC /// twin of [`record_coding_av1`]. RFI lever: a recovery anchor is an ordinary P whose /// `RefPicList0` names the known-good slot; what makes it decodable is the FULL short-term RPS /// ([`build_h265_rps_s0`]) every P-frame carries, which keeps all resident DPB pictures alive /// at the decoder so any slot the anchor references is still there. #[allow(clippy::too_many_arguments)] unsafe fn record_coding_h265( &self, dev: &ash::Device, cmd: vk::CommandBuffer, query_pool: vk::QueryPool, bs_buf: vk::Buffer, src_img: vk::Image, src_view: vk::ImageView, acquire: SrcAcquire, is_idr: bool, ref_slot: usize, setup_idx: usize, poc: i32, ) -> Result<()> { use ash::vk::native as h; // Setup/reference extent: the aligned size for app-aligned sessions (CSC, RGB — it // pairs with their aligned SPS), the render size for native NV12's true-size headers. let ext2d = if self.native_nv12 { vk::Extent2D { width: self.render_w, height: self.render_h, } } else { vk::Extent2D { width: self.width, height: self.height, } }; // Source extent additionally drops to the render size in RGB true-extent mode: RADV // derives the VCN firmware padding from srcPictureResource.codedExtent (Mesa ≥ 24.2), // so the visible-size import is never read past its extent (see RgbDirect::true_extent). let src_extent = if self.rgb.as_ref().is_some_and(|r| r.true_extent) { vk::Extent2D { width: self.render_w, height: self.render_h, } } else { ext2d }; let ref_poc = if is_idr { 0 } else { self.slot_poc[ref_slot] }; let mut pic_flags: h::StdVideoEncodeH265PictureInfoFlags = std::mem::zeroed(); pic_flags.set_is_reference(1); if is_idr { pic_flags.set_IrapPicFlag(1); } pic_flags.set_pic_output_flag(1); let mut std_pic: h::StdVideoEncodeH265PictureInfo = std::mem::zeroed(); std_pic.flags = pic_flags; std_pic.pic_type = if is_idr { h::StdVideoH265PictureType_STD_VIDEO_H265_PICTURE_TYPE_IDR } else { h::StdVideoH265PictureType_STD_VIDEO_H265_PICTURE_TYPE_P }; std_pic.PicOrderCntVal = poc; let (num_neg, deltas, used) = build_h265_rps_s0(&self.slot_poc, setup_idx, ref_poc, poc); // A P-frame's active reference must be one of the retained pictures — `ref_slot` is always // resident and never the setup slot (record_submit bumps a collision), so a miss here means // the DPB bookkeeping desynced. debug_assert!(is_idr || used != 0, "reference POC missing from the RPS"); let mut rps: h::StdVideoH265ShortTermRefPicSet = std::mem::zeroed(); rps.num_negative_pics = num_neg; rps.delta_poc_s0_minus1 = deltas; rps.used_by_curr_pic_s0_flag = used; let mut ref_lists: h::StdVideoEncodeH265ReferenceListsInfo = std::mem::zeroed(); ref_lists.RefPicList0 = [0xff; 15]; ref_lists.RefPicList1 = [0xff; 15]; ref_lists.RefPicList0[0] = ref_slot as u8; if !is_idr { std_pic.pShortTermRefPicSet = &rps; std_pic.pRefLists = &ref_lists; } let mut sh_flags: h::StdVideoEncodeH265SliceSegmentHeaderFlags = std::mem::zeroed(); sh_flags.set_first_slice_segment_in_pic_flag(1); sh_flags.set_slice_loop_filter_across_slices_enabled_flag(1); let mut std_sh: h::StdVideoEncodeH265SliceSegmentHeader = std::mem::zeroed(); std_sh.flags = sh_flags; std_sh.slice_type = if is_idr { h::StdVideoH265SliceType_STD_VIDEO_H265_SLICE_TYPE_I } else { h::StdVideoH265SliceType_STD_VIDEO_H265_SLICE_TYPE_P }; std_sh.MaxNumMergeCand = 5; let slice = vk::VideoEncodeH265NaluSliceSegmentInfoKHR::default() .constant_qp(0) .std_slice_segment_header(&std_sh); let slices = [slice]; let mut h265_pic = vk::VideoEncodeH265PictureInfoKHR::default() .nalu_slice_segment_entries(&slices) .std_picture_info(&std_pic); // setup slot (reconstruct into) + reference slot (read from) let setup_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(ext2d) .image_view_binding(self.dpb_views[setup_idx]); let mut setup_std: h::StdVideoEncodeH265ReferenceInfo = std::mem::zeroed(); setup_std.pic_type = std_pic.pic_type; setup_std.PicOrderCntVal = poc; let mut setup_dpb_a = vk::VideoEncodeH265DpbSlotInfoKHR::default().std_reference_info(&setup_std); let mut setup_dpb_b = vk::VideoEncodeH265DpbSlotInfoKHR::default().std_reference_info(&setup_std); let setup_slot = vk::VideoReferenceSlotInfoKHR::default() .slot_index(setup_idx as i32) .picture_resource(&setup_res) .push_next(&mut setup_dpb_a); let begin_setup = vk::VideoReferenceSlotInfoKHR::default() .slot_index(-1) .picture_resource(&setup_res) .push_next(&mut setup_dpb_b); let ref_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(ext2d) .image_view_binding(self.dpb_views[ref_slot]); let mut ref_std: h::StdVideoEncodeH265ReferenceInfo = std::mem::zeroed(); ref_std.pic_type = if ref_poc == 0 { h::StdVideoH265PictureType_STD_VIDEO_H265_PICTURE_TYPE_IDR } else { h::StdVideoH265PictureType_STD_VIDEO_H265_PICTURE_TYPE_P }; ref_std.PicOrderCntVal = ref_poc; let mut ref_dpb_a = vk::VideoEncodeH265DpbSlotInfoKHR::default().std_reference_info(&ref_std); let mut ref_dpb_b = vk::VideoEncodeH265DpbSlotInfoKHR::default().std_reference_info(&ref_std); let ref_begin = vk::VideoReferenceSlotInfoKHR::default() .slot_index(ref_slot as i32) .picture_resource(&ref_res) .push_next(&mut ref_dpb_a); let ref_enc = vk::VideoReferenceSlotInfoKHR::default() .slot_index(ref_slot as i32) .picture_resource(&ref_res) .push_next(&mut ref_dpb_b); let begin_p = [ref_begin, begin_setup]; let begin_i = [begin_setup]; let enc_refs = [ref_enc]; // Rate control (chained manually; push_next would clobber rc.p_next). Mode + window are // the values latched at open (see the `rc_mode`/`vbv_ms` field docs); this struct DECLARES // the session's CURRENT state (`self.bitrate`) — never a pending retarget, which is only // what the install below moves to (VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08254). let rc_layer = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(self.bitrate) .max_bitrate(self.bitrate) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let h265_rc = vk::VideoEncodeH265RateControlInfoKHR::default() .flags(vk::VideoEncodeH265RateControlFlagsKHR::REGULAR_GOP) .gop_frame_count(u32::MAX) .idr_period(u32::MAX) .consecutive_b_frame_count(0) .sub_layer_count(1); let mut rc = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&rc_layer) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); rc.p_next = &h265_rc as *const _ as *const c_void; let rc_ptr = &rc as *const _ as *const c_void; self.begin_encode_cmd(dev, cmd, query_pool, src_img, acquire)?; let begin_slots: &[vk::VideoReferenceSlotInfoKHR] = if is_idr { &begin_i } else { &begin_p }; let mut begin = vk::VideoBeginCodingInfoKHR::default() .video_session(self.session) .video_session_parameters(self.params) .reference_slots(begin_slots); // Declare the session's ACTUAL current rate-control state, not "not the first frame" — // a mid-stream `reset()` re-arms `first_frame` while the session keeps its installed CBR. if self.rc_installed { begin.p_next = rc_ptr; } (self.vq_dev.fp().cmd_begin_video_coding_khr)(cmd, &begin); if self.first_frame { // RESET + rate-control install + explicit quality level. Without ENCODE_QUALITY_LEVEL, // RADV never sends the VCN a preset op at all (the firmware default preset runs); // installing it makes the preset deterministic on every driver and selects the // fastest tier by default (see `quality_request`). The quality struct chains ahead // of the rate-control state — the spec requires a quality-level change to carry // ENCODE_RATE_CONTROL, which the RESET install provides anyway. // // The install moves to the EFFECTIVE rate — a pending retarget folds in HERE, not // into `self.bitrate` before recording. After a mid-stream `reset()` the session // still HAS the old rate (a reset never touches the session object), and the begin // above must declare that old rate; promoting the pending rate first made the // declaration name a rate the session had not installed — a one-frame // VUID-...-08254 violation on exactly the frames where ABR retarget and the stall // watchdog coincide. `post_submit_bookkeeping` promotes after recording. let nb = self.pending_bitrate.unwrap_or(self.bitrate); let install_layer = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(nb) .max_bitrate(nb) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let mut install_rc = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&install_layer) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); install_rc.p_next = &h265_rc as *const _ as *const c_void; let mut q = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(self.quality_level); q.p_next = &install_rc as *const _ as *const c_void; let mut ctrl = vk::VideoCodingControlInfoKHR::default().flags( vk::VideoCodingControlFlagsKHR::RESET | vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL | vk::VideoCodingControlFlagsKHR::ENCODE_QUALITY_LEVEL, ); ctrl.p_next = &q as *const _ as *const c_void; (self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl); } else if let Some(nb) = self.pending_bitrate { // Mid-stream retarget (`reconfigure_bitrate`): `begin` above declared the session's // CURRENT rate-control state (the spec requires the match); this control command // installs the NEW rate — the same shape with only the bitrate moved. No RESET, // no IDR: the DPB and reference chain carry straight on. `record_submit` promotes // `nb` into `self.bitrate` after recording, so later begins declare the new state. let rc_layer2 = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(nb) .max_bitrate(nb) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let mut rc2 = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&rc_layer2) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); rc2.p_next = &h265_rc as *const _ as *const c_void; let mut ctrl = vk::VideoCodingControlInfoKHR::default() .flags(vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL); ctrl.p_next = &rc2 as *const _ as *const c_void; (self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl); } dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty()); let src_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(src_extent) .image_view_binding(src_view); let mut enc = vk::VideoEncodeInfoKHR::default() .dst_buffer(bs_buf) .dst_buffer_offset(0) .dst_buffer_range(self.bs_size) .src_picture_resource(src_res) .setup_reference_slot(&setup_slot) .push_next(&mut h265_pic); if !is_idr { enc = enc.reference_slots(&enc_refs); } (self.venc_dev.fp().cmd_encode_video_khr)(cmd, &enc); dev.cmd_end_query(cmd, query_pool, 0); (self.vq_dev.fp().cmd_end_video_coding_khr)(cmd, &vk::VideoEndCodingInfoKHR::default()); dev.end_command_buffer(cmd)?; Ok(()) } /// Author the AV1 Std structs + record begin/encode/end for one frame into `cmd` — the AV1 /// twin of [`record_coding_h265`]. RFI lever: an IDR **or** a recovery frame breaks the CDF /// chain (`primary_ref_frame = PRIMARY_REF_NONE` + `error_resilient_mode`) so it decodes /// independent of the lost frames' probability context, while a normal P inherits context /// (name 0 → `ref_slot`). Unlike HEVC, reference retention needs no per-frame syntax: AV1's 8 /// virtual reference slots persist until `refresh_frame_flags` overwrites them, mirroring the /// host's DPB ring by construction. #[allow(clippy::too_many_arguments)] unsafe fn record_coding_av1( &self, dev: &ash::Device, cmd: vk::CommandBuffer, query_pool: vk::QueryPool, bs_buf: vk::Buffer, src_img: vk::Image, src_view: vk::ImageView, acquire: SrcAcquire, is_idr: bool, recovery: bool, ref_slot: usize, setup_idx: usize, order: i32, ) -> Result<()> { use super::vk_av1_encode as av1; use ash::vk::native as h; // Setup/reference extent: the aligned size for app-aligned sessions (CSC, RGB — it // pairs with their aligned SPS), the render size for native NV12's true-size headers. let ext2d = if self.native_nv12 { vk::Extent2D { width: self.render_w, height: self.render_h, } } else { vk::Extent2D { width: self.width, height: self.height, } }; // Source extent additionally drops to the render size in RGB true-extent mode: RADV // derives the VCN firmware padding from srcPictureResource.codedExtent (Mesa ≥ 24.2), // so the visible-size import is never read past its extent (see RgbDirect::true_extent). let src_extent = if self.rgb.as_ref().is_some_and(|r| r.true_extent) { vk::Extent2D { width: self.render_w, height: self.render_h, } } else { ext2d }; // ---- required AV1 frame sub-structs (single tile; no CDEF/LR/segmentation/global-motion) ---- let mut tile_flags: h::StdVideoAV1TileInfoFlags = std::mem::zeroed(); tile_flags.set_uniform_tile_spacing_flag(1); let mut tile_info: h::StdVideoAV1TileInfo = std::mem::zeroed(); tile_info.flags = tile_flags; tile_info.TileCols = 1; tile_info.TileRows = 1; let mut quant: h::StdVideoAV1Quantization = std::mem::zeroed(); quant.base_q_idx = AV1_BASE_Q_IDX; let mut loop_filter: h::StdVideoAV1LoopFilter = std::mem::zeroed(); // AV1 default_loop_filter_ref_deltas (spec 7.14.1): intra +1, golden/bwd/altref2/altref -1. loop_filter.loop_filter_ref_deltas = [1, 0, 0, 0, -1, 0, -1, -1]; let cdef: h::StdVideoAV1CDEF = std::mem::zeroed(); let mut lr: h::StdVideoAV1LoopRestoration = std::mem::zeroed(); lr.FrameRestorationType = [h::StdVideoAV1FrameRestorationType_STD_VIDEO_AV1_FRAME_RESTORATION_TYPE_NONE; 3]; let gm: h::StdVideoAV1GlobalMotion = std::mem::zeroed(); // Order hints of the 8 physical reference buffers (DPB slots), 0 where empty. let mut ref_order_hint = [0u8; 8]; for (i, &poc) in self.slot_poc.iter().enumerate().take(8) { ref_order_hint[i] = poc.max(0) as u8; } // ---- Std picture info ---- // A recovery anchor (or IDR) is error-resilient + inherits no CDF context, so it decodes // independent of the (possibly lost) frames since its reference — the AV1 RFI lever. Normal // P-frames inherit context from their reference (primary_ref = name 0 → `ref_slot`) for // compression, exactly like the HEVC path's reference chain. let independent = is_idr || recovery; let mut pic_flags: av1::StdVideoEncodeAV1PictureInfoFlags = std::mem::zeroed(); pic_flags.set_show_frame(1); if independent { pic_flags.set_error_resilient_mode(1); } // AV1 IGNORES render_width/height_minus_1 unless this flag says the render size differs // from the coded frame size. We always wrote the render size (below) but never the flag, so // at any mode that needed 64x16 alignment the decoder fell back to the CODED size and the // client displayed our alignment padding — e.g. 1080p arriving as 1088 rows, the bottom 8 // being duplicated edge pixels. if self.render_w != src_extent.width || self.render_h != src_extent.height { pic_flags.set_render_and_frame_size_different(1); } let mut std_pic: av1::StdVideoEncodeAV1PictureInfo = std::mem::zeroed(); std_pic.flags = pic_flags; std_pic.frame_type = if is_idr { h::StdVideoAV1FrameType_STD_VIDEO_AV1_FRAME_TYPE_KEY } else { h::StdVideoAV1FrameType_STD_VIDEO_AV1_FRAME_TYPE_INTER }; std_pic.order_hint = order as u8; std_pic.primary_ref_frame = if independent { av1::PRIMARY_REF_NONE } else { 0 }; std_pic.refresh_frame_flags = if is_idr { 0xff } else { 1u8 << setup_idx }; std_pic.render_width_minus_1 = (self.render_w - 1) as u16; std_pic.render_height_minus_1 = (self.render_h - 1) as u16; std_pic.interpolation_filter = 0; // EIGHTTAP std_pic.TxMode = h::StdVideoAV1TxMode_STD_VIDEO_AV1_TX_MODE_SELECT; std_pic.ref_order_hint = ref_order_hint; if !is_idr { // single-reference P: every reference name maps to the (recovery or previous) DPB slot. std_pic.ref_frame_idx = [ref_slot as i8; 7]; } std_pic.pTileInfo = &tile_info; std_pic.pQuantization = &quant; std_pic.pLoopFilter = &loop_filter; std_pic.pCDEF = &cdef; std_pic.pLoopRestoration = &lr; // pSegmentation MUST be NULL for an AV1 encode operation // (VUID-vkCmdEncodeVideoKHR-pStdPictureInfo-10350). It used to point at a zeroed // segmentation struct, which RADV's validation rejects on every frame; `std_pic` is // zeroed, so leaving it unset is both spec-correct and the same "segmentation disabled". std_pic.pGlobalMotion = &gm; // ---- KHR picture info ---- let av1_pic = av1::VideoEncodeAV1PictureInfoKHR { s_type: av1::stype(av1::ST_PICTURE_INFO), p_next: std::ptr::null(), prediction_mode: if is_idr { av1::PREDICTION_MODE_INTRA_ONLY } else { av1::PREDICTION_MODE_SINGLE_REFERENCE }, rate_control_group: if is_idr { av1::RC_GROUP_INTRA } else { av1::RC_GROUP_PREDICTIVE }, // MUST be zero whenever the rate control mode is not DISABLED // (VUID-vkCmdEncodeVideoKHR-constantQIndex-10320) — this session installs CBR, so the // driver owns Q. Passing `base_q_idx` here was rejected by validation on every frame; // the value still reaches the encoder through `pQuantization` for the header. constant_q_index: 0, p_std_picture_info: &std_pic, reference_name_slot_indices: if is_idr { [-1; av1::MAX_VIDEO_AV1_REFERENCES_PER_FRAME] } else { [ref_slot as i32; av1::MAX_VIDEO_AV1_REFERENCES_PER_FRAME] }, primary_reference_cdf_only: 0, generate_obu_extension_header: 0, }; // ---- setup (reconstruct into) + reference (read from) DPB slots ---- // DPB slots carry the SOURCE extent, not the aligned one. Without // MOTION_VECTOR_SCALING every reference slot's `codedExtent` must equal the source's // (`VUID-vkCmdEncodeVideoKHR-flags-10325`), and in true-extent mode the source is the // render size. `src_extent` already collapses to `ext2d` whenever true-extent is off, so // this is a no-op for every other configuration. let setup_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(src_extent) .image_view_binding(self.dpb_views[setup_idx]); let mut setup_ref_std: av1::StdVideoEncodeAV1ReferenceInfo = std::mem::zeroed(); setup_ref_std.frame_type = std_pic.frame_type; setup_ref_std.OrderHint = order as u8; let setup_dpb = av1::VideoEncodeAV1DpbSlotInfoKHR { s_type: av1::stype(av1::ST_DPB_SLOT_INFO), p_next: std::ptr::null(), p_std_reference_info: &setup_ref_std, }; let mut setup_slot = vk::VideoReferenceSlotInfoKHR::default() .slot_index(setup_idx as i32) .picture_resource(&setup_res); setup_slot.p_next = &setup_dpb as *const _ as *const c_void; let mut begin_setup = vk::VideoReferenceSlotInfoKHR::default() .slot_index(-1) .picture_resource(&setup_res); begin_setup.p_next = &setup_dpb as *const _ as *const c_void; let ref_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(src_extent) .image_view_binding(self.dpb_views[ref_slot]); let mut ref_ref_std: av1::StdVideoEncodeAV1ReferenceInfo = std::mem::zeroed(); ref_ref_std.frame_type = if self.slot_poc[ref_slot] == 0 { h::StdVideoAV1FrameType_STD_VIDEO_AV1_FRAME_TYPE_KEY } else { h::StdVideoAV1FrameType_STD_VIDEO_AV1_FRAME_TYPE_INTER }; ref_ref_std.OrderHint = self.slot_poc[ref_slot].max(0) as u8; let ref_dpb = av1::VideoEncodeAV1DpbSlotInfoKHR { s_type: av1::stype(av1::ST_DPB_SLOT_INFO), p_next: std::ptr::null(), p_std_reference_info: &ref_ref_std, }; let mut ref_begin = vk::VideoReferenceSlotInfoKHR::default() .slot_index(ref_slot as i32) .picture_resource(&ref_res); ref_begin.p_next = &ref_dpb as *const _ as *const c_void; let mut ref_enc = vk::VideoReferenceSlotInfoKHR::default() .slot_index(ref_slot as i32) .picture_resource(&ref_res); ref_enc.p_next = &ref_dpb as *const _ as *const c_void; let begin_p = [ref_begin, begin_setup]; let begin_i = [begin_setup]; let enc_refs = [ref_enc]; // ---- rate control (generic layer + AV1 codec info chained manually) ---- // Mode + window are the values latched at open; this struct DECLARES the session's // CURRENT state (`self.bitrate`) — see the HEVC twin for the 08254 state discipline. let rc_layer = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(self.bitrate) .max_bitrate(self.bitrate) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let av1_rc = av1::VideoEncodeAV1RateControlInfoKHR { s_type: av1::stype(av1::ST_RATE_CONTROL_INFO), p_next: std::ptr::null(), flags: 0, gop_frame_count: 0, key_frame_period: 0, consecutive_bipredictive_frame_count: 0, temporal_layer_count: 1, }; let mut rc = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&rc_layer) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); rc.p_next = &av1_rc as *const _ as *const c_void; let rc_ptr = &rc as *const _ as *const c_void; // ---- record cmd: begin + shared pre-encode barriers, then begin/encode/end coding ---- self.begin_encode_cmd(dev, cmd, query_pool, src_img, acquire)?; let begin_slots: &[vk::VideoReferenceSlotInfoKHR] = if is_idr { &begin_i } else { &begin_p }; let mut begin = vk::VideoBeginCodingInfoKHR::default() .video_session(self.session) .video_session_parameters(self.params) .reference_slots(begin_slots); // See the h265 twin: declare what the session actually has, not `!first_frame`. if self.rc_installed { begin.p_next = rc_ptr; } (self.vq_dev.fp().cmd_begin_video_coding_khr)(cmd, &begin); if self.first_frame { // RESET + rate-control install + explicit quality level — see the HEVC twin for both // disciplines: why ENCODE_QUALITY_LEVEL is explicit, and why the install (not the // declaration) is what a pending retarget folds into (VUID-...-08254). let nb = self.pending_bitrate.unwrap_or(self.bitrate); let install_layer = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(nb) .max_bitrate(nb) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let mut install_rc = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&install_layer) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); install_rc.p_next = &av1_rc as *const _ as *const c_void; let mut q = vk::VideoEncodeQualityLevelInfoKHR::default().quality_level(self.quality_level); q.p_next = &install_rc as *const _ as *const c_void; let mut ctrl = vk::VideoCodingControlInfoKHR::default().flags( vk::VideoCodingControlFlagsKHR::RESET | vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL | vk::VideoCodingControlFlagsKHR::ENCODE_QUALITY_LEVEL, ); ctrl.p_next = &q as *const _ as *const c_void; (self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl); } else if let Some(nb) = self.pending_bitrate { // Mid-stream retarget (`reconfigure_bitrate`) — see the HEVC twin for the state // discipline (begin declares CURRENT, this control installs NEW, `record_submit` // promotes after recording). No RESET, no IDR. let rc_layer2 = [vk::VideoEncodeRateControlLayerInfoKHR::default() .average_bitrate(nb) .max_bitrate(nb) .frame_rate_numerator(self.fps) .frame_rate_denominator(1)]; let mut rc2 = vk::VideoEncodeRateControlInfoKHR::default() .rate_control_mode(self.rc_mode) .layers(&rc_layer2) .virtual_buffer_size_in_ms(self.vbv_ms.0) .initial_virtual_buffer_size_in_ms(self.vbv_ms.1); rc2.p_next = &av1_rc as *const _ as *const c_void; let mut ctrl = vk::VideoCodingControlInfoKHR::default() .flags(vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL); ctrl.p_next = &rc2 as *const _ as *const c_void; (self.vq_dev.fp().cmd_control_video_coding_khr)(cmd, &ctrl); } dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty()); let src_res = vk::VideoPictureResourceInfoKHR::default() .coded_extent(src_extent) .image_view_binding(src_view); let mut enc = vk::VideoEncodeInfoKHR::default() .dst_buffer(bs_buf) .dst_buffer_offset(0) .dst_buffer_range(self.bs_size) .src_picture_resource(src_res) .setup_reference_slot(&setup_slot); if !is_idr { enc = enc.reference_slots(&enc_refs); } enc.p_next = &av1_pic as *const _ as *const c_void; (self.venc_dev.fp().cmd_encode_video_khr)(cmd, &enc); dev.cmd_end_query(cmd, query_pool, 0); (self.vq_dev.fp().cmd_end_video_coding_khr)(cmd, &vk::VideoEndCodingInfoKHR::default()); dev.end_command_buffer(cmd)?; Ok(()) } /// Read one completed slot's bitstream into an `EncodedFrame`, prepending the header framing: /// HEVC keyframes carry VPS/SPS/PPS; AV1 opens every temporal unit with a TD OBU and prepends the /// sequence-header OBU on keyframes. Caller must have confirmed the slot's fence is signaled. unsafe fn read_slot(&mut self, slot: usize) -> Result { let dev = self.device.clone(); let f = &self.frames[slot]; // Ask for the operation status alongside the two feedback words: without it a FAILED encode // is indistinguishable from a successful one, and its offset/bytes-written are read as if // they described real bitstream. The status rides as a trailing element (signed: // `VkQueryResultStatusKHR` is >0 COMPLETE, 0 NOT_READY, <0 error). let mut fb = [[0i32; 3]; 1]; dev.get_query_pool_results( f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT | vk::QueryResultFlags::WITH_STATUS_KHR, )?; let status = fb[0][2]; if status <= 0 { anyhow::bail!( "vulkan-encode: encode feedback for slot {slot} reports status {status} \ (not COMPLETE) — dropping the frame rather than shipping its bitstream" ); } let fb = [[fb[0][0] as u32, fb[0][1] as u32]]; // The (offset, bytes-written) pair is driver-reported: validate it against the bitstream // allocation BEFORE mapping, or the `from_raw_parts` below reads outside the buffer and // ships whatever it finds straight onto the wire. Checked in u64 so the add cannot wrap, // and before `map_memory` so there is no unmap to unwind on the error path. let (off64, len64) = (fb[0][0] as u64, fb[0][1] as u64); if off64.saturating_add(len64) > self.bs_size { anyhow::bail!( "vulkan-encode: driver reported bitstream feedback offset={off64} \ bytes_written={len64}, outside the {} byte bitstream buffer — the encode likely \ overflowed its destination range", self.bs_size ); } let (off, len) = (off64 as usize, len64 as usize); // PUNKTFUNK_PERF pre-encode split (best-effort): the fence signaled, so the compute/transfer // timestamps are available. This is a device duration, not permission to label the // remaining host fence wait as pure VCN time; queueing and synchronization remain in it. if self.ts_period_ns > 0.0 && f.ts_pool != vk::QueryPool::null() && f.ts_written && self.perf_at.elapsed() >= std::time::Duration::from_secs(2) { let mut ts = [0u64; 2]; if dev .get_query_pool_results( f.ts_pool, 0, &mut ts, vk::QueryResultFlags::TYPE_64 | vk::QueryResultFlags::WAIT, ) .is_ok() { self.perf_at = std::time::Instant::now(); let pre_encode_us = (ts[1].saturating_sub(ts[0]) as f64 * self.ts_period_ns) / 1000.0; if self.rgb.as_ref().is_some_and(|r| r.padded) { tracing::info!( rgb_copy_us = format!("{pre_encode_us:.0}"), au_bytes = len, "vulkan-encode split (sampled): padded RGB copy device time before EFC; \ remaining fence wait still includes queue synchronization + RGB→YUV EFC \ + video encode" ); } else { tracing::info!( csc_us = format!("{pre_encode_us:.0}"), au_bytes = len, "vulkan-encode split (sampled): RGB→NV12 compute batch device time; \ remaining fence wait still includes queue synchronization + video encode" ); } } } let f = &self.frames[slot]; let p = f.bs_ptr.0; debug_assert!(!p.is_null(), "bs_mem persistent mapping missing"); let prefix: &[u8] = if f.keyframe { &self.header } else { &self.frame_prefix }; let mut data = Vec::with_capacity(prefix.len() + len); data.extend_from_slice(prefix); data.extend_from_slice(std::slice::from_raw_parts(p.add(off), len)); Ok(EncodedFrame { data, pts_ns: f.pts_ns, keyframe: f.keyframe, recovery_anchor: f.recovery_anchor, chunk_aligned: false, }) } /// Acquire a free ring slot (blocking-draining the oldest if the ring is full), record+submit /// this frame into it without waiting, and track it as in-flight (FIFO). unsafe fn enqueue(&mut self, frame: &CapturedFrame, wire: i64) -> Result<()> { // Backpressure: if every slot is outstanding, block on the oldest, read it into `pending`, // and free it — that oldest slot is exactly the round-robin `ring` cursor we reuse next. while self.in_flight.len() >= self.frames.len() { let slot = self.in_flight.pop_front().unwrap(); // Bounded, not `u64::MAX`: this runs ON the host encode thread, which is also the // thread the stall watchdog's `reset()` would run on. An infinite wait against a // wedged GPU/driver therefore parks the one thread that could recover the session — // it never errors, never resets, and teardown blocks joining it. Surfacing expiry as // an error hands control back to the existing recovery path (same convention as the // pyrowave and Windows NVENC backends). match self.device.wait_for_fences( &[self.frames[slot].fence], true, ENCODE_FENCE_TIMEOUT_NS, ) { Ok(()) => {} Err(vk::Result::TIMEOUT) => anyhow::bail!( "vulkan-encode: fence for slot {slot} did not signal within {} ms — GPU or \ driver wedged; failing the submit so the session can reset", ENCODE_FENCE_TIMEOUT_NS / 1_000_000 ), Err(e) => return Err(e.into()), } let done = self.read_slot(slot)?; self.pending.push_back(done); } let slot = self.ring; self.ring = (self.ring + 1) % self.frames.len(); self.record_submit(slot, frame, wire)?; self.in_flight.push_back(slot); Ok(()) } } impl Encoder for VulkanVideoEncoder { fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { let wire = self.auto_wire; self.auto_wire += 1; // SAFETY: `enqueue` records/submits into a free ring slot owned by this encoder without // blocking on GPU completion (poll() does); `&mut self` guarantees exclusive access. unsafe { self.enqueue(frame, wire) } } fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> { self.auto_wire = wire_index as i64 + 1; // SAFETY: see `submit` — exclusive `&mut self`, all Vulkan work confined to owned objects. unsafe { self.enqueue(frame, wire_index as i64) } } fn caps(&self) -> EncoderCaps { EncoderCaps { supports_rfi: true, // Only the CSC path composites the metadata cursor (`prep_cursor` feeds the compute // shader). The RGB-direct/EFC front-end and the native-NV12 source have no compositing // stage at all — both merely warn once that the pointer is being dropped — so this is // the encoder's real answer, not a static one. blends_cursor: self.rgb.is_none() && !self.native_nv12, ..Default::default() } } fn request_keyframe(&mut self) { self.force_kf = true; } fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool { // Nonsense range → decline (same contract as the NVENC/AMF backends). if first_frame < 0 || first_frame > last_frame { return false; } // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision // shared with AMF and QSV — the fecbec2d sweep reached those two a commit before this // backend was carved out, and the hand-copy here shipped without it). Why the sweep: // "resident and older than THIS loss" is not the same as "the client decoded it" — after // an earlier loss [a,b] was recovered at wire r, everything in [a, r-1] is undecodable at // the client, yet those wires stay anchor candidates until the 8-slot ring rolls them // out, so a LATER loss could anchor on one and ship corruption tagged `recovery_anchor` — // the client's definitive re-anchor signal (reanchor.rs). // // This backend's mechanism: distrust = blank `slot_wire` ONLY. `slot_poc` must keep // naming every physically-resident DPB picture for `build_h265_rps_s0`, or a conforming // decoder evicts them and the anchor references a picture the client already dropped. // `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain. // `prev_slot` and the normal P-frame path are indices, not wires, so ordinary prediction // is unaffected. let plan = crate::rfi::plan_slot_recovery(&trusted_refs(&self.slot_wire), first_frame); for (s, w) in self.slot_wire.iter_mut().enumerate() { if plan.tainted & (1 << s) != 0 { *w = -1; } } // Can we anchor a clean P-frame to a resident slot strictly older than the loss? // (A sweep that empties every candidate yields `None` here and declines the RFI, matching // `qsv_live_ltr_rfi_taint_sweep_declines`.) match plan.anchor { Some(_) => { self.pending_loss = Some(first_frame); true } None => { // Decline WITHOUT self-arming an IDR: the caller owns the fallback, and its // keyframe path is cooldown-coalesced — arming `force_kf` here would bypass that // and turn a storm of hopeless RFI requests into one full IDR per request. // `pending_loss` is DELIBERATELY left armed (unlike AMF/QSV's pending_force // clear): a stale arm is re-resolved at frame-build, where a failed re-pick // forces the IDR that heals the stream — clearing it here would ship an untagged // plain P during the caller's RFI-echo window instead. tracing::debug!( first_frame, last_frame, "vulkan-encode RFI declined: no resident reference older than the loss — \ caller falls back to its (coalesced) keyframe path" ); false } } } fn poll(&mut self) -> Result> { // Backpressure-drained frames (already read, oldest) come out first, then the oldest slot // still in flight — both in submission order. BLOCKING, per the depth-1 pump contract // (`Capturer::pipeline_depth`: "capture → submit → poll-blocks", the convention the sync // NVENC backend's `lock_bitstream` follows): the pump polls right after submit and treats // `None` as "the backend holds the frame internally, re-poll next tick" (true only of the // libav AMF/QSV wrappers). A `get_fence_status` probe here therefore deferred every AU to // the NEXT tick — shipped a full frame period after the ASIC finished, so a ~5 ms VCN // encode read as `encode_us`≈interval (~17 ms at 60 Hz, the AMD field report) and cost one // frame of avoidable glass-to-glass latency. `None` now only means "nothing submitted". if let Some(f) = self.pending.pop_front() { return Ok(Some(f)); } let Some(&slot) = self.in_flight.front() else { return Ok(None); }; // Bounded like `enqueue`'s backpressure wait, and for the same reason: this is the thread // the stall recovery runs on, so a wedged GPU must surface as an error, not park it. // SAFETY: waiting a fence owned by this encoder's slot under `&mut self`. match unsafe { self.device .wait_for_fences(&[self.frames[slot].fence], true, ENCODE_FENCE_TIMEOUT_NS) } { Ok(()) => {} Err(vk::Result::TIMEOUT) => anyhow::bail!( "vulkan-encode: fence for slot {slot} did not signal within {} ms — GPU or \ driver wedged; failing the poll so the session can reset", ENCODE_FENCE_TIMEOUT_NS / 1_000_000 ), Err(e) => return Err(e.into()), } self.in_flight.pop_front(); // SAFETY: fence signaled ⟹ this slot's CSC+encode is complete; read its bitstream. Ok(Some(unsafe { self.read_slot(slot)? })) } fn reset(&mut self) -> bool { // Abandon everything in flight: wait the GPU idle, discard unread slots + queued output, and // restart GOP/DPB state so the next frame is a fresh IDR. // // The wait is BOUNDED, like every other wait on this thread (`ENCODE_FENCE_TIMEOUT_NS`, // same reasoning): reset() runs precisely because something upstream looks wedged, and an // untimed `device_wait_idle` would park the recovery path on the suspect device until the // kernel's GPU reset — if that ever comes. A timeout means the GPU really is wedged: // report "no in-place rebuild" so the caller ends the session with a real error instead. // (`Drop` still waits unbounded — teardown must be memory-safe even against a wedged // device, and by then the session is already over.) let fences: Vec = self .in_flight .iter() .map(|&s| self.frames[s].fence) .collect(); if !fences.is_empty() { // SAFETY: every in-flight slot's fence was submitted with its batch (`enqueue` pushes // only after a successful submit), and we hold `&mut self`. match unsafe { self.device .wait_for_fences(&fences, true, ENCODE_FENCE_TIMEOUT_NS) } { Ok(()) => {} Err(e) => { tracing::error!( error = ?e, "vulkan-encode: in-flight work did not go idle within {} ms — GPU or \ driver wedged; in-place rebuild abandoned", ENCODE_FENCE_TIMEOUT_NS / 1_000_000 ); return false; } } } // The fences cover the encode queue, and each encode waited its slot's `csc_sem`, so the // paired compute batches are done too. The residual — a compute batch whose encode-queue // submit failed (so it was never fenced or enqueued) — is what this sweep-up covers; it // is instant unless that rare orphan is itself wedged (then the kernel's GPU reset bounds // it, as before). An error here is a lost device: no rebuild on top of that. // SAFETY: waiting this encoder's own device idle under `&mut self`. if unsafe { self.device.device_wait_idle() }.is_err() { return false; } // Drop the dmabuf import cache while the device is provably idle (the only safe point // outside teardown): whatever wedged the GPU may be a capture-side reallocation, and the // rebuilt stream must re-import from live buffers rather than trust images imported for // the old pool. // SAFETY: device idle (waits above); each entry is owned by the cache, destroyed once. unsafe { for e in std::mem::take(&mut self.import_cache) { self.device.destroy_image_view(e.view, None); self.device.destroy_image(e.img, None); self.device.free_memory(e.mem, None); } } self.in_flight.clear(); self.pending.clear(); self.ring = 0; self.first_frame = true; self.force_kf = false; self.pending_loss = None; self.poc = 0; self.slot_wire.iter_mut().for_each(|s| *s = -1); self.slot_poc.iter_mut().for_each(|s| *s = -1); // A pending `reconfigure_bitrate` rate deliberately survives: the restart's first frame // folds it into the fresh RESET + rate-control install. true } fn reconfigure_bitrate(&mut self, bps: u64) -> bool { // The RC block is re-declared on every recorded frame, so the retarget is just a staged // rate: the next `record_submit` emits an ENCODE_RATE_CONTROL control command carrying it // — no session churn, no IDR. Same floor as `open` (a 0-rate layer is rejected) and the // same driver ceiling (the spec bounds every layer's bitrates by the profile's // `maxBitrate`; an ABR climb must not sail past it). Any clamp here is visible to the // session loop through `applied_bitrate_bps`, so the controller climbs from the real // rate, not the requested one. let clamped = bps.max(1_000_000).min(self.hw_max_bitrate); if clamped < bps { tracing::debug!( requested = bps, cap = self.hw_max_bitrate, "vulkan-encode: retarget clamped to the driver's maxBitrate" ); } self.pending_bitrate = Some(clamped); true } fn applied_bitrate_bps(&self) -> Option { // The encoder-side truth after the `hw_max_bitrate` clamp (open + retarget). Without // this, a clamped retarget leaves the session loop trusting the REQUESTED rate: the // send pacer drains at a phantom rate, the ceiling cache never learns, and the client // controller climbs from a base the ASIC never targets (§ABR overdrive — the trait doc // names this exact trap). A pending retarget is reported as applied: the very next // recorded frame installs it, and callers read this right after `reconfigure_bitrate`. Some(self.pending_bitrate.unwrap_or(self.bitrate)) } fn flush(&mut self) -> Result<()> { // Drain every outstanding slot in order into `pending` so a following poll-loop returns them. while let Some(slot) = self.in_flight.pop_front() { // SAFETY: wait this slot's fence, then read back its own owned bitstream objects. unsafe { self.device .wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?; let done = self.read_slot(slot)?; self.pending.push_back(done); } } Ok(()) } } /// One cached dmabuf import (see `import_cached`): keyed by `(st_dev, st_ino)`, carrying the /// extent it was imported at — a key hit must also prove the cached image still matches the /// caller's frame — plus the owning Vulkan objects. struct CachedImport { key: (u64, u64), extent: (u32, u32), img: vk::Image, mem: vk::DeviceMemory, view: vk::ImageView, } /// Every destructible Vulkan object the encoder owns, with the one `Drop` that destroys them in /// dependency order. Both teardown paths run through it so they cannot drift: /// /// - `open_inner` mirrors each object into one as it is created, so any early `?`/`bail!` (or /// panic) unwinds exactly what was built — previously every open failure leaked all prior /// objects (a `VkDevice` + GPU memory per retried open). The `Ok(Self)` hand-off disarms the /// guard with `mem::forget` after moving the collections out. /// - [`VulkanVideoEncoder`]'s `Drop` rebuilds one from its fields and drops it. /// /// Handles a failed build never reached stay null, and `vkDestroy*`/`vkFree*` are defined no-ops /// on `VK_NULL_HANDLE`, so the full sequence is safe to run against any prefix of the build. struct VkTeardown { instance: Option, // `device` and `vq_dev` are set together (the wrapper constructors after `create_device` are // infallible), so device-level objects can only exist once both are `Some`. device: Option, vq_dev: Option, import_cache: Vec, frames: Vec, compute_pool: vk::CommandPool, cmd_pool: vk::CommandPool, // Transient: alive only between its creation and the post-pipeline destroy in `open_inner` // (which nulls this); always null when rebuilt from the encoder's `Drop`. shader: vk::ShaderModule, csc_pipe: vk::Pipeline, csc_layout: vk::PipelineLayout, csc_pool: vk::DescriptorPool, csc_dsl: vk::DescriptorSetLayout, sampler: vk::Sampler, dpb_views: Vec, dpb_image: vk::Image, dpb_mem: vk::DeviceMemory, params: vk::VideoSessionParametersKHR, session: vk::VideoSessionKHR, session_mem: Vec, } impl VkTeardown { /// A fresh guard owning only the instance — every other handle starts null/empty. Written out /// field by field because struct-update syntax is not allowed on a `Drop` type (E0509). fn new(instance: ash::Instance) -> Self { Self { instance: Some(instance), device: None, vq_dev: None, import_cache: Vec::new(), frames: Vec::new(), compute_pool: vk::CommandPool::null(), cmd_pool: vk::CommandPool::null(), shader: vk::ShaderModule::null(), csc_pipe: vk::Pipeline::null(), csc_layout: vk::PipelineLayout::null(), csc_pool: vk::DescriptorPool::null(), csc_dsl: vk::DescriptorSetLayout::null(), sampler: vk::Sampler::null(), dpb_views: Vec::new(), dpb_image: vk::Image::null(), dpb_mem: vk::DeviceMemory::null(), params: vk::VideoSessionParametersKHR::null(), session: vk::VideoSessionKHR::null(), session_mem: Vec::new(), } } } impl Drop for VkTeardown { fn drop(&mut self) { // SAFETY: `device_wait_idle` first guarantees no GPU work still references any object, so // every handle destroyed below is idle and owned solely by `self`; each is freed exactly // once (the takes prevent a double free) and in dependency order (views before images // before memory, per-frame objects before their shared pools, session params before // session, session memory after the session, the device before the instance). Null handles // (a build prefix from a failed `open_inner`) are no-ops per the Vulkan spec. unsafe { if let Some(device) = self.device.take() { let _ = device.device_wait_idle(); for e in std::mem::take(&mut self.import_cache) { device.destroy_image_view(e.view, None); device.destroy_image(e.img, None); device.free_memory(e.mem, None); } // Per-frame ring resources (command buffers, descriptor sets freed with their pools). for f in std::mem::take(&mut self.frames) { device.destroy_semaphore(f.csc_sem, None); device.destroy_fence(f.fence, None); device.destroy_query_pool(f.query_pool, None); device.destroy_query_pool(f.ts_pool, None); device.destroy_image_view(f.pad_view, None); device.destroy_image(f.pad_img, None); device.free_memory(f.pad_mem, None); device.destroy_buffer(f.bs_buf, None); // bs_mem is persistently mapped (f.bs_ptr); vkFreeMemory implicitly unmaps. device.free_memory(f.bs_mem, None); for (img, mem, view) in [ (f.y_img, f.y_mem, f.y_view), (f.uv_img, f.uv_mem, f.uv_view), (f.nv12_src, f.nv12_mem, f.nv12_view), ] { device.destroy_image_view(view, None); device.destroy_image(img, None); device.free_memory(mem, None); } if let Some((i, m, v, ..)) = f.cpu_img { device.destroy_image_view(v, None); device.destroy_image(i, None); device.free_memory(m, None); } if let Some((b, m, _)) = f.cpu_stage { device.destroy_buffer(b, None); device.free_memory(m, None); } device.destroy_image_view(f.cursor_view, None); device.destroy_image(f.cursor_img, None); device.free_memory(f.cursor_mem, None); device.destroy_buffer(f.cursor_stage, None); device.free_memory(f.cursor_stage_mem, None); } device.destroy_command_pool(self.compute_pool, None); device.destroy_command_pool(self.cmd_pool, None); device.destroy_shader_module(self.shader, None); device.destroy_pipeline(self.csc_pipe, None); device.destroy_pipeline_layout(self.csc_layout, None); device.destroy_descriptor_pool(self.csc_pool, None); device.destroy_descriptor_set_layout(self.csc_dsl, None); device.destroy_sampler(self.sampler, None); for &v in &self.dpb_views { device.destroy_image_view(v, None); } device.destroy_image(self.dpb_image, None); device.free_memory(self.dpb_mem, None); if let Some(vq_dev) = self.vq_dev.take() { (vq_dev.fp().destroy_video_session_parameters_khr)( device.handle(), self.params, std::ptr::null(), ); (vq_dev.fp().destroy_video_session_khr)( device.handle(), self.session, std::ptr::null(), ); } for &m in &self.session_mem { device.free_memory(m, None); } device.destroy_device(None); } if let Some(instance) = self.instance.take() { instance.destroy_instance(None); } } } } impl Drop for VulkanVideoEncoder { fn drop(&mut self) { // The whole teardown sequence lives in `VkTeardown` (shared with `open_inner`'s failure // unwind): rebuild one from our fields and let its Drop run it. drop(VkTeardown { instance: Some(self.instance.clone()), device: Some(self.device.clone()), vq_dev: Some(self.vq_dev.clone()), import_cache: std::mem::take(&mut self.import_cache), frames: std::mem::take(&mut self.frames), compute_pool: self.compute_pool, cmd_pool: self.cmd_pool, shader: vk::ShaderModule::null(), csc_pipe: self.csc_pipe, csc_layout: self.csc_layout, csc_pool: self.csc_pool, csc_dsl: self.csc_dsl, sampler: self.sampler, dpb_views: std::mem::take(&mut self.dpb_views), dpb_image: self.dpb_image, dpb_mem: self.dpb_mem, params: self.params, session: self.session, session_mem: std::mem::take(&mut self.session_mem), }); } } // Session/frame construction + parameter-set builders live in `vk_build.rs` (WP7.5) — the // amf_sys.rs shape: a #[path] child module sees this file's private items (Frame and // friends), so the split costs no visibility churn. #[path = "vk_build.rs"] mod build; use self::build::{ align_up, build_parameters_av1, build_parameters_h265, make_frame, make_video_image, probe_rgb_direct, }; #[cfg(test)] mod tests { use super::{build_h265_rps_s0, parse_rgb_request, VulkanVideoEncoder}; use crate::{Codec, Encoder}; use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; /// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference. #[test] fn h265_rps_retains_all_residents() { // Steady state: slots hold POCs 8..15, current POC 16, reconstructing over the slot that // holds POC 8 (the oldest), referencing POC 15 (the newest). let slot_poc = [8i32, 9, 10, 11, 12, 13, 14, 15]; let (n, deltas, used) = build_h265_rps_s0(&slot_poc, 0, 15, 16); assert_eq!(n, 7, "all residents except the dying setup occupant"); // S0 is newest-first with cumulative deltas: POCs 15,14,...,9 → every step is 1. assert_eq!(&deltas[..7], &[0u16; 7], "delta_minus1 chain of 1-steps"); assert_eq!(used, 1 << 0, "only the newest (POC 15) is actively used"); // Recovery shape: reference an OLDER picture (POC 12) while newer residents stay listed. let (n, deltas, used) = build_h265_rps_s0(&slot_poc, 0, 12, 16); assert_eq!(n, 7); assert_eq!(used, 1 << 3, "POC 12 is 4th-newest → S0 index 3"); assert_eq!(&deltas[..7], &[0u16; 7]); // Sparse DPB right after an IDR: only POCs 0..2 resident, gaps encoded in the deltas. let slot_poc = [0i32, 1, 2, -1, -1, -1, -1, -1]; let (n, deltas, used) = build_h265_rps_s0(&slot_poc, 3, 2, 3); assert_eq!(n, 3); assert_eq!(&deltas[..3], &[0, 0, 0]); assert_eq!(used, 1 << 0); // Non-adjacent POCs: current 10, residents {9, 6, 2} → deltas-minus1 {0, 2, 3}. let slot_poc = [2i32, -1, 6, -1, 9, -1, -1, -1]; let (n, deltas, used) = build_h265_rps_s0(&slot_poc, 7, 6, 10); assert_eq!(n, 3); assert_eq!(&deltas[..3], &[0, 2, 3]); assert_eq!(used, 1 << 1, "POC 6 is the 2nd-newest → S0 index 1"); } fn cpu_frame(w: u32, h: u32, pts_ns: u64, fill: [u8; 4]) -> CapturedFrame { let mut buf = vec![0u8; (w * h * 4) as usize]; for px in buf.chunks_exact_mut(4) { px.copy_from_slice(&fill); } CapturedFrame { width: w, height: h, pts_ns, format: PixelFormat::Bgrx, payload: FramePayload::Cpu(buf), cursor: None, } } /// Index of the wire frame the smoke run "loses" and drops from the client-view dump. const SMOKE_LOST: usize = 4; /// Index of the recovery-anchor frame — the RFI fires just before this submission, and one /// normal P (frame 5, referencing the lost frame 4) is encoded IN BETWEEN, mirroring a real /// session where the loss report round-trips while the encoder keeps producing. That fed /// post-loss frame is what makes the dump exercise reference RETENTION: a conforming decoder /// processes its RPS before the anchor arrives, so the anchor's reference (frame 3) survives /// only because every P-frame's RPS lists all resident DPB pictures ([`build_h265_rps_s0`]). const SMOKE_ANCHOR: usize = 6; /// Full `open` → IDR → P-frames → RFI-recovery path through the real [`VulkanVideoEncoder`], /// codec-parameterized. Exercises the CPU→NV12 compute CSC, the NV12 plane copy, the DPB ring and /// the reference-slot RFI end-to-end; returns the AUs. Wire frame [`SMOKE_LOST`] is "lost", one /// normal P referencing it is still encoded (the in-flight window), then frame [`SMOKE_ANCHOR`] /// is the clean recovery anchor referencing pre-loss frame 3 (no IDR). fn run_smoke(codec: Codec) -> Vec { run_smoke_opts(codec, false).expect("smoke") } /// `run_smoke` with the RGB-direct source explicit. `None` = requested but unavailable on /// this driver (probe declined) — the rgb test soft-skips instead of failing. fn run_smoke_opts(codec: Codec, rgb: bool) -> Option> { let env_dim = |k: &str, d: u32| { std::env::var(k) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(d) }; let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256)); let mut enc = VulkanVideoEncoder::open_opts(codec, w, h, 60, 10_000_000, rgb).expect("open"); if rgb && enc.rgb.is_none() { eprintln!("run_smoke_opts: RGB-direct unavailable on this driver — skipping"); return None; } assert!(enc.caps().supports_rfi, "must advertise RFI"); let colors = [ [40u8, 40, 200, 255], [40, 200, 40, 255], [200, 40, 40, 255], [200, 200, 40, 255], [40, 200, 200, 255], [200, 40, 200, 255], [120, 200, 80, 255], [80, 120, 200, 255], ]; let mut aus: Vec = Vec::new(); for (i, c) in colors.iter().enumerate() { if i == SMOKE_ANCHOR { // The client reports wire frame SMOKE_LOST lost → the next frame must re-anchor // on a resident pre-loss reference (newest older than the loss = frame 3). assert!( enc.invalidate_ref_frames(SMOKE_LOST as i64, SMOKE_LOST as i64), "RFI should find an older-than-loss slot" ); } enc.submit_indexed(&cpu_frame(w, h, i as u64 * 16_666_667, *c), i as u32) .expect("submit"); // The encoder is pipelined now: submit() no longer blocks, so drain whatever completed // (FIFO = submission order) and finish the tail via flush below. while let Some(au) = enc.poll().expect("poll") { aus.push(au); } } enc.flush().expect("flush"); while let Some(au) = enc.poll().expect("poll") { aus.push(au); } assert_eq!(aus.len(), colors.len(), "one AU per submitted frame"); let (mut keyframes, mut anchors) = (0usize, 0usize); for (i, au) in aus.iter().enumerate() { assert!(!au.data.is_empty(), "AU {i} empty"); keyframes += au.keyframe as usize; anchors += au.recovery_anchor as usize; if i == 0 { assert!(au.keyframe, "frame 0 must be IDR"); } if i == SMOKE_ANCHOR { assert!( au.recovery_anchor && !au.keyframe, "frame {SMOKE_ANCHOR} must be a clean recovery P-frame, not IDR" ); } } assert_eq!(keyframes, 1, "exactly one IDR (frame 0)"); assert_eq!( anchors, 1, "exactly one recovery anchor (frame {SMOKE_ANCHOR})" ); Some(aus) } /// Dump the full stream + a client-view stream with AU [`SMOKE_LOST`] removed to /// `$HOME/vkenc-host-smoke*.{ext}` for an out-of-band `ffmpeg` decode check. The full stream /// must decode 0-error. The dropped one mirrors what a real client feeds its decoder: expect /// exactly ONE missing-reference complaint (frame 5 referencing the lost frame 4 — the /// concealment the client's freeze hides) and NONE at the anchor — a complaint about the /// anchor's reference (frame 3 / POC 3) means reference retention regressed and the "clean" /// re-anchor ships corruption. fn dump_smoke(aus: &[crate::EncodedFrame], ext: &str) { let Ok(home) = std::env::var("HOME") else { return; }; let full: Vec = aus.iter().flat_map(|a| a.data.iter().copied()).collect(); let p1 = format!("{home}/vkenc-host-smoke.{ext}"); let _ = std::fs::write(&p1, &full); eprintln!( "run_smoke: wrote {p1} ({} bytes, {} AUs)", full.len(), aus.len() ); let dropped: Vec = aus .iter() .enumerate() .filter(|(i, _)| *i != SMOKE_LOST) .flat_map(|(_, a)| a.data.iter().copied()) .collect(); let p2 = format!("{home}/vkenc-host-smoke-dropped.{ext}"); let _ = std::fs::write(&p2, &dropped); eprintln!( "run_smoke: wrote {p2} (frame {SMOKE_LOST} dropped; frame 5 conceals, \ recovery@{SMOKE_ANCHOR} anchors to frame 3 and must decode clean)" ); } /// HEVC smoke. `#[ignore]`d so it only runs where a real `VK_KHR_video_encode_h265` driver exists /// — build in the distrobox, run on the host: /// cargo test -p punktfunk-host --features vulkan-encode --no-run /// target/debug/deps/punktfunk_host- --ignored --nocapture vulkan_smoke #[test] #[ignore = "needs a real VK_KHR_video_encode_h265 device (run on the RADV host, not the build box)"] fn vulkan_smoke() { dump_smoke(&run_smoke(Codec::H265), "h265"); } /// AV1 smoke — same path over `VK_KHR_video_encode_av1`. Dumps `.obu` (low-overhead OBU stream: /// our TD + seq-header prefixes ahead of each Vulkan-emitted frame OBU) for `ffmpeg` to decode. #[test] #[ignore = "needs a real VK_KHR_video_encode_av1 device (run on the RADV host, not the build box)"] fn vulkan_smoke_av1() { dump_smoke(&run_smoke(Codec::Av1), "obu"); } /// RGB-direct (EFC) smoke — the same full path with the session opened on the RGB encode /// source (`VK_VALVE_video_encode_rgb_conversion`): the CPU frames go through the staging /// upload into a profiled BGRA encode-src and the VCN front-end does the 709-narrow CSC. /// Soft-skips (prints + passes) where the extension/probe is unavailable, so it can sit in /// the same `--ignored` run as the other smokes on any RADV box. #[test] #[ignore = "needs VK_VALVE_video_encode_rgb_conversion (RADV >= Mesa 26.0 on EFC hardware)"] fn vulkan_smoke_rgb() { if let Some(aus) = run_smoke_opts(Codec::H265, true) { dump_smoke(&aus, "rgb.h265"); } } /// RGB-direct AV1 twin of [`vulkan_smoke_rgb`]. #[test] #[ignore = "needs VK_VALVE_video_encode_rgb_conversion (RADV >= Mesa 26.0 on EFC hardware)"] fn vulkan_smoke_rgb_av1() { if let Some(aus) = run_smoke_opts(Codec::Av1, true) { dump_smoke(&aus, "rgb.obu"); } } /// 24-bpp packed CPU frame (`PixelFormat::Rgb`/`Bgr` — the PipeWire portal's CPU /// negotiation). `rgb` is (r, g, b) regardless of `fmt`'s byte order. fn cpu_frame_24(w: u32, h: u32, pts_ns: u64, rgb: [u8; 3], fmt: PixelFormat) -> CapturedFrame { let px = match fmt { PixelFormat::Rgb => [rgb[0], rgb[1], rgb[2]], PixelFormat::Bgr => [rgb[2], rgb[1], rgb[0]], _ => unreachable!("24-bpp helper"), }; let mut buf = vec![0u8; (w * h * 3) as usize]; for p in buf.chunks_exact_mut(3) { p.copy_from_slice(&px); } CapturedFrame { width: w, height: h, pts_ns, format: fmt, payload: FramePayload::Cpu(buf), cursor: None, } } /// Drive one whole 24-bpp CPU session (WP5.4: served via `normalize_cpu_rgb`, not refused). /// Frames alternate Rgb/Bgr, which also crosses the staging image's format re-key each /// frame. Returns the AUs; colour truth is checked out-of-band (the dump + ffmpeg bars /// recipe from WP1.4) — in-tree the load-bearing assertions are that every submit encodes /// and, on-glass, that the validation layers stay silent through the expand + upload. fn run_smoke_cpu24(rgb_direct: bool) -> Option> { let env_dim = |k: &str, d: u32| { std::env::var(k) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(d) }; let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256)); let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, w, h, 60, 10_000_000, rgb_direct) .expect("open"); if rgb_direct && enc.rgb.is_none() { eprintln!("run_smoke_cpu24: RGB-direct unavailable on this driver — skipping"); return None; } let colors: [[u8; 3]; 4] = [[200, 40, 40], [40, 200, 40], [40, 40, 200], [200, 200, 40]]; let mut aus: Vec = Vec::new(); for i in 0..8usize { let fmt = if i % 2 == 0 { PixelFormat::Rgb } else { PixelFormat::Bgr }; let frame = cpu_frame_24(w, h, i as u64 * 16_666_667, colors[i / 2], fmt); enc.submit_indexed(&frame, i as u32).expect("submit 24-bpp"); while let Some(au) = enc.poll().expect("poll") { aus.push(au); } } enc.flush().expect("flush"); while let Some(au) = enc.poll().expect("poll") { aus.push(au); } assert_eq!(aus.len(), 8, "one AU per 24-bpp frame"); assert!(aus[0].keyframe, "frame 0 must be IDR"); Some(aus) } /// WP5.4 smoke — 24-bpp CPU frames through the CSC path. #[test] #[ignore = "needs a real VK_KHR_video_encode_h265 device (run on the RADV host, not the build box)"] fn vulkan_smoke_cpu_rgb24() { let aus = run_smoke_cpu24(false).expect("CSC mode never soft-skips"); if let Ok(home) = std::env::var("HOME") { let full: Vec = aus.iter().flat_map(|a| a.data.iter().copied()).collect(); let p = format!("{home}/vkenc-host-smoke-cpu24.h265"); let _ = std::fs::write(&p, &full); eprintln!("vulkan_smoke_cpu_rgb24: wrote {p} ({} bytes)", full.len()); } } /// WP5.4 smoke, RGB-direct twin — the expanded R8G8B8A8/B8G8R8A8 image IS the encode source /// here, so this also answers whether the EFC profile accepts an RGBA-order source on this /// hardware (critique F7: unverified until run). Soft-skips without the VALVE extension. #[test] #[ignore = "needs VK_VALVE_video_encode_rgb_conversion (RADV >= Mesa 26.0 on EFC hardware)"] fn vulkan_smoke_rgb_cpu24() { if let Some(aus) = run_smoke_cpu24(true) { if let Ok(home) = std::env::var("HOME") { let full: Vec = aus.iter().flat_map(|a| a.data.iter().copied()).collect(); let p = format!("{home}/vkenc-host-smoke-rgb-cpu24.h265"); let _ = std::fs::write(&p, &full); eprintln!("vulkan_smoke_rgb_cpu24: wrote {p} ({} bytes)", full.len()); } } } /// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging /// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on /// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the /// current frame's extent, so a same-format size increase wrote out of bounds — and `submit` /// still returned `Ok`, so nothing upstream noticed. /// /// The out-of-bounds copy is only *observable* through the Vulkan validation layers, so run this /// as: `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV /// PHOENIX 2026-07-25: 8 x VUID-vkCmdCopyBufferToImage-imageSubresource-07971 before the fix /// ("extent.width (512) exceeds imageSubresource width extent (128)"), zero after. #[test] #[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"] fn vulkan_cpu_img_survives_a_source_size_change() { // CSC mode (rgb=false) — that is the arm where the image is sized to the SOURCE frame. let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, 512, 512, 60, 10_000_000, false) .expect("open"); // `cpu_img` is cached PER RING SLOT, so the small frames must first populate every slot; // only when the ring wraps does a slot holding a 128x128 image get a 512x512 copy. eprintln!("phase 1: 8x 128x128 — populates every ring slot with a 128x128 cpu_img"); for i in 0..8u64 { enc.submit_indexed( &cpu_frame(128, 128, i * 16_666_667, [40, 40, 200, 255]), i as u32, ) .expect("submit small"); while enc.poll().expect("poll").is_some() {} } eprintln!("phase 2: 8x 512x512 — SAME format, so each slot REUSES its 128x128 image"); for i in 8..16u64 { let r = enc.submit_indexed( &cpu_frame(512, 512, i * 16_666_667, [200, 40, 40, 255]), i as u32, ); r.expect("submit after the source grew"); while matches!(enc.poll(), Ok(Some(_))) {} } let _ = enc.flush(); while matches!(enc.poll(), Ok(Some(_))) {} eprintln!("done — under validation layers this run must report ZERO VUID errors"); } /// A mid-stream [`Encoder::reset`] must not change what `vkCmdBeginVideoCodingKHR` declares /// about the session's rate-control state. `reset()` re-arms `first_frame` (the next frame /// re-issues RESET + install), but it does NOT rebuild the session, so the CBR installed /// earlier is still the session's current mode when that frame opens its coding scope — /// which is why the declaration keys on `rc_installed`, not `!first_frame`. /// /// Phase 2 also stages a `reconfigure_bitrate` BEFORE the reset — the pending rate survives /// a reset by design, and the post-reset first frame used to promote it into the begin /// declaration before the session had installed it (`VUID-...-08254`, the ` != /// ` sibling of the 08253 case below). /// /// Only the validation layers can see either mismatch, so run as: /// `VK_LOADER_LAYERS_ENABLE='*validation*' cargo test ... -- --ignored`. Confirmed on RADV /// PHOENIX: 08253 before its fix, zero after (2026-07-25); and with this retarget phase, /// exactly one 08254 on the pre-fix build vs zero on the fixed one (same day, same box). #[test] #[ignore = "needs a real VK_KHR_video_encode_h265 device; meaningful only under validation layers"] fn vulkan_reset_keeps_the_declared_rate_control_state() { let (w, h) = (256u32, 256u32); let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, w, h, 60, 10_000_000, false).expect("open"); eprintln!("phase 1: 4 frames (installs CBR on frame 0)"); for i in 0..4u64 { enc.submit_indexed( &cpu_frame(w, h, i * 16_666_667, [40, 40, 200, 255]), i as u32, ) .expect("submit"); while enc.poll().expect("poll").is_some() {} } eprintln!("phase 2: reconfigure_bitrate() then reset() — the 08254 coincidence"); // The ABR retarget and the stall watchdog live in the same encode loop and correlate // (congestion is when you retarget AND when the encoder wedges), so the pending rate // must survive the reset — and the NEXT frame's begin must still declare the OLD rate, // because a reset never touches the session object. Promoting the pending rate before // recording declared the new rate ahead of its install: // VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08254, one frame, exactly here. assert!(enc.reconfigure_bitrate(20_000_000), "retarget should stage"); assert!(enc.reset(), "reset should succeed"); eprintln!("phase 3: 4 more frames — first one re-declares the OLD rate, installs the NEW"); for i in 4..8u64 { enc.submit_indexed( &cpu_frame(w, h, i * 16_666_667, [200, 40, 40, 255]), i as u32, ) .expect("submit after reset"); while enc.poll().expect("poll").is_some() {} } eprintln!("done — under validation layers this run must report ZERO VUID errors"); } /// `PUNKTFUNK_VULKAN_RGB_DIRECT` accepts the same spellings as every sibling knob, trimmed. #[test] fn rgb_direct_knob_accepts_the_house_spellings() { for on in ["1", "true", "yes", "on", " 1", "1 ", "\ton\n"] { assert_eq!(parse_rgb_request(Some(on)), Some(true), "{on:?}"); } for off in ["0", "false", "no", "off", " 0", "0 ", "\toff\n"] { assert_eq!(parse_rgb_request(Some(off)), Some(false), "{off:?}"); } } /// Unset, empty and unrecognised values all mean "use the default" — never a force-on. The /// pre-fix parse read anything-but-`"0"` as a force, so a trailing space on `PUNKTFUNK_...=0 ` /// silently enabled the very path the operator was disabling, cursor and all. #[test] fn rgb_direct_knob_never_force_enables_on_an_unrecognised_value() { assert_eq!(parse_rgb_request(None), None); for junk in ["", " ", "2", "maybe", "0x0", "enabled"] { assert_eq!(parse_rgb_request(Some(junk)), None, "{junk:?}"); } } }