//! AZ-658 — decoder pipeline integration tests. //! //! These tests drive the **real** [`FfmpegDecoder`] (libavcodec) end //! to end through the lifecycle loop. A synthetic H.264 bitstream is //! produced in-process by libx264 (the same FFmpeg install that //! `FfmpegDecoder` uses to decode), so the tests exercise the //! production decode path rather than a stub. //! //! ACs covered here: //! - AC-1 — software-path throughput preservation (≥95 % of input //! frames decoded; sequence numbers strictly monotonic; decoder //! backend reports `Software` on a CUDA-less host). //! - AC-3 — a single corrupted "packet" between valid ones must //! increment `decode_errors_total` exactly once and NOT abort the //! stream. //! - AC-4 — `capture_ts_monotonic_ns` is strictly increasing across //! the emitted frame stream (rides on AC-1's setup). //! //! AC-2 (NVDEC selection on Jetson) cannot be exercised here — there //! is no CUDA-capable FFmpeg on the dev/CI host. The unit-test //! counterpart in `internal/decoder.rs::tests` asserts the negative //! direction (CUDA-less host → Software backend); the positive //! direction is validated on the Jetson at deployment time and is //! covered by the Run Tests gate downstream of this batch. use std::collections::VecDeque; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; use ffmpeg_next as ffmpeg; use tokio::sync::Mutex as AsyncMutex; use tokio::time::timeout; use frame_ingest::{ BackoffPolicy, Codec, DecoderBackend, FfmpegDecoder, FrameDecoder, FrameIngest, OpenError, RtspPacket, RtspSessionConfig, RtspTransport, StreamError, }; /// Synthetic H.264 bitstream generator. Encodes `num_frames` frames /// of a checkerboard pattern at `width`x`height` and 30 fps with /// libx264 (preset `ultrafast`, tune `zerolatency`, GOP every 30 /// frames so each test run gets a few IDRs). Returns a vector of /// per-AVPacket byte blobs, each ready to feed into the decoder as /// the payload of an `RtspPacket`. fn synth_h264_stream(num_frames: usize, width: u32, height: u32) -> Vec { ffmpeg::init().expect("ffmpeg init"); let codec = ffmpeg::codec::encoder::find_by_name("libx264") .or_else(|| ffmpeg::codec::encoder::find_by_name("h264")) .expect("an H.264 encoder must be registered"); let context = ffmpeg::codec::Context::new_with_codec(codec); let mut encoder = context .encoder() .video() .expect("encoder context yields video"); encoder.set_width(width); encoder.set_height(height); encoder.set_format(ffmpeg::format::Pixel::YUV420P); encoder.set_time_base(ffmpeg::Rational::new(1, 30)); encoder.set_frame_rate(Some(ffmpeg::Rational::new(30, 1))); encoder.set_gop(30); encoder.set_max_b_frames(0); let mut opts = ffmpeg::Dictionary::new(); opts.set("preset", "ultrafast"); opts.set("tune", "zerolatency"); let mut opened = encoder .open_with(opts) .expect("libx264 encoder must open with ultrafast/zerolatency"); let mut out = Vec::with_capacity(num_frames + 4); let mut packet = ffmpeg::Packet::empty(); for i in 0..num_frames { let mut input = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height); // Fill Y plane with a per-frame gradient so the encoder has // motion to compress (a constant frame is degenerate and // libx264 can choose to emit zero packets for some inputs). let y_stride = input.stride(0); let y = input.data_mut(0); for row in 0..height as usize { let v = ((i + row) & 0xFF) as u8; for col in 0..width as usize { y[row * y_stride + col] = v ^ ((col & 0xFF) as u8); } } for plane in 1..=2 { let stride = input.stride(plane); let data = input.data_mut(plane); for row in 0..(height as usize) / 2 { for col in 0..(width as usize) / 2 { data[row * stride + col] = 128; } } } input.set_pts(Some(i as i64)); opened .send_frame(&input) .unwrap_or_else(|e| panic!("encoder send_frame ({i}) failed: {e}")); while opened.receive_packet(&mut packet).is_ok() { if let Some(d) = packet.data() { out.push(Bytes::copy_from_slice(d)); } } } opened.send_eof().expect("encoder eof"); while opened.receive_packet(&mut packet).is_ok() { if let Some(d) = packet.data() { out.push(Bytes::copy_from_slice(d)); } } assert!( !out.is_empty(), "synthetic encoder must produce at least one packet" ); out } /// RTSP-shaped transport that replays a pre-built script of byte /// blobs, then parks (so the FrameIngest task stays in `Streaming` /// until the test calls `shutdown`). When the script is exhausted, /// `next_packet` returns a parked future — the lifecycle loop's /// `tokio::select!` against the shutdown watch is what unblocks /// teardown. struct ScriptedBytesTransport { queue: Arc>>, } #[derive(Debug, Clone)] enum ScriptItem { Bytes(Bytes), } impl ScriptedBytesTransport { fn new(packets: Vec) -> Self { let queue = packets .into_iter() .map(ScriptItem::Bytes) .collect::>(); Self { queue: Arc::new(AsyncMutex::new(queue)), } } } #[async_trait] impl RtspTransport for ScriptedBytesTransport { async fn open(&mut self, _config: &RtspSessionConfig) -> Result<(), OpenError> { Ok(()) } async fn close(&mut self) {} async fn next_packet(&mut self) -> Result { loop { let item = { let mut q = self.queue.lock().await; q.pop_front() }; match item { Some(ScriptItem::Bytes(b)) => { return Ok(RtspPacket { timestamp_rtp: 0, payload: b, }); } None => { // Park forever; the lifecycle loop's shutdown // watch breaks us out via select!. std::future::pending::<()>().await; } } } } } fn fast_backoff() -> BackoffPolicy { BackoffPolicy::new(Duration::from_millis(10), Duration::from_millis(40)) } /// AC-1 + AC-4 — a software-decoded synthetic stream must preserve /// at least 95 % of input frames and stamp them with strictly /// monotonic capture timestamps + sequence numbers. The dev/CI host /// has no CUDA so backend MUST report `Software`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ac1_ac4_software_decode_preserves_throughput_and_monotonicity() { // Arrange — encode 60 frames (2 s of 30 fps content). The AC's // literal 1080p / 10 s budget is validated against the real // camera at deploy; the dev test exercises the same code path // at smaller scale to keep CI <5 s. let width = 320u32; let height = 240u32; let input_frames = 60usize; let stream = synth_h264_stream(input_frames, width, height); assert!( stream.len() >= input_frames - 5, "encoder produced {} packets for {input_frames} frames; expected ~1:1", stream.len() ); let transport = ScriptedBytesTransport::new(stream); let decoder = FfmpegDecoder::new(Codec::H264).expect("software h264 decoder must open on this host"); let ingest = FrameIngest::with_backoff(input_frames + 16, fast_backoff()); let handle = ingest.handle(); let mut frames = handle.subscribe(); // Act let task = ingest.run(transport, decoder, RtspSessionConfig::new("rtsp://fake/0")); let mut received = Vec::with_capacity(input_frames); let deadline = Duration::from_secs(10); let start = tokio::time::Instant::now(); while received.len() < input_frames && start.elapsed() < deadline { match timeout(Duration::from_millis(500), frames.recv()).await { Ok(Ok(f)) => received.push(f), Ok(Err(_)) => break, Err(_) => { if handle.frames_decoded_total() as usize == received.len() { // No more frames are coming — the encoder may // have produced fewer access units than input // frames (rare with `tune=zerolatency` but // possible). Stop waiting. break; } } } } handle.shutdown(); let _ = timeout(Duration::from_secs(2), task).await; // Assert — backend selection (AC-2 negative direction): CUDA-less // host MUST select Software. assert_eq!( handle.decoder_backend(), Some(DecoderBackend::Software), "host without h264_cuvid must fall back to Software" ); // AC-1 — at least 95 % of input frames decoded. let kept = received.len(); let min_required = (input_frames as f64 * 0.95).ceil() as usize; assert!( kept >= min_required, "decoded {kept} frames; AC-1 requires ≥{min_required} of {input_frames} ({}%)", (kept * 100) / input_frames ); // AC-1 + AC-4 — sequence numbers strictly monotonic. for w in received.windows(2) { assert!( w[0].seq < w[1].seq, "seq must strictly increase: {} → {}", w[0].seq, w[1].seq ); } // AC-4 — capture timestamps strictly monotonic. for w in received.windows(2) { assert!( w[0].capture_ts_monotonic_ns < w[1].capture_ts_monotonic_ns, "capture_ts must strictly increase: {} → {}", w[0].capture_ts_monotonic_ns, w[1].capture_ts_monotonic_ns ); } // Decode timestamps must be at-or-after capture timestamps for // every frame (decode happens after packet receipt by // construction). for f in &received { assert!( f.decode_ts_monotonic_ns >= f.capture_ts_monotonic_ns, "decode_ts {} must be ≥ capture_ts {}", f.decode_ts_monotonic_ns, f.capture_ts_monotonic_ns ); } // First-frame cold-start metric was recorded. assert!( handle.decode_ms_first_frame().is_some(), "decode_ms_first_frame must be populated after the first decode" ); assert!(handle.decode_ms_p50().is_some(), "p50 must be populated"); assert!(handle.decode_ms_p99().is_some(), "p99 must be populated"); } /// AC-2 (positive direction) — on a CUDA-capable host, the decoder /// MUST select `DecoderBackend::Nvdec`. This test cannot run on the /// Mac/Linux dev box (no CUDA-enabled FFmpeg), so it is `#[ignore]`d /// by default and explicitly opt-in via `cargo test -- --ignored` /// on a Jetson Orin Nano with the FFmpeg-cuda packages installed. /// The negative direction (no CUDA → Software) is asserted both in /// `internal::decoder::tests::ffmpeg_decoder_falls_back_to_software_on_macos_dev_host` /// and in `ac1_ac4_software_decode_preserves_throughput_and_monotonicity` /// above; together they pin the selection rule from both sides. #[tokio::test] #[ignore = "AC-2 positive: requires a CUDA-capable FFmpeg (h264_cuvid registered) — only runs on Jetson"] async fn ac2_nvdec_backend_selected_on_cuda_host() { // Arrange + Act let dec = FfmpegDecoder::new(Codec::H264).expect("h264 decoder must open on Jetson"); // Assert assert_eq!( dec.backend(), DecoderBackend::Nvdec, "Jetson Orin Nano with CUDA-enabled FFmpeg MUST select NVDEC" ); } /// AC-3 — a corrupted packet between valid ones must be counted as /// `decode_errors_total += 1` and the stream must keep producing /// frames after it. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ac3_corrupted_frame_is_counted_and_does_not_abort_stream() { // Arrange — generate two synthetic streams, one for "before" and // one for "after"; splice a garbage packet between them. let width = 320u32; let height = 240u32; let mut script: Vec = synth_h264_stream(20, width, height); let after = synth_h264_stream(20, width, height); let pre_count = script.len(); // Corrupted packet: random bytes that are not a valid NAL unit. // The decoder rejects them via `send_packet` (Annex-B start code // missing) or `receive_frame` (parsed as an unsupported NAL // type), either way returning an error from // `FfmpegDecoder::decode`. let garbage = Bytes::from_static(&[ 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34, 0x56, 0x78, ]); script.push(garbage); script.extend(after); let total_packets = script.len(); let transport = ScriptedBytesTransport::new(script); let decoder = FfmpegDecoder::new(Codec::H264).expect("software h264 decoder must open"); let ingest = FrameIngest::with_backoff(total_packets + 16, fast_backoff()); let handle = ingest.handle(); let mut frames = handle.subscribe(); // Act — drain frames until either we've collected enough to know // post-error frames landed, or we time out. let task = ingest.run(transport, decoder, RtspSessionConfig::new("rtsp://fake/0")); let mut received_seqs: Vec = Vec::new(); let deadline = Duration::from_secs(10); let start = tokio::time::Instant::now(); let target_frames = (pre_count + 5).min(35); // pre + a few post while received_seqs.len() < target_frames && start.elapsed() < deadline { match timeout(Duration::from_millis(500), frames.recv()).await { Ok(Ok(f)) => received_seqs.push(f.seq), Ok(Err(_)) => break, Err(_) => { if handle.decode_errors_total() == 0 && handle.frames_decoded_total() == 0 { continue; } if (handle.frames_decoded_total() as usize) == received_seqs.len() { break; } } } } handle.shutdown(); let _ = timeout(Duration::from_secs(2), task).await; // Assert — exactly one decode error (the garbage packet); valid // frames continued to land afterwards. assert_eq!( handle.decode_errors_total(), 1, "one corrupted packet must produce exactly one decode error" ); assert!( received_seqs.len() >= pre_count, "must receive at least the pre-error frames ({pre_count}); got {}", received_seqs.len() ); // Frames sequence is monotonic across the corrupted packet. for w in received_seqs.windows(2) { assert!( w[0] < w[1], "seq must remain strictly monotonic across decode errors: {} → {}", w[0], w[1] ); } }