grafton_ndi/receiver.rs
1//! NDI receiving functionality for video, audio, and metadata.
2//!
3//! # Monitoring Tally & Connection Count
4//!
5//! The receiver can monitor status changes including tally state and connection count:
6//!
7//! ```ignore
8//! # use grafton_ndi::{NDI, ReceiverOptions, ReceiverBandwidth, Source};
9//! # use std::time::Duration;
10//! # fn main() -> Result<(), grafton_ndi::Error> {
11//! # let ndi = NDI::new()?;
12//! // In real usage, you'd get the source from Finder::find_sources()
13//! // let source = /* obtained from Finder */;
14//! let options = ReceiverOptions::builder(source)
15//! .bandwidth(ReceiverBandwidth::MetadataOnly)
16//! .build();
17//! let receiver = Receiver::new(&ndi, &options)?;
18//!
19//! // Poll for status changes
20//! if let Some(status) = receiver.poll_status_change(Duration::from_millis(1000))? {
21//! if let Some(tally) = status.tally {
22//! println!("Tally: program={program}, preview={preview}",
23//! program = tally.on_program, preview = tally.on_preview);
24//! }
25//! if let Some(connections) = status.connections {
26//! println!("Active connections: {connections}");
27//! }
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use std::{
34 ffi::CString,
35 marker::PhantomData,
36 ptr,
37 sync::{PoisonError, RwLock, RwLockReadGuard},
38 time::{Duration, Instant},
39};
40
41use crate::{
42 capture::{capture_raw, AudioKind, CaptureKind, CaptureResult, MetadataKind, VideoKind},
43 finder::{RawSource, Source},
44 ndi_lib::*,
45 to_ms_checked, Error, Result, NDI,
46};
47
48/// Retry policy configuration for frame capture operations.
49///
50/// This struct encapsulates the timing parameters for the retry loop used by
51/// the reliable [`Capture::capture`] verb across all frame kinds.
52struct RetryPolicy {
53 /// Timeout per individual capture attempt.
54 poll_interval: Duration,
55 /// Sleep duration between retry attempts to avoid busy-waiting.
56 sleep_between: Duration,
57}
58
59impl Default for RetryPolicy {
60 fn default() -> Self {
61 Self {
62 poll_interval: Duration::from_millis(100),
63 sleep_between: Duration::from_millis(10),
64 }
65 }
66}
67
68/// Generic retry helper for frame capture operations.
69///
70/// This function encapsulates the retry logic that handles NDI SDK synchronization
71/// behavior during initial connection. The first few capture calls may return
72/// immediately while the stream synchronizes.
73///
74/// # Parameters
75///
76/// - `timeout`: Total time allowed for the operation to succeed.
77/// - `policy`: Retry timing configuration.
78/// - `capture_fn`: A closure that attempts to capture a frame with a given timeout.
79///
80/// # Returns
81///
82/// - `Ok(T)`: The captured frame on success.
83/// - `Err(Error::FrameTimeout)`: If no frame is captured within the total timeout.
84fn retry_capture<T, F>(timeout: Duration, policy: &RetryPolicy, capture_fn: F) -> Result<T>
85where
86 F: FnMut(Duration) -> Result<Option<T>>,
87{
88 let start_time = Instant::now();
89 retry_capture_with_clock(
90 timeout,
91 policy,
92 capture_fn,
93 || start_time.elapsed(),
94 std::thread::sleep,
95 )
96}
97
98fn retry_capture_with_clock<T, F, E, S>(
99 timeout: Duration,
100 policy: &RetryPolicy,
101 mut capture_fn: F,
102 mut elapsed: E,
103 mut sleep: S,
104) -> Result<T>
105where
106 F: FnMut(Duration) -> Result<Option<T>>,
107 E: FnMut() -> Duration,
108 S: FnMut(Duration),
109{
110 to_ms_checked(timeout)?;
111
112 let mut attempts = 0;
113
114 if timeout.is_zero() {
115 attempts += 1;
116 return match capture_fn(Duration::ZERO)? {
117 Some(frame) => Ok(frame),
118 None => Err(Error::FrameTimeout {
119 attempts,
120 elapsed: elapsed(),
121 }),
122 };
123 }
124
125 loop {
126 let elapsed_before_attempt = elapsed();
127 let Some(remaining) = timeout.checked_sub(elapsed_before_attempt) else {
128 return Err(Error::FrameTimeout {
129 attempts,
130 elapsed: elapsed_before_attempt,
131 });
132 };
133
134 if remaining.is_zero() {
135 return Err(Error::FrameTimeout {
136 attempts,
137 elapsed: elapsed_before_attempt,
138 });
139 }
140
141 let poll_timeout = policy.poll_interval.min(remaining);
142 attempts += 1;
143
144 match capture_fn(poll_timeout)? {
145 Some(frame) => return Ok(frame),
146 None => {
147 let elapsed_after_attempt = elapsed();
148 let Some(remaining) = timeout.checked_sub(elapsed_after_attempt) else {
149 return Err(Error::FrameTimeout {
150 attempts,
151 elapsed: elapsed_after_attempt,
152 });
153 };
154
155 if remaining.is_zero() {
156 return Err(Error::FrameTimeout {
157 attempts,
158 elapsed: elapsed_after_attempt,
159 });
160 }
161
162 let sleep_duration = policy.sleep_between.min(remaining);
163 if !sleep_duration.is_zero() {
164 sleep(sleep_duration);
165 }
166 }
167 }
168 }
169}
170
171macro_rules! ptz_command {
172 ($self:expr, $func:ident, $err_msg:expr) => {
173 if unsafe { $func($self.instance) } {
174 Ok(())
175 } else {
176 Err(Error::PtzCommandFailed($err_msg.into()))
177 }
178 };
179 ($self:expr, $func:ident, $param:expr, $err_msg:expr) => {
180 if unsafe { $func($self.instance, $param) } {
181 Ok(())
182 } else {
183 Err(Error::PtzCommandFailed($err_msg))
184 }
185 };
186 ($self:expr, $func:ident, $param1:expr, $param2:expr, $err_msg:expr) => {
187 if unsafe { $func($self.instance, $param1, $param2) } {
188 Ok(())
189 } else {
190 Err(Error::PtzCommandFailed($err_msg))
191 }
192 };
193 ($self:expr, $func:ident, $param1:expr, $param2:expr, $param3:expr, $err_msg:expr) => {
194 if unsafe { $func($self.instance, $param1, $param2, $param3) } {
195 Ok(())
196 } else {
197 Err(Error::PtzCommandFailed($err_msg))
198 }
199 };
200}
201
202/// Operational color formats for received video frames.
203///
204/// These values are receiver configuration modes that the NDI SDK can use for
205/// normal receive operation. SDK `Max` sentinel values are intentionally not
206/// exposed in safe Rust because they are placeholders, not valid receiver
207/// modes.
208///
209/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
210/// added without another avoidable public enum break. Downstream `match`
211/// expressions should include a wildcard arm.
212///
213/// Choose an explicit operational value such as [`ReceiverColorFormat::Fastest`],
214/// [`ReceiverColorFormat::Best`], [`ReceiverColorFormat::RGBX_RGBA`], or
215/// [`ReceiverColorFormat::BGRX_BGRA`] for receiver configuration.
216#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
217#[non_exhaustive]
218pub enum ReceiverColorFormat {
219 #[default]
220 BGRX_BGRA,
221 UYVY_BGRA,
222 RGBX_RGBA,
223 UYVY_RGBA,
224 Fastest,
225 Best,
226}
227
228impl From<ReceiverColorFormat> for NDIlib_recv_color_format_e {
229 fn from(format: ReceiverColorFormat) -> Self {
230 match format {
231 ReceiverColorFormat::BGRX_BGRA => {
232 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
233 }
234 ReceiverColorFormat::UYVY_BGRA => {
235 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
236 }
237 ReceiverColorFormat::RGBX_RGBA => {
238 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
239 }
240 ReceiverColorFormat::UYVY_RGBA => {
241 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
242 }
243 ReceiverColorFormat::Fastest => {
244 NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
245 }
246 ReceiverColorFormat::Best => NDIlib_recv_color_format_e_NDIlib_recv_color_format_best,
247 }
248 }
249}
250
251/// Operational bandwidth modes for receivers.
252///
253/// These values are receiver configuration modes that the NDI SDK can use for
254/// normal receive operation. SDK `Max` sentinel values are intentionally not
255/// exposed in safe Rust because they are placeholders, not valid receiver
256/// modes.
257///
258/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
259/// added without another avoidable public enum break. Downstream `match`
260/// expressions should include a wildcard arm.
261///
262/// Choose an explicit operational value such as [`ReceiverBandwidth::Highest`],
263/// [`ReceiverBandwidth::Lowest`], [`ReceiverBandwidth::AudioOnly`], or
264/// [`ReceiverBandwidth::MetadataOnly`] for receiver configuration.
265#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
266#[non_exhaustive]
267pub enum ReceiverBandwidth {
268 MetadataOnly,
269 AudioOnly,
270 Lowest,
271 #[default]
272 Highest,
273}
274
275impl From<ReceiverBandwidth> for NDIlib_recv_bandwidth_e {
276 fn from(bandwidth: ReceiverBandwidth) -> Self {
277 match bandwidth {
278 ReceiverBandwidth::MetadataOnly => {
279 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
280 }
281 ReceiverBandwidth::AudioOnly => {
282 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
283 }
284 ReceiverBandwidth::Lowest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
285 ReceiverBandwidth::Highest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
286 }
287 }
288}
289
290#[derive(Debug, Default, Clone)]
291pub struct ReceiverOptions {
292 pub source_to_connect_to: Source,
293 pub color_format: ReceiverColorFormat,
294 pub bandwidth: ReceiverBandwidth,
295 pub allow_video_fields: bool,
296 pub ndi_recv_name: Option<String>,
297}
298
299#[repr(C)]
300pub(crate) struct RawRecvCreateV3 {
301 _source: RawSource,
302 _name: Option<CString>,
303 pub raw: NDIlib_recv_create_v3_t,
304}
305
306impl ReceiverOptions {
307 /// Convert to raw format for FFI use
308 ///
309 /// # Safety
310 ///
311 /// The returned RawRecvCreateV3 struct uses #[repr(C)] to guarantee C-compatible layout
312 /// for safe FFI interop with the NDI SDK.
313 pub(crate) fn to_raw(&self) -> Result<RawRecvCreateV3> {
314 let source = self.source_to_connect_to.to_raw()?;
315 let name = self
316 .ndi_recv_name
317 .as_ref()
318 .map(|n| CString::new(n.clone()))
319 .transpose()
320 .map_err(Error::InvalidCString)?;
321
322 let p_ndi_recv_name = name.as_ref().map_or(ptr::null(), |n| n.as_ptr());
323 let source_raw = source.raw;
324
325 Ok(RawRecvCreateV3 {
326 raw: NDIlib_recv_create_v3_t {
327 source_to_connect_to: source_raw,
328 color_format: self.color_format.into(),
329 bandwidth: self.bandwidth.into(),
330 allow_video_fields: self.allow_video_fields,
331 p_ndi_recv_name,
332 },
333 _source: source,
334 _name: name,
335 })
336 }
337
338 /// Create a builder for configuring a receiver
339 pub fn builder(source: Source) -> ReceiverOptionsBuilder {
340 ReceiverOptionsBuilder::new(source)
341 }
342}
343
344/// Builder for configuring a ReceiverOptions with ergonomic method chaining
345#[derive(Debug, Clone)]
346pub struct ReceiverOptionsBuilder {
347 source_to_connect_to: Source,
348 color_format: Option<ReceiverColorFormat>,
349 bandwidth: Option<ReceiverBandwidth>,
350 allow_video_fields: Option<bool>,
351 ndi_recv_name: Option<String>,
352}
353
354impl ReceiverOptionsBuilder {
355 /// Create a new builder with the specified source
356 pub fn new(source: Source) -> Self {
357 Self {
358 source_to_connect_to: source,
359 color_format: None,
360 bandwidth: None,
361 allow_video_fields: None,
362 ndi_recv_name: None,
363 }
364 }
365
366 /// Preset for capturing snapshots (low resolution, RGBA, lowest bandwidth).
367 ///
368 /// This preset is optimized for:
369 /// - Image export and snapshot capture
370 /// - AI/ML processing pipelines
371 /// - Thumbnail generation
372 /// - Low bandwidth environments
373 ///
374 /// Configuration:
375 /// - Color format: `RGBX_RGBA` (compatible with image encoding)
376 /// - Bandwidth: `Lowest` (reduces resolution and bitrate)
377 /// - Video fields: Disabled (progressive frames only)
378 ///
379 /// # Example
380 ///
381 /// ```no_run
382 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
383 /// # use std::time::Duration;
384 /// # fn main() -> Result<(), grafton_ndi::Error> {
385 /// # let ndi = NDI::new()?;
386 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
387 /// # finder.wait_for_sources(Duration::from_secs(1))?;
388 /// # let sources = finder.current_sources()?;
389 /// let options = ReceiverOptionsBuilder::snapshot_preset(sources[0].clone())
390 /// .name("Snapshot Receiver")
391 /// .build();
392 /// let receiver = Receiver::new(&ndi, &options)?;
393 ///
394 /// // Capture and encode in one line (requires image-encoding feature)
395 /// #[cfg(feature = "image-encoding")]
396 /// {
397 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
398 /// let png_bytes = frame.encode_png()?;
399 /// std::fs::write("snapshot.png", &png_bytes)?;
400 /// }
401 /// # Ok(())
402 /// # }
403 /// ```
404 pub fn snapshot_preset(source: Source) -> Self {
405 Self::new(source)
406 .color(ReceiverColorFormat::RGBX_RGBA)
407 .bandwidth(ReceiverBandwidth::Lowest)
408 .allow_video_fields(false)
409 }
410
411 /// Preset for high-quality video processing (full resolution, highest bandwidth).
412 ///
413 /// This preset is optimized for:
414 /// - Professional video processing workflows
415 /// - Recording and archival
416 /// - Real-time video analysis requiring full quality
417 /// - Broadcasting and production
418 ///
419 /// Configuration:
420 /// - Color format: `RGBX_RGBA` (uncompressed, compatible with most tools)
421 /// - Bandwidth: `Highest` (full resolution and bitrate)
422 /// - Video fields: Enabled (supports interlaced sources)
423 ///
424 /// # Example
425 ///
426 /// ```no_run
427 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
428 /// # use std::time::Duration;
429 /// # fn main() -> Result<(), grafton_ndi::Error> {
430 /// # let ndi = NDI::new()?;
431 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
432 /// # finder.wait_for_sources(Duration::from_secs(1))?;
433 /// # let sources = finder.current_sources()?;
434 /// let options = ReceiverOptionsBuilder::high_quality_preset(sources[0].clone())
435 /// .name("High Quality Receiver")
436 /// .build();
437 /// let receiver = Receiver::new(&ndi, &options)?;
438 ///
439 /// // Capture full quality frames
440 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
441 /// println!("Captured {width}x{height} frame", width = frame.width(), height = frame.height());
442 /// # Ok(())
443 /// # }
444 /// ```
445 pub fn high_quality_preset(source: Source) -> Self {
446 Self::new(source)
447 .color(ReceiverColorFormat::RGBX_RGBA)
448 .bandwidth(ReceiverBandwidth::Highest)
449 .allow_video_fields(true)
450 }
451
452 /// Preset for metadata and tally monitoring only (no video/audio).
453 ///
454 /// This preset is optimized for:
455 /// - Tally light monitoring
456 /// - Connection status tracking
457 /// - PTZ control applications
458 /// - Minimal bandwidth overhead
459 ///
460 /// Configuration:
461 /// - Bandwidth: `MetadataOnly` (no video or audio data)
462 /// - Color format: Default (not used for metadata-only)
463 /// - Video fields: Disabled (not applicable)
464 ///
465 /// # Example
466 ///
467 /// ```no_run
468 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
469 /// # use std::time::Duration;
470 /// # fn main() -> Result<(), grafton_ndi::Error> {
471 /// # let ndi = NDI::new()?;
472 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
473 /// # finder.wait_for_sources(Duration::from_secs(1))?;
474 /// # let sources = finder.current_sources()?;
475 /// let options = ReceiverOptionsBuilder::monitoring_preset(sources[0].clone())
476 /// .name("Tally Monitor")
477 /// .build();
478 /// let receiver = Receiver::new(&ndi, &options)?;
479 ///
480 /// // Poll for status changes
481 /// if let Some(status) = receiver.poll_status_change(Duration::from_millis(1000))? {
482 /// if let Some(tally) = status.tally {
483 /// println!("Tally: program={program}, preview={preview}",
484 /// program = tally.on_program, preview = tally.on_preview);
485 /// }
486 /// if let Some(connections) = status.connections {
487 /// println!("Active connections: {connections}");
488 /// }
489 /// }
490 /// # Ok(())
491 /// # }
492 /// ```
493 pub fn monitoring_preset(source: Source) -> Self {
494 Self::new(source)
495 .bandwidth(ReceiverBandwidth::MetadataOnly)
496 .allow_video_fields(false)
497 }
498
499 /// Set the color format for received video
500 #[must_use]
501 pub fn color(mut self, fmt: ReceiverColorFormat) -> Self {
502 self.color_format = Some(fmt);
503 self
504 }
505
506 /// Set the bandwidth mode for the receiver
507 #[must_use]
508 pub fn bandwidth(mut self, bw: ReceiverBandwidth) -> Self {
509 self.bandwidth = Some(bw);
510 self
511 }
512
513 /// Configure whether to allow video fields
514 #[must_use]
515 pub fn allow_video_fields(mut self, allow: bool) -> Self {
516 self.allow_video_fields = Some(allow);
517 self
518 }
519
520 /// Set the name for this receiver
521 #[must_use]
522 pub fn name<S: Into<String>>(mut self, name: S) -> Self {
523 self.ndi_recv_name = Some(name.into());
524 self
525 }
526
527 /// Build the receiver options
528 ///
529 /// This method is infallible and simply applies defaults for any unset options.
530 /// To create a `Receiver`, pass the resulting `ReceiverOptions` to `Receiver::new()`.
531 ///
532 /// # Example
533 ///
534 /// ```no_run
535 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, Source};
536 /// # fn main() -> Result<(), grafton_ndi::Error> {
537 /// # let ndi = NDI::new()?;
538 /// # let source = Source::default();
539 /// let options = ReceiverOptions::builder(source).build();
540 /// let receiver = Receiver::new(&ndi, &options)?;
541 /// # Ok(())
542 /// # }
543 /// ```
544 pub fn build(self) -> ReceiverOptions {
545 ReceiverOptions {
546 source_to_connect_to: self.source_to_connect_to,
547 color_format: self.color_format.unwrap_or(ReceiverColorFormat::BGRX_BGRA),
548 bandwidth: self.bandwidth.unwrap_or(ReceiverBandwidth::Highest),
549 allow_video_fields: self.allow_video_fields.unwrap_or(true),
550 ndi_recv_name: self.ndi_recv_name,
551 }
552 }
553}
554
555pub struct Receiver {
556 pub(crate) instance: NDIlib_recv_instance_t,
557 _ndi: NDI,
558 source: Source,
559 /// Serializes connection changes against in-flight captures.
560 ///
561 /// Capture calls take this lock *shared* (so video, audio, and metadata
562 /// captures still run concurrently), while [`Receiver::reconnect`] takes it
563 /// *exclusively*. This guarantees `NDIlib_recv_connect` never overlaps a
564 /// `NDIlib_recv_capture_v3` on the same instance — the one combination the
565 /// SDK does not make safe — without otherwise serializing capture.
566 capture_guard: RwLock<()>,
567}
568
569impl Receiver {
570 pub fn new(ndi: &NDI, create: &ReceiverOptions) -> Result<Self> {
571 let create_raw = create.to_raw()?;
572 let instance = unsafe { NDIlib_recv_create_v3(&create_raw.raw) };
573 if instance.is_null() {
574 Err(Error::InitializationFailed(
575 "Failed to create NDI recv instance".into(),
576 ))
577 } else {
578 Ok(Self {
579 instance,
580 _ndi: ndi.clone(),
581 source: create.source_to_connect_to.clone(),
582 capture_guard: RwLock::new(()),
583 })
584 }
585 }
586
587 /// Acquire the shared capture guard, held for the duration of a single
588 /// `NDIlib_recv_capture_v3` call. Multiple captures proceed concurrently;
589 /// only [`Self::reconnect`] takes the guard exclusively. The guarded data
590 /// is `()`, so a poisoned lock carries no invalid state and is recovered.
591 fn capture_lock(&self) -> RwLockReadGuard<'_, ()> {
592 self.capture_guard
593 .read()
594 .unwrap_or_else(PoisonError::into_inner)
595 }
596
597 pub fn ptz_is_supported(&self) -> bool {
598 unsafe { NDIlib_recv_ptz_is_supported(self.instance) }
599 }
600
601 pub fn ptz_recall_preset(&self, preset: u32, speed: f32) -> Result<()> {
602 ptz_command!(
603 self,
604 NDIlib_recv_ptz_recall_preset,
605 preset as i32,
606 speed,
607 format!("Failed to recall PTZ preset {preset} with speed {speed}")
608 )
609 }
610
611 pub fn ptz_zoom(&self, zoom_value: f32) -> Result<()> {
612 ptz_command!(
613 self,
614 NDIlib_recv_ptz_zoom,
615 zoom_value,
616 format!("Failed to set PTZ zoom to {zoom_value}")
617 )
618 }
619
620 pub fn ptz_zoom_speed(&self, zoom_speed: f32) -> Result<()> {
621 ptz_command!(
622 self,
623 NDIlib_recv_ptz_zoom_speed,
624 zoom_speed,
625 format!("Failed to set PTZ zoom speed to {zoom_speed}")
626 )
627 }
628
629 pub fn ptz_pan_tilt(&self, pan_value: f32, tilt_value: f32) -> Result<()> {
630 ptz_command!(
631 self,
632 NDIlib_recv_ptz_pan_tilt,
633 pan_value,
634 tilt_value,
635 format!("Failed to set PTZ pan/tilt to ({pan_value}, {tilt_value})")
636 )
637 }
638
639 pub fn ptz_pan_tilt_speed(&self, pan_speed: f32, tilt_speed: f32) -> Result<()> {
640 ptz_command!(
641 self,
642 NDIlib_recv_ptz_pan_tilt_speed,
643 pan_speed,
644 tilt_speed,
645 format!("Failed to set PTZ pan/tilt speed to ({pan_speed}, {tilt_speed})")
646 )
647 }
648
649 pub fn ptz_store_preset(&self, preset_no: i32) -> Result<()> {
650 ptz_command!(
651 self,
652 NDIlib_recv_ptz_store_preset,
653 preset_no,
654 format!("Failed to store PTZ preset {preset_no}")
655 )
656 }
657
658 pub fn ptz_auto_focus(&self) -> Result<()> {
659 ptz_command!(
660 self,
661 NDIlib_recv_ptz_auto_focus,
662 "Failed to enable PTZ auto focus"
663 )
664 }
665
666 pub fn ptz_focus(&self, focus_value: f32) -> Result<()> {
667 ptz_command!(
668 self,
669 NDIlib_recv_ptz_focus,
670 focus_value,
671 format!("Failed to set PTZ focus to {focus_value}")
672 )
673 }
674
675 pub fn ptz_focus_speed(&self, focus_speed: f32) -> Result<()> {
676 ptz_command!(
677 self,
678 NDIlib_recv_ptz_focus_speed,
679 focus_speed,
680 format!("Failed to set PTZ focus speed to {focus_speed}")
681 )
682 }
683
684 pub fn ptz_white_balance_auto(&self) -> Result<()> {
685 ptz_command!(
686 self,
687 NDIlib_recv_ptz_white_balance_auto,
688 "Failed to set PTZ auto white balance"
689 )
690 }
691
692 pub fn ptz_white_balance_indoor(&self) -> Result<()> {
693 ptz_command!(
694 self,
695 NDIlib_recv_ptz_white_balance_indoor,
696 "Failed to set PTZ indoor white balance"
697 )
698 }
699
700 pub fn ptz_white_balance_outdoor(&self) -> Result<()> {
701 ptz_command!(
702 self,
703 NDIlib_recv_ptz_white_balance_outdoor,
704 "Failed to set PTZ outdoor white balance"
705 )
706 }
707
708 pub fn ptz_white_balance_oneshot(&self) -> Result<()> {
709 ptz_command!(
710 self,
711 NDIlib_recv_ptz_white_balance_oneshot,
712 "Failed to set PTZ oneshot white balance"
713 )
714 }
715
716 pub fn ptz_white_balance_manual(&self, red: f32, blue: f32) -> Result<()> {
717 ptz_command!(
718 self,
719 NDIlib_recv_ptz_white_balance_manual,
720 red,
721 blue,
722 format!("Failed to set PTZ manual white balance (red: {red}, blue: {blue})")
723 )
724 }
725
726 pub fn ptz_exposure_auto(&self) -> Result<()> {
727 ptz_command!(
728 self,
729 NDIlib_recv_ptz_exposure_auto,
730 "Failed to set PTZ auto exposure"
731 )
732 }
733
734 pub fn ptz_exposure_manual(&self, exposure_level: f32) -> Result<()> {
735 ptz_command!(
736 self,
737 NDIlib_recv_ptz_exposure_manual,
738 exposure_level,
739 format!("Failed to set PTZ manual exposure to {exposure_level}")
740 )
741 }
742
743 pub fn ptz_exposure_manual_v2(&self, iris: f32, gain: f32, shutter_speed: f32) -> Result<()> {
744 ptz_command!(
745 self,
746 NDIlib_recv_ptz_exposure_manual_v2,
747 iris,
748 gain,
749 shutter_speed,
750 format!("Failed to set PTZ manual exposure v2 (iris: {iris}, gain: {gain}, shutter: {shutter_speed})")
751 )
752 }
753
754 /// Capture **video** frames from this receiver.
755 ///
756 /// Returns a [`Capture`] view whose verbs cover the three capture styles:
757 ///
758 /// - [`capture`](Capture::capture) — reliable owned capture that retries
759 /// across the NDI SDK's initial-sync warm-up.
760 /// - [`try_capture`](Capture::try_capture) — a single owned poll, `Ok(None)`
761 /// when no frame is ready.
762 /// - [`try_capture_ref`](Capture::try_capture_ref) — a single zero-copy poll
763 /// borrowing the SDK buffer in place.
764 ///
765 /// Video frames may carry per-row padding (e.g. RGBX/BGRX); see
766 /// [`VideoFrame`](crate::VideoFrame) for how to read them correctly.
767 ///
768 /// # Examples
769 ///
770 /// ```no_run
771 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
772 /// # use std::time::Duration;
773 /// # fn main() -> Result<(), grafton_ndi::Error> {
774 /// # let ndi = NDI::new()?;
775 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
776 /// # let options = ReceiverOptions::builder(source).build();
777 /// # let receiver = Receiver::new(&ndi, &options)?;
778 /// // Reliable owned capture
779 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
780 /// println!("{}x{}", frame.width(), frame.height());
781 ///
782 /// // Zero-copy borrow
783 /// if let Some(frame) = receiver.video().try_capture_ref(Duration::from_secs(1))? {
784 /// let pixels = frame.data();
785 /// println!("{} bytes in place", pixels.len());
786 /// }
787 /// # Ok(())
788 /// # }
789 /// ```
790 #[must_use = "the Capture view does nothing until a capture verb is called"]
791 pub fn video(&self) -> Capture<'_, VideoKind> {
792 Capture::new(self)
793 }
794
795 /// Capture **audio** frames from this receiver.
796 ///
797 /// Returns a [`Capture`] view; see [`video`](Self::video) for the three
798 /// capture verbs it exposes ([`capture`](Capture::capture),
799 /// [`try_capture`](Capture::try_capture),
800 /// [`try_capture_ref`](Capture::try_capture_ref)).
801 ///
802 /// # Examples
803 ///
804 /// ```no_run
805 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
806 /// # use std::time::Duration;
807 /// # fn main() -> Result<(), grafton_ndi::Error> {
808 /// # let ndi = NDI::new()?;
809 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
810 /// # let options = ReceiverOptions::builder(source).build();
811 /// # let receiver = Receiver::new(&ndi, &options)?;
812 /// if let Some(audio) = receiver.audio().try_capture_ref(Duration::from_secs(1))? {
813 /// println!("{} channels, {} samples", audio.num_channels(), audio.num_samples());
814 /// }
815 /// # Ok(())
816 /// # }
817 /// ```
818 #[must_use = "the Capture view does nothing until a capture verb is called"]
819 pub fn audio(&self) -> Capture<'_, AudioKind> {
820 Capture::new(self)
821 }
822
823 /// Capture **metadata** frames from this receiver.
824 ///
825 /// Returns a [`Capture`] view; see [`video`](Self::video) for the three
826 /// capture verbs it exposes ([`capture`](Capture::capture),
827 /// [`try_capture`](Capture::try_capture),
828 /// [`try_capture_ref`](Capture::try_capture_ref)).
829 ///
830 /// # Examples
831 ///
832 /// ```no_run
833 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
834 /// # use std::time::Duration;
835 /// # fn main() -> Result<(), grafton_ndi::Error> {
836 /// # let ndi = NDI::new()?;
837 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
838 /// # let options = ReceiverOptions::builder(source).build();
839 /// # let receiver = Receiver::new(&ndi, &options)?;
840 /// if let Some(meta) = receiver.metadata().try_capture(Duration::from_millis(100))? {
841 /// println!("metadata: {}", meta.data());
842 /// }
843 /// # Ok(())
844 /// # }
845 /// ```
846 #[must_use = "the Capture view does nothing until a capture verb is called"]
847 pub fn metadata(&self) -> Capture<'_, MetadataKind> {
848 Capture::new(self)
849 }
850
851 /// Single zero-copy poll backing every [`Capture::try_capture_ref`].
852 ///
853 /// Holds the shared capture guard for the duration of one
854 /// `NDIlib_recv_capture_v3` call, so it runs concurrently with other
855 /// captures but never overlaps a [`reconnect`](Self::reconnect).
856 fn capture_ref_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<Option<K::Ref<'_>>> {
857 let timeout_ms = to_ms_checked(timeout)?;
858
859 // Shared guard: excludes a concurrent `reconnect`, not other captures.
860 let _capture = self.capture_lock();
861 // SAFETY: self.instance is a valid NDI receiver instance.
862 match unsafe { capture_raw::<K>(self.instance, timeout_ms) } {
863 CaptureResult::Frame(guard) => {
864 // Validation (e.g. FourCC) happens during ref construction.
865 let frame_ref = unsafe { K::make_ref(guard)? };
866 Ok(Some(frame_ref))
867 }
868 CaptureResult::None => Ok(None),
869 CaptureResult::Error => Err(Error::CaptureFailed("Received an error frame".into())),
870 }
871 }
872
873 /// Single owned poll backing every [`Capture::try_capture`].
874 pub(crate) fn try_capture_kind<K: CaptureKind>(
875 &self,
876 timeout: Duration,
877 ) -> Result<Option<K::Owned>> {
878 match self.capture_ref_kind::<K>(timeout)? {
879 Some(frame_ref) => Ok(Some(K::ref_to_owned(&frame_ref)?)),
880 None => Ok(None),
881 }
882 }
883
884 /// Retried owned capture backing every [`Capture::capture`].
885 ///
886 /// Polls [`try_capture_kind`](Self::try_capture_kind) under the default
887 /// [`RetryPolicy`], absorbing the SDK's initial-sync misses where it returns
888 /// `none` immediately instead of blocking for the full timeout.
889 pub(crate) fn capture_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<K::Owned> {
890 retry_capture(timeout, &RetryPolicy::default(), |poll| {
891 self.try_capture_kind::<K>(poll)
892 })
893 }
894
895 /// Check if the receiver is still connected to its source.
896 ///
897 /// Returns `true` if there is at least one active connection to the source,
898 /// `false` otherwise. This can be used to detect when a source goes offline
899 /// or becomes unavailable.
900 ///
901 /// # Examples
902 ///
903 /// ```no_run
904 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
905 /// # fn main() -> Result<(), grafton_ndi::Error> {
906 /// # let ndi = NDI::new()?;
907 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
908 /// # let options = ReceiverOptions::builder(source).build();
909 /// # let receiver = Receiver::new(&ndi, &options)?;
910 /// if receiver.is_connected() {
911 /// println!("Still connected to source");
912 /// } else {
913 /// println!("Lost connection to source");
914 /// }
915 /// # Ok(())
916 /// # }
917 /// ```
918 pub fn is_connected(&self) -> bool {
919 unsafe { NDIlib_recv_get_no_connections(self.instance) > 0 }
920 }
921
922 /// Re-establish this receiver's connection to its original source,
923 /// in place.
924 ///
925 /// Re-points the existing NDI receiver instance at the [`Source`] it
926 /// was created for **without** destroying and recreating the
927 /// receiver. This is intended for mid-session recovery: when a
928 /// source's feed has gone silent (for example an encoder-bound
929 /// NDI|HX camera that dropped its proxy stream under load), a
930 /// reconnect forces a fresh connection attempt while avoiding the
931 /// source-rediscovery round trip — and the discovery race — that
932 /// tearing down and rebuilding the receiver would incur.
933 ///
934 /// Liveness can be observed via [`Self::is_connected`] and
935 /// [`Self::connection_stats`] (`video_frames_received` resuming its
936 /// climb indicates frames are flowing again).
937 ///
938 /// # Thread safety
939 ///
940 /// `NDIlib_recv_connect` is the one receive call the SDK does *not* make
941 /// safe to run concurrently with `NDIlib_recv_capture_v3` on the same
942 /// instance. This method therefore takes the receiver's capture guard
943 /// exclusively: it **blocks until every in-flight capture on this receiver
944 /// returns**, and holds off any capture that starts while it runs. Captures
945 /// on the same receiver still run concurrently with each other; only a
946 /// reconnect is exclusive. Because it can block for as long as a capture's
947 /// timeout, prefer short capture timeouts on receivers you intend to
948 /// recover, and call it off the async runtime — `AsyncReceiver::reconnect`
949 /// does this for you.
950 ///
951 /// # Errors
952 ///
953 /// Returns an error only if the stored [`Source`] cannot be
954 /// re-marshalled to its FFI representation; `NDIlib_recv_connect`
955 /// itself reports no status, so a returned `Ok` means the reconnect was
956 /// *issued*, not that the feed has recovered — confirm recovery via
957 /// [`Self::connection_stats`].
958 pub fn reconnect(&self) -> Result<()> {
959 // Marshal before locking; this touches only `self.source`, not the
960 // instance, so it need not hold off captures.
961 let raw = self.source.to_raw()?;
962 // Exclusive guard: waits for in-flight captures to drain and blocks new
963 // ones, so `NDIlib_recv_connect` never overlaps `NDIlib_recv_capture_v3`.
964 let _exclusive = self
965 .capture_guard
966 .write()
967 .unwrap_or_else(PoisonError::into_inner);
968 // SAFETY: `self.instance` is a valid receiver instance for the
969 // lifetime of `self`, and `raw` (which owns the backing
970 // CStrings) outlives the call, so the pointers inside `raw.raw`
971 // stay valid while the SDK copies the source descriptor.
972 unsafe { NDIlib_recv_connect(self.instance, &raw.raw) };
973 Ok(())
974 }
975
976 /// Get the source this receiver is connected to.
977 ///
978 /// Returns a reference to the [`Source`] that was specified when creating
979 /// this receiver. This is useful for identifying which source a receiver
980 /// is associated with when managing multiple receivers.
981 ///
982 /// # Examples
983 ///
984 /// ```no_run
985 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
986 /// # fn main() -> Result<(), grafton_ndi::Error> {
987 /// # let ndi = NDI::new()?;
988 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
989 /// # let options = ReceiverOptions::builder(source).build();
990 /// # let receiver = Receiver::new(&ndi, &options)?;
991 /// let source = receiver.source();
992 /// println!("Connected to: {name}", name = source.name);
993 /// # Ok(())
994 /// # }
995 /// ```
996 pub fn source(&self) -> &Source {
997 &self.source
998 }
999
1000 /// Get connection and performance statistics for this receiver.
1001 ///
1002 /// Provides detailed statistics including:
1003 /// - Number of active connections
1004 /// - Total frames received (video, audio, metadata)
1005 /// - Dropped frames (video, audio, metadata)
1006 /// - Queued frames waiting to be processed
1007 ///
1008 /// This is useful for monitoring receiver health and diagnosing
1009 /// performance issues in production applications.
1010 ///
1011 /// # Examples
1012 ///
1013 /// ```no_run
1014 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1015 /// # fn main() -> Result<(), grafton_ndi::Error> {
1016 /// # let ndi = NDI::new()?;
1017 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1018 /// # let options = ReceiverOptions::builder(source).build();
1019 /// # let receiver = Receiver::new(&ndi, &options)?;
1020 /// let stats = receiver.connection_stats();
1021 /// println!("Connections: {connections}", connections = stats.connections);
1022 /// println!("Video frames: {received} (dropped: {dropped})",
1023 /// received = stats.video_frames_received,
1024 /// dropped = stats.video_frames_dropped);
1025 /// println!("Frame drop rate: {rate:.2}%",
1026 /// rate = stats.video_drop_percentage());
1027 /// # Ok(())
1028 /// # }
1029 /// ```
1030 pub fn connection_stats(&self) -> ConnectionStats {
1031 let connections = unsafe { NDIlib_recv_get_no_connections(self.instance) };
1032
1033 let mut total = NDIlib_recv_performance_t::default();
1034 let mut dropped = NDIlib_recv_performance_t::default();
1035 unsafe {
1036 NDIlib_recv_get_performance(self.instance, &mut total, &mut dropped);
1037 }
1038
1039 let mut queue = NDIlib_recv_queue_t::default();
1040 unsafe {
1041 NDIlib_recv_get_queue(self.instance, &mut queue);
1042 }
1043
1044 ConnectionStats {
1045 connections: connections.max(0) as u32,
1046 video_frames_received: total.video_frames.max(0) as u64,
1047 audio_frames_received: total.audio_frames.max(0) as u64,
1048 metadata_frames_received: total.metadata_frames.max(0) as u64,
1049 video_frames_dropped: dropped.video_frames.max(0) as u64,
1050 audio_frames_dropped: dropped.audio_frames.max(0) as u64,
1051 metadata_frames_dropped: dropped.metadata_frames.max(0) as u64,
1052 video_frames_queued: queue.video_frames.max(0) as u32,
1053 audio_frames_queued: queue.audio_frames.max(0) as u32,
1054 metadata_frames_queued: queue.metadata_frames.max(0) as u32,
1055 }
1056 }
1057
1058 /// Poll for status changes (tally, connections, etc.)
1059 ///
1060 /// # Arguments
1061 ///
1062 /// * `timeout` - Maximum time to wait for status change.
1063 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
1064 ///
1065 /// # Returns
1066 ///
1067 /// * `Some(ReceiverStatus)` - Status has changed
1068 /// * `None` - Timeout occurred with no status change
1069 ///
1070 /// # Errors
1071 ///
1072 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
1073 pub fn poll_status_change(&self, timeout: Duration) -> Result<Option<ReceiverStatus>> {
1074 let timeout_ms = to_ms_checked(timeout)?;
1075 // Shared guard: excludes a concurrent `reconnect`, not other captures.
1076 let _capture = self.capture_lock();
1077 // SAFETY: NDI SDK documentation states that recv_capture_v3 is thread-safe
1078 let frame_type = unsafe {
1079 NDIlib_recv_capture_v3(
1080 self.instance,
1081 ptr::null_mut(), // no video
1082 ptr::null_mut(), // no audio
1083 ptr::null_mut(), // no metadata
1084 timeout_ms,
1085 )
1086 };
1087
1088 match frame_type {
1089 NDIlib_frame_type_e_NDIlib_frame_type_status_change => {
1090 // Note: NDI SDK doesn't provide recv_get_tally, so we can't query current tally state
1091 // We would need to track it from set_tally calls
1092 let tally = None;
1093
1094 // Get number of connections
1095 let connections = {
1096 let conn_count = unsafe { NDIlib_recv_get_no_connections(self.instance) };
1097 if conn_count >= 0 {
1098 Some(conn_count)
1099 } else {
1100 None
1101 }
1102 };
1103
1104 let has_tally = tally.is_some();
1105 let has_connections = connections.is_some();
1106
1107 Ok(Some(ReceiverStatus {
1108 tally,
1109 connections,
1110 other: !has_tally && !has_connections,
1111 }))
1112 }
1113 _ => Ok(None),
1114 }
1115 }
1116}
1117
1118/// A typed view over a [`Receiver`] for capturing frames of one kind.
1119///
1120/// Created by [`Receiver::video`], [`Receiver::audio`], and
1121/// [`Receiver::metadata`]. The view is a cheap borrow of the receiver and does
1122/// nothing on its own — call one of its verbs to capture:
1123///
1124/// - [`capture`](Self::capture) — reliable owned capture with built-in retry.
1125/// **The primary method.** It absorbs the NDI SDK's initial-sync warm-up (the
1126/// first few polls after connecting return `none` immediately instead of
1127/// blocking), then runs with zero overhead in steady state, so it is safe in
1128/// a continuous capture loop.
1129/// - [`try_capture`](Self::try_capture) — a single owned poll; `Ok(None)` when
1130/// no frame is ready. For manual polling where you handle timing yourself.
1131/// - [`try_capture_ref`](Self::try_capture_ref) — a single zero-copy poll that
1132/// borrows the SDK's buffer in place (no allocation, no memcpy). The
1133/// recommended API for performance-critical, in-place processing: for a
1134/// 1920×1080 BGRA frame it avoids ~8.3 MB of copying per frame (~475 MB/s at
1135/// 60 fps). Convert to an owned frame with `to_owned` when you need to keep or
1136/// send it.
1137///
1138/// Every verb holds a shared capture guard for the duration of a single SDK
1139/// call, so captures of different kinds run concurrently with each other but
1140/// never overlap a [`Receiver::reconnect`].
1141pub struct Capture<'rx, K: CaptureKind> {
1142 rx: &'rx Receiver,
1143 _kind: PhantomData<K>,
1144}
1145
1146impl<'rx, K: CaptureKind> Capture<'rx, K> {
1147 fn new(rx: &'rx Receiver) -> Self {
1148 Self {
1149 rx,
1150 _kind: PhantomData,
1151 }
1152 }
1153
1154 /// Reliable owned capture: blocks up to `timeout`, retrying across the NDI
1155 /// SDK's initial-sync warm-up.
1156 ///
1157 /// `timeout` is a total retry budget; each individual SDK poll is capped to
1158 /// the remaining budget, and [`Duration::ZERO`] performs exactly one
1159 /// non-blocking attempt.
1160 ///
1161 /// # Arguments
1162 ///
1163 /// * `timeout` - Total time to wait for a frame. Must not exceed
1164 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1165 ///
1166 /// # Errors
1167 ///
1168 /// Returns [`Error::FrameTimeout`] if no frame arrives within `timeout`;
1169 /// other errors propagate from the capture itself.
1170 pub fn capture(&self, timeout: Duration) -> Result<K::Owned> {
1171 self.rx.capture_kind::<K>(timeout)
1172 }
1173
1174 /// Single owned poll: returns `Ok(None)` if no frame is available within
1175 /// `timeout`.
1176 ///
1177 /// Prefer [`capture`](Self::capture) for reliable capture; this variant does
1178 /// not retry the SDK's warm-up misses.
1179 ///
1180 /// # Arguments
1181 ///
1182 /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1183 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1184 pub fn try_capture(&self, timeout: Duration) -> Result<Option<K::Owned>> {
1185 self.rx.try_capture_kind::<K>(timeout)
1186 }
1187
1188 /// Single zero-copy poll: returns a borrowed view of the SDK's buffer, or
1189 /// `Ok(None)` if no frame is available within `timeout`.
1190 ///
1191 /// The returned reference borrows the [`Receiver`] (not this temporary
1192 /// view), so it may outlive the `Capture` while keeping the receiver
1193 /// borrowed.
1194 ///
1195 /// # Arguments
1196 ///
1197 /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1198 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1199 pub fn try_capture_ref(&self, timeout: Duration) -> Result<Option<K::Ref<'rx>>> {
1200 // Bind through `'rx` so the returned borrow is tied to the receiver, not
1201 // to this short-lived view.
1202 let rx: &'rx Receiver = self.rx;
1203 rx.capture_ref_kind::<K>(timeout)
1204 }
1205}
1206
1207impl Drop for Receiver {
1208 fn drop(&mut self) {
1209 unsafe {
1210 NDIlib_recv_destroy(self.instance);
1211 }
1212 }
1213}
1214
1215/// # Safety
1216///
1217/// The NDI 6 SDK documentation explicitly states that recv operations are thread-safe.
1218/// `NDIlib_recv_capture_v3` and related functions use internal synchronization.
1219/// The Receiver struct only holds an opaque pointer returned by the SDK, and the SDK
1220/// guarantees that this pointer can be safely moved between threads.
1221unsafe impl Send for Receiver {}
1222
1223/// # Safety
1224///
1225/// The NDI 6 SDK documentation guarantees that `NDIlib_recv_capture_v3` is internally
1226/// synchronized and can be called concurrently from multiple threads. This is explicitly
1227/// mentioned in the SDK manual's thread safety section. The capture verbs (via
1228/// [`Receiver::video`], [`Receiver::audio`], and [`Receiver::metadata`]) can be
1229/// safely called from multiple threads simultaneously.
1230unsafe impl Sync for Receiver {}
1231
1232#[derive(Debug, Clone)]
1233pub struct ReceiverStatus {
1234 /// Current Tally (program/preview) if known
1235 pub tally: Option<Tally>,
1236 /// Number of active connections (None if unknown)
1237 pub connections: Option<i32>,
1238 /// True when the receiver reports any other change (latency, PTZ, etc.)
1239 pub other: bool,
1240}
1241
1242#[derive(Debug, Clone)]
1243pub struct Tally {
1244 pub on_program: bool,
1245 pub on_preview: bool,
1246}
1247
1248impl Tally {
1249 pub fn new(on_program: bool, on_preview: bool) -> Self {
1250 Tally {
1251 on_program,
1252 on_preview,
1253 }
1254 }
1255
1256 pub(crate) fn to_raw(&self) -> NDIlib_tally_t {
1257 NDIlib_tally_t {
1258 on_program: self.on_program,
1259 on_preview: self.on_preview,
1260 }
1261 }
1262}
1263
1264/// Connection and performance statistics for a receiver.
1265///
1266/// Provides detailed metrics about receiver health including connection count,
1267/// frame counts, and drop rates. Useful for monitoring and diagnostics.
1268#[derive(Debug, Clone)]
1269pub struct ConnectionStats {
1270 /// Number of active connections to this receiver
1271 pub connections: u32,
1272
1273 /// Total number of video frames received
1274 pub video_frames_received: u64,
1275
1276 /// Total number of audio frames received
1277 pub audio_frames_received: u64,
1278
1279 /// Total number of metadata frames received
1280 pub metadata_frames_received: u64,
1281
1282 /// Number of video frames dropped due to buffer overflow or processing delays
1283 pub video_frames_dropped: u64,
1284
1285 /// Number of audio frames dropped
1286 pub audio_frames_dropped: u64,
1287
1288 /// Number of metadata frames dropped
1289 pub metadata_frames_dropped: u64,
1290
1291 /// Number of video frames currently queued for processing
1292 pub video_frames_queued: u32,
1293
1294 /// Number of audio frames currently queued
1295 pub audio_frames_queued: u32,
1296
1297 /// Number of metadata frames currently queued
1298 pub metadata_frames_queued: u32,
1299}
1300
1301impl ConnectionStats {
1302 /// Calculate video frame drop percentage.
1303 ///
1304 /// Returns the percentage of video frames that were dropped out of the total
1305 /// received + dropped. Returns 0.0 if no frames have been received.
1306 ///
1307 /// # Examples
1308 ///
1309 /// ```
1310 /// # use grafton_ndi::ConnectionStats;
1311 /// let stats = ConnectionStats {
1312 /// connections: 1,
1313 /// video_frames_received: 900,
1314 /// video_frames_dropped: 100,
1315 /// audio_frames_received: 0,
1316 /// audio_frames_dropped: 0,
1317 /// metadata_frames_received: 0,
1318 /// metadata_frames_dropped: 0,
1319 /// video_frames_queued: 5,
1320 /// audio_frames_queued: 0,
1321 /// metadata_frames_queued: 0,
1322 /// };
1323 /// assert_eq!(stats.video_drop_percentage(), 10.0);
1324 /// ```
1325 pub fn video_drop_percentage(&self) -> f64 {
1326 let total = self.video_frames_received + self.video_frames_dropped;
1327 if total == 0 {
1328 0.0
1329 } else {
1330 (self.video_frames_dropped as f64 / total as f64) * 100.0
1331 }
1332 }
1333
1334 /// Calculate audio frame drop percentage.
1335 ///
1336 /// Returns the percentage of audio frames that were dropped out of the total
1337 /// received + dropped. Returns 0.0 if no frames have been received.
1338 pub fn audio_drop_percentage(&self) -> f64 {
1339 let total = self.audio_frames_received + self.audio_frames_dropped;
1340 if total == 0 {
1341 0.0
1342 } else {
1343 (self.audio_frames_dropped as f64 / total as f64) * 100.0
1344 }
1345 }
1346
1347 /// Calculate metadata frame drop percentage.
1348 ///
1349 /// Returns the percentage of metadata frames that were dropped out of the total
1350 /// received + dropped. Returns 0.0 if no frames have been received.
1351 pub fn metadata_drop_percentage(&self) -> f64 {
1352 let total = self.metadata_frames_received + self.metadata_frames_dropped;
1353 if total == 0 {
1354 0.0
1355 } else {
1356 (self.metadata_frames_dropped as f64 / total as f64) * 100.0
1357 }
1358 }
1359
1360 /// Check if the receiver is currently connected.
1361 ///
1362 /// Returns `true` if there is at least one active connection.
1363 pub fn is_connected(&self) -> bool {
1364 self.connections > 0
1365 }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371
1372 use std::cell::Cell;
1373
1374 fn test_source() -> Source {
1375 Source::default()
1376 }
1377
1378 #[derive(Default)]
1379 struct FakeClock {
1380 elapsed: Cell<Duration>,
1381 }
1382
1383 impl FakeClock {
1384 fn elapsed(&self) -> Duration {
1385 self.elapsed.get()
1386 }
1387
1388 fn advance(&self, duration: Duration) {
1389 self.elapsed.set(self.elapsed.get() + duration);
1390 }
1391 }
1392
1393 fn assert_raw_receiver_modes(
1394 raw: &RawRecvCreateV3,
1395 expected_color_format: NDIlib_recv_color_format_e,
1396 expected_bandwidth: NDIlib_recv_bandwidth_e,
1397 ) {
1398 assert_eq!(raw.raw.color_format, expected_color_format);
1399 assert_eq!(raw.raw.bandwidth, expected_bandwidth);
1400 assert_ne!(
1401 raw.raw.color_format,
1402 NDIlib_recv_color_format_e_NDIlib_recv_color_format_max
1403 );
1404 assert_ne!(
1405 raw.raw.bandwidth,
1406 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_max
1407 );
1408 }
1409
1410 #[test]
1411 fn receiver_color_format_maps_to_exact_sdk_values() {
1412 assert_eq!(
1413 NDIlib_recv_color_format_e::from(ReceiverColorFormat::BGRX_BGRA),
1414 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
1415 );
1416 assert_eq!(
1417 NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_BGRA),
1418 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
1419 );
1420 assert_eq!(
1421 NDIlib_recv_color_format_e::from(ReceiverColorFormat::RGBX_RGBA),
1422 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
1423 );
1424 assert_eq!(
1425 NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_RGBA),
1426 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
1427 );
1428 assert_eq!(
1429 NDIlib_recv_color_format_e::from(ReceiverColorFormat::Fastest),
1430 NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
1431 );
1432 assert_eq!(
1433 NDIlib_recv_color_format_e::from(ReceiverColorFormat::Best),
1434 NDIlib_recv_color_format_e_NDIlib_recv_color_format_best
1435 );
1436 }
1437
1438 #[test]
1439 fn receiver_bandwidth_maps_to_exact_sdk_values() {
1440 assert_eq!(
1441 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::MetadataOnly),
1442 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
1443 );
1444 assert_eq!(
1445 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::AudioOnly),
1446 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
1447 );
1448 assert_eq!(
1449 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Lowest),
1450 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest
1451 );
1452 assert_eq!(
1453 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Highest),
1454 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest
1455 );
1456 }
1457
1458 #[test]
1459 fn receiver_options_defaults_emit_operational_raw_modes() {
1460 let raw = ReceiverOptions::builder(test_source())
1461 .build()
1462 .to_raw()
1463 .unwrap();
1464
1465 assert_raw_receiver_modes(
1466 &raw,
1467 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
1468 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
1469 );
1470 assert!(raw.raw.allow_video_fields);
1471 }
1472
1473 #[test]
1474 fn snapshot_preset_emits_operational_raw_modes() {
1475 let raw = ReceiverOptionsBuilder::snapshot_preset(test_source())
1476 .build()
1477 .to_raw()
1478 .unwrap();
1479
1480 assert_raw_receiver_modes(
1481 &raw,
1482 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
1483 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
1484 );
1485 assert!(!raw.raw.allow_video_fields);
1486 }
1487
1488 #[test]
1489 fn high_quality_preset_emits_operational_raw_modes() {
1490 let raw = ReceiverOptionsBuilder::high_quality_preset(test_source())
1491 .build()
1492 .to_raw()
1493 .unwrap();
1494
1495 assert_raw_receiver_modes(
1496 &raw,
1497 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
1498 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
1499 );
1500 assert!(raw.raw.allow_video_fields);
1501 }
1502
1503 #[test]
1504 fn monitoring_preset_emits_operational_raw_modes() {
1505 let raw = ReceiverOptionsBuilder::monitoring_preset(test_source())
1506 .build()
1507 .to_raw()
1508 .unwrap();
1509
1510 assert_raw_receiver_modes(
1511 &raw,
1512 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
1513 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only,
1514 );
1515 assert!(!raw.raw.allow_video_fields);
1516 }
1517
1518 #[test]
1519 fn retry_caps_sub_poll_timeout_to_total_budget() {
1520 let clock = FakeClock::default();
1521 let policy = RetryPolicy {
1522 poll_interval: Duration::from_millis(100),
1523 sleep_between: Duration::from_millis(10),
1524 };
1525 let mut polls = Vec::new();
1526
1527 let result = retry_capture_with_clock(
1528 Duration::from_millis(25),
1529 &policy,
1530 |poll| {
1531 polls.push(poll);
1532 Ok(Some(42))
1533 },
1534 || clock.elapsed(),
1535 |_| {},
1536 );
1537
1538 assert_eq!(result.unwrap(), 42);
1539 assert_eq!(polls, [Duration::from_millis(25)]);
1540 }
1541
1542 #[test]
1543 fn retry_shrinks_later_poll_to_remaining_budget() {
1544 let clock = FakeClock::default();
1545 let policy = RetryPolicy {
1546 poll_interval: Duration::from_millis(40),
1547 sleep_between: Duration::from_millis(10),
1548 };
1549 let mut polls = Vec::new();
1550 let mut sleeps = Vec::new();
1551
1552 let result: Result<i32> = retry_capture_with_clock(
1553 Duration::from_millis(50),
1554 &policy,
1555 |poll| {
1556 polls.push(poll);
1557 if polls.len() == 1 {
1558 clock.advance(Duration::from_millis(5));
1559 } else {
1560 clock.advance(poll);
1561 }
1562 Ok(None)
1563 },
1564 || clock.elapsed(),
1565 |sleep| {
1566 sleeps.push(sleep);
1567 clock.advance(sleep);
1568 },
1569 );
1570
1571 assert!(matches!(
1572 result,
1573 Err(Error::FrameTimeout { attempts: 2, .. })
1574 ));
1575 assert_eq!(
1576 polls,
1577 [Duration::from_millis(40), Duration::from_millis(35)]
1578 );
1579 assert_eq!(sleeps, [Duration::from_millis(10)]);
1580 }
1581
1582 #[test]
1583 fn retry_zero_timeout_success_makes_one_nonblocking_attempt() {
1584 let clock = FakeClock::default();
1585 let mut polls = Vec::new();
1586
1587 let result = retry_capture_with_clock(
1588 Duration::ZERO,
1589 &RetryPolicy::default(),
1590 |poll| {
1591 polls.push(poll);
1592 Ok(Some(42))
1593 },
1594 || clock.elapsed(),
1595 |_| panic!("zero-timeout success should not sleep"),
1596 );
1597
1598 assert_eq!(result.unwrap(), 42);
1599 assert_eq!(polls, [Duration::ZERO]);
1600 }
1601
1602 #[test]
1603 fn retry_zero_timeout_miss_times_out_after_one_attempt() {
1604 let clock = FakeClock::default();
1605 let mut polls = Vec::new();
1606
1607 let result: Result<i32> = retry_capture_with_clock(
1608 Duration::ZERO,
1609 &RetryPolicy::default(),
1610 |poll| {
1611 polls.push(poll);
1612 Ok(None)
1613 },
1614 || clock.elapsed(),
1615 |_| panic!("zero-timeout miss should not sleep"),
1616 );
1617
1618 match result {
1619 Err(Error::FrameTimeout { attempts, elapsed }) => {
1620 assert_eq!(attempts, 1);
1621 assert_eq!(elapsed, Duration::ZERO);
1622 }
1623 _ => panic!("Expected FrameTimeout error"),
1624 }
1625 assert_eq!(polls, [Duration::ZERO]);
1626 }
1627
1628 #[test]
1629 fn retry_timeout_counts_only_actual_capture_attempts() {
1630 let clock = FakeClock::default();
1631 let policy = RetryPolicy {
1632 poll_interval: Duration::from_millis(8),
1633 sleep_between: Duration::from_millis(5),
1634 };
1635 let mut polls = Vec::new();
1636
1637 let result: Result<i32> = retry_capture_with_clock(
1638 Duration::from_millis(10),
1639 &policy,
1640 |poll| {
1641 polls.push(poll);
1642 clock.advance(Duration::from_millis(10));
1643 Ok(None)
1644 },
1645 || clock.elapsed(),
1646 |_| panic!("expired timeout should not sleep"),
1647 );
1648
1649 match result {
1650 Err(Error::FrameTimeout { attempts, elapsed }) => {
1651 assert_eq!(attempts, 1);
1652 assert_eq!(elapsed, Duration::from_millis(10));
1653 }
1654 _ => panic!("Expected FrameTimeout error"),
1655 }
1656 assert_eq!(polls, [Duration::from_millis(8)]);
1657 }
1658
1659 #[test]
1660 fn retry_caps_sleep_to_remaining_budget() {
1661 let clock = FakeClock::default();
1662 let policy = RetryPolicy {
1663 poll_interval: Duration::from_millis(8),
1664 sleep_between: Duration::from_millis(5),
1665 };
1666 let mut sleeps = Vec::new();
1667
1668 let result: Result<i32> = retry_capture_with_clock(
1669 Duration::from_millis(10),
1670 &policy,
1671 |poll| {
1672 assert_eq!(poll, Duration::from_millis(8));
1673 clock.advance(Duration::from_millis(8));
1674 Ok(None)
1675 },
1676 || clock.elapsed(),
1677 |sleep| {
1678 sleeps.push(sleep);
1679 clock.advance(sleep);
1680 },
1681 );
1682
1683 assert!(matches!(
1684 result,
1685 Err(Error::FrameTimeout { attempts: 1, .. })
1686 ));
1687 assert_eq!(sleeps, [Duration::from_millis(2)]);
1688 }
1689
1690 #[test]
1691 fn retry_validates_total_timeout_before_attempts() {
1692 let attempts = Cell::new(0);
1693
1694 let result: Result<i32> = retry_capture(
1695 crate::MAX_TIMEOUT + Duration::from_nanos(1),
1696 &RetryPolicy::default(),
1697 |_| {
1698 attempts.set(attempts.get() + 1);
1699 Ok(Some(42))
1700 },
1701 );
1702
1703 assert!(matches!(result, Err(Error::InvalidConfiguration(_))));
1704 assert_eq!(attempts.get(), 0);
1705 }
1706
1707 #[test]
1708 fn retry_succeeds_after_retry_before_timeout() {
1709 let clock = FakeClock::default();
1710 let attempts = Cell::new(0);
1711
1712 let result = retry_capture_with_clock(
1713 Duration::from_millis(50),
1714 &RetryPolicy::default(),
1715 |_| {
1716 attempts.set(attempts.get() + 1);
1717 if attempts.get() == 1 {
1718 Ok(None)
1719 } else {
1720 Ok(Some(42))
1721 }
1722 },
1723 || clock.elapsed(),
1724 |sleep| clock.advance(sleep),
1725 );
1726
1727 assert_eq!(result.unwrap(), 42);
1728 assert_eq!(attempts.get(), 2);
1729 }
1730
1731 #[test]
1732 fn retry_propagates_error() {
1733 let clock = FakeClock::default();
1734 let attempts = Cell::new(0);
1735 let mut sleeps = Vec::new();
1736
1737 let result: Result<i32> = retry_capture_with_clock(
1738 Duration::from_secs(1),
1739 &RetryPolicy::default(),
1740 |_| {
1741 attempts.set(attempts.get() + 1);
1742 Err(Error::CaptureFailed("test error".into()))
1743 },
1744 || clock.elapsed(),
1745 |sleep| sleeps.push(sleep),
1746 );
1747
1748 assert!(
1749 matches!(result, Err(Error::CaptureFailed(_))),
1750 "Should propagate CaptureFailed error"
1751 );
1752 assert_eq!(attempts.get(), 1);
1753 assert!(sleeps.is_empty());
1754 }
1755}