Files
One-KVM/src/video/pipeline/shared.rs

3090 lines
127 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Universal shared video encoding pipeline
//!
//! Supports multiple codecs: H264, H265, VP8, VP9
//! A single encoder broadcasts to multiple WebRTC sessions.
//!
//! Architecture:
//! ```text
//! V4L2 capture
//! |
//! v
//! SharedVideoPipeline (capture + encode + broadcast)
//! |
//! v
//! ┌────┴────┬────────┬────────┐
//! v v v v
//! Session1 Session2 Session3 ...
//! ```
use bytes::Bytes;
use parking_lot::Mutex as ParkingMutex;
use parking_lot::RwLock as ParkingRwLock;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{debug, error, info, trace, warn};
use super::delivery::{
DeliveryStats, EncodedBatch, EncodedVideoFrameReceiver, FrameMailboxHandle,
VideoFrameReservation, VideoSubscriber,
};
use super::encoder_state::{build_encoder_state, should_parallel_decode_mjpeg, EncoderThreadState};
use super::frame_mailbox::{FrameMailbox, PublishResult};
use super::mjpeg_admission::{MjpegAdmissionPolicy, MjpegAdmissionStage, MjpegDropReason};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
#[path = "dmabuf.rs"]
mod dmabuf;
#[cfg(any(
test,
all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm"))
))]
fn rkmpp_dma_eligible(
backend: Option<EncoderBackend>,
codec: VideoEncoderType,
format: PixelFormat,
) -> bool {
backend == Some(EncoderBackend::Rkmpp)
&& matches!(codec, VideoEncoderType::H264 | VideoEncoderType::H265)
&& matches!(
format,
PixelFormat::Bgr24
| PixelFormat::Nv12
| PixelFormat::Yuyv
| PixelFormat::Rgb24
| PixelFormat::Mjpeg
)
}
#[cfg(test)]
mod dma_selection_tests {
use super::*;
#[test]
fn only_selected_rkmpp_uses_dma() {
for backend in [
EncoderBackend::Software,
EncoderBackend::Vaapi,
EncoderBackend::Nvenc,
EncoderBackend::Qsv,
EncoderBackend::Amf,
EncoderBackend::V4l2m2m,
] {
for codec in [VideoEncoderType::H264, VideoEncoderType::H265] {
for format in [
PixelFormat::Bgr24,
PixelFormat::Nv12,
PixelFormat::Yuyv,
PixelFormat::Rgb24,
PixelFormat::Mjpeg,
] {
assert!(!rkmpp_dma_eligible(Some(backend), codec, format));
}
}
}
assert!(!rkmpp_dma_eligible(
None,
VideoEncoderType::H264,
PixelFormat::Nv12
));
for codec in [VideoEncoderType::H264, VideoEncoderType::H265] {
for format in [
PixelFormat::Bgr24,
PixelFormat::Nv12,
PixelFormat::Yuyv,
PixelFormat::Rgb24,
PixelFormat::Mjpeg,
] {
assert!(rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
codec,
format
));
}
}
for format in [
PixelFormat::Nv16,
PixelFormat::Nv21,
PixelFormat::Nv24,
PixelFormat::Yuv420,
] {
assert!(!rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
VideoEncoderType::H264,
format
));
}
assert!(!rkmpp_dma_eligible(
Some(EncoderBackend::Rkmpp),
VideoEncoderType::VP9,
PixelFormat::Nv12
));
}
}
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
/// After this many consecutive timeouts, log a prominent warning.
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
const CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD: u32 = 3;
/// Throttle repeated encoding errors to avoid log flooding
const ENCODE_ERROR_THROTTLE_SECS: u64 = 5;
static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::video::capture::runtime::{
open_capture_stream, open_capture_stream_for_retry, CaptureOpenResult,
};
use crate::video::capture::status::{
capture_error_log_key, classify_capture_io_error, is_device_lost_message,
signal_status_from_capture_kind, CaptureIoErrorKind,
};
use crate::video::capture::{BridgeContext, CaptureReadError, CaptureStream};
use crate::video::codec::registry::{EncoderBackend, VideoEncoderType};
use crate::video::codec::MjpegToNv12Decoder;
use crate::video::codec::{h264_bitstream, h265_bitstream};
use crate::video::device::parse_bridge_kind;
use crate::video::device::VideoControlMode;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use crate::video::recovery::{wait_for_source_change, CaptureRecoveryPolicy};
use crate::video::signal::SignalStatus;
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
const OUTPUT_BACKPRESSURE_GRACE_NS: u64 = 10_000_000;
fn sustained_output_backpressure(started_ns: u64, now_ns: u64) -> bool {
started_ns != 0 && now_ns.saturating_sub(started_ns - 1) >= OUTPUT_BACKPRESSURE_GRACE_NS
}
type VideoFrameMailbox = FrameMailbox<Arc<VideoFrame>>;
fn spawn_video_worker(
name: &'static str,
worker: impl FnOnce() + Send + 'static,
) -> std::io::Result<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name(name.to_string())
.spawn(worker)
}
struct MjpegDecodeJob {
data: Vec<u8>,
sequence: u64,
}
fn mjpeg_decode_worker_count(available_parallelism: usize) -> usize {
available_parallelism.max(1)
}
fn spawn_mjpeg_decode_workers(
pipeline: &Arc<SharedVideoPipeline>,
frame_mailbox: &Arc<VideoFrameMailbox>,
buffer_pool: &Arc<FrameBufferPool>,
resolution: Resolution,
admission_policy: MjpegAdmissionPolicy,
) -> Vec<SyncSender<MjpegDecodeJob>> {
let available = std::thread::available_parallelism()
.map(|count| count.get())
.unwrap_or(1);
let worker_count = mjpeg_decode_worker_count(available);
let mut senders = Vec::with_capacity(worker_count);
for worker_id in 0..worker_count {
// A rendezvous channel deliberately has no queue. If every decoder is
// busy, capture drops the new compressed frame instead of building up
// latency behind stale frames.
let (tx, rx) = sync_channel::<MjpegDecodeJob>(0);
let worker_pipeline = pipeline.clone();
let worker_frame_mailbox = frame_mailbox.clone();
let worker_buffer_pool = buffer_pool.clone();
let thread_name = format!("mjpeg-decoder-{worker_id}");
let spawn_result = std::thread::Builder::new()
.name(thread_name)
.spawn(move || {
let mut decoder = MjpegToNv12Decoder::new(resolution);
while let Ok(job) = rx.recv() {
if !worker_pipeline.running_flag.load(Ordering::Acquire) {
worker_buffer_pool.put(job.data);
break;
}
if worker_pipeline.drop_pending_mjpeg(
&worker_frame_mailbox,
admission_policy,
MjpegAdmissionStage::Decode,
) {
worker_buffer_pool.put(job.data);
continue;
}
let nv12_size = resolution.width as usize * resolution.height as usize * 3 / 2;
let mut nv12 = worker_buffer_pool.take(nv12_size);
let decode_started = Instant::now();
let decode_result = decoder.decode_into(&job.data, &mut nv12);
worker_buffer_pool.put(job.data);
if let Err(error) = decode_result {
worker_buffer_pool.put(nv12);
warn!("Dropping undecodable MJPEG frame: {}", error);
continue;
}
worker_pipeline.mjpeg_decode_time_ns.fetch_add(
decode_started.elapsed().as_nanos() as u64,
Ordering::Relaxed,
);
worker_pipeline
.mjpeg_decoded_frames
.fetch_add(1, Ordering::Relaxed);
if !worker_pipeline.running_flag.load(Ordering::Acquire) {
worker_pipeline
.decoded_frames_not_encoded
.fetch_add(1, Ordering::Relaxed);
worker_buffer_pool.put(nv12);
break;
}
let frame = Arc::new(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(nv12, Some(worker_buffer_pool.clone()))),
resolution,
PixelFormat::Nv12,
resolution.width,
job.sequence,
));
if worker_frame_mailbox.publish(job.sequence, frame) != PublishResult::Published
{
worker_pipeline
.decoded_frames_not_encoded
.fetch_add(1, Ordering::Relaxed);
}
}
});
match spawn_result {
Ok(_) => senders.push(tx),
Err(error) => error!("Failed to start MJPEG decoder worker: {}", error),
}
}
info!("Started {} parallel MJPEG decoder worker(s)", senders.len());
senders
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
/// Encoded video frame for distribution
#[derive(Debug, Clone)]
pub struct EncodedVideoFrame {
/// Encoded data (Annex B for H264/H265, raw for VP8/VP9)
pub data: Bytes,
/// Presentation timestamp in milliseconds
pub pts_ms: i64,
/// Whether this frame can initialize a decoder without earlier frames.
///
/// For H.264/H.265 this is stricter than the encoder packet flag: the
/// payload must be IDR/IRAP and include all required parameter sets.
pub is_keyframe: bool,
/// Frame sequence number
pub sequence: u64,
/// Frame duration
pub duration: Duration,
/// Codec type
pub codec: VideoEncoderType,
}
enum PipelineCmd {
SetBitrate {
preset: crate::video::codec::BitratePreset,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PipelineStateNotification {
pub state: &'static str,
pub reason: Option<&'static str>,
pub next_retry_ms: Option<u64>,
pub applied_config: Option<PipelineAppliedConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PipelineAppliedConfig {
pub resolution: Resolution,
pub format: PixelFormat,
pub fps: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PipelineLifecycle {
Running,
Stopping,
Stopped,
}
impl PipelineStateNotification {
fn streaming(resolution: Resolution, format: PixelFormat, fps: u32) -> Self {
Self {
state: "streaming",
reason: None,
next_retry_ms: None,
applied_config: Some(PipelineAppliedConfig {
resolution,
format,
fps,
}),
}
}
fn no_signal(status: SignalStatus, next_retry_ms: Option<u64>) -> Self {
Self {
state: "no_signal",
reason: Some(status.as_str()),
next_retry_ms,
applied_config: None,
}
}
}
/// Shared video pipeline configuration
#[derive(Debug, Clone)]
pub struct SharedVideoPipelineConfig {
/// Whether the capture mode is configured by the client or follows HDMI.
pub control_mode: VideoControlMode,
/// Input resolution
pub resolution: Resolution,
/// Input pixel format
pub input_format: PixelFormat,
/// Output codec type
pub output_codec: VideoEncoderType,
/// Bitrate preset (replaces raw bitrate_kbps)
pub bitrate_preset: crate::video::codec::BitratePreset,
/// Target FPS
pub fps: u32,
/// Encoder backend (None = auto select best available)
pub encoder_backend: Option<EncoderBackend>,
}
impl Default for SharedVideoPipelineConfig {
fn default() -> Self {
Self {
control_mode: VideoControlMode::Configurable,
resolution: Resolution::HD720,
input_format: PixelFormat::Yuyv,
output_codec: VideoEncoderType::H264,
bitrate_preset: crate::video::codec::BitratePreset::Balanced,
fps: 30,
encoder_backend: None,
}
}
}
impl SharedVideoPipelineConfig {
/// Keep encoder timing aligned with the negotiated HDMI source on every open.
fn align_source_fps(&mut self, source_fps: Option<f64>) {
if self.control_mode == VideoControlMode::SourceFollowing {
if let Some(fps) = source_fps {
self.fps = fps.round().clamp(1.0, 120.0) as u32;
}
}
}
/// Get effective bitrate in kbps
pub fn bitrate_kbps(&self) -> u32 {
self.bitrate_preset.bitrate_kbps()
}
/// Get effective GOP size
pub fn gop_size(&self) -> u32 {
self.bitrate_preset.gop_size(self.fps)
}
/// Create H264 config with bitrate preset
pub fn h264(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::H264,
bitrate_preset: preset,
..Default::default()
}
}
/// Create H265 config with bitrate preset
pub fn h265(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::H265,
bitrate_preset: preset,
..Default::default()
}
}
/// Create VP8 config with bitrate preset
pub fn vp8(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::VP8,
bitrate_preset: preset,
..Default::default()
}
}
/// Create VP9 config with bitrate preset
pub fn vp9(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::VP9,
bitrate_preset: preset,
..Default::default()
}
}
/// Create config with legacy bitrate_kbps (for compatibility during migration)
pub fn with_bitrate_kbps(mut self, bitrate_kbps: u32) -> Self {
self.bitrate_preset = crate::video::codec::BitratePreset::from_kbps(bitrate_kbps);
self
}
}
fn classify_encode_error(err: &AppError) -> String {
let message = err.to_string();
if message.contains("FFmpeg HW encode failed") {
if message.contains("avcodec_send_packet failed") && message.contains("ret=-11") {
"encode_ffmpeg_hw_send_packet_eagain".to_string()
} else if message.contains("avcodec_send_frame failed") && message.contains("ret=-11") {
"encode_ffmpeg_hw_send_frame_eagain".to_string()
} else if message.contains("avcodec_receive_packet failed") && message.contains("ret=-11") {
"encode_ffmpeg_hw_receive_packet_eagain".to_string()
} else if message.contains("Resource temporarily unavailable") {
"encode_ffmpeg_hw_eagain".to_string()
} else if message.contains("avcodec_send_packet failed") {
"encode_ffmpeg_hw_send_packet".to_string()
} else if message.contains("avcodec_send_frame failed") {
"encode_ffmpeg_hw_send_frame".to_string()
} else if message.contains("avcodec_receive_packet failed") {
"encode_ffmpeg_hw_receive_packet".to_string()
} else {
"encode_ffmpeg_hw".to_string()
}
} else {
format!("encode_{}", message)
}
}
fn log_encoding_error(
throttler: &LogThrottler,
suppressed_errors: &mut HashMap<String, u64>,
err: &AppError,
) {
let key = classify_encode_error(err);
if throttler.should_log(&key) {
let suppressed = suppressed_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!(
"Encoding failed: {} (suppressed {} repeats)",
err, suppressed
);
} else {
error!("Encoding failed: {}", err);
}
} else {
let counter = suppressed_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
}
fn latency_percentile_ms(sorted_time_ns: &[u64], percentile: usize) -> f64 {
if sorted_time_ns.is_empty() {
return 0.0;
}
let index = (sorted_time_ns.len() * percentile.clamp(1, 100)).div_ceil(100) - 1;
sorted_time_ns[index] as f64 / 1_000_000.0
}
fn log_encoder_input_performance(
stats: hwcodec::ffmpeg_ram::encode::EncodeInputStats,
previous: &mut hwcodec::ffmpeg_ram::encode::EncodeInputStats,
) {
let delta = |current: u64, before: u64| current.saturating_sub(before);
let inputs = delta(stats.input_frames, previous.input_frames);
let average_ms = |current: u64, before: u64| {
if inputs == 0 {
0.0
} else {
delta(current, before) as f64 / inputs as f64 / 1000.0
}
};
info!(
"[VideoInputPerf] inputs={} borrowed={} copied={} copied_bytes={} prepare_ms={:.3} send_frame_ms={:.3} receive_packet_ms={:.3} packet_callback_ms={:.3} output_copy_ms={:.3}",
inputs,
delta(stats.borrowed_frames, previous.borrowed_frames),
delta(stats.copied_frames, previous.copied_frames),
delta(stats.copied_bytes, previous.copied_bytes),
average_ms(stats.prepare_us, previous.prepare_us),
average_ms(stats.send_us, previous.send_us),
average_ms(stats.receive_us, previous.receive_us),
average_ms(stats.packet_us, previous.packet_us),
average_ms(stats.output_copy_us, previous.output_copy_us),
);
*previous = stats;
}
fn log_pipeline_performance(
stats: &SharedVideoPipelineStats,
previous: &SharedVideoPipelineStats,
elapsed: f64,
sorted_encode_time_ns: &[u64],
) {
let delta = |current: u64, previous: u64| current.saturating_sub(previous);
let decoded = delta(stats.mjpeg_decoded_frames, previous.mjpeg_decoded_frames);
let encoded_inputs = delta(stats.encoded_input_frames, previous.encoded_input_frames);
let encoded = delta(stats.encoded_frames, previous.encoded_frames);
let sent = delta(stats.sent_frames, previous.sent_frames);
let send_errors = delta(stats.send_errors, previous.send_errors);
let average_ms = |time_ns: u64, count: u64| {
if count == 0 {
0.0
} else {
time_ns as f64 / count as f64 / 1_000_000.0
}
};
info!(
"[VideoPerf] window_s={:.2} decoded_fps={:.2} encoded_fps={:.2} sent_fps={:.2} decode_ms={:.2} encode_ms={:.2} send_ms={:.2} encoded_mbps={:.2} dropped_before_decode={} decoded_unused={} backpressure_events={} slow_subscriber_skips={} send_errors={}",
elapsed,
decoded as f64 / elapsed,
encoded as f64 / elapsed,
sent as f64 / elapsed,
average_ms(delta(stats.mjpeg_decode_time_ns, previous.mjpeg_decode_time_ns), decoded),
average_ms(delta(stats.encode_time_ns, previous.encode_time_ns), encoded_inputs),
average_ms(delta(stats.send_time_ns, previous.send_time_ns), sent + send_errors),
delta(stats.encoded_bytes, previous.encoded_bytes) as f64 * 8.0 / elapsed / 1_000_000.0,
delta(stats.mjpeg_dropped_before_decode, previous.mjpeg_dropped_before_decode),
delta(stats.decoded_frames_not_encoded, previous.decoded_frames_not_encoded),
delta(stats.output_backpressure_events, previous.output_backpressure_events),
delta(stats.encoded_frames_skipped_for_slow_subscribers, previous.encoded_frames_skipped_for_slow_subscribers),
send_errors,
);
info!(
"[VideoQueuePerf] window_s={:.2} input_fps={:.2} frame_wait_ms={:.2} output_wait_ms={:.2} timed_inputs={} encode_p95_ms={:.2} encode_p99_ms={:.2} drop_pending_capture={} drop_pending_decode={} drop_backpressure_capture={} drop_backpressure_decode={} drop_workers_busy={}",
elapsed,
encoded_inputs as f64 / elapsed,
average_ms(delta(stats.encoder_frame_wait_ns, previous.encoder_frame_wait_ns), encoded_inputs),
average_ms(delta(stats.encoder_output_wait_ns, previous.encoder_output_wait_ns), encoded_inputs),
sorted_encode_time_ns.len(),
latency_percentile_ms(sorted_encode_time_ns, 95),
latency_percentile_ms(sorted_encode_time_ns, 99),
delta(stats.mjpeg_dropped_pending_capture, previous.mjpeg_dropped_pending_capture),
delta(stats.mjpeg_dropped_pending_decode, previous.mjpeg_dropped_pending_decode),
delta(stats.mjpeg_dropped_backpressure_capture, previous.mjpeg_dropped_backpressure_capture),
delta(stats.mjpeg_dropped_backpressure_decode, previous.mjpeg_dropped_backpressure_decode),
delta(stats.mjpeg_dropped_workers_busy, previous.mjpeg_dropped_workers_busy),
);
}
/// Pipeline statistics
#[derive(Debug, Clone, Default)]
pub struct SharedVideoPipelineStats {
pub current_fps: f32,
pub mjpeg_decoded_frames: u64,
pub mjpeg_dropped_before_decode: u64,
pub mjpeg_dropped_pending_capture: u64,
pub mjpeg_dropped_pending_decode: u64,
pub mjpeg_dropped_backpressure_capture: u64,
pub mjpeg_dropped_backpressure_decode: u64,
pub mjpeg_dropped_workers_busy: u64,
pub decoded_frames_not_encoded: u64,
pub mjpeg_decode_time_ns: u64,
pub encoded_input_frames: u64,
pub encoded_frames: u64,
pub encoded_bytes: u64,
pub encode_time_ns: u64,
pub encoder_frame_wait_ns: u64,
pub encoder_output_wait_ns: u64,
pub output_backpressure_events: u64,
pub encoded_frames_skipped_for_slow_subscribers: u64,
pub sent_frames: u64,
pub send_time_ns: u64,
pub send_errors: u64,
}
#[derive(Default)]
struct CachedH26xParameterSets {
h264_sps: Option<Vec<u8>>,
h264_pps: Option<Vec<u8>>,
h265_vps: Option<Vec<u8>>,
h265_sps: Option<Vec<u8>>,
h265_pps: Option<Vec<u8>>,
}
/// Universal shared video pipeline
pub struct SharedVideoPipeline {
config: RwLock<SharedVideoPipelineConfig>,
subscribers: ParkingRwLock<Vec<Arc<VideoSubscriber>>>,
stats: ParkingMutex<SharedVideoPipelineStats>,
frame_mailbox: FrameMailboxHandle,
mjpeg_decoded_frames: AtomicU64,
mjpeg_dropped_before_decode: AtomicU64,
mjpeg_dropped_pending_capture: AtomicU64,
mjpeg_dropped_pending_decode: AtomicU64,
mjpeg_dropped_backpressure_capture: AtomicU64,
mjpeg_dropped_backpressure_decode: AtomicU64,
mjpeg_dropped_workers_busy: AtomicU64,
decoded_frames_not_encoded: AtomicU64,
mjpeg_decode_time_ns: AtomicU64,
encoded_input_frames: AtomicU64,
encoded_frames: AtomicU64,
encoded_bytes: AtomicU64,
encode_time_ns: AtomicU64,
encoder_frame_wait_ns: AtomicU64,
encoder_output_wait_ns: AtomicU64,
output_backpressure_events: AtomicU64,
output_backpressured: AtomicBool,
output_backpressure_since_ns: AtomicU64,
encoded_frames_skipped_for_slow_subscribers: AtomicU64,
delivery_stats: Arc<DeliveryStats>,
running: watch::Sender<bool>,
running_rx: watch::Receiver<bool>,
/// Becomes true only after the synchronous encoder worker has exited and
/// dropped its encoder handles.
encoder_done: watch::Sender<bool>,
encoder_done_rx: watch::Receiver<bool>,
h264_profile_level_id: watch::Sender<Option<String>>,
h264_profile_level_id_rx: watch::Receiver<Option<String>>,
cmd_tx: ParkingRwLock<Option<tokio::sync::mpsc::UnboundedSender<PipelineCmd>>>,
/// Fast running flag for blocking capture loop
running_flag: AtomicBool,
/// Frame sequence counter (atomic for lock-free access)
sequence: AtomicU64,
/// Atomic flag for keyframe request (avoids lock contention)
keyframe_requested: AtomicBool,
parameter_sets: ParkingMutex<CachedH26xParameterSets>,
/// Most recent random-access frame with all decoder parameter sets.
bootstrap_frame: ParkingRwLock<Option<Arc<EncodedVideoFrame>>>,
/// Pipeline start time for monotonic PTS calculation (microseconds from process start).
/// Uses AtomicI64 instead of Mutex for lock-free access.
pipeline_start_time_us: AtomicI64,
pending_sync_geometry: ParkingMutex<Option<(Resolution, PixelFormat)>>,
device_lost_reason: ParkingMutex<Option<String>>,
state_notifier: ParkingRwLock<Option<Arc<dyn Fn(PipelineStateNotification) + Send + Sync>>>,
last_state_notification: ParkingMutex<Option<PipelineStateNotification>>,
}
impl SharedVideoPipeline {
/// Create a new shared video pipeline
pub fn new(config: SharedVideoPipelineConfig) -> Result<Arc<Self>> {
info!(
"Creating shared video pipeline: {} {}x{} @ {} (input: {})",
config.output_codec,
config.resolution.width,
config.resolution.height,
config.bitrate_preset,
config.input_format
);
let (running_tx, running_rx) = watch::channel(false);
let (encoder_done_tx, encoder_done_rx) = watch::channel(true);
let (h264_profile_tx, h264_profile_rx) = watch::channel(None);
let pipeline = Arc::new(Self {
config: RwLock::new(config),
subscribers: ParkingRwLock::new(Vec::new()),
stats: ParkingMutex::new(SharedVideoPipelineStats::default()),
frame_mailbox: Arc::new(ParkingMutex::new(None)),
mjpeg_decoded_frames: AtomicU64::new(0),
mjpeg_dropped_before_decode: AtomicU64::new(0),
mjpeg_dropped_pending_capture: AtomicU64::new(0),
mjpeg_dropped_pending_decode: AtomicU64::new(0),
mjpeg_dropped_backpressure_capture: AtomicU64::new(0),
mjpeg_dropped_backpressure_decode: AtomicU64::new(0),
mjpeg_dropped_workers_busy: AtomicU64::new(0),
decoded_frames_not_encoded: AtomicU64::new(0),
mjpeg_decode_time_ns: AtomicU64::new(0),
encoded_input_frames: AtomicU64::new(0),
encoded_frames: AtomicU64::new(0),
encoded_bytes: AtomicU64::new(0),
encode_time_ns: AtomicU64::new(0),
encoder_frame_wait_ns: AtomicU64::new(0),
encoder_output_wait_ns: AtomicU64::new(0),
output_backpressure_events: AtomicU64::new(0),
output_backpressured: AtomicBool::new(false),
output_backpressure_since_ns: AtomicU64::new(0),
encoded_frames_skipped_for_slow_subscribers: AtomicU64::new(0),
delivery_stats: Arc::new(DeliveryStats::default()),
running: running_tx,
running_rx,
encoder_done: encoder_done_tx,
encoder_done_rx,
h264_profile_level_id: h264_profile_tx,
h264_profile_level_id_rx: h264_profile_rx,
cmd_tx: ParkingRwLock::new(None),
running_flag: AtomicBool::new(false),
sequence: AtomicU64::new(0),
keyframe_requested: AtomicBool::new(false),
parameter_sets: ParkingMutex::new(CachedH26xParameterSets::default()),
bootstrap_frame: ParkingRwLock::new(None),
pipeline_start_time_us: AtomicI64::new(0),
pending_sync_geometry: ParkingMutex::new(None),
device_lost_reason: ParkingMutex::new(None),
state_notifier: ParkingRwLock::new(None),
last_state_notification: ParkingMutex::new(None),
});
Ok(pipeline)
}
pub fn take_pending_sync_geometry(&self) -> Option<(Resolution, PixelFormat)> {
self.pending_sync_geometry.lock().take()
}
pub fn take_device_lost_reason(&self) -> Option<String> {
self.device_lost_reason.lock().take()
}
fn mark_device_lost(&self, reason: String) {
*self.device_lost_reason.lock() = Some(reason);
}
pub fn set_state_notifier(
&self,
notifier: Option<Arc<dyn Fn(PipelineStateNotification) + Send + Sync>>,
) {
*self.state_notifier.write() = notifier;
}
fn notify_state(&self, notification: PipelineStateNotification) {
let should_emit = {
let mut last = self.last_state_notification.lock();
if last.as_ref() == Some(&notification) {
false
} else {
*last = Some(notification);
true
}
};
if !should_emit {
return;
}
tracing::debug!(
"Pipeline state notification: state={}, reason={:?}",
notification.state,
notification.reason
);
if let Some(notifier) = self.state_notifier.read().clone() {
notifier(notification);
}
}
/// Subscribe to encoded frames
pub fn subscribe(&self) -> EncodedVideoFrameReceiver {
let (tx, rx) = mpsc::channel(1);
if let Some(frame) = self.bootstrap_frame.read().clone() {
let batch: EncodedBatch = vec![frame].into();
let _ = tx.try_send(batch);
}
self.subscribers.write().push(Arc::new(VideoSubscriber {
sender: tx,
needs_keyframe: AtomicBool::new(true),
recovery_requested: AtomicBool::new(false),
}));
if let Some(mailbox) = self.frame_mailbox.lock().as_ref() {
mailbox.notify();
}
EncodedVideoFrameReceiver::new(rx, self.frame_mailbox.clone(), self.delivery_stats.clone())
}
/// Get subscriber count
pub fn subscriber_count(&self) -> usize {
self.subscribers
.read()
.iter()
.filter(|subscriber| !subscriber.sender.is_closed())
.count()
}
/// Request encoder to produce a keyframe on next encode
///
/// This is useful when a new client connects and needs an immediate
/// keyframe to start decoding the video stream.
///
/// Uses an atomic flag to avoid lock contention with the encoding loop.
pub async fn request_keyframe(&self) {
self.keyframe_requested.store(true, Ordering::Release);
info!("[Pipeline] Keyframe requested for new client");
}
fn send_cmd(&self, cmd: PipelineCmd) {
let tx = self.cmd_tx.read().clone();
if let Some(tx) = tx {
let _ = tx.send(cmd);
}
}
fn clear_cmd_tx(&self) {
let mut guard = self.cmd_tx.write();
*guard = None;
}
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
match cmd {
PipelineCmd::SetBitrate { preset } => {
let bitrate_kbps = preset.bitrate_kbps();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if state.ffmpeg_hw_enabled {
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
pipeline
.reconfigure(bitrate_kbps as i32, preset.gop_size(state.fps) as i32)
.map_err(|e| {
let detail = if e.is_empty() {
ffmpeg_hw_last_error()
} else {
e
};
AppError::VideoError(format!(
"FFmpeg HW reconfigure failed: {}",
detail
))
})?;
return Ok(());
}
}
if let Some(ref mut encoder) = state.encoder {
encoder.set_bitrate(bitrate_kbps)?;
}
}
}
Ok(())
}
/// Get current stats
pub async fn stats(&self) -> SharedVideoPipelineStats {
self.stats_snapshot()
}
fn stats_snapshot(&self) -> SharedVideoPipelineStats {
let mut stats = self.stats.lock().clone();
stats.mjpeg_decoded_frames = self.mjpeg_decoded_frames.load(Ordering::Relaxed);
stats.mjpeg_dropped_before_decode =
self.mjpeg_dropped_before_decode.load(Ordering::Relaxed);
stats.mjpeg_dropped_pending_capture =
self.mjpeg_dropped_pending_capture.load(Ordering::Relaxed);
stats.mjpeg_dropped_pending_decode =
self.mjpeg_dropped_pending_decode.load(Ordering::Relaxed);
stats.mjpeg_dropped_backpressure_capture = self
.mjpeg_dropped_backpressure_capture
.load(Ordering::Relaxed);
stats.mjpeg_dropped_backpressure_decode = self
.mjpeg_dropped_backpressure_decode
.load(Ordering::Relaxed);
stats.mjpeg_dropped_workers_busy = self.mjpeg_dropped_workers_busy.load(Ordering::Relaxed);
stats.decoded_frames_not_encoded = self.decoded_frames_not_encoded.load(Ordering::Relaxed);
stats.mjpeg_decode_time_ns = self.mjpeg_decode_time_ns.load(Ordering::Relaxed);
stats.encoded_input_frames = self.encoded_input_frames.load(Ordering::Relaxed);
stats.encoded_frames = self.encoded_frames.load(Ordering::Relaxed);
stats.encoded_bytes = self.encoded_bytes.load(Ordering::Relaxed);
stats.encode_time_ns = self.encode_time_ns.load(Ordering::Relaxed);
stats.encoder_frame_wait_ns = self.encoder_frame_wait_ns.load(Ordering::Relaxed);
stats.encoder_output_wait_ns = self.encoder_output_wait_ns.load(Ordering::Relaxed);
stats.output_backpressure_events = self.output_backpressure_events.load(Ordering::Relaxed);
stats.encoded_frames_skipped_for_slow_subscribers = self
.encoded_frames_skipped_for_slow_subscribers
.load(Ordering::Relaxed);
stats.sent_frames = self.delivery_stats.sent_frames.load(Ordering::Relaxed);
stats.send_time_ns = self.delivery_stats.send_time_ns.load(Ordering::Relaxed);
stats.send_errors = self.delivery_stats.send_errors.load(Ordering::Relaxed);
stats
}
fn record_mjpeg_drop(&self, reason: MjpegDropReason) {
let counter = match reason {
MjpegDropReason::PendingCapture => &self.mjpeg_dropped_pending_capture,
MjpegDropReason::PendingDecode => &self.mjpeg_dropped_pending_decode,
MjpegDropReason::BackpressureCapture => &self.mjpeg_dropped_backpressure_capture,
MjpegDropReason::BackpressureDecode => &self.mjpeg_dropped_backpressure_decode,
MjpegDropReason::WorkersBusy => &self.mjpeg_dropped_workers_busy,
};
counter.fetch_add(1, Ordering::Relaxed);
self.mjpeg_dropped_before_decode
.fetch_add(1, Ordering::Relaxed);
}
fn drop_pending_mjpeg(
&self,
mailbox: &VideoFrameMailbox,
policy: MjpegAdmissionPolicy,
stage: MjpegAdmissionStage,
) -> bool {
let backpressured = self.output_backpressured.load(Ordering::Acquire);
if backpressured && policy == MjpegAdmissionPolicy::BoundedPrefetch {
let started = self.output_backpressure_since_ns.load(Ordering::Acquire);
let now = PROCESS_START.get_or_init(Instant::now).elapsed().as_nanos() as u64;
if !sustained_output_backpressure(started, now) {
return false;
}
}
if !policy.check_pending(backpressured) {
return false;
}
if let Some(reason) = policy.drop_reason(mailbox.has_pending(), backpressured, stage) {
self.record_mjpeg_drop(reason);
true
} else {
false
}
}
/// Check if running
pub fn is_running(&self) -> bool {
*self.running_rx.borrow()
}
/// Lifecycle state derived from the stop-request flag and the capture
/// thread's completion signal. A stopping pipeline must never receive a
/// new subscriber or be replaced before it releases the V4L2 device.
pub fn lifecycle(&self) -> PipelineLifecycle {
if self.running_flag.load(Ordering::Acquire) {
PipelineLifecycle::Running
} else if *self.running_rx.borrow() {
PipelineLifecycle::Stopping
} else {
PipelineLifecycle::Stopped
}
}
/// Subscribe to running state changes
///
/// Returns a watch receiver that can be used to detect when the pipeline stops.
/// This is useful for auto-cleanup when the pipeline auto-stops due to no subscribers.
pub fn running_watch(&self) -> watch::Receiver<bool> {
self.running_rx.clone()
}
pub fn h264_profile_level_id_watch(&self) -> watch::Receiver<Option<String>> {
self.h264_profile_level_id_rx.clone()
}
fn update_h264_profile_level_id(&self, sps: &[u8]) {
let Some(profile_level_id) = h264_bitstream::parse_profile_level_id_from_sps(sps) else {
return;
};
if self.h264_profile_level_id.borrow().as_deref() == Some(profile_level_id.as_str()) {
return;
}
let _ = self.h264_profile_level_id.send(Some(profile_level_id));
}
fn inspect_and_parameterize_packet(
&self,
codec: VideoEncoderType,
data: Bytes,
ffmpeg_keyframe: bool,
) -> (Bytes, bool) {
match codec {
VideoEncoderType::H264 => {
let was_annex_b = h264_bitstream::is_annex_b(data.as_ref());
let data = h264_bitstream::normalize_annex_b(data);
if !was_annex_b && h264_bitstream::is_annex_b(data.as_ref()) {
debug!("[Pipeline] Converted length-prefixed H264 packet to Annex-B");
}
let inspection = h264_bitstream::inspect_annex_b(data.as_ref());
let sps = inspection.sps;
let pps = inspection.pps;
// Require metadata and payload to agree before advertising a
// decoder bootstrap frame.
let is_idr = ffmpeg_keyframe && inspection.is_idr;
let mut cache = self.parameter_sets.lock();
if let Some(sps) = sps {
self.update_h264_profile_level_id(sps);
cache.h264_sps = Some(sps.to_vec());
}
if let Some(pps) = pps {
cache.h264_pps = Some(pps.to_vec());
}
if !is_idr {
return (data, false);
}
if sps.is_some() && pps.is_some() {
return (data, true);
}
match (&cache.h264_sps, &cache.h264_pps) {
(Some(cached_sps), Some(cached_pps)) => {
let mut output = Vec::with_capacity(
data.len() + cached_sps.len() + cached_pps.len() + 8,
);
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(cached_sps);
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(cached_pps);
output.extend_from_slice(data.as_ref());
debug!("[Pipeline] Prepended cached SPS/PPS to H264 IDR");
(Bytes::from(output), true)
}
// An IDR without SPS/PPS is not a decoder bootstrap frame.
_ => (data, false),
}
}
VideoEncoderType::H265 => {
let (vps, sps, pps) = h265_bitstream::extract_vps_sps_pps(data.as_ref());
let is_irap = ffmpeg_keyframe && h265_bitstream::is_keyframe(data.as_ref());
let mut cache = self.parameter_sets.lock();
if let Some(vps) = vps.as_ref() {
cache.h265_vps = Some(vps.clone());
}
if let Some(sps) = sps.as_ref() {
cache.h265_sps = Some(sps.clone());
}
if let Some(pps) = pps.as_ref() {
cache.h265_pps = Some(pps.clone());
}
if !is_irap {
return (data, false);
}
if vps.is_some() && sps.is_some() && pps.is_some() {
return (data, true);
}
match (&cache.h265_vps, &cache.h265_sps, &cache.h265_pps) {
(Some(cached_vps), Some(cached_sps), Some(cached_pps)) => {
let mut output = Vec::with_capacity(
data.len()
+ cached_vps.len()
+ cached_sps.len()
+ cached_pps.len()
+ 12,
);
for parameter_set in [cached_vps, cached_sps, cached_pps] {
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(parameter_set);
}
output.extend_from_slice(data.as_ref());
debug!("[Pipeline] Prepended cached VPS/SPS/PPS to H265 IRAP");
(Bytes::from(output), true)
}
_ => (data, false),
}
}
_ => (data, ffmpeg_keyframe),
}
}
fn reserve_outputs(&self) -> Option<Vec<VideoFrameReservation>> {
let subscribers = self.subscribers.read();
let mut active_subscribers = 0;
let mut reservations = Vec::with_capacity(subscribers.len());
for subscriber in subscribers.iter() {
if subscriber.sender.is_closed() {
continue;
}
active_subscribers += 1;
if let Ok(permit) = subscriber.sender.clone().try_reserve_owned() {
if subscriber.needs_keyframe.load(Ordering::Acquire)
&& !subscriber.recovery_requested.swap(true, Ordering::AcqRel)
{
self.keyframe_requested.store(true, Ordering::Release);
}
reservations.push(VideoFrameReservation {
subscriber: subscriber.clone(),
permit,
});
}
}
if active_subscribers > 0 && reservations.is_empty() {
if !self.output_backpressured.load(Ordering::Acquire) {
let now = PROCESS_START.get_or_init(Instant::now).elapsed().as_nanos() as u64;
self.output_backpressure_since_ns
.store(now.saturating_add(1), Ordering::Release);
}
if !self.output_backpressured.swap(true, Ordering::AcqRel) {
self.output_backpressure_events
.fetch_add(1, Ordering::Relaxed);
}
None
} else {
self.output_backpressured.store(false, Ordering::Release);
self.output_backpressure_since_ns
.store(0, Ordering::Release);
Some(reservations)
}
}
fn broadcast_encoded_batch(
&self,
frames: EncodedBatch,
reservations: Vec<VideoFrameReservation>,
) {
if frames.is_empty() {
return;
}
self.encoded_frames
.fetch_add(frames.len() as u64, Ordering::Relaxed);
self.encoded_bytes.fetch_add(
frames.iter().map(|frame| frame.data.len() as u64).sum(),
Ordering::Relaxed,
);
if let Some(frame) = frames.iter().rev().find(|frame| frame.is_keyframe) {
*self.bootstrap_frame.write() = Some(frame.clone());
}
{
let mut subscribers = self.subscribers.write();
subscribers.retain(|subscriber| !subscriber.sender.is_closed());
for subscriber in subscribers.iter() {
if !reservations
.iter()
.any(|reservation| Arc::ptr_eq(&reservation.subscriber, subscriber))
{
subscriber.needs_keyframe.store(true, Ordering::Release);
subscriber
.recovery_requested
.store(false, Ordering::Release);
self.encoded_frames_skipped_for_slow_subscribers
.fetch_add(frames.len() as u64, Ordering::Relaxed);
}
}
}
for reservation in reservations {
let batch = if reservation
.subscriber
.needs_keyframe
.load(Ordering::Acquire)
{
let Some(first_keyframe) = frames.iter().position(|frame| frame.is_keyframe) else {
self.encoded_frames_skipped_for_slow_subscribers
.fetch_add(frames.len() as u64, Ordering::Relaxed);
continue;
};
reservation
.subscriber
.needs_keyframe
.store(false, Ordering::Release);
reservation
.subscriber
.recovery_requested
.store(false, Ordering::Release);
self.encoded_frames_skipped_for_slow_subscribers
.fetch_add(first_keyframe as u64, Ordering::Relaxed);
if first_keyframe == 0 {
frames.clone()
} else {
frames[first_keyframe..].to_vec().into()
}
} else {
frames.clone()
};
reservation.permit.send(batch);
}
}
/// Start the pipeline by owning capture + encode in a single loop.
///
/// Capture and encode stay tightly coupled to avoid maintaining separate
/// raw-frame fan-out and direct-device execution paths.
pub async fn start_with_device(
self: &Arc<Self>,
device_path: std::path::PathBuf,
buffer_count: u32,
_jpeg_quality: u8,
subdev_path: Option<std::path::PathBuf>,
bridge_kind: Option<String>,
) -> Result<()> {
if *self.running_rx.borrow() {
warn!("Pipeline already running");
return Ok(());
}
*self.parameter_sets.lock() = CachedH26xParameterSets::default();
*self.bootstrap_frame.write() = None;
let mut config = self.config.read().await.clone();
let parallel_mjpeg_decode = should_parallel_decode_mjpeg(&config);
{
let mut last = self.last_state_notification.lock();
*last = None;
}
// Pre-open for DV negotiation; align encoder to probed size.
let bridge_ctx_probe = BridgeContext::from_parts(
subdev_path.clone(),
parse_bridge_kind(bridge_kind.as_deref()),
);
#[allow(unused_mut)]
let mut preopened: Option<CaptureStream> = match open_capture_stream(
&device_path,
config.resolution,
config.input_format,
config.fps,
buffer_count.max(1),
Duration::from_secs(2),
bridge_ctx_probe,
config.control_mode,
) {
Ok(s) => {
let negotiated_res = s.resolution();
let negotiated_fmt = s.format();
let previous = (config.resolution, config.input_format, config.fps);
config.align_source_fps(s.source_fps());
config.resolution = negotiated_res;
config.input_format = negotiated_fmt;
if previous != (config.resolution, config.input_format, config.fps) {
info!(
"Negotiated capture {}x{} {:?} @ {} fps (configured {}x{} {:?} @ {} fps) — aligning encoder to source",
negotiated_res.width,
negotiated_res.height,
negotiated_fmt,
config.fps,
previous.0.width,
previous.0.height,
previous.1,
previous.2,
);
}
*self.config.write().await = config.clone();
Some(s)
}
Err(AppError::CaptureNoSignal { kind }) => {
debug!(
"Pre-probe: no signal — encoder uses configured geometry until capture opens"
);
let status = signal_status_from_capture_kind(&kind);
self.notify_state(PipelineStateNotification::no_signal(
status,
Some(
CaptureRecoveryPolicy::new(config.control_mode)
.retry_delay(1)
.as_millis() as u64,
),
));
None
}
Err(e) => return Err(e),
};
#[cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "arm")))]
if dmabuf::eligible(&config) {
if let Some(stream) = preopened.as_ref().filter(|s| s.supports_rkmpp_dmabuf()) {
match dmabuf::prepare(stream, &config) {
Ok(encoder) => {
return dmabuf::start(
self.clone(),
preopened.take().expect("preopened DMA capture"),
encoder,
config,
device_path,
buffer_count,
BridgeContext::from_parts(
subdev_path,
parse_bridge_kind(bridge_kind.as_deref()),
),
);
}
Err(error) => warn!(
"RKMPP DMA unavailable; using existing copy pipeline: {}",
error
),
}
}
}
let mut encoder_config = config.clone();
let admission_policy = MjpegAdmissionPolicy::from_prefetch_setting(
std::env::var("ONE_KVM_MJPEG_PREFETCH").ok().as_deref(),
);
if parallel_mjpeg_decode {
encoder_config.input_format = PixelFormat::Nv12;
info!("Using parallel libyuv MJPEG decode with hardware encoding");
info!("MJPEG admission policy: {:?}", admission_policy);
}
let mut encoder_state = build_encoder_state(&encoder_config)?;
let frame_mailbox = Arc::new(VideoFrameMailbox::new());
*self.frame_mailbox.lock() = Some(frame_mailbox.clone());
self.mjpeg_decoded_frames.store(0, Ordering::Relaxed);
self.mjpeg_dropped_before_decode.store(0, Ordering::Relaxed);
self.mjpeg_dropped_pending_capture
.store(0, Ordering::Relaxed);
self.mjpeg_dropped_pending_decode
.store(0, Ordering::Relaxed);
self.mjpeg_dropped_backpressure_capture
.store(0, Ordering::Relaxed);
self.mjpeg_dropped_backpressure_decode
.store(0, Ordering::Relaxed);
self.mjpeg_dropped_workers_busy.store(0, Ordering::Relaxed);
self.decoded_frames_not_encoded.store(0, Ordering::Relaxed);
self.mjpeg_decode_time_ns.store(0, Ordering::Relaxed);
self.encoded_input_frames.store(0, Ordering::Relaxed);
self.encoded_frames.store(0, Ordering::Relaxed);
self.encoded_bytes.store(0, Ordering::Relaxed);
self.encode_time_ns.store(0, Ordering::Relaxed);
self.encoder_frame_wait_ns.store(0, Ordering::Relaxed);
self.encoder_output_wait_ns.store(0, Ordering::Relaxed);
self.output_backpressure_events.store(0, Ordering::Relaxed);
self.output_backpressured.store(false, Ordering::Release);
self.output_backpressure_since_ns
.store(0, Ordering::Release);
self.encoded_frames_skipped_for_slow_subscribers
.store(0, Ordering::Relaxed);
self.delivery_stats.sent_frames.store(0, Ordering::Relaxed);
self.delivery_stats.send_time_ns.store(0, Ordering::Relaxed);
self.delivery_stats.send_errors.store(0, Ordering::Relaxed);
for subscriber in self.subscribers.read().iter() {
subscriber.needs_keyframe.store(true, Ordering::Release);
subscriber
.recovery_requested
.store(false, Ordering::Release);
}
let _ = self.running.send(true);
let _ = self.encoder_done.send(false);
self.running_flag.store(true, Ordering::Release);
let pipeline = self.clone();
let buffer_pool = Arc::new(FrameBufferPool::new(buffer_count.max(4) as usize));
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
{
let mut guard = self.cmd_tx.write();
*guard = Some(cmd_tx);
}
// Encoder loop uses a dedicated OS thread because FFmpeg work is synchronous.
{
let pipeline = pipeline.clone();
let frame_mailbox = frame_mailbox.clone();
let encoder_worker = spawn_video_worker("video-encoder", move || {
let mut input_frame_count: u64 = 0;
let mut encoded_frame_count: u64 = 0;
let mut last_fps_time = Instant::now();
let mut fps_frame_count: u64 = 0;
let perf_logging = std::env::var("ONE_KVM_VIDEO_STATS").as_deref() == Ok("1");
let mut last_perf_time = Instant::now();
let mut last_perf_stats = pipeline.stats_snapshot();
let mut last_input_stats = Default::default();
let mut encode_latencies = Vec::with_capacity(if perf_logging { 256 } else { 0 });
let encode_error_throttler = LogThrottler::with_secs(ENCODE_ERROR_THROTTLE_SECS);
let mut suppressed_encode_errors: HashMap<String, u64> = HashMap::new();
while pipeline.running_flag.load(Ordering::Acquire) {
let Some((frame, reservations, waits)) = frame_mailbox
.receive_when_timed(|| pipeline.reserve_outputs(), perf_logging)
else {
break;
};
if !pipeline.running_flag.load(Ordering::Acquire) {
break;
}
if reservations.is_empty() {
if parallel_mjpeg_decode {
pipeline
.decoded_frames_not_encoded
.fetch_add(1, Ordering::Relaxed);
}
continue;
}
if perf_logging {
pipeline
.encoder_frame_wait_ns
.fetch_add(waits.frame.as_nanos() as u64, Ordering::Relaxed);
pipeline
.encoder_output_wait_ns
.fetch_add(waits.output.as_nanos() as u64, Ordering::Relaxed);
}
while let Ok(cmd) = cmd_rx.try_recv() {
if let Err(e) = pipeline.apply_cmd(&mut encoder_state, cmd) {
error!("Failed to apply pipeline command: {}", e);
}
}
input_frame_count = input_frame_count.wrapping_add(1);
pipeline
.encoded_input_frames
.fetch_add(1, Ordering::Relaxed);
let encode_started = Instant::now();
let encode_result = pipeline.encode_frame_sync(&mut encoder_state, &frame);
let encode_elapsed_ns = encode_started.elapsed().as_nanos() as u64;
pipeline
.encode_time_ns
.fetch_add(encode_elapsed_ns, Ordering::Relaxed);
if perf_logging {
encode_latencies.push(encode_elapsed_ns);
}
match encode_result {
Ok(encoded_frames) => {
encoded_frame_count =
encoded_frame_count.wrapping_add(encoded_frames.len() as u64);
fps_frame_count += encoded_frames.len() as u64;
let frames: EncodedBatch = encoded_frames
.into_iter()
.map(Arc::new)
.collect::<Vec<_>>()
.into();
pipeline.broadcast_encoded_batch(frames, reservations);
}
Err(e) => {
log_encoding_error(
&encode_error_throttler,
&mut suppressed_encode_errors,
&e,
);
}
}
let fps_elapsed = last_fps_time.elapsed();
if fps_elapsed >= Duration::from_secs(1) {
let current_fps = fps_frame_count as f32 / fps_elapsed.as_secs_f32();
fps_frame_count = 0;
last_fps_time = Instant::now();
pipeline.stats.lock().current_fps = current_fps;
trace!(
"Shared pipeline processed {} input frames, emitted {} encoded frames; MJPEG decoded={}, dropped before decode={}, decoded but not encoded={}",
input_frame_count,
encoded_frame_count,
pipeline.mjpeg_decoded_frames.load(Ordering::Relaxed),
pipeline.mjpeg_dropped_before_decode.load(Ordering::Relaxed),
pipeline.decoded_frames_not_encoded.load(Ordering::Relaxed),
);
}
if perf_logging && last_perf_time.elapsed() >= Duration::from_secs(5) {
let elapsed = last_perf_time.elapsed().as_secs_f64();
let stats = pipeline.stats_snapshot();
encode_latencies.sort_unstable();
log_pipeline_performance(
&stats,
&last_perf_stats,
elapsed,
&encode_latencies,
);
if let Some(stats) = encoder_state
.encoder
.as_ref()
.and_then(|encoder| encoder.input_stats())
{
log_encoder_input_performance(stats, &mut last_input_stats);
}
encode_latencies.clear();
last_perf_stats = stats;
last_perf_time = Instant::now();
}
}
pipeline.clear_cmd_tx();
// Release encoder resources before allowing a replacement pipeline.
drop(encoder_state);
let _ = pipeline.encoder_done.send(true);
});
if let Err(error) = encoder_worker {
self.stop();
let _ = self.running.send(false);
let _ = self.encoder_done.send(true);
return Err(AppError::VideoError(format!(
"Failed to start encoder worker: {error}"
)));
}
}
// Capture loop (runs on thread, updates latest frame)
{
let pipeline = pipeline.clone();
let frame_mailbox = frame_mailbox.clone();
let buffer_pool = buffer_pool.clone();
let bridge_ctx =
BridgeContext::from_parts(subdev_path, parse_bridge_kind(bridge_kind.as_deref()));
let capture_worker = spawn_video_worker("video-capture", move || {
let mut stream: Option<CaptureStream> = None;
let mut initial_geometry: Option<(Resolution, PixelFormat)> = None;
let mut resolution = config.resolution;
let mut pixel_format = config.input_format;
let mut active_fps = config.fps;
let mut stride: u32 = 0;
let mut mjpeg_decode_senders = parallel_mjpeg_decode
.then(|| {
spawn_mjpeg_decode_workers(
&pipeline,
&frame_mailbox,
&buffer_pool,
config.resolution,
admission_policy,
)
})
.filter(|senders| !senders.is_empty());
let mut next_mjpeg_decoder = 0usize;
let mut mjpeg_decoder = parallel_mjpeg_decode
.then(|| MjpegToNv12Decoder::new(config.resolution))
.filter(|_| {
mjpeg_decode_senders
.as_ref()
.is_none_or(|senders| senders.is_empty())
});
if let Some(s) = preopened {
resolution = s.resolution();
pixel_format = s.format();
active_fps = s
.source_fps()
.map(|fps| fps.round().clamp(1.0, 120.0) as u32)
.unwrap_or(config.fps);
stride = s.stride();
initial_geometry = Some((resolution, pixel_format));
stream = Some(s);
}
fn open_or_retry(
device_path: &std::path::Path,
config: &SharedVideoPipelineConfig,
buffer_count: u32,
bridge_ctx: BridgeContext,
) -> CaptureOpenResult {
match open_capture_stream_for_retry(
device_path,
config.resolution,
config.input_format,
config.fps,
buffer_count.max(1),
Duration::from_secs(2),
bridge_ctx,
config.control_mode,
is_device_lost_message,
) {
CaptureOpenResult::NoSignal(status) => {
debug!("Capture soft-restart: still no signal ({:?})", status);
CaptureOpenResult::NoSignal(status)
}
CaptureOpenResult::DeviceLost(reason) => {
error!("Capture device lost during soft-restart: {}", reason);
CaptureOpenResult::DeviceLost(reason)
}
CaptureOpenResult::Fatal => {
error!("Capture soft-restart failed");
CaptureOpenResult::Fatal
}
opened => opened,
}
}
let mut no_subscribers_since: Option<Instant> = None;
let mut capture_sequence = 0u64;
let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS);
let mut consecutive_timeouts: u32 = 0;
let recovery_policy = CaptureRecoveryPolicy::new(config.control_mode);
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
while pipeline.running_flag.load(Ordering::Acquire) {
let subscriber_count = pipeline.subscriber_count();
if subscriber_count == 0 {
if no_subscribers_since.is_none() {
no_subscribers_since = Some(Instant::now());
trace!("No subscribers, starting grace period timer");
}
if let Some(since) = no_subscribers_since {
if since.elapsed() >= grace_period {
info!(
"No subscribers for {}s, auto-stopping video pipeline",
grace_period.as_secs()
);
pipeline.running_flag.store(false, Ordering::Release);
break;
}
}
std::thread::sleep(Duration::from_millis(5));
continue;
} else if no_subscribers_since.is_some() {
trace!("Subscriber connected, resetting grace period timer");
no_subscribers_since = None;
}
// ── No usable stream? Try to (re)open, back off on failure. ──
if stream.is_none() {
match open_or_retry(&device_path, &config, buffer_count, bridge_ctx.clone())
{
CaptureOpenResult::Opened(new_stream) => {
let new_res = new_stream.resolution();
let new_fmt = new_stream.format();
let new_stride = new_stream.stride();
let new_fps = new_stream
.source_fps()
.map(|fps| fps.round().clamp(1.0, 120.0) as u32)
.unwrap_or(config.fps);
// Pre-probe was skipped (no signal at pipeline start) but the
// encoder was sized to saved settings — if DV timings now
// disagree, we cannot encode until WebRTC resyncs dimensions.
if initial_geometry.is_none()
&& (new_res != config.resolution
|| new_fmt != config.input_format)
{
info!(
"Deferred capture open is {}x{} {:?} but encoder expects {}x{} {:?} — stopping for dimension resync",
new_res.width,
new_res.height,
new_fmt,
config.resolution.width,
config.resolution.height,
config.input_format
);
pipeline.notify_state(PipelineStateNotification::no_signal(
SignalStatus::NoSignal,
Some(recovery_policy.retry_delay(1).as_millis() as u64),
));
*pipeline.pending_sync_geometry.lock() =
Some((new_res, new_fmt));
pipeline.running_flag.store(false, Ordering::Release);
break;
}
// If this is the very first successful open,
// record it and run normally. Otherwise check
// for a geometry change — the encoder thread
// is pinned to the original geometry, so a
// change requires tearing the pipeline down
// and letting the upper layer rebuild.
match initial_geometry {
Some((orig_res, orig_fmt))
if orig_res != new_res || orig_fmt != new_fmt =>
{
info!(
"Capture soft-restart detected geometry change \
{:?}/{:?} -> {:?}/{:?}, stopping pipeline for \
encoder rebuild",
orig_res, orig_fmt, new_res, new_fmt
);
pipeline.notify_state(
PipelineStateNotification::no_signal(
SignalStatus::NoSignal,
Some(recovery_policy.retry_delay(1).as_millis()
as u64),
),
);
*pipeline.pending_sync_geometry.lock() =
Some((new_res, new_fmt));
pipeline.running_flag.store(false, Ordering::Release);
break;
}
_ => {}
}
if initial_geometry.is_none() {
initial_geometry = Some((new_res, new_fmt));
}
resolution = new_res;
pixel_format = new_fmt;
active_fps = new_fps;
stride = new_stride;
stream = Some(new_stream);
consecutive_timeouts = 0;
info!(
"Capture stream (re)opened: {}x{} {:?} stride={}",
resolution.width, resolution.height, pixel_format, stride
);
}
CaptureOpenResult::NoSignal(status) => {
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
if !recovery_policy.should_retry(consecutive_timeouts) {
warn!(
"Capture soft-restart gave up after {} attempts, \
stopping pipeline",
consecutive_timeouts
);
pipeline.running_flag.store(false, Ordering::Release);
break;
}
let delay = recovery_policy.retry_delay(consecutive_timeouts);
pipeline.notify_state(PipelineStateNotification::no_signal(
status,
Some(delay.as_millis() as u64),
));
if wait_for_source_change(&bridge_ctx, delay, || {
pipeline.running_flag.load(Ordering::Acquire)
}) {
info!("SOURCE_CHANGE woke capture retry");
}
continue;
}
CaptureOpenResult::DeviceLost(reason) => {
pipeline.mark_device_lost(reason);
pipeline.running_flag.store(false, Ordering::Release);
break;
}
CaptureOpenResult::Fatal => {
pipeline.running_flag.store(false, Ordering::Release);
break;
}
}
}
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
let next_result = stream
.as_mut()
.expect("stream is Some above")
.next_into(&mut owned);
let meta = match next_result {
Ok(meta) => {
consecutive_timeouts = 0;
meta
}
Err(CaptureReadError::SourceChanged) => {
// V4L2 driver reported V4L2_EVENT_SOURCE_CHANGE.
// The current capture is effectively invalidated:
// drop the stream so the next iteration re-opens
// via a fresh DV_TIMINGS probe. This is the fast
// path for source-side resolution switches on
// RK628 / rkcif; the retry policy is only a fallback
// when a driver does not provide usable events.
info!(
"Capture reported SOURCE_CHANGE — \
dropping stream for immediate re-open"
);
if recovery_policy.control_mode() == VideoControlMode::SourceFollowing {
pipeline.notify_state(PipelineStateNotification::no_signal(
SignalStatus::NoSignal,
Some(recovery_policy.retry_delay(1).as_millis() as u64),
));
}
consecutive_timeouts = 0;
stream = None;
continue;
}
Err(CaptureReadError::Io(e)) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
continue;
}
if e.kind() == std::io::ErrorKind::TimedOut {
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
if recovery_policy.control_mode()
== VideoControlMode::SourceFollowing
{
let delay = recovery_policy.retry_delay(consecutive_timeouts);
pipeline.notify_state(PipelineStateNotification::no_signal(
SignalStatus::NoSignal,
Some(delay.as_millis() as u64),
));
stream = None;
continue;
}
if consecutive_timeouts >= CAPTURE_TIMEOUT_SOFT_RESTART_THRESHOLD {
// Drop the stream so the next loop
// iteration re-opens via the DV-timings
// probe. This catches source-side
// resolution changes in ~6 s without
// taking the encoder down.
warn!(
"Capture timed out {} consecutive times, \
closing stream for soft-restart",
consecutive_timeouts
);
pipeline.notify_state(PipelineStateNotification::no_signal(
SignalStatus::UvcCaptureStall,
Some(Duration::from_secs(2).as_millis() as u64),
));
stream = None;
continue;
}
if consecutive_timeouts == CAPTURE_TIMEOUT_RESTART_THRESHOLD {
warn!(
"Capture timed out {} consecutive times – no signal?",
consecutive_timeouts
);
}
} else {
consecutive_timeouts = 0;
// EIO (5) / EPIPE (32) / EPROTO (71) in next_into generally
// mean the source or UVC USB transport glitched mid-stream.
// Tear down the stream and let the open loop re-probe.
match classify_capture_io_error(&e) {
CaptureIoErrorKind::TransientSignal { status } => {
if status == Some(SignalStatus::UvcUsbError) {
warn!(
"Capture transient error (EPROTO/-71, often UVC USB): {} — soft-restart",
e
);
pipeline.notify_state(
PipelineStateNotification::no_signal(
SignalStatus::UvcUsbError,
Some(Duration::from_secs(2).as_millis() as u64),
),
);
} else {
warn!(
"Capture transient error ({}), closing stream for \
soft-restart",
e
);
}
stream = None;
continue;
}
CaptureIoErrorKind::DeviceLost => {
error!("Capture device lost: {}", e);
pipeline.mark_device_lost(e.to_string());
pipeline.running_flag.store(false, Ordering::Release);
break;
}
CaptureIoErrorKind::Other => {}
}
let key = capture_error_log_key(&e);
if capture_error_throttler.should_log(&key) {
let suppressed =
suppressed_capture_errors.remove(&key).unwrap_or(0);
if suppressed > 0 {
error!(
"Capture error: {} (suppressed {} repeats)",
e, suppressed
);
} else {
error!("Capture error: {}", e);
}
} else {
let counter = suppressed_capture_errors.entry(key).or_insert(0);
*counter = counter.saturating_add(1);
}
}
continue;
}
};
let frame_size = meta.bytes_used;
if frame_size < MIN_CAPTURE_FRAME_SIZE {
continue;
}
owned.truncate(frame_size);
capture_sequence = capture_sequence.wrapping_add(1);
if parallel_mjpeg_decode
&& pixel_format == PixelFormat::Mjpeg
&& pipeline.drop_pending_mjpeg(
&frame_mailbox,
admission_policy,
MjpegAdmissionStage::Capture,
)
{
buffer_pool.put(owned);
continue;
}
// Notify streaming only after the short-frame guard passes.
pipeline.notify_state(PipelineStateNotification::streaming(
resolution,
pixel_format,
active_fps,
));
if let Some(senders) = mjpeg_decode_senders.as_mut() {
let mut pending = Some(MjpegDecodeJob {
data: owned,
sequence: capture_sequence,
});
for offset in 0..senders.len() {
let index = (next_mjpeg_decoder + offset) % senders.len();
let job = pending.take().expect("pending MJPEG decode job");
match senders[index].try_send(job) {
Ok(()) => {
next_mjpeg_decoder = (index + 1) % senders.len();
break;
}
Err(TrySendError::Full(job))
| Err(TrySendError::Disconnected(job)) => {
pending = Some(job);
}
}
}
if let Some(job) = pending {
pipeline.record_mjpeg_drop(MjpegDropReason::WorkersBusy);
buffer_pool.put(job.data);
}
continue;
}
let (frame_data, frame_format, frame_stride) =
if let Some(decoder) = mjpeg_decoder.as_mut() {
let nv12_size =
resolution.width as usize * resolution.height as usize * 3 / 2;
let mut nv12 = buffer_pool.take(nv12_size);
let decode_started = Instant::now();
if let Err(error) = decoder.decode_into(&owned, &mut nv12) {
buffer_pool.put(owned);
buffer_pool.put(nv12);
let key = "capture_mjpeg_decode";
if capture_error_throttler.should_log(key) {
error!("Dropping undecodable MJPEG frame: {}", error);
}
continue;
}
buffer_pool.put(owned);
pipeline.mjpeg_decode_time_ns.fetch_add(
decode_started.elapsed().as_nanos() as u64,
Ordering::Relaxed,
);
pipeline
.mjpeg_decoded_frames
.fetch_add(1, Ordering::Relaxed);
(nv12, PixelFormat::Nv12, resolution.width)
} else {
(owned, pixel_format, stride)
};
let frame = Arc::new(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(frame_data, Some(buffer_pool.clone()))),
resolution,
frame_format,
frame_stride,
capture_sequence,
));
if frame_mailbox.publish(capture_sequence, frame) != PublishResult::Published
&& mjpeg_decoder.is_some()
{
pipeline
.decoded_frames_not_encoded
.fetch_add(1, Ordering::Relaxed);
}
}
// `running` represents completed lifecycle state, not a stop request.
// Drop the V4L2 stream first so STREAMOFF, buffer teardown and FD close
// have all completed before another consumer is told the device is free.
drop(stream);
pipeline.running_flag.store(false, Ordering::Release);
frame_mailbox.close();
let _ = pipeline.running.send(false);
info!("Video pipeline stopped and capture device released");
});
if let Err(error) = capture_worker {
self.stop();
let _ = self.running.send(false);
return Err(AppError::VideoError(format!(
"Failed to start capture worker: {error}"
)));
}
}
Ok(())
}
/// Encode a single frame (synchronous, no async locks)
fn encode_frame_sync(
&self,
state: &mut EncoderThreadState,
frame: &VideoFrame,
) -> Result<Vec<EncodedVideoFrame>> {
let fps = state.fps;
let codec = state.codec;
let input_format = state.input_format;
let raw_frame = frame.data();
let pts_ms = self.pts_ms();
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
if state.ffmpeg_hw_enabled {
if input_format != PixelFormat::Mjpeg {
return Err(AppError::VideoError(
"FFmpeg HW pipeline requires MJPEG input".to_string(),
));
}
let pipeline = state.ffmpeg_hw_pipeline.as_mut().ok_or_else(|| {
AppError::VideoError("FFmpeg HW pipeline not initialized".to_string())
})?;
if self.keyframe_requested.swap(false, Ordering::AcqRel) {
pipeline.request_keyframe();
debug!("[Pipeline] FFmpeg HW keyframe requested");
}
let packet = pipeline.encode(raw_frame, pts_ms).map_err(|e| {
let detail = if e.is_empty() {
ffmpeg_hw_last_error()
} else {
e
};
AppError::VideoError(format!("FFmpeg HW encode failed: {}", detail))
})?;
if let Some((data, is_keyframe)) = packet {
let (data, is_keyframe) =
self.inspect_and_parameterize_packet(codec, Bytes::from(data), is_keyframe);
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
return Ok(vec![EncodedVideoFrame {
data,
pts_ms,
is_keyframe,
sequence,
duration: Duration::from_millis(1000 / fps as u64),
codec,
}]);
}
return Ok(Vec::new());
}
let decoded_buf = if input_format.is_compressed() {
let decoder = state
.mjpeg_decoder
.as_mut()
.ok_or_else(|| AppError::VideoError("MJPEG decoder not initialized".to_string()))?;
let decoded = match decoder.decode(raw_frame) {
Ok(decoded) => decoded,
Err(err) => {
warn!("Dropping undecodable MJPEG frame before encode: {}", err);
return Ok(Vec::new());
}
};
Some(decoded)
} else {
None
};
let compacted_buf = if decoded_buf.is_none() {
compact_strided_frame_for_encoder(frame, raw_frame)?
} else {
None
};
let raw_frame = decoded_buf
.as_deref()
.or(compacted_buf.as_deref())
.unwrap_or(raw_frame);
let needs_yuv420p = state.encoder_needs_yuv420p;
let encoder = state
.encoder
.as_mut()
.ok_or_else(|| AppError::VideoError("Encoder not initialized".to_string()))?;
// Check and consume keyframe request (atomic, no lock contention)
if self.keyframe_requested.swap(false, Ordering::AcqRel) {
encoder.request_keyframe();
debug!("[Pipeline] Keyframe will be generated for this frame");
}
let encode_result = if needs_yuv420p {
// Software encoder with direct input conversion to YUV420P
if let Some(conv) = state.yuv420p_converter.as_mut() {
let yuv420p_data = conv.convert(raw_frame).map_err(|e| {
AppError::VideoError(format!("YUV420P conversion failed: {}", e))
})?;
encoder.encode_raw(yuv420p_data, pts_ms)
} else {
encoder.encode_raw(raw_frame, pts_ms)
}
} else if let Some(conv) = state.nv12_converter.as_mut() {
// Hardware encoder with input conversion to NV12
let nv12_data = conv
.convert(raw_frame)
.map_err(|e| AppError::VideoError(format!("NV12 conversion failed: {}", e)))?;
encoder.encode_raw(nv12_data, pts_ms)
} else {
// Direct input (already in correct format)
if decoded_buf.is_none()
&& compacted_buf.is_none()
&& frame.format == PixelFormat::Nv12
&& encoder.supports_owned_nv12_input()
{
encoder.encode_owned_nv12(frame.owned_data_bytes(), pts_ms)
} else {
encoder.encode_raw(raw_frame, pts_ms)
}
};
match encode_result {
Ok(frames) => {
if frames.is_empty() {
trace!("Encoder returned no frame ({})", codec);
return Ok(Vec::new());
}
let mut encoded_frames = Vec::with_capacity(frames.len());
for encoded in frames {
let (data, is_keyframe) =
self.inspect_and_parameterize_packet(codec, encoded.data, encoded.key == 1);
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
encoded_frames.push(EncodedVideoFrame {
data,
pts_ms: encoded.pts,
is_keyframe,
sequence,
duration: Duration::from_millis(1000 / fps as u64),
codec,
});
}
Ok(encoded_frames)
}
Err(e) => Err(e),
}
}
fn pts_ms(&self) -> i64 {
let current_ts_us = PROCESS_START
.get_or_init(Instant::now)
.elapsed()
.as_micros() as i64;
let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire);
let start_ts_us = if start_ts_us == 0 {
match self.pipeline_start_time_us.compare_exchange(
0,
current_ts_us,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => current_ts_us,
Err(existing) => existing,
}
} else {
start_ts_us
};
current_ts_us.saturating_sub(start_ts_us) / 1000
}
/// Stop the pipeline (non-blocking, does not wait for capture thread to exit)
pub fn stop(&self) {
let was_running = self.running_flag.swap(false, Ordering::AcqRel);
if let Some(frame_mailbox) = self.frame_mailbox.lock().as_ref() {
frame_mailbox.close();
}
if was_running {
self.clear_cmd_tx();
info!("Stopping video pipeline");
}
}
/// Stop the pipeline and wait for the capture thread to fully exit.
///
/// This ensures the V4L2 device is released before returning, which is
/// necessary when another consumer (e.g. MJPEG streamer) needs to open
/// the same device immediately after.
pub async fn stop_and_wait(&self, timeout: std::time::Duration) -> Result<()> {
self.stop();
let mut rx = self.running_watch();
let mut encoder_rx = self.encoder_done_rx.clone();
let deadline = tokio::time::Instant::now() + timeout;
while *rx.borrow() {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(AppError::VideoError(format!(
"Timed out waiting {:?} for video pipeline to release capture device",
timeout
)));
}
match tokio::time::timeout(remaining, rx.changed()).await {
Ok(Ok(())) => {}
Ok(Err(_)) if !*rx.borrow() => break,
Ok(Err(_)) => {
return Err(AppError::VideoError(
"Video pipeline lifecycle channel closed before capture device release"
.to_string(),
));
}
Err(_) => {
return Err(AppError::VideoError(format!(
"Timed out waiting {:?} for video pipeline to release capture device",
timeout
)));
}
}
}
while !*encoder_rx.borrow() {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(AppError::VideoError(format!(
"Timed out waiting {:?} for video encoder to release vendor session",
timeout
)));
}
match tokio::time::timeout(remaining, encoder_rx.changed()).await {
Ok(Ok(())) => {}
Ok(Err(_)) if *encoder_rx.borrow() => break,
Ok(Err(_)) => {
return Err(AppError::VideoError(
"Video encoder lifecycle channel closed before vendor session release"
.to_string(),
));
}
Err(_) => {
return Err(AppError::VideoError(format!(
"Timed out waiting {:?} for video encoder to release vendor session",
timeout
)));
}
}
}
Ok(())
}
/// Set bitrate using preset
pub async fn set_bitrate_preset(
&self,
preset: crate::video::codec::BitratePreset,
) -> Result<()> {
{
let mut config = self.config.write().await;
config.bitrate_preset = preset;
}
self.send_cmd(PipelineCmd::SetBitrate { preset });
Ok(())
}
/// Set bitrate using raw kbps value (converts to appropriate preset)
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
let preset = crate::video::codec::BitratePreset::from_kbps(bitrate_kbps);
self.set_bitrate_preset(preset).await
}
/// Get current config
pub async fn config(&self) -> SharedVideoPipelineConfig {
self.config.read().await.clone()
}
}
fn compact_strided_frame_for_encoder(frame: &VideoFrame, data: &[u8]) -> Result<Option<Vec<u8>>> {
let width = frame.resolution.width as usize;
let height = frame.resolution.height as usize;
let stride = frame.stride as usize;
if width == 0 || height == 0 || stride == 0 || frame.format.is_compressed() {
return Ok(None);
}
let compact_size = match frame.format {
PixelFormat::Nv12 | PixelFormat::Nv21 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
width * height * 3 / 2
}
PixelFormat::Nv16 | PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => {
width * height * 2
}
PixelFormat::Nv24 | PixelFormat::Rgb24 | PixelFormat::Bgr24 => width * height * 3,
PixelFormat::Rgb565 => width * height * 2,
PixelFormat::Grey => width * height,
PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None),
};
if data.len() == compact_size {
return Ok(None);
}
let mut out = vec![0u8; compact_size];
match frame.format {
PixelFormat::Nv12 | PixelFormat::Nv21 => {
let src_y_size = stride * height;
let src_uv_size = stride * height / 2;
require_len(data, src_y_size + src_uv_size, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
copy_rows(
data,
src_y_size,
stride,
&mut out,
width * height,
width,
width,
height / 2,
);
}
PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
let src_y_size = stride * height;
let src_chroma_stride = stride / 2;
let src_chroma_size = src_chroma_stride * height / 2;
let dst_y_size = width * height;
let dst_chroma_stride = width / 2;
let dst_chroma_size = dst_chroma_stride * height / 2;
require_len(data, src_y_size + src_chroma_size * 2, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
copy_rows(
data,
src_y_size,
src_chroma_stride,
&mut out,
dst_y_size,
dst_chroma_stride,
dst_chroma_stride,
height / 2,
);
copy_rows(
data,
src_y_size + src_chroma_size,
src_chroma_stride,
&mut out,
dst_y_size + dst_chroma_size,
dst_chroma_stride,
dst_chroma_stride,
height / 2,
);
}
PixelFormat::Nv16 => {
let src_y_size = stride * height;
require_len(data, src_y_size + stride * height, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
copy_rows(
data,
src_y_size,
stride,
&mut out,
width * height,
width,
width,
height,
);
}
PixelFormat::Nv24 => {
let src_y_size = stride * height;
let src_uv_stride = stride * 2;
require_len(
data,
src_y_size + src_uv_stride * height,
frame.format,
stride,
)?;
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
copy_rows(
data,
src_y_size,
src_uv_stride,
&mut out,
width * height,
width * 2,
width * 2,
height,
);
}
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy | PixelFormat::Rgb565 => {
let row_bytes = width * 2;
require_len(data, stride * height, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height);
}
PixelFormat::Rgb24 | PixelFormat::Bgr24 => {
let row_bytes = width * 3;
require_len(data, stride * height, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height);
}
PixelFormat::Grey => {
require_len(data, stride * height, frame.format, stride)?;
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
}
PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None),
}
trace!(
"Compacted strided {} frame for encoder: {} -> {} bytes (stride={}, width={})",
frame.format,
data.len(),
out.len(),
stride,
width
);
Ok(Some(out))
}
fn require_len(data: &[u8], required: usize, format: PixelFormat, stride: usize) -> Result<()> {
if data.len() < required {
return Err(AppError::VideoError(format!(
"{} frame too small for stride compaction: {} < {} (stride={})",
format,
data.len(),
required,
stride
)));
}
Ok(())
}
fn copy_rows(
src: &[u8],
src_offset: usize,
src_stride: usize,
dst: &mut [u8],
dst_offset: usize,
dst_stride: usize,
row_bytes: usize,
rows: usize,
) {
for row in 0..rows {
let src_start = src_offset + row * src_stride;
let dst_start = dst_offset + row * dst_stride;
dst[dst_start..dst_start + row_bytes]
.copy_from_slice(&src[src_start..src_start + row_bytes]);
}
}
impl Drop for SharedVideoPipeline {
fn drop(&mut self) {
self.running_flag.store(false, Ordering::Release);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::video::codec::BitratePreset;
#[tokio::test]
async fn bitrate_commands_preserve_custom_values_and_gop_policy() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::default()).unwrap();
let (tx, mut rx) = mpsc::unbounded_channel();
*pipeline.cmd_tx.write() = Some(tx);
for preset in [
BitratePreset::Custom(2500),
BitratePreset::Custom(1000),
BitratePreset::Speed,
BitratePreset::Quality,
] {
pipeline.set_bitrate_preset(preset).await.unwrap();
let PipelineCmd::SetBitrate { preset: received } = rx.try_recv().unwrap();
assert_eq!(received, preset);
assert_eq!(pipeline.config().await.bitrate_preset, preset);
// Rebuilt encoders must retain the preset's policy at the new FPS.
let restored = SharedVideoPipelineConfig {
bitrate_preset: received,
fps: 60,
..Default::default()
};
assert_eq!(restored.bitrate_kbps(), preset.bitrate_kbps());
assert_eq!(restored.gop_size(), preset.gop_size(60));
}
}
#[test]
fn source_reopen_updates_fps_and_gop_without_changing_geometry_or_bitrate() {
let mut config = SharedVideoPipelineConfig {
control_mode: VideoControlMode::SourceFollowing,
resolution: Resolution::HD1080,
fps: 60,
bitrate_preset: BitratePreset::Quality,
..Default::default()
};
config.align_source_fps(Some(29.97));
assert_eq!(config.fps, 30);
assert_eq!(config.gop_size(), 60);
assert_eq!(config.resolution, Resolution::HD1080);
assert_eq!(config.bitrate_kbps(), 8000);
config.align_source_fps(None);
assert_eq!(config.fps, 30);
config.align_source_fps(Some(59.94));
assert_eq!(config.fps, 60);
assert_eq!(config.gop_size(), 120);
}
#[test]
fn configurable_capture_keeps_requested_fps() {
let mut config = SharedVideoPipelineConfig::default();
config.align_source_fps(Some(60.0));
assert_eq!(config.fps, 30);
}
#[test]
fn test_pipeline_config() {
let h264 = SharedVideoPipelineConfig::h264(Resolution::HD1080, BitratePreset::Balanced);
assert_eq!(h264.output_codec, VideoEncoderType::H264);
let h265 = SharedVideoPipelineConfig::h265(Resolution::HD720, BitratePreset::Speed);
assert_eq!(h265.output_codec, VideoEncoderType::H265);
}
#[test]
fn h264_keyframe_requires_idr_and_parameter_sets() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let predicted = Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, predicted, true);
assert!(
!key,
"a driver flag must not turn a P-frame into a keyframe"
);
let parameter_sets =
Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, parameter_sets, false);
assert!(!key, "parameter sets alone are not a keyframe");
let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]);
let (_, key) = pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, false);
assert!(!key, "an IDR without a driver key flag is not trusted");
let idr = Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]);
let (bootstrap, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, idr, true);
assert!(
key,
"matching driver metadata and IDR payload should bootstrap"
);
assert!(h264_bitstream::has_sps_pps(bootstrap.as_ref()));
assert!(h264_bitstream::is_keyframe(bootstrap.as_ref()));
}
#[test]
fn h264_profile_is_updated_from_parameter_only_packet() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let packet =
Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42, 0xe0, 0x1f, 0, 0, 0, 1, 0x68, 0xce]);
let (_, keyframe) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H264, packet, false);
assert!(!keyframe);
assert_eq!(
pipeline.h264_profile_level_id_watch().borrow().as_deref(),
Some("42e01f")
);
}
#[tokio::test]
async fn decoder_statistics_report_work_and_drops() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
pipeline.mjpeg_decoded_frames.store(30, Ordering::Relaxed);
pipeline
.mjpeg_dropped_before_decode
.store(10, Ordering::Relaxed);
pipeline
.decoded_frames_not_encoded
.store(5, Ordering::Relaxed);
let stats = pipeline.stats().await;
assert_eq!(stats.mjpeg_decoded_frames, 30);
assert_eq!(stats.mjpeg_dropped_before_decode, 10);
assert_eq!(stats.decoded_frames_not_encoded, 5);
}
#[test]
fn brief_output_backpressure_keeps_bounded_prefetch_running() {
assert!(!sustained_output_backpressure(0, 100_000_000));
assert!(!sustained_output_backpressure(1, 9_999_999));
assert!(sustained_output_backpressure(1, 10_000_000));
assert!(sustained_output_backpressure(1, 100_000_000));
assert!(!sustained_output_backpressure(20_000_001, 10_000_000));
}
#[test]
fn mjpeg_drop_reasons_sum_to_total_without_double_counting() {
let pipeline = delivery_test_pipeline();
for reason in [
MjpegDropReason::PendingCapture,
MjpegDropReason::PendingDecode,
MjpegDropReason::BackpressureCapture,
MjpegDropReason::BackpressureDecode,
MjpegDropReason::WorkersBusy,
] {
pipeline.record_mjpeg_drop(reason);
}
pipeline.encoder_frame_wait_ns.store(123, Ordering::Relaxed);
pipeline
.encoder_output_wait_ns
.store(456, Ordering::Relaxed);
let stats = pipeline.stats_snapshot();
assert_eq!(stats.mjpeg_dropped_before_decode, 5);
assert_eq!(stats.mjpeg_dropped_pending_capture, 1);
assert_eq!(stats.mjpeg_dropped_pending_decode, 1);
assert_eq!(stats.mjpeg_dropped_backpressure_capture, 1);
assert_eq!(stats.mjpeg_dropped_backpressure_decode, 1);
assert_eq!(stats.mjpeg_dropped_workers_busy, 1);
assert_eq!(stats.encoder_frame_wait_ns, 123);
assert_eq!(stats.encoder_output_wait_ns, 456);
}
#[test]
fn bounded_prefetch_accepts_pending_frame_when_output_is_healthy() {
let pipeline = delivery_test_pipeline();
let mailbox = VideoFrameMailbox::new();
mailbox.publish(1, delivery_test_raw_frame(1));
for stage in [MjpegAdmissionStage::Capture, MjpegAdmissionStage::Decode] {
assert!(!pipeline.drop_pending_mjpeg(
&mailbox,
MjpegAdmissionPolicy::BoundedPrefetch,
stage,
));
assert!(pipeline.drop_pending_mjpeg(
&mailbox,
MjpegAdmissionPolicy::PendingSlot,
stage,
));
}
assert!(mailbox.has_pending());
let stats = pipeline.stats_snapshot();
assert_eq!(stats.mjpeg_dropped_before_decode, 2);
assert_eq!(stats.mjpeg_dropped_pending_capture, 1);
assert_eq!(stats.mjpeg_dropped_pending_decode, 1);
}
#[test]
fn bounded_prefetch_drops_only_with_pending_frame_and_output_backpressure() {
let pipeline = delivery_test_pipeline();
let mailbox = VideoFrameMailbox::new();
let mut receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(1, true)].into(), reservations);
assert!(pipeline.reserve_outputs().is_none());
for stage in [MjpegAdmissionStage::Capture, MjpegAdmissionStage::Decode] {
assert!(!pipeline.drop_pending_mjpeg(
&mailbox,
MjpegAdmissionPolicy::BoundedPrefetch,
stage,
));
}
mailbox.publish(1, delivery_test_raw_frame(1));
for stage in [MjpegAdmissionStage::Capture, MjpegAdmissionStage::Decode] {
std::thread::sleep(Duration::from_millis(11));
assert!(pipeline.drop_pending_mjpeg(
&mailbox,
MjpegAdmissionPolicy::BoundedPrefetch,
stage,
));
}
assert_eq!(receiver.try_recv().unwrap().sequence, 1);
let _reservations = pipeline.reserve_outputs().unwrap();
assert!(!pipeline.drop_pending_mjpeg(
&mailbox,
MjpegAdmissionPolicy::BoundedPrefetch,
MjpegAdmissionStage::Capture,
));
assert!(mailbox.has_pending());
let stats = pipeline.stats_snapshot();
assert_eq!(stats.mjpeg_dropped_before_decode, 2);
assert_eq!(stats.mjpeg_dropped_backpressure_capture, 1);
assert_eq!(stats.mjpeg_dropped_backpressure_decode, 1);
assert_eq!(stats.mjpeg_dropped_pending_capture, 0);
assert_eq!(stats.mjpeg_dropped_pending_decode, 0);
}
#[test]
fn encoding_percentiles_use_nearest_rank_and_handle_empty_windows() {
assert_eq!(latency_percentile_ms(&[], 95), 0.0);
assert_eq!(latency_percentile_ms(&[3_000_000], 99), 3.0);
let sorted: Vec<u64> = (1..=100).map(|millis| millis * 1_000_000).collect();
assert_eq!(latency_percentile_ms(&sorted, 95), 95.0);
assert_eq!(latency_percentile_ms(&sorted, 99), 99.0);
}
#[test]
fn encoder_packet_pts_is_preserved() {
use super::super::encoder_state::{EncodedFrame, VideoEncoderTrait};
struct TestEncoder;
impl VideoEncoderTrait for TestEncoder {
fn encode_raw(&mut self, _data: &[u8], _pts_ms: i64) -> Result<Vec<EncodedFrame>> {
Ok(vec![EncodedFrame {
data: Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]),
key: 0,
pts: 321,
}])
}
fn set_bitrate(&mut self, _bitrate_kbps: u32) -> Result<()> {
Ok(())
}
fn codec_name(&self) -> &str {
"test-h264"
}
fn request_keyframe(&mut self) {}
}
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let mut state = EncoderThreadState {
encoder: Some(Box::new(TestEncoder)),
mjpeg_decoder: None,
nv12_converter: None,
yuv420p_converter: None,
encoder_needs_yuv420p: false,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_pipeline: None,
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
ffmpeg_hw_enabled: false,
fps: 30,
codec: VideoEncoderType::H264,
input_format: PixelFormat::Nv12,
};
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(vec![0; 1280 * 720 * 3 / 2], None)),
Resolution::HD720,
PixelFormat::Nv12,
1280,
1,
);
let encoded = pipeline.encode_frame_sync(&mut state, &frame).unwrap();
assert_eq!(encoded.len(), 1);
assert_eq!(encoded[0].pts_ms, 321);
}
#[test]
fn h265_keyframe_requires_irap_and_parameter_sets() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h265(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let trail = Bytes::from_static(&[0, 0, 0, 1, 1 << 1, 1, 0xaa]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, trail, true);
assert!(
!key,
"a driver flag must not turn a trailing frame into a keyframe"
);
let parameter_sets = Bytes::from_static(&[
0,
0,
0,
1,
32 << 1,
1,
0xaa,
0,
0,
0,
1,
33 << 1,
1,
0xbb,
0,
0,
0,
1,
34 << 1,
1,
0xcc,
]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, parameter_sets, false);
assert!(!key, "parameter sets alone are not a keyframe");
let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]);
let (_, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, false);
assert!(!key, "an IRAP without a driver key flag is not trusted");
let irap = Bytes::from_static(&[0, 0, 0, 1, 19 << 1, 1, 0xdd]);
let (bootstrap, key) =
pipeline.inspect_and_parameterize_packet(VideoEncoderType::H265, irap, true);
assert!(
key,
"matching driver metadata and IRAP payload should bootstrap"
);
assert!(h265_bitstream::has_vps_sps_pps(bootstrap.as_ref()));
assert!(h265_bitstream::is_keyframe(bootstrap.as_ref()));
}
#[test]
fn new_subscriber_receives_cached_bootstrap_frame() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let bootstrap = Arc::new(EncodedVideoFrame {
data: Bytes::from_static(&[
0, 0, 0, 1, 0x67, 0x42, 0x40, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 0, 0, 0, 1, 0x65, 0x88,
]),
pts_ms: 0,
is_keyframe: true,
sequence: 1,
duration: Duration::from_millis(33),
codec: VideoEncoderType::H264,
});
pipeline.broadcast_encoded_batch(vec![bootstrap.clone()].into(), Vec::new());
let mut subscriber = pipeline.subscribe();
let received = subscriber
.try_recv()
.expect("cached bootstrap frame should seed the subscriber queue");
assert!(Arc::ptr_eq(&received, &bootstrap));
}
fn delivery_test_pipeline() -> Arc<SharedVideoPipeline> {
SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap()
}
fn delivery_test_frame(sequence: u64, is_keyframe: bool) -> Arc<EncodedVideoFrame> {
Arc::new(EncodedVideoFrame {
data: Bytes::from_static(&[0, 0, 0, 1, 0x41, 0xc0]),
pts_ms: sequence as i64 * 33,
is_keyframe,
sequence,
duration: Duration::from_millis(33),
codec: VideoEncoderType::H264,
})
}
fn delivery_test_raw_frame(sequence: u64) -> Arc<VideoFrame> {
Arc::new(VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(vec![0; 6], None)),
Resolution {
width: 2,
height: 2,
},
PixelFormat::Nv12,
2,
sequence,
))
}
#[test]
fn encoded_batch_delivers_all_frames_in_order() {
let pipeline = delivery_test_pipeline();
let mut receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(
vec![
delivery_test_frame(1, true),
delivery_test_frame(2, false),
delivery_test_frame(3, false),
]
.into(),
reservations,
);
assert_eq!(receiver.try_recv().unwrap().sequence, 1);
assert_eq!(receiver.try_recv().unwrap().sequence, 2);
assert_eq!(receiver.try_recv().unwrap().sequence, 3);
assert!(matches!(
receiver.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
}
#[test]
fn single_subscriber_backpressure_preserves_reference_chain() {
let pipeline = delivery_test_pipeline();
let mut receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(1, true)].into(), reservations);
assert!(pipeline.reserve_outputs().is_none());
assert!(pipeline.reserve_outputs().is_none());
assert_eq!(pipeline.stats_snapshot().output_backpressure_events, 1);
assert_eq!(receiver.try_recv().unwrap().sequence, 1);
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(2, false)].into(), reservations);
assert_eq!(receiver.try_recv().unwrap().sequence, 2);
let stats = pipeline.stats_snapshot();
assert_eq!(stats.encoded_frames, 2);
assert_eq!(stats.encoded_frames_skipped_for_slow_subscribers, 0);
}
#[test]
fn output_capacity_wakes_encoder_with_latest_raw_frame() {
use std::sync::mpsc as sync_mpsc;
let pipeline = delivery_test_pipeline();
let mailbox = Arc::new(VideoFrameMailbox::new());
*pipeline.frame_mailbox.lock() = Some(mailbox.clone());
let mut receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(1, true)].into(), reservations);
mailbox.publish(1, delivery_test_raw_frame(1));
mailbox.publish(2, delivery_test_raw_frame(2));
let (waiting_sender, waiting_receiver) = sync_mpsc::sync_channel(1);
let (result_sender, result_receiver) = sync_mpsc::channel();
let worker = std::thread::spawn(move || {
let result = mailbox.receive_when(|| {
let reservations = pipeline.reserve_outputs();
if reservations.is_none() {
let _ = waiting_sender.try_send(());
}
reservations
});
let (frame, reservations) = result.unwrap();
result_sender
.send((frame.sequence, reservations.len()))
.unwrap();
});
waiting_receiver
.recv_timeout(Duration::from_secs(2))
.unwrap();
assert_eq!(receiver.try_recv().unwrap().sequence, 1);
assert_eq!(
result_receiver
.recv_timeout(Duration::from_secs(2))
.unwrap(),
(2, 1)
);
worker.join().unwrap();
}
#[test]
fn receiver_drop_wakes_encoder_waiting_for_capacity() {
use std::sync::mpsc as sync_mpsc;
let pipeline = delivery_test_pipeline();
let mailbox = Arc::new(VideoFrameMailbox::new());
*pipeline.frame_mailbox.lock() = Some(mailbox.clone());
let receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(1, true)].into(), reservations);
mailbox.publish(1, delivery_test_raw_frame(1));
let (waiting_sender, waiting_receiver) = sync_mpsc::sync_channel(1);
let (result_sender, result_receiver) = sync_mpsc::channel();
let worker = std::thread::spawn(move || {
let result = mailbox.receive_when(|| {
let reservations = pipeline.reserve_outputs();
if reservations.is_none() {
let _ = waiting_sender.try_send(());
}
reservations
});
result_sender.send(result.unwrap().1.is_empty()).unwrap();
});
waiting_receiver
.recv_timeout(Duration::from_secs(2))
.unwrap();
drop(receiver);
assert!(result_receiver
.recv_timeout(Duration::from_secs(2))
.unwrap());
worker.join().unwrap();
}
#[test]
fn slow_subscriber_is_isolated_and_resumes_at_keyframe() {
let pipeline = delivery_test_pipeline();
let mut fast_receiver = pipeline.subscribe();
let mut slow_receiver = pipeline.subscribe();
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(1, true)].into(), reservations);
pipeline.keyframe_requested.store(false, Ordering::Release);
assert_eq!(fast_receiver.try_recv().unwrap().sequence, 1);
let reservations = pipeline.reserve_outputs().unwrap();
assert_eq!(reservations.len(), 1);
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(2, false)].into(), reservations);
assert_eq!(fast_receiver.try_recv().unwrap().sequence, 2);
assert!(!pipeline.keyframe_requested.load(Ordering::Acquire));
assert_eq!(slow_receiver.try_recv().unwrap().sequence, 1);
let reservations = pipeline.reserve_outputs().unwrap();
assert_eq!(reservations.len(), 2);
assert!(pipeline.keyframe_requested.swap(false, Ordering::AcqRel));
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(3, false)].into(), reservations);
assert_eq!(fast_receiver.try_recv().unwrap().sequence, 3);
assert!(matches!(
slow_receiver.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
let reservations = pipeline.reserve_outputs().unwrap();
assert!(!pipeline.keyframe_requested.load(Ordering::Acquire));
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(4, true)].into(), reservations);
assert_eq!(fast_receiver.try_recv().unwrap().sequence, 4);
assert_eq!(slow_receiver.try_recv().unwrap().sequence, 4);
let reservations = pipeline.reserve_outputs().unwrap();
pipeline.broadcast_encoded_batch(vec![delivery_test_frame(5, false)].into(), reservations);
assert_eq!(fast_receiver.try_recv().unwrap().sequence, 5);
assert_eq!(slow_receiver.try_recv().unwrap().sequence, 5);
assert_eq!(
pipeline
.stats_snapshot()
.encoded_frames_skipped_for_slow_subscribers,
2
);
}
#[test]
fn send_statistics_include_successes_errors_and_duration() {
let pipeline = delivery_test_pipeline();
let receiver = pipeline.subscribe();
receiver.record_send(Duration::from_micros(100), true);
receiver.record_send(Duration::from_micros(200), false);
let stats = pipeline.stats_snapshot();
assert_eq!(stats.sent_frames, 1);
assert_eq!(stats.send_errors, 1);
assert_eq!(stats.send_time_ns, 300_000);
}
#[test]
fn mjpeg_workers_match_available_cpu_count() {
assert_eq!(mjpeg_decode_worker_count(1), 1);
assert_eq!(mjpeg_decode_worker_count(2), 2);
assert_eq!(mjpeg_decode_worker_count(4), 4);
assert_eq!(mjpeg_decode_worker_count(64), 64);
}
#[test]
fn stop_wakes_idle_encoder_even_after_capture_requested_stop() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let mailbox = Arc::new(VideoFrameMailbox::new());
*pipeline.frame_mailbox.lock() = Some(mailbox.clone());
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
sender.send(mailbox.receive().is_none()).unwrap();
});
pipeline.stop();
assert!(receiver.recv_timeout(Duration::from_secs(2)).unwrap());
worker.join().unwrap();
}
#[test]
fn stop_request_does_not_publish_worker_exit() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let _ = pipeline.running.send(true);
pipeline.running_flag.store(true, Ordering::Release);
pipeline.stop();
assert!(!pipeline.running_flag.load(Ordering::Acquire));
assert!(pipeline.is_running());
assert_eq!(pipeline.lifecycle(), PipelineLifecycle::Stopping);
// Simulate the capture thread's common cleanup tail.
let _ = pipeline.running.send(false);
assert!(!pipeline.is_running());
assert_eq!(pipeline.lifecycle(), PipelineLifecycle::Stopped);
}
#[tokio::test]
async fn stop_and_wait_observes_completed_worker_cleanup() {
let pipeline = SharedVideoPipeline::new(SharedVideoPipelineConfig::h264(
Resolution::HD720,
BitratePreset::Balanced,
))
.unwrap();
let _ = pipeline.running.send(true);
let _ = pipeline.encoder_done.send(false);
pipeline.running_flag.store(true, Ordering::Release);
let worker = pipeline.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = worker.running.send(false);
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = worker.encoder_done.send(true);
});
let started = Instant::now();
pipeline
.stop_and_wait(Duration::from_secs(1))
.await
.unwrap();
assert!(started.elapsed() >= Duration::from_millis(50));
assert!(!pipeline.is_running());
}
}