feat(pf-bitstream): H.265 DecodePlan layer — M3's AU-to-hardware contract

H265Planner mirrors the H.264 layer's contract exactly: plan_au -> AuPlan
{ picture, slices with ref lists by stable PicId, DpbUpdate, warnings },
same concealment posture (warnings never abort, in-place reference
substitution preserving ref_idx positions, outputs survive failed AUs,
flush gates on AwaitingIdr, any IRAP resumes). Ported logic: RPS 8.3.2
(short-term AND long-term incl. PocLsbLt/MSB-cycle - the hosts' RFI
recovery rides long-term refs), ref lists 8.3.3/8.3.4, DPB C.5.2.2/C.5.2.3
via the vendored dpb; POC 8.3.1 from the vendored PictureData. Written
fresh: the plan surface, AU walk, envelope gates (multilayer, interlaced,
SCC self-reference, DPB>16, conf-window overflow - checked at EVERY
activation, not just parse), HEVC recovery-point SEI (prefix NALU 39,
se(v) recovery_poc_cnt), VUI colour with E.3.1 inference, and a test-only
HEVC bitstream synthesizer (upstream has none).

Upstream deviations worth naming (all in-code with spec anchors): the
empty-RPS inter slice cannot infinite-loop (upstream bug); RASL behind a
joined CRA refuses BEFORE any state change (PlanError::RaslSkipped - the
WP-2 wiring must map it to skip, not reanchor; module docs carry the
contract note); MaxPicOrderCntLsb reads from the ACTIVATING SPS (upstream
latches at parse - a latent multi-SPS bug); C.5.2.2's exemption is
picture 0 of the BITSTREAM (EobNut), never first-after-EOS.

Vendored parser gained PROVENANCE deviation 7 (report upstream): hostile
slice headers with num_long_term_sps+num_long_term_pics > 16 indexed out
of bounds of SliceHeader's [_;16] arrays - a production panic on exactly
the long-term-reference path, now a parse error.

Port review round 7: 10 findings (3 blocking: the vendor panic, an
EOS-boundary output interleave, an envelope bypass through PPS-only SPS
rebind reaching wrapping crop arithmetic) - 9 fixed with a regression
test each, 1 documented as the WP-2 contract note. Known follow-up: the
h264 AU-tail truncation detector shares h265's dead-arm shape (its arm
also cuts reserved NALU types, so the fix is not identical - deferred).

Tests: 29 h265 planner + 2 HEVC SEI + full test-25fps.h265/bear/bbb clip
walks with real invariants (every stored id output exactly once,
ascending POC per IRAP period). Gates: fmt clean; clippy -D warnings zero
(mac + pf-lxcheck2 incl. pf-client-core/pf-presenter); tests 45+69 mac,
69+121+53 container.
This commit is contained in:
2026-08-05 23:29:45 +02:00
parent dc0766b2f3
commit cf5db2d485
5 changed files with 3321 additions and 18 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@
#![forbid(unsafe_code)]
pub mod h264;
pub mod h265;
pub mod sei;
// The vendor-pinning smoke tests below assert against byte counts and golden values from
+129 -18
View File
@@ -1,8 +1,15 @@
//! SEI payload parsing — the piece the vendored parser layer lacks: upstream classifies
//! SEI NALUs (`NaluType::Sei`) but never reads a payload. punktfunk needs exactly one:
//! the recovery point SEI (payload type 6, spec D.1.8 syntax / D.2.8 semantics), which
//! hosts emit on RFI recovery so the client knows where a decode-from-here point lands.
//! Every other payload type is skipped by its declared size.
//! SEI NALUs but never reads a payload. punktfunk needs exactly one payload type per
//! codec: the recovery point SEI, which hosts emit on RFI recovery so the client knows
//! where a decode-from-here point lands. Every other payload type is skipped by its
//! declared size.
//!
//! Both codecs put the recovery point at payload type 6 with the same D.1 message
//! framing, but the payload syntax differs: H.264 (D.1.8/D.2.8) counts recovery in
//! `frame_num` increments (`recovery_frame_cnt`, ue(v)) and carries a slice-group bit
//! pair; H.265 (D.2.8/D.3.8) counts in picture order (`recovery_poc_cnt`, se(v) — it
//! can be negative) and has no slice-group field. Hence two parsers over one shared
//! message walk.
/// Recovery point SEI (D.2.8).
///
@@ -16,16 +23,73 @@ pub struct RecoveryPoint {
pub broken_link: bool,
}
/// Parse the first recovery point SEI message out of a SEI NALU.
/// Recovery point SEI, H.265 flavour (D.3.8).
///
/// `recovery_poc_cnt` is the POC delta from the picture carrying the SEI to the
/// recovery-point picture — se(v)-coded, so unlike H.264's `recovery_frame_cnt` it can
/// be NEGATIVE (a recovery point among leading pictures). `exact_match`/`broken_link`
/// keep their H.264 semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecoveryPointHevc {
pub recovery_poc_cnt: i32,
pub exact_match: bool,
pub broken_link: bool,
}
/// Parse the first recovery point SEI message out of an H.264 SEI NALU.
///
/// `sei_payload` are the bytes of the NALU after its one-byte NAL header, emulation
/// prevention bytes still in place (they are removed here — 7.4.1 RBSP extraction).
/// `Ok(None)` means the NALU parsed cleanly but carries no recovery point.
pub fn parse_recovery_point(sei_payload: &[u8]) -> Result<Option<RecoveryPoint>, String> {
let rbsp = strip_emulation_prevention(sei_payload);
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
return Ok(None);
};
let mut r = BitCursor::new(payload);
let recovery_frame_cnt = r.read_ue()?;
let exact_match = r.read_bit()? != 0;
let broken_link = r.read_bit()? != 0;
// changing_slice_group_idc u(2): parsed to keep the reader honest, unused —
// slice groups are outside every profile punktfunk hosts emit.
let _changing_slice_group_idc = r.read_bits(2)?;
Ok(Some(RecoveryPoint {
recovery_frame_cnt,
exact_match,
broken_link,
}))
}
/// Parse the first recovery point SEI message out of an H.265 prefix SEI NALU.
///
/// `sei_payload` are the bytes of the NALU after its TWO-byte NAL header (H.265 NALU
/// headers are 16 bits), emulation prevention still in place. Only prefix SEI NALUs
/// (type 39) can carry a recovery point — D.2.1 lists it as prefix-only, so suffix SEI
/// NALUs (type 40) need never reach here.
pub fn parse_recovery_point_hevc(sei_payload: &[u8]) -> Result<Option<RecoveryPointHevc>, String> {
let rbsp = strip_emulation_prevention(sei_payload);
let Some(payload) = first_recovery_point_payload(&rbsp)? else {
return Ok(None);
};
let mut r = BitCursor::new(payload);
let recovery_poc_cnt = r.read_se()?;
let exact_match = r.read_bit()? != 0;
let broken_link = r.read_bit()? != 0;
Ok(Some(RecoveryPointHevc {
recovery_poc_cnt,
exact_match,
broken_link,
}))
}
/// Walk the D.1 SEI message framing (shared verbatim between H.264 and H.265) and
/// return the payload bytes of the first recovery point message (payload type 6 in
/// both codecs), if any. `rbsp` is already emulation-prevention-stripped.
fn first_recovery_point_payload(rbsp: &[u8]) -> Result<Option<&[u8]>, String> {
let mut i = 0usize;
while i < rbsp.len() && !is_rbsp_trailing(&rbsp, i) {
while i < rbsp.len() && !is_rbsp_trailing(rbsp, i) {
// D.1: payload type and size are ff-coded — 0xFF bytes each add 255 until a
// non-0xFF byte terminates the value. The run length is unbounded, so the type
// accumulates saturating: an adversarial ~16M-byte 0xFF run must not overflow
@@ -59,18 +123,7 @@ pub fn parse_recovery_point(sei_payload: &[u8]) -> Result<Option<RecoveryPoint>,
.ok_or_else(|| "SEI payload overruns the NALU".to_string())?;
if payload_type == 6 {
let mut r = BitCursor::new(&rbsp[i..end]);
let recovery_frame_cnt = r.read_ue()?;
let exact_match = r.read_bit()? != 0;
let broken_link = r.read_bit()? != 0;
// changing_slice_group_idc u(2): parsed to keep the reader honest, unused —
// slice groups are outside every profile punktfunk hosts emit.
let _changing_slice_group_idc = r.read_bits(2)?;
return Ok(Some(RecoveryPoint {
recovery_frame_cnt,
exact_match,
broken_link,
}));
return Ok(Some(&rbsp[i..end]));
}
i = end;
@@ -147,6 +200,15 @@ impl<'a> BitCursor<'a> {
.checked_add(suffix)
.ok_or_else(|| "exp-Golomb value overflows u32".to_string())
}
/// se(v), spec 9.1.1: the ue(v) code point k maps to (1)^(k+1) · ⌈k/2⌉.
fn read_se(&mut self) -> Result<i32, String> {
let k = self.read_ue()?;
let magnitude = k.div_ceil(2);
let magnitude =
i32::try_from(magnitude).map_err(|_| "exp-Golomb value overflows i32".to_string())?;
Ok(if k % 2 == 1 { magnitude } else { -magnitude })
}
}
#[cfg(test)]
@@ -232,4 +294,53 @@ mod tests {
let sei = [0x06, 0x0A, 0x00];
assert!(parse_recovery_point(&sei).is_err());
}
#[test]
fn the_hevc_recovery_point_parses_its_se_coded_poc_count() {
// recovery_poc_cnt se(0) = '1', exact = 0, broken = 0, payload alignment:
// 0b1001_0000.
let sei = [0x06, 0x01, 0x90, 0x80];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: 0,
exact_match: false,
broken_link: false
})
);
// se(-1) = '011' (ue code point 2), exact = 1, broken = 0, alignment:
// 0b0111_0100 — the negative range H.264's ue(v) syntax cannot express.
let sei = [0x06, 0x01, 0x74, 0x80];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: -1,
exact_match: true,
broken_link: false
})
);
}
#[test]
fn the_hevc_parser_skips_earlier_messages_and_reports_absence_as_none() {
// User-data message first, then the recovery point (poc_cnt se(3): ue code
// point 5 = '00110', exact = 1, broken = 1, alignment: 0b0011_0111).
let sei = [
0x05, 0x02, 0xAA, 0xBB, // type 5
0x06, 0x01, 0x37, // recovery point
0x80,
];
assert_eq!(
parse_recovery_point_hevc(&sei).unwrap(),
Some(RecoveryPointHevc {
recovery_poc_cnt: 3,
exact_match: true,
broken_link: true
})
);
let sei = [0x05, 0x01, 0x00, 0x80];
assert_eq!(parse_recovery_point_hevc(&sei).unwrap(), None);
}
}
+10
View File
@@ -56,5 +56,15 @@ in the future."
SPS). Found by pf-bitstream's conformance-window tests; upstream never hits it
because real encoders crop right/bottom only.
7. `src/codec/h265/parser.rs``parse_slice_header`: reject
`num_long_term_sps + num_long_term_pics > 16` before the long-term RPS loop.
Upstream bounds the pair only by `MAX_LONG_TERM_REF_PIC_SETS` (32) combined, while
every long-term array in `SliceHeader` (`poc_lsb_lt`, `used_by_curr_pic_lt`,
`delta_poc_msb_present_flag`, `delta_poc_msb_cycle_lt`, `lt_idx_sps`) is `[_; 16]`
— a hostile slice header with 17+ entries panics the parser with an
index-out-of-bounds (bounds checks stay on in release). Found by pf-bitstream's
H.265 planner review; regression-tested there
(`a_hostile_long_term_count_is_a_parse_error_not_a_panic`). **Report upstream.**
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
@@ -3885,6 +3885,19 @@ impl Parser {
)?;
let num_lt = hdr.num_long_term_sps + hdr.num_long_term_pics;
// The long-term RPS arrays in SliceHeader (poc_lsb_lt,
// used_by_curr_pic_lt, delta_poc_msb_present_flag,
// delta_poc_msb_cycle_lt, lt_idx_sps) hold 16 entries — the DPB
// bound — while the reads above admit up to
// MAX_LONG_TERM_REF_PIC_SETS (32) combined; the loop below would
// index out of bounds on such a header. See PROVENANCE.md
// deviation 7.
if usize::from(num_lt) > hdr.poc_lsb_lt.len() {
return Err(format!(
"Invalid num_long_term_sps + num_long_term_pics: {}",
num_lt
));
}
for i in 0..usize::from(num_lt) {
// The variables `PocLsbLt[ i ]` and `UsedByCurrPicLt[ i ]` are derived as follows:
//