[AZ-658] frame_ingest H.264/265 decoder (NVDEC + sw fallback)

Wires a real ffmpeg-next 8.1 decoder into the frame_ingest lifecycle
loop. NVDEC is probed at runtime via h264_cuvid / hevc_cuvid; CUDA-less
hosts transparently fall back to software h264 / hevc. Each decoded
frame is stamped with capture_ts (taken at packet receipt) and
decode_ts (taken after decode returns) so movement_detector sees
accurate frame-arrival times. Single-frame decode errors are counted
toward decode_errors_total and dropped; the stream is never aborted.

Adds new public API on FrameIngestHandle: decoder_backend(),
decode_errors_total(), frames_decoded_total(), decode_ms_first_frame(),
decode_ms_p50(), decode_ms_p99(). Integration tests under
crates/frame_ingest/tests/decoder_pipeline.rs cover AC-1, AC-3, AC-4
end-to-end through the real FfmpegDecoder using libx264-encoded
synthetic streams; AC-2 positive (NVDEC selection) is opt-in via
--ignored on a CUDA host. AZ-657 lifecycle tests retained via a
StubDecoder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-20 17:05:27 +03:00
parent c1558ac5c3
commit 251ebed1c2
12 changed files with 1566 additions and 65 deletions
@@ -0,0 +1,153 @@
//! AZ-658 — frame timestamping helpers.
//!
//! `description.md §4` requires every emitted [`Frame`] to carry a
//! monotonic capture timestamp stamped at the earliest practical
//! point in the pipeline (the moment the lifecycle loop receives an
//! RTSP packet from the transport). The decoder runs *after* that
//! point, so the [`Frame::decode_ts_monotonic_ns`] field records when
//! `FrameDecoder::decode` returned — the difference is the per-frame
//! decode latency that feeds the `decode_ms_p50` / `decode_ms_p99` /
//! `decode_ms_first_frame` health metrics.
//!
//! This module owns:
//! - [`SeqCounter`] — a strictly-monotonic `u64` sequence number used
//! as the frame's identity downstream of the decoder. Saturates at
//! `u64::MAX` so a session that never restarts cannot wrap and
//! produce duplicate IDs (saturating is preferred over wrapping
//! here because `movement_detector` keys per-frame state by `seq`
//! and a wrap would corrupt that map).
//! - [`FrameStamper`] — pairs a `MonoClock` and a `SeqCounter` so the
//! lifecycle loop has one place to read both timestamps for a
//! single packet → frame transition.
use shared::clock::MonoClock;
/// Strictly-monotonic frame sequence counter. Saturates at
/// `u64::MAX`; in practice a 30 fps stream takes ~19.5 billion years
/// to overflow `u64`, so saturation behaviour is observable only as a
/// post-condition for tests with `u64::MAX - 1` priming.
#[derive(Debug, Default)]
pub struct SeqCounter {
next: u64,
}
impl SeqCounter {
pub fn new() -> Self {
Self { next: 0 }
}
/// Returns the next sequence number and advances internal state.
/// Saturates at `u64::MAX` (subsequent calls keep returning
/// `u64::MAX`). Named `advance` rather than `next` so that the
/// type does not collide with `Iterator::next` semantics in
/// caller code (and to satisfy `clippy::should_implement_trait`
/// — `SeqCounter` is intentionally NOT an Iterator: an unbounded
/// monotonic counter has no natural `None` terminator).
pub fn advance(&mut self) -> u64 {
let s = self.next;
self.next = self.next.saturating_add(1);
s
}
}
/// Holds a clock + sequence counter so the lifecycle loop only has
/// to call [`FrameStamper::capture`] (immediately on packet receipt)
/// and [`FrameStamper::decoded`] (immediately after decode returns)
/// to produce both monotonic timestamps for the next frame.
#[derive(Debug)]
pub struct FrameStamper {
clock: MonoClock,
seq: SeqCounter,
}
impl FrameStamper {
pub fn new(clock: MonoClock) -> Self {
Self {
clock,
seq: SeqCounter::new(),
}
}
/// Snapshot the capture-side timestamp + sequence number. Call
/// this the moment the transport hands us the packet, BEFORE
/// invoking the decoder. The capture timestamp is the head of
/// the per-frame latency budget (`description.md §8`: ≤30 ms p99
/// from RTSP rx → publish on Jetson Orin Nano).
pub fn capture(&mut self) -> CaptureMark {
CaptureMark {
seq: self.seq.advance(),
ts_ns: self.clock.elapsed_ns(),
}
}
/// Read the decode-side timestamp at the moment
/// `FrameDecoder::decode` returned. Used both for the emitted
/// `Frame::decode_ts_monotonic_ns` field and to compute
/// `decode_duration = decode_ts - capture_ts` for the histogram.
pub fn decoded(&self) -> u64 {
self.clock.elapsed_ns()
}
}
/// One capture-side mark per packet. Carried through the decode call
/// so the emitted `Frame` keeps the timestamp from packet receipt,
/// not from after-decode.
#[derive(Debug, Clone, Copy)]
pub struct CaptureMark {
pub seq: u64,
pub ts_ns: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seq_counter_is_strictly_monotonic() {
// Arrange
let mut c = SeqCounter::new();
// Act
let a = c.advance();
let b = c.advance();
let d = c.advance();
// Assert
assert_eq!(a, 0);
assert_eq!(b, 1);
assert_eq!(d, 2);
}
#[test]
fn seq_counter_saturates_at_max_instead_of_wrapping() {
// Arrange — prime to u64::MAX - 1 by direct field assignment
// so the test runs in O(1).
let mut c = SeqCounter { next: u64::MAX - 1 };
// Act
let a = c.advance();
let b = c.advance();
let d = c.advance();
// Assert — once we hit MAX, every subsequent call must keep
// returning MAX (no wrap to 0).
assert_eq!(a, u64::MAX - 1);
assert_eq!(b, u64::MAX);
assert_eq!(d, u64::MAX);
}
#[test]
fn frame_stamper_capture_advances_seq_and_ts() {
// Arrange
let mut s = FrameStamper::new(MonoClock::new());
// Act
let m1 = s.capture();
let m2 = s.capture();
// Assert
assert_eq!(m1.seq, 0);
assert_eq!(m2.seq, 1);
assert!(m2.ts_ns >= m1.ts_ns, "monotonic clock went backwards");
}
}