mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 17:11:11 +00:00
[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:
+127
-42
@@ -3,26 +3,22 @@
|
||||
//! Real implementation lands in:
|
||||
//! - AZ-657 `frame_ingest_rtsp_session` — session lifecycle + bounded
|
||||
//! reconnect + AI-lock plumb (this crate, modules in `internal/`).
|
||||
//! - AZ-658 `frame_ingest_decoder` — H.264/265 decode into raw
|
||||
//! pixel buffers + retina/FFmpeg/GStreamer transport binding.
|
||||
//! - AZ-658 `frame_ingest_decoder` — H.264/265 decode (NVDEC + sw
|
||||
//! fallback) + per-frame monotonic timestamping + decode stats
|
||||
//! (this crate, `internal/decoder.rs` + `internal/timestamp.rs`).
|
||||
//! - AZ-659 `frame_ingest_publisher` — bounded broadcast + per-consumer
|
||||
//! drop policy.
|
||||
//!
|
||||
//! ## AZ-657 surface
|
||||
//! ## AZ-658 surface (extends AZ-657)
|
||||
//!
|
||||
//! - [`FrameIngest::new`] — construct in `Closed` state.
|
||||
//! - [`FrameIngest::run`] — spawn the lifecycle loop driving the given
|
||||
//! `RtspTransport` through `connect → stream → reconnect` cycles
|
||||
//! with bounded backoff. Returns a `JoinHandle`.
|
||||
//! - [`FrameIngestHandle::subscribe`] — broadcast frame stream (the
|
||||
//! AZ-657 lifecycle emits only synthetic header frames; real
|
||||
//! decoded frames come in AZ-658).
|
||||
//! - [`FrameIngestHandle::set_ai_lock`] — `bringCameraDown` /
|
||||
//! `bringCameraUp` signal. Stamps `Frame.ai_locked` on every
|
||||
//! subsequently emitted frame.
|
||||
//! - [`FrameIngestHandle::session_state`] — current FSM state.
|
||||
//! - [`FrameIngestHandle::health`] — `ComponentHealth` reflecting the
|
||||
//! FSM state + `last_packet_age` + `ai_locked`.
|
||||
//! `FrameIngest::run` now takes a [`FrameDecoder`]. The lifecycle loop
|
||||
//! stamps the capture timestamp the moment a packet leaves the
|
||||
//! transport, hands the encoded payload to the decoder, and emits one
|
||||
//! [`Frame`] per decoded picture with `decode_ts_monotonic_ns` set
|
||||
//! when the decoder returned. Single-frame decode errors increment
|
||||
//! `decode_errors_total` and drop the frame; the stream is never
|
||||
//! aborted (AC-3). The decoder backend (`Nvdec` / `Software`) is
|
||||
//! observable via [`FrameIngestHandle::decoder_backend`].
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
@@ -37,10 +33,15 @@ use shared::models::frame::Frame;
|
||||
|
||||
pub mod internal;
|
||||
|
||||
pub use internal::decoder::{
|
||||
Codec, DecodeError, DecodeStats, DecodedPixels, DecoderBackend, DecoderInitError,
|
||||
FfmpegDecoder, FrameDecoder,
|
||||
};
|
||||
pub use internal::lifecycle::{BackoffPolicy, LifecycleStats, SessionState};
|
||||
pub use internal::rtsp_client::{
|
||||
OpenError, RtspPacket, RtspSessionConfig, RtspTransport, RtspTransportHint, StreamError,
|
||||
};
|
||||
pub use internal::timestamp::FrameStamper;
|
||||
|
||||
use internal::lifecycle::{transition, Trigger};
|
||||
|
||||
@@ -56,7 +57,9 @@ pub struct FrameIngest {
|
||||
ai_lock_tx: watch::Sender<bool>,
|
||||
state_tx: watch::Sender<SessionState>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
backend_tx: watch::Sender<Option<DecoderBackend>>,
|
||||
stats: Arc<LifecycleStats>,
|
||||
decode_stats: Arc<DecodeStats>,
|
||||
backoff: BackoffPolicy,
|
||||
clock: MonoClock,
|
||||
}
|
||||
@@ -74,12 +77,15 @@ impl FrameIngest {
|
||||
let (ai_lock_tx, _) = watch::channel(false);
|
||||
let (state_tx, _) = watch::channel(SessionState::Closed);
|
||||
let (shutdown_tx, _) = watch::channel(false);
|
||||
let (backend_tx, _) = watch::channel(None);
|
||||
Self {
|
||||
tx,
|
||||
ai_lock_tx,
|
||||
state_tx,
|
||||
shutdown_tx,
|
||||
backend_tx,
|
||||
stats: LifecycleStats::new(),
|
||||
decode_stats: DecodeStats::shared(),
|
||||
backoff,
|
||||
clock: MonoClock::new(),
|
||||
}
|
||||
@@ -91,36 +97,50 @@ impl FrameIngest {
|
||||
ai_lock_tx: self.ai_lock_tx.clone(),
|
||||
state_rx: self.state_tx.subscribe(),
|
||||
shutdown_tx: self.shutdown_tx.clone(),
|
||||
backend_rx: self.backend_tx.subscribe(),
|
||||
stats: Arc::clone(&self.stats),
|
||||
decode_stats: Arc::clone(&self.decode_stats),
|
||||
clock: self.clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the lifecycle loop. The returned handle resolves when
|
||||
/// the loop exits (shutdown signalled via
|
||||
/// Spawn the lifecycle loop. Returns a `JoinHandle` that resolves
|
||||
/// when the loop exits (shutdown signalled via
|
||||
/// [`FrameIngestHandle::shutdown`] or a hard-fail trapped the FSM).
|
||||
pub fn run<T>(&self, transport: T, config: RtspSessionConfig) -> JoinHandle<()>
|
||||
///
|
||||
/// `decoder` is owned exclusively by the spawned task; only one
|
||||
/// decoder is active per `FrameIngest` instance.
|
||||
pub fn run<T, D>(&self, transport: T, decoder: D, config: RtspSessionConfig) -> JoinHandle<()>
|
||||
where
|
||||
T: RtspTransport + 'static,
|
||||
D: FrameDecoder + 'static,
|
||||
{
|
||||
let tx = self.tx.clone();
|
||||
let ai_lock = self.ai_lock_tx.subscribe();
|
||||
let state_tx = self.state_tx.clone();
|
||||
let backend_tx = self.backend_tx.clone();
|
||||
let shutdown_rx = self.shutdown_tx.subscribe();
|
||||
let stats = Arc::clone(&self.stats);
|
||||
let decode_stats = Arc::clone(&self.decode_stats);
|
||||
let backoff = self.backoff;
|
||||
let clock = self.clock;
|
||||
let transport = Arc::new(Mutex::new(transport));
|
||||
let decoder: Box<dyn FrameDecoder + Send> = Box::new(decoder);
|
||||
// Snapshot the decoder backend immediately so it is observable
|
||||
// even before the first packet.
|
||||
backend_tx.send_replace(Some(decoder.backend()));
|
||||
|
||||
tokio::spawn(async move {
|
||||
lifecycle_loop(
|
||||
transport,
|
||||
decoder,
|
||||
config,
|
||||
tx,
|
||||
ai_lock,
|
||||
state_tx,
|
||||
shutdown_rx,
|
||||
stats,
|
||||
decode_stats,
|
||||
backoff,
|
||||
clock,
|
||||
)
|
||||
@@ -136,19 +156,22 @@ fn is_shutdown(rx: &watch::Receiver<bool>) -> bool {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn lifecycle_loop<T>(
|
||||
transport: Arc<Mutex<T>>,
|
||||
mut decoder: Box<dyn FrameDecoder + Send>,
|
||||
config: RtspSessionConfig,
|
||||
tx: broadcast::Sender<Frame>,
|
||||
mut ai_lock: watch::Receiver<bool>,
|
||||
state_tx: watch::Sender<SessionState>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
stats: Arc<LifecycleStats>,
|
||||
decode_stats: Arc<DecodeStats>,
|
||||
backoff: BackoffPolicy,
|
||||
clock: MonoClock,
|
||||
) where
|
||||
T: RtspTransport,
|
||||
{
|
||||
let mut state = SessionState::Closed;
|
||||
let mut seq: u64 = 0;
|
||||
let mut stamper = FrameStamper::new(clock);
|
||||
let mut decoded_buffer: Vec<DecodedPixels> = Vec::with_capacity(4);
|
||||
|
||||
loop {
|
||||
if is_shutdown(&shutdown_rx) {
|
||||
@@ -203,29 +226,47 @@ async fn lifecycle_loop<T>(
|
||||
|
||||
match packet {
|
||||
Ok(pkt) => {
|
||||
let now_ns = clock.elapsed_ns();
|
||||
stats.note_packet(now_ns);
|
||||
// Capture timestamp + sequence number are
|
||||
// taken at the EARLIEST point per
|
||||
// `description.md §4` — before the decoder
|
||||
// has run, so movement_detector's skew
|
||||
// gate sees the original packet arrival
|
||||
// time.
|
||||
let mark = stamper.capture();
|
||||
stats.note_packet(mark.ts_ns);
|
||||
let locked = *ai_lock.borrow_and_update();
|
||||
// AZ-657 emits a synthetic frame envelope
|
||||
// per inbound RTSP packet so the lifecycle
|
||||
// FSM can be exercised end-to-end without
|
||||
// the decoder (AZ-658 swaps this for the
|
||||
// actual decoded frame).
|
||||
let frame = Frame {
|
||||
seq,
|
||||
capture_ts_monotonic_ns: now_ns,
|
||||
decode_ts_monotonic_ns: now_ns,
|
||||
pixels: Arc::new(pkt.payload),
|
||||
width: 0,
|
||||
height: 0,
|
||||
pix_fmt: shared::models::frame::PixelFormat::Nv12,
|
||||
ai_locked: locked,
|
||||
};
|
||||
seq = seq.saturating_add(1);
|
||||
// A no-subscriber send is a no-op error in
|
||||
// the broadcast channel; the lifecycle
|
||||
// does not care.
|
||||
let _ = tx.send(frame);
|
||||
decoded_buffer.clear();
|
||||
match decoder.decode(&pkt.payload, &mut decoded_buffer) {
|
||||
Ok(()) => {
|
||||
for dp in decoded_buffer.drain(..) {
|
||||
decode_stats.note_decoded(dp.decode_duration);
|
||||
let frame = Frame {
|
||||
seq: mark.seq,
|
||||
capture_ts_monotonic_ns: mark.ts_ns,
|
||||
decode_ts_monotonic_ns: stamper.decoded(),
|
||||
pixels: Arc::new(dp.pixels),
|
||||
width: dp.width,
|
||||
height: dp.height,
|
||||
pix_fmt: dp.pix_fmt,
|
||||
ai_locked: locked,
|
||||
};
|
||||
// Send errors are no-ops when
|
||||
// the broadcast has no
|
||||
// subscribers; per-consumer
|
||||
// back-pressure is AZ-659's
|
||||
// problem.
|
||||
let _ = tx.send(frame);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
decode_stats.note_decode_error();
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
seq = mark.seq,
|
||||
"frame_ingest dropped a frame on decode error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let trig = Trigger::from_stream_error(&e);
|
||||
@@ -272,7 +313,9 @@ pub struct FrameIngestHandle {
|
||||
ai_lock_tx: watch::Sender<bool>,
|
||||
state_rx: watch::Receiver<SessionState>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
backend_rx: watch::Receiver<Option<DecoderBackend>>,
|
||||
stats: Arc<LifecycleStats>,
|
||||
decode_stats: Arc<DecodeStats>,
|
||||
clock: MonoClock,
|
||||
}
|
||||
|
||||
@@ -314,6 +357,44 @@ impl FrameIngestHandle {
|
||||
self.stats.reopens_total.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Backend the active decoder selected at construction. `None`
|
||||
/// before `FrameIngest::run` has been called.
|
||||
pub fn decoder_backend(&self) -> Option<DecoderBackend> {
|
||||
*self.backend_rx.borrow()
|
||||
}
|
||||
|
||||
pub fn decode_errors_total(&self) -> u64 {
|
||||
self.decode_stats
|
||||
.decode_errors_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn frames_decoded_total(&self) -> u64 {
|
||||
self.decode_stats
|
||||
.frames_decoded_total
|
||||
.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn decode_ms_first_frame(&self) -> Option<Duration> {
|
||||
let ns = self
|
||||
.decode_stats
|
||||
.first_frame_decode_duration_ns
|
||||
.load(Ordering::Relaxed);
|
||||
if ns == 0 && self.frames_decoded_total() == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_nanos(ns))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_ms_p50(&self) -> Option<Duration> {
|
||||
self.decode_stats.p50_ns().map(Duration::from_nanos)
|
||||
}
|
||||
|
||||
pub fn decode_ms_p99(&self) -> Option<Duration> {
|
||||
self.decode_stats.p99_ns().map(Duration::from_nanos)
|
||||
}
|
||||
|
||||
/// Request the lifecycle loop to drain to `Closed` and exit. The
|
||||
/// loop races every transport call against this signal, so a
|
||||
/// hung transport cannot wedge graceful exit.
|
||||
@@ -366,6 +447,10 @@ mod tests {
|
||||
let h = FrameIngest::new(8).handle();
|
||||
assert_eq!(h.session_state(), SessionState::Closed);
|
||||
assert_eq!(h.health().level, HealthLevel::Disabled);
|
||||
assert!(
|
||||
h.decoder_backend().is_none(),
|
||||
"no decoder is wired until run() is called"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user