feat: 初步增加 Windows 支持

This commit is contained in:
mofeng-git
2026-05-18 22:43:28 +08:00
parent 0b9d94f53f
commit 935fa823f2
163 changed files with 11419 additions and 7581 deletions

View File

@@ -22,9 +22,9 @@ use v4l2r::nix::errno::Errno;
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
use crate::error::{AppError, Result};
use crate::video::csi_bridge::{self, CsiBridgeKind, ProbeResult};
use crate::video::device::bridge::{self as csi_bridge, CsiBridgeKind, ProbeResult};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::SignalStatus;
use crate::video::signal::SignalStatus;
/// `io::Error` payload when the driver posts `V4L2_EVENT_SOURCE_CHANGE`.
pub const SOURCE_CHANGED_MARKER: &str = "v4l2_source_changed";
@@ -60,7 +60,7 @@ impl BridgeContext {
}
/// V4L2 capture stream backed by v4l2r ioctl.
pub struct V4l2rCaptureStream {
pub struct CaptureStream {
fd: File,
queue: QueueType,
resolution: Resolution,
@@ -72,7 +72,7 @@ pub struct V4l2rCaptureStream {
bridge_kind: Option<CsiBridgeKind>,
}
impl V4l2rCaptureStream {
impl CaptureStream {
/// UVC: uses `resolution`. CSI bridges: DV-probe first; may return `CaptureNoSignal`.
pub fn open(
device_path: impl AsRef<Path>,
@@ -538,7 +538,7 @@ impl V4l2rCaptureStream {
}
}
impl Drop for V4l2rCaptureStream {
impl Drop for CaptureStream {
fn drop(&mut self) {
// Release ordering matters on rkcif: a subsequent open()/S_FMT from a
// freshly-constructed stream returns EBUSY if the previous capture has
@@ -571,9 +571,9 @@ impl Drop for V4l2rCaptureStream {
}
/// Driver-name check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, tc358743,
/// …) that expose DV timings. Kept in sync with `video::is_csi_hdmi_bridge`
/// …) that expose DV timings. Kept in sync with `video::device::is_csi_hdmi_bridge`
/// but queries the raw V4L2 driver string so we don't need a full
/// `VideoDeviceInfo` at `V4l2rCaptureStream::open` time.
/// `VideoDeviceInfo` at `CaptureStream::open` time.
fn is_csi_bridge_driver(driver: &str) -> bool {
let d = driver.to_ascii_lowercase();
d == "rk_hdmirx" || d == "rkcif" || d == "tc358743" || d.starts_with("rkcif")

12
src/video/capture/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
//! Video capture implementations and capture-state helpers.
pub(crate) mod runtime;
pub(crate) mod status;
#[cfg(unix)]
mod linux;
#[cfg(windows)]
#[path = "windows.rs"]
mod linux;
pub use linux::*;

View File

@@ -0,0 +1,70 @@
use std::path::Path;
use std::time::Duration;
use crate::error::AppError;
use crate::video::capture::status::signal_status_from_capture_kind;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::signal::SignalStatus;
use super::{BridgeContext, CaptureStream};
pub enum CaptureOpenResult {
Opened(CaptureStream),
NoSignal(SignalStatus),
DeviceLost(String),
Fatal,
}
pub fn open_capture_stream(
device_path: &Path,
resolution: Resolution,
format: PixelFormat,
fps: u32,
buffer_count: u32,
timeout: Duration,
bridge_ctx: BridgeContext,
) -> Result<CaptureStream, AppError> {
CaptureStream::open_with_bridge(
device_path,
resolution,
format,
fps,
buffer_count.max(1),
timeout,
bridge_ctx,
)
}
pub fn open_capture_stream_for_retry(
device_path: &Path,
resolution: Resolution,
format: PixelFormat,
fps: u32,
buffer_count: u32,
timeout: Duration,
bridge_ctx: BridgeContext,
is_device_lost_message: impl FnOnce(&str) -> bool,
) -> CaptureOpenResult {
match open_capture_stream(
device_path,
resolution,
format,
fps,
buffer_count,
timeout,
bridge_ctx,
) {
Ok(stream) => CaptureOpenResult::Opened(stream),
Err(AppError::CaptureNoSignal { kind }) => {
CaptureOpenResult::NoSignal(signal_status_from_capture_kind(&kind))
}
Err(error) => {
let reason = error.to_string();
if is_device_lost_message(&reason) {
CaptureOpenResult::DeviceLost(reason)
} else {
CaptureOpenResult::Fatal
}
}
}
}

View File

@@ -2,7 +2,7 @@
use std::io;
use crate::video::SignalStatus;
use crate::video::signal::SignalStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureIoErrorKind {

View File

@@ -0,0 +1,181 @@
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{AppError, Result};
use crate::video::device::bridge::{CsiBridgeKind, ProbeResult};
use crate::video::device::{directshow_display_name_from_path, normalize_windows_device_path};
use crate::video::format::{PixelFormat, Resolution};
pub const SOURCE_CHANGED_MARKER: &str = "dshow_source_changed";
pub fn is_source_changed_error(err: &io::Error) -> bool {
err.get_ref()
.map(|inner| inner.to_string() == SOURCE_CHANGED_MARKER)
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy)]
pub struct CaptureMeta {
pub bytes_used: usize,
pub sequence: u64,
}
#[derive(Debug, Clone, Default)]
pub struct BridgeContext {
pub subdev_path: Option<PathBuf>,
pub kind: Option<CsiBridgeKind>,
}
impl BridgeContext {
pub fn from_parts(subdev_path: Option<PathBuf>, kind: Option<CsiBridgeKind>) -> Self {
Self { subdev_path, kind }
}
pub fn has_subdev(&self) -> bool {
false
}
}
pub struct CaptureStream {
capture: hwcodec::capture::DshowCapture,
resolution: Resolution,
format: PixelFormat,
stride: u32,
}
unsafe impl Send for CaptureStream {}
impl CaptureStream {
pub fn open(
device_path: impl AsRef<Path>,
resolution: Resolution,
format: PixelFormat,
fps: u32,
buffer_count: u32,
timeout: Duration,
) -> Result<Self> {
let _ = buffer_count;
let path = normalize_windows_device_path(device_path);
let display_name = directshow_display_name_from_path(&path).ok_or_else(|| {
AppError::VideoError(format!(
"Unsupported DirectShow device path: {}",
path.display()
))
})?;
let capture = hwcodec::capture::DshowCapture::open(
&display_name,
resolution.width as i32,
resolution.height as i32,
fps as i32,
map_pixel_format(format),
timeout.as_millis().clamp(1, i32::MAX as u128) as i32,
)
.map_err(|e| AppError::VideoError(format!("Failed to open DirectShow capture: {}", e)))?;
let info = capture.info().map_err(|e| {
AppError::VideoError(format!("Failed to query DirectShow capture: {}", e))
})?;
let actual_format = map_capture_format(info.pixel_format)?;
let actual_resolution =
Resolution::new(info.width.max(1) as u32, info.height.max(1) as u32);
Ok(Self {
capture,
resolution: actual_resolution,
format: actual_format,
stride: info.stride.max(0) as u32,
})
}
pub fn open_with_bridge(
device_path: impl AsRef<Path>,
resolution: Resolution,
format: PixelFormat,
fps: u32,
buffer_count: u32,
timeout: Duration,
bridge: BridgeContext,
) -> Result<Self> {
let _ = bridge;
Self::open(device_path, resolution, format, fps, buffer_count, timeout)
}
pub fn resolution(&self) -> Resolution {
self.resolution
}
pub fn format(&self) -> PixelFormat {
self.format
}
pub fn stride(&self) -> u32 {
self.stride
}
pub fn next_into(&mut self, dst: &mut Vec<u8>) -> io::Result<CaptureMeta> {
match self.capture.read_packet() {
Ok((packet, sequence)) => {
dst.clear();
dst.extend_from_slice(&packet);
Ok(CaptureMeta {
bytes_used: packet.len(),
sequence,
})
}
Err(err) => {
let kind = if err.code == -110 {
io::ErrorKind::TimedOut
} else {
io::ErrorKind::Other
};
Err(io::Error::new(kind, err.message))
}
}
}
pub fn probe_bridge_signal_with_timeout(&self, _limit: Duration) -> Option<ProbeResult> {
None
}
}
fn map_pixel_format(format: PixelFormat) -> hwcodec::capture::CapturePixelFormat {
match format {
PixelFormat::Mjpeg => hwcodec::capture::CapturePixelFormat::Mjpeg,
PixelFormat::Jpeg => hwcodec::capture::CapturePixelFormat::Jpeg,
PixelFormat::Yuyv => hwcodec::capture::CapturePixelFormat::Yuyv,
PixelFormat::Yvyu => hwcodec::capture::CapturePixelFormat::Yvyu,
PixelFormat::Uyvy => hwcodec::capture::CapturePixelFormat::Uyvy,
PixelFormat::Nv12 => hwcodec::capture::CapturePixelFormat::Nv12,
PixelFormat::Nv21 => hwcodec::capture::CapturePixelFormat::Nv21,
PixelFormat::Nv16 => hwcodec::capture::CapturePixelFormat::Nv16,
PixelFormat::Nv24 => hwcodec::capture::CapturePixelFormat::Nv24,
PixelFormat::Yuv420 => hwcodec::capture::CapturePixelFormat::Yuv420,
PixelFormat::Yvu420 => hwcodec::capture::CapturePixelFormat::Yvu420,
PixelFormat::Rgb24 => hwcodec::capture::CapturePixelFormat::Rgb24,
PixelFormat::Bgr24 => hwcodec::capture::CapturePixelFormat::Bgr24,
PixelFormat::Grey => hwcodec::capture::CapturePixelFormat::Grey,
PixelFormat::Rgb565 => hwcodec::capture::CapturePixelFormat::Unknown,
}
}
fn map_capture_format(format: hwcodec::capture::CapturePixelFormat) -> Result<PixelFormat> {
match format {
hwcodec::capture::CapturePixelFormat::Mjpeg => Ok(PixelFormat::Mjpeg),
hwcodec::capture::CapturePixelFormat::Jpeg => Ok(PixelFormat::Jpeg),
hwcodec::capture::CapturePixelFormat::Yuyv => Ok(PixelFormat::Yuyv),
hwcodec::capture::CapturePixelFormat::Yvyu => Ok(PixelFormat::Yvyu),
hwcodec::capture::CapturePixelFormat::Uyvy => Ok(PixelFormat::Uyvy),
hwcodec::capture::CapturePixelFormat::Nv12 => Ok(PixelFormat::Nv12),
hwcodec::capture::CapturePixelFormat::Nv21 => Ok(PixelFormat::Nv21),
hwcodec::capture::CapturePixelFormat::Nv16 => Ok(PixelFormat::Nv16),
hwcodec::capture::CapturePixelFormat::Nv24 => Ok(PixelFormat::Nv24),
hwcodec::capture::CapturePixelFormat::Yuv420 => Ok(PixelFormat::Yuv420),
hwcodec::capture::CapturePixelFormat::Yvu420 => Ok(PixelFormat::Yvu420),
hwcodec::capture::CapturePixelFormat::Rgb24 => Ok(PixelFormat::Rgb24),
hwcodec::capture::CapturePixelFormat::Bgr24 => Ok(PixelFormat::Bgr24),
hwcodec::capture::CapturePixelFormat::Grey => Ok(PixelFormat::Grey),
hwcodec::capture::CapturePixelFormat::Unknown => Err(AppError::ServiceUnavailable(
"DirectShow returned an unsupported pixel format".to_string(),
)),
}
}

View File

@@ -1,30 +0,0 @@
//! Shared tuning for V4L2 MJPEG capture paths (`Streamer` + `SharedVideoPipeline`).
/// Frames smaller than this are treated as incomplete / noise.
pub(crate) const MIN_CAPTURE_FRAME_SIZE: usize = 128;
/// After startup, validate JPEG header every N frames to limit CPU use.
pub(crate) const JPEG_VALIDATE_INTERVAL: u64 = 30;
/// Validate every MJPEG frame for the first N frames (UVC warm-up / bad headers).
pub(crate) const STARTUP_JPEG_VALIDATE_FRAMES: u64 = 3;
#[inline]
pub(crate) fn should_validate_jpeg_frame(validate_counter: u64) -> bool {
validate_counter <= STARTUP_JPEG_VALIDATE_FRAMES
|| validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jpeg_validation_policy_startup_then_interval() {
assert!(should_validate_jpeg_frame(1));
assert!(should_validate_jpeg_frame(2));
assert!(should_validate_jpeg_frame(3));
assert!(!should_validate_jpeg_frame(4));
assert!(should_validate_jpeg_frame(30));
}
}

View File

@@ -0,0 +1,299 @@
//! H.264 Annex-B/AVCC bitstream helpers shared by WebRTC, RTSP and RustDesk.
pub const FALLBACK_WEBRTC_PROFILE_LEVEL_ID: &str = "42e01f";
pub fn webrtc_fmtp_line(profile_level_id: &str) -> String {
format!(
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id={}",
profile_level_id
)
}
pub fn fallback_webrtc_fmtp_line() -> String {
webrtc_fmtp_line(FALLBACK_WEBRTC_PROFILE_LEVEL_ID)
}
pub fn strip_aud_nal_units(data: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
let (start_code_pos, start_code_len) = if i + 4 <= data.len()
&& data[i] == 0
&& data[i + 1] == 0
&& data[i + 2] == 0
&& data[i + 3] == 1
{
(i, 4)
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
(i, 3)
} else {
i += 1;
continue;
};
let nal_start = start_code_pos + start_code_len;
if nal_start >= data.len() {
break;
}
let nal_type = data[nal_start] & 0x1F;
let mut nal_end = data.len();
let mut j = nal_start + 1;
while j + 3 <= data.len() {
if (data[j] == 0 && data[j + 1] == 0 && data[j + 2] == 1)
|| (j + 4 <= data.len()
&& data[j] == 0
&& data[j + 1] == 0
&& data[j + 2] == 0
&& data[j + 3] == 1)
{
nal_end = j;
break;
}
j += 1;
}
if nal_type != 9 && nal_type != 12 {
result.extend_from_slice(&data[start_code_pos..nal_end]);
}
i = nal_end;
}
if result.is_empty() && !data.is_empty() {
return data.to_vec();
}
result
}
pub fn extract_sps_pps(data: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
let mut sps: Option<Vec<u8>> = None;
let mut pps: Option<Vec<u8>> = None;
let mut i = 0;
while i < data.len() {
let start_code_len = if i + 4 <= data.len()
&& data[i] == 0
&& data[i + 1] == 0
&& data[i + 2] == 0
&& data[i + 3] == 1
{
4
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
3
} else {
i += 1;
continue;
};
let nal_start = i + start_code_len;
if nal_start >= data.len() {
break;
}
let nal_type = data[nal_start] & 0x1F;
let mut nal_end = data.len();
let mut j = nal_start + 1;
while j + 3 <= data.len() {
if (data[j] == 0 && data[j + 1] == 0 && data[j + 2] == 1)
|| (j + 4 <= data.len()
&& data[j] == 0
&& data[j + 1] == 0
&& data[j + 2] == 0
&& data[j + 3] == 1)
{
nal_end = j;
break;
}
j += 1;
}
match nal_type {
7 => {
sps = Some(data[nal_start..nal_end].to_vec());
}
8 => {
pps = Some(data[nal_start..nal_end].to_vec());
}
_ => {}
}
i = nal_end;
}
(sps, pps)
}
pub fn has_sps_pps(data: &[u8]) -> bool {
let mut has_sps = false;
let mut has_pps = false;
let mut i = 0;
while i < data.len() {
let start_code_len = if i + 4 <= data.len()
&& data[i] == 0
&& data[i + 1] == 0
&& data[i + 2] == 0
&& data[i + 3] == 1
{
4
} else if i + 3 <= data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
3
} else {
i += 1;
continue;
};
let nal_start = i + start_code_len;
if nal_start >= data.len() {
break;
}
let nal_type = data[nal_start] & 0x1F;
match nal_type {
7 => has_sps = true,
8 => has_pps = true,
_ => {}
}
if has_sps && has_pps {
return true;
}
i = nal_start + 1;
}
has_sps && has_pps
}
pub fn is_keyframe(data: &[u8]) -> bool {
let mut i = 0;
while i < data.len() {
if i + 3 < data.len() && data[i] == 0 && data[i + 1] == 0 {
let nal_start = if data[i + 2] == 1 {
i + 3
} else if i + 4 < data.len() && data[i + 2] == 0 && data[i + 3] == 1 {
i + 4
} else {
i += 1;
continue;
};
if nal_start < data.len() {
let nal_type = data[nal_start] & 0x1F;
if nal_type == 5 {
return true;
}
}
i = nal_start;
} else {
i += 1;
}
}
false
}
/// `profile-level-id` hex for SDP (`42001f` etc.); expects SPS NAL without start code.
pub fn parse_profile_level_id_from_sps(sps: &[u8]) -> Option<String> {
if sps.len() < 4 {
return None;
}
let profile_idc = sps[1];
let constraint_set_flags = sps[2];
let level_idc = sps[3];
Some(format!(
"{:02x}{:02x}{:02x}",
profile_idc, constraint_set_flags, level_idc
))
}
pub fn extract_profile_level_id(data: &[u8]) -> Option<String> {
let (sps, _) = extract_sps_pps(data);
sps.and_then(|sps_data| parse_profile_level_id_from_sps(&sps_data))
}
pub fn is_annex_b(data: &[u8]) -> bool {
data.starts_with(&[0, 0, 1]) || data.starts_with(&[0, 0, 0, 1])
}
pub fn avcc_to_annex_b(data: &[u8]) -> Option<Vec<u8>> {
let mut pos = 0;
let mut output = Vec::with_capacity(data.len() + 16);
let mut nalu_count = 0usize;
while pos + 4 <= data.len() {
let nalu_len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
if nalu_len == 0 || pos + nalu_len > data.len() {
return None;
}
let nal_type = data[pos] & 0x1F;
if nal_type != 9 && nal_type != 12 {
output.extend_from_slice(&[0, 0, 0, 1]);
output.extend_from_slice(&data[pos..pos + nalu_len]);
}
nalu_count += 1;
pos += nalu_len;
}
if pos == data.len() && nalu_count > 0 && !output.is_empty() {
Some(output)
} else {
None
}
}
pub fn normalize_for_webrtc(data: &[u8]) -> Vec<u8> {
if is_annex_b(data) {
return strip_aud_nal_units(data);
}
if let Some(annex_b) = avcc_to_annex_b(data) {
return strip_aud_nal_units(&annex_b);
}
data.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_h264_keyframes() {
let idr_frame = vec![0x00, 0x00, 0x00, 0x01, 0x65];
assert!(is_keyframe(&idr_frame));
let idr_frame_3 = vec![0x00, 0x00, 0x01, 0x65];
assert!(is_keyframe(&idr_frame_3));
let p_frame = vec![0x00, 0x00, 0x00, 0x01, 0x41];
assert!(!is_keyframe(&p_frame));
let sps = vec![0x00, 0x00, 0x00, 0x01, 0x67];
assert!(!is_keyframe(&sps));
let multi_nal = vec![
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f, 0x00, 0x00, 0x00, 0x01, 0x68, 0xce,
0x38, 0x80, 0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84,
];
assert!(is_keyframe(&multi_nal));
}
#[test]
fn parses_profile_level_id_from_sps() {
assert_eq!(
parse_profile_level_id_from_sps(&[0x67, 0x42, 0x40, 0x2a]),
Some("42402a".to_string())
);
}
}

View File

@@ -347,7 +347,7 @@ impl JpegEncoder {
}
}
impl crate::video::encoder::traits::Encoder for JpegEncoder {
impl crate::video::codec::traits::Encoder for JpegEncoder {
fn name(&self) -> &str {
"JPEG (libyuv+turbojpeg)"
}

View File

@@ -5,7 +5,7 @@ use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::video::convert::Nv12Converter;
use crate::video::codec::convert::Nv12Converter;
use crate::video::format::Resolution;
pub struct MjpegRkmppDecoder {

View File

@@ -1,57 +1,45 @@
//! Video encoder implementations
//!
//! This module provides video encoding capabilities including:
//! - JPEG encoding for raw frames (YUYV, NV12, etc.)
//! - H264 encoding (hardware + software)
//! - H265 encoding (hardware + software)
//! - VP8 encoding (hardware + software)
//! - VP9 encoding (hardware + software)
//! - WebRTC video codec abstraction
//! - Encoder registry for automatic detection
//! Video codec, conversion, encoding, and decoding implementations.
use hwcodec::common::DataFormat;
use hwcodec::ffmpeg_ram::CodecInfo;
pub mod codec;
pub mod convert;
pub mod h264;
pub mod h264_bitstream;
pub mod h265;
pub mod jpeg;
pub mod registry;
pub mod self_check;
pub mod traits;
pub mod video_codec;
pub mod vp8;
pub mod vp9;
// Core traits and types
pub use traits::{
BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory,
};
pub mod mjpeg_turbo;
// WebRTC codec abstraction
pub use codec::{CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
pub mod mjpeg_rkmpp;
// Encoder registry
pub use convert::{PixelConverter, Yuv420pBuffer};
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
pub use jpeg::JpegEncoder;
pub use mjpeg_turbo::MjpegTurboDecoder;
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
pub use self_check::{
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
VideoEncoderSelfCheckCodec, VideoEncoderSelfCheckResponse, VideoEncoderSelfCheckRow,
};
// H264 encoder
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
// H265 encoder
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
// VP8 encoder
pub use traits::{
BitratePreset, EncodedFormat, EncodedFrame, Encoder, EncoderConfig, EncoderFactory,
};
pub use video_codec::{
CodecFrame, VideoCodec, VideoCodecConfig, VideoCodecFactory, VideoCodecType,
};
pub use vp8::{VP8Config, VP8Encoder, VP8EncoderType, VP8InputFormat};
// VP9 encoder
pub use vp9::{VP9Config, VP9Encoder, VP9EncoderType, VP9InputFormat};
// JPEG encoder
pub use jpeg::JpegEncoder;
pub(crate) fn select_codec_for_format<F>(
encoders: &[CodecInfo],
format: DataFormat,

View File

@@ -258,7 +258,7 @@ pub trait VideoCodec: Send {
/// Get SDP fmtp parameters (codec-specific)
///
/// For H264: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"
/// For H264: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=<sps>"
/// For VP8/VP9: None or empty
fn sdp_fmtp(&self) -> Option<String>;

View File

@@ -1,7 +1,7 @@
use crate::config::{AppConfig, RtspCodec, StreamMode};
use crate::error::Result;
use crate::video::encoder::registry::VideoEncoderType;
use crate::video::encoder::VideoCodecType;
use crate::video::codec::registry::VideoEncoderType;
use crate::video::codec::VideoCodecType;
use crate::video::VideoStreamManager;
use std::sync::Arc;

View File

@@ -1,7 +0,0 @@
//! Video decoder implementations
//!
//! This module provides video decoding capabilities.
pub mod mjpeg_turbo;
pub use mjpeg_turbo::MjpegTurboDecoder;

View File

@@ -17,7 +17,7 @@ use v4l2r::bindings::{
use v4l2r::ioctl::{self, Event as V4l2Event, EventType, QueryDvTimingsError, SubscribeEventFlags};
use v4l2r::nix::errno::Errno;
use crate::video::SignalStatus;
use crate::video::signal::SignalStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CsiBridgeKind {

View File

@@ -0,0 +1,80 @@
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::video::signal::SignalStatus;
pub const RK628_SUBDEV_PROBE_TIMEOUT: Duration = Duration::from_millis(3000);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CsiBridgeKind {
Rk628,
RkHdmirx,
Tc358743,
Unknown,
}
#[derive(Debug, Clone)]
pub enum ProbeResult {
Locked(DvTimingsMode),
NoCable,
NoSync,
OutOfRange,
NoSignal,
}
impl ProbeResult {
pub fn as_status(&self) -> Option<SignalStatus> {
match self {
ProbeResult::Locked(_) => None,
ProbeResult::NoCable => Some(SignalStatus::NoCable),
ProbeResult::NoSync => Some(SignalStatus::NoSync),
ProbeResult::OutOfRange => Some(SignalStatus::OutOfRange),
ProbeResult::NoSignal => Some(SignalStatus::NoSignal),
}
}
pub fn is_locked(&self) -> bool {
matches!(self, ProbeResult::Locked(_))
}
}
#[derive(Debug, Clone, Copy)]
pub struct DvTimingsMode {
pub width: u32,
pub height: u32,
pub pixelclock: u64,
pub fps: Option<f64>,
pub raw: (),
}
pub fn discover_subdev_for_video(_video_path: &Path) -> Option<(PathBuf, CsiBridgeKind)> {
None
}
pub fn open_subdev(path: &Path) -> io::Result<File> {
File::open(path)
}
pub fn probe_signal(_subdev_fd: &File, _kind: CsiBridgeKind) -> ProbeResult {
ProbeResult::NoSignal
}
pub fn probe_signal_thread_timeout(
_subdev_fd: &File,
_kind: CsiBridgeKind,
_timeout: Duration,
) -> Option<ProbeResult> {
Some(ProbeResult::NoSignal)
}
pub fn apply_dv_timings(_subdev_fd: &File, _timings: ()) {}
pub fn subscribe_source_change(_subdev_fd: &File) -> io::Result<()> {
Ok(())
}
pub fn wait_source_change(_subdev_fd: &File, _timeout: Duration) -> io::Result<bool> {
Ok(false)
}

View File

@@ -16,10 +16,10 @@ use v4l2r::ioctl::{
use v4l2r::nix::errno::Errno;
use v4l2r::{Format as V4l2rFormat, QueueType};
use super::csi_bridge;
use super::format::{PixelFormat, Resolution};
use super::bridge as csi_bridge;
use super::{is_rk_hdmirx_driver, is_rkcif_driver};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
/// Per-node probe limit; rkcif/RK628 ioctl chains can exceed 1s under contention.
const DEVICE_PROBE_TIMEOUT_MS: u64 = 10_000;

35
src/video/device/mod.rs Normal file
View File

@@ -0,0 +1,35 @@
//! Video device discovery, capability probing, and platform adapters.
#[cfg(unix)]
mod linux;
#[cfg(windows)]
mod windows;
#[cfg(unix)]
pub use linux::*;
#[cfg(windows)]
pub use windows::*;
#[cfg(unix)]
pub mod bridge;
#[cfg(windows)]
#[path = "disabled_bridge.rs"]
pub mod bridge;
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx")
}
pub(crate) fn is_rk_hdmirx_device(device: &VideoDeviceInfo) -> bool {
is_rk_hdmirx_driver(&device.driver, &device.card)
}
pub(crate) fn is_rkcif_driver(driver: &str) -> bool {
driver.eq_ignore_ascii_case("rkcif")
}
/// Unified check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, etc.)
/// that require special enumeration and format-selection logic.
pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool {
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
}

359
src/video/device/windows.rs Normal file
View File

@@ -0,0 +1,359 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoDeviceInfo {
pub path: PathBuf,
pub name: String,
pub driver: String,
pub bus_info: String,
pub card: String,
pub formats: Vec<FormatInfo>,
pub capabilities: DeviceCapabilities,
pub is_capture_card: bool,
pub priority: u32,
pub has_signal: bool,
pub subdev_path: Option<PathBuf>,
pub bridge_kind: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VideoDeviceRecoveryHint {
pub path: PathBuf,
pub name: String,
pub driver: String,
pub bus_info: String,
pub card: String,
pub is_capture_card: bool,
}
impl From<&VideoDeviceInfo> for VideoDeviceRecoveryHint {
fn from(device: &VideoDeviceInfo) -> Self {
Self {
path: device.path.clone(),
name: device.name.clone(),
driver: device.driver.clone(),
bus_info: device.bus_info.clone(),
card: device.card.clone(),
is_capture_card: device.is_capture_card,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatInfo {
pub format: PixelFormat,
pub resolutions: Vec<ResolutionInfo>,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolutionInfo {
pub width: u32,
pub height: u32,
pub fps: Vec<f64>,
}
impl ResolutionInfo {
pub fn new(width: u32, height: u32, fps: Vec<f64>) -> Self {
Self { width, height, fps }
}
pub fn resolution(&self) -> Resolution {
Resolution::new(self.width, self.height)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceCapabilities {
pub video_capture: bool,
pub video_capture_mplane: bool,
pub video_output: bool,
pub streaming: bool,
pub read_write: bool,
}
pub struct VideoDevice {
pub path: PathBuf,
}
pub(crate) const DIRECTSHOW_DEVICE_PREFIX: &str = "dshow:";
impl VideoDevice {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = normalize_windows_device_path(path.as_ref());
if enumerate_devices()?
.iter()
.any(|device| device.path == path)
{
Ok(Self { path })
} else {
Err(AppError::VideoError(format!(
"Windows video device not found: {}",
path.display()
)))
}
}
pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
Self::open(path)
}
pub fn info(&self) -> Result<VideoDeviceInfo> {
enumerate_devices()?
.into_iter()
.find(|device| device.path == self.path)
.ok_or_else(|| {
AppError::VideoError(format!(
"Windows video device not found: {}",
self.path.display()
))
})
}
}
pub(crate) fn normalize_windows_device_path(path: impl AsRef<Path>) -> PathBuf {
let raw = path.as_ref().to_string_lossy();
if raw.eq_ignore_ascii_case("auto") {
return find_best_device()
.map(|device| device.path)
.unwrap_or_else(|_| PathBuf::from(raw.as_ref()));
}
PathBuf::from(raw.as_ref())
}
pub(crate) fn directshow_display_name_from_path(path: impl AsRef<Path>) -> Option<String> {
path.as_ref()
.to_string_lossy()
.strip_prefix(DIRECTSHOW_DEVICE_PREFIX)
.map(str::to_string)
}
pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
let names = hwcodec::capture::list_dshow_video_devices().map_err(|e| {
AppError::VideoError(format!("Failed to enumerate DirectShow devices: {}", e))
})?;
let mut devices = names
.into_iter()
.enumerate()
.map(|(index, name)| directshow_device_from_name(index, name))
.collect::<Vec<_>>();
devices.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.name.cmp(&b.name))
});
Ok(devices)
}
pub fn find_best_device() -> Result<VideoDeviceInfo> {
enumerate_devices()?.into_iter().next().ok_or_else(|| {
AppError::VideoError("No DirectShow video capture devices found".to_string())
})
}
pub fn parse_bridge_kind(value: Option<&str>) -> Option<super::bridge::CsiBridgeKind> {
value.and_then(|_| None)
}
pub fn select_recovery_device(
devices: &[VideoDeviceInfo],
hint: &VideoDeviceRecoveryHint,
) -> Option<VideoDeviceInfo> {
devices
.iter()
.find(|device| device.path == hint.path || device.bus_info == hint.bus_info)
.cloned()
}
fn directshow_device_from_name(index: usize, name: String) -> VideoDeviceInfo {
let name = if name.trim().is_empty() {
format!("Windows Capture Device {}", index + 1)
} else {
name
};
let path = PathBuf::from(format!("{}{}", DIRECTSHOW_DEVICE_PREFIX, name));
let formats = enumerate_directshow_formats(&name);
let priority = score_capture_device(&name, &path.to_string_lossy(), &formats);
VideoDeviceInfo {
path,
name: name.clone(),
driver: "directshow".to_string(),
bus_info: name.clone(),
card: name,
formats,
capabilities: DeviceCapabilities {
video_capture: true,
video_capture_mplane: false,
video_output: false,
streaming: true,
read_write: false,
},
is_capture_card: true,
priority,
has_signal: true,
subdev_path: None,
bridge_kind: None,
}
}
fn enumerate_directshow_formats(name: &str) -> Vec<FormatInfo> {
let Ok(capabilities) = hwcodec::capture::list_dshow_device_capabilities(name) else {
return fallback_windows_formats();
};
let mut formats: Vec<FormatInfo> = Vec::new();
for capability in capabilities {
let Some(format) = map_capture_format(capability.format) else {
continue;
};
if capability.width == 0 || capability.height == 0 {
continue;
}
if let Some(existing) = formats.iter_mut().find(|info| info.format == format) {
merge_resolution(
&mut existing.resolutions,
capability.width,
capability.height,
&capability.fps,
);
continue;
}
let mut resolutions = Vec::new();
merge_resolution(
&mut resolutions,
capability.width,
capability.height,
&capability.fps,
);
formats.push(FormatInfo {
format,
resolutions,
description: format_description(format).to_string(),
});
}
for info in &mut formats {
info.resolutions.sort_by(|left, right| {
b_pixels(right)
.cmp(&b_pixels(left))
.then_with(|| right.width.cmp(&left.width))
.then_with(|| right.height.cmp(&left.height))
});
}
formats.sort_by(|a, b| {
b.format
.priority()
.cmp(&a.format.priority())
.then_with(|| a.description.cmp(&b.description))
});
if formats.is_empty() {
fallback_windows_formats()
} else {
formats
}
}
fn merge_resolution(resolutions: &mut Vec<ResolutionInfo>, width: u32, height: u32, fps: &[u32]) {
if let Some(existing) = resolutions
.iter_mut()
.find(|resolution| resolution.width == width && resolution.height == height)
{
existing.fps.extend(fps.iter().map(|value| *value as f64));
normalize_fps_list(&mut existing.fps);
return;
}
let mut fps_values = fps.iter().map(|value| *value as f64).collect::<Vec<_>>();
normalize_fps_list(&mut fps_values);
resolutions.push(ResolutionInfo::new(width, height, fps_values));
}
fn b_pixels(resolution: &ResolutionInfo) -> u32 {
resolution.width.saturating_mul(resolution.height)
}
fn map_capture_format(format: hwcodec::capture::CapturePixelFormat) -> Option<PixelFormat> {
match format {
hwcodec::capture::CapturePixelFormat::Mjpeg => Some(PixelFormat::Mjpeg),
hwcodec::capture::CapturePixelFormat::Jpeg => Some(PixelFormat::Jpeg),
hwcodec::capture::CapturePixelFormat::Yuyv => Some(PixelFormat::Yuyv),
hwcodec::capture::CapturePixelFormat::Yvyu => Some(PixelFormat::Yvyu),
hwcodec::capture::CapturePixelFormat::Uyvy => Some(PixelFormat::Uyvy),
hwcodec::capture::CapturePixelFormat::Nv12 => Some(PixelFormat::Nv12),
hwcodec::capture::CapturePixelFormat::Nv21 => Some(PixelFormat::Nv21),
hwcodec::capture::CapturePixelFormat::Nv16 => Some(PixelFormat::Nv16),
hwcodec::capture::CapturePixelFormat::Nv24 => Some(PixelFormat::Nv24),
hwcodec::capture::CapturePixelFormat::Yuv420 => Some(PixelFormat::Yuv420),
hwcodec::capture::CapturePixelFormat::Yvu420 => Some(PixelFormat::Yvu420),
hwcodec::capture::CapturePixelFormat::Rgb24 => Some(PixelFormat::Rgb24),
hwcodec::capture::CapturePixelFormat::Bgr24 => Some(PixelFormat::Bgr24),
hwcodec::capture::CapturePixelFormat::Grey => Some(PixelFormat::Grey),
hwcodec::capture::CapturePixelFormat::Unknown => None,
}
}
fn normalize_fps_list(fps_list: &mut Vec<f64>) {
fps_list.retain(|fps| fps.is_finite() && *fps > 0.0);
for fps in fps_list.iter_mut() {
*fps = (*fps * 100.0).round() / 100.0;
}
fps_list.sort_by(|a, b| b.total_cmp(a));
fps_list.dedup_by(|a, b| (*a - *b).abs() < 0.01);
}
fn format_description(format: PixelFormat) -> &'static str {
match format {
PixelFormat::Mjpeg => "MJPEG",
PixelFormat::Jpeg => "JPEG",
PixelFormat::Yuyv => "YUYV 4:2:2",
PixelFormat::Yvyu => "YVYU 4:2:2",
PixelFormat::Uyvy => "UYVY 4:2:2",
PixelFormat::Nv12 => "NV12",
PixelFormat::Nv21 => "NV21",
PixelFormat::Nv16 => "NV16",
PixelFormat::Nv24 => "NV24",
PixelFormat::Yuv420 => "YUV420",
PixelFormat::Yvu420 => "YVU420",
PixelFormat::Rgb565 => "RGB565",
PixelFormat::Rgb24 => "RGB24",
PixelFormat::Bgr24 => "BGR24",
PixelFormat::Grey => "GREY",
}
}
fn score_capture_device(name: &str, device_id: &str, formats: &[FormatInfo]) -> u32 {
let haystack = format!("{} {}", name, device_id).to_ascii_lowercase();
let mut score = 50;
if formats
.iter()
.any(|format| format.format == PixelFormat::Mjpeg)
{
score += 25;
}
for keyword in ["capture", "hdmi", "uvc", "video", "usb"] {
if haystack.contains(keyword) {
score += 10;
}
}
score
}
fn fallback_windows_formats() -> Vec<FormatInfo> {
vec![FormatInfo {
format: PixelFormat::Mjpeg,
resolutions: Vec::new(),
description: "DirectShow auto-detected stream format".to_string(),
}]
}

View File

@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use std::fmt;
#[cfg(unix)]
use v4l2r::PixelFormat as V4l2rPixelFormat;
/// Supported pixel formats
@@ -85,11 +86,13 @@ impl PixelFormat {
}
/// Convert to v4l2r PixelFormat
#[cfg(unix)]
pub fn to_v4l2r(&self) -> V4l2rPixelFormat {
V4l2rPixelFormat::from(&self.to_fourcc())
}
/// Convert from v4l2r PixelFormat
#[cfg(unix)]
pub fn from_v4l2r(format: V4l2rPixelFormat) -> Option<Self> {
let repr: [u8; 4] = format.into();
Self::from_fourcc(repr)

View File

@@ -2,81 +2,30 @@
//!
//! This module provides V4L2 video capture, encoding, and streaming functionality.
pub(crate) mod capture_limits;
pub(crate) mod capture_status;
pub mod capture;
pub mod codec;
pub mod codec_constraints;
pub mod convert;
pub mod csi_bridge;
pub mod decoder;
pub mod device;
pub mod encoder;
pub mod format;
pub mod frame;
pub mod shared_video_pipeline;
pub mod pipeline;
pub mod signal;
pub mod stream_manager;
pub mod streamer;
pub mod traits;
pub mod types;
pub mod usb_reset;
pub mod v4l2r_capture;
pub use convert::{PixelConverter, Yuv420pBuffer};
pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer};
pub use device::{VideoDevice, VideoDeviceInfo};
pub use encoder::{H264Encoder, H264EncoderType, JpegEncoder};
pub use format::PixelFormat;
pub use frame::VideoFrame;
pub use shared_video_pipeline::{
pub use pipeline::{
EncodedVideoFrame, SharedVideoPipeline, SharedVideoPipelineConfig, SharedVideoPipelineStats,
};
pub use signal::SignalStatus;
pub use stream_manager::VideoStreamManager;
pub use streamer::{Streamer, StreamerState};
/// Fine-grained signal status reported by CSI/HDMI bridge devices.
///
/// Only `rk_hdmirx` / `rkcif` / tc358743-class bridges can distinguish these
/// via `VIDIOC_QUERY_DV_TIMINGS` errno; USB UVC devices always report `Ok`
/// until they fail with a generic timeout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignalStatus {
/// HDMI cable physically disconnected (`ENOLINK`).
NoCable,
/// TMDS signal present but timings cannot be locked (`ENOLCK`).
NoSync,
/// Timings outside of hardware capability (`ERANGE`).
OutOfRange,
/// Generic "no usable source" (fallback for EINVAL / EIO / unknown errnos).
NoSignal,
/// UVC/USB isochronous protocol error (common kernel: status -71 / userspace EPROTO).
UvcUsbError,
/// UVC capture stalled (repeated DQBUF timeouts; often cable, hub, or controller load).
UvcCaptureStall,
}
impl SignalStatus {
pub fn as_str(self) -> &'static str {
match self {
SignalStatus::NoCable => "no_cable",
SignalStatus::NoSync => "no_sync",
SignalStatus::OutOfRange => "out_of_range",
SignalStatus::NoSignal => "no_signal",
SignalStatus::UvcUsbError => "uvc_usb_error",
SignalStatus::UvcCaptureStall => "uvc_capture_stall",
}
}
pub fn from_str(s: &str) -> Option<Self> {
Some(match s {
"no_cable" => SignalStatus::NoCable,
"no_sync" => SignalStatus::NoSync,
"out_of_range" => SignalStatus::OutOfRange,
"no_signal" => SignalStatus::NoSignal,
"uvc_usb_error" => SignalStatus::UvcUsbError,
"uvc_capture_stall" => SignalStatus::UvcCaptureStall,
_ => return None,
})
}
}
impl From<SignalStatus> for streamer::StreamerState {
fn from(value: SignalStatus) -> Self {
match value {
@@ -89,21 +38,3 @@ impl From<SignalStatus> for streamer::StreamerState {
}
}
}
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx")
}
pub(crate) fn is_rk_hdmirx_device(device: &device::VideoDeviceInfo) -> bool {
is_rk_hdmirx_driver(&device.driver, &device.card)
}
pub(crate) fn is_rkcif_driver(driver: &str) -> bool {
driver.eq_ignore_ascii_case("rkcif")
}
/// Unified check for CSI/HDMI bridge devices (rk_hdmirx, rkcif, etc.)
/// that require special enumeration and format-selection logic.
pub(crate) fn is_csi_hdmi_bridge(device: &device::VideoDeviceInfo) -> bool {
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
}

View File

@@ -1,12 +1,12 @@
use crate::error::{AppError, Result};
use crate::video::convert::{Nv12Converter, PixelConverter};
use crate::video::decoder::MjpegTurboDecoder;
use crate::video::encoder::h264::{H264Config, H264Encoder, H264InputFormat};
use crate::video::encoder::h265::{H265Config, H265Encoder, H265InputFormat};
use crate::video::encoder::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use crate::video::encoder::traits::EncoderConfig;
use crate::video::encoder::vp8::{VP8Config, VP8Encoder};
use crate::video::encoder::vp9::{VP9Config, VP9Encoder};
use crate::video::codec::convert::{Nv12Converter, PixelConverter};
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
use crate::video::codec::traits::EncoderConfig;
use crate::video::codec::vp8::{VP8Config, VP8Encoder};
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
use crate::video::codec::MjpegTurboDecoder;
use crate::video::format::{PixelFormat, Resolution};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::{
@@ -14,7 +14,7 @@ use hwcodec::ffmpeg_hw::{
};
use tracing::info;
use super::SharedVideoPipelineConfig;
use super::shared::SharedVideoPipelineConfig;
pub(super) struct EncoderThreadState {
pub(super) encoder: Option<Box<dyn VideoEncoderTrait + Send>>,

View File

@@ -0,0 +1,9 @@
//! Video processing pipelines.
mod encoder_state;
mod shared;
pub use shared::{
EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig,
SharedVideoPipelineStats,
};

View File

@@ -16,8 +16,6 @@
//! Session1 Session2 Session3 ...
//! ```
mod encoder_state;
use bytes::Bytes;
use parking_lot::Mutex as ParkingMutex;
use parking_lot::RwLock as ParkingRwLock;
@@ -28,7 +26,7 @@ use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, Mutex, RwLock};
use tracing::{debug, error, info, trace, warn};
use self::encoder_state::{build_encoder_state, EncoderThreadState};
use super::encoder_state::{build_encoder_state, EncoderThreadState};
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
@@ -41,20 +39,27 @@ const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20);
/// 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_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE};
use crate::video::capture_status::{
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::csi_bridge::{self, ProbeResult};
use crate::video::capture::{is_source_changed_error, BridgeContext, CaptureStream};
use crate::video::codec::h264_bitstream;
use crate::video::codec::registry::{EncoderBackend, VideoEncoderType};
use crate::video::device::bridge::{self as csi_bridge, ProbeResult};
use crate::video::device::parse_bridge_kind;
use crate::video::encoder::registry::{EncoderBackend, VideoEncoderType};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use crate::video::v4l2r_capture::{is_source_changed_error, BridgeContext, V4l2rCaptureStream};
use crate::video::SignalStatus;
use crate::video::signal::SignalStatus;
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
@@ -122,7 +127,7 @@ pub struct SharedVideoPipelineConfig {
/// Output codec type
pub output_codec: VideoEncoderType,
/// Bitrate preset (replaces raw bitrate_kbps)
pub bitrate_preset: crate::video::encoder::BitratePreset,
pub bitrate_preset: crate::video::codec::BitratePreset,
/// Target FPS
pub fps: u32,
/// Encoder backend (None = auto select best available)
@@ -135,7 +140,7 @@ impl Default for SharedVideoPipelineConfig {
resolution: Resolution::HD720,
input_format: PixelFormat::Yuyv,
output_codec: VideoEncoderType::H264,
bitrate_preset: crate::video::encoder::BitratePreset::Balanced,
bitrate_preset: crate::video::codec::BitratePreset::Balanced,
fps: 30,
encoder_backend: None,
}
@@ -154,7 +159,7 @@ impl SharedVideoPipelineConfig {
}
/// Create H264 config with bitrate preset
pub fn h264(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
pub fn h264(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::H264,
@@ -164,7 +169,7 @@ impl SharedVideoPipelineConfig {
}
/// Create H265 config with bitrate preset
pub fn h265(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
pub fn h265(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::H265,
@@ -174,7 +179,7 @@ impl SharedVideoPipelineConfig {
}
/// Create VP8 config with bitrate preset
pub fn vp8(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
pub fn vp8(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::VP8,
@@ -184,7 +189,7 @@ impl SharedVideoPipelineConfig {
}
/// Create VP9 config with bitrate preset
pub fn vp9(resolution: Resolution, preset: crate::video::encoder::BitratePreset) -> Self {
pub fn vp9(resolution: Resolution, preset: crate::video::codec::BitratePreset) -> Self {
Self {
resolution,
output_codec: VideoEncoderType::VP9,
@@ -195,7 +200,7 @@ impl SharedVideoPipelineConfig {
/// 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::encoder::BitratePreset::from_kbps(bitrate_kbps);
self.bitrate_preset = crate::video::codec::BitratePreset::from_kbps(bitrate_kbps);
self
}
}
@@ -261,6 +266,8 @@ pub struct SharedVideoPipeline {
stats: Mutex<SharedVideoPipelineStats>,
running: watch::Sender<bool>,
running_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,
@@ -268,9 +275,9 @@ pub struct SharedVideoPipeline {
sequence: AtomicU64,
/// Atomic flag for keyframe request (avoids lock contention)
keyframe_requested: AtomicBool,
/// Pipeline start time for PTS calculation (epoch millis, 0 = not set)
/// Uses AtomicI64 instead of Mutex for lock-free access
pipeline_start_time_ms: AtomicI64,
/// 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>>>,
@@ -365,6 +372,7 @@ impl SharedVideoPipeline {
);
let (running_tx, running_rx) = watch::channel(false);
let (h264_profile_tx, h264_profile_rx) = watch::channel(None);
let pipeline = Arc::new(Self {
config: RwLock::new(config),
@@ -372,11 +380,13 @@ impl SharedVideoPipeline {
stats: Mutex::new(SharedVideoPipelineStats::default()),
running: running_tx,
running_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),
pipeline_start_time_ms: AtomicI64::new(0),
pipeline_start_time_us: AtomicI64::new(0),
pending_sync_geometry: ParkingMutex::new(None),
device_lost_reason: ParkingMutex::new(None),
state_notifier: ParkingRwLock::new(None),
@@ -518,6 +528,20 @@ impl SharedVideoPipeline {
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, data: &[u8]) {
let Some(profile_level_id) = h264_bitstream::extract_profile_level_id(data) 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));
}
async fn broadcast_encoded(&self, frame: Arc<EncodedVideoFrame>) {
let subscribers = {
let guard = self.subscribers.read();
@@ -568,7 +592,7 @@ impl SharedVideoPipeline {
subdev_path.clone(),
parse_bridge_kind(bridge_kind.as_deref()),
);
let preopened: Option<V4l2rCaptureStream> = match V4l2rCaptureStream::open_with_bridge(
let preopened: Option<CaptureStream> = match open_capture_stream(
&device_path,
config.resolution,
config.input_format,
@@ -712,7 +736,7 @@ impl SharedVideoPipeline {
let bridge_ctx =
BridgeContext::from_parts(subdev_path, parse_bridge_kind(bridge_kind.as_deref()));
std::thread::spawn(move || {
let mut stream: Option<V4l2rCaptureStream> = None;
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;
@@ -727,7 +751,7 @@ impl SharedVideoPipeline {
stream = Some(s);
}
None => {
match V4l2rCaptureStream::open_with_bridge(
match open_capture_stream(
&device_path,
config.resolution,
config.input_format,
@@ -786,24 +810,13 @@ impl SharedVideoPipeline {
}
}
/// Helper: try to (re)open the capture stream. Returns:
/// * `Ok(Some(stream))` — opened successfully
/// * `Ok(None)` — CaptureNoSignal, keep retrying later
/// * `Err(())` — fatal (stop pipeline)
enum OpenResult {
Opened(V4l2rCaptureStream),
NoSignal(SignalStatus),
DeviceLost(String),
Fatal,
}
fn open_or_retry(
device_path: &std::path::Path,
config: &SharedVideoPipelineConfig,
buffer_count: u32,
bridge_ctx: BridgeContext,
) -> OpenResult {
match V4l2rCaptureStream::open_with_bridge(
) -> CaptureOpenResult {
match open_capture_stream_for_retry(
device_path,
config.resolution,
config.input_format,
@@ -811,28 +824,27 @@ impl SharedVideoPipeline {
buffer_count.max(1),
Duration::from_secs(2),
bridge_ctx,
is_device_lost_message,
) {
Ok(s) => OpenResult::Opened(s),
Err(AppError::CaptureNoSignal { kind }) => {
debug!("Capture soft-restart: still no signal ({})", kind);
OpenResult::NoSignal(signal_status_from_capture_kind(&kind))
CaptureOpenResult::NoSignal(status) => {
debug!("Capture soft-restart: still no signal ({:?})", status);
CaptureOpenResult::NoSignal(status)
}
Err(e) => {
let reason = e.to_string();
if is_device_lost_message(&reason) {
error!("Capture device lost during soft-restart: {}", e);
return OpenResult::DeviceLost(reason);
}
error!("Capture soft-restart failed: {}", e);
OpenResult::Fatal
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 grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS);
let mut sequence: u64 = 0;
let mut validate_counter: u64 = 0;
let mut consecutive_timeouts: u32 = 0;
let capture_error_throttler = LogThrottler::with_secs(5);
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
@@ -869,7 +881,7 @@ impl SharedVideoPipeline {
if stream.is_none() {
match open_or_retry(&device_path, &config, buffer_count, bridge_ctx.clone())
{
OpenResult::Opened(new_stream) => {
CaptureOpenResult::Opened(new_stream) => {
let new_res = new_stream.resolution();
let new_fmt = new_stream.format();
let new_stride = new_stream.stride();
@@ -945,7 +957,7 @@ impl SharedVideoPipeline {
resolution.width, resolution.height, pixel_format, stride
);
}
OpenResult::NoSignal(status) => {
CaptureOpenResult::NoSignal(status) => {
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
if consecutive_timeouts >= CAPTURE_TIMEOUT_STOP_THRESHOLD {
warn!(
@@ -966,14 +978,14 @@ impl SharedVideoPipeline {
std::thread::sleep(Duration::from_millis(wait_ms));
continue;
}
OpenResult::DeviceLost(reason) => {
CaptureOpenResult::DeviceLost(reason) => {
pipeline.mark_device_lost(reason);
let _ = pipeline.running.send(false);
pipeline.running_flag.store(false, Ordering::Release);
let _ = frame_seq_tx.send(sequence.wrapping_add(1));
break;
}
OpenResult::Fatal => {
CaptureOpenResult::Fatal => {
let _ = pipeline.running.send(false);
pipeline.running_flag.store(false, Ordering::Release);
let _ = frame_seq_tx.send(sequence.wrapping_add(1));
@@ -1194,14 +1206,6 @@ impl SharedVideoPipeline {
continue;
}
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& should_validate_jpeg_frame(validate_counter)
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
{
continue;
}
owned.truncate(frame_size);
// Notify streaming only after frame validation passes —
// stale/warm-up frames from V4L2 kernel queues can cause
@@ -1246,27 +1250,22 @@ impl SharedVideoPipeline {
let input_format = state.input_format;
let raw_frame = frame.data();
// Calculate PTS from real capture timestamp (lock-free using AtomicI64)
// This ensures smooth playback even when capture timing varies
let frame_ts_ms = frame.capture_ts.elapsed().as_millis() as i64;
// Convert Instant to a comparable value (negate elapsed to get "time since epoch")
let current_ts_ms = -(frame_ts_ms);
// Try to set start time if not yet set (first frame wins)
let start_ts = self.pipeline_start_time_ms.load(Ordering::Acquire);
let pts_ms = if start_ts == 0 {
// First frame - try to set the start time
// Use compare_exchange to ensure only one thread sets it
let _ = self.pipeline_start_time_ms.compare_exchange(
let process_start = PROCESS_START.get_or_init(Instant::now);
let current_ts_us = process_start.elapsed().as_micros() as i64;
let start_ts_us = self.pipeline_start_time_us.load(Ordering::Acquire);
let pts_ms = if start_ts_us == 0 {
let start_ts_us = match self.pipeline_start_time_us.compare_exchange(
0,
current_ts_ms,
current_ts_us,
Ordering::AcqRel,
Ordering::Acquire,
);
0 // First frame has PTS 0
) {
Ok(_) => current_ts_us,
Err(existing) => existing,
};
current_ts_us.saturating_sub(start_ts_us) / 1000
} else {
// Subsequent frames: PTS = current - start
current_ts_ms - start_ts
current_ts_us.saturating_sub(start_ts_us) / 1000
};
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
@@ -1370,6 +1369,9 @@ impl SharedVideoPipeline {
let encoded = frames.into_iter().next().unwrap();
let is_keyframe = encoded.key == 1;
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
if codec == VideoEncoderType::H264 {
self.update_h264_profile_level_id(&encoded.data);
}
// Debug log for H265 encoded frame
if codec == VideoEncoderType::H265 && (is_keyframe || frame_count % 30 == 1) {
@@ -1464,7 +1466,7 @@ impl SharedVideoPipeline {
/// Set bitrate using preset
pub async fn set_bitrate_preset(
&self,
preset: crate::video::encoder::BitratePreset,
preset: crate::video::codec::BitratePreset,
) -> Result<()> {
let bitrate_kbps = preset.bitrate_kbps();
let gop = {
@@ -1478,7 +1480,7 @@ impl SharedVideoPipeline {
/// Set bitrate using raw kbps value (converts to appropriate preset)
pub async fn set_bitrate(&self, bitrate_kbps: u32) -> Result<()> {
let preset = crate::video::encoder::BitratePreset::from_kbps(bitrate_kbps);
let preset = crate::video::codec::BitratePreset::from_kbps(bitrate_kbps);
self.set_bitrate_preset(preset).await
}
@@ -1549,7 +1551,7 @@ fn parse_h265_nal_types(data: &[u8]) -> Vec<(u8, usize)> {
#[cfg(test)]
mod tests {
use super::*;
use crate::video::encoder::BitratePreset;
use crate::video::codec::BitratePreset;
#[test]
fn test_pipeline_config() {

47
src/video/signal.rs Normal file
View File

@@ -0,0 +1,47 @@
//! Video signal status classification.
/// Fine-grained signal status reported by CSI/HDMI bridge devices.
///
/// Only `rk_hdmirx` / `rkcif` / tc358743-class bridges can distinguish these
/// via `VIDIOC_QUERY_DV_TIMINGS` errno; USB UVC devices always report `Ok`
/// until they fail with a generic timeout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignalStatus {
/// HDMI cable physically disconnected (`ENOLINK`).
NoCable,
/// TMDS signal present but timings cannot be locked (`ENOLCK`).
NoSync,
/// Timings outside of hardware capability (`ERANGE`).
OutOfRange,
/// Generic "no usable source" (fallback for EINVAL / EIO / unknown errnos).
NoSignal,
/// UVC/USB isochronous protocol error (common kernel: status -71 / userspace EPROTO).
UvcUsbError,
/// UVC capture stalled (repeated DQBUF timeouts; often cable, hub, or controller load).
UvcCaptureStall,
}
impl SignalStatus {
pub fn as_str(self) -> &'static str {
match self {
SignalStatus::NoCable => "no_cable",
SignalStatus::NoSync => "no_sync",
SignalStatus::OutOfRange => "out_of_range",
SignalStatus::NoSignal => "no_signal",
SignalStatus::UvcUsbError => "uvc_usb_error",
SignalStatus::UvcCaptureStall => "uvc_capture_stall",
}
}
pub fn from_str(s: &str) -> Option<Self> {
Some(match s {
"no_cable" => SignalStatus::NoCable,
"no_sync" => SignalStatus::NoSync,
"out_of_range" => SignalStatus::OutOfRange,
"no_signal" => SignalStatus::NoSignal,
"uvc_usb_error" => SignalStatus::UvcUsbError,
"uvc_capture_stall" => SignalStatus::UvcCaptureStall,
_ => return None,
})
}
}

View File

@@ -37,8 +37,8 @@ use crate::events::{EventBus, SystemEvent, VideoDeviceInfo};
use crate::hid::HidController;
use crate::stream::MjpegStreamHandler;
use crate::video::codec_constraints::StreamCodecConstraints;
use crate::video::device::is_csi_hdmi_bridge;
use crate::video::format::{PixelFormat, Resolution};
use crate::video::is_csi_hdmi_bridge;
use crate::video::streamer::{Streamer, StreamerState, StreamerStats};
use crate::video::traits::VideoOutput;
@@ -762,9 +762,7 @@ impl VideoStreamManager {
pub async fn subscribe_encoded_frames(
&self,
) -> Option<
tokio::sync::mpsc::Receiver<
std::sync::Arc<crate::video::shared_video_pipeline::EncodedVideoFrame>,
>,
tokio::sync::mpsc::Receiver<std::sync::Arc<crate::video::pipeline::EncodedVideoFrame>>,
> {
// 1. Ensure video capture is initialized (for config discovery)
if self.streamer.state().await == StreamerState::Uninitialized {
@@ -803,12 +801,12 @@ impl VideoStreamManager {
/// Get the current video encoding configuration from the shared pipeline
pub async fn get_encoding_config(
&self,
) -> Option<crate::video::shared_video_pipeline::SharedVideoPipelineConfig> {
) -> Option<crate::video::pipeline::SharedVideoPipelineConfig> {
self.webrtc_streamer.get_pipeline_config().await
}
/// Get current video codec type
pub async fn current_video_codec(&self) -> crate::video::encoder::VideoCodecType {
pub async fn current_video_codec(&self) -> crate::video::codec::VideoCodecType {
self.webrtc_streamer.current_video_codec().await
}
@@ -823,7 +821,7 @@ impl VideoStreamManager {
/// before subscribing to encoded frames.
pub async fn set_video_codec(
&self,
codec: crate::video::encoder::VideoCodecType,
codec: crate::video::codec::VideoCodecType,
) -> crate::error::Result<()> {
self.webrtc_streamer.set_video_codec(codec).await
}
@@ -834,7 +832,7 @@ impl VideoStreamManager {
/// based on client preferences.
pub async fn set_bitrate_preset(
&self,
preset: crate::video::encoder::BitratePreset,
preset: crate::video::codec::BitratePreset,
) -> crate::error::Result<()> {
self.webrtc_streamer.set_bitrate_preset(preset).await
}
@@ -908,19 +906,19 @@ impl VideoStreamManager {
}
/// Convert VideoCodecType to lowercase string for frontend
fn codec_to_string(codec: crate::video::encoder::VideoCodecType) -> String {
fn codec_to_string(codec: crate::video::codec::VideoCodecType) -> String {
match codec {
crate::video::encoder::VideoCodecType::H264 => "h264".to_string(),
crate::video::encoder::VideoCodecType::H265 => "h265".to_string(),
crate::video::encoder::VideoCodecType::VP8 => "vp8".to_string(),
crate::video::encoder::VideoCodecType::VP9 => "vp9".to_string(),
crate::video::codec::VideoCodecType::H264 => "h264".to_string(),
crate::video::codec::VideoCodecType::H265 => "h265".to_string(),
crate::video::codec::VideoCodecType::VP8 => "vp8".to_string(),
crate::video::codec::VideoCodecType::VP9 => "vp9".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::video::encoder::VideoCodecType;
use crate::video::codec::VideoCodecType;
#[test]
fn test_codec_to_string() {

View File

@@ -11,24 +11,25 @@ use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, error, info, trace, warn};
use super::csi_bridge;
use super::device::{
enumerate_devices, find_best_device, parse_bridge_kind, select_recovery_device, VideoDevice,
VideoDeviceInfo, VideoDeviceRecoveryHint,
bridge as csi_bridge, enumerate_devices, find_best_device, is_csi_hdmi_bridge,
parse_bridge_kind, select_recovery_device, VideoDevice, VideoDeviceInfo,
VideoDeviceRecoveryHint,
};
use super::format::{PixelFormat, Resolution};
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use super::is_csi_hdmi_bridge;
use crate::error::{AppError, Result};
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::stream::MjpegStreamHandler;
use crate::utils::LogThrottler;
use crate::video::capture_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE};
use crate::video::capture_status::{
use crate::video::capture::runtime::open_capture_stream;
use crate::video::capture::status::{
capture_error_log_key, classify_capture_io_error, signal_status_from_capture_kind,
CaptureIoErrorKind,
};
use crate::video::v4l2r_capture::{is_source_changed_error, BridgeContext, V4l2rCaptureStream};
use crate::video::capture::{is_source_changed_error, BridgeContext, CaptureStream};
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
/// Streamer configuration
#[derive(Debug, Clone)]
@@ -358,10 +359,17 @@ impl Streamer {
self.publish_event(self.current_state_event().await).await;
let devices = enumerate_devices()?;
let device = devices
.into_iter()
.find(|d| d.path.to_string_lossy() == device_path)
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?;
let device = if device_path.eq_ignore_ascii_case("auto") {
devices
.into_iter()
.next()
.ok_or_else(|| AppError::VideoError("No video devices found".to_string()))?
} else {
devices
.into_iter()
.find(|d| d.path.to_string_lossy() == device_path)
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?
};
let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?;
@@ -853,12 +861,12 @@ impl Streamer {
// On RK628 this prevents a kernel null-pointer deref.
if let Some(subdev_path) = bridge_ctx.subdev_path.as_ref() {
match probe_subdev_signal(subdev_path, bridge_ctx.kind) {
Some(crate::video::SignalStatus::NoCable)
| Some(crate::video::SignalStatus::NoSync)
| Some(crate::video::SignalStatus::NoSignal)
| Some(crate::video::SignalStatus::OutOfRange) => {
Some(crate::video::signal::SignalStatus::NoCable)
| Some(crate::video::signal::SignalStatus::NoSync)
| Some(crate::video::signal::SignalStatus::NoSignal)
| Some(crate::video::signal::SignalStatus::OutOfRange) => {
let status = probe_subdev_signal(subdev_path, bridge_ctx.kind)
.unwrap_or(crate::video::SignalStatus::NoSignal);
.unwrap_or(crate::video::signal::SignalStatus::NoSignal);
let wait_secs = backoff_secs(no_signal_restart_count);
debug!(
"Pre-STREAMON gate: subdev {:?} reports {:?} — \
@@ -884,7 +892,7 @@ impl Streamer {
}
// ── Open the capture stream ─────────────────────────────────────────
let mut stream_opt: Option<V4l2rCaptureStream> = None;
let mut stream_opt: Option<CaptureStream> = None;
let mut last_error: Option<String> = None;
for attempt in 0..MAX_RETRIES {
@@ -893,7 +901,7 @@ impl Streamer {
return;
}
match V4l2rCaptureStream::open_with_bridge(
match open_capture_stream(
&device_path,
config.resolution,
config.format,
@@ -985,7 +993,6 @@ impl Streamer {
let buffer_pool = Arc::new(FrameBufferPool::new(BUFFER_COUNT.max(4) as usize));
let mut signal_present = true;
let mut validate_counter: u64 = 0;
let mut idle_since: Option<std::time::Instant> = None;
let mut fps_frame_count: u64 = 0;
@@ -1091,7 +1098,7 @@ impl Streamer {
break 'capture;
}
CaptureIoErrorKind::TransientSignal { status } => {
if status == Some(crate::video::SignalStatus::UvcUsbError) {
if status == Some(crate::video::signal::SignalStatus::UvcUsbError) {
warn!(
"Capture transient error (EPROTO/-71, often UVC USB): {}",
e
@@ -1145,14 +1152,6 @@ impl Streamer {
continue 'capture;
}
validate_counter = validate_counter.wrapping_add(1);
if pixel_format.is_compressed()
&& should_validate_jpeg_frame(validate_counter)
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
{
continue 'capture;
}
owned.truncate(frame_size);
let frame = VideoFrame::from_pooled(
Arc::new(FrameBuffer::new(owned, Some(buffer_pool.clone()))),
@@ -1275,7 +1274,7 @@ impl Streamer {
// Reset no_signal_since so the back-off timer is fresh for the new session.
// no_signal_since will be re-set if the new session immediately times out.
// Continue 'session → re-open V4l2rCaptureStream with updated config.
// Continue 'session → re-open CaptureStream with updated config.
} // 'session
self.direct_active.store(false, Ordering::SeqCst);
@@ -1580,7 +1579,7 @@ pub struct StreamerStats {
fn probe_subdev_signal(
subdev_path: &std::path::Path,
kind: Option<csi_bridge::CsiBridgeKind>,
) -> Option<crate::video::SignalStatus> {
) -> Option<crate::video::signal::SignalStatus> {
let fd = match csi_bridge::open_subdev(subdev_path) {
Ok(f) => f,
Err(e) => {
@@ -1588,7 +1587,7 @@ fn probe_subdev_signal(
"probe_subdev_signal: failed to open {:?}: {}",
subdev_path, e
);
return Some(crate::video::SignalStatus::NoSignal);
return Some(crate::video::signal::SignalStatus::NoSignal);
}
};
let kind = kind.unwrap_or(csi_bridge::CsiBridgeKind::Unknown);

View File

@@ -9,14 +9,14 @@ pub use super::format::{PixelFormat, Resolution};
// From video::frame
pub use super::frame::VideoFrame;
// From video::encoder (codec-level types)
pub use super::encoder::{BitratePreset, VideoCodecType};
// From video::codec (codec-level types)
pub use super::codec::{BitratePreset, VideoCodecType};
// From video::encoder::registry
pub use super::encoder::registry::{EncoderBackend, VideoEncoderType};
// From video::codec::registry
pub use super::codec::registry::{EncoderBackend, VideoEncoderType};
// From video::shared_video_pipeline
pub use super::shared_video_pipeline::{
// From video::pipeline
pub use super::pipeline::{
EncodedVideoFrame, PipelineStateNotification, SharedVideoPipeline, SharedVideoPipelineConfig,
SharedVideoPipelineStats,
};

View File

@@ -1,205 +0,0 @@
//! USB device enumeration and reset via sysfs `authorized`.
//!
//! Provides APIs for the settings page to list and reset USB devices.
//! Requires write access to `/sys/bus/usb/devices/.../authorized` (typically root).
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Walk up from a V4L sysfs `device` link until we find a USB device node
/// (`busnum` + `devnum` present).
fn usb_device_dir_for_v4l_sysfs(device_link: &Path) -> io::Result<PathBuf> {
let mut p = device_link.canonicalize()?;
loop {
if p.join("busnum").is_file() && p.join("devnum").is_file() {
return Ok(p);
}
p = p
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no USB parent in sysfs"))?
.to_path_buf();
if p.as_os_str().is_empty() || p == Path::new("/") {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"reached sysfs root without USB device",
));
}
}
}
// ---------------------------------------------------------------------------
// USB device enumeration & reset-by-bus/dev (for the settings API)
// ---------------------------------------------------------------------------
use serde::Serialize;
/// Information about a single USB device, read from `/sys/bus/usb/devices/`.
#[derive(Debug, Serialize)]
pub struct UsbDeviceInfo {
/// USB bus number (`busnum` sysfs attribute).
pub bus_num: u32,
/// USB device number on the bus (`devnum` sysfs attribute).
pub dev_num: u32,
/// Vendor ID hex string, e.g. `"1d6b"`.
pub id_vendor: String,
/// Product ID hex string, e.g. `"0002"`.
pub id_product: String,
/// Product name from sysfs `product`.
#[serde(skip_serializing_if = "Option::is_none")]
pub product: Option<String>,
/// Manufacturer name from sysfs `manufacturer`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
/// Speed in Mbps from sysfs `speed`, e.g. `"480"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
/// `true` if authorized=1, `false` if authorized=0, `None` if no file.
#[serde(skip_serializing_if = "Option::is_none")]
pub authorized: Option<bool>,
/// Kernel driver bound to this device (from driver symlink).
#[serde(skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
/// Associated `/dev/videoN` node, if this USB device has a V4L2 child.
#[serde(skip_serializing_if = "Option::is_none")]
pub video_device: Option<String>,
}
/// Read a sysfs string attribute, trimming trailing newline.
fn read_sysfs_str(dir: &Path, attr: &str) -> Option<String> {
std::fs::read_to_string(dir.join(attr))
.ok()
.map(|s| s.trim_end().to_string())
}
/// Read a sysfs u32 attribute.
fn read_sysfs_u32(dir: &Path, attr: &str) -> Option<u32> {
read_sysfs_str(dir, attr).and_then(|s| s.parse().ok())
}
/// Build a map from USB sysfs dir → video device name by scanning
/// `/sys/class/video4linux/`.
fn build_usb_to_video_map() -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let v4l_class = Path::new("/sys/class/video4linux");
let entries = match std::fs::read_dir(v4l_class) {
Ok(e) => e,
Err(_) => return map,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) if s.starts_with("video") => s,
_ => continue,
};
// Resolve the device symlink and walk up to find the USB parent
let device_link = v4l_class.join(name_str).join("device");
if let Ok(usb_dir) = usb_device_dir_for_v4l_sysfs(&device_link) {
if let Some(key) = usb_dir.file_name().and_then(|k| k.to_str()) {
map.insert(key.to_string(), format!("/dev/{}", name_str));
}
}
}
map
}
/// List all USB devices visible in `/sys/bus/usb/devices/`.
pub fn list_usb_devices() -> Vec<UsbDeviceInfo> {
let usb_bus = Path::new("/sys/bus/usb/devices");
let entries = match std::fs::read_dir(usb_bus) {
Ok(e) => e,
Err(_) => return vec![],
};
let video_map = build_usb_to_video_map();
let mut devices: Vec<UsbDeviceInfo> = entries
.flatten()
.filter_map(|entry| {
let dir = entry.path();
// Only consider entries that have busnum + devnum (actual devices, not interfaces)
let bus_num = read_sysfs_u32(&dir, "busnum")?;
let dev_num = read_sysfs_u32(&dir, "devnum")?;
let id_vendor = read_sysfs_str(&dir, "idVendor").unwrap_or_default();
let id_product = read_sysfs_str(&dir, "idProduct").unwrap_or_default();
let product = read_sysfs_str(&dir, "product");
let manufacturer = read_sysfs_str(&dir, "manufacturer");
let speed = read_sysfs_str(&dir, "speed");
let authorized = if dir.join("authorized").exists() {
read_sysfs_str(&dir, "authorized")
.and_then(|s| s.trim().parse::<u8>().ok())
.map(|v| v != 0)
} else {
None
};
let driver = std::fs::read_link(dir.join("driver"))
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string()));
let dir_name = dir.file_name()?.to_str()?.to_string();
let video_device = video_map.get(&dir_name).cloned();
Some(UsbDeviceInfo {
bus_num,
dev_num,
id_vendor,
id_product,
product,
manufacturer,
speed,
authorized,
driver,
video_device,
})
})
.collect();
// Sort by bus, then device number for stable ordering.
devices.sort_by(|a, b| (a.bus_num, a.dev_num).cmp(&(b.bus_num, b.dev_num)));
devices
}
/// Reset a USB device identified by bus/dev numbers via the `authorized` sysfs
/// attribute. After re-authorizing, waits for the device to reappear.
pub fn reset_usb_device(bus_num: u32, dev_num: u32) -> io::Result<()> {
let usb_bus = Path::new("/sys/bus/usb/devices");
let entries = std::fs::read_dir(usb_bus)?;
for entry in entries.flatten() {
let dir = entry.path();
if read_sysfs_u32(&dir, "busnum") != Some(bus_num)
|| read_sysfs_u32(&dir, "devnum") != Some(dev_num)
{
continue;
}
let authorized = dir.join("authorized");
if !authorized.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("device {bus_num}-{dev_num} has no authorized attribute"),
));
}
std::fs::write(&authorized, b"0")?;
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&authorized, b"1")?;
// Wait for device to reappear
let wait_until = Instant::now() + Duration::from_secs(2);
while !dir.join("busnum").exists() {
if Instant::now() >= wait_until {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("USB device {bus_num}-{dev_num} not found in sysfs"),
))
}