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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user