feat(encode): AV1 on the Linux Vulkan Video encoder (real RFI)
ci / web (push) Successful in 1m12s
ci / docs-site (push) Successful in 1m22s
decky / build-publish (push) Successful in 19s
ci / bench (push) Successful in 5m36s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
arch / build-publish (push) Successful in 12m32s
docker / deploy-docs (push) Successful in 21s
android / android (push) Successful in 13m27s
ci / rust (push) Successful in 17m43s
deb / build-publish (push) Successful in 13m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m36s
windows-host / package (push) Successful in 14m43s
apple / swift (push) Successful in 4m42s
apple / screenshots (push) Successful in 19m20s

Extend the raw Vulkan Video backend to AV1 (`VK_KHR_video_encode_av1`)
alongside HEVC, so AMD/Intel Linux hosts get the same clean-P-frame loss
recovery for AV1 that HEVC already has — no full IDR on packet loss.

ash 0.38.0+1.3.281 predates the AV1-encode extension (finalized in Vulkan
1.3.290) and bumping ash breaks the SDL/Vulkan client (it drops the
lifetime on AllocationCallbacks, which sdl3-sys still generates). So the
AV1-encode structs/flags/enums are vendored host-only in
`encode/linux/vk_av1_encode.rs`, copied verbatim from ash-master's
generated code and chained into ash's generic video-encode calls via raw
p_next — the common StdVideoAV1* types (from AV1 decode) are reused from
ash 1.3.281.

`vulkan_video.rs` gains a parallel AV1 path: AV1 Main profile/caps/session
(+ max-level session-create), a bit-packed sequence-header OBU + per-frame
temporal-delimiter framing (Vulkan AV1 encode, unlike H26x, emits only the
frame OBU), and per-frame StdVideoEncodeAV1PictureInfo with the RFI
reference model — a normal P inherits CDF context from its reference for
compression, while an IDR or recovery anchor sets primary_ref_frame=NONE +
error_resilient_mode so it decodes independent of the (possibly lost)
frames since its reference. HEVC recording is unchanged; the shared CSC /
ring / DPB-barrier pipeline is reused as-is. Codec routing in
`open_video_backend` extends the HEVC arm to HEVC|AV1.

The seq header enables only order-hint (+128px superblocks per caps),
matching FFmpeg's proven Vulkan AV1 config — enabling CDEF/restoration made
VCN emit frame-header sections our seq header didn't match, desyncing every
inter frame.

Headless-validated on real RADV (780M): open + 6-frame encode (I P P P P P)
decodes 0-error on both dav1d and ffmpeg/cbs; the RFI recovery anchor at
frame 4 is a clean P (not IDR), and dropping the "lost" frame 3 still
decodes clean (re-anchored to frame 2). HEVC smoke unchanged (no
regression). `cargo clippy --features vulkan-encode -- -D warnings` and the
no-feature build both green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 22:03:13 +02:00
parent 9514a8c0e2
commit 2d37835545
3 changed files with 1437 additions and 296 deletions
+12 -4
View File
@@ -536,12 +536,13 @@ fn open_video_backend(
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
let open_amd_intel = || -> Result<Box<dyn Encoder>> {
#[cfg(feature = "vulkan-encode")]
if codec == Codec::H265 && vulkan_encode_enabled() {
if matches!(codec, Codec::H265 | Codec::Av1) && vulkan_encode_enabled() {
match vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
{
Ok(e) => {
tracing::info!(
"Linux Vulkan Video HEVC encode (real RFI via DPB reference slots) — \
codec = ?codec,
"Linux Vulkan Video encode (real RFI via DPB reference slots) — \
set PUNKTFUNK_VULKAN_ENCODE=0 for libav VAAPI"
);
return Ok(Box::new(e) as Box<dyn Encoder>);
@@ -581,9 +582,9 @@ fn open_video_backend(
"vulkan" | "vulkan-video" => {
#[cfg(feature = "vulkan-encode")]
{
if codec != Codec::H265 {
if !matches!(codec, Codec::H265 | Codec::Av1) {
anyhow::bail!(
"the Vulkan Video encoder is HEVC-only; the session negotiated {codec:?}"
"the Vulkan Video encoder supports HEVC + AV1; the session negotiated {codec:?}"
);
}
vulkan_video::VulkanVideoEncoder::open(codec, width, height, fps, bitrate_bps)
@@ -1251,6 +1252,13 @@ mod vaapi;
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
#[path = "encode/linux/vulkan_video.rs"]
mod vulkan_video;
// Vendored `VK_KHR_video_encode_av1` bindings (host-only) — the AV1 encode structs our pinned
// `ash 0.38.0+1.3.281` predates (finalized Vulkan 1.3.290). Copied verbatim from ash-master's
// generated code rather than bumping `ash` (which breaks the SDL/Vulkan client). Consumed by
// `vulkan_video.rs` via `super::vk_av1_encode`.
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
#[path = "encode/linux/vk_av1_encode.rs"]
mod vk_av1_encode;
#[cfg(test)]
mod tests {
@@ -0,0 +1,488 @@
//! Vendored `VK_KHR_video_encode_av1` bindings — the AV1-encode structs, `StdVideoEncodeAV1*`
//! types and struct-type constants that our pinned `ash 0.38.0+1.3.281` does not ship (the
//! extension was finalized in Vulkan 1.3.290). Bumping `ash` to git-master (`+1.4.352`, which has
//! them) breaks the *client*: it drops the lifetime on `vk::AllocationCallbacks`, and `sdl3-sys`'s
//! `ash` feature still generates `AllocationCallbacks<'static>`, so the presenter's SDL/Vulkan
//! surface path won't compile. Rather than churn the client for a host-only need, we vendor just
//! the encode-side definitions here, **copied verbatim from ash-master's generated code** (so the
//! layouts are correct-by-construction) and chain them into ash's generic video-encode-queue calls
//! via raw `p_next`, exactly as the HEVC path already chains its rate-control struct.
//!
//! Everything *common* to AV1 (sequence header, tile/quant/loop-filter/CDEF/… sub-structs, the
//! `StdVideoAV1*` enums) is already present in 1.3.281's `ash::vk::native` — AV1 **decode** brought
//! it in — so we reuse those and vendor only the encode-specific pieces. Delete this module and
//! switch to `ash::vk::*` once `ash` publishes a 1.4.x release and `sdl3-sys` regenerates.
#![allow(non_snake_case, non_camel_case_types, dead_code)]
use ash::vk;
use ash::vk::native::{
StdVideoAV1FrameType, StdVideoAV1InterpolationFilter, StdVideoAV1Level, StdVideoAV1Profile,
StdVideoAV1SequenceHeader, StdVideoAV1TxMode,
};
use std::ffi::{c_void, CStr};
/// `VK_KHR_video_encode_av1` extension name — ash 0.38's `ash::khr::video_encode_av1` doesn't exist,
/// so we pass this raw to `enabled_extension_names`.
pub const EXTENSION_NAME: &CStr = c"VK_KHR_video_encode_av1";
// ---------- struct-type (VkStructureType) values — construct via `vk::StructureType::from_raw` ----------
pub const ST_CAPABILITIES: i32 = 1_000_513_000;
pub const ST_SESSION_PARAMETERS_CREATE_INFO: i32 = 1_000_513_001;
pub const ST_PICTURE_INFO: i32 = 1_000_513_002;
pub const ST_DPB_SLOT_INFO: i32 = 1_000_513_003;
pub const ST_PROFILE_INFO: i32 = 1_000_513_005;
pub const ST_RATE_CONTROL_INFO: i32 = 1_000_513_006;
pub const ST_RATE_CONTROL_LAYER_INFO: i32 = 1_000_513_007;
pub const ST_SESSION_CREATE_INFO: i32 = 1_000_513_009;
pub const ST_GOP_REMAINING_FRAME_INFO: i32 = 1_000_513_010;
/// `VK_VIDEO_CODEC_OPERATION_ENCODE_AV1_BIT_KHR` (bit 18).
pub const VIDEO_CODEC_OPERATION_ENCODE_AV1: u32 = 0x0004_0000;
/// `VK_MAX_VIDEO_AV1_REFERENCES_PER_FRAME_KHR` — LAST..ALTREF (the 7 inter reference names).
pub const MAX_VIDEO_AV1_REFERENCES_PER_FRAME: usize = 7;
/// `STD_VIDEO_AV1_PRIMARY_REF_NONE` — a frame that inherits no CDF/context from any reference
/// (the recovery-anchor lever: a clean P-frame independent of prior probability state).
pub const PRIMARY_REF_NONE: u8 = 7;
/// `VK_VIDEO_ENCODE_AV1_SUPERBLOCK_SIZE_128_BIT_KHR` (bit 1 of the superblock-size flags).
pub const SUPERBLOCK_SIZE_128: u32 = 0x2;
// `VkVideoEncodeAV1PredictionModeKHR`
pub const PREDICTION_MODE_INTRA_ONLY: i32 = 0;
pub const PREDICTION_MODE_SINGLE_REFERENCE: i32 = 1;
// `VkVideoEncodeAV1RateControlGroupKHR`
pub const RC_GROUP_INTRA: i32 = 0;
pub const RC_GROUP_PREDICTIVE: i32 = 1;
pub const RC_GROUP_BIPREDICTIVE: i32 = 2;
// AV1 reference names (index into `reference_name_slot_indices`, which is 0-based over LAST..ALTREF).
pub const REFERENCE_NAME_LAST_FRAME_IDX: usize = 0; // STD_VIDEO_AV1_REFERENCE_NAME_LAST_FRAME - 1
// ---------- bindgen bitfield helper (copied verbatim from ash-master native.rs) ----------
#[repr(C)]
#[derive(Debug, Default, Copy, Clone)]
pub struct __BindgenBitfieldUnit<Storage> {
storage: Storage,
}
impl<Storage> __BindgenBitfieldUnit<Storage> {
#[inline]
pub const fn new(storage: Storage) -> Self {
Self { storage }
}
}
impl<Storage> __BindgenBitfieldUnit<Storage>
where
Storage: AsRef<[u8]> + AsMut<[u8]>,
{
#[inline]
pub fn get_bit(&self, index: usize) -> bool {
let byte_index = index / 8;
let byte = self.storage.as_ref()[byte_index];
let bit_index = if cfg!(target_endian = "big") {
7 - (index % 8)
} else {
index % 8
};
byte & (1 << bit_index) == (1 << bit_index)
}
#[inline]
pub fn set_bit(&mut self, index: usize, val: bool) {
let byte_index = index / 8;
let byte = &mut self.storage.as_mut()[byte_index];
let bit_index = if cfg!(target_endian = "big") {
7 - (index % 8)
} else {
index % 8
};
let mask = 1 << bit_index;
if val {
*byte |= mask;
} else {
*byte &= !mask;
}
}
#[inline]
pub fn get(&self, bit_offset: usize, bit_width: u8) -> u64 {
let mut val = 0;
for i in 0..(bit_width as usize) {
if self.get_bit(i + bit_offset) {
let index = if cfg!(target_endian = "big") {
bit_width as usize - 1 - i
} else {
i
};
val |= 1 << index;
}
}
val
}
#[inline]
pub fn set(&mut self, bit_offset: usize, bit_width: u8, val: u64) {
for i in 0..(bit_width as usize) {
let mask = 1 << i;
let val_bit_is_set = val & mask == mask;
let index = if cfg!(target_endian = "big") {
bit_width as usize - 1 - i
} else {
i
};
self.set_bit(index + bit_offset, val_bit_is_set);
}
}
}
// ---------- Std encode structs (copied from ash-master native.rs; common Std types reused from ash) ----------
#[repr(C, align(4))]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1PictureInfoFlags {
pub _bitfield_align_1: [u8; 0],
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
}
impl StdVideoEncodeAV1PictureInfoFlags {
#[inline]
pub fn set_error_resilient_mode(&mut self, val: u32) {
self._bitfield_1.set(0, 1, val as u64)
}
#[inline]
pub fn set_disable_cdf_update(&mut self, val: u32) {
self._bitfield_1.set(1, 1, val as u64)
}
#[inline]
pub fn set_use_superres(&mut self, val: u32) {
self._bitfield_1.set(2, 1, val as u64)
}
#[inline]
pub fn set_render_and_frame_size_different(&mut self, val: u32) {
self._bitfield_1.set(3, 1, val as u64)
}
#[inline]
pub fn set_allow_screen_content_tools(&mut self, val: u32) {
self._bitfield_1.set(4, 1, val as u64)
}
#[inline]
pub fn set_is_filter_switchable(&mut self, val: u32) {
self._bitfield_1.set(5, 1, val as u64)
}
#[inline]
pub fn set_force_integer_mv(&mut self, val: u32) {
self._bitfield_1.set(6, 1, val as u64)
}
#[inline]
pub fn set_frame_size_override_flag(&mut self, val: u32) {
self._bitfield_1.set(7, 1, val as u64)
}
#[inline]
pub fn set_buffer_removal_time_present_flag(&mut self, val: u32) {
self._bitfield_1.set(8, 1, val as u64)
}
#[inline]
pub fn set_allow_intrabc(&mut self, val: u32) {
self._bitfield_1.set(9, 1, val as u64)
}
#[inline]
pub fn set_frame_refs_short_signaling(&mut self, val: u32) {
self._bitfield_1.set(10, 1, val as u64)
}
#[inline]
pub fn set_allow_high_precision_mv(&mut self, val: u32) {
self._bitfield_1.set(11, 1, val as u64)
}
#[inline]
pub fn set_is_motion_mode_switchable(&mut self, val: u32) {
self._bitfield_1.set(12, 1, val as u64)
}
#[inline]
pub fn set_use_ref_frame_mvs(&mut self, val: u32) {
self._bitfield_1.set(13, 1, val as u64)
}
#[inline]
pub fn set_disable_frame_end_update_cdf(&mut self, val: u32) {
self._bitfield_1.set(14, 1, val as u64)
}
#[inline]
pub fn set_allow_warped_motion(&mut self, val: u32) {
self._bitfield_1.set(15, 1, val as u64)
}
#[inline]
pub fn set_reduced_tx_set(&mut self, val: u32) {
self._bitfield_1.set(16, 1, val as u64)
}
#[inline]
pub fn set_skip_mode_present(&mut self, val: u32) {
self._bitfield_1.set(17, 1, val as u64)
}
#[inline]
pub fn set_delta_q_present(&mut self, val: u32) {
self._bitfield_1.set(18, 1, val as u64)
}
#[inline]
pub fn set_delta_lf_present(&mut self, val: u32) {
self._bitfield_1.set(19, 1, val as u64)
}
#[inline]
pub fn set_delta_lf_multi(&mut self, val: u32) {
self._bitfield_1.set(20, 1, val as u64)
}
#[inline]
pub fn set_segmentation_enabled(&mut self, val: u32) {
self._bitfield_1.set(21, 1, val as u64)
}
#[inline]
pub fn set_segmentation_update_map(&mut self, val: u32) {
self._bitfield_1.set(22, 1, val as u64)
}
#[inline]
pub fn set_segmentation_temporal_update(&mut self, val: u32) {
self._bitfield_1.set(23, 1, val as u64)
}
#[inline]
pub fn set_segmentation_update_data(&mut self, val: u32) {
self._bitfield_1.set(24, 1, val as u64)
}
#[inline]
pub fn set_UsesLr(&mut self, val: u32) {
self._bitfield_1.set(25, 1, val as u64)
}
#[inline]
pub fn set_usesChromaLr(&mut self, val: u32) {
self._bitfield_1.set(26, 1, val as u64)
}
#[inline]
pub fn set_show_frame(&mut self, val: u32) {
self._bitfield_1.set(27, 1, val as u64)
}
#[inline]
pub fn set_showable_frame(&mut self, val: u32) {
self._bitfield_1.set(28, 1, val as u64)
}
#[inline]
pub fn set_reserved(&mut self, val: u32) {
self._bitfield_1.set(29, 3, val as u64)
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1PictureInfo {
pub flags: StdVideoEncodeAV1PictureInfoFlags,
pub frame_type: StdVideoAV1FrameType,
pub frame_presentation_time: u32,
pub current_frame_id: u32,
pub order_hint: u8,
pub primary_ref_frame: u8,
pub refresh_frame_flags: u8,
pub coded_denom: u8,
pub render_width_minus_1: u16,
pub render_height_minus_1: u16,
pub interpolation_filter: StdVideoAV1InterpolationFilter,
pub TxMode: StdVideoAV1TxMode,
pub delta_q_res: u8,
pub delta_lf_res: u8,
pub ref_order_hint: [u8; 8usize],
pub ref_frame_idx: [i8; 7usize],
pub reserved1: [u8; 3usize],
pub delta_frame_id_minus_1: [u32; 7usize],
pub pTileInfo: *const ash::vk::native::StdVideoAV1TileInfo,
pub pQuantization: *const ash::vk::native::StdVideoAV1Quantization,
pub pSegmentation: *const ash::vk::native::StdVideoAV1Segmentation,
pub pLoopFilter: *const ash::vk::native::StdVideoAV1LoopFilter,
pub pCDEF: *const ash::vk::native::StdVideoAV1CDEF,
pub pLoopRestoration: *const ash::vk::native::StdVideoAV1LoopRestoration,
pub pGlobalMotion: *const ash::vk::native::StdVideoAV1GlobalMotion,
pub pExtensionHeader: *const StdVideoEncodeAV1ExtensionHeader,
pub pBufferRemovalTimes: *const u32,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1ReferenceInfoFlags {
pub _bitfield_align_1: [u32; 0],
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
}
impl StdVideoEncodeAV1ReferenceInfoFlags {
#[inline]
pub fn set_disable_frame_end_update_cdf(&mut self, val: u32) {
self._bitfield_1.set(0, 1, val as u64)
}
#[inline]
pub fn set_segmentation_enabled(&mut self, val: u32) {
self._bitfield_1.set(1, 1, val as u64)
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1ReferenceInfo {
pub flags: StdVideoEncodeAV1ReferenceInfoFlags,
pub RefFrameId: u32,
pub frame_type: StdVideoAV1FrameType,
pub OrderHint: u8,
pub reserved1: [u8; 3usize],
pub pExtensionHeader: *const StdVideoEncodeAV1ExtensionHeader,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1ExtensionHeader {
pub temporal_id: u8,
pub spatial_id: u8,
}
// ---------- KHR extension structs (repr(C); lifetimes/PhantomData dropped — layout-identical,
// chained by raw p_next). Flag/enum newtypes flattened to their u32/i32 repr. ----------
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1ProfileInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub std_profile: StdVideoAV1Profile,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1CapabilitiesKHR {
pub s_type: vk::StructureType,
pub p_next: *mut c_void,
pub flags: u32,
pub max_level: StdVideoAV1Level,
pub coded_picture_alignment: vk::Extent2D,
pub max_tiles: vk::Extent2D,
pub min_tile_size: vk::Extent2D,
pub max_tile_size: vk::Extent2D,
pub superblock_sizes: u32,
pub max_single_reference_count: u32,
pub single_reference_name_mask: u32,
pub max_unidirectional_compound_reference_count: u32,
pub max_unidirectional_compound_group1_reference_count: u32,
pub unidirectional_compound_reference_name_mask: u32,
pub max_bidirectional_compound_reference_count: u32,
pub max_bidirectional_compound_group1_reference_count: u32,
pub max_bidirectional_compound_group2_reference_count: u32,
pub bidirectional_compound_reference_name_mask: u32,
pub max_temporal_layer_count: u32,
pub max_spatial_layer_count: u32,
pub max_operating_points: u32,
pub min_q_index: u32,
pub max_q_index: u32,
pub prefers_gop_remaining_frames: vk::Bool32,
pub requires_gop_remaining_frames: vk::Bool32,
pub std_syntax_flags: u32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1SessionParametersCreateInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub p_std_sequence_header: *const StdVideoAV1SequenceHeader,
pub p_std_decoder_model_info: *const c_void,
pub std_operating_point_count: u32,
pub p_std_operating_points: *const c_void,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1PictureInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub prediction_mode: i32,
pub rate_control_group: i32,
pub constant_q_index: u32,
pub p_std_picture_info: *const StdVideoEncodeAV1PictureInfo,
pub reference_name_slot_indices: [i32; MAX_VIDEO_AV1_REFERENCES_PER_FRAME],
pub primary_reference_cdf_only: vk::Bool32,
pub generate_obu_extension_header: vk::Bool32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1DpbSlotInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub p_std_reference_info: *const StdVideoEncodeAV1ReferenceInfo,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1RateControlInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub flags: u32,
pub gop_frame_count: u32,
pub key_frame_period: u32,
pub consecutive_bipredictive_frame_count: u32,
pub temporal_layer_count: u32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1QIndexKHR {
pub intra_q_index: u32,
pub predictive_q_index: u32,
pub bipredictive_q_index: u32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1FrameSizeKHR {
pub intra_frame_size: u32,
pub predictive_frame_size: u32,
pub bipredictive_frame_size: u32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1RateControlLayerInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub use_min_q_index: vk::Bool32,
pub min_q_index: VideoEncodeAV1QIndexKHR,
pub use_max_q_index: vk::Bool32,
pub max_q_index: VideoEncodeAV1QIndexKHR,
pub use_max_frame_size: vk::Bool32,
pub max_frame_size: VideoEncodeAV1FrameSizeKHR,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1GopRemainingFrameInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub use_gop_remaining_frames: vk::Bool32,
pub gop_remaining_intra: u32,
pub gop_remaining_predictive: u32,
pub gop_remaining_bipredictive: u32,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct VideoEncodeAV1SessionCreateInfoKHR {
pub s_type: vk::StructureType,
pub p_next: *const c_void,
pub use_max_level: vk::Bool32,
pub max_level: StdVideoAV1Level,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct StdVideoEncodeAV1OperatingPointInfoFlags {
pub _bitfield_align_1: [u32; 0],
pub _bitfield_1: __BindgenBitfieldUnit<[u8; 4usize]>,
}
#[repr(C)]
#[derive(Copy, Clone)]
pub struct StdVideoEncodeAV1OperatingPointInfo {
pub flags: StdVideoEncodeAV1OperatingPointInfoFlags,
pub operating_point_idc: u16,
pub seq_level_idx: u8,
pub seq_tier: u8,
pub decoder_buffer_delay: u32,
pub encoder_buffer_delay: u32,
pub initial_display_delay_minus_1: u8,
}
/// `vk::StructureType` for a raw `ST_*` constant above.
#[inline]
pub fn stype(raw: i32) -> vk::StructureType {
vk::StructureType::from_raw(raw)
}
@@ -1,10 +1,13 @@
//! Raw **Vulkan Video** HEVC encoder (`VK_KHR_video_encode_h265`) 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).
//! 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 + a device that advertises h265 encode.
//! 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};
@@ -29,6 +32,9 @@ const DPB_SLOTS: u32 = 8;
/// 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 {
@@ -97,11 +103,18 @@ pub struct VulkanVideoEncoder {
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,
header: Vec<u8>, // VPS/SPS/PPS bytes, prepended to each keyframe
// 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,
@@ -137,7 +150,9 @@ pub struct VulkanVideoEncoder {
// --- state ---
width: u32,
height: u32,
poc: i32, // monotonic HEVC picture-order-count
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
@@ -153,20 +168,45 @@ 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 codec != Codec::H265 {
bail!("vulkan-encode backend is HEVC-only (got {codec:?})");
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); a conformance window
// crops the aligned padding back to (width,height) on the decoder.
// 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(w, h, width, height, fps.max(1), bitrate_bps.max(1_000_000)) }
unsafe {
Self::open_inner(
codec,
w,
h,
width,
height,
fps.max(1),
bitrate_bps.max(1_000_000),
)
}
}
unsafe fn open_inner(w: u32, h: u32, rw: u32, rh: u32, fps: u32, bitrate: u64) -> Result<Self> {
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
@@ -194,9 +234,7 @@ impl VulkanVideoEncoder {
.queue_family_properties
.queue_flags
.contains(vk::QueueFlags::VIDEO_ENCODE_KHR)
&& video[i]
.video_codec_operations
.contains(vk::VideoCodecOperationFlagsKHR::ENCODE_H265)
&& video[i].video_codec_operations.contains(codec_op)
{
found = Some((pd, i as u32));
break;
@@ -206,7 +244,7 @@ impl VulkanVideoEncoder {
break;
}
}
found.context("no VK_KHR_video_encode_h265 queue on any device")?
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);
@@ -218,38 +256,60 @@ impl VulkanVideoEncoder {
.context("no compute queue")? as u32
};
// the H265 Main encode profile
// 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 profile = vk::VideoProfileInfoKHR::default()
.video_codec_operation(vk::VideoCodecOperationFlagsKHR::ENCODE_H265)
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 h265_profile)
.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 (chain required for encode) -> std header version
// 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)
.push_next(&mut h265_caps);
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
// 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(),
ash::khr::video_encode_h265::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(),
@@ -283,8 +343,14 @@ impl VulkanVideoEncoder {
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 ----
let session_ci = vk::VideoSessionCreateInfoKHR::default()
// ---- 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)
@@ -296,6 +362,9 @@ impl VulkanVideoEncoder {
.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(),
@@ -346,9 +415,25 @@ impl VulkanVideoEncoder {
bail!("bind_video_session_memory: {r:?}");
}
// ---- session parameters (VPS/SPS/PPS) + retrieve header ----
let (params, header) =
build_parameters(&device, &vq_dev, &venc_dev, session, w, h, rw, rh)?;
// ---- 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 =
@@ -488,10 +573,12 @@ impl VulkanVideoEncoder {
compute_queue,
compute_family,
mem_props,
codec,
session,
session_mem,
params,
header,
frame_prefix,
dpb_image,
dpb_mem,
dpb_views,
@@ -514,6 +601,8 @@ impl VulkanVideoEncoder {
fps,
width: w,
height: h,
render_w: rw,
render_h: rh,
poc: 0,
enc_count: 0,
auto_wire: 0,
@@ -1010,7 +1099,14 @@ impl VulkanVideoEncoder {
);
dev.end_command_buffer(compute_cmd)?;
// ---- 3. author HEVC Std structs + record encode into `cmd` ----
// ---- 3. record encode into `cmd`: codec-specific Std authoring + begin/encode/end.
// AV1 is self-contained in its own method; HEVC stays inline in the `else` below. ----
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 {
let mut pic_flags: h::StdVideoEncodeH265PictureInfoFlags = std::mem::zeroed();
pic_flags.set_is_reference(1);
if is_idr {
@@ -1233,6 +1329,7 @@ impl VulkanVideoEncoder {
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)?;
} // end HEVC branch
// ---- 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
@@ -1278,8 +1375,307 @@ impl VulkanVideoEncoder {
Ok(())
}
/// Read one completed slot's bitstream into an `EncodedFrame` (VPS/SPS/PPS prepended on
/// keyframes). Caller must have confirmed the slot's fence is signaled (blocking wait or probe).
/// Author the AV1 Std structs + record begin/encode/end for one frame into `cmd`. Self-contained
/// (own begin/end command-buffer + pre-encode barriers) — the AV1 twin of the inline HEVC path in
/// `record_submit`. 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`).
#[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, pre-encode barriers + query reset, begin/encode/end coding ----
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 mut pre_enc = vec![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))];
let dpb_range = vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: DPB_SLOTS,
};
if self.first_frame {
pre_enc.push(
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)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(self.dpb_image)
.subresource_range(dpb_range),
);
} else {
// Same pipelining DPB self-barrier as the HEVC path: order the previous frame's
// reconstruct-write before this frame's reference read/write across the two command buffers.
pre_enc.push(
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(dpb_range),
);
}
dev.cmd_pipeline_barrier2(
cmd,
&vk::DependencyInfo::default().image_memory_barriers(&pre_enc),
);
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);
}
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];
@@ -1288,10 +1684,13 @@ impl VulkanVideoEncoder {
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 mut data = Vec::with_capacity(self.header.len() + len);
if f.keyframe {
data.extend_from_slice(&self.header);
} // keyframes carry VPS/SPS/PPS
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 {
@@ -1791,7 +2190,7 @@ unsafe fn make_frame(
/// 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(
unsafe fn build_parameters_h265(
device: &ash::Device,
vq_dev: &ash::khr::video_queue::Device,
venc_dev: &ash::khr::video_encode_queue::Device,
@@ -1906,6 +2305,236 @@ unsafe fn build_parameters(
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::VulkanVideoEncoder;
@@ -1926,17 +2555,11 @@ mod tests {
}
}
/// Full `open` → IDR → P-frames → RFI-recovery path through the real [`VulkanVideoEncoder`] on
/// whatever Vulkan device is present (RADV on the test bed). Exercises the CPU→NV12 compute CSC,
/// the NV12 plane copy, the DPB ring and the reference-slot RFI end-to-end, and dumps the
/// elementary stream to `$HOME/vkenc-host-smoke.h265` for an out-of-band `ffmpeg` decode check.
/// `#[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() {
/// 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. Loss of wire frame 3 is simulated so frame
/// 4 becomes a clean recovery anchor referencing frame 2 (no IDR).
fn run_smoke(codec: Codec) -> Vec<crate::encode::EncodedFrame> {
let env_dim = |k: &str, d: u32| {
std::env::var(k)
.ok()
@@ -1944,7 +2567,7 @@ mod tests {
.unwrap_or(d)
};
let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256));
let mut enc = VulkanVideoEncoder::open(Codec::H265, w, h, 60, 10_000_000).expect("open");
let mut enc = VulkanVideoEncoder::open(codec, w, h, 60, 10_000_000).expect("open");
assert!(enc.caps().supports_rfi, "must advertise RFI");
let colors = [
@@ -1995,28 +2618,50 @@ mod tests {
}
assert_eq!(keyframes, 1, "exactly one IDR (frame 0)");
assert_eq!(anchors, 1, "exactly one recovery anchor (frame 4)");
aus
}
if let Ok(home) = std::env::var("HOME") {
/// Dump the full stream + a "frame-3-lost" stream to `$HOME/vkenc-host-smoke*.{ext}` for an
/// out-of-band `ffmpeg` decode check (both must decode 0-error; the dropped one proves the
/// recovery anchor healed real loss without an IDR).
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.h265");
let p1 = format!("{home}/vkenc-host-smoke.{ext}");
let _ = std::fs::write(&p1, &full);
eprintln!(
"vulkan_smoke: wrote {p1} ({} bytes, {} AUs)",
"run_smoke: wrote {p1} ({} bytes, {} AUs)",
full.len(),
aus.len()
);
// Drop the "lost" AU (wire frame 3). Because the recovery frame re-anchored to frame 2,
// this must still decode cleanly — the on-host proof that the ported RFI heals real loss
// without an IDR (a non-RFI encoder's frame 4 would reference frame 3 → decoder corrupts).
let dropped: Vec<u8> = aus
.iter()
.enumerate()
.filter(|(i, _)| *i != 3)
.flat_map(|(_, a)| a.data.iter().copied())
.collect();
let p2 = format!("{home}/vkenc-host-smoke-dropped.h265");
let p2 = format!("{home}/vkenc-host-smoke-dropped.{ext}");
let _ = std::fs::write(&p2, &dropped);
eprintln!("vulkan_smoke: wrote {p2} (frame 3 dropped; recovery@4 anchors to frame 2)");
eprintln!("run_smoke: wrote {p2} (frame 3 dropped; recovery@4 anchors to frame 2)");
}
/// 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");
}
}