feat(pf-vkdecode): the GPU half — session, DPB pools, decode recording, status queries
M2 WP-B. VkVideoSessionKHR lifecycle with drain-before-destroy on parameters recreation, DPB pools in both coincide and distinct modes (caps-derived, usage/flags validated against the driver's format properties), an aligned bitstream ring, vkCmdDecodeVideoKHR recording with one-shot RESET re-armed on failed submits, timeline-semaphore completion, and the per-op RESULT_STATUS query ring — the signal FFmpeg's hwaccel never reads and the reason this program exists. Frame lifetime is two-phase by construction: release_frame pins a delivered frame's slot against reuse, closing the coincide-mode overwrite the adversarial review round proved (a full DPB handed a just-returned frame's image back as the same call's decode target). Nine review findings fixed pre-commit; a counterfactual test pins the collision. Generation-stamped frames, memory-type misses as errors, granularity-aligned extents, level gate. AuPlan now carries its activated SPS/PPS (Rc) so backends never re-parse. GPU smoke test (ignored) decodes 48 AUs past DPB-full with releases — the fleet runs it in WP-D. Gates: fmt clean, clippy -D warnings zero, 45+27+53 tests green on macOS and the linux/amd64 container.
This commit is contained in:
@@ -74,6 +74,15 @@ pub struct AuPlan {
|
||||
pub slices: Vec<SlicePlan>,
|
||||
pub dpb: DpbUpdate,
|
||||
pub warnings: Vec<PlanWarning>,
|
||||
/// The SPS the planner activated for this AU — the one [`Self::picture`]'s
|
||||
/// parameters derive from (the FIRST slice's PPS's SPS; a later slice may
|
||||
/// legally reference another PPS, and that drift deliberately does not reach
|
||||
/// here). Cloned out of the parser's table so backends build their parameter
|
||||
/// objects from exactly what was activated, never by re-parsing the AU.
|
||||
pub sps: Rc<Sps>,
|
||||
/// The PPS the picture was begun with (the first slice's), same contract as
|
||||
/// [`Self::sps`]. Its `sps` field is the same `Rc` as [`Self::sps`].
|
||||
pub pps: Rc<Pps>,
|
||||
}
|
||||
|
||||
/// Per-picture parameters, captured after 8.2.1 POC derivation and before end-of-picture
|
||||
@@ -465,6 +474,10 @@ impl H264Planner {
|
||||
// Captured before finish_picture: MMCO5 rewrites the stored POC afterwards, but
|
||||
// backends submit the picture with its 8.2.1 values.
|
||||
let picture = Self::picture_plan(&cur, recovery_point);
|
||||
// The activated parameter sets ride out with the plan (AuPlan field docs);
|
||||
// cloned before finish_picture consumes `cur`.
|
||||
let pps = Rc::clone(&cur.first_slice_pps);
|
||||
let sps = Rc::clone(&pps.sps);
|
||||
let stored = self.finish_picture(cur, &mut warnings)?;
|
||||
|
||||
// `removed` is the delta against what the backend last SAW alive, not against
|
||||
@@ -485,6 +498,8 @@ impl H264Planner {
|
||||
removed,
|
||||
},
|
||||
warnings,
|
||||
sps,
|
||||
pps,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2402,5 +2417,42 @@ mod tests {
|
||||
height: 64
|
||||
}
|
||||
);
|
||||
// The accessor pair follows the same first-slice rule: backends build
|
||||
// their parameter objects from these, so drifting to PPS 1 here would
|
||||
// desynchronize them from `picture`.
|
||||
assert_eq!(plan.pps.pic_parameter_set_id, 0);
|
||||
assert_eq!(plan.sps.seq_parameter_set_id, 0);
|
||||
assert!(
|
||||
Rc::ptr_eq(&plan.sps, &plan.pps.sps),
|
||||
"the SPS accessor is the PPS's own SPS, not a second copy"
|
||||
);
|
||||
assert!(
|
||||
!plan.sps.frame_cropping_flag,
|
||||
"SPS 0, not the cropped SPS 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plans_parameter_set_accessors_carry_the_activated_content() {
|
||||
let (sps, pps) = authored_sps_pps();
|
||||
let mut au0 = param_set_au(&sps, &pps);
|
||||
au0.extend(write_idr_slice());
|
||||
|
||||
let plan = H264Planner::new().plan_au(&au0).unwrap();
|
||||
// The parser re-parses the in-band parameter sets, so pointer identity
|
||||
// with the authored `sps`/`pps` is not expected (and whole-struct
|
||||
// equality would compare parser-side normalizations like the flat
|
||||
// scaling-list fill); the contract is that the ACTIVATED content rides
|
||||
// out. Spot-check the fields backends build parameter objects from.
|
||||
assert_eq!(plan.sps.seq_parameter_set_id, sps.seq_parameter_set_id);
|
||||
assert_eq!(plan.sps.profile_idc, sps.profile_idc);
|
||||
assert_eq!(plan.sps.level_idc, sps.level_idc);
|
||||
assert_eq!(plan.sps.max_num_ref_frames, sps.max_num_ref_frames);
|
||||
assert_eq!(plan.sps.width(), sps.width());
|
||||
assert_eq!(plan.sps.height(), sps.height());
|
||||
assert_eq!(plan.pps.pic_parameter_set_id, pps.pic_parameter_set_id);
|
||||
assert_eq!(plan.pps.seq_parameter_set_id, pps.seq_parameter_set_id);
|
||||
assert_eq!(plan.pps.pic_init_qp_minus26, pps.pic_init_qp_minus26);
|
||||
assert!(Rc::ptr_eq(&plan.sps, &plan.pps.sps));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
//! H.264 decode capability query + derivation.
|
||||
//!
|
||||
//! Split on purpose: [`query_h264_caps`] is the one THIN function that talks to the
|
||||
//! driver (`vkGetPhysicalDeviceVideoCapabilitiesKHR` + the three video-format-property
|
||||
//! enumerations) and only COPIES facts into [`RawH264Caps`]; [`derive_caps`] turns
|
||||
//! those facts into the [`DecodeCaps`] the session/image/ring modules consume and is
|
||||
//! a pure function over a hand-buildable struct — every mode/format decision is
|
||||
//! unit-tested without a GPU (the RADV-vs-NVIDIA coincide/distinct split is exactly
|
||||
//! the driver variance the risk register names).
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::native as hh;
|
||||
|
||||
use crate::device::DecodeDevice;
|
||||
|
||||
/// The 8-bit 4:2:0 semi-planar format every punktfunk H.264 session decodes to.
|
||||
/// (P010 joins with the HEVC/10-bit milestone; H.264 in this program is 8-bit.)
|
||||
pub const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM;
|
||||
|
||||
/// The usage the pools actually create with, per role — the format queries ask the
|
||||
/// driver about EXACTLY these combinations (a query for less would validate an
|
||||
/// image nobody creates):
|
||||
///
|
||||
/// distinct-mode DPB images: reference-only, never sampled.
|
||||
pub const DPB_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR;
|
||||
/// Distinct-mode output images: decode destination + presenter sampling.
|
||||
pub const OUTPUT_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw(
|
||||
vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR.as_raw() | vk::ImageUsageFlags::SAMPLED.as_raw(),
|
||||
);
|
||||
/// Coincide-mode images: DPB + decode destination + presenter sampling in one.
|
||||
pub const COINCIDE_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw(
|
||||
vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR.as_raw()
|
||||
| vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR.as_raw()
|
||||
| vk::ImageUsageFlags::SAMPLED.as_raw(),
|
||||
);
|
||||
|
||||
/// One `VkVideoFormatPropertiesKHR` entry as this crate consumes it: the format
|
||||
/// plus the driver's advertised usage/create-flag envelope for it — creation must
|
||||
/// stay INSIDE that envelope (finding of the adversarial round: the flags used to
|
||||
/// be assumed, not honoured).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct VideoFormat {
|
||||
pub format: vk::Format,
|
||||
/// `imageUsageFlags` the driver supports for this format under the queried
|
||||
/// profile (a superset of the query's usage on a conformant driver).
|
||||
pub image_usage: vk::ImageUsageFlags,
|
||||
/// `imageCreateFlags` the driver allows — per-plane views require
|
||||
/// `MUTABLE_FORMAT` to appear here.
|
||||
pub image_create_flags: vk::ImageCreateFlags,
|
||||
}
|
||||
|
||||
/// Everything the thin query copies out of the driver, hand-buildable for tests.
|
||||
///
|
||||
/// The three format lists correspond to the three REAL usage combinations the
|
||||
/// pools create with ([`DPB_USAGE`], [`OUTPUT_USAGE`], [`COINCIDE_USAGE`] — the
|
||||
/// presenter-facing ones include `SAMPLED`), in the exact shape the driver was
|
||||
/// asked: a usage the implementation does not support yields an EMPTY list (the
|
||||
/// thin query maps `VK_ERROR_FORMAT_NOT_SUPPORTED` /
|
||||
/// `VK_ERROR_IMAGE_USAGE_NOT_SUPPORTED` for that usage to empty rather than
|
||||
/// failing the whole probe).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RawH264Caps {
|
||||
/// `VkVideoCapabilitiesKHR::flags`.
|
||||
pub capability_flags: vk::VideoCapabilityFlagsKHR,
|
||||
/// `VkVideoDecodeCapabilitiesKHR::flags` (the coincide/distinct advertisement).
|
||||
pub decode_flags: vk::VideoDecodeCapabilityFlagsKHR,
|
||||
pub min_bitstream_buffer_offset_alignment: u64,
|
||||
pub min_bitstream_buffer_size_alignment: u64,
|
||||
pub picture_access_granularity: vk::Extent2D,
|
||||
pub min_coded_extent: vk::Extent2D,
|
||||
pub max_coded_extent: vk::Extent2D,
|
||||
pub max_dpb_slots: u32,
|
||||
pub max_active_reference_pictures: u32,
|
||||
/// `VkVideoDecodeH264CapabilitiesKHR::maxLevelIdc` (index-coded Std level).
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
/// `VkVideoCapabilitiesKHR::stdHeaderVersion` — session creation echoes it back.
|
||||
pub std_header_version: vk::ExtensionProperties,
|
||||
/// Formats usable for DISTINCT-mode DPB images (queried with [`DPB_USAGE`]).
|
||||
pub dpb_formats: Vec<VideoFormat>,
|
||||
/// Formats usable for DISTINCT-mode outputs (queried with [`OUTPUT_USAGE`]).
|
||||
pub output_formats: Vec<VideoFormat>,
|
||||
/// Formats usable when DPB and output COINCIDE ([`COINCIDE_USAGE`]).
|
||||
pub coincide_formats: Vec<VideoFormat>,
|
||||
}
|
||||
|
||||
/// The derived facts the rest of the crate keys off. One value per session profile;
|
||||
/// rebuilt only when the stream renegotiates to a different profile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodeCaps {
|
||||
/// Chosen DPB/output arrangement: `true` = the decode output IS the DPB image
|
||||
/// (RADV's shape), `false` = separate DPB array + output images (NVIDIA's).
|
||||
/// When a driver advertises both, coincide wins — half the images, and the
|
||||
/// mode field data trusts most on the fleet's AMD boxes.
|
||||
pub coincide: bool,
|
||||
/// `true` when the driver does NOT advertise `SEPARATE_REFERENCE_IMAGES`: every
|
||||
/// DPB slot must then be a layer of ONE image array. When separate references
|
||||
/// are allowed this stays `false` and each slot gets its own image (simpler
|
||||
/// lifetime story; nothing downstream requires the layered arrangement).
|
||||
pub layered_dpb: bool,
|
||||
/// Bitstream buffer alignments, normalized to at least 1 so ring math never
|
||||
/// divides by the zero an uninitialized fixture would carry.
|
||||
pub min_bitstream_offset_alignment: u64,
|
||||
pub min_bitstream_size_alignment: u64,
|
||||
pub picture_access_granularity: vk::Extent2D,
|
||||
pub min_coded_extent: vk::Extent2D,
|
||||
pub max_coded_extent: vk::Extent2D,
|
||||
pub max_dpb_slots: u32,
|
||||
pub max_active_references: u32,
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
/// DPB image format (== `output_format` in coincide mode).
|
||||
pub dpb_format: vk::Format,
|
||||
/// Decode-output image format.
|
||||
pub output_format: vk::Format,
|
||||
pub std_header_version: vk::ExtensionProperties,
|
||||
}
|
||||
|
||||
impl DecodeCaps {
|
||||
/// `coded` rounded up to the device's `pictureAccessGranularity` — the extent
|
||||
/// pool IMAGES are created at (the per-picture `codedExtent` stays the stream's
|
||||
/// coded size; only the backing store rounds up). A zero granularity axis (an
|
||||
/// uninitialized fixture) degrades to 1.
|
||||
pub fn aligned_extent(&self, coded: vk::Extent2D) -> vk::Extent2D {
|
||||
let round = |value: u32, granularity: u32| -> u32 {
|
||||
let granularity = granularity.max(1);
|
||||
value.div_ceil(granularity) * granularity
|
||||
};
|
||||
vk::Extent2D {
|
||||
width: round(coded.width, self.picture_access_granularity.width),
|
||||
height: round(coded.height, self.picture_access_granularity.height),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw caps that do not add up to a usable decoder. All of these are device gaps
|
||||
/// the caller demotes on (the ladder's next rung), not stream conditions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CapsError {
|
||||
/// The driver advertises neither COINCIDE nor DISTINCT — no way to arrange a
|
||||
/// DPB at all (a broken driver; the spec requires at least one).
|
||||
NoDecodeMode,
|
||||
/// The mode's format list does not contain [`NV12`]. `mode` names which list.
|
||||
NoNv12Format { mode: &'static str },
|
||||
/// The driver's NV12 entry for `mode` does not advertise every usage bit the
|
||||
/// pool would create with (`missing` names the gap) — creating anyway would be
|
||||
/// a silent VUID violation.
|
||||
UsageUnsupported {
|
||||
mode: &'static str,
|
||||
missing: vk::ImageUsageFlags,
|
||||
},
|
||||
/// The presenter-facing NV12 entry for `mode` does not allow `MUTABLE_FORMAT`,
|
||||
/// so the per-plane `R8`/`R8G8` views the presenter samples through cannot
|
||||
/// exist on this device.
|
||||
NoMutableFormat { mode: &'static str },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CapsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CapsError::NoDecodeMode => {
|
||||
write!(
|
||||
f,
|
||||
"driver advertises neither DPB_AND_OUTPUT_COINCIDE nor DISTINCT"
|
||||
)
|
||||
}
|
||||
CapsError::NoNv12Format { mode } => {
|
||||
write!(f, "no NV12 in the {mode} video format properties")
|
||||
}
|
||||
CapsError::UsageUnsupported { mode, missing } => {
|
||||
write!(
|
||||
f,
|
||||
"the {mode} NV12 entry does not advertise usage {missing:?}"
|
||||
)
|
||||
}
|
||||
CapsError::NoMutableFormat { mode } => {
|
||||
write!(
|
||||
f,
|
||||
"the {mode} NV12 entry does not allow MUTABLE_FORMAT (per-plane views)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CapsError {}
|
||||
|
||||
/// Derive the session-shaping facts from one raw query. Pure — the whole
|
||||
/// coincide/distinct/layered decision table lives here and in the tests below.
|
||||
pub fn derive_caps(raw: &RawH264Caps) -> Result<DecodeCaps, CapsError> {
|
||||
let coincide = raw
|
||||
.decode_flags
|
||||
.contains(vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE);
|
||||
let distinct = raw
|
||||
.decode_flags
|
||||
.contains(vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT);
|
||||
if !coincide && !distinct {
|
||||
return Err(CapsError::NoDecodeMode);
|
||||
}
|
||||
|
||||
// Coincide preferred when both are offered (struct docs). Each picked entry is
|
||||
// validated against the EXACT usage/create-flags its pool will use: presenter-
|
||||
// facing images (coincide pool, distinct outputs) additionally need
|
||||
// MUTABLE_FORMAT for their per-plane views; the distinct DPB needs neither
|
||||
// sampling nor plane views.
|
||||
let (dpb_format, output_format) = if coincide {
|
||||
let mode = "coincide (DPB|DST|SAMPLED)";
|
||||
let entry = pick_nv12(&raw.coincide_formats, mode)?;
|
||||
require_usage(&entry, COINCIDE_USAGE, mode)?;
|
||||
require_mutable(&entry, mode)?;
|
||||
(entry.format, entry.format)
|
||||
} else {
|
||||
let dpb = pick_nv12(&raw.dpb_formats, "DPB")?;
|
||||
require_usage(&dpb, DPB_USAGE, "DPB")?;
|
||||
let out_mode = "output (DST|SAMPLED)";
|
||||
let output = pick_nv12(&raw.output_formats, out_mode)?;
|
||||
require_usage(&output, OUTPUT_USAGE, out_mode)?;
|
||||
require_mutable(&output, out_mode)?;
|
||||
(dpb.format, output.format)
|
||||
};
|
||||
|
||||
Ok(DecodeCaps {
|
||||
coincide,
|
||||
layered_dpb: !raw
|
||||
.capability_flags
|
||||
.contains(vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES),
|
||||
min_bitstream_offset_alignment: raw.min_bitstream_buffer_offset_alignment.max(1),
|
||||
min_bitstream_size_alignment: raw.min_bitstream_buffer_size_alignment.max(1),
|
||||
picture_access_granularity: raw.picture_access_granularity,
|
||||
min_coded_extent: raw.min_coded_extent,
|
||||
max_coded_extent: raw.max_coded_extent,
|
||||
max_dpb_slots: raw.max_dpb_slots,
|
||||
max_active_references: raw.max_active_reference_pictures,
|
||||
max_level_idc: raw.max_level_idc,
|
||||
dpb_format,
|
||||
output_format,
|
||||
std_header_version: raw.std_header_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn pick_nv12(formats: &[VideoFormat], mode: &'static str) -> Result<VideoFormat, CapsError> {
|
||||
formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.format == NV12)
|
||||
.ok_or(CapsError::NoNv12Format { mode })
|
||||
}
|
||||
|
||||
/// The pool's creation usage must sit inside the driver's advertised envelope.
|
||||
fn require_usage(
|
||||
entry: &VideoFormat,
|
||||
usage: vk::ImageUsageFlags,
|
||||
mode: &'static str,
|
||||
) -> Result<(), CapsError> {
|
||||
let missing = usage & !entry.image_usage;
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CapsError::UsageUnsupported { mode, missing })
|
||||
}
|
||||
}
|
||||
|
||||
fn require_mutable(entry: &VideoFormat, mode: &'static str) -> Result<(), CapsError> {
|
||||
if entry
|
||||
.image_create_flags
|
||||
.contains(vk::ImageCreateFlags::MUTABLE_FORMAT)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CapsError::NoMutableFormat { mode })
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete H.264 decode profile chain in one movable value, mirroring the
|
||||
/// encoder's `RgbProfileStack`: profile identity in Vulkan is BY VALUE, so every
|
||||
/// consumer (caps query, session create, image/buffer create, query pool create)
|
||||
/// rebuilds a structurally identical chain rather than sharing pointers.
|
||||
///
|
||||
/// [`Self::wire`] links `profile.p_next` to this struct's OWN `h264` field; the
|
||||
/// value must not move between `wire()` and the last use of the returned reference
|
||||
/// (the borrow checker pins it — `wire` borrows `self` for the reference's life).
|
||||
pub(crate) struct H264ProfileChain {
|
||||
h264: vk::VideoDecodeH264ProfileInfoKHR<'static>,
|
||||
profile: vk::VideoProfileInfoKHR<'static>,
|
||||
}
|
||||
|
||||
impl H264ProfileChain {
|
||||
/// Build the (unwired) chain for one SPS profile. `std_profile_idc` is the
|
||||
/// value WP-A's conversion validated (66/77/100/244 pass-through); H.264 here
|
||||
/// is 8-bit 4:2:0 progressive by the program envelope.
|
||||
pub(crate) fn new(std_profile_idc: hh::StdVideoH264ProfileIdc) -> Self {
|
||||
Self {
|
||||
h264: vk::VideoDecodeH264ProfileInfoKHR::default()
|
||||
.std_profile_idc(std_profile_idc)
|
||||
.picture_layout(vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE),
|
||||
profile: vk::VideoProfileInfoKHR::default()
|
||||
.video_codec_operation(vk::VideoCodecOperationFlagsKHR::DECODE_H264)
|
||||
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
|
||||
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
|
||||
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the internal `p_next` chain and hand out the profile root. Do not move
|
||||
/// `self` while the returned reference (or any pointer taken from it) lives.
|
||||
pub(crate) fn wire(&mut self) -> &vk::VideoProfileInfoKHR<'static> {
|
||||
self.profile.p_next = (&self.h264 as *const vk::VideoDecodeH264ProfileInfoKHR<'_>).cast();
|
||||
&self.profile
|
||||
}
|
||||
}
|
||||
|
||||
/// The one function that asks the driver: video capabilities (with the decode +
|
||||
/// H.264 capability structs chained) plus the three format-property enumerations.
|
||||
/// Copies facts out and returns; derivation happens in [`derive_caps`].
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `dev` wraps live handles per the [`crate::DeviceHandles`] contract (this calls
|
||||
/// instance-level functions against its physical device).
|
||||
pub(crate) unsafe fn query_h264_caps(
|
||||
dev: &DecodeDevice,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<RawH264Caps, vk::Result> {
|
||||
let mut chain = H264ProfileChain::new(std_profile_idc);
|
||||
let profile = chain.wire();
|
||||
|
||||
let mut h264_caps = vk::VideoDecodeH264CapabilitiesKHR::default();
|
||||
let mut decode_caps = vk::VideoDecodeCapabilitiesKHR::default();
|
||||
let mut caps = vk::VideoCapabilitiesKHR::default()
|
||||
.push_next(&mut decode_caps)
|
||||
.push_next(&mut h264_caps);
|
||||
// SAFETY: physical device is live (DeviceHandles contract); `profile` roots a
|
||||
// fully wired, immovable chain; `caps` chains driver-fillable structs that all
|
||||
// outlive the call.
|
||||
let r = unsafe {
|
||||
(dev.video_queue_instance()
|
||||
.fp()
|
||||
.get_physical_device_video_capabilities_khr)(
|
||||
dev.physical_device(), profile, &mut caps
|
||||
)
|
||||
};
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err(r);
|
||||
}
|
||||
// Copy everything out before the chained &mut borrows end (encoder precedent).
|
||||
let capability_flags = caps.flags;
|
||||
let min_bitstream_buffer_offset_alignment = caps.min_bitstream_buffer_offset_alignment;
|
||||
let min_bitstream_buffer_size_alignment = caps.min_bitstream_buffer_size_alignment;
|
||||
let picture_access_granularity = caps.picture_access_granularity;
|
||||
let min_coded_extent = caps.min_coded_extent;
|
||||
let max_coded_extent = caps.max_coded_extent;
|
||||
let max_dpb_slots = caps.max_dpb_slots;
|
||||
let max_active_reference_pictures = caps.max_active_reference_pictures;
|
||||
let std_header_version = caps.std_header_version;
|
||||
let decode_flags = decode_caps.flags;
|
||||
let max_level_idc = h264_caps.max_level_idc;
|
||||
|
||||
// The three queries carry the REAL creation usages (SAMPLED included for the
|
||||
// presenter-facing roles) so the answers validate the images the pools build.
|
||||
// SAFETY: same liveness as above; the helper wires its own chain (this and
|
||||
// the two calls below).
|
||||
let dpb_formats = unsafe { query_formats(dev, std_profile_idc, DPB_USAGE)? };
|
||||
// SAFETY: as above.
|
||||
let output_formats = unsafe { query_formats(dev, std_profile_idc, OUTPUT_USAGE)? };
|
||||
// SAFETY: as above.
|
||||
let coincide_formats = unsafe { query_formats(dev, std_profile_idc, COINCIDE_USAGE)? };
|
||||
|
||||
Ok(RawH264Caps {
|
||||
capability_flags,
|
||||
decode_flags,
|
||||
min_bitstream_buffer_offset_alignment,
|
||||
min_bitstream_buffer_size_alignment,
|
||||
picture_access_granularity,
|
||||
min_coded_extent,
|
||||
max_coded_extent,
|
||||
max_dpb_slots,
|
||||
max_active_reference_pictures,
|
||||
max_level_idc,
|
||||
std_header_version,
|
||||
dpb_formats,
|
||||
output_formats,
|
||||
coincide_formats,
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerate the video format properties for one usage combination. A usage the
|
||||
/// implementation rejects outright maps to an EMPTY list (that is the driver saying
|
||||
/// "not this arrangement", which [`derive_caps`] then routes around).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// As [`query_h264_caps`].
|
||||
unsafe fn query_formats(
|
||||
dev: &DecodeDevice,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
usage: vk::ImageUsageFlags,
|
||||
) -> Result<Vec<VideoFormat>, vk::Result> {
|
||||
let mut chain = H264ProfileChain::new(std_profile_idc);
|
||||
let profile = chain.wire();
|
||||
let mut profile_list =
|
||||
vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile));
|
||||
let info = vk::PhysicalDeviceVideoFormatInfoKHR::default()
|
||||
.image_usage(usage)
|
||||
.push_next(&mut profile_list);
|
||||
|
||||
let fp = dev
|
||||
.video_queue_instance()
|
||||
.fp()
|
||||
.get_physical_device_video_format_properties_khr;
|
||||
let mut count = 0u32;
|
||||
// SAFETY: live physical device; `info` roots a wired chain outliving the call;
|
||||
// null properties pointer is the spec's count-query form.
|
||||
let r = unsafe {
|
||||
fp(
|
||||
dev.physical_device(),
|
||||
&info,
|
||||
&mut count,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
match r {
|
||||
vk::Result::SUCCESS => {}
|
||||
// "This usage/profile combination has no formats" — an arrangement gap,
|
||||
// not a failure (derive_caps decides whether a usable mode remains).
|
||||
vk::Result::ERROR_FORMAT_NOT_SUPPORTED
|
||||
| vk::Result::ERROR_IMAGE_USAGE_NOT_SUPPORTED_KHR => return Ok(Vec::new()),
|
||||
err => return Err(err),
|
||||
}
|
||||
let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize];
|
||||
// SAFETY: as above, with a properties array of exactly the driver-reported count.
|
||||
let r = unsafe { fp(dev.physical_device(), &info, &mut count, props.as_mut_ptr()) };
|
||||
if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE {
|
||||
return Err(r);
|
||||
}
|
||||
props.truncate(count as usize);
|
||||
Ok(props
|
||||
.iter()
|
||||
.map(|p| VideoFormat {
|
||||
format: p.format,
|
||||
image_usage: p.image_usage_flags,
|
||||
image_create_flags: p.image_create_flags,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A format entry advertising `usage` plus the mutable-format allowance.
|
||||
fn entry(format: vk::Format, usage: vk::ImageUsageFlags) -> VideoFormat {
|
||||
VideoFormat {
|
||||
format,
|
||||
image_usage: usage,
|
||||
image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT
|
||||
| vk::ImageCreateFlags::ALIAS
|
||||
| vk::ImageCreateFlags::EXTENDED_USAGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw-caps fixture in RADV's shape: coincide advertised, separate reference
|
||||
/// images allowed, sane alignments.
|
||||
fn radv_like() -> RawH264Caps {
|
||||
RawH264Caps {
|
||||
capability_flags: vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES,
|
||||
decode_flags: vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE,
|
||||
min_bitstream_buffer_offset_alignment: 128,
|
||||
min_bitstream_buffer_size_alignment: 128,
|
||||
picture_access_granularity: vk::Extent2D {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
min_coded_extent: vk::Extent2D {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
max_coded_extent: vk::Extent2D {
|
||||
width: 8192,
|
||||
height: 8192,
|
||||
},
|
||||
max_dpb_slots: 17,
|
||||
max_active_reference_pictures: 16,
|
||||
max_level_idc: hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2,
|
||||
std_header_version: vk::ExtensionProperties::default(),
|
||||
dpb_formats: vec![],
|
||||
output_formats: vec![],
|
||||
coincide_formats: vec![
|
||||
entry(NV12, COINCIDE_USAGE),
|
||||
entry(
|
||||
vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16,
|
||||
COINCIDE_USAGE,
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// NVIDIA's shape: distinct only, NO separate reference images (layered DPB
|
||||
/// array), and only the distinct-mode format lists populated. The DPB entry
|
||||
/// deliberately advertises NEITHER sampling nor mutable formats — reference
|
||||
/// arrays need neither, and requiring them there would fail real devices.
|
||||
fn nvidia_like() -> RawH264Caps {
|
||||
RawH264Caps {
|
||||
capability_flags: vk::VideoCapabilityFlagsKHR::empty(),
|
||||
decode_flags: vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT,
|
||||
dpb_formats: vec![VideoFormat {
|
||||
format: NV12,
|
||||
image_usage: DPB_USAGE,
|
||||
image_create_flags: vk::ImageCreateFlags::empty(),
|
||||
}],
|
||||
output_formats: vec![entry(NV12, OUTPUT_USAGE)],
|
||||
coincide_formats: vec![],
|
||||
..radv_like()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coincide_device_derives_coincide_with_one_shared_format() {
|
||||
let caps = derive_caps(&radv_like()).unwrap();
|
||||
assert!(caps.coincide);
|
||||
assert!(
|
||||
!caps.layered_dpb,
|
||||
"separate reference images advertised — per-slot images"
|
||||
);
|
||||
assert_eq!(caps.dpb_format, NV12);
|
||||
assert_eq!(caps.output_format, NV12);
|
||||
assert_eq!(caps.max_dpb_slots, 17);
|
||||
assert_eq!(caps.min_bitstream_offset_alignment, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_distinct_device_derives_distinct_with_a_layered_dpb() {
|
||||
let caps = derive_caps(&nvidia_like()).unwrap();
|
||||
assert!(!caps.coincide);
|
||||
assert!(
|
||||
caps.layered_dpb,
|
||||
"no SEPARATE_REFERENCE_IMAGES — one image array carries every slot"
|
||||
);
|
||||
assert_eq!(caps.dpb_format, NV12);
|
||||
assert_eq!(caps.output_format, NV12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_advertising_both_modes_prefers_coincide() {
|
||||
let mut raw = radv_like();
|
||||
raw.decode_flags = vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE
|
||||
| vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT;
|
||||
raw.dpb_formats = vec![entry(NV12, DPB_USAGE)];
|
||||
raw.output_formats = vec![entry(NV12, OUTPUT_USAGE)];
|
||||
let caps = derive_caps(&raw).unwrap();
|
||||
assert!(caps.coincide, "coincide wins when both are offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mode_and_no_nv12_are_distinct_hard_errors() {
|
||||
let mut raw = radv_like();
|
||||
raw.decode_flags = vk::VideoDecodeCapabilityFlagsKHR::empty();
|
||||
assert_eq!(derive_caps(&raw).unwrap_err(), CapsError::NoDecodeMode);
|
||||
|
||||
let mut raw = radv_like();
|
||||
raw.coincide_formats = vec![entry(
|
||||
vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16,
|
||||
COINCIDE_USAGE,
|
||||
)];
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::NoNv12Format {
|
||||
mode: "coincide (DPB|DST|SAMPLED)"
|
||||
}
|
||||
);
|
||||
|
||||
// Distinct mode reports which HALF is missing NV12.
|
||||
let mut raw = nvidia_like();
|
||||
raw.output_formats = vec![];
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::NoNv12Format {
|
||||
mode: "output (DST|SAMPLED)"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_advertised_usage_missing_a_creation_bit_is_an_error_naming_the_gap() {
|
||||
// A coincide entry that supports decode but NOT sampling: the presenter
|
||||
// cannot read it, so derivation must refuse rather than create anyway.
|
||||
let mut raw = radv_like();
|
||||
raw.coincide_formats = vec![entry(
|
||||
NV12,
|
||||
vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR,
|
||||
)];
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::UsageUnsupported {
|
||||
mode: "coincide (DPB|DST|SAMPLED)",
|
||||
missing: vk::ImageUsageFlags::SAMPLED
|
||||
}
|
||||
);
|
||||
|
||||
// Same on the distinct output half.
|
||||
let mut raw = nvidia_like();
|
||||
raw.output_formats = vec![entry(NV12, vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR)];
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::UsageUnsupported {
|
||||
mode: "output (DST|SAMPLED)",
|
||||
missing: vk::ImageUsageFlags::SAMPLED
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presenter_facing_entry_without_mutable_format_is_refused() {
|
||||
let mut raw = radv_like();
|
||||
raw.coincide_formats = vec![VideoFormat {
|
||||
format: NV12,
|
||||
image_usage: COINCIDE_USAGE,
|
||||
image_create_flags: vk::ImageCreateFlags::empty(),
|
||||
}];
|
||||
assert_eq!(
|
||||
derive_caps(&raw).unwrap_err(),
|
||||
CapsError::NoMutableFormat {
|
||||
mode: "coincide (DPB|DST|SAMPLED)"
|
||||
}
|
||||
);
|
||||
|
||||
// The distinct DPB entry needs NO mutable-format allowance (nvidia_like's
|
||||
// DPB entry has empty create flags and derives fine).
|
||||
assert!(derive_caps(&nvidia_like()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extents_round_up_to_the_picture_access_granularity() {
|
||||
let mut raw = radv_like();
|
||||
raw.picture_access_granularity = vk::Extent2D {
|
||||
width: 64,
|
||||
height: 16,
|
||||
};
|
||||
let caps = derive_caps(&raw).unwrap();
|
||||
// 1920x1080: width already aligned, height rounds to 1088 — the exact
|
||||
// padded shape the old smeared-rows class came from, now explicit.
|
||||
let aligned = caps.aligned_extent(vk::Extent2D {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
assert_eq!((aligned.width, aligned.height), (1920, 1088));
|
||||
|
||||
// Granularity 1 is the identity; a zero axis degrades to 1, not a panic.
|
||||
let mut raw = radv_like();
|
||||
raw.picture_access_granularity = vk::Extent2D {
|
||||
width: 0,
|
||||
height: 1,
|
||||
};
|
||||
let caps = derive_caps(&raw).unwrap();
|
||||
let aligned = caps.aligned_extent(vk::Extent2D {
|
||||
width: 321,
|
||||
height: 241,
|
||||
});
|
||||
assert_eq!((aligned.width, aligned.height), (321, 241));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_alignments_normalize_to_one_so_ring_math_never_divides_by_zero() {
|
||||
let mut raw = radv_like();
|
||||
raw.min_bitstream_buffer_offset_alignment = 0;
|
||||
raw.min_bitstream_buffer_size_alignment = 0;
|
||||
let caps = derive_caps(&raw).unwrap();
|
||||
assert_eq!(caps.min_bitstream_offset_alignment, 1);
|
||||
assert_eq!(caps.min_bitstream_size_alignment, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_chain_wires_h264_behind_the_root_profile() {
|
||||
let mut chain =
|
||||
H264ProfileChain::new(hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN);
|
||||
let profile = chain.wire();
|
||||
assert_eq!(
|
||||
profile.video_codec_operation,
|
||||
vk::VideoCodecOperationFlagsKHR::DECODE_H264
|
||||
);
|
||||
assert!(!profile.p_next.is_null());
|
||||
// SAFETY: wire() pointed p_next at chain's own h264 field, which lives for
|
||||
// this whole scope and is a valid VideoDecodeH264ProfileInfoKHR.
|
||||
let h264 = unsafe {
|
||||
&*profile
|
||||
.p_next
|
||||
.cast::<vk::VideoDecodeH264ProfileInfoKHR<'_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
h264.std_profile_idc,
|
||||
hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN
|
||||
);
|
||||
assert_eq!(
|
||||
h264.picture_layout,
|
||||
vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
||||
//! Borrowed-device wrap: the presenter's live Vulkan handles loaded into ash
|
||||
//! function tables, plus the queue-lock contract every queue submission runs under.
|
||||
//!
|
||||
//! Ownership: everything in [`DeviceHandles`] is BORROWED. This crate never creates
|
||||
//! and never destroys the instance/device — [`DecodeDevice`]'s ash wrappers are
|
||||
//! function tables over foreign handles, and dropping them destroys nothing. The
|
||||
//! objects this crate does create (sessions, images, buffers, pools) are destroyed
|
||||
//! by their owning structs' `Drop` impls, all of which must run before the borrowed
|
||||
//! device dies — the same liveness contract FFmpeg's decoder had over the identical
|
||||
//! handle bundle (`pf-client-core`'s `VulkanDecodeDevice`), now written down.
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::Handle;
|
||||
|
||||
/// The borrowed handles of the presenter's decode-capable device, as raw integers so
|
||||
/// the type stays FFI-plain (mirrors `pf-client-core`'s `VulkanDecodeDevice`, which
|
||||
/// adapts into this in WP-C — pf-vkdecode deliberately does not depend on it).
|
||||
///
|
||||
/// Caller contract (checked where cheap, otherwise trusted):
|
||||
/// - All four handles are live, and stay live for the lifetime of every object this
|
||||
/// crate builds from them (the presenter outlives every session pump).
|
||||
/// - The instance/device were created with the Vulkan Video decode stack enabled:
|
||||
/// `VK_KHR_video_queue`, `VK_KHR_video_decode_queue`, `VK_KHR_video_decode_h264`,
|
||||
/// plus the `synchronization2` and `timelineSemaphore` features (the presenter's
|
||||
/// device meets all of this when it advertises `video_decode`).
|
||||
/// - `decode_qf`/`decode_queue_index` name a queue with `VIDEO_DECODE_KHR` ops whose
|
||||
/// family advertises H.264 decode; `graphics_qf` is the family the presenter
|
||||
/// samples on (image sharing crosses the two when they differ).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceHandles {
|
||||
/// `PFN_vkGetInstanceProcAddr` from the loader; everything else is resolved
|
||||
/// through it.
|
||||
pub get_instance_proc_addr: usize,
|
||||
pub instance: usize,
|
||||
pub physical_device: usize,
|
||||
pub device: usize,
|
||||
/// The video-decode queue family.
|
||||
pub decode_qf: u32,
|
||||
/// Queue index within `decode_qf` this decoder submits on.
|
||||
pub decode_queue_index: u32,
|
||||
/// The presenter's graphics+present family (the other side of image sharing).
|
||||
pub graphics_qf: u32,
|
||||
}
|
||||
|
||||
/// External synchronization for `vkQueueSubmit`: the caller supplies the lock that
|
||||
/// serializes EVERY submit on the shared device — in WP-C that is pf-client-core's
|
||||
/// `QueueLock`, the same object the presenter holds around its own submits/presents
|
||||
/// (the 2026-07-09 `VK_ERROR_DEVICE_LOST` race is why this is a first-class contract
|
||||
/// and not an afterthought). Tests use [`NoopQueueLock`].
|
||||
///
|
||||
/// `lock` blocks until the queue is free and takes it; `unlock` releases it. Use
|
||||
/// [`QueueSubmitGuard`] rather than calling the pair by hand.
|
||||
pub trait QueueLock {
|
||||
fn lock(&self);
|
||||
fn unlock(&self);
|
||||
}
|
||||
|
||||
/// A [`QueueLock`] that guards nothing — for tests and for callers whose decode
|
||||
/// queue is provably not shared with any other submitter.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NoopQueueLock;
|
||||
|
||||
impl QueueLock for NoopQueueLock {
|
||||
fn lock(&self) {}
|
||||
fn unlock(&self) {}
|
||||
}
|
||||
|
||||
/// RAII scope over a [`QueueLock`]: acquired for exactly the duration of a queue
|
||||
/// submission, released on drop (including unwinds — though this crate's own paths
|
||||
/// never panic while holding it).
|
||||
pub struct QueueSubmitGuard<'a> {
|
||||
lock: &'a dyn QueueLock,
|
||||
}
|
||||
|
||||
impl<'a> QueueSubmitGuard<'a> {
|
||||
/// Take the queue (blocking until free).
|
||||
pub fn acquire(lock: &'a dyn QueueLock) -> Self {
|
||||
lock.lock();
|
||||
Self { lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QueueSubmitGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`DeviceHandles`] bundle that cannot be wrapped. Every variant is a caller
|
||||
/// bug (a half-filled bundle), not a runtime condition.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceError {
|
||||
/// One of the four raw handles is zero/null.
|
||||
NullHandle(&'static str),
|
||||
}
|
||||
|
||||
/// A device allocation that cannot proceed. Wraps the raw Vulkan failure OR the
|
||||
/// memory-type miss that used to be silently papered over with index 0 — a wrong
|
||||
/// type index is at best an immediate validation error and at worst a mapping of
|
||||
/// the wrong heap, so a miss is an ERROR here, never a fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AllocError {
|
||||
Vk(vk::Result),
|
||||
/// No memory type satisfies (`type_bits`, `flags`) on this device.
|
||||
NoMemoryType {
|
||||
type_bits: u32,
|
||||
flags: vk::MemoryPropertyFlags,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<vk::Result> for AllocError {
|
||||
fn from(r: vk::Result) -> Self {
|
||||
AllocError::Vk(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// First memory type matching `bits` and `want` — an [`AllocError::NoMemoryType`]
|
||||
/// when none does (the encoder's `find_mem` falls back to 0 there; here the miss
|
||||
/// surfaces).
|
||||
pub(crate) fn find_memory_type(
|
||||
props: &vk::PhysicalDeviceMemoryProperties,
|
||||
bits: u32,
|
||||
want: vk::MemoryPropertyFlags,
|
||||
) -> Result<u32, AllocError> {
|
||||
for i in 0..props.memory_type_count {
|
||||
if (bits & (1 << i)) != 0 && props.memory_types[i as usize].property_flags.contains(want) {
|
||||
return Ok(i);
|
||||
}
|
||||
}
|
||||
Err(AllocError::NoMemoryType {
|
||||
type_bits: bits,
|
||||
flags: want,
|
||||
})
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DeviceError::NullHandle(which) => {
|
||||
write!(f, "DeviceHandles.{which} is null — a half-filled bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DeviceError {}
|
||||
|
||||
/// The borrowed device with ash function tables loaded: the object every other
|
||||
/// module in this crate makes its Vulkan calls through.
|
||||
///
|
||||
/// Clone is cheap-ish (ash tables are plain structs of function pointers) and safe:
|
||||
/// clones share the same borrowed handles under the same liveness contract.
|
||||
#[derive(Clone)]
|
||||
pub struct DecodeDevice {
|
||||
instance: ash::Instance,
|
||||
device: ash::Device,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
video_queue_instance: ash::khr::video_queue::Instance,
|
||||
video_queue: ash::khr::video_queue::Device,
|
||||
video_decode_queue: ash::khr::video_decode_queue::Device,
|
||||
decode_queue: vk::Queue,
|
||||
decode_qf: u32,
|
||||
graphics_qf: u32,
|
||||
}
|
||||
|
||||
impl DecodeDevice {
|
||||
/// Load ash function tables over the borrowed handles.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The full [`DeviceHandles`] caller contract: live handles (outliving `self`
|
||||
/// and everything created through it), the video-decode extensions/features
|
||||
/// enabled at creation, and truthful queue-family fields. Null handles are
|
||||
/// rejected here; everything else cannot be checked and is trusted.
|
||||
pub unsafe fn wrap(handles: &DeviceHandles) -> Result<Self, DeviceError> {
|
||||
if handles.get_instance_proc_addr == 0 {
|
||||
return Err(DeviceError::NullHandle("get_instance_proc_addr"));
|
||||
}
|
||||
if handles.instance == 0 {
|
||||
return Err(DeviceError::NullHandle("instance"));
|
||||
}
|
||||
if handles.physical_device == 0 {
|
||||
return Err(DeviceError::NullHandle("physical_device"));
|
||||
}
|
||||
if handles.device == 0 {
|
||||
return Err(DeviceError::NullHandle("device"));
|
||||
}
|
||||
|
||||
// SAFETY: the usize is non-zero (checked above) and the caller contract says
|
||||
// it is the loader's PFN_vkGetInstanceProcAddr; fn pointers and usize share
|
||||
// size/ABI on every supported target.
|
||||
let gipa: vk::PFN_vkGetInstanceProcAddr = unsafe {
|
||||
std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
||||
handles.get_instance_proc_addr,
|
||||
)
|
||||
};
|
||||
// SAFETY: `gipa` is a valid Vulkan-1.0-conformant loader entry point per the
|
||||
// caller contract, valid for the returned Entry's lifetime (handle liveness).
|
||||
let entry = unsafe {
|
||||
ash::Entry::from_static_fn(ash::StaticFn {
|
||||
get_instance_proc_addr: gipa,
|
||||
})
|
||||
};
|
||||
// SAFETY: `handles.instance` is a live VkInstance created through this very
|
||||
// loader (caller contract), so loading instance-level functions against it
|
||||
// is exactly the ash::Instance::load contract.
|
||||
let instance = unsafe {
|
||||
ash::Instance::load(
|
||||
entry.static_fn(),
|
||||
vk::Instance::from_raw(handles.instance as u64),
|
||||
)
|
||||
};
|
||||
// SAFETY: `handles.device` is a live VkDevice of that instance (caller
|
||||
// contract) — the ash::Device::load contract.
|
||||
let device = unsafe {
|
||||
ash::Device::load(
|
||||
instance.fp_v1_0(),
|
||||
vk::Device::from_raw(handles.device as u64),
|
||||
)
|
||||
};
|
||||
let video_queue_instance = ash::khr::video_queue::Instance::new(&entry, &instance);
|
||||
let video_queue = ash::khr::video_queue::Device::new(&instance, &device);
|
||||
let video_decode_queue = ash::khr::video_decode_queue::Device::new(&instance, &device);
|
||||
// SAFETY: the caller contract guarantees `decode_qf`/`decode_queue_index`
|
||||
// name a queue the device was created with.
|
||||
let decode_queue =
|
||||
unsafe { device.get_device_queue(handles.decode_qf, handles.decode_queue_index) };
|
||||
// `entry` is only the ladder the tables above were loaded through; nothing
|
||||
// needs it afterwards (ash tables own their function pointers).
|
||||
drop(entry);
|
||||
|
||||
Ok(Self {
|
||||
instance,
|
||||
device,
|
||||
physical_device: vk::PhysicalDevice::from_raw(handles.physical_device as u64),
|
||||
video_queue_instance,
|
||||
video_queue,
|
||||
video_decode_queue,
|
||||
decode_queue,
|
||||
decode_qf: handles.decode_qf,
|
||||
graphics_qf: handles.graphics_qf,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ash(&self) -> &ash::Device {
|
||||
&self.device
|
||||
}
|
||||
|
||||
pub(crate) fn physical_device(&self) -> vk::PhysicalDevice {
|
||||
self.physical_device
|
||||
}
|
||||
|
||||
pub(crate) fn video_queue_instance(&self) -> &ash::khr::video_queue::Instance {
|
||||
&self.video_queue_instance
|
||||
}
|
||||
|
||||
pub(crate) fn video_queue(&self) -> &ash::khr::video_queue::Device {
|
||||
&self.video_queue
|
||||
}
|
||||
|
||||
pub(crate) fn video_decode_queue(&self) -> &ash::khr::video_decode_queue::Device {
|
||||
&self.video_decode_queue
|
||||
}
|
||||
|
||||
pub(crate) fn decode_queue(&self) -> vk::Queue {
|
||||
self.decode_queue
|
||||
}
|
||||
|
||||
pub(crate) fn decode_qf(&self) -> u32 {
|
||||
self.decode_qf
|
||||
}
|
||||
|
||||
/// The queue families image sharing spans: empty (EXCLUSIVE) when decode and
|
||||
/// graphics are one family, both otherwise (CONCURRENT — the presenter samples
|
||||
/// decode output on its own family and per-frame ownership transfers would buy
|
||||
/// latency for nothing at punktfunk's frame rates).
|
||||
pub(crate) fn sharing_families(&self) -> Vec<u32> {
|
||||
if self.decode_qf == self.graphics_qf {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![self.decode_qf, self.graphics_qf]
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's memory properties (queried fresh; cheap and stateless).
|
||||
pub(crate) fn memory_properties(&self) -> vk::PhysicalDeviceMemoryProperties {
|
||||
// SAFETY: `physical_device` is live per the DeviceHandles contract; the call
|
||||
// fills a plain struct and touches nothing else.
|
||||
unsafe {
|
||||
self.instance
|
||||
.get_physical_device_memory_properties(self.physical_device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_half_filled_bundle_is_rejected_before_any_ffi() {
|
||||
let mut handles = DeviceHandles {
|
||||
get_instance_proc_addr: 0,
|
||||
instance: 1,
|
||||
physical_device: 1,
|
||||
device: 1,
|
||||
decode_qf: 0,
|
||||
decode_queue_index: 0,
|
||||
graphics_qf: 0,
|
||||
};
|
||||
// SAFETY: wrap rejects the null handle before making any Vulkan call, so no
|
||||
// part of the liveness contract is exercised. (`Err` matched by hand: the
|
||||
// Ok side holds ash tables, which carry no Debug for unwrap_err.)
|
||||
let result = unsafe { DecodeDevice::wrap(&handles) };
|
||||
let Err(err) = result else {
|
||||
panic!("a null gipa must be rejected")
|
||||
};
|
||||
assert_eq!(err, DeviceError::NullHandle("get_instance_proc_addr"));
|
||||
|
||||
handles.get_instance_proc_addr = 1;
|
||||
handles.device = 0;
|
||||
// SAFETY: as above — the null device handle is rejected before any FFI.
|
||||
let result = unsafe { DecodeDevice::wrap(&handles) };
|
||||
let Err(err) = result else {
|
||||
panic!("a null device must be rejected")
|
||||
};
|
||||
assert_eq!(err, DeviceError::NullHandle("device"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_type_miss_is_an_error_never_a_fallback_to_index_zero() {
|
||||
let mut props = vk::PhysicalDeviceMemoryProperties {
|
||||
memory_type_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
props.memory_types[0].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
|
||||
props.memory_types[1].property_flags =
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
|
||||
|
||||
// A hit resolves to the matching index, not the first.
|
||||
assert_eq!(
|
||||
find_memory_type(
|
||||
&props,
|
||||
0b11,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT
|
||||
),
|
||||
Ok(1)
|
||||
);
|
||||
// A type excluded by the requirement bits does not count as a hit.
|
||||
assert_eq!(
|
||||
find_memory_type(&props, 0b01, vk::MemoryPropertyFlags::HOST_VISIBLE),
|
||||
Err(AllocError::NoMemoryType {
|
||||
type_bits: 0b01,
|
||||
flags: vk::MemoryPropertyFlags::HOST_VISIBLE
|
||||
})
|
||||
);
|
||||
// Flags nothing advertises: an error carrying the miss, never index 0.
|
||||
assert_eq!(
|
||||
find_memory_type(&props, 0b11, vk::MemoryPropertyFlags::PROTECTED),
|
||||
Err(AllocError::NoMemoryType {
|
||||
type_bits: 0b11,
|
||||
flags: vk::MemoryPropertyFlags::PROTECTED
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_submit_guard_brackets_the_lock() {
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CountingLock {
|
||||
depth: AtomicI32,
|
||||
peak: AtomicI32,
|
||||
}
|
||||
impl QueueLock for CountingLock {
|
||||
fn lock(&self) {
|
||||
let d = self.depth.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.peak.fetch_max(d, Ordering::SeqCst);
|
||||
}
|
||||
fn unlock(&self) {
|
||||
self.depth.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let lock = CountingLock::default();
|
||||
{
|
||||
let _guard = QueueSubmitGuard::acquire(&lock);
|
||||
assert_eq!(lock.depth.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
assert_eq!(lock.depth.load(Ordering::SeqCst), 0, "released on drop");
|
||||
assert_eq!(lock.peak.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
//! DPB + decode-output image pools, caps-driven for BOTH DPB arrangements:
|
||||
//!
|
||||
//! - **coincide** (`DPB_AND_OUTPUT_COINCIDE`, RADV's shape): the decode output IS
|
||||
//! the DPB picture — one pool, every slot usable both as setup/reference and as
|
||||
//! the frame handed to the presenter.
|
||||
//! - **distinct** (NVIDIA's shape): a reference-only DPB pool plus a small ring of
|
||||
//! output images the decoder writes `dst` into.
|
||||
//!
|
||||
//! Within either mode the DPB is **layered** (one image, one array layer per slot —
|
||||
//! mandatory when the driver lacks `SEPARATE_REFERENCE_IMAGES`) or **per-slot**
|
||||
//! (one image each). [`plan_pools`] is the pure decision table; [`ImagePool`] is
|
||||
//! the thin Vulkan half.
|
||||
//!
|
||||
//! Presenter-facing surfaces (outputs) carry `MUTABLE_FORMAT` (advertised by the
|
||||
//! driver — [`crate::caps::derive_caps`] refuses otherwise; nothing here aliases,
|
||||
//! so no `ALIAS`) so per-plane `R8`/`R8G8` views exist for the presenter's
|
||||
//! sampling path, one TIMELINE semaphore per output slot signals decode
|
||||
//! completion, and the conformance-window crop rides the frame struct — the
|
||||
//! 1088-row smear class dies by construction because the consumer is TOLD the
|
||||
//! crop instead of guessing from the pool shape. Images are allocated at the
|
||||
//! `pictureAccessGranularity`-rounded extent; the stream's coded extent rides
|
||||
//! separately for per-picture resources.
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::native as hh;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::H264ProfileChain;
|
||||
use crate::caps::COINCIDE_USAGE;
|
||||
use crate::caps::DPB_USAGE;
|
||||
use crate::caps::OUTPUT_USAGE;
|
||||
use crate::device::find_memory_type;
|
||||
use crate::device::AllocError;
|
||||
use crate::device::DecodeDevice;
|
||||
|
||||
/// Distinct-mode output ring depth: decode-ahead is one-in/one-out under the
|
||||
/// punktfunk envelope, so a small ring covers pipelining plus a frame in the
|
||||
/// presenter's hands.
|
||||
pub const OUTPUT_RING: u32 = 4;
|
||||
|
||||
/// The pure pool shape for one (caps, slot-count) pair.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PoolPlan {
|
||||
pub dpb_image_count: u32,
|
||||
pub dpb_layers_per_image: u32,
|
||||
pub dpb_usage: vk::ImageUsageFlags,
|
||||
pub dpb_flags: vk::ImageCreateFlags,
|
||||
/// 0 in coincide mode — outputs ARE the DPB slots.
|
||||
pub output_image_count: u32,
|
||||
pub output_usage: vk::ImageUsageFlags,
|
||||
pub output_flags: vk::ImageCreateFlags,
|
||||
/// Semaphore/query/command ring size: DPB slots when coincide, the output
|
||||
/// ring otherwise.
|
||||
pub output_slots: u32,
|
||||
}
|
||||
|
||||
/// Decide the pool shape. Pure — the four caps combinations are unit-tested below.
|
||||
///
|
||||
/// The usages are exactly the ones the caps derivation validated against the
|
||||
/// driver's advertised envelope ([`DPB_USAGE`]/[`OUTPUT_USAGE`]/[`COINCIDE_USAGE`]);
|
||||
/// presenter-facing images add only `MUTABLE_FORMAT` (advertised — derive_caps
|
||||
/// gates on it; no `ALIAS`: nothing aliases these images).
|
||||
pub fn plan_pools(caps: &DecodeCaps, dpb_slots: u32, output_ring: u32) -> PoolPlan {
|
||||
let (dpb_image_count, dpb_layers_per_image) = if caps.layered_dpb {
|
||||
(1, dpb_slots)
|
||||
} else {
|
||||
(dpb_slots, 1)
|
||||
};
|
||||
let presented_flags = vk::ImageCreateFlags::MUTABLE_FORMAT;
|
||||
if caps.coincide {
|
||||
PoolPlan {
|
||||
dpb_image_count,
|
||||
dpb_layers_per_image,
|
||||
dpb_usage: COINCIDE_USAGE,
|
||||
dpb_flags: presented_flags,
|
||||
output_image_count: 0,
|
||||
output_usage: vk::ImageUsageFlags::empty(),
|
||||
output_flags: vk::ImageCreateFlags::empty(),
|
||||
output_slots: dpb_slots,
|
||||
}
|
||||
} else {
|
||||
PoolPlan {
|
||||
dpb_image_count,
|
||||
dpb_layers_per_image,
|
||||
dpb_usage: DPB_USAGE,
|
||||
dpb_flags: vk::ImageCreateFlags::empty(),
|
||||
output_image_count: output_ring,
|
||||
output_usage: OUTPUT_USAGE,
|
||||
output_flags: presented_flags,
|
||||
output_slots: output_ring,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One presenter-facing output slot: the image (a DPB slot's in coincide mode, a
|
||||
/// ring image otherwise), its full + per-plane views, and the timeline semaphore
|
||||
/// each decode into this slot signals.
|
||||
pub(crate) struct OutputSlot {
|
||||
pub image: vk::Image,
|
||||
/// The image's array layer this slot occupies (barriers target it; the VIEWS
|
||||
/// already select it, so picture resources use `base_array_layer` 0).
|
||||
pub layer: u32,
|
||||
/// Full-picture view in the pool format (what decode binds as `dst`).
|
||||
pub view: vk::ImageView,
|
||||
/// `R8_UNORM` / `R8G8_UNORM` plane views for the presenter's sampler path.
|
||||
pub plane_views: [vk::ImageView; 2],
|
||||
pub semaphore: vk::Semaphore,
|
||||
/// Last timeline value signalled on `semaphore` (0 = never used).
|
||||
pub value: u64,
|
||||
}
|
||||
|
||||
/// The Vulkan half: images, memory, views, semaphores. Destroys everything it
|
||||
/// created on drop (null-safe, so a half-built pool from a failed create unwinds
|
||||
/// cleanly).
|
||||
pub(crate) struct ImagePool {
|
||||
device: ash::Device,
|
||||
pub(crate) coincide: bool,
|
||||
/// The STREAM's coded extent — what per-picture resources report.
|
||||
pub(crate) coded_extent: vk::Extent2D,
|
||||
/// The allocation extent: `coded_extent` rounded up to the device's
|
||||
/// `pictureAccessGranularity` (images only; never leaks into picture params).
|
||||
image_extent: vk::Extent2D,
|
||||
images: Vec<vk::Image>,
|
||||
memory: Vec<vk::DeviceMemory>,
|
||||
/// Per-DPB-slot full view (setup/reference binding).
|
||||
dpb_views: Vec<vk::ImageView>,
|
||||
/// Per-DPB-slot (image index, array layer) for barrier targeting.
|
||||
dpb_location: Vec<(usize, u32)>,
|
||||
pub(crate) outputs: Vec<OutputSlot>,
|
||||
}
|
||||
|
||||
impl ImagePool {
|
||||
/// Create the pools for `plan`: images at the granularity-rounded
|
||||
/// `image_extent`, picture metadata at the stream's `coded_extent`.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `dev` wraps live handles ([`crate::DeviceHandles`] contract).
|
||||
pub(crate) unsafe fn create(
|
||||
dev: &DecodeDevice,
|
||||
caps: &DecodeCaps,
|
||||
plan: &PoolPlan,
|
||||
coded_extent: vk::Extent2D,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<Self, AllocError> {
|
||||
let mut pool = Self {
|
||||
device: dev.ash().clone(),
|
||||
coincide: caps.coincide,
|
||||
coded_extent,
|
||||
image_extent: caps.aligned_extent(coded_extent),
|
||||
images: Vec::new(),
|
||||
memory: Vec::new(),
|
||||
dpb_views: Vec::new(),
|
||||
dpb_location: Vec::new(),
|
||||
outputs: Vec::new(),
|
||||
};
|
||||
// SAFETY: caller's contract; on error `pool` drops and unwinds whatever
|
||||
// half was built (Drop is null-safe and destroys only owned objects).
|
||||
unsafe { pool.build(dev, caps, plan, std_profile_idc)? };
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// As [`Self::create`].
|
||||
unsafe fn build(
|
||||
&mut self,
|
||||
dev: &DecodeDevice,
|
||||
caps: &DecodeCaps,
|
||||
plan: &PoolPlan,
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<(), AllocError> {
|
||||
let families = dev.sharing_families();
|
||||
|
||||
// DPB images + per-slot views.
|
||||
for _ in 0..plan.dpb_image_count {
|
||||
// SAFETY: fn contract (live device).
|
||||
let (image, memory) = unsafe {
|
||||
create_video_image(
|
||||
dev,
|
||||
caps.dpb_format,
|
||||
self.image_extent,
|
||||
plan.dpb_layers_per_image,
|
||||
plan.dpb_usage,
|
||||
plan.dpb_flags,
|
||||
&families,
|
||||
std_profile_idc,
|
||||
)?
|
||||
};
|
||||
self.images.push(image);
|
||||
self.memory.push(memory);
|
||||
}
|
||||
let dpb_slots = plan.dpb_image_count * plan.dpb_layers_per_image;
|
||||
for slot in 0..dpb_slots {
|
||||
let (image_index, layer) = if plan.dpb_image_count == 1 {
|
||||
(0usize, slot)
|
||||
} else {
|
||||
(slot as usize, 0u32)
|
||||
};
|
||||
// SAFETY: `image` was created above with at least `layer + 1` layers.
|
||||
let view = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
self.images[image_index],
|
||||
caps.dpb_format,
|
||||
vk::ImageAspectFlags::COLOR,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
self.dpb_views.push(view);
|
||||
self.dpb_location.push((image_index, layer));
|
||||
}
|
||||
|
||||
// Output slots: over the DPB slots (coincide) or over a fresh ring.
|
||||
let output_targets: Vec<(vk::Image, u32)> = if caps.coincide {
|
||||
self.dpb_location
|
||||
.iter()
|
||||
.map(|&(image_index, layer)| (self.images[image_index], layer))
|
||||
.collect()
|
||||
} else {
|
||||
let mut targets = Vec::new();
|
||||
for _ in 0..plan.output_image_count {
|
||||
// SAFETY: fn contract (live device).
|
||||
let (image, memory) = unsafe {
|
||||
create_video_image(
|
||||
dev,
|
||||
caps.output_format,
|
||||
self.image_extent,
|
||||
1,
|
||||
plan.output_usage,
|
||||
plan.output_flags,
|
||||
&families,
|
||||
std_profile_idc,
|
||||
)?
|
||||
};
|
||||
self.images.push(image);
|
||||
self.memory.push(memory);
|
||||
targets.push((image, 0));
|
||||
}
|
||||
targets
|
||||
};
|
||||
|
||||
for (image, layer) in output_targets {
|
||||
// SAFETY: `image` exists with `layer` in range (holds for all four
|
||||
// creates below); the formats are plane-compatible with the pool
|
||||
// format (NV12: R8 + R8G8) and the image carries MUTABLE_FORMAT for
|
||||
// the reinterpreting views.
|
||||
let view = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
caps.output_format,
|
||||
vk::ImageAspectFlags::COLOR,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
// SAFETY: as above.
|
||||
let plane_y = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
vk::Format::R8_UNORM,
|
||||
vk::ImageAspectFlags::PLANE_0,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
// SAFETY: as above.
|
||||
let plane_uv = unsafe {
|
||||
create_view(
|
||||
&self.device,
|
||||
image,
|
||||
vk::Format::R8G8_UNORM,
|
||||
vk::ImageAspectFlags::PLANE_1,
|
||||
layer,
|
||||
)?
|
||||
};
|
||||
let mut type_info = vk::SemaphoreTypeCreateInfo::default()
|
||||
.semaphore_type(vk::SemaphoreType::TIMELINE)
|
||||
.initial_value(0);
|
||||
let sem_ci = vk::SemaphoreCreateInfo::default().push_next(&mut type_info);
|
||||
// SAFETY: live device; timelineSemaphore is enabled per the
|
||||
// DeviceHandles feature contract.
|
||||
let semaphore = unsafe { self.device.create_semaphore(&sem_ci, None)? };
|
||||
self.outputs.push(OutputSlot {
|
||||
image,
|
||||
layer,
|
||||
view,
|
||||
plane_views: [plane_y, plane_uv],
|
||||
semaphore,
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The DPB binding view of `slot`.
|
||||
pub(crate) fn dpb_view(&self, slot: u8) -> vk::ImageView {
|
||||
self.dpb_views[usize::from(slot)]
|
||||
}
|
||||
|
||||
/// The image + array layer behind DPB `slot` (barrier targeting).
|
||||
pub(crate) fn dpb_target(&self, slot: u8) -> (vk::Image, u32) {
|
||||
let (image_index, layer) = self.dpb_location[usize::from(slot)];
|
||||
(self.images[image_index], layer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ImagePool {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: every handle below was created by this pool on this (still-live,
|
||||
// per the DeviceHandles contract) device; the owning decoder drains GPU
|
||||
// work before dropping state. vkDestroy*/vkFree ignore NULL handles.
|
||||
unsafe {
|
||||
for view in self.dpb_views.drain(..) {
|
||||
self.device.destroy_image_view(view, None);
|
||||
}
|
||||
for out in self.outputs.drain(..) {
|
||||
self.device.destroy_image_view(out.view, None);
|
||||
self.device.destroy_image_view(out.plane_views[0], None);
|
||||
self.device.destroy_image_view(out.plane_views[1], None);
|
||||
self.device.destroy_semaphore(out.semaphore, None);
|
||||
}
|
||||
for image in self.images.drain(..) {
|
||||
self.device.destroy_image(image, None);
|
||||
}
|
||||
for memory in self.memory.drain(..) {
|
||||
self.device.free_memory(memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One OPTIMAL-tiling video image bound to fresh DEVICE_LOCAL memory, profile-listed
|
||||
/// (mirrors the encoder's `make_video_image`, minus its `&mut` profile-list plumbing).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `dev` wraps live handles.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn create_video_image(
|
||||
dev: &DecodeDevice,
|
||||
format: vk::Format,
|
||||
extent: vk::Extent2D,
|
||||
layers: u32,
|
||||
usage: vk::ImageUsageFlags,
|
||||
flags: vk::ImageCreateFlags,
|
||||
families: &[u32],
|
||||
std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory), AllocError> {
|
||||
let mut chain = H264ProfileChain::new(std_profile_idc);
|
||||
let profile = chain.wire();
|
||||
let mut profile_list =
|
||||
vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile));
|
||||
let mut ci = vk::ImageCreateInfo::default()
|
||||
.flags(flags)
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D {
|
||||
width: extent.width,
|
||||
height: extent.height,
|
||||
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(&mut profile_list);
|
||||
ci = if families.len() >= 2 {
|
||||
ci.sharing_mode(vk::SharingMode::CONCURRENT)
|
||||
.queue_family_indices(families)
|
||||
} else {
|
||||
ci.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
};
|
||||
// SAFETY: live device; `ci` roots a chain of locals outliving the call.
|
||||
let image = unsafe { dev.ash().create_image(&ci, None)? };
|
||||
// SAFETY: `image` was just created on this device.
|
||||
let req = unsafe { dev.ash().get_image_memory_requirements(image) };
|
||||
let props = dev.memory_properties();
|
||||
let type_index = match find_memory_type(
|
||||
&props,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
) {
|
||||
Ok(index) => index,
|
||||
Err(e) => {
|
||||
// SAFETY: destroying the just-created, never-bound image.
|
||||
unsafe { dev.ash().destroy_image(image, None) };
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let alloc = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(type_index);
|
||||
// SAFETY: live device; unwind destroys the unbound image so the error path
|
||||
// leaks nothing.
|
||||
let memory = match unsafe { dev.ash().allocate_memory(&alloc, None) } {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// SAFETY: destroying the just-created, never-bound image.
|
||||
unsafe { dev.ash().destroy_image(image, None) };
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
// SAFETY: fresh image + fresh memory of the required size.
|
||||
if let Err(e) = unsafe { dev.ash().bind_image_memory(image, memory, 0) } {
|
||||
// SAFETY: unwinding the two objects created above.
|
||||
unsafe {
|
||||
dev.ash().destroy_image(image, None);
|
||||
dev.ash().free_memory(memory, None);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok((image, memory))
|
||||
}
|
||||
|
||||
/// One single-layer 2D view (`base_array_layer = layer`, identity swizzle).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `image` is live on `device` with `layer` in range; `format`/`aspect` are
|
||||
/// compatible with the image's creation (same format for COLOR, plane-compatible
|
||||
/// under MUTABLE_FORMAT for the plane aspects).
|
||||
unsafe fn create_view(
|
||||
device: &ash::Device,
|
||||
image: vk::Image,
|
||||
format: vk::Format,
|
||||
aspect: vk::ImageAspectFlags,
|
||||
layer: u32,
|
||||
) -> Result<vk::ImageView, vk::Result> {
|
||||
let ci = vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(vk::ImageSubresourceRange {
|
||||
aspect_mask: aspect,
|
||||
base_mip_level: 0,
|
||||
level_count: 1,
|
||||
base_array_layer: layer,
|
||||
layer_count: 1,
|
||||
});
|
||||
// SAFETY: the fn-level contract restates exactly what create_image_view needs.
|
||||
unsafe { device.create_image_view(&ci, None) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::caps::derive_caps;
|
||||
use crate::caps::RawH264Caps;
|
||||
use crate::caps::VideoFormat;
|
||||
use crate::caps::NV12;
|
||||
|
||||
fn caps(coincide: bool, layered: bool) -> DecodeCaps {
|
||||
// Every entry advertises its role's full usage plus MUTABLE_FORMAT — the
|
||||
// derivation gates on those; this module's decision table is downstream.
|
||||
let entry = |usage: vk::ImageUsageFlags| VideoFormat {
|
||||
format: NV12,
|
||||
image_usage: usage,
|
||||
image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT,
|
||||
};
|
||||
let raw = RawH264Caps {
|
||||
capability_flags: if layered {
|
||||
vk::VideoCapabilityFlagsKHR::empty()
|
||||
} else {
|
||||
vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES
|
||||
},
|
||||
decode_flags: if coincide {
|
||||
vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE
|
||||
} else {
|
||||
vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_DISTINCT
|
||||
},
|
||||
dpb_formats: vec![entry(DPB_USAGE)],
|
||||
output_formats: vec![entry(OUTPUT_USAGE)],
|
||||
coincide_formats: vec![entry(COINCIDE_USAGE)],
|
||||
..Default::default()
|
||||
};
|
||||
derive_caps(&raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincide_layered_is_one_dual_use_array_with_no_output_ring() {
|
||||
let plan = plan_pools(&caps(true, true), 5, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 5));
|
||||
assert_eq!(
|
||||
plan.dpb_usage, COINCIDE_USAGE,
|
||||
"coincide: the DPB image is the decode output AND the sampled surface"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.dpb_flags,
|
||||
vk::ImageCreateFlags::MUTABLE_FORMAT,
|
||||
"plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS"
|
||||
);
|
||||
assert_eq!(plan.output_image_count, 0);
|
||||
assert_eq!(plan.output_slots, 5, "one output slot per DPB slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincide_separate_is_one_dual_use_image_per_slot() {
|
||||
let plan = plan_pools(&caps(true, false), 5, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (5, 1));
|
||||
assert_eq!(plan.output_image_count, 0);
|
||||
assert_eq!(plan.output_slots, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_layered_is_a_reference_only_array_plus_an_output_ring() {
|
||||
let plan = plan_pools(&caps(false, true), 17, OUTPUT_RING);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (1, 17));
|
||||
assert_eq!(
|
||||
plan.dpb_usage, DPB_USAGE,
|
||||
"distinct: the DPB is never sampled and never a decode dst"
|
||||
);
|
||||
assert_eq!(plan.dpb_flags, vk::ImageCreateFlags::empty());
|
||||
assert_eq!(plan.output_image_count, OUTPUT_RING);
|
||||
assert_eq!(plan.output_usage, OUTPUT_USAGE);
|
||||
assert_eq!(
|
||||
plan.output_flags,
|
||||
vk::ImageCreateFlags::MUTABLE_FORMAT,
|
||||
"plane views need MUTABLE_FORMAT; nothing aliases, so no ALIAS"
|
||||
);
|
||||
assert_eq!(plan.output_slots, OUTPUT_RING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_separate_is_per_slot_reference_images_plus_the_ring() {
|
||||
let plan = plan_pools(&caps(false, false), 3, 2);
|
||||
assert_eq!((plan.dpb_image_count, plan.dpb_layers_per_image), (3, 1));
|
||||
assert_eq!(plan.output_image_count, 2);
|
||||
assert_eq!(plan.output_slots, 2);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
//! (design/client-native-decode.md §3.2).
|
||||
//!
|
||||
//! This crate sits between [`pf_bitstream`]'s per-AU planning and Vulkan Video
|
||||
//! submission. WP-A (this round) is the CPU-testable half, and everything in it runs
|
||||
//! without a GPU:
|
||||
//! submission.
|
||||
//!
|
||||
//! WP-A — the CPU-testable half, everything runs without a GPU:
|
||||
//!
|
||||
//! - [`params`]: the vendored parser's `Sps`/`Pps` converted into the
|
||||
//! `StdVideoH264*ParameterSet` structs session parameters are created from, behind
|
||||
@@ -16,19 +17,58 @@
|
||||
//! `StdVideoDecodeH264PictureInfo`/`StdVideoDecodeH264ReferenceInfo` set plus slice
|
||||
//! offsets and slot bindings a `vkCmdDecodeVideoKHR` call wants.
|
||||
//!
|
||||
//! WP-B adds the other half: VkVideoSessionKHR/session-parameters objects, DPB image
|
||||
//! memory, command recording and result queries. Nothing here touches a VkDevice.
|
||||
//! WP-B — the GPU half, built ON the borrowed presenter device (this crate never
|
||||
//! creates or destroys a VkDevice) with the decision logic split out pure so it
|
||||
//! stays CPU-testable:
|
||||
//!
|
||||
//! - [`device`]: [`DeviceHandles`] (the borrowed handle bundle + its liveness
|
||||
//! contract), the [`QueueLock`] trait every submit runs under, [`DecodeDevice`].
|
||||
//! - [`caps`]: one thin driver query + [`derive_caps`], the pure
|
||||
//! coincide/distinct/layered decision table ([`DecodeCaps`]).
|
||||
//! - [`session`]: `VkVideoSessionKHR` + versioned session parameters (pure ledger
|
||||
//! decides Add-vs-Recreate; extent/DPB renegotiation rebuilds the session).
|
||||
//! - [`images`]: DPB + output pools for BOTH DPB arrangements (pure [`plan_pools`]
|
||||
//! decides the shape), per-plane views, one timeline semaphore per output slot,
|
||||
//! crop carried on the frame.
|
||||
//! - [`ring`]: the host-visible bitstream upload ring (pure alignment/growth math).
|
||||
//! - [`decoder`]: [`VkH264Decoder`] — plan → convert → upload → record → submit,
|
||||
//! with a per-op `RESULT_STATUS_ONLY` query ([`VkH264Decoder::poll_status`]) so
|
||||
//! driver-reported corruption is finally observable (the Ally X class).
|
||||
//!
|
||||
//! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate
|
||||
//! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the
|
||||
//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and WP-B brings Vulkan
|
||||
//! FFI. Every unsafe block therefore carries a written `// SAFETY:` proof, enforced:
|
||||
//! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is
|
||||
//! Vulkan FFI. Every unsafe block therefore carries a written `// SAFETY:` proof,
|
||||
//! enforced (and unlike the encoder there is NO file-level
|
||||
//! `unsafe_op_in_unsafe_fn` exemption — every operation is individually fenced):
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
pub mod caps;
|
||||
pub mod decoder;
|
||||
pub mod device;
|
||||
pub mod images;
|
||||
pub mod params;
|
||||
pub mod pic;
|
||||
pub mod ring;
|
||||
pub mod session;
|
||||
pub mod slots;
|
||||
|
||||
pub use caps::derive_caps;
|
||||
pub use caps::CapsError;
|
||||
pub use caps::DecodeCaps;
|
||||
pub use caps::RawH264Caps;
|
||||
pub use caps::VideoFormat;
|
||||
pub use decoder::DecodeStatus;
|
||||
pub use decoder::DecodedVkFrame;
|
||||
pub use decoder::VkDecodeError;
|
||||
pub use decoder::VkH264Decoder;
|
||||
pub use device::DecodeDevice;
|
||||
pub use device::DeviceHandles;
|
||||
pub use device::NoopQueueLock;
|
||||
pub use device::QueueLock;
|
||||
pub use device::QueueSubmitGuard;
|
||||
pub use images::plan_pools;
|
||||
pub use images::PoolPlan;
|
||||
pub use params::pps_to_std;
|
||||
pub use params::sps_to_std;
|
||||
pub use params::OwnedStdPps;
|
||||
@@ -38,5 +78,8 @@ pub use pic::plan_to_vk;
|
||||
pub use pic::DecodePlanVk;
|
||||
pub use pic::PlanToVkError;
|
||||
pub use pic::VkRef;
|
||||
pub use ring::RingLayout;
|
||||
pub use session::ParamsAction;
|
||||
pub use session::SessionConfig;
|
||||
pub use slots::SlotError;
|
||||
pub use slots::SlotMap;
|
||||
|
||||
@@ -121,8 +121,9 @@ impl OwnedStdPps {
|
||||
}
|
||||
|
||||
/// H.264 `level_idc` (value-coded: 10 ⇒ 1.0) to Vulkan's index-coded
|
||||
/// `StdVideoH264LevelIdc`.
|
||||
const fn level_to_std(level: Level) -> hh::StdVideoH264LevelIdc {
|
||||
/// `StdVideoH264LevelIdc`. The Std code points ascend with the level, so the
|
||||
/// decoder's `maxLevelIdc` gate compares them numerically.
|
||||
pub(crate) const fn level_to_std(level: Level) -> hh::StdVideoH264LevelIdc {
|
||||
match level {
|
||||
Level::L1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_0,
|
||||
// Vulkan has no 1b code point. 1b is signalled on the wire as level_idc 11
|
||||
|
||||
@@ -683,6 +683,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_dpb_bump_reuses_the_evicted_slot_only_after_its_frame_is_released() {
|
||||
// Depth-1 DPB (Level 1 at 320x240 ⇒ max_dpb_frames 1, capacity 2): every
|
||||
// stored P evicts the previous picture, and that picture's id lands in
|
||||
// BOTH `outputs` and `removed` of the SAME plan — the exact sequence
|
||||
// where, without pins, `plan_to_vk` frees the evicted slot and
|
||||
// immediately re-assigns it as this AU's setup while the evicted
|
||||
// picture's frame is still in the consumer's hands (the HIGH overwrite
|
||||
// bug of the adversarial round). This test drives the decoder's exact
|
||||
// call pattern: pin at frame creation, unpin at release_frame.
|
||||
let sps = SpsBuilder::new()
|
||||
.seq_parameter_set_id(0)
|
||||
.profile_idc(Profile::Main)
|
||||
.level_idc(Level::L1)
|
||||
.frame_mbs_only_flag(true)
|
||||
.direct_8x8_inference_flag(true)
|
||||
.max_num_ref_frames(1)
|
||||
.log2_max_frame_num_minus4(0)
|
||||
.pic_order_cnt_type(0)
|
||||
.log2_max_pic_order_cnt_lsb_minus4(0)
|
||||
.resolution(320, 240)
|
||||
.build();
|
||||
let pps = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(0)
|
||||
.pic_init_qp(26)
|
||||
.build();
|
||||
let mut au0 = Vec::new();
|
||||
Synthesizer::<'_, Sps, _>::synthesize(3, &sps, &mut au0, true).unwrap();
|
||||
Synthesizer::<'_, Pps, _>::synthesize(3, &pps, &mut au0, true).unwrap();
|
||||
au0.extend(write_idr_slice(None));
|
||||
|
||||
// First, the COUNTERFACTUAL (no pins): the bump hands the evicted
|
||||
// picture's slot straight back as the next setup — the bug this guards.
|
||||
{
|
||||
let mut planner = H264Planner::new();
|
||||
let p0 = planner.plan_au(&au0).unwrap();
|
||||
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
|
||||
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
|
||||
let p1 = planner
|
||||
.plan_au(&write_p_slice(1, 2, None, 1, None))
|
||||
.unwrap();
|
||||
let id0 = vk0.setup_id;
|
||||
assert!(p1.dpb.outputs.contains(&id0) && p1.dpb.removed.contains(&id0));
|
||||
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
|
||||
assert_eq!(
|
||||
vk1.setup_slot, vk0.setup_slot,
|
||||
"without pins the delivered frame's image IS the next decode target"
|
||||
);
|
||||
}
|
||||
|
||||
// Now the decoder's discipline: every frame pins its slot at creation.
|
||||
let mut planner = H264Planner::new();
|
||||
let p0 = planner.plan_au(&au0).unwrap();
|
||||
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
|
||||
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
|
||||
slots.pin(vk0.setup_slot);
|
||||
|
||||
// AU1 bumps AU0's picture (outputs + removed) — the freed slot is
|
||||
// pinned, so the setup lands elsewhere and the unreleased frame's image
|
||||
// is never a decode target.
|
||||
let p1 = planner
|
||||
.plan_au(&write_p_slice(1, 2, None, 1, None))
|
||||
.unwrap();
|
||||
assert!(p1.dpb.removed.contains(&vk0.setup_id));
|
||||
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
|
||||
assert_ne!(
|
||||
vk1.setup_slot, vk0.setup_slot,
|
||||
"a pinned (delivered, unreleased) slot must never be the setup"
|
||||
);
|
||||
slots.pin(vk1.setup_slot);
|
||||
|
||||
// With NOTHING released, the next AU has no assignable slot: explicit
|
||||
// backpressure (AllPinned → the decoder's NoFreeSlot), never an overwrite.
|
||||
let p2 = planner
|
||||
.plan_au(&write_p_slice(2, 4, None, 1, None))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
plan_to_vk(&p2, &mut slots, 0),
|
||||
Err(PlanToVkError::Slot(SlotError::AllPinned { .. }))
|
||||
));
|
||||
|
||||
// The consumer releases frame 0 (decoder: release_frame → unpin): its
|
||||
// slot becomes the next setup — reuse happens exactly one release later.
|
||||
assert!(slots.unpin(vk0.setup_slot));
|
||||
let p3 = planner
|
||||
.plan_au(&write_idr_slice(None))
|
||||
.expect("an IDR restart plans after the stalled AU");
|
||||
let vk3 = plan_to_vk(&p3, &mut slots, 0).unwrap();
|
||||
assert_eq!(vk3.setup_slot, vk0.setup_slot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_delta_bottom_reaches_setup_and_reference_poc_pairs_distinctly() {
|
||||
// A PPS with bottom_field_pic_order_in_frame_present_flag: progressive
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Host-visible bitstream upload ring: one persistent-mapped `VIDEO_DECODE_SRC`
|
||||
//! buffer cut into equal slots, honouring the profile's
|
||||
//! `minBitstreamBufferOffsetAlignment`/`SizeAlignment`.
|
||||
//!
|
||||
//! Split like the rest of the crate: [`RingLayout`] + [`SlotStates`] are the pure,
|
||||
//! unit-tested halves (offset/alignment math including growth, and the recycle
|
||||
//! bookkeeping); [`BitstreamRing`] is the thin Vulkan half that allocates the
|
||||
//! buffer and copies AU bytes. Slots recycle when the timeline value of the submit
|
||||
//! that consumed them completes; an AU larger than the slot size grows the ring by
|
||||
//! RECREATING the buffer (after draining every in-flight slot) — growth is rare
|
||||
//! (an IDR burst outsizing the initial slots) and a stall there beats permanently
|
||||
//! oversized slots.
|
||||
|
||||
use ash::vk;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::caps::H264ProfileChain;
|
||||
use crate::device::find_memory_type;
|
||||
use crate::device::AllocError;
|
||||
use crate::device::DecodeDevice;
|
||||
|
||||
/// Initial per-slot capacity. Sized for comfort at streaming bitrates (a 4K IDR at
|
||||
/// punktfunk rates is a few hundred KiB); the ring grows on first contact with a
|
||||
/// larger AU rather than pre-reserving worst cases.
|
||||
pub const INITIAL_SLOT_SIZE: u64 = 2 * 1024 * 1024;
|
||||
/// Slot count: enough to keep uploads ahead of a couple of in-flight decodes; the
|
||||
/// pipeline depth itself is bounded by the output/query rings, not by this.
|
||||
pub const RING_SLOTS: u32 = 4;
|
||||
|
||||
/// `x` rounded up to a multiple of power-of-two `align`.
|
||||
const fn align_up(x: u64, align: u64) -> u64 {
|
||||
(x + align - 1) & !(align - 1)
|
||||
}
|
||||
|
||||
/// Pure geometry of the ring buffer. Both Vulkan alignments are powers of two per
|
||||
/// the spec's alignment-value convention, which [`RingLayout::new`] debug-asserts;
|
||||
/// the slot size is a multiple of BOTH, so every slot offset satisfies the offset
|
||||
/// alignment and every full-slot range satisfies the size alignment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RingLayout {
|
||||
pub slot_size: u64,
|
||||
pub slots: u32,
|
||||
pub offset_alignment: u64,
|
||||
pub size_alignment: u64,
|
||||
}
|
||||
|
||||
impl RingLayout {
|
||||
pub fn new(min_slot_size: u64, slots: u32, offset_alignment: u64, size_alignment: u64) -> Self {
|
||||
debug_assert!(
|
||||
offset_alignment.is_power_of_two() && size_alignment.is_power_of_two(),
|
||||
"Vulkan alignment values are powers of two"
|
||||
);
|
||||
debug_assert!(slots > 0 && min_slot_size > 0);
|
||||
let align = offset_alignment.max(size_alignment);
|
||||
Self {
|
||||
slot_size: align_up(min_slot_size, align),
|
||||
slots,
|
||||
offset_alignment,
|
||||
size_alignment,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of `slot` — a `minBitstreamBufferOffsetAlignment` multiple by
|
||||
/// construction.
|
||||
pub fn offset_of(&self, slot: u32) -> u64 {
|
||||
debug_assert!(slot < self.slots);
|
||||
u64::from(slot) * self.slot_size
|
||||
}
|
||||
|
||||
/// Whether an AU of `len` bytes fits one slot (its aligned range included).
|
||||
pub fn fits(&self, len: u64) -> bool {
|
||||
self.record_range(len) <= self.slot_size
|
||||
}
|
||||
|
||||
/// The `srcBufferRange` to record for an AU of `len` bytes: the length rounded
|
||||
/// up to `minBitstreamBufferSizeAlignment`.
|
||||
pub fn record_range(&self, len: u64) -> u64 {
|
||||
align_up(len, self.size_alignment)
|
||||
}
|
||||
|
||||
/// Total buffer size.
|
||||
pub fn buffer_size(&self) -> u64 {
|
||||
self.slot_size * u64::from(self.slots)
|
||||
}
|
||||
|
||||
/// The layout a recreation adopts so an AU of `len` bytes fits with headroom:
|
||||
/// slot size doubles from the current one until sufficient (geometric growth —
|
||||
/// one recreation per size class, not one per oversized AU).
|
||||
pub fn grown_for(&self, len: u64) -> Self {
|
||||
let mut slot = self.slot_size.max(1);
|
||||
while align_up(len, self.size_alignment) > slot {
|
||||
slot *= 2;
|
||||
}
|
||||
Self::new(slot, self.slots, self.offset_alignment, self.size_alignment)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure recycle bookkeeping: which slots are free, which carry an in-flight token.
|
||||
/// Generic over the token so the FIFO/recycle behaviour is testable without a
|
||||
/// device (the ring instantiates `T = (vk::Semaphore, u64)`).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SlotStates<T> {
|
||||
pending: Vec<Option<T>>,
|
||||
/// Round-robin cursor: slots are handed out in order, so the slot AT the
|
||||
/// cursor is always the oldest in-flight one — the right one to wait on.
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl<T> SlotStates<T> {
|
||||
pub(crate) fn new(slots: usize) -> Self {
|
||||
Self {
|
||||
pending: (0..slots).map(|_| None).collect(),
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the next slot in round-robin order. `is_done` is consulted when the
|
||||
/// slot still carries a token (`Ok(true)` frees it); returning `Ok(false)`
|
||||
/// yields `Ok(None)` — the caller then waits on [`Self::oldest`]'s token and
|
||||
/// retries. Errors pass through untouched.
|
||||
pub(crate) fn acquire<E>(
|
||||
&mut self,
|
||||
mut is_done: impl FnMut(&T) -> Result<bool, E>,
|
||||
) -> Result<Option<usize>, E> {
|
||||
let slot = self.cursor;
|
||||
if let Some(token) = &self.pending[slot] {
|
||||
if !is_done(token)? {
|
||||
return Ok(None);
|
||||
}
|
||||
self.pending[slot] = None;
|
||||
}
|
||||
self.cursor = (self.cursor + 1) % self.pending.len();
|
||||
Ok(Some(slot))
|
||||
}
|
||||
|
||||
/// The oldest in-flight token (the one blocking [`Self::acquire`]), if any.
|
||||
pub(crate) fn oldest(&self) -> Option<&T> {
|
||||
self.pending[self.cursor].as_ref()
|
||||
}
|
||||
|
||||
/// Record `token` as `slot`'s in-flight use.
|
||||
pub(crate) fn set_pending(&mut self, slot: usize, token: T) {
|
||||
debug_assert!(
|
||||
self.pending[slot].is_none(),
|
||||
"slot handed out while pending"
|
||||
);
|
||||
self.pending[slot] = Some(token);
|
||||
}
|
||||
|
||||
/// All in-flight tokens (drain-before-recreate walks these).
|
||||
pub(crate) fn in_flight(&self) -> impl Iterator<Item = &T> {
|
||||
self.pending.iter().filter_map(Option::as_ref)
|
||||
}
|
||||
|
||||
/// Forget every token (after the caller has drained them).
|
||||
pub(crate) fn clear(&mut self) {
|
||||
for p in &mut self.pending {
|
||||
*p = None;
|
||||
}
|
||||
self.cursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// One uploaded AU: what `vkCmdDecodeVideoKHR` needs plus the slot to mark pending
|
||||
/// once the submit's timeline token exists.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct UploadedAu {
|
||||
pub offset: u64,
|
||||
pub range: u64,
|
||||
pub slot: usize,
|
||||
}
|
||||
|
||||
/// The in-flight token a used slot waits on: a timeline (semaphore, value) pair —
|
||||
/// the same pair the submit that consumed the slot signalled.
|
||||
pub(crate) type Token = (vk::Semaphore, u64);
|
||||
|
||||
/// The Vulkan half: buffer + memory + persistent map. Created against the session's
|
||||
/// video profile (the spec requires the src buffer to be profile-listed).
|
||||
pub(crate) struct BitstreamRing {
|
||||
device: ash::Device,
|
||||
layout: RingLayout,
|
||||
std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc,
|
||||
buffer: vk::Buffer,
|
||||
memory: vk::DeviceMemory,
|
||||
ptr: *mut u8,
|
||||
pub(crate) pending: SlotStates<Token>,
|
||||
}
|
||||
|
||||
impl BitstreamRing {
|
||||
/// Allocate the buffer for `layout`.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `dev` wraps live handles ([`crate::DeviceHandles`] contract).
|
||||
pub(crate) unsafe fn create(
|
||||
dev: &DecodeDevice,
|
||||
layout: RingLayout,
|
||||
std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc,
|
||||
) -> Result<Self, AllocError> {
|
||||
// SAFETY: live device; allocate_backing only creates objects it returns.
|
||||
let (buffer, memory, ptr) =
|
||||
unsafe { Self::allocate_backing(dev, &layout, std_profile_idc)? };
|
||||
Ok(Self {
|
||||
device: dev.ash().clone(),
|
||||
layout,
|
||||
std_profile_idc,
|
||||
buffer,
|
||||
memory,
|
||||
ptr,
|
||||
pending: SlotStates::new(layout.slots as usize),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn buffer(&self) -> vk::Buffer {
|
||||
self.buffer
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// As [`Self::create`].
|
||||
unsafe fn allocate_backing(
|
||||
dev: &DecodeDevice,
|
||||
layout: &RingLayout,
|
||||
std_profile_idc: ash::vk::native::StdVideoH264ProfileIdc,
|
||||
) -> Result<(vk::Buffer, vk::DeviceMemory, *mut u8), AllocError> {
|
||||
let mut chain = H264ProfileChain::new(std_profile_idc);
|
||||
let profile = chain.wire();
|
||||
let mut profile_list =
|
||||
vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile));
|
||||
let ci = vk::BufferCreateInfo::default()
|
||||
.size(layout.buffer_size())
|
||||
.usage(vk::BufferUsageFlags::VIDEO_DECODE_SRC_KHR)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.push_next(&mut profile_list);
|
||||
// SAFETY: live device; `ci` roots a chain of locals outliving the call.
|
||||
let buffer = unsafe { dev.ash().create_buffer(&ci, None)? };
|
||||
// SAFETY: `buffer` was just created on this device.
|
||||
let req = unsafe { dev.ash().get_buffer_memory_requirements(buffer) };
|
||||
let mem_props = dev.memory_properties();
|
||||
let type_index = match find_memory_type(
|
||||
&mem_props,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
) {
|
||||
Ok(index) => index,
|
||||
Err(e) => {
|
||||
// SAFETY: destroying the just-created, never-bound buffer.
|
||||
unsafe { dev.ash().destroy_buffer(buffer, None) };
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let alloc = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(type_index);
|
||||
// SAFETY: live device; on failure the buffer is destroyed before returning
|
||||
// so nothing leaks.
|
||||
let memory = match unsafe { dev.ash().allocate_memory(&alloc, None) } {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// SAFETY: destroying the just-created, never-bound buffer.
|
||||
unsafe { dev.ash().destroy_buffer(buffer, None) };
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
// SAFETY: fresh buffer + fresh memory of at least the required size.
|
||||
if let Err(e) = unsafe { dev.ash().bind_buffer_memory(buffer, memory, 0) } {
|
||||
// SAFETY: unwinding the two objects created above (unbound/unused).
|
||||
unsafe {
|
||||
dev.ash().destroy_buffer(buffer, None);
|
||||
dev.ash().free_memory(memory, None);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
// SAFETY: `memory` is HOST_VISIBLE and unmapped; WHOLE_SIZE maps its full
|
||||
// range for the buffer's lifetime (vkFreeMemory implicitly unmaps).
|
||||
let ptr = match unsafe {
|
||||
dev.ash()
|
||||
.map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())
|
||||
} {
|
||||
Ok(p) => p.cast::<u8>(),
|
||||
Err(e) => {
|
||||
// SAFETY: unwinding the two objects created above.
|
||||
unsafe {
|
||||
dev.ash().destroy_buffer(buffer, None);
|
||||
dev.ash().free_memory(memory, None);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
Ok((buffer, memory, ptr))
|
||||
}
|
||||
|
||||
/// Upload one AU, recycling or growing as needed.
|
||||
///
|
||||
/// `poll`/`wait` bridge to the caller's timeline-semaphore facts: `poll`
|
||||
/// answers "has this token completed?" without blocking; `wait` blocks until
|
||||
/// it has (bounded by the caller's timeout policy). The split keeps this
|
||||
/// module free of any semaphore knowledge.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Live device (contract), and the tokens passed to prior
|
||||
/// [`SlotStates::set_pending`] calls must genuinely cover every GPU read of
|
||||
/// their slots — recycling rewrites slot bytes as soon as a token reports done.
|
||||
pub(crate) unsafe fn upload<E: From<AllocError>>(
|
||||
&mut self,
|
||||
dev: &DecodeDevice,
|
||||
au: &[u8],
|
||||
poll: &mut dyn FnMut(&Token) -> Result<bool, E>,
|
||||
wait: &mut dyn FnMut(&Token) -> Result<(), E>,
|
||||
) -> Result<UploadedAu, E> {
|
||||
let len = au.len() as u64;
|
||||
if !self.layout.fits(len) {
|
||||
// Grow: drain EVERYTHING in flight (their reads target the old buffer),
|
||||
// then recreate the backing under the grown layout.
|
||||
for token in self.pending.in_flight() {
|
||||
wait(token)?;
|
||||
}
|
||||
self.pending.clear();
|
||||
let grown = self.layout.grown_for(len);
|
||||
debug!(
|
||||
old = self.layout.slot_size,
|
||||
new = grown.slot_size,
|
||||
au = len,
|
||||
"bitstream ring grows for an oversized AU"
|
||||
);
|
||||
// SAFETY: every in-flight read was drained above; destroy_backing only
|
||||
// touches this ring's own objects.
|
||||
unsafe { self.destroy_backing() };
|
||||
// SAFETY: caller's live-device contract.
|
||||
let (buffer, memory, ptr) =
|
||||
unsafe { Self::allocate_backing(dev, &grown, self.std_profile_idc)? };
|
||||
self.layout = grown;
|
||||
self.buffer = buffer;
|
||||
self.memory = memory;
|
||||
self.ptr = ptr;
|
||||
self.pending = SlotStates::new(grown.slots as usize);
|
||||
}
|
||||
|
||||
let slot = match self.pending.acquire(&mut *poll)? {
|
||||
Some(slot) => slot,
|
||||
None => {
|
||||
// The oldest slot is still in flight: wait it out, then retry —
|
||||
// guaranteed to succeed now.
|
||||
if let Some(token) = self.pending.oldest() {
|
||||
wait(token)?;
|
||||
}
|
||||
self.pending
|
||||
.acquire(|_| Ok(true))?
|
||||
.expect("the waited slot is free")
|
||||
}
|
||||
};
|
||||
|
||||
let offset = self.layout.offset_of(slot as u32);
|
||||
let range = self.layout.record_range(len);
|
||||
// SAFETY: `ptr` is the live persistent mapping of a buffer of
|
||||
// `layout.buffer_size()` bytes; `offset + range <= buffer_size` because
|
||||
// `range <= slot_size` (fits/grown above) and offset is `slot * slot_size`
|
||||
// with `slot < slots`. The slot is not concurrently read: its previous use
|
||||
// completed (poll/wait above) and its next use is submitted after this copy.
|
||||
unsafe {
|
||||
let base = self.ptr.add(offset as usize);
|
||||
std::ptr::copy_nonoverlapping(au.as_ptr(), base, au.len());
|
||||
// Zero the alignment tail so the recorded range never hands the driver
|
||||
// stale bytes from a previous AU behind this one's end.
|
||||
std::ptr::write_bytes(base.add(au.len()), 0, (range - len) as usize);
|
||||
}
|
||||
Ok(UploadedAu {
|
||||
offset,
|
||||
range,
|
||||
slot,
|
||||
})
|
||||
}
|
||||
|
||||
/// Destroy buffer + memory (which implicitly unmaps). Callers must have
|
||||
/// drained in-flight reads first.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Live device; no submitted-and-unfinished GPU work reads the buffer.
|
||||
unsafe fn destroy_backing(&mut self) {
|
||||
// SAFETY: the fn-level contract — objects are this ring's own, reads drained.
|
||||
unsafe {
|
||||
self.device.destroy_buffer(self.buffer, None);
|
||||
self.device.free_memory(self.memory, None);
|
||||
}
|
||||
self.buffer = vk::Buffer::null();
|
||||
self.memory = vk::DeviceMemory::null();
|
||||
self.ptr = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BitstreamRing {
|
||||
fn drop(&mut self) {
|
||||
if self.buffer == vk::Buffer::null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: the owning decoder drains its queue before dropping state (and the
|
||||
// borrowed device is alive by the DeviceHandles liveness contract).
|
||||
unsafe { self.destroy_backing() };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slot_offsets_and_ranges_honour_both_alignments() {
|
||||
// Deliberately DIFFERENT alignments: offset 256, size 64.
|
||||
let layout = RingLayout::new(1000, 4, 256, 64);
|
||||
// Slot size rounds up to a multiple of max(256, 64).
|
||||
assert_eq!(layout.slot_size, 1024);
|
||||
for slot in 0..4 {
|
||||
assert_eq!(layout.offset_of(slot) % 256, 0, "offset alignment");
|
||||
}
|
||||
assert_eq!(layout.buffer_size(), 4096);
|
||||
// Ranges round to the SIZE alignment, independent of the offset one.
|
||||
assert_eq!(layout.record_range(1), 64);
|
||||
assert_eq!(layout.record_range(64), 64);
|
||||
assert_eq!(layout.record_range(65), 128);
|
||||
assert!(layout.fits(1024));
|
||||
assert!(!layout.fits(1025));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_doubles_the_slot_size_until_the_au_fits_and_keeps_alignment() {
|
||||
let layout = RingLayout::new(1024, 4, 128, 128);
|
||||
let grown = layout.grown_for(5000);
|
||||
assert_eq!(grown.slot_size, 8192, "1024 → 2048 → 4096 → 8192");
|
||||
assert_eq!(grown.slots, 4);
|
||||
assert!(grown.fits(5000));
|
||||
assert_eq!(grown.offset_of(3) % 128, 0);
|
||||
|
||||
// An AU already fitting changes nothing.
|
||||
assert_eq!(layout.grown_for(512), layout);
|
||||
|
||||
// The aligned RANGE drives growth, not the raw length: a 1025-byte AU has
|
||||
// a 1152-byte range under a 128 alignment and needs the next size up.
|
||||
assert_eq!(layout.grown_for(1025).slot_size, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_byte_alignments_degenerate_cleanly() {
|
||||
let layout = RingLayout::new(100, 2, 1, 1);
|
||||
assert_eq!(layout.slot_size, 100);
|
||||
assert_eq!(layout.record_range(37), 37);
|
||||
assert!(layout.fits(100));
|
||||
assert!(!layout.fits(101));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_recycle_in_fifo_order_only_after_their_token_completes() {
|
||||
let mut states: SlotStates<u64> = SlotStates::new(2);
|
||||
let s0 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap();
|
||||
states.set_pending(s0, 10);
|
||||
let s1 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap();
|
||||
states.set_pending(s1, 11);
|
||||
assert_ne!(s0, s1);
|
||||
|
||||
// Ring full, oldest (slot 0, token 10) not done: acquire yields None and
|
||||
// names the token to wait on.
|
||||
assert_eq!(states.acquire(|&t| Ok::<_, ()>(t > 10)).unwrap(), None);
|
||||
assert_eq!(states.oldest(), Some(&10));
|
||||
|
||||
// Once done, the OLDEST slot is the one handed back (FIFO, not LIFO).
|
||||
let s2 = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap();
|
||||
assert_eq!(s2, s0);
|
||||
|
||||
// Errors from the completion probe pass through untouched.
|
||||
states.set_pending(s2, 12);
|
||||
assert_eq!(states.acquire(|_| Err("gpu gone")).unwrap_err(), "gpu gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_forgets_every_token_and_restarts_the_cursor() {
|
||||
let mut states: SlotStates<u64> = SlotStates::new(3);
|
||||
for token in 0..3 {
|
||||
let s = states.acquire(|_| Ok::<_, ()>(true)).unwrap().unwrap();
|
||||
states.set_pending(s, token);
|
||||
}
|
||||
assert_eq!(states.in_flight().count(), 3);
|
||||
states.clear();
|
||||
assert_eq!(states.in_flight().count(), 0);
|
||||
assert_eq!(states.acquire(|_| Ok::<_, ()>(true)).unwrap(), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
//! `VkVideoSessionKHR` + `VkVideoSessionParametersKHR` lifecycle.
|
||||
//!
|
||||
//! The session is created from the STREAM's facts (the SPS's coded extent and DPB
|
||||
//! depth), its memory requirements bound exactly like the encoder does, and its
|
||||
//! parameters object holds WP-A's converted `StdVideoH264*ParameterSet`s. Parameter
|
||||
//! versioning follows Vulkan's rules precisely:
|
||||
//!
|
||||
//! - a NEW (sps-id / pps-id) is ADDED via `vkUpdateVideoSessionParametersKHR` with
|
||||
//! `updateSequenceCount` = previous + 1 (the spec's exact-increment rule);
|
||||
//! - an EXISTING id whose content changed cannot be updated in place — the object
|
||||
//! is RECREATED (Vulkan forbids replacing a stored parameter set), as is an
|
||||
//! object whose capacity would overflow;
|
||||
//! - a stream renegotiation that resizes the DPB or the coded extent recreates the
|
||||
//! whole session — `plan_to_vk`'s `CapacityMismatch` is the trigger the decoder
|
||||
//! sees for the DPB half, the extent comparison covers the other.
|
||||
//!
|
||||
//! [`ParamsLedger`] is the pure half of that decision table (unit-tested);
|
||||
//! [`VideoSession`] is the thin Vulkan half.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::native as hh;
|
||||
use cros_codecs::codec::h264::parser::Pps;
|
||||
use cros_codecs::codec::h264::parser::Sps;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::H264ProfileChain;
|
||||
use crate::device::find_memory_type;
|
||||
use crate::device::AllocError;
|
||||
use crate::device::DecodeDevice;
|
||||
use crate::params::pps_to_std;
|
||||
use crate::params::sps_to_std;
|
||||
use crate::params::ParamsError;
|
||||
|
||||
/// Parameter-object capacity. Punktfunk hosts emit one SPS + one PPS per stream;
|
||||
/// the headroom absorbs id churn across renegotiations without recreation, and an
|
||||
/// overflow beyond it recreates rather than fails.
|
||||
pub(crate) const MAX_STD_SPS: usize = 4;
|
||||
pub(crate) const MAX_STD_PPS: usize = 8;
|
||||
|
||||
/// What the ledger decided for one (SPS, PPS) activation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ParamsAction {
|
||||
/// Both sets are already stored with identical content — nothing to do.
|
||||
Current,
|
||||
/// At least one set is new; one update call (seq += 1) adds what is missing.
|
||||
Add { add_sps: bool, add_pps: bool },
|
||||
/// A stored id changed content, or capacity would overflow: recreate the
|
||||
/// parameters object (Vulkan cannot replace or evict a stored set).
|
||||
Recreate,
|
||||
}
|
||||
|
||||
/// Pure bookkeeping for the parameters object: which sets it holds (by id AND
|
||||
/// content — the parser re-parses in-band parameter sets every keyframe, so
|
||||
/// pointer identity means nothing) and the update sequence counter.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ParamsLedger {
|
||||
sps: Vec<(u8, Rc<Sps>)>,
|
||||
pps: Vec<((u8, u8), Rc<Pps>)>,
|
||||
update_seq: u32,
|
||||
}
|
||||
|
||||
impl ParamsLedger {
|
||||
/// Decide the action for activating (`sps`, `pps`). Pure — mutate via
|
||||
/// [`Self::commit`].
|
||||
pub(crate) fn plan(&self, sps: &Rc<Sps>, pps: &Rc<Pps>) -> ParamsAction {
|
||||
let sps_key = sps.seq_parameter_set_id;
|
||||
let pps_key = (pps.seq_parameter_set_id, pps.pic_parameter_set_id);
|
||||
|
||||
let stored_sps = self.sps.iter().find(|(id, _)| *id == sps_key);
|
||||
let stored_pps = self.pps.iter().find(|(id, _)| *id == pps_key);
|
||||
if let Some((_, stored)) = stored_sps {
|
||||
if **stored != **sps {
|
||||
return ParamsAction::Recreate;
|
||||
}
|
||||
}
|
||||
if let Some((_, stored)) = stored_pps {
|
||||
if **stored != **pps {
|
||||
return ParamsAction::Recreate;
|
||||
}
|
||||
}
|
||||
let add_sps = stored_sps.is_none();
|
||||
let add_pps = stored_pps.is_none();
|
||||
if !add_sps && !add_pps {
|
||||
return ParamsAction::Current;
|
||||
}
|
||||
if (add_sps && self.sps.len() >= MAX_STD_SPS) || (add_pps && self.pps.len() >= MAX_STD_PPS)
|
||||
{
|
||||
return ParamsAction::Recreate;
|
||||
}
|
||||
ParamsAction::Add { add_sps, add_pps }
|
||||
}
|
||||
|
||||
/// Apply a decided action. `Add` bumps the sequence count by EXACTLY one (the
|
||||
/// Vulkan update rule — one call may carry both sets); `Recreate` resets the
|
||||
/// ledger to just the current pair with a fresh object's zero counter (any
|
||||
/// other id the stream still references simply re-Adds on next activation).
|
||||
pub(crate) fn commit(&mut self, action: ParamsAction, sps: &Rc<Sps>, pps: &Rc<Pps>) {
|
||||
match action {
|
||||
ParamsAction::Current => {}
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
if add_sps {
|
||||
self.sps.push((sps.seq_parameter_set_id, Rc::clone(sps)));
|
||||
}
|
||||
if add_pps {
|
||||
self.pps.push((
|
||||
(pps.seq_parameter_set_id, pps.pic_parameter_set_id),
|
||||
Rc::clone(pps),
|
||||
));
|
||||
}
|
||||
self.update_seq += 1;
|
||||
}
|
||||
ParamsAction::Recreate => {
|
||||
self.sps.clear();
|
||||
self.pps.clear();
|
||||
self.sps.push((sps.seq_parameter_set_id, Rc::clone(sps)));
|
||||
self.pps.push((
|
||||
(pps.seq_parameter_set_id, pps.pic_parameter_set_id),
|
||||
Rc::clone(pps),
|
||||
));
|
||||
self.update_seq = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence count the NEXT `vkUpdateVideoSessionParametersKHR` must carry.
|
||||
pub(crate) fn next_update_seq(&self) -> u32 {
|
||||
self.update_seq + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's create-time shape; a plan disagreeing with it forces a rebuild.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionConfig {
|
||||
pub max_coded_extent: vk::Extent2D,
|
||||
pub max_dpb_slots: u32,
|
||||
pub max_active_references: u32,
|
||||
/// The Std profile the session was created against (a profile change is a
|
||||
/// renegotiation too).
|
||||
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
}
|
||||
|
||||
/// Session creation/parameter failures the decoder maps into its error type.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SessionError {
|
||||
Vk(vk::Result),
|
||||
Params(ParamsError),
|
||||
/// Session memory binding found no matching memory type (never a fallback).
|
||||
NoMemoryType {
|
||||
type_bits: u32,
|
||||
flags: vk::MemoryPropertyFlags,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<vk::Result> for SessionError {
|
||||
fn from(r: vk::Result) -> Self {
|
||||
SessionError::Vk(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParamsError> for SessionError {
|
||||
fn from(e: ParamsError) -> Self {
|
||||
SessionError::Params(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AllocError> for SessionError {
|
||||
fn from(e: AllocError) -> Self {
|
||||
match e {
|
||||
AllocError::Vk(r) => SessionError::Vk(r),
|
||||
AllocError::NoMemoryType { type_bits, flags } => {
|
||||
SessionError::NoMemoryType { type_bits, flags }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Vulkan half: session + bound memory + parameters object.
|
||||
pub(crate) struct VideoSession {
|
||||
device: ash::Device,
|
||||
video_queue: ash::khr::video_queue::Device,
|
||||
session: vk::VideoSessionKHR,
|
||||
memory: Vec<vk::DeviceMemory>,
|
||||
parameters: vk::VideoSessionParametersKHR,
|
||||
ledger: ParamsLedger,
|
||||
pub(crate) config: SessionConfig,
|
||||
/// The session has never run a coding scope: the first one records a
|
||||
/// `VK_VIDEO_CODING_CONTROL_RESET_BIT_KHR` control before anything else (the
|
||||
/// spec's initialization requirement; same shape as the encoder's first-frame
|
||||
/// RESET install).
|
||||
needs_reset: ResetArm,
|
||||
}
|
||||
|
||||
impl VideoSession {
|
||||
/// Create the session + an EMPTY parameters object (sets arrive via
|
||||
/// [`Self::ensure_parameters`], which the decoder calls before the first
|
||||
/// decode).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `dev` wraps live handles ([`crate::DeviceHandles`] contract).
|
||||
pub(crate) unsafe fn create(
|
||||
dev: &DecodeDevice,
|
||||
caps: &DecodeCaps,
|
||||
config: SessionConfig,
|
||||
) -> Result<Self, SessionError> {
|
||||
let mut chain = H264ProfileChain::new(config.std_profile_idc);
|
||||
let profile = chain.wire();
|
||||
let std_header_version = caps.std_header_version;
|
||||
let session_ci = vk::VideoSessionCreateInfoKHR::default()
|
||||
.queue_family_index(dev.decode_qf())
|
||||
.video_profile(profile)
|
||||
.picture_format(caps.output_format)
|
||||
.max_coded_extent(config.max_coded_extent)
|
||||
.reference_picture_format(caps.dpb_format)
|
||||
.max_dpb_slots(config.max_dpb_slots)
|
||||
.max_active_reference_pictures(config.max_active_references)
|
||||
.std_header_version(&std_header_version);
|
||||
let mut session = vk::VideoSessionKHR::null();
|
||||
// SAFETY: live device; `session_ci` roots locals (chain, header version)
|
||||
// that outlive the call.
|
||||
let r = unsafe {
|
||||
(dev.video_queue().fp().create_video_session_khr)(
|
||||
dev.ash().handle(),
|
||||
&session_ci,
|
||||
std::ptr::null(),
|
||||
&mut session,
|
||||
)
|
||||
};
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err(SessionError::Vk(r));
|
||||
}
|
||||
|
||||
let mut built = Self {
|
||||
device: dev.ash().clone(),
|
||||
video_queue: dev.video_queue().clone(),
|
||||
session,
|
||||
memory: Vec::new(),
|
||||
parameters: vk::VideoSessionParametersKHR::null(),
|
||||
ledger: ParamsLedger::default(),
|
||||
config,
|
||||
needs_reset: ResetArm::armed(),
|
||||
};
|
||||
// SAFETY: fn contract; on error `built` drops and unwinds the session +
|
||||
// whatever memory was bound.
|
||||
unsafe {
|
||||
built.bind_memory(dev)?;
|
||||
built.parameters = built.create_parameters_object(&[], &[])?;
|
||||
}
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Query and bind the session's memory requirements (the encoder's exact shape).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// As [`Self::create`].
|
||||
unsafe fn bind_memory(&mut self, dev: &DecodeDevice) -> Result<(), SessionError> {
|
||||
let get = dev
|
||||
.video_queue()
|
||||
.fp()
|
||||
.get_video_session_memory_requirements_khr;
|
||||
let mut count = 0u32;
|
||||
// SAFETY: live device + the session created above; null pointer is the
|
||||
// count-query form.
|
||||
let _ = unsafe {
|
||||
get(
|
||||
self.device.handle(),
|
||||
self.session,
|
||||
&mut count,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
let mut reqs = vec![vk::VideoSessionMemoryRequirementsKHR::default(); count as usize];
|
||||
// SAFETY: as above with an array of the reported count.
|
||||
let _ = unsafe {
|
||||
get(
|
||||
self.device.handle(),
|
||||
self.session,
|
||||
&mut count,
|
||||
reqs.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
let props = dev.memory_properties();
|
||||
let mut binds = Vec::with_capacity(reqs.len());
|
||||
for rq in &reqs {
|
||||
let mr = rq.memory_requirements;
|
||||
let type_index = find_memory_type(
|
||||
&props,
|
||||
mr.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
)?;
|
||||
let alloc = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(mr.size)
|
||||
.memory_type_index(type_index);
|
||||
// SAFETY: live device; the allocation is parked in `self.memory` so the
|
||||
// Drop unwind owns it from the moment it exists.
|
||||
let memory = unsafe { self.device.allocate_memory(&alloc, None)? };
|
||||
self.memory.push(memory);
|
||||
binds.push(
|
||||
vk::BindVideoSessionMemoryInfoKHR::default()
|
||||
.memory_bind_index(rq.memory_bind_index)
|
||||
.memory(memory)
|
||||
.memory_offset(0)
|
||||
.memory_size(mr.size),
|
||||
);
|
||||
}
|
||||
// SAFETY: session + freshly allocated memory, one bind per requirement.
|
||||
let r = unsafe {
|
||||
(self.video_queue.fp().bind_video_session_memory_khr)(
|
||||
self.device.handle(),
|
||||
self.session,
|
||||
binds.len() as u32,
|
||||
binds.as_ptr(),
|
||||
)
|
||||
};
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err(SessionError::Vk(r));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a parameters object holding exactly `sps`/`pps` (either may be empty).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Live device + live session; the Std slices' backing (the `OwnedStd*`
|
||||
/// wrappers) outlives this call — Vulkan copies all parameter data before
|
||||
/// returning.
|
||||
unsafe fn create_parameters_object(
|
||||
&self,
|
||||
sps: &[hh::StdVideoH264SequenceParameterSet],
|
||||
pps: &[hh::StdVideoH264PictureParameterSet],
|
||||
) -> Result<vk::VideoSessionParametersKHR, SessionError> {
|
||||
let add = vk::VideoDecodeH264SessionParametersAddInfoKHR::default()
|
||||
.std_sp_ss(sps)
|
||||
.std_pp_ss(pps);
|
||||
let mut h264 = vk::VideoDecodeH264SessionParametersCreateInfoKHR::default()
|
||||
.max_std_sps_count(MAX_STD_SPS as u32)
|
||||
.max_std_pps_count(MAX_STD_PPS as u32)
|
||||
.parameters_add_info(&add);
|
||||
let ci = vk::VideoSessionParametersCreateInfoKHR::default()
|
||||
.video_session(self.session)
|
||||
.push_next(&mut h264);
|
||||
let mut parameters = vk::VideoSessionParametersKHR::null();
|
||||
// SAFETY: fn contract; `ci` roots locals outliving the call.
|
||||
let r = unsafe {
|
||||
(self.video_queue.fp().create_video_session_parameters_khr)(
|
||||
self.device.handle(),
|
||||
&ci,
|
||||
std::ptr::null(),
|
||||
&mut parameters,
|
||||
)
|
||||
};
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err(SessionError::Vk(r));
|
||||
}
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
/// The ledger's verdict for activating (`sps`, `pps`), without mutating
|
||||
/// anything — the decoder consults this BEFORE [`Self::ensure_parameters`] so
|
||||
/// a [`ParamsAction::Recreate`] can be preceded by a full in-flight drain
|
||||
/// (the destroy inside the recreate must never race a submitted decode).
|
||||
pub(crate) fn parameters_action(&self, sps: &Rc<Sps>, pps: &Rc<Pps>) -> ParamsAction {
|
||||
self.ledger.plan(sps, pps)
|
||||
}
|
||||
|
||||
/// Make the parameters object hold this AU's activated (SPS, PPS), converting
|
||||
/// through WP-A and Adding/Recreating per the ledger's decision.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Live device; when [`Self::parameters_action`] says `Recreate`, the caller
|
||||
/// has ALREADY drained every in-flight decode (waited each output slot's
|
||||
/// newest submitted timeline value) — the old object is destroyed here, and a
|
||||
/// still-executing decode reading it would be use-after-free at the driver
|
||||
/// level. The decoder enforces exactly that ordering in `decode_inner`;
|
||||
/// `Current`/`Add` touch no object a submitted decode can be reading.
|
||||
pub(crate) unsafe fn ensure_parameters(
|
||||
&mut self,
|
||||
sps: &Rc<Sps>,
|
||||
pps: &Rc<Pps>,
|
||||
) -> Result<(), SessionError> {
|
||||
let action = self.ledger.plan(sps, pps);
|
||||
match action {
|
||||
ParamsAction::Current => Ok(()),
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
let owned_sps = if add_sps {
|
||||
Some(sps_to_std(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std(pps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sps_slice: &[hh::StdVideoH264SequenceParameterSet] = match &owned_sps {
|
||||
Some(o) => std::slice::from_ref(o.std()),
|
||||
None => &[],
|
||||
};
|
||||
let pps_slice: &[hh::StdVideoH264PictureParameterSet] = match &owned_pps {
|
||||
Some(o) => std::slice::from_ref(o.std()),
|
||||
None => &[],
|
||||
};
|
||||
let mut add = vk::VideoDecodeH264SessionParametersAddInfoKHR::default()
|
||||
.std_sp_ss(sps_slice)
|
||||
.std_pp_ss(pps_slice);
|
||||
let update = vk::VideoSessionParametersUpdateInfoKHR::default()
|
||||
.update_sequence_count(self.ledger.next_update_seq())
|
||||
.push_next(&mut add);
|
||||
// SAFETY: live device + parameters object; `update` roots locals
|
||||
// (incl. the OwnedStd backings) outliving the call, and Vulkan
|
||||
// copies parameter data before returning.
|
||||
let r = unsafe {
|
||||
(self.video_queue.fp().update_video_session_parameters_khr)(
|
||||
self.device.handle(),
|
||||
self.parameters,
|
||||
&update,
|
||||
)
|
||||
};
|
||||
if r != vk::Result::SUCCESS {
|
||||
return Err(SessionError::Vk(r));
|
||||
}
|
||||
self.ledger.commit(action, sps, pps);
|
||||
Ok(())
|
||||
}
|
||||
ParamsAction::Recreate => {
|
||||
debug!(
|
||||
sps_id = sps.seq_parameter_set_id,
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_sps = sps_to_std(sps)?;
|
||||
let owned_pps = pps_to_std(pps)?;
|
||||
// SAFETY: fn contract (the OwnedStd backings live across the call).
|
||||
let fresh = unsafe {
|
||||
self.create_parameters_object(
|
||||
std::slice::from_ref(owned_sps.std()),
|
||||
std::slice::from_ref(owned_pps.std()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: the fn-level contract — the caller drained every
|
||||
// in-flight decode before a Recreate reached here (checked via
|
||||
// parameters_action), so no submitted work reads the old object;
|
||||
// it is this session's own handle.
|
||||
unsafe {
|
||||
(self.video_queue.fp().destroy_video_session_parameters_khr)(
|
||||
self.device.handle(),
|
||||
self.parameters,
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
self.parameters = fresh;
|
||||
self.ledger.commit(action, sps, pps);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session(&self) -> vk::VideoSessionKHR {
|
||||
self.session
|
||||
}
|
||||
|
||||
pub(crate) fn parameters(&self) -> vk::VideoSessionParametersKHR {
|
||||
self.parameters
|
||||
}
|
||||
|
||||
/// Whether the next coding scope must record the initialization RESET —
|
||||
/// `true` exactly once per session, PROVIDED the command buffer that recorded
|
||||
/// it actually reaches the queue: a recording/submit failure after this
|
||||
/// returned `true` must call [`Self::re_arm_reset`], or the session would run
|
||||
/// its whole life uninitialized.
|
||||
pub(crate) fn take_needs_reset(&mut self) -> bool {
|
||||
self.needs_reset.take()
|
||||
}
|
||||
|
||||
/// Undo a consumed [`Self::take_needs_reset`] whose RESET never reached the
|
||||
/// queue (end/submit failed after recording it).
|
||||
pub(crate) fn re_arm_reset(&mut self) {
|
||||
self.needs_reset.re_arm();
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-shot session-RESET arm, its own type so the take/re-arm cycle is
|
||||
/// testable without a live session object.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ResetArm(bool);
|
||||
|
||||
impl ResetArm {
|
||||
pub(crate) fn armed() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> bool {
|
||||
std::mem::take(&mut self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn re_arm(&mut self) {
|
||||
self.0 = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VideoSession {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: all handles are this session's own on the (contract-live) device;
|
||||
// the owning decoder drains GPU work before dropping state. The destroy
|
||||
// entry points ignore NULL handles, covering half-built sessions.
|
||||
unsafe {
|
||||
(self.video_queue.fp().destroy_video_session_parameters_khr)(
|
||||
self.device.handle(),
|
||||
self.parameters,
|
||||
std::ptr::null(),
|
||||
);
|
||||
(self.video_queue.fp().destroy_video_session_khr)(
|
||||
self.device.handle(),
|
||||
self.session,
|
||||
std::ptr::null(),
|
||||
);
|
||||
for memory in self.memory.drain(..) {
|
||||
self.device.free_memory(memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cros_codecs::codec::h264::parser::PpsBuilder;
|
||||
use cros_codecs::codec::h264::parser::Profile;
|
||||
use cros_codecs::codec::h264::parser::SpsBuilder;
|
||||
use pf_bitstream::h264::Level;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn authored(sps_id: u8, pps_id: u8, qp: u8) -> (Rc<Sps>, Rc<Pps>) {
|
||||
let sps = SpsBuilder::new()
|
||||
.seq_parameter_set_id(sps_id)
|
||||
.profile_idc(Profile::Main)
|
||||
.level_idc(Level::L4)
|
||||
.frame_mbs_only_flag(true)
|
||||
.direct_8x8_inference_flag(true)
|
||||
.max_num_ref_frames(4)
|
||||
.resolution(64, 64)
|
||||
.build();
|
||||
let pps = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(pps_id)
|
||||
.pic_init_qp(qp)
|
||||
.build();
|
||||
(sps, pps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reactivated_identical_pair_is_current_even_across_reparses() {
|
||||
let (sps_a, pps_a) = authored(0, 0, 26);
|
||||
// The parser re-parses in-band sets each keyframe: same content, NEW Rcs.
|
||||
let (sps_b, pps_b) = authored(0, 0, 26);
|
||||
assert!(!Rc::ptr_eq(&sps_a, &sps_b));
|
||||
|
||||
let mut ledger = ParamsLedger::default();
|
||||
let first = ledger.plan(&sps_a, &pps_a);
|
||||
assert_eq!(
|
||||
first,
|
||||
ParamsAction::Add {
|
||||
add_sps: true,
|
||||
add_pps: true
|
||||
}
|
||||
);
|
||||
ledger.commit(first, &sps_a, &pps_a);
|
||||
assert_eq!(ledger.plan(&sps_b, &pps_b), ParamsAction::Current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_pps_id_over_a_stored_sps_adds_only_the_pps() {
|
||||
let (sps, pps0) = authored(0, 0, 26);
|
||||
let pps1 = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(1)
|
||||
.pic_init_qp(26)
|
||||
.build();
|
||||
|
||||
let mut ledger = ParamsLedger::default();
|
||||
let a = ledger.plan(&sps, &pps0);
|
||||
ledger.commit(a, &sps, &pps0);
|
||||
assert_eq!(
|
||||
ledger.plan(&sps, &pps1),
|
||||
ParamsAction::Add {
|
||||
add_sps: false,
|
||||
add_pps: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_content_under_a_stored_id_recreates_and_resets_the_sequence() {
|
||||
let (sps, pps) = authored(0, 0, 26);
|
||||
let mut ledger = ParamsLedger::default();
|
||||
let a = ledger.plan(&sps, &pps);
|
||||
ledger.commit(a, &sps, &pps);
|
||||
assert_eq!(ledger.next_update_seq(), 2, "one Add happened");
|
||||
|
||||
// Same ids, different content (qp changed): Vulkan cannot replace a
|
||||
// stored set, so this must recreate.
|
||||
let (sps2, pps2) = authored(0, 0, 30);
|
||||
let action = ledger.plan(&sps2, &pps2);
|
||||
assert_eq!(action, ParamsAction::Recreate);
|
||||
ledger.commit(action, &sps2, &pps2);
|
||||
assert_eq!(
|
||||
ledger.next_update_seq(),
|
||||
1,
|
||||
"a fresh object restarts its counter"
|
||||
);
|
||||
// And the pair is now Current under the new content.
|
||||
assert_eq!(ledger.plan(&sps2, &pps2), ParamsAction::Current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_overflow_recreates_with_just_the_current_pair() {
|
||||
let mut ledger = ParamsLedger::default();
|
||||
// Fill the PPS capacity under one SPS.
|
||||
let (sps, first) = authored(0, 0, 26);
|
||||
let a = ledger.plan(&sps, &first);
|
||||
ledger.commit(a, &sps, &first);
|
||||
for pps_id in 1..MAX_STD_PPS as u8 {
|
||||
let pps = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(pps_id)
|
||||
.pic_init_qp(26)
|
||||
.build();
|
||||
let a = ledger.plan(&sps, &pps);
|
||||
assert!(matches!(a, ParamsAction::Add { .. }));
|
||||
ledger.commit(a, &sps, &pps);
|
||||
}
|
||||
assert_eq!(ledger.next_update_seq() - 1, MAX_STD_PPS as u32);
|
||||
|
||||
// One past capacity: recreate; afterwards the evicted first PPS re-Adds.
|
||||
let overflow = PpsBuilder::new(Rc::clone(&sps))
|
||||
.pic_parameter_set_id(MAX_STD_PPS as u8)
|
||||
.pic_init_qp(26)
|
||||
.build();
|
||||
let action = ledger.plan(&sps, &overflow);
|
||||
assert_eq!(action, ParamsAction::Recreate);
|
||||
ledger.commit(action, &sps, &overflow);
|
||||
assert_eq!(
|
||||
ledger.plan(&sps, &first),
|
||||
ParamsAction::Add {
|
||||
add_sps: false,
|
||||
add_pps: true
|
||||
},
|
||||
"sets evicted by a recreate re-add on next activation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reset_arm_fires_once_unless_the_failed_submit_re_arms_it() {
|
||||
let mut arm = ResetArm::armed();
|
||||
assert!(arm.take(), "a fresh session needs its RESET");
|
||||
assert!(
|
||||
!arm.take(),
|
||||
"consumed — the next scope must NOT reset again"
|
||||
);
|
||||
|
||||
// The recorded RESET never reached the queue (end/submit failed): the
|
||||
// re-arm makes the next successful recording carry it instead.
|
||||
arm.re_arm();
|
||||
assert!(arm.take());
|
||||
assert!(!arm.take());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sequence_counts_one_per_add_call_not_per_set() {
|
||||
let (sps, pps) = authored(0, 0, 26);
|
||||
let mut ledger = ParamsLedger::default();
|
||||
assert_eq!(ledger.next_update_seq(), 1);
|
||||
// One call carries BOTH sets: the counter moves by exactly one.
|
||||
let a = ledger.plan(&sps, &pps);
|
||||
assert_eq!(
|
||||
a,
|
||||
ParamsAction::Add {
|
||||
add_sps: true,
|
||||
add_pps: true
|
||||
}
|
||||
);
|
||||
ledger.commit(a, &sps, &pps);
|
||||
assert_eq!(ledger.next_update_seq(), 2);
|
||||
}
|
||||
}
|
||||
+115
-13
@@ -14,8 +14,10 @@ use tracing::trace;
|
||||
/// The H.264 slot ceiling: 16 reference frames plus the picture being decoded.
|
||||
const MAX_SLOTS: usize = 17;
|
||||
|
||||
/// What went wrong with a slot operation. Both variants are caller bugs, not stream
|
||||
/// conditions — pf-bitstream degrades stream damage to warnings long before here.
|
||||
/// What went wrong with a slot operation. `Full`/`AlreadyAssigned` are caller
|
||||
/// bugs, not stream conditions — pf-bitstream degrades stream damage to warnings
|
||||
/// long before here. `AllPinned` is neither: it reports a consumer that has not
|
||||
/// released delivered frames (backpressure, not a ledger fault).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlotError {
|
||||
/// No free slot. The map is sized to `max_dpb_frames + 1`, which the planner's
|
||||
@@ -23,6 +25,11 @@ pub enum SlotError {
|
||||
Full { capacity: usize },
|
||||
/// The id already holds a slot; ids are per-picture and never re-assigned.
|
||||
AlreadyAssigned { id: PicId, slot: u8 },
|
||||
/// Free slots exist but every one is [pinned](SlotMap::pin) by an out-of-DPB
|
||||
/// reader (a delivered frame the consumer has not released). Assigning one
|
||||
/// would let the next decode overwrite an image someone is still reading —
|
||||
/// the caller must surface backpressure instead.
|
||||
AllPinned { free: usize },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SlotError {
|
||||
@@ -37,6 +44,12 @@ impl std::fmt::Display for SlotError {
|
||||
SlotError::AlreadyAssigned { id, slot } => {
|
||||
write!(f, "picture {id} already holds slot {slot}")
|
||||
}
|
||||
SlotError::AllPinned { free } => {
|
||||
write!(
|
||||
f,
|
||||
"every free DPB slot ({free}) is pinned by an unreleased frame"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,11 +62,19 @@ impl std::error::Error for SlotError {}
|
||||
/// Invariants (unit-tested):
|
||||
/// - a [`PicId`] keeps its slot from [`Self::assign`] until [`Self::release`];
|
||||
/// - a slot is reused only after its holder is released;
|
||||
/// - assigning past capacity errors instead of evicting.
|
||||
/// - assigning past capacity errors instead of evicting;
|
||||
/// - a [pinned](Self::pin) slot is never assigned, held or free — pins are the
|
||||
/// WP-B two-phase-release layer: a slot backing a DELIVERED-but-unreleased
|
||||
/// frame stays pinned past its DPB eviction, because in coincide mode the next
|
||||
/// setup assignment would otherwise overwrite the very image the consumer is
|
||||
/// still reading (the full-DPB bump hands `outputs`+`removed` the same id in
|
||||
/// the same plan, and the freed slot is exactly the lowest one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlotMap {
|
||||
/// `slots[i]` holds the id bound to slot `i`, `None` while the slot is free.
|
||||
slots: Vec<Option<PicId>>,
|
||||
/// Out-of-DPB reader counts per slot (refcounted, orthogonal to residency).
|
||||
pinned: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SlotMap {
|
||||
@@ -73,6 +94,7 @@ impl SlotMap {
|
||||
);
|
||||
Self {
|
||||
slots: vec![None; max_dpb_frames + 1],
|
||||
pinned: vec![0; max_dpb_frames + 1],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,21 +119,62 @@ impl SlotMap {
|
||||
.filter_map(|(index, slot)| slot.map(|id| (index as u8, id)))
|
||||
}
|
||||
|
||||
/// Bind `id` to the lowest free slot.
|
||||
/// Bind `id` to the lowest free UNPINNED slot.
|
||||
///
|
||||
/// Free-but-pinned slots are skipped (their images are still read outside the
|
||||
/// DPB); when only such slots remain the error is [`SlotError::AllPinned`],
|
||||
/// distinct from [`SlotError::Full`] because it names a consumer that owes a
|
||||
/// release, not a ledger bug.
|
||||
pub fn assign(&mut self, id: PicId) -> Result<u8, SlotError> {
|
||||
if let Some(slot) = self.slot_of(id) {
|
||||
return Err(SlotError::AlreadyAssigned { id, slot });
|
||||
}
|
||||
let free = self
|
||||
.slots
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.ok_or(SlotError::Full {
|
||||
let mut free_but_pinned = 0usize;
|
||||
let assignable = self.slots.iter().enumerate().position(|(index, slot)| {
|
||||
if slot.is_some() {
|
||||
return false;
|
||||
}
|
||||
if self.pinned[index] > 0 {
|
||||
free_but_pinned += 1;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
match assignable {
|
||||
Some(free) => {
|
||||
self.slots[free] = Some(id);
|
||||
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
||||
Ok(free as u8)
|
||||
}
|
||||
None if free_but_pinned > 0 => Err(SlotError::AllPinned {
|
||||
free: free_but_pinned,
|
||||
}),
|
||||
None => Err(SlotError::Full {
|
||||
capacity: self.slots.len(),
|
||||
})?;
|
||||
self.slots[free] = Some(id);
|
||||
// The envelope-gated capacity (<= 17) keeps every index within u8.
|
||||
Ok(free as u8)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one out-of-DPB reader to `slot` (refcounted): the slot stays
|
||||
/// unassignable — even after its picture leaves the DPB — until the matching
|
||||
/// [`Self::unpin`]. The decoder pins a slot for every live [frame] it backs
|
||||
/// and unpins on `release_frame`/internal drop.
|
||||
///
|
||||
/// [frame]: crate::decoder::DecodedVkFrame
|
||||
pub fn pin(&mut self, slot: u8) {
|
||||
self.pinned[usize::from(slot)] += 1;
|
||||
}
|
||||
|
||||
/// Remove one reader from `slot`. Returns `false` (and changes nothing) when
|
||||
/// the slot carried no pin — a double release, tolerated but never silent at
|
||||
/// the caller.
|
||||
pub fn unpin(&mut self, slot: u8) -> bool {
|
||||
let count = &mut self.pinned[usize::from(slot)];
|
||||
if *count == 0 {
|
||||
return false;
|
||||
}
|
||||
*count -= 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// The slot `id` holds, if any.
|
||||
@@ -246,6 +309,45 @@ mod tests {
|
||||
assert_eq!(slots.slot_of(2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_slot_is_skipped_by_assign_until_every_pin_is_released() {
|
||||
let mut slots = SlotMap::new(1); // capacity 2
|
||||
let s0 = slots.assign(1).unwrap();
|
||||
slots.pin(s0);
|
||||
slots.pin(s0); // refcounted: two readers
|
||||
slots.release(1); // DPB eviction — the pin must keep protecting the slot
|
||||
|
||||
// The pinned slot is skipped; the other free slot is handed out.
|
||||
let s1 = slots.assign(2).unwrap();
|
||||
assert_ne!(s1, s0);
|
||||
|
||||
// Now every free slot is pinned: a DISTINCT error from Full (the ledger
|
||||
// is fine; the consumer owes a release).
|
||||
assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 }));
|
||||
|
||||
// One unpin is not enough (two readers were counted)…
|
||||
assert!(slots.unpin(s0));
|
||||
assert_eq!(slots.assign(3), Err(SlotError::AllPinned { free: 1 }));
|
||||
// …the second frees it, and the slot is assignable again.
|
||||
assert!(slots.unpin(s0));
|
||||
assert_eq!(slots.assign(3), Ok(s0));
|
||||
|
||||
// A pin-less unpin is a reported no-op, not an underflow.
|
||||
assert!(!slots.unpin(s1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_and_all_pinned_stay_distinct_verdicts() {
|
||||
let mut slots = SlotMap::new(1); // capacity 2
|
||||
slots.assign(1).unwrap();
|
||||
slots.assign(2).unwrap();
|
||||
// Genuinely full (all HELD): the missed-removals bug class.
|
||||
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
|
||||
// A pin on a HELD slot changes nothing about that verdict.
|
||||
slots.pin(0);
|
||||
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hundred_synthetic_dpb_updates_churn_without_aliasing_a_slot() {
|
||||
// A sliding window of 4 references over 100 pictures: each id's slot must
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! GPU smoke test — `#[ignore]`d because it needs real Vulkan Video hardware.
|
||||
//!
|
||||
//! Run on a Vulkan-Video box with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p pf-vkdecode -- --ignored
|
||||
//! ```
|
||||
//!
|
||||
//! Environment expectations (the fleet's RADV boxes .21/.25, the NVIDIA .173, or
|
||||
//! any machine like them):
|
||||
//! - a Vulkan 1.3 loader on the library path (`libvulkan.so.1` / `vulkan-1.dll`);
|
||||
//! - a physical device advertising `VK_KHR_video_queue`,
|
||||
//! `VK_KHR_video_decode_queue` and `VK_KHR_video_decode_h264`, with a queue
|
||||
//! family carrying `VIDEO_DECODE_KHR` ops for H.264;
|
||||
//! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core).
|
||||
//!
|
||||
//! What it proves: device wrap → caps query/derivation on REAL caps → session +
|
||||
//! parameters creation → DPB/output/ring pools → 48 AUs of the vendored 25fps
|
||||
//! vector decoded through `vkCmdDecodeVideoKHR` — well past DPB-full, so the
|
||||
//! bump-eviction slot-reuse path runs — with the full frame lifecycle each
|
||||
//! delivery: `wait_status` reading the RESULT_STATUS_ONLY query back as
|
||||
//! COMPLETE, then `release_frame` returning the slot (the two-phase release the
|
||||
//! coincide-mode overwrite fix depends on). What it deliberately does NOT prove
|
||||
//! (WP-D on-glass): pixel correctness vs the ffmpeg rung, presenter
|
||||
//! interop/layout round-trips, soak, and both vendors' DPB arrangements at once
|
||||
//! (each box exercises only its own).
|
||||
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use ash::vk;
|
||||
use ash::vk::Handle;
|
||||
use cros_codecs::codec::h264::parser::Nalu;
|
||||
use cros_codecs::codec::h264::parser::NaluType;
|
||||
use pf_vkdecode::DecodeStatus;
|
||||
use pf_vkdecode::DeviceHandles;
|
||||
use pf_vkdecode::NoopQueueLock;
|
||||
use pf_vkdecode::VkH264Decoder;
|
||||
|
||||
// The same vendored vector the WP-A tests convert, same relative path.
|
||||
const TEST_25FPS: &[u8] = include_bytes!(
|
||||
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
|
||||
);
|
||||
|
||||
/// Test-only AU splitter, mirroring pf-bitstream's (`#[cfg(test)]`-private there).
|
||||
fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> {
|
||||
let mut aus = Vec::new();
|
||||
let mut cursor = Cursor::new(stream);
|
||||
let mut au_start = 0usize;
|
||||
let mut au_has_slice = false;
|
||||
|
||||
while let Ok(nalu) = Nalu::next(&mut cursor) {
|
||||
let nalu_offset = cursor.position() as usize;
|
||||
let start = nalu_offset - nalu.offset;
|
||||
let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr);
|
||||
let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0);
|
||||
|
||||
if au_has_slice && (!is_slice || first_mb_zero) {
|
||||
aus.push(&stream[au_start..start]);
|
||||
au_start = start;
|
||||
au_has_slice = false;
|
||||
}
|
||||
au_has_slice |= is_slice;
|
||||
}
|
||||
aus.push(&stream[au_start..]);
|
||||
aus
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
|
||||
fn decodes_48_aus_with_status_reads_and_frame_releases_past_dpb_full() {
|
||||
// ---- instance ----
|
||||
// SAFETY: loads the system Vulkan loader; no Vulkan objects exist yet.
|
||||
let entry = unsafe { ash::Entry::load() }.expect("a Vulkan loader on this box");
|
||||
let app = vk::ApplicationInfo::default().api_version(vk::make_api_version(0, 1, 3, 0));
|
||||
let instance_ci = vk::InstanceCreateInfo::default().application_info(&app);
|
||||
// SAFETY: valid create info rooted in locals; the instance is destroyed at the
|
||||
// end of this test after everything created from it.
|
||||
let instance =
|
||||
unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance");
|
||||
|
||||
// ---- physical device with an H.264 decode queue family ----
|
||||
// SAFETY: live instance.
|
||||
let physical_devices =
|
||||
unsafe { instance.enumerate_physical_devices() }.expect("enumerate physical devices");
|
||||
let mut picked: Option<(vk::PhysicalDevice, u32, u32)> = None;
|
||||
for pd in physical_devices {
|
||||
// SAFETY: `pd` was just enumerated from this instance.
|
||||
let ext_props =
|
||||
unsafe { instance.enumerate_device_extension_properties(pd) }.unwrap_or_default();
|
||||
let has = |name: &std::ffi::CStr| {
|
||||
ext_props.iter().any(|e| {
|
||||
e.extension_name_as_c_str()
|
||||
.is_ok_and(|extension| extension == name)
|
||||
})
|
||||
};
|
||||
if !(has(ash::khr::video_queue::NAME)
|
||||
&& has(ash::khr::video_decode_queue::NAME)
|
||||
&& has(ash::khr::video_decode_h264::NAME))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// SAFETY: live physical device; the two-call form fills the chained video
|
||||
// properties for each family.
|
||||
let family_count = unsafe { instance.get_physical_device_queue_family_properties2_len(pd) };
|
||||
let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count];
|
||||
let mut families: Vec<vk::QueueFamilyProperties2<'_>> = video_props
|
||||
.iter_mut()
|
||||
.map(|v| vk::QueueFamilyProperties2::default().push_next(v))
|
||||
.collect();
|
||||
// SAFETY: as above, arrays sized to the reported count.
|
||||
unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families) };
|
||||
let flags_per_family: Vec<vk::QueueFlags> = families
|
||||
.iter()
|
||||
.map(|f| f.queue_family_properties.queue_flags)
|
||||
.collect();
|
||||
drop(families); // release the &mut borrows so video_props is readable
|
||||
|
||||
let mut decode_qf = None;
|
||||
let mut graphics_qf = None;
|
||||
for (index, flags) in flags_per_family.iter().enumerate() {
|
||||
if flags.contains(vk::QueueFlags::GRAPHICS) && graphics_qf.is_none() {
|
||||
graphics_qf = Some(index as u32);
|
||||
}
|
||||
if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR)
|
||||
&& video_props[index]
|
||||
.video_codec_operations
|
||||
.contains(vk::VideoCodecOperationFlagsKHR::DECODE_H264)
|
||||
&& decode_qf.is_none()
|
||||
{
|
||||
decode_qf = Some(index as u32);
|
||||
}
|
||||
}
|
||||
if let Some(decode) = decode_qf {
|
||||
picked = Some((pd, decode, graphics_qf.unwrap_or(decode)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (pd, decode_qf, graphics_qf) =
|
||||
picked.expect("a physical device with VK_KHR_video_decode_h264 and a decode queue");
|
||||
|
||||
// ---- logical device: decode (+ graphics) queues, video + sync features ----
|
||||
let priorities = [1.0f32];
|
||||
let mut queue_infos = vec![vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(decode_qf)
|
||||
.queue_priorities(&priorities)];
|
||||
if graphics_qf != decode_qf {
|
||||
queue_infos.push(
|
||||
vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(graphics_qf)
|
||||
.queue_priorities(&priorities),
|
||||
);
|
||||
}
|
||||
let extensions = [
|
||||
ash::khr::video_queue::NAME.as_ptr(),
|
||||
ash::khr::video_decode_queue::NAME.as_ptr(),
|
||||
ash::khr::video_decode_h264::NAME.as_ptr(),
|
||||
];
|
||||
let mut features12 = vk::PhysicalDeviceVulkan12Features::default().timeline_semaphore(true);
|
||||
let mut features13 = vk::PhysicalDeviceVulkan13Features::default().synchronization2(true);
|
||||
let device_ci = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&queue_infos)
|
||||
.enabled_extension_names(&extensions)
|
||||
.push_next(&mut features12)
|
||||
.push_next(&mut features13);
|
||||
// SAFETY: live physical device, valid create info rooted in locals; destroyed
|
||||
// at the end of this test after the decoder drops.
|
||||
let device =
|
||||
unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device");
|
||||
|
||||
// ---- the decoder over borrowed handles, exactly as WP-C will hold it ----
|
||||
let handles = DeviceHandles {
|
||||
get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize,
|
||||
instance: instance.handle().as_raw() as usize,
|
||||
physical_device: pd.as_raw() as usize,
|
||||
device: device.handle().as_raw() as usize,
|
||||
decode_qf,
|
||||
decode_queue_index: 0,
|
||||
graphics_qf,
|
||||
};
|
||||
{
|
||||
// SAFETY: the handles above are live for this whole block (the decoder
|
||||
// drops at its end, before the device/instance destroys below), the device
|
||||
// was created with the decode extensions + timeline/sync2 features, and
|
||||
// the queue fields name the families/queues created above.
|
||||
let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||||
.expect("wrap the device");
|
||||
|
||||
// 48 AUs — far past the vector's DPB depth, so evicted slots recycle
|
||||
// repeatedly — with WP-C's one-in/one-out lifecycle on every delivered
|
||||
// frame: wait its status (the program's whole point: the driver must
|
||||
// say COMPLETE, per op) and release it so its slot may host a later
|
||||
// decode. Output lags decode by a couple of AUs (B-pictures), so the
|
||||
// delivered count is asserted with slack.
|
||||
let aus = split_into_aus(TEST_25FPS);
|
||||
let mut delivered = 0usize;
|
||||
let mut geometry_checked = false;
|
||||
for au in aus.iter().take(48) {
|
||||
let mut next = decoder
|
||||
.decode(au)
|
||||
.expect("decode an AU of the clean vector");
|
||||
while let Some(frame) = next {
|
||||
if !geometry_checked {
|
||||
assert_eq!((frame.coded_width, frame.coded_height), (320, 240));
|
||||
assert_eq!(
|
||||
(frame.crop.width, frame.crop.height),
|
||||
(320, 240),
|
||||
"the vector is uncropped"
|
||||
);
|
||||
assert_ne!(frame.image, vk::Image::null());
|
||||
assert_ne!(frame.semaphore, vk::Semaphore::null());
|
||||
assert!(frame.value > 0);
|
||||
geometry_checked = true;
|
||||
}
|
||||
assert_eq!(
|
||||
decoder.wait_status(&frame),
|
||||
DecodeStatus::Ok,
|
||||
"the driver must report every decode op COMPLETE"
|
||||
);
|
||||
decoder
|
||||
.release_frame(&frame)
|
||||
.expect("a current-generation frame releases");
|
||||
delivered += 1;
|
||||
next = decoder.take_ready();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
delivered >= 40,
|
||||
"expected at least 40 delivered frames from 48 AUs, got {delivered}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- teardown (decoder is gone; its Drop drained the queue) ----
|
||||
// SAFETY: every object created from the device (the decoder's pools/session)
|
||||
// was destroyed when the decoder dropped above.
|
||||
unsafe {
|
||||
device.destroy_device(None);
|
||||
instance.destroy_instance(None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user