feat(pf-vkdecode): the CPU half of native Vulkan decode — StdVideo conversion + slot map

M2 WP-A (design/client-native-decode.md §3.2). AuPlan -> StdVideo
parameter sets (owned pointer backings), per-AU decode info with slice
start-code offsets, and a PicId->slot map that never evicts on its own.
Deliberate rejections over silent claims: FMO, separate colour planes,
DPBs deeper than 16 frames (unbounded VUI ue(v)) all fail closed.

pf-bitstream API grew what the review proved necessary: RefPic carries
the true top/bottom field order counts (a single poc fabricated
BottomFieldOrderCnt whenever the PPS signals pic-order deltas), the
>16-frame DPB envelope gate, and an MMCO5 rebase warning.

Adversarial review round two: 8 findings fixed pre-commit, including
transactional slot mutation (an error path could permanently desync
the map) and count/pointer coherence on type-1 POC offsets.

Gates: fmt clean, clippy -D warnings zero, 45+26+21 tests green on
macOS and the linux/amd64 container.
This commit is contained in:
2026-08-05 15:03:39 +02:00
parent 0a359525a7
commit d24f7fc6ac
8 changed files with 1946 additions and 6 deletions
Generated
+10
View File
@@ -3215,6 +3215,16 @@ dependencies = [
"x11rb",
]
[[package]]
name = "pf-vkdecode"
version = "0.24.0"
dependencies = [
"ash",
"cros-codecs",
"pf-bitstream",
"tracing",
]
[[package]]
name = "pf-win-display"
version = "0.24.0"
+1
View File
@@ -25,6 +25,7 @@ members = [
"crates/pf-capture",
"crates/pf-inject",
"crates/pf-vdisplay",
"crates/pf-vkdecode",
"crates/pyrowave-sys",
"crates/libvpl-sys",
"clients/probe",
+115 -6
View File
@@ -127,7 +127,14 @@ pub struct SlicePlan {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RefPic {
pub id: PicId,
pub poc: i32,
/// The stored picture's 8.2.1 field order counts. Equal for a progressive frame
/// UNLESS the PPS set `bottom_field_pic_order_in_frame_present_flag` and the
/// slice carried a nonzero `delta_pic_order_cnt_bottom` — backend picparams
/// formats want the pair, and collapsing to one value would fabricate the bottom
/// count. After an MMCO 5 these are the picture's REBASED values (8.2.5.4.5),
/// which is what later AUs reference it by — see [`PlanWarning::Mmco5Rebase`].
pub top_field_order_cnt: i32,
pub bottom_field_order_cnt: i32,
pub is_long_term: bool,
/// `frame_num` for short-term references, `LongTermFrameIdx` for long-term ones —
/// the pair DXVA and Vulkan both key reference pictures by.
@@ -160,6 +167,14 @@ pub enum PlanWarning {
/// or a slice belonging to another picture (mis-split AU). The plan covers only
/// the slices before the cut; `offset` is the byte position of the cut in the AU.
TruncatedAu { offset: usize },
/// The AU carried an MMCO 5 (8.2.5.4.5): the DPB was drained and the CURRENT
/// picture's stored frame_num/POC were rebased to zero AFTER its plan was
/// captured. Spec-legal and fully planned — the [`PicturePlan`] holds the
/// pre-rebase 8.2.1 values a decoder submits with, while later AUs reference the
/// picture by its rebased values ([`RefPic`] carries the stored pair). punktfunk
/// hosts never emit MMCO 5, so this warning is the field signal if that
/// assumption ever breaks.
Mmco5Rebase,
}
/// The AU cannot be planned at all.
@@ -510,6 +525,16 @@ impl H264Planner {
"separate colour plane coding (separate_colour_plane_flag == 1)",
));
}
// A.3.1 caps the DPB at 16 frames; the only route past the cap is the VUI's
// max_dec_frame_buffering, an unbounded ue(v) the vendored parser reads
// uncapped. No hardware decoder implements a deeper DPB — a larger value is a
// corrupt (or hostile) VUI, not a feature request — and backends size real
// slot pools from this number, so it is gated here, at SPS activation.
if sps.max_dpb_frames() > 16 {
return Err(PlanError::OutsideEnvelope(
"DPB deeper than 16 frames (max_dec_frame_buffering)",
));
}
Ok(())
}
@@ -1086,7 +1111,8 @@ impl H264Planner {
slots.push(Some((
RefPic {
id,
poc: pic.pic_order_cnt,
top_field_order_cnt: pic.top_field_order_cnt,
bottom_field_order_cnt: pic.bottom_field_order_cnt,
is_long_term,
frame_num_or_lt_idx,
},
@@ -1119,7 +1145,8 @@ impl H264Planner {
if let Some((substitute, frame_num)) = prev_existing.or(first_existing) {
out.push(RefPic {
id: substitute.id,
poc: substitute.poc,
top_field_order_cnt: substitute.top_field_order_cnt,
bottom_field_order_cnt: substitute.bottom_field_order_cnt,
is_long_term: false,
frame_num_or_lt_idx: frame_num,
});
@@ -1479,6 +1506,7 @@ impl H264Planner {
self.prev_pic_info.fill(&pic);
if pic.has_mmco_5 {
warnings.push(PlanWarning::Mmco5Rebase);
// C.4.5.3 "Bumping process"
// The bumping process is invoked in the following cases:
// Clause 3:
@@ -1566,6 +1594,7 @@ mod tests {
use cros_codecs::codec::h264::parser::PpsBuilder;
use cros_codecs::codec::h264::parser::Profile;
use cros_codecs::codec::h264::parser::SpsBuilder;
use cros_codecs::codec::h264::parser::VuiParams;
use cros_codecs::codec::h264::synthesizer::Synthesizer;
use super::*;
@@ -1704,9 +1733,11 @@ mod tests {
let ids1: Vec<PicId> = slice.ref_list1.iter().map(|r| r.id).collect();
assert_ne!(ids0, ids1, "list1 must not be list0's ordering");
// 8.2.4.2.3: list0 leads with the past, list1 with the future.
assert!(slice.ref_list0[0].poc < plan.picture.pic_order_cnt);
assert!(slice.ref_list1[0].poc > plan.picture.pic_order_cnt);
// 8.2.4.2.3: list0 leads with the past, list1 with the future
// (a frame's PicOrderCnt is the min of its field order counts).
let poc = |r: &RefPic| r.top_field_order_cnt.min(r.bottom_field_order_cnt);
assert!(poc(&slice.ref_list0[0]) < plan.picture.pic_order_cnt);
assert!(poc(&slice.ref_list1[0]) > plan.picture.pic_order_cnt);
}
}
@@ -2077,6 +2108,84 @@ mod tests {
));
}
#[test]
fn a_dpb_deeper_than_16_frames_is_rejected_as_outside_the_envelope() {
// The one route past the A.3.1 16-frame cap: the VUI bitstream restriction's
// max_dec_frame_buffering, an unbounded ue(v) that overrides the level-derived
// size in `Sps::max_dpb_frames`. The builder has no VUI-restriction setter, so
// the Sps is constructed directly (its fields are public).
let sps = Sps {
profile_idc: Profile::Main as u8,
level_idc: Level::L4,
frame_mbs_only_flag: true,
direct_8x8_inference_flag: true,
max_num_ref_frames: 4,
vui_parameters_present_flag: true,
vui_parameters: VuiParams {
bitstream_restriction_flag: true,
max_dec_frame_buffering: 17,
..Default::default()
},
..Default::default()
};
let mut au = Vec::new();
Synthesizer::<'_, Sps, _>::synthesize(3, &sps, &mut au, true).unwrap();
let err = H264Planner::new().plan_au(&au).unwrap_err();
assert!(
matches!(err, PlanError::OutsideEnvelope(what) if what.contains("DPB")),
"{err:?}"
);
}
/// MMCO 5 writer: op 5 takes NO argument (Table 7-9), so the generic
/// [`write_p_slice`] — whose supported ops all take exactly one — cannot author
/// it.
fn write_p_slice_mmco5(frame_num: u32, poc_lsb: u32) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = NaluWriter::new(&mut buf, true);
w.write_header(1, NaluType::Slice as u8).unwrap();
w.write_ue(0u32).unwrap(); // first_mb_in_slice
w.write_ue(0u32).unwrap(); // slice_type: P
w.write_ue(0u32).unwrap(); // pic_parameter_set_id
w.write_f(4, frame_num).unwrap(); // frame_num, u(4)
w.write_f(4, poc_lsb).unwrap(); // pic_order_cnt_lsb, u(4)
w.write_f(1, 1u32).unwrap(); // num_ref_idx_active_override_flag
w.write_ue(0u32).unwrap(); // num_ref_idx_l0_active_minus1
w.write_f(1, 0u32).unwrap(); // ref_pic_list_modification_flag_l0
w.write_f(1, 1u32).unwrap(); // adaptive_ref_pic_marking_mode_flag
w.write_ue(5u32).unwrap(); // memory_management_control_operation 5
w.write_ue(0u32).unwrap(); // memory_management_control_operation end
w.write_se(0i32).unwrap(); // slice_qp_delta
w.write_f(1, 1u32).unwrap(); // rbsp stop bit
while !w.aligned() {
w.write_f(1, 0u32).unwrap();
}
}
buf
}
#[test]
fn an_mmco_5_is_planned_with_a_rebase_warning_not_rejected() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let au1 = write_p_slice_mmco5(1, 2);
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
let p1 = planner.plan_au(&au1).unwrap();
assert!(p1.warnings.contains(&PlanWarning::Mmco5Rebase));
// The plan carries the pre-rebase 8.2.1 values a decoder submits with; the
// zeroed frame_num/POC exist only in the STORED picture later AUs reference.
assert_eq!(p1.picture.frame_num, 1);
assert_eq!(p1.picture.pic_order_cnt, 2);
// And the op's C.4.5.3 clause-3 drain ran: the IDR is display-ready.
assert!(p1.dpb.outputs.contains(&p0.dpb.stored.unwrap()));
}
#[test]
fn a_separate_colour_plane_sps_is_rejected_as_outside_the_envelope() {
// SpsBuilder has no separate_colour_plane setter; construct the Sps directly
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "pf-vkdecode"
description = "Native Vulkan Video H.264 decode for the clients (M2): StdVideo parameter-set/picture conversion and DPB slot management over pf-bitstream's AuPlans — the CPU-testable half; session, memory and command recording follow in WP-B (design/client-native-decode.md §3.2)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
ash = "0.38"
# Direct dependency on the vendored parser crate, not just pf-bitstream: the parameter-set
# conversion consumes the parser's `Sps`/`Pps` types wholesale (pf-bitstream re-exports only
# `Level`/`SliceHeader`), and the same path means the one crate instance the workspace already
# builds — no duplicate types.
cros-codecs = { path = "../pf-bitstream/vendor/cros-codecs" }
pf-bitstream = { path = "../pf-bitstream" }
tracing = "0.1"
[lints]
workspace = true
+42
View File
@@ -0,0 +1,42 @@
//! Native Vulkan Video H.264 decode for the clients — M2 of the native-decode program
//! (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:
//!
//! - [`params`]: the vendored parser's `Sps`/`Pps` converted into the
//! `StdVideoH264*ParameterSet` structs session parameters are created from, behind
//! owning wrappers ([`OwnedStdSps`]/[`OwnedStdPps`]) because the Std structs embed
//! raw pointers.
//! - [`slots`]: [`SlotMap`], the hardware DPB slot ledger keyed by
//! [`pf_bitstream::h264::PicId`]. pf-bitstream's DPB decides what lives and dies;
//! this map only translates ids to slot indices and refuses to guess.
//! - [`pic`]: [`plan_to_vk`], one [`pf_bitstream::h264::AuPlan`] converted into the
//! `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.
//!
//! 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:
#![deny(clippy::undocumented_unsafe_blocks)]
pub mod params;
pub mod pic;
pub mod slots;
pub use params::pps_to_std;
pub use params::sps_to_std;
pub use params::OwnedStdPps;
pub use params::OwnedStdSps;
pub use params::ParamsError;
pub use pic::plan_to_vk;
pub use pic::DecodePlanVk;
pub use pic::PlanToVkError;
pub use pic::VkRef;
pub use slots::SlotError;
pub use slots::SlotMap;
+717
View File
@@ -0,0 +1,717 @@
//! Parameter-set conversion: the vendored parser's [`Sps`]/[`Pps`] into the
//! `StdVideoH264*ParameterSet` structs a Vulkan Video session-parameters object is
//! created from (WP-B's `vkCreateVideoSessionParametersKHR`).
//!
//! The Std structs embed raw pointers (`pOffsetForRefFrame`, `pScalingLists`,
//! `pSequenceParameterSetVui`), so conversion returns OWNING wrappers instead of bare
//! structs — see [`OwnedStdSps`] for the aliasing/lifetime contract.
//!
//! VUI is deliberately not converted: a DECODE session consumes no VUI (it shapes
//! display, not reconstruction), so `vui_parameters_present_flag` stays 0 and
//! `pSequenceParameterSetVui` stays null. Colour handling rides
//! [`pf_bitstream::h264::PicturePlan`] into the presenter instead, exactly as the
//! FFmpeg-based path did.
use ash::vk::native as hh;
use cros_codecs::codec::h264::parser::Level;
pub use cros_codecs::codec::h264::parser::Pps;
pub use cros_codecs::codec::h264::parser::Sps;
/// A parameter set that cannot be represented as a StdVideo struct. All of these are
/// outside the punktfunk decode envelope (8-bit 4:2:0 streams from encoders we
/// control), so hitting one is a stream-integrity failure, not a feature gap —
/// reject-with-error rather than submit a half-truth to a driver.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamsError {
/// `profile_idc` has no `StdVideoH264ProfileIdc` code point. Vulkan defines
/// Baseline (66), Main (77), High (100) and High 4:4:4 Predictive (244);
/// Extended/High10/High422 land here.
UnmappableProfileIdc(u8),
/// `chroma_format_idc` past 3 — not legal H.264 to begin with.
InvalidChromaFormatIdc(u8),
/// `pic_order_cnt_type` past 2 — not legal H.264 to begin with.
InvalidPocType(u8),
/// `weighted_bipred_idc` of 3: representable in the two-bit field, invalid per
/// 7.4.2.2, and no `StdVideoH264WeightedBipredIdc` code point exists for it.
InvalidWeightedBipredIdc(u8),
/// FMO (`num_slice_groups_minus1 > 0`): `StdVideoH264PictureParameterSet` has no
/// slice-group fields at all — Vulkan Video cannot express it.
SliceGroups(u32),
}
impl std::fmt::Display for ParamsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParamsError::UnmappableProfileIdc(idc) => {
write!(
f,
"profile_idc {idc} has no StdVideoH264ProfileIdc code point"
)
}
ParamsError::InvalidChromaFormatIdc(idc) => {
write!(f, "invalid chroma_format_idc {idc}")
}
ParamsError::InvalidPocType(t) => write!(f, "invalid pic_order_cnt_type {t}"),
ParamsError::InvalidWeightedBipredIdc(idc) => {
write!(f, "invalid weighted_bipred_idc {idc}")
}
ParamsError::SliceGroups(n) => {
write!(
f,
"FMO ({} slice groups) is not expressible in Vulkan Video",
n + 1
)
}
}
}
}
impl std::error::Error for ParamsError {}
/// The converted SPS plus the heap allocations its embedded pointers target.
///
/// `StdVideoH264SequenceParameterSet` points at data it does not contain: the
/// POC-type-1 offset array (`pOffsetForRefFrame`) and the scaling lists
/// (`pScalingLists`). This wrapper owns that data, and the ownership design is the
/// contract WP-B builds on:
///
/// - The backing is boxed, so the wrapper may be MOVED freely: moving it relocates
/// the `Box` handles (pointer values), never the heap blocks the Std struct's
/// pointers hold the addresses of.
/// - [`Self::std`] hands the struct out by shared reference. The struct is `Copy`; a
/// copy taken out of the wrapper still points INTO the wrapper's backing and must
/// not outlive it. The intended use is passing the reference straight into
/// `vkCreateVideoSessionParametersKHR`, which copies all parameter data before
/// returning — keeping the wrapper alive across that call is the whole obligation.
/// - Nothing exposes mutation of the backing, so for the wrapper's lifetime the
/// pointed-to data is immutable and the `*const` aliasing rules hold trivially.
/// - Deliberately NOT `Clone`: a derived clone would duplicate the pointer VALUES but
/// not the backing, silently tying the clone's validity to the original's lifetime.
/// Re-convert from the `Sps` instead — conversion is cheap and pure.
#[derive(Debug)]
pub struct OwnedStdSps {
std: hh::StdVideoH264SequenceParameterSet,
/// `pOffsetForRefFrame`'s target (POC type 1 only, else `None`/null).
_offset_backing: Option<Box<[i32]>>,
/// `pScalingLists`' target (`seq_scaling_matrix_present_flag` only, else null).
_scaling_backing: Option<Box<hh::StdVideoH264ScalingLists>>,
}
impl OwnedStdSps {
/// The Std struct, valid for as long as `self` lives (see the type-level
/// contract; do not let a `Copy` of it outlive the wrapper).
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
&self.std
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
/// Same ownership contract as [`OwnedStdSps`], with the one pointer.
#[derive(Debug)]
pub struct OwnedStdPps {
std: hh::StdVideoH264PictureParameterSet,
_scaling_backing: Option<Box<hh::StdVideoH264ScalingLists>>,
}
impl OwnedStdPps {
/// The Std struct, valid for as long as `self` lives (see [`OwnedStdSps`]).
pub fn std(&self) -> &hh::StdVideoH264PictureParameterSet {
&self.std
}
}
/// H.264 `level_idc` (value-coded: 10 ⇒ 1.0) to Vulkan's index-coded
/// `StdVideoH264LevelIdc`.
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
// plus constraint_set3_flag — the flag is mapped, so 1.1 is the faithful cap.
Level::L1B => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_1,
Level::L1_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_1,
Level::L1_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_2,
Level::L1_3 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_3,
Level::L2_0 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_0,
Level::L2_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_1,
Level::L2_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_2,
Level::L3 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_0,
Level::L3_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1,
Level::L3_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_2,
Level::L4 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_0,
Level::L4_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_1,
Level::L4_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_2,
Level::L5 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_0,
Level::L5_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_1,
Level::L5_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_2,
Level::L6 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_0,
Level::L6_1 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_1,
Level::L6_2 => hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2,
}
}
/// Pack the parser's scaling-list arrays into the Std layout.
///
/// The vendored parser has already run 7.3.2.1.1.1 plus the Table 7-2 default and
/// fallback rules, so the arrays hold the fully RESOLVED lists. Every resolved list is
/// therefore declared present verbatim (`scaling_list_present_mask` set,
/// `use_default_scaling_matrix_mask` 0) and the driver applies no further inference —
/// simpler and equivalent to re-encoding which lists came from the bitstream.
///
/// `num_8x8` is the count of 8x8 lists the parser actually resolved: 2 for 4:2:0/4:2:2
/// (Y intra/inter), 6 for 4:4:4, and 0 for a PPS without `transform_8x8_mode_flag`
/// (whose 8x8 arrays are untouched zeros and must not be declared present).
fn scaling_lists_to_std(
lists_4x4: &[[u8; 16]; 6],
lists_8x8: &[[u8; 64]; 6],
num_8x8: u16,
) -> hh::StdVideoH264ScalingLists {
// SAFETY: StdVideoH264ScalingLists is a plain-C bindgen struct of two u16 masks
// and two byte arrays; the all-zero bit pattern is a valid value for every field.
let mut std: hh::StdVideoH264ScalingLists = unsafe { std::mem::zeroed() };
std.scaling_list_present_mask = 0x3F | (((1u16 << num_8x8) - 1) << 6);
std.use_default_scaling_matrix_mask = 0;
std.ScalingList4x4 = *lists_4x4;
// All six 8x8 arrays are copied even when only two are declared present; the
// driver ignores entries whose mask bit is clear.
std.ScalingList8x8 = *lists_8x8;
std
}
/// Convert one SPS into the Std struct (owning wrapper), mapping every field the
/// H.264 decode profile consumes. VUI is skipped by design (module docs).
pub fn sps_to_std(sps: &Sps) -> Result<OwnedStdSps, ParamsError> {
// StdVideoH264ProfileIdc code points equal the profile_idc values they name, so
// recognised ones pass through; everything else has no representation.
let profile_idc = match u32::from(sps.profile_idc) {
p @ (66 | 77 | 100 | 244) => p,
_ => return Err(ParamsError::UnmappableProfileIdc(sps.profile_idc)),
};
if sps.chroma_format_idc > 3 {
return Err(ParamsError::InvalidChromaFormatIdc(sps.chroma_format_idc));
}
if sps.pic_order_cnt_type > 2 {
return Err(ParamsError::InvalidPocType(sps.pic_order_cnt_type));
}
// SAFETY: StdVideoH264SequenceParameterSet is a plain-C bindgen struct of
// integers, a bitfield word and const pointers; all-zero is a valid value for
// every field (null for the pointers) and is the "everything absent" baseline the
// field writes below build on. Same idiom as pf-encode's vk_build.rs.
let mut std: hh::StdVideoH264SequenceParameterSet = unsafe { std::mem::zeroed() };
std.flags
.set_constraint_set0_flag(u32::from(sps.constraint_set0_flag));
std.flags
.set_constraint_set1_flag(u32::from(sps.constraint_set1_flag));
std.flags
.set_constraint_set2_flag(u32::from(sps.constraint_set2_flag));
std.flags
.set_constraint_set3_flag(u32::from(sps.constraint_set3_flag));
std.flags
.set_constraint_set4_flag(u32::from(sps.constraint_set4_flag));
std.flags
.set_constraint_set5_flag(u32::from(sps.constraint_set5_flag));
std.flags
.set_direct_8x8_inference_flag(u32::from(sps.direct_8x8_inference_flag));
std.flags
.set_mb_adaptive_frame_field_flag(u32::from(sps.mb_adaptive_frame_field_flag));
// 1 under the punktfunk envelope (pf-bitstream rejects interlaced SPSes), but the
// conversion itself is faithful, not envelope-coupled.
std.flags
.set_frame_mbs_only_flag(u32::from(sps.frame_mbs_only_flag));
std.flags
.set_delta_pic_order_always_zero_flag(u32::from(sps.delta_pic_order_always_zero_flag));
std.flags
.set_separate_colour_plane_flag(u32::from(sps.separate_colour_plane_flag));
std.flags
.set_gaps_in_frame_num_value_allowed_flag(u32::from(
sps.gaps_in_frame_num_value_allowed_flag,
));
std.flags
.set_qpprime_y_zero_transform_bypass_flag(u32::from(
sps.qpprime_y_zero_transform_bypass_flag,
));
std.flags
.set_frame_cropping_flag(u32::from(sps.frame_cropping_flag));
std.flags
.set_seq_scaling_matrix_present_flag(u32::from(sps.seq_scaling_matrix_present_flag));
// vui_parameters_present_flag stays 0: decode sessions consume no VUI (module docs).
std.profile_idc = profile_idc;
std.level_idc = level_to_std(sps.level_idc);
// Chroma format code points equal the chroma_format_idc values (0..3).
std.chroma_format_idc = u32::from(sps.chroma_format_idc);
std.seq_parameter_set_id = sps.seq_parameter_set_id;
std.bit_depth_luma_minus8 = sps.bit_depth_luma_minus8;
std.bit_depth_chroma_minus8 = sps.bit_depth_chroma_minus8;
std.log2_max_frame_num_minus4 = sps.log2_max_frame_num_minus4;
// POC type code points equal the pic_order_cnt_type values (0..2).
std.pic_order_cnt_type = u32::from(sps.pic_order_cnt_type);
std.offset_for_non_ref_pic = sps.offset_for_non_ref_pic;
std.offset_for_top_to_bottom_field = sps.offset_for_top_to_bottom_field;
std.log2_max_pic_order_cnt_lsb_minus4 = sps.log2_max_pic_order_cnt_lsb_minus4;
std.max_num_ref_frames = sps.max_num_ref_frames;
std.pic_width_in_mbs_minus1 = u32::from(sps.pic_width_in_mbs_minus1);
std.pic_height_in_map_units_minus1 = u32::from(sps.pic_height_in_map_units_minus1);
std.frame_crop_left_offset = sps.frame_crop_left_offset;
std.frame_crop_right_offset = sps.frame_crop_right_offset;
std.frame_crop_top_offset = sps.frame_crop_top_offset;
std.frame_crop_bottom_offset = sps.frame_crop_bottom_offset;
// POC type 1's offset array: exactly num_ref_frames_in_pic_order_cnt_cycle
// entries, boxed so the pointer survives moves of the wrapper. The COUNT field
// and the pointer derive from this one condition so they can never disagree — a
// stale cycle count on a type-0/2 SPS must not become a nonzero count over a
// null array (both stay zeroed instead).
let offset_backing =
(sps.pic_order_cnt_type == 1 && sps.num_ref_frames_in_pic_order_cnt_cycle > 0).then(|| {
let cycle = usize::from(sps.num_ref_frames_in_pic_order_cnt_cycle);
Box::<[i32]>::from(&sps.offset_for_ref_frame[..cycle])
});
if let Some(backing) = &offset_backing {
std.num_ref_frames_in_pic_order_cnt_cycle = sps.num_ref_frames_in_pic_order_cnt_cycle;
std.pOffsetForRefFrame = backing.as_ptr();
}
let scaling_backing = sps.seq_scaling_matrix_present_flag.then(|| {
let num_8x8 = if sps.chroma_format_idc == 3 { 6 } else { 2 };
Box::new(scaling_lists_to_std(
&sps.scaling_lists_4x4,
&sps.scaling_lists_8x8,
num_8x8,
))
});
if let Some(backing) = &scaling_backing {
std.pScalingLists = &**backing;
}
Ok(OwnedStdSps {
std,
_offset_backing: offset_backing,
_scaling_backing: scaling_backing,
})
}
/// Convert one PPS into the Std struct (owning wrapper), mapping every field the
/// H.264 decode profile consumes.
///
/// `num_slice_groups_minus1` has no Std field at all; a PPS carrying FMO is rejected
/// rather than converted into a struct that silently claims there is none.
pub fn pps_to_std(pps: &Pps) -> Result<OwnedStdPps, ParamsError> {
if pps.num_slice_groups_minus1 != 0 {
return Err(ParamsError::SliceGroups(pps.num_slice_groups_minus1));
}
if pps.weighted_bipred_idc > 2 {
return Err(ParamsError::InvalidWeightedBipredIdc(
pps.weighted_bipred_idc,
));
}
// SAFETY: StdVideoH264PictureParameterSet is a plain-C bindgen struct of
// integers, a bitfield word and one const pointer; all-zero is a valid value for
// every field (null for the pointer) and is the baseline the writes below fill.
let mut std: hh::StdVideoH264PictureParameterSet = unsafe { std::mem::zeroed() };
std.flags
.set_transform_8x8_mode_flag(u32::from(pps.transform_8x8_mode_flag));
std.flags
.set_redundant_pic_cnt_present_flag(u32::from(pps.redundant_pic_cnt_present_flag));
std.flags
.set_constrained_intra_pred_flag(u32::from(pps.constrained_intra_pred_flag));
std.flags
.set_deblocking_filter_control_present_flag(u32::from(
pps.deblocking_filter_control_present_flag,
));
std.flags
.set_weighted_pred_flag(u32::from(pps.weighted_pred_flag));
std.flags
.set_bottom_field_pic_order_in_frame_present_flag(u32::from(
pps.bottom_field_pic_order_in_frame_present_flag,
));
std.flags
.set_entropy_coding_mode_flag(u32::from(pps.entropy_coding_mode_flag));
std.flags
.set_pic_scaling_matrix_present_flag(u32::from(pps.pic_scaling_matrix_present_flag));
std.seq_parameter_set_id = pps.seq_parameter_set_id;
std.pic_parameter_set_id = pps.pic_parameter_set_id;
std.num_ref_idx_l0_default_active_minus1 = pps.num_ref_idx_l0_default_active_minus1;
std.num_ref_idx_l1_default_active_minus1 = pps.num_ref_idx_l1_default_active_minus1;
// Code points equal the weighted_bipred_idc values (0..2), validated above.
std.weighted_bipred_idc = u32::from(pps.weighted_bipred_idc);
std.pic_init_qp_minus26 = pps.pic_init_qp_minus26;
std.pic_init_qs_minus26 = pps.pic_init_qs_minus26;
std.chroma_qp_index_offset = pps.chroma_qp_index_offset;
std.second_chroma_qp_index_offset = pps.second_chroma_qp_index_offset;
let scaling_backing = pps.pic_scaling_matrix_present_flag.then(|| {
// The parser resolves a PPS's 8x8 lists only under transform_8x8_mode_flag
// (7.3.2.2 reads them only then); without it the arrays are untouched zeros
// and must not be declared present.
let num_8x8 = match (pps.transform_8x8_mode_flag, pps.sps.chroma_format_idc == 3) {
(false, _) => 0,
(true, false) => 2,
(true, true) => 6,
};
Box::new(scaling_lists_to_std(
&pps.scaling_lists_4x4,
&pps.scaling_lists_8x8,
num_8x8,
))
});
if let Some(backing) = &scaling_backing {
std.pScalingLists = &**backing;
}
Ok(OwnedStdPps {
std,
_scaling_backing: scaling_backing,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// An SPS exercising every mapped field with distinct values. The sixteen flags
/// follow the Std bitfield order and strictly ALTERNATE false/true, so a swap of
/// any two adjacent flag mappings fails; `vui_parameters_present_flag` is true at
/// the SOURCE precisely because the conversion must NOT copy it (VUI skip).
fn full_sps() -> Sps {
Sps {
seq_parameter_set_id: 3,
profile_idc: 100,
// Flags, in Std bit order 0..15: F T F T F T F T F T F T F T F (T).
constraint_set1_flag: true,
constraint_set3_flag: true,
constraint_set5_flag: true,
mb_adaptive_frame_field_flag: true,
delta_pic_order_always_zero_flag: true,
gaps_in_frame_num_value_allowed_flag: true,
frame_cropping_flag: true,
vui_parameters_present_flag: true,
// constraint_set0/2/4, direct_8x8_inference, frame_mbs_only,
// separate_colour_plane, qpprime_y_zero_transform_bypass and
// seq_scaling_matrix_present stay false via ..Default.
level_idc: Level::L4_1,
chroma_format_idc: 1,
bit_depth_luma_minus8: 2,
bit_depth_chroma_minus8: 3,
log2_max_frame_num_minus4: 5,
pic_order_cnt_type: 0,
log2_max_pic_order_cnt_lsb_minus4: 6,
offset_for_non_ref_pic: -7,
offset_for_top_to_bottom_field: 3,
max_num_ref_frames: 4,
pic_width_in_mbs_minus1: 119,
pic_height_in_map_units_minus1: 67,
frame_crop_left_offset: 1,
frame_crop_right_offset: 2,
frame_crop_top_offset: 3,
frame_crop_bottom_offset: 4,
..Default::default()
}
}
/// A PPS over `sps` exercising every mapped field with distinct values. The
/// eight flags follow the Std bitfield order and strictly ALTERNATE true/false,
/// so a swap of any two adjacent flag mappings fails.
fn full_pps(sps: Sps) -> Pps {
Pps {
pic_parameter_set_id: 5,
seq_parameter_set_id: 3,
// Flags, in Std bit order 0..7: T F T F T F T F.
transform_8x8_mode_flag: true,
redundant_pic_cnt_present_flag: false,
constrained_intra_pred_flag: true,
deblocking_filter_control_present_flag: false,
weighted_pred_flag: true,
bottom_field_pic_order_in_frame_present_flag: false,
entropy_coding_mode_flag: true,
pic_scaling_matrix_present_flag: false,
num_slice_groups_minus1: 0,
num_ref_idx_l0_default_active_minus1: 2,
num_ref_idx_l1_default_active_minus1: 1,
weighted_bipred_idc: 2,
pic_init_qp_minus26: -3,
pic_init_qs_minus26: 4,
chroma_qp_index_offset: -2,
scaling_lists_4x4: [[0; 16]; 6],
scaling_lists_8x8: [[0; 64]; 6],
second_chroma_qp_index_offset: 6,
sps: std::rc::Rc::new(sps),
}
}
#[test]
fn every_mapped_sps_field_and_flag_round_trips_exactly() {
let sps = full_sps();
let owned = sps_to_std(&sps).unwrap();
let std = owned.std();
// The strictly alternating pattern of the fixture, bit for bit.
assert_eq!(std.flags.constraint_set0_flag(), 0);
assert_eq!(std.flags.constraint_set1_flag(), 1);
assert_eq!(std.flags.constraint_set2_flag(), 0);
assert_eq!(std.flags.constraint_set3_flag(), 1);
assert_eq!(std.flags.constraint_set4_flag(), 0);
assert_eq!(std.flags.constraint_set5_flag(), 1);
assert_eq!(std.flags.direct_8x8_inference_flag(), 0);
assert_eq!(std.flags.mb_adaptive_frame_field_flag(), 1);
assert_eq!(std.flags.frame_mbs_only_flag(), 0);
assert_eq!(std.flags.delta_pic_order_always_zero_flag(), 1);
assert_eq!(std.flags.separate_colour_plane_flag(), 0);
assert_eq!(std.flags.gaps_in_frame_num_value_allowed_flag(), 1);
assert_eq!(std.flags.qpprime_y_zero_transform_bypass_flag(), 0);
assert_eq!(std.flags.frame_cropping_flag(), 1);
assert_eq!(std.flags.seq_scaling_matrix_present_flag(), 0);
assert_eq!(
std.flags.vui_parameters_present_flag(),
0,
"true at the source, skipped by design"
);
assert_eq!(
std.profile_idc,
hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH
);
assert_eq!(
std.level_idc,
hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_1
);
assert_eq!(
std.chroma_format_idc,
hh::StdVideoH264ChromaFormatIdc_STD_VIDEO_H264_CHROMA_FORMAT_IDC_420
);
assert_eq!(std.seq_parameter_set_id, 3);
assert_eq!(std.bit_depth_luma_minus8, 2);
assert_eq!(std.bit_depth_chroma_minus8, 3, "distinct from luma");
assert_eq!(std.log2_max_frame_num_minus4, 5);
assert_eq!(
std.pic_order_cnt_type,
hh::StdVideoH264PocType_STD_VIDEO_H264_POC_TYPE_0
);
assert_eq!(std.offset_for_non_ref_pic, -7);
assert_eq!(std.offset_for_top_to_bottom_field, 3);
assert_eq!(std.log2_max_pic_order_cnt_lsb_minus4, 6);
assert_eq!(std.num_ref_frames_in_pic_order_cnt_cycle, 0);
assert_eq!(std.max_num_ref_frames, 4);
assert_eq!(std.pic_width_in_mbs_minus1, 119);
assert_eq!(std.pic_height_in_map_units_minus1, 67);
assert_eq!(std.frame_crop_left_offset, 1);
assert_eq!(std.frame_crop_right_offset, 2);
assert_eq!(std.frame_crop_top_offset, 3);
assert_eq!(std.frame_crop_bottom_offset, 4);
assert!(
std.pOffsetForRefFrame.is_null(),
"POC type 0 carries no offset array"
);
assert!(std.pScalingLists.is_null());
assert!(std.pSequenceParameterSetVui.is_null());
}
#[test]
fn poc_type_1_offsets_are_owned_and_survive_moving_the_wrapper() {
let mut sps = full_sps();
sps.pic_order_cnt_type = 1;
sps.num_ref_frames_in_pic_order_cnt_cycle = 3;
sps.offset_for_ref_frame[0] = 2;
sps.offset_for_ref_frame[1] = -1;
sps.offset_for_ref_frame[2] = 4;
// Box the wrapper AFTER conversion: a move that relocates the wrapper itself
// must not invalidate the pointer, because the backing is heap-pinned.
let owned = Box::new(sps_to_std(&sps).unwrap());
let std = owned.std();
assert_eq!(
std.pic_order_cnt_type,
hh::StdVideoH264PocType_STD_VIDEO_H264_POC_TYPE_1
);
assert_eq!(std.num_ref_frames_in_pic_order_cnt_cycle, 3);
assert!(!std.pOffsetForRefFrame.is_null());
// SAFETY: pOffsetForRefFrame points into `owned`'s boxed backing of exactly
// num_ref_frames_in_pic_order_cnt_cycle i32s, alive for this whole scope.
let offsets = unsafe { std::slice::from_raw_parts(std.pOffsetForRefFrame, 3) };
assert_eq!(offsets, [2, -1, 4]);
}
#[test]
fn sps_scaling_lists_convert_when_present_and_stay_absent_when_not() {
let mut sps = full_sps();
assert!(sps_to_std(&sps).unwrap().std().pScalingLists.is_null());
sps.seq_scaling_matrix_present_flag = true;
// Each list gets a DISTINCT fill byte: a permutation of lists, or an
// intra/inter reinterleave, cannot pass.
sps.scaling_lists_4x4 = std::array::from_fn(|i| [10 + i as u8; 16]);
sps.scaling_lists_8x8 = std::array::from_fn(|i| [20 + i as u8; 64]);
let owned = sps_to_std(&sps).unwrap();
let std = owned.std();
assert_eq!(std.flags.seq_scaling_matrix_present_flag(), 1);
assert!(!std.pScalingLists.is_null());
// SAFETY: pScalingLists points at `owned`'s boxed StdVideoH264ScalingLists,
// alive for this whole scope.
let lists = unsafe { &*std.pScalingLists };
// 4:2:0: bits 0-5 (the six 4x4 lists) + bits 6-7 (the two resolved 8x8
// lists), none deferred to driver-side defaults (the parser already
// resolved them).
assert_eq!(lists.scaling_list_present_mask, 0xFF);
assert_eq!(lists.use_default_scaling_matrix_mask, 0);
for i in 0..6 {
assert_eq!(lists.ScalingList4x4[i], [10 + i as u8; 16], "4x4 list {i}");
assert_eq!(lists.ScalingList8x8[i], [20 + i as u8; 64], "8x8 list {i}");
}
// 4:4:4 resolves all six 8x8 lists.
sps.chroma_format_idc = 3;
let owned = sps_to_std(&sps).unwrap();
// SAFETY: as above — the pointer targets `owned`'s boxed backing.
let lists = unsafe { &*owned.std().pScalingLists };
assert_eq!(lists.scaling_list_present_mask, 0xFFF);
}
#[test]
fn every_mapped_pps_field_and_flag_round_trips_exactly() {
let pps = full_pps(full_sps());
let owned = pps_to_std(&pps).unwrap();
let std = owned.std();
// The strictly alternating pattern of the fixture, bit for bit.
assert_eq!(std.flags.transform_8x8_mode_flag(), 1);
assert_eq!(std.flags.redundant_pic_cnt_present_flag(), 0);
assert_eq!(std.flags.constrained_intra_pred_flag(), 1);
assert_eq!(std.flags.deblocking_filter_control_present_flag(), 0);
assert_eq!(std.flags.weighted_pred_flag(), 1);
assert_eq!(std.flags.bottom_field_pic_order_in_frame_present_flag(), 0);
assert_eq!(std.flags.entropy_coding_mode_flag(), 1);
assert_eq!(std.flags.pic_scaling_matrix_present_flag(), 0);
assert_eq!(std.seq_parameter_set_id, 3);
assert_eq!(std.pic_parameter_set_id, 5);
assert_eq!(std.num_ref_idx_l0_default_active_minus1, 2);
assert_eq!(std.num_ref_idx_l1_default_active_minus1, 1);
assert_eq!(
std.weighted_bipred_idc,
hh::StdVideoH264WeightedBipredIdc_STD_VIDEO_H264_WEIGHTED_BIPRED_IDC_IMPLICIT
);
assert_eq!(std.pic_init_qp_minus26, -3);
assert_eq!(std.pic_init_qs_minus26, 4);
assert_eq!(std.chroma_qp_index_offset, -2);
assert_eq!(std.second_chroma_qp_index_offset, 6);
assert!(std.pScalingLists.is_null());
}
#[test]
fn pps_scaling_lists_declare_8x8_present_only_under_transform_8x8_mode() {
let mut pps = full_pps(full_sps());
pps.pic_scaling_matrix_present_flag = true;
// Distinct fill bytes per list, as in the SPS test.
pps.scaling_lists_4x4 = std::array::from_fn(|i| [30 + i as u8; 16]);
pps.scaling_lists_8x8 = std::array::from_fn(|i| [40 + i as u8; 64]);
let owned = pps_to_std(&pps).unwrap();
// SAFETY: pScalingLists points at `owned`'s boxed backing, alive here.
let lists = unsafe { &*owned.std().pScalingLists };
assert_eq!(lists.scaling_list_present_mask, 0xFF);
assert_eq!(lists.use_default_scaling_matrix_mask, 0);
for i in 0..6 {
assert_eq!(lists.ScalingList4x4[i], [30 + i as u8; 16], "4x4 list {i}");
assert_eq!(lists.ScalingList8x8[i], [40 + i as u8; 64], "8x8 list {i}");
}
// Without transform_8x8_mode the parser never resolved the 8x8 arrays: only
// the six 4x4 lists may be declared present.
pps.transform_8x8_mode_flag = false;
let owned = pps_to_std(&pps).unwrap();
// SAFETY: as above — the pointer targets `owned`'s boxed backing.
let lists = unsafe { &*owned.std().pScalingLists };
assert_eq!(lists.scaling_list_present_mask, 0x3F);
assert_eq!(lists.use_default_scaling_matrix_mask, 0);
}
#[test]
fn a_stale_cycle_count_on_a_type_0_sps_converts_to_zero_offsets() {
let mut sps = full_sps();
sps.pic_order_cnt_type = 0;
// A stale/corrupt count with no POC-type-1 semantics behind it: the Std
// struct must not claim a cycle over a null array.
sps.num_ref_frames_in_pic_order_cnt_cycle = 5;
let owned = sps_to_std(&sps).unwrap();
assert_eq!(
owned.std().num_ref_frames_in_pic_order_cnt_cycle,
0,
"count and pointer derive from one condition"
);
assert!(owned.std().pOffsetForRefFrame.is_null());
}
#[test]
fn the_25fps_vectors_own_parameter_sets_convert_cleanly() {
use std::io::Cursor;
use cros_codecs::codec::h264::parser::Nalu;
use cros_codecs::codec::h264::parser::NaluType;
use cros_codecs::codec::h264::parser::Parser;
// The same vendored vector pf-bitstream's tests plan, same relative path.
const TEST_25FPS: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
);
let mut cursor = Cursor::new(TEST_25FPS);
let mut parser = Parser::default();
let (mut sps_seen, mut pps_seen) = (false, false);
while let Ok(nalu) = Nalu::next(&mut cursor) {
match nalu.header.type_ {
NaluType::Sps if !sps_seen => {
let sps = parser.parse_sps(&nalu).expect("the vector's SPS parses");
let owned = sps_to_std(sps).expect("the vector's SPS converts");
let std = owned.std();
// The vector's own goldens: 320x240 progressive Main profile.
assert_eq!(
std.profile_idc,
hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN
);
assert_eq!((std.pic_width_in_mbs_minus1 + 1) * 16, 320);
assert_eq!((std.pic_height_in_map_units_minus1 + 1) * 16, 240);
assert_eq!(std.flags.frame_mbs_only_flag(), 1);
assert_eq!(std.flags.vui_parameters_present_flag(), 0);
sps_seen = true;
}
NaluType::Pps if !pps_seen => {
let pps = parser.parse_pps(&nalu).expect("the vector's PPS parses");
let owned = pps_to_std(pps).expect("the vector's PPS converts");
assert_eq!(owned.std().pic_parameter_set_id, 0);
pps_seen = true;
}
_ => {}
}
if sps_seen && pps_seen {
break;
}
}
assert!(sps_seen && pps_seen, "the vector opens with SPS + PPS");
}
#[test]
fn unrepresentable_parameter_sets_are_rejected_not_approximated() {
let mut sps = full_sps();
sps.profile_idc = 110; // High10: no StdVideoH264ProfileIdc code point.
assert_eq!(
sps_to_std(&sps).unwrap_err(),
ParamsError::UnmappableProfileIdc(110)
);
let mut pps = full_pps(full_sps());
pps.num_slice_groups_minus1 = 1;
assert_eq!(pps_to_std(&pps).unwrap_err(), ParamsError::SliceGroups(1));
let mut pps = full_pps(full_sps());
pps.weighted_bipred_idc = 3;
assert_eq!(
pps_to_std(&pps).unwrap_err(),
ParamsError::InvalidWeightedBipredIdc(3)
);
}
}
+760
View File
@@ -0,0 +1,760 @@
//! Per-AU conversion: one [`AuPlan`] into the `StdVideoDecodeH264*` structs, slice
//! offsets and DPB slot bindings a `vkCmdDecodeVideoKHR` call is built from (WP-B).
//!
//! Progressive envelope: pf-bitstream's planner rejects interlaced streams before a
//! plan exists, so every field/bottom FLAG here is written 0. The top/bottom
//! PicOrderCnt pairs are still real pairs — a progressive frame's bottom count
//! differs from its top whenever the PPS carries
//! `bottom_field_pic_order_in_frame_present_flag` — and ride through from
//! pf-bitstream verbatim.
use ash::vk::native as hh;
use pf_bitstream::h264::AuPlan;
use pf_bitstream::h264::PicId;
use pf_bitstream::h264::RefPic;
use tracing::trace;
use crate::slots::SlotError;
use crate::slots::SlotMap;
/// One active reference of the AU: its DPB slot, its Std reference info, and the
/// planner id it resolves (kept so the backend can map the slot to its image).
#[derive(Debug, Clone)]
pub struct VkRef {
pub slot: u8,
pub std: hh::StdVideoDecodeH264ReferenceInfo,
pub id: PicId,
}
/// Everything CPU-derivable of one AU's decode submission. WP-B adds the live
/// objects: bitstream buffer, DPB images, session and command recording.
#[derive(Debug, Clone)]
pub struct DecodePlanVk {
pub std_pic: hh::StdVideoDecodeH264PictureInfo,
/// Byte offset of each slice NALU in the submitted AU, START CODE INCLUDED —
/// Vulkan's `pSliceOffsets` points at start codes within the bitstream buffer,
/// and punktfunk submits the AU exactly as planned.
pub slice_offsets: Vec<u32>,
/// The slot the decoded picture activates (`pSetupReferenceSlot`).
pub setup_slot: u8,
/// Reference info for the setup slot: the picture's own FrameNum/POC, with the
/// long-term flag already set when this very AU marks itself long-term (IDR
/// `long_term_reference_flag` or MMCO 6).
pub setup_ref: hh::StdVideoDecodeH264ReferenceInfo,
/// The planner id of the decoded picture (`AuPlan.dpb.stored`) — the backend
/// keys its image bookkeeping by it.
pub setup_id: PicId,
/// Whether the decoded picture is a reference. When `false` the setup slot
/// exists for the decode itself (plus any remaining DPB residency the planner
/// grants the picture) and must never be bound as a reference for later AUs —
/// it may even have been released already, via this very AU's `removed`, when
/// the picture bypassed the DPB.
pub setup_is_reference: bool,
/// The unique referenced pictures across all slices, in first-appearance order.
pub refs: Vec<VkRef>,
}
/// Conversion failures. Stream damage never lands here — pf-bitstream degrades it to
/// [`pf_bitstream::h264::PlanWarning`]s upstream; these are caller/session bugs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanToVkError {
/// The plan holds no slices; there is nothing to submit.
NoSlices,
/// The plan's `DpbUpdate.stored` is `None`. `plan_au` always stores; only
/// `flush()` produces such updates, and those go to [`SlotMap::apply`] directly.
NoStoredId,
/// A reference list entry's id holds no slot: an earlier plan of this stream
/// never went through this [`SlotMap`].
UnresolvedReference(PicId),
Slot(SlotError),
/// A slice offset exceeds `u32` (Vulkan submits offsets as `u32`).
OffsetOverflow(usize),
/// The map was built for a different DPB depth than this plan's
/// `max_dpb_frames` — an SPS renegotiation resized the DPB. The session (WP-C)
/// must rebuild the video session and its [`SlotMap`]; converting against the
/// stale map would hand out slot indices the session's image pool does not have.
CapacityMismatch {
required: usize,
capacity: usize,
},
}
impl std::fmt::Display for PlanToVkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanToVkError::NoSlices => write!(f, "the plan holds no slices"),
PlanToVkError::NoStoredId => {
write!(
f,
"the plan stores no picture (flush updates go to SlotMap::apply)"
)
}
PlanToVkError::UnresolvedReference(id) => {
write!(f, "referenced picture {id} holds no DPB slot in this map")
}
PlanToVkError::Slot(err) => write!(f, "slot assignment failed: {err}"),
PlanToVkError::OffsetOverflow(offset) => {
write!(f, "slice offset {offset} exceeds u32")
}
PlanToVkError::CapacityMismatch { required, capacity } => {
write!(
f,
"the plan needs {required} slots but the map holds {capacity} — \
an SPS renegotiation resized the DPB; rebuild session and map"
)
}
}
}
}
impl std::error::Error for PlanToVkError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
PlanToVkError::Slot(err) => Some(err),
_ => None,
}
}
}
impl From<SlotError> for PlanToVkError {
fn from(err: SlotError) -> Self {
PlanToVkError::Slot(err)
}
}
/// One [`RefPic`] as Std reference info.
fn ref_info(rp: &RefPic) -> hh::StdVideoDecodeH264ReferenceInfo {
// SAFETY: StdVideoDecodeH264ReferenceInfo is a plain-C bindgen struct of a
// bitfield word and integers; all-zero is a valid value for every field.
let mut std: hh::StdVideoDecodeH264ReferenceInfo = unsafe { std::mem::zeroed() };
std.flags
.set_used_for_long_term_reference(u32::from(rp.is_long_term));
// top/bottom_field_flag stay 0 and is_non_existing stays 0: progressive envelope,
// and pf-bitstream never emits a gap placeholder as an id (it substitutes and
// warns instead).
//
// FrameNum carries exactly the pair-key the Std struct wants: frame_num for
// short-term references, LongTermFrameIdx for long-term ones.
std.FrameNum = rp.frame_num_or_lt_idx;
// The stored picture's real 8.2.1 pair: top != bottom whenever the PPS carried
// bottom_field_pic_order_in_frame_present_flag and the slice a nonzero
// delta_pic_order_cnt_bottom — even for progressive frames.
std.PicOrderCnt = [rp.top_field_order_cnt, rp.bottom_field_order_cnt];
std
}
/// Convert one planned AU, driving `slots` through the AU's slot lifecycle.
///
/// `sps_id` is the id of the active SPS: an [`AuPlan`] names the active PPS (each
/// slice header carries `pic_parameter_set_id`) but not the SPS that PPS references —
/// the caller resolves it through its parameter-set table (in WP-B,
/// [`crate::OwnedStdPps`]'s `seq_parameter_set_id` keyed by the first slice's PPS id).
///
/// Atomicity contract: every fallible step runs before any mutation of `slots`, so
/// an error leaves the map exactly as it was. In order:
/// 1. capacity is validated against the plan's `max_dpb_frames` (read-only);
/// 2. references resolve against the PRE-removal state (read-only) — this AU's own
/// end-of-picture marking (8.2.5) can evict a picture its slices legitimately
/// reference, e.g. the sliding window dropping the oldest short-term reference,
/// so `removed` must not be applied before the lists are mapped;
/// 3. slice offsets are validated (read-only);
/// 4. `removed` is applied — removals were real regardless of this AU's fate — and
/// the setup slot is assigned last (its failures are caller bugs; nothing is ever
/// half-applied). Released slots become assignable to later pictures; keeping the
/// underlying images alive until in-flight decodes complete is WP-B's
/// synchronization, not this map's.
pub fn plan_to_vk(
plan: &AuPlan,
slots: &mut SlotMap,
sps_id: u8,
) -> Result<DecodePlanVk, PlanToVkError> {
let first_slice = plan.slices.first().ok_or(PlanToVkError::NoSlices)?;
let setup_id = plan.dpb.stored.ok_or(PlanToVkError::NoStoredId)?;
// The map must match THIS plan's DPB depth; a mismatch means an SPS
// renegotiation resized the DPB and the session must be rebuilt (WP-C).
let required = plan.picture.max_dpb_frames + 1;
if slots.capacity() != required {
return Err(PlanToVkError::CapacityMismatch {
required,
capacity: slots.capacity(),
});
}
// Unique referenced pictures across every slice's two lists, first appearance
// first. Per-slice list ORDER (ref_idx mapping) lives in the SlicePlans; this Vec
// is the AU-level slot binding set Vulkan wants (each slot listed once).
let mut refs: Vec<VkRef> = Vec::new();
for slice in &plan.slices {
for rp in slice.ref_list0.iter().chain(&slice.ref_list1) {
if refs.iter().any(|existing| existing.id == rp.id) {
continue;
}
let slot = slots
.slot_of(rp.id)
.ok_or(PlanToVkError::UnresolvedReference(rp.id))?;
refs.push(VkRef {
slot,
std: ref_info(rp),
id: rp.id,
});
}
}
let pic = &plan.picture;
// SAFETY: StdVideoDecodeH264PictureInfo is a plain-C bindgen struct of a bitfield
// word and integers; all-zero is a valid value for every field.
let mut std_pic: hh::StdVideoDecodeH264PictureInfo = unsafe { std::mem::zeroed() };
let is_intra = plan
.slices
.iter()
.all(|slice| slice.header.slice_type.is_i() || slice.header.slice_type.is_si());
std_pic.flags.set_is_intra(u32::from(is_intra));
std_pic.flags.set_is_reference(u32::from(pic.is_reference));
std_pic.flags.set_IdrPicFlag(u32::from(pic.is_idr));
// field_pic_flag / bottom_field_flag / complementary_field_pair stay 0 by the
// progressive envelope (module docs).
std_pic.seq_parameter_set_id = sps_id;
std_pic.pic_parameter_set_id = first_slice.header.pic_parameter_set_id;
std_pic.frame_num = pic.frame_num;
std_pic.idr_pic_id = if pic.is_idr {
first_slice.header.idr_pic_id
} else {
0
};
std_pic.PicOrderCnt = [pic.top_field_order_cnt, pic.bottom_field_order_cnt];
// The setup slot's reference info: this picture's own identity. When the AU marks
// ITSELF long-term — an IDR's long_term_reference_flag, or an MMCO 6 assigning an
// index (8.2.5) — the slot activates as a long-term reference keyed by
// LongTermFrameIdx, mirroring how ref_info keys long-term entries.
//
// ACTIVATION-vs-REFERENCE asymmetry under MMCO 5 (spec-legal, deliberately NOT
// rejected): these are the picture's 8.2.1 values as decoded, but an MMCO 5 in
// this same AU rebases the STORED frame_num/POC to zero after decoding
// (8.2.5.4.5), so later AUs reference this slot by the rebased pair (RefPic
// carries the stored values). punktfunk hosts never emit MMCO 5;
// pf_bitstream::h264::PlanWarning::Mmco5Rebase flags any occurrence so field
// logs tell us if that assumption ever breaks.
// SAFETY: as above — all-zero is a valid StdVideoDecodeH264ReferenceInfo.
let mut setup_ref: hh::StdVideoDecodeH264ReferenceInfo = unsafe { std::mem::zeroed() };
setup_ref.PicOrderCnt = [pic.top_field_order_cnt, pic.bottom_field_order_cnt];
let marking = &first_slice.header.dec_ref_pic_marking;
let self_lt_idx = if pic.is_idr {
marking.long_term_reference_flag.then_some(0u32)
} else if marking.adaptive_ref_pic_marking_mode_flag {
marking
.inner
.iter()
.find(|op| op.memory_management_control_operation == 6)
.map(|op| op.long_term_frame_idx)
} else {
None
};
match self_lt_idx {
Some(idx) => {
setup_ref.flags.set_used_for_long_term_reference(1);
// Same saturation as pf-bitstream's frame_num_or_lt_idx: the spec bounds
// the ue(v)-coded index at 15, the parser does not.
setup_ref.FrameNum = u16::try_from(idx).unwrap_or(u16::MAX);
}
None => setup_ref.FrameNum = pic.frame_num,
}
let mut slice_offsets = Vec::with_capacity(plan.slices.len());
for slice in &plan.slices {
// SlicePlan.data starts at the slice NALU's start code — exactly the offset
// Vulkan wants (struct docs).
slice_offsets.push(
u32::try_from(slice.data.start)
.map_err(|_| PlanToVkError::OffsetOverflow(slice.data.start))?,
);
}
// Mutations LAST, after every fallible step above (fn docs). Removals first —
// they were real regardless of this AU's fate — then the setup assignment.
//
// The AU's own picture can itself appear in `removed`: a non-reference picture
// with no free frame buffer bypasses the DPB and is stored-and-evicted within
// one plan. Its slot must still exist for the decode itself, so it is assigned
// here and released right after — see `DecodePlanVk::setup_is_reference`.
let setup_evicted = plan.dpb.removed.contains(&setup_id);
for &id in &plan.dpb.removed {
if id == setup_id {
continue;
}
if !slots.release(id) {
// Tolerated but never silent: reachable only when the caller skipped
// feeding an AU's plan through this map.
trace!(id, "DpbUpdate removed an id this SlotMap never assigned");
}
}
let setup_slot = slots.assign(setup_id)?;
if setup_evicted {
slots.release(setup_id);
}
Ok(DecodePlanVk {
std_pic,
slice_offsets,
setup_slot,
setup_ref,
setup_id,
setup_is_reference: pic.is_reference,
refs,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::io::Cursor;
use std::rc::Rc;
use cros_codecs::codec::h264::nalu_writer::NaluWriter;
use cros_codecs::codec::h264::parser::Nalu;
use cros_codecs::codec::h264::parser::NaluType;
use cros_codecs::codec::h264::parser::Pps;
use cros_codecs::codec::h264::parser::PpsBuilder;
use cros_codecs::codec::h264::parser::Profile;
use cros_codecs::codec::h264::parser::Sps;
use cros_codecs::codec::h264::parser::SpsBuilder;
use cros_codecs::codec::h264::synthesizer::Synthesizer;
use pf_bitstream::h264::H264Planner;
use pf_bitstream::h264::Level;
use super::*;
// The same vendored vector pf-bitstream's tests plan (its goldens: 250 AUs, 500
// slices), included from the same path rather than copied.
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 `split_into_aus` helper (which
/// is `#[cfg(test)]`-private there): a new AU starts at a non-slice NALU following
/// slices, or at a slice with `first_mb_in_slice == 0` (whose ue(v) encoding makes
/// the first RBSP bit 1) when the current AU already has slices.
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]
fn the_full_25fps_vector_converts_with_stable_slots_and_start_code_offsets() {
let aus = split_into_aus(TEST_25FPS);
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
// PicId -> the slot it was assigned; entries leave only on `removed`.
let mut held: BTreeMap<PicId, u8> = BTreeMap::new();
let mut converted = 0usize;
for au in &aus {
let plan = planner.plan_au(au).expect("the clean vector plans");
let slots = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
let vk = plan_to_vk(&plan, slots, 0).expect("the clean vector converts");
converted += 1;
// Slot stability: every reference resolves to the slot its picture was
// assigned when IT was decoded, and no ref shares the setup slot.
for r in &vk.refs {
assert_eq!(
held.get(&r.id),
Some(&r.slot),
"a referenced picture's slot changed while it was referenced"
);
assert_ne!(r.slot, vk.setup_slot, "a reference aliases the setup slot");
}
// Slice offsets: one per slice, each at a start-code boundary of the AU,
// and exactly where the plan said the slice begins.
assert_eq!(vk.slice_offsets.len(), plan.slices.len());
for (offset, slice) in vk.slice_offsets.iter().zip(&plan.slices) {
let offset = *offset as usize;
assert_eq!(offset, slice.data.start);
let at = &au[offset..];
assert!(
at.starts_with(&[0, 0, 1]) || at.starts_with(&[0, 0, 0, 1]),
"slice offset {offset} does not sit on a start code"
);
}
assert_eq!(vk.std_pic.frame_num, plan.picture.frame_num);
assert_eq!(
u32::from(plan.picture.is_idr),
vk.std_pic.flags.IdrPicFlag()
);
assert_eq!(
vk.setup_ref.PicOrderCnt[0],
plan.picture.top_field_order_cnt
);
// Mirror the map's bookkeeping: record the new picture, drop the removed.
let stored = plan.dpb.stored.unwrap();
assert_eq!(vk.setup_id, stored);
assert_eq!(vk.setup_is_reference, plan.picture.is_reference);
held.insert(stored, vk.setup_slot);
for id in &plan.dpb.removed {
held.remove(id);
}
// held() must mirror the plan-driven bookkeeping exactly, every AU.
let ledger: BTreeMap<PicId, u8> = slots.held().map(|(slot, id)| (id, slot)).collect();
assert_eq!(ledger, held);
}
assert_eq!(converted, 250, "the vector's own golden");
// Teardown: the flush update releases every remaining slot.
let mut slots = slots.unwrap();
slots.apply(&planner.flush());
assert_eq!(slots.active(), 0);
}
/// Byte-level authoring, mirroring pf-bitstream's MMCO/LTR test (its helpers are
/// `#[cfg(test)]`-private): parameter sets via the vendored builders +
/// synthesizer, slice headers hand-written with the vendored `NaluWriter`. The
/// planner only reads headers, so no slice data follows the rbsp stop bit.
fn authored_sps_pps() -> (Rc<Sps>, Rc<Pps>) {
let sps = SpsBuilder::new()
.seq_parameter_set_id(0)
.profile_idc(Profile::Main)
.level_idc(Level::L4)
.frame_mbs_only_flag(true)
.direct_8x8_inference_flag(true)
.max_num_ref_frames(4)
.log2_max_frame_num_minus4(0)
.pic_order_cnt_type(0)
.log2_max_pic_order_cnt_lsb_minus4(0)
.resolution(64, 64)
.build();
let pps = PpsBuilder::new(Rc::clone(&sps))
.pic_parameter_set_id(0)
.pic_init_qp(26)
.build();
(sps, pps)
}
/// `bottom_delta` writes `delta_pic_order_cnt_bottom` — only legal when the PPS
/// the slice references sets `bottom_field_pic_order_in_frame_present_flag`
/// (the parser reads the field iff the flag is set, so writer and PPS must
/// agree).
fn write_idr_slice(bottom_delta: Option<i32>) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = NaluWriter::new(&mut buf, true);
w.write_header(3, NaluType::SliceIdr as u8).unwrap();
w.write_ue(0u32).unwrap(); // first_mb_in_slice
w.write_ue(2u32).unwrap(); // slice_type: I
w.write_ue(0u32).unwrap(); // pic_parameter_set_id
w.write_f(4, 0u32).unwrap(); // frame_num, u(4): log2_max_frame_num_minus4 = 0
w.write_ue(7u32).unwrap(); // idr_pic_id
w.write_f(4, 0u32).unwrap(); // pic_order_cnt_lsb, u(4)
if let Some(delta) = bottom_delta {
w.write_se(delta).unwrap(); // delta_pic_order_cnt_bottom
}
w.write_f(1, 0u32).unwrap(); // no_output_of_prior_pics_flag
w.write_f(1, 0u32).unwrap(); // long_term_reference_flag
w.write_se(0i32).unwrap(); // slice_qp_delta
w.write_f(1, 1u32).unwrap(); // rbsp stop bit
while !w.aligned() {
w.write_f(1, 0u32).unwrap();
}
}
buf
}
/// One P slice NALU. `mmco_ops` = `None` for sliding-window marking, `Some(ops)`
/// for adaptive marking with `(operation, single-argument)` pairs (ops 2/4/6 all
/// take exactly one) — the writer appends the terminating op 0. `bottom_delta`
/// as in [`write_idr_slice`].
fn write_p_slice(
frame_num: u32,
poc_lsb: u32,
bottom_delta: Option<i32>,
num_ref_idx_l0_active: u32,
mmco_ops: Option<&[(u32, u32)]>,
) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = NaluWriter::new(&mut buf, true);
w.write_header(1, NaluType::Slice as u8).unwrap();
w.write_ue(0u32).unwrap(); // first_mb_in_slice
w.write_ue(0u32).unwrap(); // slice_type: P
w.write_ue(0u32).unwrap(); // pic_parameter_set_id
w.write_f(4, frame_num).unwrap(); // frame_num, u(4)
w.write_f(4, poc_lsb).unwrap(); // pic_order_cnt_lsb, u(4)
if let Some(delta) = bottom_delta {
w.write_se(delta).unwrap(); // delta_pic_order_cnt_bottom
}
w.write_f(1, 1u32).unwrap(); // num_ref_idx_active_override_flag
w.write_ue(num_ref_idx_l0_active - 1).unwrap();
w.write_f(1, 0u32).unwrap(); // ref_pic_list_modification_flag_l0
match mmco_ops {
None => w.write_f(1, 0u32).map(|_| ()).unwrap(),
Some(ops) => {
w.write_f(1, 1u32).unwrap(); // adaptive_ref_pic_marking_mode_flag
for (op, arg) in ops {
w.write_ue(*op).unwrap();
w.write_ue(*arg).unwrap();
}
w.write_ue(0u32).unwrap(); // memory_management_control_operation end
}
}
w.write_se(0i32).unwrap(); // slice_qp_delta
w.write_f(1, 1u32).unwrap(); // rbsp stop bit
while !w.aligned() {
w.write_f(1, 0u32).unwrap();
}
}
buf
}
#[test]
fn an_mmco_self_marking_sets_the_setup_lt_flag_and_later_refs_carry_it() {
let (sps, pps) = authored_sps_pps();
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));
// AU1 marks itself long-term: MMCO 4 admits long-term index 0, MMCO 6
// assigns it to the current picture.
let au1 = write_p_slice(1, 2, None, 1, Some(&[(4, 1), (6, 0)]));
let au2 = write_p_slice(2, 4, None, 2, None);
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
let idr_id = p0.dpb.stored.unwrap();
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
assert_eq!(vk0.std_pic.flags.IdrPicFlag(), 1);
assert_eq!(vk0.std_pic.flags.is_intra(), 1);
assert_eq!(vk0.std_pic.idr_pic_id, 7, "from the authored slice header");
assert_eq!(vk0.std_pic.PicOrderCnt, [0, 0]);
assert_eq!(vk0.setup_ref.flags.used_for_long_term_reference(), 0);
assert_eq!(vk0.setup_id, idr_id);
assert!(vk0.setup_is_reference, "an IDR is a reference");
assert!(vk0.refs.is_empty());
// AU1: the setup slot activates LONG-TERM (its own MMCO 6), keyed by
// LongTermFrameIdx 0, not by frame_num 1.
let p1 = planner.plan_au(&au1).unwrap();
let lt_id = p1.dpb.stored.unwrap();
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
assert_eq!(vk1.std_pic.flags.is_intra(), 0);
assert_eq!(vk1.std_pic.PicOrderCnt, [2, 2]);
assert_eq!(vk1.setup_ref.flags.used_for_long_term_reference(), 1);
assert_eq!(vk1.setup_ref.FrameNum, 0, "LongTermFrameIdx, not frame_num");
assert_eq!(vk1.refs.len(), 1);
assert_eq!(vk1.refs[0].id, idr_id);
assert_eq!(vk1.refs[0].std.flags.used_for_long_term_reference(), 0);
// AU2 references both: the IDR short-term (keyed by frame_num) and AU1
// long-term (keyed by LongTermFrameIdx), each on its stable slot.
let p2 = planner.plan_au(&au2).unwrap();
let vk2 = plan_to_vk(&p2, &mut slots, 0).unwrap();
let by_id: BTreeMap<PicId, &VkRef> = vk2.refs.iter().map(|r| (r.id, r)).collect();
let idr_ref = by_id[&idr_id];
assert_eq!(idr_ref.std.flags.used_for_long_term_reference(), 0);
assert_eq!(idr_ref.std.FrameNum, 0);
assert_eq!(idr_ref.slot, vk0.setup_slot);
let lt_ref = by_id[&lt_id];
assert_eq!(lt_ref.std.flags.used_for_long_term_reference(), 1);
assert_eq!(lt_ref.std.FrameNum, 0, "LongTermFrameIdx, not frame_num");
assert_eq!(
lt_ref.std.PicOrderCnt,
[2, 2],
"the stored top/bottom pair (equal here: no delta_pic_order_cnt_bottom)"
);
assert_eq!(lt_ref.slot, vk1.setup_slot);
assert_ne!(vk2.setup_slot, idr_ref.slot);
assert_ne!(vk2.setup_slot, lt_ref.slot);
}
#[test]
fn a_failed_conversion_leaves_the_slot_map_untouched_and_the_session_recovers() {
let (sps, pps) = authored_sps_pps();
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));
let au1 = write_p_slice(1, 2, None, 1, None);
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
let idr_id = p0.dpb.stored.unwrap();
let p1 = planner.plan_au(&au1).unwrap();
// A right-sized map that never saw AU0, holding one unrelated slot: the
// reference must fail loudly, not resolve to a fabricated slot.
let mut fresh = SlotMap::new(p1.picture.max_dpb_frames);
fresh.assign(999).unwrap();
assert_eq!(
plan_to_vk(&p1, &mut fresh, 0).unwrap_err(),
PlanToVkError::UnresolvedReference(idr_id)
);
// Atomicity: the failed conversion mutated nothing.
assert_eq!(fresh.active(), 1);
assert_eq!(fresh.held().collect::<Vec<_>>(), vec![(0, 999)]);
// And the session recovers: the next valid AU (an IDR restart) still
// converts on the same map. Its `removed` names ids this map never assigned
// (planned before the map existed) — tolerated by design.
let p2 = planner.plan_au(&write_idr_slice(None)).unwrap();
let vk2 = plan_to_vk(&p2, &mut fresh, 0).unwrap();
assert_eq!(vk2.setup_slot, 1, "the lowest free slot after the held one");
assert_eq!(fresh.active(), 2);
}
#[test]
fn an_sps_switch_that_resizes_the_dpb_is_a_capacity_mismatch_not_a_guess() {
// Two SPSes whose only planning-relevant difference is DPB depth: Level 1 at
// 320x240 gives MaxDpbMbs 396 / 300 = 1 frame; Level 4 gives 16.
let authored = |level: Level, max_refs: u8| -> (Rc<Sps>, Rc<Pps>) {
let sps = SpsBuilder::new()
.seq_parameter_set_id(0)
.profile_idc(Profile::Main)
.level_idc(level)
.frame_mbs_only_flag(true)
.direct_8x8_inference_flag(true)
.max_num_ref_frames(max_refs)
.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();
(sps, pps)
};
let (sps_a, pps_a) = authored(Level::L1, 1);
let (sps_b, pps_b) = authored(Level::L4, 4);
let mut au0 = Vec::new();
Synthesizer::<'_, Sps, _>::synthesize(3, &sps_a, &mut au0, true).unwrap();
Synthesizer::<'_, Pps, _>::synthesize(3, &pps_a, &mut au0, true).unwrap();
au0.extend(write_idr_slice(None));
let mut au1 = Vec::new();
Synthesizer::<'_, Sps, _>::synthesize(3, &sps_b, &mut au1, true).unwrap();
Synthesizer::<'_, Pps, _>::synthesize(3, &pps_b, &mut au1, true).unwrap();
au1.extend(write_idr_slice(None));
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
assert_eq!(p0.picture.max_dpb_frames, 1);
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
plan_to_vk(&p0, &mut slots, 0).unwrap();
// The renegotiated stream needs a deeper DPB than this map was built for:
// refuse, so the session (WP-C) rebuilds session + map instead of handing
// out slots the image pool does not have.
let p1 = planner.plan_au(&au1).unwrap();
assert_eq!(p1.picture.max_dpb_frames, 16);
assert_eq!(
plan_to_vk(&p1, &mut slots, 0).unwrap_err(),
PlanToVkError::CapacityMismatch {
required: 17,
capacity: 2
}
);
}
#[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
// frames then carry delta_pic_order_cnt_bottom and bottom != top. The
// builder has no setter for the flag, so the Pps is constructed directly
// (its fields are public) and synthesized from there.
let sps = SpsBuilder::new()
.seq_parameter_set_id(0)
.profile_idc(Profile::Main)
.level_idc(Level::L4)
.frame_mbs_only_flag(true)
.direct_8x8_inference_flag(true)
.max_num_ref_frames(4)
.log2_max_frame_num_minus4(0)
.pic_order_cnt_type(0)
.log2_max_pic_order_cnt_lsb_minus4(0)
.resolution(64, 64)
.build();
let pps = Pps {
pic_parameter_set_id: 0,
seq_parameter_set_id: 0,
entropy_coding_mode_flag: false,
bottom_field_pic_order_in_frame_present_flag: true,
num_slice_groups_minus1: 0,
num_ref_idx_l0_default_active_minus1: 0,
num_ref_idx_l1_default_active_minus1: 0,
weighted_pred_flag: false,
weighted_bipred_idc: 0,
pic_init_qp_minus26: 0,
pic_init_qs_minus26: 0,
chroma_qp_index_offset: 0,
deblocking_filter_control_present_flag: false,
constrained_intra_pred_flag: false,
redundant_pic_cnt_present_flag: false,
transform_8x8_mode_flag: false,
pic_scaling_matrix_present_flag: false,
scaling_lists_4x4: [[0; 16]; 6],
scaling_lists_8x8: [[0; 64]; 6],
second_chroma_qp_index_offset: 0,
sps: Rc::clone(&sps),
};
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(Some(2))); // top 0, bottom 0 + 2
let au1 = write_p_slice(1, 4, Some(1), 1, None); // top 4, bottom 4 + 1
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
assert_eq!(
(
p0.picture.top_field_order_cnt,
p0.picture.bottom_field_order_cnt
),
(0, 2)
);
let mut slots = SlotMap::new(p0.picture.max_dpb_frames);
let vk0 = plan_to_vk(&p0, &mut slots, 0).unwrap();
// BOTH orders, both structs: a top/bottom swap or a collapse to one value
// must fail here.
assert_eq!(vk0.std_pic.PicOrderCnt, [0, 2]);
assert_eq!(vk0.setup_ref.PicOrderCnt, [0, 2]);
let p1 = planner.plan_au(&au1).unwrap();
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
assert_eq!(vk1.std_pic.PicOrderCnt, [4, 5]);
assert_eq!(vk1.setup_ref.PicOrderCnt, [4, 5]);
// The reference carries the STORED pair of the IDR — through RefPic, not a
// fabricated bottom.
assert_eq!(vk1.refs.len(), 1);
assert_eq!(vk1.refs[0].id, p0.dpb.stored.unwrap());
assert_eq!(vk1.refs[0].std.PicOrderCnt, [0, 2]);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! The hardware DPB slot ledger: [`pf_bitstream::h264::PicId`]s mapped to the slot
//! indices a Vulkan Video session binds DPB images by.
//!
//! Division of labour: pf-bitstream's DPB runs the 8.2.5/C.4.5.3 processes and
//! DECIDES which pictures live and die — this map only translates its verdicts into
//! stable slot indices. It therefore never evicts on its own: running out of slots is
//! an error ([`SlotError::Full`]), because it can only mean removals were missed, and
//! a silent eviction would hide that bug behind corrupted output.
use pf_bitstream::h264::DpbUpdate;
use pf_bitstream::h264::PicId;
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.
#[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
/// DPB never exceeds; overflow means this map missed `removed` entries.
Full { capacity: usize },
/// The id already holds a slot; ids are per-picture and never re-assigned.
AlreadyAssigned { id: PicId, slot: u8 },
}
impl std::fmt::Display for SlotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SlotError::Full { capacity } => {
write!(
f,
"all {capacity} DPB slots are held — removals were missed"
)
}
SlotError::AlreadyAssigned { id, slot } => {
write!(f, "picture {id} already holds slot {slot}")
}
}
}
}
impl std::error::Error for SlotError {}
/// The slot ledger. One per decode session; feed it every [`DpbUpdate`] in decode
/// order (via [`Self::apply`] or `plan_to_vk`, which applies internally).
///
/// 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.
#[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>>,
}
impl SlotMap {
/// Sized from [`pf_bitstream::h264::PicturePlan::max_dpb_frames`] plus one for
/// the picture being decoded (its setup slot coexists with a full reference
/// window).
///
/// pf-bitstream's envelope gate rejects any SPS asking for a DPB deeper than the
/// spec's 16 frames before a plan exists, so a larger request here is a caller
/// bug — debug-asserted, never silently clamped (a clamp would turn the bug into
/// silent evictions later).
pub fn new(max_dpb_frames: usize) -> Self {
debug_assert!(
max_dpb_frames < MAX_SLOTS,
"a {max_dpb_frames}-frame DPB exceeds the H.264 ceiling pf-bitstream's \
envelope gate enforces"
);
Self {
slots: vec![None; max_dpb_frames + 1],
}
}
/// Total slot count (fixed at construction).
pub fn capacity(&self) -> usize {
self.slots.len()
}
/// Slots currently held.
pub fn active(&self) -> usize {
self.slots.iter().filter(|slot| slot.is_some()).count()
}
/// The held slots as `(slot, id)` pairs, in slot order — WP-B walks this to
/// build `VkVideoReferenceSlotInfoKHR` bindings and to map slots back to their
/// images.
pub fn held(&self) -> impl Iterator<Item = (u8, PicId)> + '_ {
self.slots
.iter()
.enumerate()
// The envelope-gated capacity (<= 17) keeps every index within u8.
.filter_map(|(index, slot)| slot.map(|id| (index as u8, id)))
}
/// Bind `id` to the lowest free slot.
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 {
capacity: self.slots.len(),
})?;
self.slots[free] = Some(id);
// The envelope-gated capacity (<= 17) keeps every index within u8.
Ok(free as u8)
}
/// The slot `id` holds, if any.
pub fn slot_of(&self, id: PicId) -> Option<u8> {
self.slots
.iter()
.position(|slot| *slot == Some(id))
// The envelope-gated capacity (<= 17) keeps every index within u8.
.map(|index| index as u8)
}
/// Free `id`'s slot. Returns whether the id held one.
///
/// Slot lifetime is DPB RESIDENCY: a picture holds its slot for exactly as long
/// as the planner's DPB holds the picture — as a reference OR as a decoded
/// picture awaiting output — and that residency ends only when a
/// [`DpbUpdate::removed`] entry reports it. This method is that report's
/// primitive: `plan_to_vk` and [`Self::apply`] call it with the planner's
/// `removed` ids and nothing else may release a slot.
///
/// Releasing is CPU-side bookkeeping (the slot becomes assignable to a later
/// picture); keeping the released slot's IMAGE out of reuse until in-flight
/// decodes complete is the backend's synchronization, not this ledger's.
pub fn release(&mut self, id: PicId) -> bool {
match self.slots.iter().position(|slot| *slot == Some(id)) {
Some(index) => {
self.slots[index] = None;
true
}
None => false,
}
}
/// Apply one [`DpbUpdate`]: release every `removed` id.
///
/// `outputs` is deliberately ignored: output-readiness is display sequencing,
/// not the end of DPB residency — a display-ready picture can still be a
/// reference (its slot stays), and only its later `removed` entry frees the
/// slot.
pub fn apply(&mut self, update: &DpbUpdate) {
for &id in &update.removed {
if !self.release(id) {
// Tolerated but never silent: reachable only when the caller skipped
// feeding an AU's plan through this map.
trace!(id, "DpbUpdate removed an id this SlotMap never assigned");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pic_id_keeps_its_slot_until_released_and_the_slot_is_then_reusable() {
let mut slots = SlotMap::new(3); // capacity 4
let s0 = slots.assign(10).unwrap();
let s1 = slots.assign(11).unwrap();
assert_ne!(s0, s1);
// Stable across unrelated churn.
assert_eq!(slots.slot_of(10), Some(s0));
slots.release(11);
assert_eq!(slots.slot_of(10), Some(s0));
assert_eq!(slots.slot_of(11), None);
// The freed slot is reusable; the held one is not.
let s2 = slots.assign(12).unwrap();
assert_eq!(s2, s1, "the lowest free slot is the released one");
assert_eq!(slots.slot_of(10), Some(s0));
assert_eq!(slots.active(), 2);
}
#[test]
fn assigning_past_capacity_is_an_error_never_a_silent_eviction() {
let mut slots = SlotMap::new(1); // capacity 2
slots.assign(1).unwrap();
slots.assign(2).unwrap();
assert_eq!(slots.assign(3), Err(SlotError::Full { capacity: 2 }));
// The failed assign evicted nothing.
assert_eq!(slots.slot_of(1), Some(0));
assert_eq!(slots.slot_of(2), Some(1));
}
#[test]
fn re_assigning_a_held_id_is_an_error_not_a_move() {
let mut slots = SlotMap::new(2);
let s = slots.assign(7).unwrap();
assert_eq!(
slots.assign(7),
Err(SlotError::AlreadyAssigned { id: 7, slot: s })
);
assert_eq!(slots.active(), 1);
}
#[test]
fn capacity_is_dpb_frames_plus_one_for_the_setup_slot() {
assert_eq!(SlotMap::new(16).capacity(), 17);
assert_eq!(SlotMap::new(4).capacity(), 5);
}
#[test]
#[should_panic(expected = "envelope")]
fn a_dpb_past_the_h264_ceiling_is_a_debug_panic_not_a_clamp() {
// pf-bitstream's envelope gate makes this unreachable from a real stream;
// reaching it means a caller bypassed the planner.
let _ = SlotMap::new(17);
}
#[test]
fn held_lists_slot_id_pairs_in_slot_order() {
let mut slots = SlotMap::new(3);
slots.assign(10).unwrap();
slots.assign(11).unwrap();
slots.assign(12).unwrap();
slots.release(11);
assert_eq!(slots.held().collect::<Vec<_>>(), vec![(0, 10), (2, 12)]);
}
#[test]
fn apply_releases_removed_ids_and_ignores_outputs() {
let mut slots = SlotMap::new(3);
slots.assign(1).unwrap();
slots.assign(2).unwrap();
slots.apply(&DpbUpdate {
stored: None,
outputs: vec![1], // display-ready, still a reference: must keep its slot
removed: vec![2],
});
assert_eq!(slots.slot_of(1), Some(0));
assert_eq!(slots.slot_of(2), None);
}
#[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
// stay fixed while it lives, and no two live ids may ever share a slot.
let mut slots = SlotMap::new(4);
let mut recorded: Vec<(PicId, u8)> = Vec::new();
for id in 0u64..100 {
let slot = slots.assign(id).unwrap();
assert!(
recorded.iter().all(|&(_, held)| held != slot),
"assign handed out a slot a live picture still holds"
);
recorded.push((id, slot));
let removed = if id >= 4 { vec![id - 4] } else { Vec::new() };
slots.apply(&DpbUpdate {
stored: Some(id),
outputs: vec![id],
removed: removed.clone(),
});
for gone in removed {
recorded.retain(|&(held_id, _)| held_id != gone);
}
// Every live picture still holds exactly the slot it was assigned.
for &(live, slot) in &recorded {
assert_eq!(slots.slot_of(live), Some(slot));
}
assert!(slots.active() <= 5);
}
}
}