#![allow(dead_code)]
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NdiPixelFormat {
Bgra8,
Uyvy,
Rgba8,
V210,
Rgba16f,
}
impl NdiPixelFormat {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn bytes_per_pixel(&self) -> f64 {
match self {
Self::Bgra8 | Self::Rgba8 => 4.0,
Self::Uyvy => 2.0,
Self::V210 => 8.0 / 3.0,
Self::Rgba16f => 8.0,
}
}
#[must_use]
pub fn has_alpha(&self) -> bool {
matches!(self, Self::Bgra8 | Self::Rgba8 | Self::Rgba16f)
}
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Bgra8 => "BGRA8",
Self::Uyvy => "UYVY",
Self::Rgba8 => "RGBA8",
Self::V210 => "V210",
Self::Rgba16f => "RGBA16F",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NdiFrameRate {
pub num: u32,
pub den: u32,
}
impl NdiFrameRate {
#[must_use]
pub fn new(num: u32, den: u32) -> Self {
Self { num, den }
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn fps(&self) -> f64 {
if self.den == 0 {
return 0.0;
}
f64::from(self.num) / f64::from(self.den)
}
#[must_use]
pub fn ntsc_30() -> Self {
Self {
num: 30000,
den: 1001,
}
}
#[must_use]
pub fn ntsc_60() -> Self {
Self {
num: 60000,
den: 1001,
}
}
#[must_use]
pub fn pal_25() -> Self {
Self { num: 25, den: 1 }
}
}
#[derive(Debug, Clone)]
pub struct NdiFrame {
pub width: u32,
pub height: u32,
pub pixel_format: NdiPixelFormat,
pub frame_rate: NdiFrameRate,
pub stride: u32,
pub data: Vec<u8>,
pub pts_us: i64,
}
impl NdiFrame {
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[must_use]
pub fn new(
width: u32,
height: u32,
pixel_format: NdiPixelFormat,
frame_rate: NdiFrameRate,
) -> Self {
let bpp = pixel_format.bytes_per_pixel();
let stride = (f64::from(width) * bpp).ceil() as u32;
let data_len = (stride * height) as usize;
Self {
width,
height,
pixel_format,
frame_rate,
stride,
data: vec![0u8; data_len],
pts_us: 0,
}
}
#[must_use]
pub fn data_size(&self) -> usize {
self.data.len()
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.data.len() == (self.stride as usize) * (self.height as usize)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NdiBridgeHealth {
Healthy,
Degraded,
Disconnected,
}
#[derive(Debug, Clone)]
pub struct NdiSource {
pub name: String,
pub address: String,
pub port: u16,
pub last_seen: Instant,
}
impl NdiSource {
pub fn new(name: impl Into<String>, address: impl Into<String>, port: u16) -> Self {
Self {
name: name.into(),
address: address.into(),
port,
last_seen: Instant::now(),
}
}
#[must_use]
pub fn is_stale(&self, timeout: Duration) -> bool {
self.last_seen.elapsed() > timeout
}
}
#[derive(Debug, Clone)]
pub struct NdiBridgeConfig {
pub preferred_format: NdiPixelFormat,
pub target_frame_rate: NdiFrameRate,
pub discovery_timeout: Duration,
pub max_queue_depth: usize,
pub health_check_interval: Duration,
}
impl Default for NdiBridgeConfig {
fn default() -> Self {
Self {
preferred_format: NdiPixelFormat::Bgra8,
target_frame_rate: NdiFrameRate::ntsc_60(),
discovery_timeout: Duration::from_secs(10),
max_queue_depth: 4,
health_check_interval: Duration::from_secs(2),
}
}
}
#[derive(Debug, Clone)]
pub struct NdiBridgeStats {
pub frames_received: u64,
pub frames_sent: u64,
pub frames_dropped: u64,
pub bytes_transferred: u64,
pub health: NdiBridgeHealth,
pub uptime: Duration,
}
impl NdiBridgeStats {
#[must_use]
pub fn new() -> Self {
Self {
frames_received: 0,
frames_sent: 0,
frames_dropped: 0,
bytes_transferred: 0,
health: NdiBridgeHealth::Disconnected,
uptime: Duration::ZERO,
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn drop_ratio(&self) -> f64 {
let total = self.frames_received + self.frames_dropped;
if total == 0 {
return 0.0;
}
self.frames_dropped as f64 / total as f64 * 100.0
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn throughput_mbps(&self) -> f64 {
let secs = self.uptime.as_secs_f64();
if secs < f64::EPSILON {
return 0.0;
}
(self.bytes_transferred as f64) / secs / (1024.0 * 1024.0)
}
}
impl Default for NdiBridgeStats {
fn default() -> Self {
Self::new()
}
}
pub struct NdiBridge {
config: NdiBridgeConfig,
sources: HashMap<String, NdiSource>,
stats: NdiBridgeStats,
started_at: Instant,
running: bool,
}
impl NdiBridge {
#[must_use]
pub fn new(config: NdiBridgeConfig) -> Self {
Self {
config,
sources: HashMap::new(),
stats: NdiBridgeStats::new(),
started_at: Instant::now(),
running: false,
}
}
pub fn start(&mut self) {
self.running = true;
self.started_at = Instant::now();
self.stats.health = NdiBridgeHealth::Healthy;
}
pub fn stop(&mut self) {
self.running = false;
self.stats.health = NdiBridgeHealth::Disconnected;
}
#[must_use]
pub fn is_running(&self) -> bool {
self.running
}
pub fn register_source(&mut self, source: NdiSource) {
self.sources.insert(source.name.clone(), source);
}
pub fn prune_stale_sources(&mut self) -> usize {
let timeout = self.config.discovery_timeout;
let before = self.sources.len();
self.sources.retain(|_, s| !s.is_stale(timeout));
before - self.sources.len()
}
#[must_use]
pub fn sources(&self) -> Vec<&NdiSource> {
self.sources.values().collect()
}
pub fn receive_frame(&mut self, frame: &NdiFrame) {
if !self.running {
return;
}
self.stats.frames_received += 1;
self.stats.bytes_transferred += frame.data_size() as u64;
self.stats.uptime = self.started_at.elapsed();
}
pub fn send_frame(&mut self, frame: &NdiFrame) {
if !self.running {
return;
}
self.stats.frames_sent += 1;
self.stats.bytes_transferred += frame.data_size() as u64;
self.stats.uptime = self.started_at.elapsed();
}
pub fn record_drop(&mut self) {
self.stats.frames_dropped += 1;
if self.stats.drop_ratio() > 5.0 {
self.stats.health = NdiBridgeHealth::Degraded;
}
}
#[must_use]
pub fn stats(&self) -> &NdiBridgeStats {
&self.stats
}
#[must_use]
pub fn config(&self) -> &NdiBridgeConfig {
&self.config
}
pub fn evaluate_health(&mut self) -> NdiBridgeHealth {
if !self.running {
self.stats.health = NdiBridgeHealth::Disconnected;
} else if self.stats.drop_ratio() > 10.0 {
self.stats.health = NdiBridgeHealth::Degraded;
} else {
self.stats.health = NdiBridgeHealth::Healthy;
}
self.stats.health
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pixel_format_bytes_per_pixel() {
assert!((NdiPixelFormat::Bgra8.bytes_per_pixel() - 4.0).abs() < f64::EPSILON);
assert!((NdiPixelFormat::Uyvy.bytes_per_pixel() - 2.0).abs() < f64::EPSILON);
assert!((NdiPixelFormat::Rgba16f.bytes_per_pixel() - 8.0).abs() < f64::EPSILON);
}
#[test]
fn test_pixel_format_has_alpha() {
assert!(NdiPixelFormat::Bgra8.has_alpha());
assert!(NdiPixelFormat::Rgba8.has_alpha());
assert!(!NdiPixelFormat::Uyvy.has_alpha());
assert!(!NdiPixelFormat::V210.has_alpha());
}
#[test]
fn test_pixel_format_name() {
assert_eq!(NdiPixelFormat::V210.name(), "V210");
assert_eq!(NdiPixelFormat::Rgba16f.name(), "RGBA16F");
}
#[test]
fn test_frame_rate_fps() {
let ntsc = NdiFrameRate::ntsc_30();
assert!((ntsc.fps() - 29.97).abs() < 0.1);
let pal = NdiFrameRate::pal_25();
assert!((pal.fps() - 25.0).abs() < f64::EPSILON);
}
#[test]
fn test_frame_rate_zero_den() {
let fr = NdiFrameRate::new(30000, 0);
assert!((fr.fps()).abs() < f64::EPSILON);
}
#[test]
fn test_frame_creation_and_validity() {
let frame = NdiFrame::new(1920, 1080, NdiPixelFormat::Bgra8, NdiFrameRate::ntsc_60());
assert_eq!(frame.width, 1920);
assert_eq!(frame.height, 1080);
assert!(frame.is_valid());
assert_eq!(frame.data_size(), 1920 * 4 * 1080);
}
#[test]
fn test_frame_uyvy_stride() {
let frame = NdiFrame::new(1920, 1080, NdiPixelFormat::Uyvy, NdiFrameRate::pal_25());
assert_eq!(frame.stride, 3840);
assert!(frame.is_valid());
}
#[test]
fn test_ndi_source_staleness() {
let source = NdiSource::new("Cam1", "192.168.1.100", 5961);
assert!(!source.is_stale(Duration::from_secs(5)));
}
#[test]
fn test_bridge_start_stop() {
let mut bridge = NdiBridge::new(NdiBridgeConfig::default());
assert!(!bridge.is_running());
bridge.start();
assert!(bridge.is_running());
assert_eq!(bridge.evaluate_health(), NdiBridgeHealth::Healthy);
bridge.stop();
assert!(!bridge.is_running());
assert_eq!(bridge.evaluate_health(), NdiBridgeHealth::Disconnected);
}
#[test]
fn test_bridge_receive_send() {
let mut bridge = NdiBridge::new(NdiBridgeConfig::default());
bridge.start();
let frame = NdiFrame::new(1920, 1080, NdiPixelFormat::Bgra8, NdiFrameRate::ntsc_60());
bridge.receive_frame(&frame);
bridge.send_frame(&frame);
assert_eq!(bridge.stats().frames_received, 1);
assert_eq!(bridge.stats().frames_sent, 1);
}
#[test]
fn test_bridge_source_management() {
let mut bridge = NdiBridge::new(NdiBridgeConfig::default());
bridge.register_source(NdiSource::new("Cam1", "10.0.0.1", 5961));
bridge.register_source(NdiSource::new("Cam2", "10.0.0.2", 5961));
assert_eq!(bridge.sources().len(), 2);
}
#[test]
fn test_stats_drop_ratio() {
let mut stats = NdiBridgeStats::new();
assert!((stats.drop_ratio()).abs() < f64::EPSILON);
stats.frames_received = 95;
stats.frames_dropped = 5;
assert!((stats.drop_ratio() - 5.0).abs() < f64::EPSILON);
}
#[test]
fn test_stats_throughput() {
let stats = NdiBridgeStats {
frames_received: 100,
frames_sent: 0,
frames_dropped: 0,
bytes_transferred: 1024 * 1024 * 100,
health: NdiBridgeHealth::Healthy,
uptime: Duration::from_secs(10),
};
assert!((stats.throughput_mbps() - 10.0).abs() < 0.01);
}
#[test]
fn test_bridge_degraded_health_on_drops() {
let mut bridge = NdiBridge::new(NdiBridgeConfig::default());
bridge.start();
bridge.stats.frames_received = 80;
bridge.stats.frames_dropped = 20;
let health = bridge.evaluate_health();
assert_eq!(health, NdiBridgeHealth::Degraded);
}
#[test]
fn test_default_config() {
let cfg = NdiBridgeConfig::default();
assert_eq!(cfg.preferred_format, NdiPixelFormat::Bgra8);
assert_eq!(cfg.max_queue_depth, 4);
assert_eq!(cfg.discovery_timeout, Duration::from_secs(10));
}
}