Files
punktfunk/crates/punktfunk-host/src/encode/linux/vulkan_video.rs
T
enricobuehler a1af916e38
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m25s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
arch / build-publish (push) Successful in 11m11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
android / android (push) Successful in 14m53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m24s
deb / build-publish (push) Successful in 11m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
ci / rust (push) Successful in 23m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m54s
docker / deploy-docs (push) Successful in 22s
apple / swift (push) Successful in 4m31s
windows-host / package (push) Successful in 14m46s
apple / screenshots (push) Successful in 18m26s
feat(host): in-place encoder rate reconfigure — ABR steps no longer cost an IDR
Every adaptive-bitrate step used to tear the encoder down and rebuild it,
opening on a full IDR (a 20-40x frame-size spike, in-flight AU forfeit and
an IDR-cooldown anchor) — exactly when the Automatic controller is climbing.
Encoder::reconfigure_bitrate(bps) retargets the LIVE encoder instead
(default false, so libavcodec/software paths keep the rebuild fallback,
which also still owns the bitrate clamping):

- Linux + Windows direct NVENC: nvEncReconfigureEncoder (added to the
  hand-rolled runtime EncodeApi tables) with resetEncoder=0 / forceIDR=0;
  the same init/config is re-authored via the new shared build_config/
  build_init_params with only avg/max bitrate + VBV (PUNKTFUNK_VBV_FRAMES)
  moved. On-hardware test: 20→60→10 Mbps in place, zero IDRs (RTX 5070 Ti).
- Native AMF: TargetBitrate/PeakBitrate/VBVBufferSize are dynamic
  properties — SetProperty on the live component, no Terminate/re-Init.
- Vulkan Video (HEVC + AV1): stage the rate and emit an
  ENCODE_RATE_CONTROL control command on the next recorded frame (begin
  keeps declaring the session's current state, as the spec requires).

The session glue tries the in-place retarget first and skips the rebuild/
inflight-clear/IDR-cooldown bookkeeping when it succeeds — the reference
chain and the wire-index prediction survive, so RFI keeps working across
rate steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:53:18 +02:00

2849 lines
121 KiB
Rust

//! 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 crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use crate::encode::{Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{bail, Context, Result};
use ash::vk;
use std::collections::VecDeque;
use std::ffi::c_void;
use std::os::fd::{AsRawFd, IntoRawFd};
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");
/// 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;
/// 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::<usize>().ok())
.map(|n| n.clamp(2, 6))
.unwrap_or(RING_DEFAULT)
}
/// Newest resident DPB slot whose wire index is strictly older than the loss — the clean anchor.
fn pick_recovery_slot(slot_wire: &[i64], loss_first: i64) -> Option<usize> {
let mut best: Option<usize> = None;
let mut best_wire = -1i64;
for (i, &w) in slot_wire.iter().enumerate() {
if w >= 0 && w < loss_first && w > best_wire {
best = Some(i);
best_wire = w;
}
}
best
}
/// 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 [`pick_recovery_slot`] 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<i32> = 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.
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
bs_buf: vk::Buffer,
bs_mem: vk::DeviceMemory,
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).
cpu_img: Option<(vk::Image, vk::DeviceMemory, vk::ImageView, vk::Format)>,
cpu_stage: Option<(vk::Buffer, vk::DeviceMemory, u64)>,
// 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,
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<vk::DeviceMemory>,
params: vk::VideoSessionParametersKHR,
// Keyframe prefix: HEVC = VPS/SPS/PPS; AV1 = temporal-delimiter OBU + sequence-header OBU.
header: Vec<u8>,
// 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<u8>,
// --- DPB ---
dpb_image: vk::Image,
dpb_mem: vk::DeviceMemory,
dpb_views: Vec<vk::ImageView>,
slot_wire: Vec<i64>, // wire index held per slot (-1 = empty) — RFI/loss domain
slot_poc: Vec<i32>, // 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<(u64, u64, vk::Image, vk::DeviceMemory, vk::ImageView)>,
// --- in-flight frame ring (pipelining) ---
frames: Vec<Frame>, // per-slot private resources
ring: usize, // next slot to record into (round-robin over `frames`)
in_flight: VecDeque<usize>, // slots submitted but not yet read back, oldest first
bs_size: u64,
cmd_pool: vk::CommandPool,
compute_pool: vk::CommandPool,
// --- rate control (CBR), rebuilt-safe ---
bitrate: u64,
fps: u32,
/// 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<u64>,
// --- 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 + CBR install + IDR
force_kf: bool, // request_keyframe / non-recoverable loss -> next frame is IDR
pending_loss: Option<i64>, // invalidate_ref_frames(first) -> recover on next frame
pending: VecDeque<EncodedFrame>,
}
// 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` (see `nvenc_cuda::NvencCudaEncoder::open`).
pub fn open(codec: Codec, width: u32, height: u32, fps: u32, bitrate_bps: u64) -> Result<Self> {
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),
)
}
}
unsafe fn open_inner(
codec: Codec,
w: u32,
h: u32,
rw: u32,
rh: u32,
fps: u32,
bitrate: u64,
) -> Result<Self> {
use super::vk_av1_encode as av1b;
let av1 = codec == Codec::Av1;
let codec_op = if av1 {
vk::VideoCodecOperationFlagsKHR::from_raw(av1b::VIDEO_CODEC_OPERATION_ENCODE_AV1)
} else {
vk::VideoCodecOperationFlagsKHR::ENCODE_H265
};
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")?;
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
// pick the physical device + encode queue family (skip llvmpipe)
let (pd, encode_family) = {
let mut found = None;
for pd in instance.enumerate_physical_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)
{
found = Some((pd, i as u32));
break;
}
}
if found.is_some() {
break;
}
}
found.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)
let compute_family = {
let qf = instance.get_physical_device_queue_family_properties(pd);
qf.iter()
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
.context("no compute queue")? as u32
};
// the encode profile — H265 Main, or AV1 Main (AV1 profile chained raw since ash 0.38 lacks it)
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);
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)
.push_next(&mut usage);
if av1 {
// prepend the AV1 profile into the p_next chain (it can't `push_next` — vendored struct)
av1_profile.p_next = profile.p_next;
profile.p_next = &av1_profile as *const _ as *const c_void;
} else {
profile = profile.push_next(&mut h265_profile);
}
// 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:?}");
}
let std_hdr = caps.std_header_version;
let av1_superblock128 = av1 && (av1_caps.superblock_sizes & av1b::SUPERBLOCK_SIZE_128 != 0);
// logical device: encode + compute queues + video extensions (AV1 ext name is raw — ash lacks it)
let dev_exts = [
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(),
];
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;
}
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);
// ---- 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,
};
let mut session_ci = vk::VideoSessionCreateInfoKHR::default()
.queue_family_index(encode_family)
.video_profile(&profile)
.picture_format(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;
}
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:?}");
}
// 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 session_mem = Vec::new();
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,
)?;
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) ----
let (params, header, frame_prefix) = if av1 {
build_parameters_av1(
&device,
&vq_dev,
session,
w,
h,
rw,
rh,
av1_caps.max_level,
av1_superblock128,
)?
} else {
let (p, hdr) =
build_parameters_h265(&device, &vq_dev, &venc_dev, session, w, h, rw, rh)?;
(p, hdr, Vec::new())
};
// ---- 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,
&[],
)?;
let dpb_views: Vec<vk::ImageView> = (0..DPB_SLOTS)
.map(|slot| make_view(&device, dpb_image, NV12, slot))
.collect::<Result<_>>()?;
// 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,
)?;
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)?;
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),
];
let csc_dsl = device.create_descriptor_set_layout(
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
None,
)?;
let dsls = [csc_dsl];
let csc_layout = device.create_pipeline_layout(
&vk::PipelineLayoutCreateInfo::default().set_layouts(&dsls),
None,
)?;
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];
device.destroy_shader_module(shader, None);
// 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)
.descriptor_count(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,
)?;
// ---- bitstream size (shared) + shared command pools ----
let bs_size = align_up(
3 * w as u64 * h as u64 + (1 << 16),
caps.min_bitstream_buffer_size_alignment.max(1),
);
let cmd_pool = device.create_command_pool(
&vk::CommandPoolCreateInfo::default()
.queue_family_index(encode_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None,
)?;
let compute_pool = device.create_command_pool(
&vk::CommandPoolCreateInfo::default()
.queue_family_index(compute_family)
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
None,
)?;
// ---- build the in-flight frame ring ----
let mut frames = Vec::with_capacity(nframes);
for _ in 0..nframes {
frames.push(make_frame(
&device,
&mem_props,
w,
h,
&fams,
&profile,
&mut profile_list,
csc_dsl,
csc_pool,
cmd_pool,
compute_pool,
bs_size,
)?);
}
Ok(Self {
_entry: entry,
instance,
device,
ext_fd,
vq_dev,
venc_dev,
encode_queue,
compute_queue,
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,
pending_bitrate: None,
width: w,
height: h,
render_w: rw,
render_h: rh,
poc: 0,
enc_count: 0,
auto_wire: 0,
first_frame: true,
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)],
&[],
);
}
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys.
unsafe fn import_dmabuf(
&self,
d: &crate::capture::DmabufFrame,
cw: u32,
ch: u32,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let fmt = fourcc_to_vk(d.fourcc)
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
let plane = [vk::SubresourceLayout::default()
.offset(d.offset as u64)
.row_pitch(d.stride as u64)];
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
.drm_format_modifier(d.modifier)
.plane_layouts(&plane);
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
let img = self.device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: cw,
height: ch,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
.usage(vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(&mut ext)
.push_next(&mut drm),
None,
)?;
// dup the fd; Vulkan takes ownership of the dup on a successful import.
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
let fd_props = {
let mut p = vk::MemoryFdPropertiesKHR::default();
let _ = (self.ext_fd.fp().get_memory_fd_properties_khr)(
self.device.handle(),
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
dup,
&mut p,
);
p.memory_type_bits
};
let req = self.device.get_image_memory_requirements(img);
let bits = req.memory_type_bits & fd_props;
let ti = find_mem(
&self.mem_props,
if bits != 0 {
bits
} else {
req.memory_type_bits
},
vk::MemoryPropertyFlags::empty(),
);
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
let mut import = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(dup);
let mem = self.device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(ti)
.push_next(&mut ded)
.push_next(&mut import),
None,
)?;
self.device.bind_image_memory(img, mem, 0)?;
let view = self.device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(img)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(0)),
None,
)?;
Ok((img, mem, view))
}
/// 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: &crate::capture::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(&(_, _, img, _, view)) = self.import_cache.iter().find(|e| (e.0, e.1) == key) {
return Ok((img, view, false));
}
let (img, mem, view) = self.import_dmabuf(d, cw, ch)?;
// 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).
while self.import_cache.len() >= IMPORT_CACHE_CAP {
let (_, _, oi, om, ov) = self.import_cache.remove(0);
self.device.destroy_image_view(ov, None);
self.device.destroy_image(oi, None);
self.device.free_memory(om, None);
}
self.import_cache.push((key.0, key.1, 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],
) -> Result<vk::ImageView> {
let dev = self.device.clone();
let (w, h) = (self.width, self.height);
let need = (w * h * 4) as u64;
if self.frames[slot].cpu_img.map(|(_, _, _, f)| f) != Some(fmt) {
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) = make_plain_image(
&dev,
&self.mem_props,
fmt,
w,
h,
vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
)?;
self.frames[slot].cpu_img = Some((i, m, v, fmt));
}
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 = dev.create_buffer(
&vk::BufferCreateInfo::default()
.size(need)
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
None,
)?;
let req = dev.get_buffer_memory_requirements(buf);
let mem = dev.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
&self.mem_props,
req.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE
| vk::MemoryPropertyFlags::HOST_COHERENT,
)),
None,
)?;
dev.bind_buffer_memory(buf, mem, 0)?;
self.frames[slot].cpu_stage = Some((buf, mem, need));
}
let (_, m, _) = self.frames[slot].cpu_stage.unwrap();
let p = dev.map_memory(m, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *mut u8;
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 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) ----
// Mid-stream rate retarget (`reconfigure_bitrate`): a first frame installs its RC state
// fresh (RESET + ENCODE_RATE_CONTROL in the record fns), so a pending rate folds straight
// into it; mid-stream it stays pending — the record fns emit an ENCODE_RATE_CONTROL
// control command against the declared current state, and step 5 promotes it.
if self.first_frame {
if let Some(nb) = self.pending_bitrate.take() {
self.bitrate = nb;
}
}
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 {
match pick_recovery_slot(&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. RGB source -> compute_cmd: prep barriers + CSC + copy into nv12_src ----
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),
)?;
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) => {
let fmt = pixel_to_vk(frame.format).context("unsupported CPU pixel format")?;
let view = self.ensure_cpu_rgb(slot, fmt, bytes)?;
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),
)
.image_extent(vk::Extent3D {
width: self.width,
height: self.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)"),
};
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],
&[],
);
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,
)],
);
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, is_idr, recovery, ref_slot,
setup_idx, poc,
)?;
} else {
self.record_coding_h265(
&dev, cmd, query_pool, bs_buf, nv12_src, nv12_view, 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,
)?;
// Stash the metadata `read_slot` needs once `fence` signals.
self.frames[slot].pts_ns = frame.pts_ns;
self.frames[slot].keyframe = is_idr;
self.frames[slot].recovery_anchor = recovery;
// ---- 5. advance DPB bookkeeping (in submission order, before returning) ----
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;
self.first_frame = false;
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::info!(
mbps = nb / 1_000_000,
"vulkan-encode: rate control retargeted in place (no IDR)"
);
}
Ok(())
}
/// Begin `cmd` and record the pre-encode setup shared by both codecs: the query-pool reset,
/// nv12_src GENERAL → VIDEO_ENCODE_SRC (the CSC semaphore already ordered the copy before
/// this), 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,
nv12_src: vk::Image,
) -> 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,
});
let pre_enc = [
vk::ImageMemoryBarrier2::default()
.src_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)
.src_access_mask(vk::AccessFlags2::NONE)
.dst_stage_mask(vk::PipelineStageFlags2::VIDEO_ENCODE_KHR)
.dst_access_mask(vk::AccessFlags2::VIDEO_ENCODE_READ_KHR)
.old_layout(vk::ImageLayout::GENERAL)
.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(nv12_src)
.subresource_range(color_range(0)),
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,
nv12_src: vk::Image,
nv12_view: vk::ImageView,
is_idr: bool,
ref_slot: usize,
setup_idx: usize,
poc: i32,
) -> Result<()> {
use ash::vk::native as h;
let ext2d = vk::Extent2D {
width: self.width,
height: self.height,
};
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];
// CBR rate control (chained manually; push_next would clobber rc.p_next)
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(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
.layers(&rc_layer)
.virtual_buffer_size_in_ms(1000)
.initial_virtual_buffer_size_in_ms(500);
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, nv12_src)?;
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);
if !self.first_frame {
begin.p_next = rc_ptr;
} // CBR is current state after frame 0's control
(self.vq_dev.fp().cmd_begin_video_coding_khr)(cmd, &begin);
if self.first_frame {
let mut ctrl = vk::VideoCodingControlInfoKHR::default().flags(
vk::VideoCodingControlFlagsKHR::RESET
| vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL,
);
ctrl.p_next = rc_ptr;
(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 CBR 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(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
.layers(&rc_layer2)
.virtual_buffer_size_in_ms(1000)
.initial_virtual_buffer_size_in_ms(500);
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(ext2d)
.image_view_binding(nv12_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,
nv12_src: vk::Image,
nv12_view: vk::ImageView,
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;
let ext2d = vk::Extent2D {
width: self.width,
height: self.height,
};
// ---- 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 seg: h::StdVideoAV1Segmentation = std::mem::zeroed();
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);
}
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;
std_pic.pSegmentation = &seg;
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
},
constant_q_index: quant.base_q_idx as u32,
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 ----
let setup_res = vk::VideoPictureResourceInfoKHR::default()
.coded_extent(ext2d)
.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(ext2d)
.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];
// ---- CBR rate control (generic layer + AV1 codec info chained manually) ----
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(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
.layers(&rc_layer)
.virtual_buffer_size_in_ms(1000)
.initial_virtual_buffer_size_in_ms(500);
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, nv12_src)?;
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);
if !self.first_frame {
begin.p_next = rc_ptr;
}
(self.vq_dev.fp().cmd_begin_video_coding_khr)(cmd, &begin);
if self.first_frame {
let mut ctrl = vk::VideoCodingControlInfoKHR::default().flags(
vk::VideoCodingControlFlagsKHR::RESET
| vk::VideoCodingControlFlagsKHR::ENCODE_RATE_CONTROL,
);
ctrl.p_next = rc_ptr;
(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(vk::VideoEncodeRateControlModeFlagsKHR::CBR)
.layers(&rc_layer2)
.virtual_buffer_size_in_ms(1000)
.initial_virtual_buffer_size_in_ms(500);
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(ext2d)
.image_view_binding(nv12_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<EncodedFrame> {
let dev = self.device.clone();
let f = &self.frames[slot];
let mut fb = [[0u32; 2]; 1];
dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?;
let (off, len) = (fb[0][0] as usize, fb[0][1] as usize);
let p =
dev.map_memory(f.bs_mem, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())? as *const u8;
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));
dev.unmap_memory(f.bs_mem);
Ok(EncodedFrame {
data,
pts_ns: f.pts_ns,
keyframe: f.keyframe,
recovery_anchor: f.recovery_anchor,
})
}
/// 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();
self.device
.wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?;
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,
..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;
}
// Can we anchor a clean P-frame to a resident slot strictly older than the loss?
match pick_recovery_slot(&self.slot_wire, first_frame) {
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.
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<Option<EncodedFrame>> {
// Backpressure-drained frames (already read, oldest) come out first, then the oldest slot
// still in flight once its fence signals — both in submission order. Non-blocking: an
// unfinished frame returns None so the caller keeps the pipeline moving.
if let Some(f) = self.pending.pop_front() {
return Ok(Some(f));
}
let Some(&slot) = self.in_flight.front() else {
return Ok(None);
};
// SAFETY: probing a fence + reading back this slot's own owned objects under `&mut self`.
let ready = unsafe { self.device.get_fence_status(self.frames[slot].fence)? };
if !ready {
return Ok(None);
}
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.
// SAFETY: `device_wait_idle` guarantees no slot's fence is still pending before we drop them.
unsafe {
let _ = self.device.device_wait_idle();
}
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 CBR layer is rejected).
self.pending_bitrate = Some(bps.max(1_000_000));
true
}
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(())
}
}
impl Drop for VulkanVideoEncoder {
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 drains prevent a double free) and in dependency order (views before images before
// memory, per-frame objects before their shared pools, session params before session).
unsafe {
let _ = self.device.device_wait_idle();
for (_, _, img, mem, view) in std::mem::take(&mut self.import_cache) {
self.device.destroy_image_view(view, None);
self.device.destroy_image(img, None);
self.device.free_memory(mem, None);
}
// Per-frame ring resources (command buffers, descriptor sets freed with their pools).
for f in std::mem::take(&mut self.frames) {
self.device.destroy_semaphore(f.csc_sem, None);
self.device.destroy_fence(f.fence, None);
self.device.destroy_query_pool(f.query_pool, None);
self.device.destroy_buffer(f.bs_buf, None);
self.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),
] {
self.device.destroy_image_view(view, None);
self.device.destroy_image(img, None);
self.device.free_memory(mem, None);
}
if let Some((i, m, v, _)) = f.cpu_img {
self.device.destroy_image_view(v, None);
self.device.destroy_image(i, None);
self.device.free_memory(m, None);
}
if let Some((b, m, _)) = f.cpu_stage {
self.device.destroy_buffer(b, None);
self.device.free_memory(m, None);
}
}
self.device.destroy_command_pool(self.compute_pool, None);
self.device.destroy_command_pool(self.cmd_pool, None);
self.device.destroy_pipeline(self.csc_pipe, None);
self.device.destroy_pipeline_layout(self.csc_layout, None);
self.device.destroy_descriptor_pool(self.csc_pool, None);
self.device
.destroy_descriptor_set_layout(self.csc_dsl, None);
self.device.destroy_sampler(self.sampler, None);
for &v in &self.dpb_views {
self.device.destroy_image_view(v, None);
}
self.device.destroy_image(self.dpb_image, None);
self.device.free_memory(self.dpb_mem, None);
(self.vq_dev.fp().destroy_video_session_parameters_khr)(
self.device.handle(),
self.params,
std::ptr::null(),
);
(self.vq_dev.fp().destroy_video_session_khr)(
self.device.handle(),
self.session,
std::ptr::null(),
);
for &m in &self.session_mem {
self.device.free_memory(m, None);
}
self.device.destroy_device(None);
self.instance.destroy_instance(None);
}
}
}
// ---------- free helpers ----------
fn color_range(layer: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: layer,
layer_count: 1,
}
}
fn align_up(v: u64, a: u64) -> u64 {
v.div_ceil(a) * a
}
unsafe fn find_mem(
mp: &vk::PhysicalDeviceMemoryProperties,
bits: u32,
want: vk::MemoryPropertyFlags,
) -> u32 {
for i in 0..mp.memory_type_count {
if (bits & (1 << i)) != 0 && mp.memory_types[i as usize].property_flags.contains(want) {
return i;
}
}
0
}
/// DRM fourcc -> the VkFormat whose *color* components match (Vulkan handles the byte swizzle).
fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
// fourcc_code(a,b,c,d) = a | b<<8 | c<<16 | d<<24
const XR24: u32 = 0x3432_5258; // XRGB8888
const AR24: u32 = 0x3432_5241; // ARGB8888
const XB24: u32 = 0x3432_4258; // XBGR8888
const AB24: u32 = 0x3432_4241; // ABGR8888
match fourcc {
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
match fmt {
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
_ => None,
}
}
unsafe fn make_view(
device: &ash::Device,
image: vk::Image,
fmt: vk::Format,
layer: u32,
) -> Result<vk::ImageView> {
Ok(device.create_image_view(
&vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(fmt)
.subresource_range(color_range(layer)),
None,
)?)
}
unsafe fn make_plain_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,
fmt: vk::Format,
w: u32,
h: u32,
usage: vk::ImageUsageFlags,
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
let img = device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: w,
height: h,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(usage)
.initial_layout(vk::ImageLayout::UNDEFINED),
None,
)?;
let req = device.get_image_memory_requirements(img);
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
mp,
req.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
)?;
device.bind_image_memory(img, mem, 0)?;
let view = make_view(device, img, fmt, 0)?;
Ok((img, mem, view))
}
unsafe fn make_video_image(
device: &ash::Device,
mp: &vk::PhysicalDeviceMemoryProperties,
fmt: vk::Format,
w: u32,
h: u32,
layers: u32,
usage: vk::ImageUsageFlags,
profile_list: &mut vk::VideoProfileListInfoKHR,
concurrent: &[u32],
) -> Result<(vk::Image, vk::DeviceMemory)> {
let mut ci = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(fmt)
.extent(vk::Extent3D {
width: w,
height: h,
depth: 1,
})
.mip_levels(1)
.array_layers(layers)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(usage)
.initial_layout(vk::ImageLayout::UNDEFINED)
.push_next(profile_list);
if concurrent.len() >= 2 {
ci = ci
.sharing_mode(vk::SharingMode::CONCURRENT)
.queue_family_indices(concurrent);
} else {
ci = ci.sharing_mode(vk::SharingMode::EXCLUSIVE);
}
let img = device.create_image(&ci, None)?;
let req = device.get_image_memory_requirements(img);
let mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(req.size)
.memory_type_index(find_mem(
mp,
req.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
)),
None,
)?;
device.bind_image_memory(img, mem, 0)?;
Ok((img, mem))
}
/// Build one in-flight frame's private resources: NV12 encode-src, Y/UV CSC scratch, its CSC
/// descriptor set (Y/UV bound now, RGB per use), the bitstream buffer + feedback query, and the
/// per-frame command buffers + sync. `profile_list`/`profile` are borrowed only during creation.
unsafe fn make_frame(
device: &ash::Device,
mem_props: &vk::PhysicalDeviceMemoryProperties,
w: u32,
h: u32,
fams: &[u32],
profile: &vk::VideoProfileInfoKHR,
profile_list: &mut vk::VideoProfileListInfoKHR,
csc_dsl: vk::DescriptorSetLayout,
csc_pool: vk::DescriptorPool,
cmd_pool: vk::CommandPool,
compute_pool: vk::CommandPool,
bs_size: u64,
) -> Result<Frame> {
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
let (nv12_src, nv12_mem) = make_video_image(
device,
mem_props,
NV12,
w,
h,
1,
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST,
profile_list,
fams,
)?;
let nv12_view = make_view(device, nv12_src, NV12, 0)?;
// CSC scratch (Y R8 full-res, UV RG8 half-res).
let (y_img, y_mem, y_view) = make_plain_image(
device,
mem_props,
vk::Format::R8_UNORM,
w,
h,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
)?;
let (uv_img, uv_mem, uv_view) = make_plain_image(
device,
mem_props,
vk::Format::R8G8_UNORM,
w / 2,
h / 2,
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
)?;
// Descriptor set — Y/UV storage bindings fixed; binding 0 (RGB) rewritten per use.
let dsls = [csc_dsl];
let csc_set = device.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(csc_pool)
.set_layouts(&dsls),
)?[0];
let y_info = [vk::DescriptorImageInfo::default()
.image_view(y_view)
.image_layout(vk::ImageLayout::GENERAL)];
let uv_info = [vk::DescriptorImageInfo::default()
.image_view(uv_view)
.image_layout(vk::ImageLayout::GENERAL)];
device.update_descriptor_sets(
&[
vk::WriteDescriptorSet::default()
.dst_set(csc_set)
.dst_binding(1)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&y_info),
vk::WriteDescriptorSet::default()
.dst_set(csc_set)
.dst_binding(2)
.descriptor_type(vk::DescriptorType::STORAGE_IMAGE)
.image_info(&uv_info),
],
&[],
);
// Bitstream buffer + feedback query.
let bs_buf = device.create_buffer(
&vk::BufferCreateInfo::default()
.size(bs_size)
.usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR)
.push_next(profile_list),
None,
)?;
let bs_req = device.get_buffer_memory_requirements(bs_buf);
let bs_mem = device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(bs_req.size)
.memory_type_index(find_mem(
mem_props,
bs_req.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)),
None,
)?;
device.bind_buffer_memory(bs_buf, bs_mem, 0)?;
let mut fb_ci = vk::QueryPoolVideoEncodeFeedbackCreateInfoKHR::default().encode_feedback_flags(
vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BUFFER_OFFSET
| vk::VideoEncodeFeedbackFlagsKHR::BITSTREAM_BYTES_WRITTEN,
);
fb_ci.p_next = profile as *const _ as *const c_void;
let mut query_ci = vk::QueryPoolCreateInfo::default()
.query_type(vk::QueryType::VIDEO_ENCODE_FEEDBACK_KHR)
.query_count(1);
query_ci.p_next = &fb_ci as *const _ as *const c_void;
let query_pool = device.create_query_pool(&query_ci, None)?;
// Command buffers + per-frame sync.
let cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool)
.command_buffer_count(1),
)?[0];
let compute_cmd = device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(compute_pool)
.command_buffer_count(1),
)?[0];
let csc_sem = device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)?;
let fence = device.create_fence(&vk::FenceCreateInfo::default(), None)?;
Ok(Frame {
compute_cmd,
cmd,
csc_sem,
fence,
query_pool,
bs_buf,
bs_mem,
csc_set,
y_img,
y_mem,
y_view,
uv_img,
uv_mem,
uv_view,
nv12_src,
nv12_mem,
nv12_view,
cpu_img: None,
cpu_stage: None,
pts_ns: 0,
keyframe: false,
recovery_anchor: false,
})
}
/// Author VPS/SPS/PPS (Main, level 4.0, low-latency, conformance-window crop) and return the
/// session-parameters object + the encoded header bytes (VPS+SPS+PPS NALs) for keyframes.
unsafe fn build_parameters_h265(
device: &ash::Device,
vq_dev: &ash::khr::video_queue::Device,
venc_dev: &ash::khr::video_encode_queue::Device,
session: vk::VideoSessionKHR,
w: u32,
h: u32,
rw: u32,
rh: u32,
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
use ash::vk::native as hh;
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
ptl.flags.set_general_progressive_source_flag(1);
ptl.flags.set_general_frame_only_constraint_flag(1);
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN;
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
dpbm.max_dec_pic_buffering_minus1[0] = (DPB_SLOTS - 1) as u8;
dpbm.max_num_reorder_pics[0] = 0;
dpbm.max_latency_increase_plus1[0] = 0;
let mut vps: hh::StdVideoH265VideoParameterSet = std::mem::zeroed();
vps.flags.set_vps_temporal_id_nesting_flag(1);
vps.flags.set_vps_sub_layer_ordering_info_present_flag(1);
vps.pDecPicBufMgr = &dpbm;
vps.pProfileTierLevel = &ptl;
let mut sps: hh::StdVideoH265SequenceParameterSet = std::mem::zeroed();
sps.flags.set_sps_temporal_id_nesting_flag(1);
sps.flags.set_sps_sub_layer_ordering_info_present_flag(1);
sps.chroma_format_idc = hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_420;
sps.pic_width_in_luma_samples = w;
sps.pic_height_in_luma_samples = h;
sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
sps.log2_diff_max_min_luma_coding_block_size = 3;
sps.log2_diff_max_min_luma_transform_block_size = 3;
sps.max_transform_hierarchy_depth_inter = 4;
sps.max_transform_hierarchy_depth_intra = 4;
sps.pProfileTierLevel = &ptl;
sps.pDecPicBufMgr = &dpbm;
if w != rw || h != rh {
sps.flags.set_conformance_window_flag(1);
sps.conf_win_right_offset = (w - rw) / 2; // 4:2:0 SubWidthC = 2
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
}
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
pps.flags.set_cu_qp_delta_enabled_flag(1);
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
let vps_arr = [vps];
let sps_arr = [sps];
let pps_arr = [pps];
let add = vk::VideoEncodeH265SessionParametersAddInfoKHR::default()
.std_vp_ss(&vps_arr)
.std_sp_ss(&sps_arr)
.std_pp_ss(&pps_arr);
let mut h265_ci = vk::VideoEncodeH265SessionParametersCreateInfoKHR::default()
.max_std_vps_count(1)
.max_std_sps_count(1)
.max_std_pps_count(1)
.parameters_add_info(&add);
let ci = vk::VideoSessionParametersCreateInfoKHR::default()
.video_session(session)
.push_next(&mut h265_ci);
let mut params = vk::VideoSessionParametersKHR::null();
let r = (vq_dev.fp().create_video_session_parameters_khr)(
device.handle(),
&ci,
std::ptr::null(),
&mut params,
);
if r != vk::Result::SUCCESS {
bail!("create_video_session_parameters: {r:?}");
}
let mut get_h265 = vk::VideoEncodeH265SessionParametersGetInfoKHR::default()
.write_std_vps(true)
.write_std_sps(true)
.write_std_pps(true)
.std_vps_id(0)
.std_sps_id(0)
.std_pps_id(0);
let get = vk::VideoEncodeSessionParametersGetInfoKHR::default()
.video_session_parameters(params)
.push_next(&mut get_h265);
let get_fn = venc_dev.fp().get_encoded_video_session_parameters_khr;
let mut fb = vk::VideoEncodeSessionParametersFeedbackInfoKHR::default();
let mut size: usize = 0;
let r = get_fn(
device.handle(),
&get,
&mut fb,
&mut size,
std::ptr::null_mut(),
);
if r != vk::Result::SUCCESS {
bail!("get header size: {r:?}");
}
let mut buf = vec![0u8; size];
let r = get_fn(
device.handle(),
&get,
&mut fb,
&mut size,
buf.as_mut_ptr() as *mut c_void,
);
if r != vk::Result::SUCCESS {
bail!("get header bytes: {r:?}");
}
buf.truncate(size);
Ok((params, buf))
}
/// AV1 low-overhead OBU bit-writer (MSB-first), used to hand-pack the sequence-header OBU that
/// Vulkan AV1 encode (unlike H26x) never emits itself.
struct Av1BitWriter {
buf: Vec<u8>,
cur: u8,
fill: u8,
}
impl Av1BitWriter {
fn new() -> Self {
Self {
buf: Vec::new(),
cur: 0,
fill: 0,
}
}
fn bit(&mut self, b: u32) {
self.cur = (self.cur << 1) | (b as u8 & 1);
self.fill += 1;
if self.fill == 8 {
self.buf.push(self.cur);
self.cur = 0;
self.fill = 0;
}
}
fn put(&mut self, val: u32, bits: u32) {
for i in (0..bits).rev() {
self.bit((val >> i) & 1);
}
}
/// Flush, zero-padding the final partial byte (OBU size field delimits the payload).
fn finish(mut self) -> Vec<u8> {
if self.fill > 0 {
self.cur <<= 8 - self.fill;
self.buf.push(self.cur);
}
self.buf
}
}
/// AV1 leb128 (little-endian base-128) encoding of an OBU size.
fn leb128(mut v: u64) -> Vec<u8> {
let mut out = Vec::new();
loop {
let mut byte = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
byte |= 0x80;
}
out.push(byte);
if v == 0 {
break;
}
}
out
}
/// Bit-pack a `sequence_header_obu` (AV1 spec §5.5) into a size-delimited OBU. The field values here
/// MUST mirror the `StdVideoAV1SequenceHeader` handed to the driver in `build_parameters_av1` so the
/// driver-emitted frame OBUs parse against this header. Single operating point, 8-bit 4:2:0,
/// order-hint on, CDEF+restoration+filter-intra allowed, everything exotic (compound/warp/superres)
/// disabled — the profile our single-reference P-frame encoder actually uses.
fn av1_sequence_header_obu(
sb128: bool,
fwb: u32,
fhb: u32,
max_w_m1: u32,
max_h_m1: u32,
order_hint_bits_minus_1: u32,
seq_level_idx: u32,
) -> Vec<u8> {
let mut w = Av1BitWriter::new();
w.put(0, 3); // seq_profile = MAIN
w.bit(0); // still_picture
w.bit(0); // reduced_still_picture_header
w.bit(0); // timing_info_present_flag
w.bit(0); // initial_display_delay_present_flag
w.put(0, 5); // operating_points_cnt_minus_1 = 0
w.put(0, 12); // operating_point_idc[0]
w.put(seq_level_idx, 5); // seq_level_idx[0]
if seq_level_idx > 7 {
w.bit(0); // seq_tier[0] = 0
}
w.put(fwb, 4); // frame_width_bits_minus_1
w.put(fhb, 4); // frame_height_bits_minus_1
w.put(max_w_m1, fwb + 1); // max_frame_width_minus_1
w.put(max_h_m1, fhb + 1); // max_frame_height_minus_1
w.bit(0); // frame_id_numbers_present_flag
w.bit(sb128 as u32); // use_128x128_superblock
w.bit(0); // enable_filter_intra
w.bit(0); // enable_intra_edge_filter
w.bit(0); // enable_interintra_compound
w.bit(0); // enable_masked_compound
w.bit(0); // enable_warped_motion
w.bit(0); // enable_dual_filter
w.bit(1); // enable_order_hint
w.bit(0); // enable_jnt_comp
w.bit(0); // enable_ref_frame_mvs
w.bit(1); // seq_choose_screen_content_tools -> seq_force_screen_content_tools = SELECT
w.bit(1); // seq_choose_integer_mv -> seq_force_integer_mv = SELECT
w.put(order_hint_bits_minus_1, 3); // order_hint_bits_minus_1
w.bit(0); // enable_superres
w.bit(0); // enable_cdef
w.bit(0); // enable_restoration
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range
w.bit(0); // high_bitdepth
w.bit(0); // mono_chrome
w.bit(0); // color_description_present_flag
w.bit(0); // color_range (studio/limited)
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
w.bit(0); // separate_uv_delta_q
w.bit(0); // film_grain_params_present
// trailing_bits(): a stop `1` bit then zero-pad to a byte (the size field delimits the OBU, but
// the parser still requires the trailing_one_bit — dav1d/cbs reject a plain zero pad).
w.bit(1);
let payload = w.finish();
let mut obu = vec![0x0au8]; // obu_header: type=OBU_SEQUENCE_HEADER(1), has_size_field=1
obu.extend_from_slice(&leb128(payload.len() as u64));
obu.extend_from_slice(&payload);
obu
}
/// AV1 session parameters + header framing. Vulkan AV1 encode emits only the per-frame OBU, so we
/// return the app-owned prefixes: a temporal-delimiter OBU that opens every temporal unit
/// (`frame_prefix`), and TD + the bit-packed sequence-header OBU for keyframes (`header`).
#[allow(clippy::too_many_arguments)]
unsafe fn build_parameters_av1(
device: &ash::Device,
vq_dev: &ash::khr::video_queue::Device,
session: vk::VideoSessionKHR,
w: u32,
h: u32,
_rw: u32,
_rh: u32,
max_level: ash::vk::native::StdVideoAV1Level,
sb128: bool,
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>, Vec<u8>)> {
use super::vk_av1_encode as av1;
use ash::vk::native as hh;
let fwb = 31 - w.leading_zeros(); // av_log2(w): enough bits for max_frame_width_minus_1 = w-1
let fhb = 31 - h.leading_zeros();
let order_hint_bits_minus_1: u32 = 7; // OrderHintBits = 8
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
// ---- Std sequence header (must match the OBU packed below) ----
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
cc.flags = cc_flags;
cc.BitDepth = 8;
cc.subsampling_x = 1;
cc.subsampling_y = 1;
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED;
cc.transfer_characteristics =
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
cc.matrix_coefficients =
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED;
cc.chroma_sample_position =
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
// Match FFmpeg's Vulkan AV1 encoder (proven on this RADV/VCN path): the ONLY coding tools
// enabled are order-hint and (per caps) 128x128 superblocks. CDEF, loop restoration, filter-
// intra, warped/compound motion, superres all OFF — enabling them made VCN emit frame-header
// sections whose bit layout our sequence header didn't match, desyncing every inter frame.
let mut sh_flags: hh::StdVideoAV1SequenceHeaderFlags = std::mem::zeroed();
if sb128 {
sh_flags.set_use_128x128_superblock(1);
}
sh_flags.set_enable_order_hint(1);
let mut sh: hh::StdVideoAV1SequenceHeader = std::mem::zeroed();
sh.flags = sh_flags;
sh.seq_profile = hh::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN;
sh.frame_width_bits_minus_1 = fwb as u8;
sh.frame_height_bits_minus_1 = fhb as u8;
sh.max_frame_width_minus_1 = (w - 1) as u16;
sh.max_frame_height_minus_1 = (h - 1) as u16;
sh.order_hint_bits_minus_1 = order_hint_bits_minus_1 as u8;
sh.seq_force_integer_mv = 2; // SELECT
sh.seq_force_screen_content_tools = 2; // SELECT
sh.pColorConfig = &cc;
// ---- single operating point conveying the level/tier the driver targets ----
let op = av1::StdVideoEncodeAV1OperatingPointInfo {
flags: std::mem::zeroed(),
operating_point_idc: 0,
seq_level_idx: seq_level_idx as u8,
seq_tier: 0,
decoder_buffer_delay: 0,
encoder_buffer_delay: 0,
initial_display_delay_minus_1: 0,
};
let ops = [op];
let av1_spci = av1::VideoEncodeAV1SessionParametersCreateInfoKHR {
s_type: av1::stype(av1::ST_SESSION_PARAMETERS_CREATE_INFO),
p_next: std::ptr::null(),
p_std_sequence_header: &sh,
p_std_decoder_model_info: std::ptr::null(),
std_operating_point_count: 1,
p_std_operating_points: ops.as_ptr() as *const c_void,
};
let mut ci = vk::VideoSessionParametersCreateInfoKHR::default().video_session(session);
ci.p_next = &av1_spci as *const _ as *const c_void;
let mut params = vk::VideoSessionParametersKHR::null();
let r = (vq_dev.fp().create_video_session_parameters_khr)(
device.handle(),
&ci,
std::ptr::null(),
&mut params,
);
if r != vk::Result::SUCCESS {
bail!("create_video_session_parameters (av1): {r:?}");
}
// ---- header framing: TD every temporal unit; TD + seq-header OBU on keyframes ----
let td = vec![0x12u8, 0x00]; // temporal_delimiter OBU (type=2, size=0)
let seq_obu = av1_sequence_header_obu(
sb128,
fwb,
fhb,
w - 1,
h - 1,
order_hint_bits_minus_1,
seq_level_idx,
);
let mut keyframe_prefix = td.clone();
keyframe_prefix.extend_from_slice(&seq_obu);
Ok((params, keyframe_prefix, td))
}
#[cfg(test)]
mod tests {
use super::{build_h265_rps_s0, pick_recovery_slot, VulkanVideoEncoder};
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use crate::encode::{Codec, Encoder};
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
/// slots never qualify.
#[test]
fn recovery_slot_picks_newest_pre_loss() {
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
assert_eq!(pick_recovery_slot(&wires, 9), Some(0));
// loss older than everything resident → no anchor (caller keyframes).
assert_eq!(pick_recovery_slot(&wires, 5), None);
// empty slots (-1) are skipped.
assert_eq!(pick_recovery_slot(&[-1, 3, -1, 4], 5), Some(3));
assert_eq!(pick_recovery_slot(&[-1; 8], 5), None);
}
/// 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),
}
}
/// 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<crate::encode::EncodedFrame> {
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(codec, w, h, 60, 10_000_000).expect("open");
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<crate::encode::EncodedFrame> = 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})"
);
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::encode::EncodedFrame], ext: &str) {
let Ok(home) = std::env::var("HOME") else {
return;
};
let full: Vec<u8> = 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<u8> = 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
/// <host> target/debug/deps/punktfunk_host-<hash> --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");
}
}