grafton_ndi/receiver.rs
1//! NDI receiving functionality for video, audio, and metadata.
2//!
3//! # Monitoring Connection Status
4//!
5//! The receiver can monitor connection status changes:
6//!
7//! ```no_run
8//! # use grafton_ndi::{NDI, Receiver, ReceiverOptions, ReceiverBandwidth, Source};
9//! # use std::time::Duration;
10//! # fn main() -> Result<(), grafton_ndi::Error> {
11//! # let ndi = NDI::new()?;
12//! # let source = Source::default();
13//! // In real usage, obtain the source from Finder::find_sources().
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//! println!("Active connections: {}", status.connections);
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27use std::{
28 ffi::CString,
29 marker::PhantomData,
30 ptr,
31 sync::atomic::{AtomicBool, Ordering},
32 time::{Duration, Instant},
33};
34
35use parking_lot::{RwLock, RwLockReadGuard};
36
37use crate::{
38 capture::{capture_raw, AudioKind, CaptureKind, CaptureResult, MetadataKind, VideoKind},
39 finder::{RawSource, Source},
40 ndi_lib::*,
41 to_ms_checked, Error, MetadataFrame, Result, NDI,
42};
43
44/// Retry policy configuration for frame capture operations.
45///
46/// This struct encapsulates the timing parameters for the retry loop used by
47/// the reliable [`Capture::capture`] verb across all frame kinds.
48struct RetryPolicy {
49 /// Timeout per individual capture attempt.
50 poll_interval: Duration,
51 /// Sleep duration between retry attempts to avoid busy-waiting.
52 sleep_between: Duration,
53}
54
55impl Default for RetryPolicy {
56 fn default() -> Self {
57 Self {
58 poll_interval: Duration::from_millis(100),
59 sleep_between: Duration::from_millis(10),
60 }
61 }
62}
63
64/// Generic retry helper for frame capture operations.
65///
66/// This function encapsulates the retry logic that handles NDI SDK synchronization
67/// behavior during initial connection. The first few capture calls may return
68/// immediately while the stream synchronizes.
69///
70/// # Parameters
71///
72/// - `timeout`: Total time allowed for the operation to succeed.
73/// - `policy`: Retry timing configuration.
74/// - `capture_fn`: A closure that attempts to capture a frame with a given timeout.
75///
76/// # Returns
77///
78/// - `Ok(T)`: The captured frame on success.
79/// - `Err(Error::FrameTimeout)`: If no frame is captured within the total timeout.
80fn retry_capture<T, F>(timeout: Duration, policy: &RetryPolicy, capture_fn: F) -> Result<T>
81where
82 F: FnMut(Duration) -> Result<Option<T>>,
83{
84 let start_time = Instant::now();
85 retry_capture_with_clock(
86 timeout,
87 policy,
88 capture_fn,
89 || start_time.elapsed(),
90 std::thread::sleep,
91 )
92}
93
94fn retry_capture_with_clock<T, F, E, S>(
95 timeout: Duration,
96 policy: &RetryPolicy,
97 mut capture_fn: F,
98 mut elapsed: E,
99 mut sleep: S,
100) -> Result<T>
101where
102 F: FnMut(Duration) -> Result<Option<T>>,
103 E: FnMut() -> Duration,
104 S: FnMut(Duration),
105{
106 to_ms_checked(timeout)?;
107
108 let mut attempts = 0;
109
110 if timeout.is_zero() {
111 attempts += 1;
112 return match capture_fn(Duration::ZERO)? {
113 Some(frame) => Ok(frame),
114 None => Err(Error::FrameTimeout {
115 attempts,
116 elapsed: elapsed(),
117 }),
118 };
119 }
120
121 loop {
122 let elapsed_before_attempt = elapsed();
123 let Some(remaining) = timeout.checked_sub(elapsed_before_attempt) else {
124 return Err(Error::FrameTimeout {
125 attempts,
126 elapsed: elapsed_before_attempt,
127 });
128 };
129
130 if remaining.is_zero() {
131 return Err(Error::FrameTimeout {
132 attempts,
133 elapsed: elapsed_before_attempt,
134 });
135 }
136
137 let poll_timeout = policy.poll_interval.min(remaining);
138 attempts += 1;
139
140 match capture_fn(poll_timeout)? {
141 Some(frame) => return Ok(frame),
142 None => {
143 let elapsed_after_attempt = elapsed();
144 let Some(remaining) = timeout.checked_sub(elapsed_after_attempt) else {
145 return Err(Error::FrameTimeout {
146 attempts,
147 elapsed: elapsed_after_attempt,
148 });
149 };
150
151 if remaining.is_zero() {
152 return Err(Error::FrameTimeout {
153 attempts,
154 elapsed: elapsed_after_attempt,
155 });
156 }
157
158 let sleep_duration = policy.sleep_between.min(remaining);
159 if !sleep_duration.is_zero() {
160 sleep(sleep_duration);
161 }
162 }
163 }
164 }
165}
166
167fn validate_ptz_value(name: &str, value: f32, min: f32, max: f32) -> Result<()> {
168 if value.is_finite() && (min..=max).contains(&value) {
169 Ok(())
170 } else {
171 Err(Error::InvalidConfiguration(format!(
172 "{name} must be finite and within {min}..={max}"
173 )))
174 }
175}
176
177fn validate_ptz_preset(preset: i64) -> Result<i32> {
178 if (0..=99).contains(&preset) {
179 Ok(preset as i32)
180 } else {
181 Err(Error::InvalidConfiguration(
182 "PTZ preset must be within 0..=99".into(),
183 ))
184 }
185}
186
187macro_rules! ptz_command {
188 ($self:expr, $func:ident, $err_msg:expr) => {
189 if unsafe { $func($self.core.instance) } {
190 Ok(())
191 } else {
192 Err(Error::PtzCommandFailed($err_msg.into()))
193 }
194 };
195 ($self:expr, $func:ident, $param:expr, $err_msg:expr) => {
196 if unsafe { $func($self.core.instance, $param) } {
197 Ok(())
198 } else {
199 Err(Error::PtzCommandFailed($err_msg))
200 }
201 };
202 ($self:expr, $func:ident, $param1:expr, $param2:expr, $err_msg:expr) => {
203 if unsafe { $func($self.core.instance, $param1, $param2) } {
204 Ok(())
205 } else {
206 Err(Error::PtzCommandFailed($err_msg))
207 }
208 };
209 ($self:expr, $func:ident, $param1:expr, $param2:expr, $param3:expr, $err_msg:expr) => {
210 if unsafe { $func($self.core.instance, $param1, $param2, $param3) } {
211 Ok(())
212 } else {
213 Err(Error::PtzCommandFailed($err_msg))
214 }
215 };
216}
217
218/// Operational color formats for received video frames.
219///
220/// These values are receiver configuration modes that the NDI SDK can use for
221/// normal receive operation. SDK `Max` sentinel values are intentionally not
222/// exposed in safe Rust because they are placeholders, not valid receiver
223/// modes.
224///
225/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
226/// added without another avoidable public enum break. Downstream `match`
227/// expressions should include a wildcard arm.
228///
229/// Choose an explicit operational value such as [`ReceiverColorFormat::Fastest`],
230/// [`ReceiverColorFormat::Best`], [`ReceiverColorFormat::RGBX_RGBA`], or
231/// [`ReceiverColorFormat::BGRX_BGRA`] for receiver configuration.
232#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
233#[non_exhaustive]
234pub enum ReceiverColorFormat {
235 #[default]
236 BGRX_BGRA,
237 UYVY_BGRA,
238 RGBX_RGBA,
239 UYVY_RGBA,
240 Fastest,
241 Best,
242}
243
244impl From<ReceiverColorFormat> for NDIlib_recv_color_format_e {
245 fn from(format: ReceiverColorFormat) -> Self {
246 match format {
247 ReceiverColorFormat::BGRX_BGRA => {
248 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
249 }
250 ReceiverColorFormat::UYVY_BGRA => {
251 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
252 }
253 ReceiverColorFormat::RGBX_RGBA => {
254 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
255 }
256 ReceiverColorFormat::UYVY_RGBA => {
257 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
258 }
259 ReceiverColorFormat::Fastest => {
260 NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
261 }
262 ReceiverColorFormat::Best => NDIlib_recv_color_format_e_NDIlib_recv_color_format_best,
263 }
264 }
265}
266
267/// Operational bandwidth modes for receivers.
268///
269/// These values are receiver configuration modes that the NDI SDK can use for
270/// normal receive operation. SDK `Max` sentinel values are intentionally not
271/// exposed in safe Rust because they are placeholders, not valid receiver
272/// modes.
273///
274/// This enum is marked `#[non_exhaustive]` so future SDK receiver modes can be
275/// added without another avoidable public enum break. Downstream `match`
276/// expressions should include a wildcard arm.
277///
278/// Choose an explicit operational value such as [`ReceiverBandwidth::Highest`],
279/// [`ReceiverBandwidth::Lowest`], [`ReceiverBandwidth::AudioOnly`], or
280/// [`ReceiverBandwidth::MetadataOnly`] for receiver configuration.
281#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
282#[non_exhaustive]
283pub enum ReceiverBandwidth {
284 MetadataOnly,
285 AudioOnly,
286 Lowest,
287 #[default]
288 Highest,
289}
290
291impl From<ReceiverBandwidth> for NDIlib_recv_bandwidth_e {
292 fn from(bandwidth: ReceiverBandwidth) -> Self {
293 match bandwidth {
294 ReceiverBandwidth::MetadataOnly => {
295 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
296 }
297 ReceiverBandwidth::AudioOnly => {
298 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
299 }
300 ReceiverBandwidth::Lowest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
301 ReceiverBandwidth::Highest => NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
302 }
303 }
304}
305
306#[derive(Debug, Default, Clone)]
307pub struct ReceiverOptions {
308 pub source_to_connect_to: Source,
309 pub color_format: ReceiverColorFormat,
310 pub bandwidth: ReceiverBandwidth,
311 pub allow_video_fields: bool,
312 pub ndi_recv_name: Option<String>,
313}
314
315#[repr(C)]
316pub(crate) struct RawRecvCreateV3 {
317 _source: RawSource,
318 _name: Option<CString>,
319 pub raw: NDIlib_recv_create_v3_t,
320}
321
322impl ReceiverOptions {
323 /// Convert to raw format for FFI use
324 ///
325 /// # Safety
326 ///
327 /// The returned RawRecvCreateV3 struct uses #[repr(C)] to guarantee C-compatible layout
328 /// for safe FFI interop with the NDI SDK.
329 pub(crate) fn to_raw(&self) -> Result<RawRecvCreateV3> {
330 let source = self.source_to_connect_to.to_raw()?;
331 let name = self
332 .ndi_recv_name
333 .as_ref()
334 .map(|n| CString::new(n.clone()))
335 .transpose()
336 .map_err(Error::InvalidCString)?;
337
338 let p_ndi_recv_name = name.as_ref().map_or(ptr::null(), |n| n.as_ptr());
339 let source_raw = source.raw;
340
341 Ok(RawRecvCreateV3 {
342 raw: NDIlib_recv_create_v3_t {
343 source_to_connect_to: source_raw,
344 color_format: self.color_format.into(),
345 bandwidth: self.bandwidth.into(),
346 allow_video_fields: self.allow_video_fields,
347 p_ndi_recv_name,
348 },
349 _source: source,
350 _name: name,
351 })
352 }
353
354 /// Create a builder for configuring a receiver
355 pub fn builder(source: Source) -> ReceiverOptionsBuilder {
356 ReceiverOptionsBuilder::new(source)
357 }
358}
359
360/// Builder for configuring a ReceiverOptions with ergonomic method chaining
361#[derive(Debug, Clone)]
362pub struct ReceiverOptionsBuilder {
363 source_to_connect_to: Source,
364 color_format: Option<ReceiverColorFormat>,
365 bandwidth: Option<ReceiverBandwidth>,
366 allow_video_fields: Option<bool>,
367 ndi_recv_name: Option<String>,
368}
369
370impl ReceiverOptionsBuilder {
371 /// Create a new builder with the specified source
372 pub fn new(source: Source) -> Self {
373 Self {
374 source_to_connect_to: source,
375 color_format: None,
376 bandwidth: None,
377 allow_video_fields: None,
378 ndi_recv_name: None,
379 }
380 }
381
382 /// Preset for capturing snapshots (low resolution, RGBA, lowest bandwidth).
383 ///
384 /// This preset is optimized for:
385 /// - Image export and snapshot capture
386 /// - AI/ML processing pipelines
387 /// - Thumbnail generation
388 /// - Low bandwidth environments
389 ///
390 /// Configuration:
391 /// - Color format: `RGBX_RGBA` (compatible with image encoding)
392 /// - Bandwidth: `Lowest` (reduces resolution and bitrate)
393 /// - Video fields: Disabled (progressive frames only)
394 ///
395 /// # Example
396 ///
397 /// ```no_run
398 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
399 /// # use std::time::Duration;
400 /// # fn main() -> Result<(), grafton_ndi::Error> {
401 /// # let ndi = NDI::new()?;
402 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
403 /// # finder.wait_for_sources(Duration::from_secs(1))?;
404 /// # let sources = finder.current_sources()?;
405 /// let options = ReceiverOptionsBuilder::snapshot_preset(sources[0].clone())
406 /// .name("Snapshot Receiver")
407 /// .build();
408 /// let receiver = Receiver::new(&ndi, &options)?;
409 ///
410 /// // Capture and encode in one line (requires image-encoding feature)
411 /// #[cfg(feature = "image-encoding")]
412 /// {
413 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
414 /// let png_bytes = frame.encode_png()?;
415 /// std::fs::write("snapshot.png", &png_bytes)?;
416 /// }
417 /// # Ok(())
418 /// # }
419 /// ```
420 pub fn snapshot_preset(source: Source) -> Self {
421 Self::new(source)
422 .color(ReceiverColorFormat::RGBX_RGBA)
423 .bandwidth(ReceiverBandwidth::Lowest)
424 .allow_video_fields(false)
425 }
426
427 /// Preset for high-quality video processing (full resolution, highest bandwidth).
428 ///
429 /// This preset is optimized for:
430 /// - Professional video processing workflows
431 /// - Recording and archival
432 /// - Real-time video analysis requiring full quality
433 /// - Broadcasting and production
434 ///
435 /// Configuration:
436 /// - Color format: `RGBX_RGBA` (uncompressed, compatible with most tools)
437 /// - Bandwidth: `Highest` (full resolution and bitrate)
438 /// - Video fields: Enabled (supports interlaced sources)
439 ///
440 /// # Example
441 ///
442 /// ```no_run
443 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
444 /// # use std::time::Duration;
445 /// # fn main() -> Result<(), grafton_ndi::Error> {
446 /// # let ndi = NDI::new()?;
447 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
448 /// # finder.wait_for_sources(Duration::from_secs(1))?;
449 /// # let sources = finder.current_sources()?;
450 /// let options = ReceiverOptionsBuilder::high_quality_preset(sources[0].clone())
451 /// .name("High Quality Receiver")
452 /// .build();
453 /// let receiver = Receiver::new(&ndi, &options)?;
454 ///
455 /// // Capture full quality frames
456 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
457 /// println!("Captured {width}x{height} frame", width = frame.width(), height = frame.height());
458 /// # Ok(())
459 /// # }
460 /// ```
461 pub fn high_quality_preset(source: Source) -> Self {
462 Self::new(source)
463 .color(ReceiverColorFormat::RGBX_RGBA)
464 .bandwidth(ReceiverBandwidth::Highest)
465 .allow_video_fields(true)
466 }
467
468 /// Preset for metadata and connection monitoring only (no video/audio).
469 ///
470 /// This preset is optimized for:
471 /// - Connection status tracking
472 /// - PTZ control applications
473 /// - Minimal bandwidth overhead
474 ///
475 /// Configuration:
476 /// - Bandwidth: `MetadataOnly` (no video or audio data)
477 /// - Color format: Default (not used for metadata-only)
478 /// - Video fields: Disabled (not applicable)
479 ///
480 /// # Example
481 ///
482 /// ```no_run
483 /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptionsBuilder, Receiver};
484 /// # use std::time::Duration;
485 /// # fn main() -> Result<(), grafton_ndi::Error> {
486 /// # let ndi = NDI::new()?;
487 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
488 /// # finder.wait_for_sources(Duration::from_secs(1))?;
489 /// # let sources = finder.current_sources()?;
490 /// let options = ReceiverOptionsBuilder::monitoring_preset(sources[0].clone())
491 /// .name("Status Monitor")
492 /// .build();
493 /// let receiver = Receiver::new(&ndi, &options)?;
494 ///
495 /// // Poll for status changes
496 /// if let Some(status) = receiver.poll_status_change(Duration::from_millis(1000))? {
497 /// println!("Active connections: {}", status.connections);
498 /// }
499 /// # Ok(())
500 /// # }
501 /// ```
502 pub fn monitoring_preset(source: Source) -> Self {
503 Self::new(source)
504 .bandwidth(ReceiverBandwidth::MetadataOnly)
505 .allow_video_fields(false)
506 }
507
508 /// Set the color format for received video
509 #[must_use]
510 pub fn color(mut self, fmt: ReceiverColorFormat) -> Self {
511 self.color_format = Some(fmt);
512 self
513 }
514
515 /// Set the bandwidth mode for the receiver
516 #[must_use]
517 pub fn bandwidth(mut self, bw: ReceiverBandwidth) -> Self {
518 self.bandwidth = Some(bw);
519 self
520 }
521
522 /// Configure whether to allow video fields
523 #[must_use]
524 pub fn allow_video_fields(mut self, allow: bool) -> Self {
525 self.allow_video_fields = Some(allow);
526 self
527 }
528
529 /// Set the name for this receiver
530 #[must_use]
531 pub fn name<S: Into<String>>(mut self, name: S) -> Self {
532 self.ndi_recv_name = Some(name.into());
533 self
534 }
535
536 /// Build the receiver options
537 ///
538 /// This method is infallible and simply applies defaults for any unset options.
539 /// To create a `Receiver`, pass the resulting `ReceiverOptions` to `Receiver::new()`.
540 ///
541 /// # Example
542 ///
543 /// ```no_run
544 /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, Source};
545 /// # fn main() -> Result<(), grafton_ndi::Error> {
546 /// # let ndi = NDI::new()?;
547 /// # let source = Source::default();
548 /// let options = ReceiverOptions::builder(source).build();
549 /// let receiver = Receiver::new(&ndi, &options)?;
550 /// # Ok(())
551 /// # }
552 /// ```
553 pub fn build(self) -> ReceiverOptions {
554 ReceiverOptions {
555 source_to_connect_to: self.source_to_connect_to,
556 color_format: self.color_format.unwrap_or(ReceiverColorFormat::BGRX_BGRA),
557 bandwidth: self.bandwidth.unwrap_or(ReceiverBandwidth::Highest),
558 allow_video_fields: self.allow_video_fields.unwrap_or(true),
559 ndi_recv_name: self.ndi_recv_name,
560 }
561 }
562}
563
564mod mode_sealed {
565 pub trait Sealed {}
566}
567
568/// Sealed receiver capability mode. Common controls, audio, and metadata work in both modes.
569pub trait ReceiverMode: mode_sealed::Sealed + Send + Sync {}
570
571/// Ordinary receiver mode, permitting raw video capture.
572#[derive(Debug)]
573pub enum RawCapture {}
574/// Receiver mode held exclusively by [`crate::FrameSync`], without raw video access.
575#[derive(Debug)]
576pub enum FrameSyncAttached {}
577impl mode_sealed::Sealed for RawCapture {}
578impl mode_sealed::Sealed for FrameSyncAttached {}
579impl ReceiverMode for RawCapture {}
580impl ReceiverMode for FrameSyncAttached {}
581
582/// An owned NDI receiver. The default mode permits all raw capture kinds.
583/// FrameSync privately changes the mode while retaining the same resource core.
584pub struct Receiver<M: ReceiverMode = RawCapture> {
585 pub(crate) core: ReceiverCore,
586 _mode: PhantomData<M>,
587}
588
589pub(crate) struct ReceiverCore {
590 pub(crate) instance: NDIlib_recv_instance_t,
591 pub(crate) _ndi: NDI,
592 source: Source,
593 /// Serializes connection changes against in-flight captures.
594 ///
595 /// Capture calls take this lock *shared* (so video, audio, and metadata
596 /// captures still run concurrently), while [`Receiver::reconnect`] takes it
597 /// *exclusively*. This guarantees `NDIlib_recv_connect` never overlaps a
598 /// `NDIlib_recv_capture_v3` on the same instance — the one combination the
599 /// SDK does not make safe — without otherwise serializing capture.
600 capture_guard: RwLock<()>,
601 pending_status: AtomicBool,
602}
603
604impl Receiver {
605 pub fn new(ndi: &NDI, create: &ReceiverOptions) -> Result<Self> {
606 let create_raw = create.to_raw()?;
607 let instance = unsafe { NDIlib_recv_create_v3(&create_raw.raw) };
608 if instance.is_null() {
609 Err(Error::InitializationFailed(
610 "Failed to create NDI recv instance".into(),
611 ))
612 } else {
613 Ok(Self {
614 core: ReceiverCore {
615 instance,
616 _ndi: ndi.clone(),
617 source: create.source_to_connect_to.clone(),
618 capture_guard: RwLock::new(()),
619 pending_status: AtomicBool::new(false),
620 },
621 _mode: PhantomData,
622 })
623 }
624 }
625
626 /// Capture **video** frames from this receiver.
627 ///
628 /// Returns a [`Capture`] view whose verbs cover the three capture styles:
629 ///
630 /// - [`capture`](Capture::capture) — reliable owned capture that retries
631 /// across the NDI SDK's initial-sync warm-up.
632 /// - [`try_capture`](Capture::try_capture) — a single owned poll, `Ok(None)`
633 /// when no frame is ready.
634 /// - [`try_capture_ref`](Capture::try_capture_ref) — a single zero-copy poll
635 /// borrowing the SDK buffer in place.
636 ///
637 /// Video frames may carry per-row padding (e.g. RGBX/BGRX); see
638 /// [`VideoFrame`](crate::VideoFrame) for how to read them correctly.
639 ///
640 /// # Examples
641 ///
642 /// ```no_run
643 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
644 /// # use std::time::Duration;
645 /// # fn main() -> Result<(), grafton_ndi::Error> {
646 /// # let ndi = NDI::new()?;
647 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
648 /// # let options = ReceiverOptions::builder(source).build();
649 /// # let receiver = Receiver::new(&ndi, &options)?;
650 /// // Reliable owned capture
651 /// let frame = receiver.video().capture(Duration::from_secs(5))?;
652 /// println!("{}x{}", frame.width(), frame.height());
653 ///
654 /// // Zero-copy borrow
655 /// if let Some(frame) = receiver.video().try_capture_ref(Duration::from_secs(1))? {
656 /// for plane in frame.planes() {
657 /// for row in plane.rows() {
658 /// println!("{} active bytes in place", row.len());
659 /// }
660 /// }
661 /// }
662 /// # Ok(())
663 /// # }
664 /// ```
665 #[must_use = "the Capture view does nothing until a capture verb is called"]
666 pub fn video(&self) -> Capture<'_, VideoKind> {
667 Capture::new(&self.core)
668 }
669}
670
671impl<M: ReceiverMode> Receiver<M> {
672 pub(crate) fn into_mode<T: ReceiverMode>(self) -> Receiver<T> {
673 Receiver {
674 core: self.core,
675 _mode: PhantomData,
676 }
677 }
678
679 /// Publish this receiver's program/preview tally upstream.
680 ///
681 /// Returns the SDK's status flag unchanged. SDK implementations may report
682 /// success while disconnected; use [`Self::is_connected`] to check the
683 /// connection. The SDK retains tally state for future connections.
684 pub fn set_tally(&self, tally: &Tally) -> bool {
685 unsafe { NDIlib_recv_set_tally(self.core.instance, &tally.to_raw()) }
686 }
687
688 /// Send metadata upstream and return the SDK's status flag unchanged.
689 ///
690 /// This flag is not a connection query; use [`Self::is_connected`] for that.
691 pub fn send_metadata(&self, metadata: &MetadataFrame) -> bool {
692 let raw = metadata.to_raw();
693 // The borrowed C string remains live through this synchronous SDK call.
694 unsafe { NDIlib_recv_send_metadata(self.core.instance, &raw) }
695 }
696
697 /// Return whether the connected source supports PTZ commands.
698 pub fn ptz_is_supported(&self) -> bool {
699 unsafe { NDIlib_recv_ptz_is_supported(self.core.instance) }
700 }
701
702 /// Recall preset `0..=99` at a finite speed within `0.0..=1.0`.
703 ///
704 /// # Errors
705 ///
706 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
707 /// parameters, before issuing an SDK command.
708 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
709 pub fn ptz_recall_preset(&self, preset: u32, speed: f32) -> Result<()> {
710 let preset = validate_ptz_preset(i64::from(preset))?;
711 validate_ptz_value("speed", speed, 0.0, 1.0)?;
712 ptz_command!(
713 self,
714 NDIlib_recv_ptz_recall_preset,
715 preset,
716 speed,
717 format!("Failed to recall PTZ preset {preset} with speed {speed}")
718 )
719 }
720
721 /// Set absolute zoom to a finite value within `0.0..=1.0`.
722 /// Zero is zoomed in; one is zoomed out.
723 ///
724 /// # Errors
725 ///
726 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
727 /// parameters, before issuing an SDK command.
728 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
729 pub fn ptz_zoom(&self, zoom_value: f32) -> Result<()> {
730 validate_ptz_value("zoom_value", zoom_value, 0.0, 1.0)?;
731 ptz_command!(
732 self,
733 NDIlib_recv_ptz_zoom,
734 zoom_value,
735 format!("Failed to set PTZ zoom to {zoom_value}")
736 )
737 }
738
739 /// Set zoom speed within `-1.0..=1.0` (finite).
740 /// Negative moves outward; positive moves inward.
741 ///
742 /// # Errors
743 ///
744 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
745 /// parameters, before issuing an SDK command.
746 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
747 pub fn ptz_zoom_speed(&self, zoom_speed: f32) -> Result<()> {
748 validate_ptz_value("zoom_speed", zoom_speed, -1.0, 1.0)?;
749 ptz_command!(
750 self,
751 NDIlib_recv_ptz_zoom_speed,
752 zoom_speed,
753 format!("Failed to set PTZ zoom speed to {zoom_speed}")
754 )
755 }
756
757 /// Set finite pan and tilt positions within `-1.0..=1.0`.
758 /// Pan increases from left to right; tilt increases from bottom to top.
759 ///
760 /// # Errors
761 ///
762 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
763 /// parameters, before issuing an SDK command.
764 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
765 pub fn ptz_pan_tilt(&self, pan_value: f32, tilt_value: f32) -> Result<()> {
766 validate_ptz_value("pan_value", pan_value, -1.0, 1.0)?;
767 validate_ptz_value("tilt_value", tilt_value, -1.0, 1.0)?;
768 ptz_command!(
769 self,
770 NDIlib_recv_ptz_pan_tilt,
771 pan_value,
772 tilt_value,
773 format!("Failed to set PTZ pan/tilt to ({pan_value}, {tilt_value})")
774 )
775 }
776
777 /// Set finite pan and tilt speeds within `-1.0..=1.0`.
778 /// Positive pan moves left; positive tilt moves up. Zero stops motion.
779 ///
780 /// # Errors
781 ///
782 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
783 /// parameters, before issuing an SDK command.
784 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
785 pub fn ptz_pan_tilt_speed(&self, pan_speed: f32, tilt_speed: f32) -> Result<()> {
786 validate_ptz_value("pan_speed", pan_speed, -1.0, 1.0)?;
787 validate_ptz_value("tilt_speed", tilt_speed, -1.0, 1.0)?;
788 ptz_command!(
789 self,
790 NDIlib_recv_ptz_pan_tilt_speed,
791 pan_speed,
792 tilt_speed,
793 format!("Failed to set PTZ pan/tilt speed to ({pan_speed}, {tilt_speed})")
794 )
795 }
796
797 /// Store the current camera settings as preset `0..=99`.
798 ///
799 /// # Errors
800 ///
801 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
802 /// parameters, before issuing an SDK command.
803 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
804 pub fn ptz_store_preset(&self, preset_no: i32) -> Result<()> {
805 let preset_no = validate_ptz_preset(i64::from(preset_no))?;
806 ptz_command!(
807 self,
808 NDIlib_recv_ptz_store_preset,
809 preset_no,
810 format!("Failed to store PTZ preset {preset_no}")
811 )
812 }
813
814 /// Enable automatic focus.
815 ///
816 /// # Errors
817 ///
818 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
819 pub fn ptz_auto_focus(&self) -> Result<()> {
820 ptz_command!(
821 self,
822 NDIlib_recv_ptz_auto_focus,
823 "Failed to enable PTZ auto focus"
824 )
825 }
826
827 /// Set finite absolute focus within `0.0..=1.0`.
828 /// Zero focuses at infinity; one focuses as close as possible.
829 ///
830 /// # Errors
831 ///
832 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
833 /// parameters, before issuing an SDK command.
834 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
835 pub fn ptz_focus(&self, focus_value: f32) -> Result<()> {
836 validate_ptz_value("focus_value", focus_value, 0.0, 1.0)?;
837 ptz_command!(
838 self,
839 NDIlib_recv_ptz_focus,
840 focus_value,
841 format!("Failed to set PTZ focus to {focus_value}")
842 )
843 }
844
845 /// Set finite focus speed within `-1.0..=1.0`.
846 /// Negative focuses outward; positive focuses inward.
847 ///
848 /// # Errors
849 ///
850 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
851 /// parameters, before issuing an SDK command.
852 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
853 pub fn ptz_focus_speed(&self, focus_speed: f32) -> Result<()> {
854 validate_ptz_value("focus_speed", focus_speed, -1.0, 1.0)?;
855 ptz_command!(
856 self,
857 NDIlib_recv_ptz_focus_speed,
858 focus_speed,
859 format!("Failed to set PTZ focus speed to {focus_speed}")
860 )
861 }
862
863 /// Enable automatic white balance.
864 ///
865 /// # Errors
866 ///
867 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
868 pub fn ptz_white_balance_auto(&self) -> Result<()> {
869 ptz_command!(
870 self,
871 NDIlib_recv_ptz_white_balance_auto,
872 "Failed to set PTZ auto white balance"
873 )
874 }
875
876 /// Select the indoor white-balance preset.
877 ///
878 /// # Errors
879 ///
880 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
881 pub fn ptz_white_balance_indoor(&self) -> Result<()> {
882 ptz_command!(
883 self,
884 NDIlib_recv_ptz_white_balance_indoor,
885 "Failed to set PTZ indoor white balance"
886 )
887 }
888
889 /// Select the outdoor white-balance preset.
890 ///
891 /// # Errors
892 ///
893 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
894 pub fn ptz_white_balance_outdoor(&self) -> Result<()> {
895 ptz_command!(
896 self,
897 NDIlib_recv_ptz_white_balance_outdoor,
898 "Failed to set PTZ outdoor white balance"
899 )
900 }
901
902 /// Set white balance from the current image.
903 ///
904 /// # Errors
905 ///
906 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
907 pub fn ptz_white_balance_oneshot(&self) -> Result<()> {
908 ptz_command!(
909 self,
910 NDIlib_recv_ptz_white_balance_oneshot,
911 "Failed to set PTZ oneshot white balance"
912 )
913 }
914
915 /// Set finite red and blue white-balance levels within `0.0..=1.0`.
916 ///
917 /// # Errors
918 ///
919 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
920 /// parameters, before issuing an SDK command.
921 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
922 pub fn ptz_white_balance_manual(&self, red: f32, blue: f32) -> Result<()> {
923 validate_ptz_value("red", red, 0.0, 1.0)?;
924 validate_ptz_value("blue", blue, 0.0, 1.0)?;
925 ptz_command!(
926 self,
927 NDIlib_recv_ptz_white_balance_manual,
928 red,
929 blue,
930 format!("Failed to set PTZ manual white balance (red: {red}, blue: {blue})")
931 )
932 }
933
934 /// Enable automatic exposure.
935 ///
936 /// # Errors
937 ///
938 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
939 pub fn ptz_exposure_auto(&self) -> Result<()> {
940 ptz_command!(
941 self,
942 NDIlib_recv_ptz_exposure_auto,
943 "Failed to set PTZ auto exposure"
944 )
945 }
946
947 /// Set finite exposure within `0.0..=1.0`, from dark to light.
948 ///
949 /// # Errors
950 ///
951 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
952 /// parameters, before issuing an SDK command.
953 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
954 pub fn ptz_exposure_manual(&self, exposure_level: f32) -> Result<()> {
955 validate_ptz_value("exposure_level", exposure_level, 0.0, 1.0)?;
956 ptz_command!(
957 self,
958 NDIlib_recv_ptz_exposure_manual,
959 exposure_level,
960 format!("Failed to set PTZ manual exposure to {exposure_level}")
961 )
962 }
963
964 /// Set finite iris, gain, and shutter-speed levels within `0.0..=1.0`.
965 /// Iris and gain increase from dark to light; shutter speed increases from slow to fast.
966 ///
967 /// # Errors
968 ///
969 /// Returns [`Error::InvalidConfiguration`] for out-of-range or non-finite
970 /// parameters, before issuing an SDK command.
971 /// Returns [`Error::PtzCommandFailed`] if the SDK rejects the command.
972 pub fn ptz_exposure_manual_v2(&self, iris: f32, gain: f32, shutter_speed: f32) -> Result<()> {
973 validate_ptz_value("iris", iris, 0.0, 1.0)?;
974 validate_ptz_value("gain", gain, 0.0, 1.0)?;
975 validate_ptz_value("shutter_speed", shutter_speed, 0.0, 1.0)?;
976 ptz_command!(
977 self,
978 NDIlib_recv_ptz_exposure_manual_v2,
979 iris,
980 gain,
981 shutter_speed,
982 format!("Failed to set PTZ manual exposure v2 (iris: {iris}, gain: {gain}, shutter: {shutter_speed})")
983 )
984 }
985
986 /// Capture **audio** frames from this receiver.
987 ///
988 /// Returns a [`Capture`] view; see [`Receiver::video`] for the three
989 /// capture verbs it exposes ([`capture`](Capture::capture),
990 /// [`try_capture`](Capture::try_capture),
991 /// [`try_capture_ref`](Capture::try_capture_ref)).
992 ///
993 /// # Examples
994 ///
995 /// ```no_run
996 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
997 /// # use std::time::Duration;
998 /// # fn main() -> Result<(), grafton_ndi::Error> {
999 /// # let ndi = NDI::new()?;
1000 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1001 /// # let options = ReceiverOptions::builder(source).build();
1002 /// # let receiver = Receiver::new(&ndi, &options)?;
1003 /// if let Some(audio) = receiver.audio().try_capture_ref(Duration::from_secs(1))? {
1004 /// println!("{} channels, {} samples", audio.num_channels(), audio.num_samples());
1005 /// }
1006 /// # Ok(())
1007 /// # }
1008 /// ```
1009 #[must_use = "the Capture view does nothing until a capture verb is called"]
1010 pub fn audio(&self) -> Capture<'_, AudioKind> {
1011 Capture::new(&self.core)
1012 }
1013
1014 /// Capture **metadata** frames from this receiver.
1015 ///
1016 /// Returns a [`Capture`] view; see [`Receiver::video`] for the three
1017 /// capture verbs it exposes ([`capture`](Capture::capture),
1018 /// [`try_capture`](Capture::try_capture),
1019 /// [`try_capture_ref`](Capture::try_capture_ref)).
1020 ///
1021 /// # Examples
1022 ///
1023 /// ```no_run
1024 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1025 /// # use std::time::Duration;
1026 /// # fn main() -> Result<(), grafton_ndi::Error> {
1027 /// # let ndi = NDI::new()?;
1028 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1029 /// # let options = ReceiverOptions::builder(source).build();
1030 /// # let receiver = Receiver::new(&ndi, &options)?;
1031 /// if let Some(meta) = receiver.metadata().try_capture(Duration::from_millis(100))? {
1032 /// println!("metadata: {}", meta.data());
1033 /// }
1034 /// # Ok(())
1035 /// # }
1036 /// ```
1037 #[must_use = "the Capture view does nothing until a capture verb is called"]
1038 pub fn metadata(&self) -> Capture<'_, MetadataKind> {
1039 Capture::new(&self.core)
1040 }
1041
1042 /// Check if the receiver is still connected to its source.
1043 ///
1044 /// Returns `true` if there is at least one active connection to the source,
1045 /// `false` otherwise. This can be used to detect when a source goes offline
1046 /// or becomes unavailable.
1047 ///
1048 /// # Examples
1049 ///
1050 /// ```no_run
1051 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1052 /// # fn main() -> Result<(), grafton_ndi::Error> {
1053 /// # let ndi = NDI::new()?;
1054 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1055 /// # let options = ReceiverOptions::builder(source).build();
1056 /// # let receiver = Receiver::new(&ndi, &options)?;
1057 /// if receiver.is_connected() {
1058 /// println!("Still connected to source");
1059 /// } else {
1060 /// println!("Lost connection to source");
1061 /// }
1062 /// # Ok(())
1063 /// # }
1064 /// ```
1065 pub fn is_connected(&self) -> bool {
1066 unsafe { NDIlib_recv_get_no_connections(self.core.instance) > 0 }
1067 }
1068
1069 /// Re-establish this receiver's connection to its original source,
1070 /// in place.
1071 ///
1072 /// Re-points the existing NDI receiver instance at the [`Source`] it
1073 /// was created for **without** destroying and recreating the
1074 /// receiver. This is intended for mid-session recovery: when a
1075 /// source's feed has gone silent (for example an encoder-bound
1076 /// NDI|HX camera that dropped its proxy stream under load), a
1077 /// reconnect forces a fresh connection attempt while avoiding the
1078 /// source-rediscovery round trip — and the discovery race — that
1079 /// tearing down and rebuilding the receiver would incur.
1080 ///
1081 /// Liveness can be observed via [`Self::is_connected`] and
1082 /// [`Self::connection_stats`] (`video_frames_received` resuming its
1083 /// climb indicates frames are flowing again).
1084 ///
1085 /// # Thread safety
1086 ///
1087 /// `NDIlib_recv_connect` is the one receive call the SDK does *not* make
1088 /// safe to run concurrently with `NDIlib_recv_capture_v3` on the same
1089 /// instance. This method therefore takes the receiver's capture guard
1090 /// exclusively: it **blocks until every in-flight capture on this receiver
1091 /// returns**, and holds off any capture that starts while it runs. Captures
1092 /// on the same receiver still run concurrently with each other; only a
1093 /// reconnect is exclusive. Because it can block for as long as a capture's
1094 /// timeout, prefer short capture timeouts on receivers you intend to
1095 /// recover, and call it off the async runtime — `AsyncReceiver::reconnect`
1096 /// does this for you.
1097 ///
1098 /// # Errors
1099 ///
1100 /// Returns an error only if the stored [`Source`] cannot be
1101 /// re-marshalled to its FFI representation; `NDIlib_recv_connect`
1102 /// itself reports no status, so a returned `Ok` means the reconnect was
1103 /// *issued*, not that the feed has recovered — confirm recovery via
1104 /// [`Self::connection_stats`].
1105 pub fn reconnect(&self) -> Result<()> {
1106 // Marshal before locking; this touches only `self.core.source`, not the
1107 // instance, so it need not hold off captures.
1108 let raw = self.core.source.to_raw()?;
1109 // Exclusive guard: waits for in-flight captures to drain and blocks new
1110 // ones, so `NDIlib_recv_connect` never overlaps `NDIlib_recv_capture_v3`.
1111 let _exclusive = self.core.capture_guard.write();
1112 // SAFETY: `self.core.instance` is a valid receiver instance for the
1113 // lifetime of `self`, and `raw` (which owns the backing
1114 // CStrings) outlives the call, so the pointers inside `raw.raw`
1115 // stay valid while the SDK copies the source descriptor.
1116 unsafe { NDIlib_recv_connect(self.core.instance, &raw.raw) };
1117 Ok(())
1118 }
1119
1120 /// Get the source this receiver is connected to.
1121 ///
1122 /// Returns a reference to the [`Source`] that was specified when creating
1123 /// this receiver. This is useful for identifying which source a receiver
1124 /// is associated with when managing multiple receivers.
1125 ///
1126 /// # Examples
1127 ///
1128 /// ```no_run
1129 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1130 /// # fn main() -> Result<(), grafton_ndi::Error> {
1131 /// # let ndi = NDI::new()?;
1132 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1133 /// # let options = ReceiverOptions::builder(source).build();
1134 /// # let receiver = Receiver::new(&ndi, &options)?;
1135 /// let source = receiver.source();
1136 /// println!("Connected to: {name}", name = source.name);
1137 /// # Ok(())
1138 /// # }
1139 /// ```
1140 pub fn source(&self) -> &Source {
1141 &self.core.source
1142 }
1143
1144 /// Get connection and performance statistics for this receiver.
1145 ///
1146 /// Provides detailed statistics including:
1147 /// - Number of active connections
1148 /// - Total frames received (video, audio, metadata)
1149 /// - Dropped frames (video, audio, metadata)
1150 /// - Queued frames waiting to be processed
1151 ///
1152 /// This is useful for monitoring receiver health and diagnosing
1153 /// performance issues in production applications.
1154 ///
1155 /// # Examples
1156 ///
1157 /// ```no_run
1158 /// # use grafton_ndi::{NDI, Receiver, ReceiverOptions, Source, SourceAddress};
1159 /// # fn main() -> Result<(), grafton_ndi::Error> {
1160 /// # let ndi = NDI::new()?;
1161 /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
1162 /// # let options = ReceiverOptions::builder(source).build();
1163 /// # let receiver = Receiver::new(&ndi, &options)?;
1164 /// let stats = receiver.connection_stats();
1165 /// println!("Connections: {connections}", connections = stats.connections);
1166 /// println!("Video frames: {received} (dropped: {dropped})",
1167 /// received = stats.video_frames_received,
1168 /// dropped = stats.video_frames_dropped);
1169 /// println!("Frame drop rate: {rate:.2}%",
1170 /// rate = stats.video_drop_percentage());
1171 /// # Ok(())
1172 /// # }
1173 /// ```
1174 pub fn connection_stats(&self) -> ConnectionStats {
1175 let connections = unsafe { NDIlib_recv_get_no_connections(self.core.instance) };
1176
1177 let mut total = NDIlib_recv_performance_t::default();
1178 let mut dropped = NDIlib_recv_performance_t::default();
1179 unsafe {
1180 NDIlib_recv_get_performance(self.core.instance, &mut total, &mut dropped);
1181 }
1182
1183 let mut queue = NDIlib_recv_queue_t::default();
1184 unsafe {
1185 NDIlib_recv_get_queue(self.core.instance, &mut queue);
1186 }
1187
1188 ConnectionStats {
1189 connections: connections.max(0) as u32,
1190 video_frames_received: total.video_frames.max(0) as u64,
1191 audio_frames_received: total.audio_frames.max(0) as u64,
1192 metadata_frames_received: total.metadata_frames.max(0) as u64,
1193 video_frames_dropped: dropped.video_frames.max(0) as u64,
1194 audio_frames_dropped: dropped.audio_frames.max(0) as u64,
1195 metadata_frames_dropped: dropped.metadata_frames.max(0) as u64,
1196 video_frames_queued: queue.video_frames.max(0) as u32,
1197 audio_frames_queued: queue.audio_frames.max(0) as u32,
1198 metadata_frames_queued: queue.metadata_frames.max(0) as u32,
1199 }
1200 }
1201
1202 /// Poll for SDK receiver status changes, including notifications consumed by media capture.
1203 ///
1204 /// Multiple unobserved notifications can coalesce. Concurrent status pollers
1205 /// compete to consume them; this is not a broadcast or event history. A pending
1206 /// notification avoids waiting, but publication cannot interrupt an SDK wait
1207 /// already in progress. Notifications are rechecked after lock/SDK waits.
1208 ///
1209 /// # Arguments
1210 ///
1211 /// * `timeout` - Maximum time to wait for status change.
1212 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
1213 ///
1214 /// # Returns
1215 ///
1216 /// * `Some(ReceiverStatus)` - Status has changed
1217 /// * `None` - Timeout occurred with no status change, including when a
1218 /// reconnect holds the capture lock for the remaining budget.
1219 ///
1220 /// # Errors
1221 ///
1222 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`],
1223 /// or [`Error::CaptureFailed`] if the SDK reports a lost connection.
1224 /// The budget includes lock contention; SDK calls may overshoot it.
1225 pub fn poll_status_change(&self, timeout: Duration) -> Result<Option<ReceiverStatus>> {
1226 self.core.poll_status_change(timeout)
1227 }
1228}
1229
1230impl ReceiverCore {
1231 /// Acquire only within the caller's existing budget, including contention.
1232 fn capture_lock_until(&self, deadline: Instant) -> Option<RwLockReadGuard<'_, ()>> {
1233 self.capture_guard.try_read_until(deadline)
1234 }
1235
1236 /// Single zero-copy poll backing every [`Capture::try_capture_ref`].
1237 ///
1238 /// Holds the shared capture guard for the duration of one
1239 /// `NDIlib_recv_capture_v3` call, so it runs concurrently with other
1240 /// captures but never overlaps a [`reconnect`](Receiver::reconnect).
1241 fn capture_ref_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<Option<K::Ref<'_>>> {
1242 to_ms_checked(timeout)?;
1243 self.capture_ref_kind_until::<K>(Instant::now() + timeout)
1244 }
1245
1246 fn capture_ref_kind_until<K: CaptureKind>(
1247 &self,
1248 deadline: Instant,
1249 ) -> Result<Option<K::Ref<'_>>> {
1250 let Some(_capture) = self.capture_lock_until(deadline) else {
1251 return Ok(None);
1252 };
1253 let timeout_ms = to_ms_checked(deadline.saturating_duration_since(Instant::now()))?;
1254 // SAFETY: self.instance is a valid NDI receiver instance.
1255 self.capture_result::<K>(unsafe { capture_raw::<K>(self.instance, timeout_ms) })
1256 }
1257
1258 fn capture_result<'rx, K: CaptureKind>(
1259 &'rx self,
1260 result: CaptureResult<'rx, K>,
1261 ) -> Result<Option<K::Ref<'rx>>> {
1262 match result {
1263 CaptureResult::Frame(guard) => {
1264 // Validation (e.g. FourCC) happens during ref construction.
1265 let frame_ref = unsafe { K::make_ref(guard)? };
1266 Ok(Some(frame_ref))
1267 }
1268 CaptureResult::StatusChanged => {
1269 self.pending_status.store(true, Ordering::Release);
1270 Ok(None)
1271 }
1272 CaptureResult::None => Ok(None),
1273 CaptureResult::Error => Err(Error::CaptureFailed("Received an error frame".into())),
1274 }
1275 }
1276
1277 /// Single owned poll backing every [`Capture::try_capture`].
1278 pub(crate) fn try_capture_kind<K: CaptureKind>(
1279 &self,
1280 timeout: Duration,
1281 ) -> Result<Option<K::Owned>> {
1282 match self.capture_ref_kind::<K>(timeout)? {
1283 Some(frame_ref) => Ok(Some(K::ref_to_owned(&frame_ref)?)),
1284 None => Ok(None),
1285 }
1286 }
1287
1288 pub(crate) fn try_capture_kind_until<K: CaptureKind>(
1289 &self,
1290 deadline: Instant,
1291 ) -> Result<Option<K::Owned>> {
1292 match self.capture_ref_kind_until::<K>(deadline)? {
1293 Some(frame_ref) => Ok(Some(K::ref_to_owned(&frame_ref)?)),
1294 None => Ok(None),
1295 }
1296 }
1297
1298 /// Retried owned capture backing every [`Capture::capture`].
1299 ///
1300 /// Polls [`try_capture_kind`](Self::try_capture_kind) under the default
1301 /// [`RetryPolicy`], absorbing the SDK's initial-sync misses where it returns
1302 /// `none` immediately instead of blocking for the full timeout.
1303 pub(crate) fn capture_kind<K: CaptureKind>(&self, timeout: Duration) -> Result<K::Owned> {
1304 to_ms_checked(timeout)?;
1305 self.capture_kind_until::<K>(Instant::now() + timeout)
1306 }
1307
1308 pub(crate) fn capture_kind_until<K: CaptureKind>(&self, deadline: Instant) -> Result<K::Owned> {
1309 retry_capture(
1310 deadline.saturating_duration_since(Instant::now()),
1311 &RetryPolicy::default(),
1312 |poll| self.try_capture_kind_until::<K>(deadline.min(Instant::now() + poll)),
1313 )
1314 }
1315
1316 pub fn poll_status_change(&self, timeout: Duration) -> Result<Option<ReceiverStatus>> {
1317 self.poll_status_with(timeout, |deadline| {
1318 let _capture = self.capture_lock_until(deadline)?;
1319 let timeout_ms = to_ms_checked(deadline.saturating_duration_since(Instant::now()))
1320 .expect("remaining validated timeout");
1321 // SAFETY: The live receiver and shared guard exclude reconnect;
1322 // null media slots request only notifications (Processing.NDI.Recv.h).
1323 Some(unsafe {
1324 NDIlib_recv_capture_v3(
1325 self.instance,
1326 ptr::null_mut(),
1327 ptr::null_mut(),
1328 ptr::null_mut(),
1329 timeout_ms,
1330 )
1331 })
1332 })
1333 }
1334
1335 fn take_pending_status(&self) -> Option<ReceiverStatus> {
1336 // Atomic swap linearizes competing pollers with capture publication.
1337 // A store after this take remains pending; no load/store pair can erase it.
1338 self.pending_status
1339 .swap(false, Ordering::AcqRel)
1340 .then(|| self.current_status())
1341 }
1342
1343 fn current_status(&self) -> ReceiverStatus {
1344 ReceiverStatus {
1345 connections: unsafe { NDIlib_recv_get_no_connections(self.instance).max(0) as u32 },
1346 }
1347 }
1348
1349 fn poll_status_with(
1350 &self,
1351 timeout: Duration,
1352 capture: impl FnOnce(Instant) -> Option<NDIlib_frame_type_e>,
1353 ) -> Result<Option<ReceiverStatus>> {
1354 to_ms_checked(timeout)?;
1355 let deadline = Instant::now() + timeout;
1356 if let Some(status) = self.take_pending_status() {
1357 return Ok(Some(status));
1358 }
1359 match capture(deadline) {
1360 Some(NDIlib_frame_type_e_NDIlib_frame_type_error) => {
1361 // Errors must not consume notifications published during the wait.
1362 Err(Error::CaptureFailed("Received an error frame".into()))
1363 }
1364 Some(NDIlib_frame_type_e_NDIlib_frame_type_status_change) => {
1365 Ok(Some(self.current_status()))
1366 }
1367 _ => Ok(self.take_pending_status()),
1368 }
1369 }
1370}
1371
1372/// A typed view over a [`Receiver`] for capturing frames of one kind.
1373///
1374/// Created by [`Receiver::video`], [`Receiver::audio`], and
1375/// [`Receiver::metadata`]. The view is a cheap borrow of the receiver and does
1376/// nothing on its own — call one of its verbs to capture:
1377///
1378/// - [`capture`](Self::capture) — reliable owned capture with built-in retry.
1379/// **The primary method.** It absorbs the NDI SDK's initial-sync warm-up (the
1380/// first few polls after connecting may return `none` immediately instead of
1381/// blocking). It can be used in a continuous capture loop.
1382/// - [`try_capture`](Self::try_capture) — a single owned poll; `Ok(None)` when
1383/// no frame is ready. For manual polling where you handle timing yourself.
1384/// - [`try_capture_ref`](Self::try_capture_ref) — a single zero-copy poll that
1385/// borrows the SDK's buffer in place (no allocation, no memcpy). The
1386/// recommended API for performance-critical, in-place processing: for a
1387/// 1920×1080 BGRA frame it avoids ~8.3 MB of copying per frame (~475 MB/s at
1388/// 60 fps). Convert to an owned frame with `to_owned` when you need to keep or
1389/// send it.
1390///
1391/// Media capture retains status notifications for [`Receiver::poll_status_change`],
1392/// even when retries later time out.
1393///
1394/// Every verb holds a shared capture guard for the duration of a single SDK
1395/// call, so captures of different kinds run concurrently with each other but
1396/// never overlap a [`Receiver::reconnect`]. Each timeout budget starts when the
1397/// verb is called and includes waiting for that guard. A zero timeout never
1398/// waits for the guard; if it is unavailable, no SDK capture is attempted.
1399/// SDK calls and owned-frame copying may finish after the budget expires, so
1400/// the timeout is not a strict wall-clock completion guarantee.
1401///
1402/// Only the ordinary receiver mode exposes a video view. FrameSync owns video
1403/// capture until [`crate::FrameSync::into_receiver`] restores the ordinary mode.
1404pub struct Capture<'rx, K: CaptureKind> {
1405 rx: &'rx ReceiverCore,
1406 _kind: PhantomData<K>,
1407}
1408
1409impl<'rx, K: CaptureKind> Capture<'rx, K> {
1410 fn new(rx: &'rx ReceiverCore) -> Self {
1411 Self {
1412 rx,
1413 _kind: PhantomData,
1414 }
1415 }
1416
1417 /// Reliable owned capture: blocks up to `timeout`, retrying across the NDI
1418 /// SDK's initial-sync warm-up.
1419 ///
1420 /// `timeout` is a total retry budget; each individual SDK poll is capped to
1421 /// the remaining budget, and [`Duration::ZERO`] performs one non-blocking
1422 /// attempt, skipping the SDK call if the capture guard is unavailable.
1423 /// Waiting for a reconnect lock consumes the same budget. The SDK itself may return after its requested timeout, so this
1424 /// budget is not a strict wall-clock completion guarantee.
1425 ///
1426 /// # Arguments
1427 ///
1428 /// * `timeout` - Total time to wait for a frame. Must not exceed
1429 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1430 ///
1431 /// # Errors
1432 ///
1433 /// Returns [`Error::FrameTimeout`] if no frame arrives within `timeout`;
1434 /// returns [`Error::InvalidConfiguration`] for an excessive timeout. SDK and frame-validation errors
1435 /// propagate from the capture itself.
1436 pub fn capture(&self, timeout: Duration) -> Result<K::Owned> {
1437 self.rx.capture_kind::<K>(timeout)
1438 }
1439
1440 /// Single owned poll: returns `Ok(None)` if no frame is available within
1441 /// `timeout`.
1442 ///
1443 /// Prefer [`capture`](Self::capture) for reliable capture; this variant does
1444 /// not retry the SDK's warm-up misses. The timeout includes waiting for a
1445 /// reconnect lock; see [`Capture`] for the shared timeout contract.
1446 ///
1447 /// # Arguments
1448 ///
1449 /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1450 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1451 ///
1452 /// # Errors
1453 ///
1454 /// Returns [`Error::InvalidConfiguration`] for an excessive timeout. SDK connection errors return
1455 /// [`Error::CaptureFailed`]; malformed frame descriptors also return errors.
1456 pub fn try_capture(&self, timeout: Duration) -> Result<Option<K::Owned>> {
1457 self.rx.try_capture_kind::<K>(timeout)
1458 }
1459
1460 /// Single zero-copy poll: returns a borrowed view of the SDK's buffer, or
1461 /// `Ok(None)` if no frame is available within `timeout`.
1462 ///
1463 /// The returned reference borrows the [`Receiver`] (not this temporary
1464 /// view), so it may outlive the `Capture` while keeping the receiver
1465 /// borrowed. The timeout includes waiting for a reconnect lock; see
1466 /// [`Capture`] for the shared timeout contract.
1467 ///
1468 /// # Arguments
1469 ///
1470 /// * `timeout` - Maximum time to wait for a frame. Must not exceed
1471 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
1472 ///
1473 /// # Errors
1474 ///
1475 /// Returns [`Error::InvalidConfiguration`] for an excessive timeout. SDK connection errors return
1476 /// [`Error::CaptureFailed`]; malformed frame descriptors also return errors.
1477 pub fn try_capture_ref(&self, timeout: Duration) -> Result<Option<K::Ref<'rx>>> {
1478 // Bind through `'rx` so the returned borrow is tied to the receiver, not
1479 // to this short-lived view.
1480 let rx: &'rx ReceiverCore = self.rx;
1481 rx.capture_ref_kind::<K>(timeout)
1482 }
1483}
1484
1485impl Drop for ReceiverCore {
1486 fn drop(&mut self) {
1487 unsafe {
1488 NDIlib_recv_destroy(self.instance);
1489 }
1490 #[cfg(test)]
1491 lifecycle_test_events::record("receiver");
1492 }
1493}
1494
1495/// # Safety
1496///
1497/// The NDI 6 SDK documentation explicitly states that recv operations are thread-safe.
1498/// `NDIlib_recv_capture_v3` and related functions use internal synchronization.
1499/// The Receiver struct only holds an opaque pointer returned by the SDK, and the SDK
1500/// guarantees that this pointer can be safely moved between threads.
1501unsafe impl Send for ReceiverCore {}
1502
1503/// # Safety
1504///
1505/// The NDI 6 SDK documentation guarantees that `NDIlib_recv_capture_v3` is internally
1506/// synchronized and can be called concurrently from multiple threads. This is explicitly
1507/// mentioned in the SDK manual's thread safety section. The capture verbs (via
1508/// [`Receiver::video`], [`Receiver::audio`], and [`Receiver::metadata`]) can be
1509/// safely called from multiple threads simultaneously. The wrapper's capture
1510/// guard excludes reconnect calls from receiver capture calls. Receiver modes
1511/// prevent raw video capture while a FrameSync owns this core.
1512/// Borrowed frame guards retain the receiver borrow until they free their buffers.
1513unsafe impl Sync for ReceiverCore {}
1514
1515/// An SDK status-change notification and the current connection count.
1516///
1517/// The event may also indicate changed source capabilities or web control;
1518/// the SDK does not identify a specific changed field. Tally is published
1519/// upstream through [`Receiver::set_tally`], rather than received here.
1520#[derive(Debug, Clone)]
1521pub struct ReceiverStatus {
1522 /// Current number of connected sources (normally zero or one).
1523 pub connections: u32,
1524}
1525
1526#[derive(Debug, Clone)]
1527pub struct Tally {
1528 pub on_program: bool,
1529 pub on_preview: bool,
1530}
1531
1532impl Tally {
1533 pub fn new(on_program: bool, on_preview: bool) -> Self {
1534 Tally {
1535 on_program,
1536 on_preview,
1537 }
1538 }
1539
1540 pub(crate) fn to_raw(&self) -> NDIlib_tally_t {
1541 NDIlib_tally_t {
1542 on_program: self.on_program,
1543 on_preview: self.on_preview,
1544 }
1545 }
1546}
1547
1548/// Connection and performance statistics for a receiver.
1549///
1550/// Provides detailed metrics about receiver health including connection count,
1551/// frame counts, and drop rates. Useful for monitoring and diagnostics.
1552#[derive(Debug, Clone)]
1553pub struct ConnectionStats {
1554 /// Number of active connections to this receiver
1555 pub connections: u32,
1556
1557 /// Total number of video frames received
1558 pub video_frames_received: u64,
1559
1560 /// Total number of audio frames received
1561 pub audio_frames_received: u64,
1562
1563 /// Total number of metadata frames received
1564 pub metadata_frames_received: u64,
1565
1566 /// Number of video frames dropped due to buffer overflow or processing delays
1567 pub video_frames_dropped: u64,
1568
1569 /// Number of audio frames dropped
1570 pub audio_frames_dropped: u64,
1571
1572 /// Number of metadata frames dropped
1573 pub metadata_frames_dropped: u64,
1574
1575 /// Number of video frames currently queued for processing
1576 pub video_frames_queued: u32,
1577
1578 /// Number of audio frames currently queued
1579 pub audio_frames_queued: u32,
1580
1581 /// Number of metadata frames currently queued
1582 pub metadata_frames_queued: u32,
1583}
1584
1585impl ConnectionStats {
1586 /// Calculate video frame drop percentage.
1587 ///
1588 /// Returns the percentage of video frames that were dropped out of the total
1589 /// received + dropped. Returns 0.0 if both counters are zero.
1590 ///
1591 /// # Examples
1592 ///
1593 /// ```
1594 /// # use grafton_ndi::ConnectionStats;
1595 /// let stats = ConnectionStats {
1596 /// connections: 1,
1597 /// video_frames_received: 900,
1598 /// video_frames_dropped: 100,
1599 /// audio_frames_received: 0,
1600 /// audio_frames_dropped: 0,
1601 /// metadata_frames_received: 0,
1602 /// metadata_frames_dropped: 0,
1603 /// video_frames_queued: 5,
1604 /// audio_frames_queued: 0,
1605 /// metadata_frames_queued: 0,
1606 /// };
1607 /// assert_eq!(stats.video_drop_percentage(), 10.0);
1608 /// ```
1609 pub fn video_drop_percentage(&self) -> f64 {
1610 drop_percentage(self.video_frames_received, self.video_frames_dropped)
1611 }
1612
1613 /// Calculate audio frame drop percentage.
1614 ///
1615 /// Returns the percentage of audio frames that were dropped out of the total
1616 /// received + dropped. Returns 0.0 if both counters are zero.
1617 pub fn audio_drop_percentage(&self) -> f64 {
1618 drop_percentage(self.audio_frames_received, self.audio_frames_dropped)
1619 }
1620
1621 /// Calculate metadata frame drop percentage.
1622 ///
1623 /// Returns the percentage of metadata frames that were dropped out of the total
1624 /// received + dropped. Returns 0.0 if both counters are zero.
1625 pub fn metadata_drop_percentage(&self) -> f64 {
1626 drop_percentage(self.metadata_frames_received, self.metadata_frames_dropped)
1627 }
1628
1629 /// Check if the receiver is currently connected.
1630 ///
1631 /// Returns `true` if there is at least one active connection.
1632 pub fn is_connected(&self) -> bool {
1633 self.connections > 0
1634 }
1635}
1636
1637fn drop_percentage(received: u64, dropped: u64) -> f64 {
1638 let total = u128::from(received) + u128::from(dropped);
1639 if total == 0 {
1640 0.0
1641 } else {
1642 (dropped as f64 / total as f64) * 100.0
1643 }
1644}
1645
1646#[cfg(test)]
1647pub(crate) mod lifecycle_test_events {
1648 use std::cell::RefCell;
1649 std::thread_local! {
1650 static EVENTS: RefCell<Option<Vec<&'static str>>> = const { RefCell::new(None) };
1651 }
1652 pub(crate) fn record(event: &'static str) {
1653 EVENTS.with(|events| {
1654 if let Some(events) = &mut *events.borrow_mut() {
1655 events.push(event);
1656 }
1657 });
1658 }
1659 pub(crate) fn track(action: impl FnOnce()) -> Vec<&'static str> {
1660 EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
1661 action();
1662 EVENTS.with(|events| events.borrow_mut().take().unwrap())
1663 }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668 use super::*;
1669
1670 use std::cell::Cell;
1671
1672 fn test_source() -> Source {
1673 Source::default()
1674 }
1675
1676 #[derive(Default)]
1677 struct FakeClock {
1678 elapsed: Cell<Duration>,
1679 }
1680
1681 impl FakeClock {
1682 fn elapsed(&self) -> Duration {
1683 self.elapsed.get()
1684 }
1685
1686 fn advance(&self, duration: Duration) {
1687 self.elapsed.set(self.elapsed.get() + duration);
1688 }
1689 }
1690
1691 fn assert_raw_receiver_modes(
1692 raw: &RawRecvCreateV3,
1693 expected_color_format: NDIlib_recv_color_format_e,
1694 expected_bandwidth: NDIlib_recv_bandwidth_e,
1695 ) {
1696 assert_eq!(raw.raw.color_format, expected_color_format);
1697 assert_eq!(raw.raw.bandwidth, expected_bandwidth);
1698 assert_ne!(
1699 raw.raw.color_format,
1700 NDIlib_recv_color_format_e_NDIlib_recv_color_format_max
1701 );
1702 assert_ne!(
1703 raw.raw.bandwidth,
1704 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_max
1705 );
1706 }
1707
1708 #[test]
1709 fn media_status_is_retained_coalesced_and_consumed_once() {
1710 let ndi = NDI::new().unwrap();
1711 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1712 fn check<K: CaptureKind>(receiver: &Receiver) {
1713 let (_, allocations) = crate::test_alloc::allocation_count(|| {
1714 for _ in 0..3 {
1715 assert!(receiver
1716 .core
1717 .capture_result::<K>(CaptureResult::StatusChanged)
1718 .unwrap()
1719 .is_none());
1720 }
1721 assert!(receiver
1722 .core
1723 .capture_result::<K>(CaptureResult::None)
1724 .unwrap()
1725 .is_none());
1726 assert!(receiver
1727 .core
1728 .poll_status_with(Duration::ZERO, |_| panic!("pending skips SDK"))
1729 .unwrap()
1730 .is_some());
1731 assert!(receiver
1732 .core
1733 .poll_status_with(Duration::ZERO, |_| Some(
1734 NDIlib_frame_type_e_NDIlib_frame_type_none
1735 ))
1736 .unwrap()
1737 .is_none());
1738 });
1739 assert_eq!(allocations, 0);
1740 }
1741 check::<VideoKind>(&receiver);
1742 check::<AudioKind>(&receiver);
1743 check::<MetadataKind>(&receiver);
1744 }
1745
1746 #[test]
1747 fn status_wait_rechecks_publication_and_preserves_it_on_errors() {
1748 let ndi = NDI::new().unwrap();
1749 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1750 for outcome in [None, Some(NDIlib_frame_type_e_NDIlib_frame_type_none)] {
1751 assert!(receiver
1752 .core
1753 .poll_status_with(Duration::ZERO, |_| {
1754 receiver.core.pending_status.store(true, Ordering::Release);
1755 outcome
1756 })
1757 .unwrap()
1758 .is_some());
1759 assert!(!receiver.core.pending_status.load(Ordering::Acquire));
1760 }
1761 assert!(matches!(
1762 receiver.core.poll_status_with(Duration::ZERO, |_| {
1763 receiver.core.pending_status.store(true, Ordering::Release);
1764 Some(NDIlib_frame_type_e_NDIlib_frame_type_error)
1765 }),
1766 Err(Error::CaptureFailed(_))
1767 ));
1768 assert!(receiver.poll_status_change(Duration::MAX).is_err());
1769 assert!(receiver.core.pending_status.load(Ordering::Acquire));
1770 assert!(receiver
1771 .poll_status_change(Duration::ZERO)
1772 .unwrap()
1773 .is_some());
1774 assert!(receiver
1775 .core
1776 .poll_status_with(Duration::ZERO, |_| Some(
1777 NDIlib_frame_type_e_NDIlib_frame_type_status_change
1778 ))
1779 .unwrap()
1780 .is_some());
1781 }
1782
1783 #[test]
1784 fn concurrent_status_pollers_take_once_and_later_publications_survive() {
1785 let ndi = NDI::new().unwrap();
1786 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1787 receiver.core.pending_status.store(true, Ordering::Release);
1788 let barrier = std::sync::Barrier::new(3);
1789 std::thread::scope(|scope| {
1790 let handles: Vec<_> = (0..2)
1791 .map(|_| {
1792 let receiver = &receiver;
1793 let barrier = &barrier;
1794 scope.spawn(move || {
1795 barrier.wait();
1796 receiver.core.take_pending_status().is_some()
1797 })
1798 })
1799 .collect();
1800 barrier.wait();
1801 assert_eq!(
1802 handles
1803 .into_iter()
1804 .map(|h| usize::from(h.join().unwrap()))
1805 .sum::<usize>(),
1806 1
1807 );
1808 });
1809 receiver.core.pending_status.store(true, Ordering::Release);
1810 assert!(receiver.core.take_pending_status().is_some());
1811 }
1812
1813 #[test]
1814 fn status_publication_during_waits_is_reported_and_newer_publication_survives() {
1815 let ndi = NDI::new().unwrap();
1816 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1817 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
1818 let (resume_tx, resume_rx) = std::sync::mpsc::channel();
1819 std::thread::scope(|scope| {
1820 let receiver = &receiver;
1821 let worker = scope.spawn(move || {
1822 receiver.core.poll_status_with(Duration::from_secs(1), |_| {
1823 entered_tx.send(()).unwrap();
1824 resume_rx.recv().unwrap();
1825 Some(NDIlib_frame_type_e_NDIlib_frame_type_none)
1826 })
1827 });
1828 entered_rx.recv_timeout(Duration::from_secs(2)).unwrap();
1829 receiver
1830 .core
1831 .capture_result::<MetadataKind>(CaptureResult::StatusChanged)
1832 .unwrap();
1833 resume_tx.send(()).unwrap();
1834 assert!(worker.join().unwrap().unwrap().is_some());
1835 });
1836 assert!(receiver.core.take_pending_status().is_none());
1837 receiver
1838 .core
1839 .capture_result::<AudioKind>(CaptureResult::StatusChanged)
1840 .unwrap();
1841 assert!(receiver.core.take_pending_status().is_some());
1842
1843 // A pending notification avoids even a contended reconnect lock.
1844 let _exclusive = receiver.core.capture_guard.write();
1845 receiver
1846 .core
1847 .capture_result::<VideoKind>(CaptureResult::StatusChanged)
1848 .unwrap();
1849 assert!(receiver
1850 .poll_status_change(Duration::ZERO)
1851 .unwrap()
1852 .is_some());
1853 // Publication during a failed lock wait is also rechecked, without a
1854 // second SDK call or a fresh deadline.
1855 assert!(receiver
1856 .core
1857 .poll_status_with(Duration::ZERO, |deadline| {
1858 receiver
1859 .core
1860 .capture_result::<VideoKind>(CaptureResult::StatusChanged)
1861 .unwrap();
1862 assert!(receiver.core.capture_lock_until(deadline).is_none());
1863 None
1864 })
1865 .unwrap()
1866 .is_some());
1867 }
1868
1869 #[test]
1870 fn receiver_mode_transitions_preserve_pending_status_and_core_size() {
1871 let ndi = NDI::new().unwrap();
1872 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1873 receiver.core.pending_status.store(true, Ordering::Release);
1874 let fs = crate::FrameSync::new(receiver).unwrap();
1875 assert!(fs
1876 .receiver()
1877 .poll_status_change(Duration::ZERO)
1878 .unwrap()
1879 .is_some());
1880 fs.receiver()
1881 .core
1882 .pending_status
1883 .store(true, Ordering::Release);
1884 let receiver = fs.into_receiver();
1885 assert!(receiver
1886 .poll_status_change(Duration::ZERO)
1887 .unwrap()
1888 .is_some());
1889 assert!(!receiver.core.pending_status.load(Ordering::Acquire));
1890 assert_eq!(
1891 std::mem::size_of::<Receiver>(),
1892 std::mem::size_of::<ReceiverCore>()
1893 );
1894 assert_eq!(
1895 std::mem::size_of::<Receiver<FrameSyncAttached>>(),
1896 std::mem::size_of::<ReceiverCore>()
1897 );
1898 }
1899
1900 #[test]
1901 fn drop_rates_handle_extreme_public_counters() {
1902 assert_eq!(drop_percentage(0, 0), 0.0);
1903 assert_eq!(drop_percentage(900, 100), 10.0);
1904 assert_eq!(drop_percentage(u64::MAX, u64::MAX), 50.0);
1905 assert_eq!(drop_percentage(0, u64::MAX), 100.0);
1906 }
1907
1908 #[test]
1909 fn ptz_rejects_invalid_commands_before_calling_sdk() {
1910 let ndi = NDI::new().unwrap();
1911 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1912 for result in [
1913 receiver.ptz_recall_preset(u32::MAX, 0.5),
1914 receiver.ptz_recall_preset(100, 0.5),
1915 receiver.ptz_recall_preset(0, f32::NAN),
1916 receiver.ptz_store_preset(-1),
1917 receiver.ptz_zoom(f32::INFINITY),
1918 receiver.ptz_zoom_speed(-1.1),
1919 receiver.ptz_pan_tilt(0.0, 1.1),
1920 receiver.ptz_pan_tilt_speed(f32::NAN, 0.0),
1921 receiver.ptz_focus(-0.1),
1922 receiver.ptz_focus_speed(1.1),
1923 receiver.ptz_white_balance_manual(0.0, 1.1),
1924 receiver.ptz_exposure_manual(f32::NEG_INFINITY),
1925 receiver.ptz_exposure_manual_v2(0.5, f32::NAN, 0.5),
1926 ] {
1927 assert!(matches!(result, Err(Error::InvalidConfiguration(_))));
1928 }
1929 assert_eq!(validate_ptz_preset(0).unwrap(), 0);
1930 assert_eq!(validate_ptz_preset(99).unwrap(), 99);
1931 assert!(validate_ptz_value("speed", -1.0, -1.0, 1.0).is_ok());
1932 assert!(validate_ptz_value("speed", 1.0, -1.0, 1.0).is_ok());
1933 }
1934
1935 #[test]
1936 fn outbound_controls_accept_invocation_without_a_source() {
1937 let ndi = NDI::new().unwrap();
1938 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1939 // Runtime implementations differ in their status flags when no source
1940 // is connected. These controls must remain callable in that state.
1941 let _sdk_status = receiver.set_tally(&Tally::new(true, false));
1942 let frame = MetadataFrame::with_data("<ndi_text>é</ndi_text>", 42).unwrap();
1943 let pointer = frame.to_raw().p_data;
1944 for _ in 0..3 {
1945 let _sdk_status = receiver.send_metadata(&frame);
1946 assert_eq!(frame.to_raw().p_data, pointer);
1947 }
1948 }
1949
1950 #[test]
1951 fn capture_contention_obeys_zero_and_bounded_budgets() {
1952 let ndi = NDI::new().unwrap();
1953 let receiver = Receiver::new(&ndi, &ReceiverOptions::default()).unwrap();
1954 let exclusive = receiver.core.capture_guard.write();
1955 std::thread::scope(|scope| {
1956 let (done, completed) = std::sync::mpsc::channel();
1957 let receiver = &receiver;
1958 let worker = scope.spawn(move || {
1959 assert!(receiver
1960 .video()
1961 .try_capture(Duration::ZERO)
1962 .unwrap()
1963 .is_none());
1964 assert!(receiver
1965 .poll_status_change(Duration::ZERO)
1966 .unwrap()
1967 .is_none());
1968 assert!(receiver
1969 .audio()
1970 .try_capture(Duration::from_millis(5))
1971 .unwrap()
1972 .is_none());
1973 assert!(matches!(
1974 receiver.video().capture(Duration::from_millis(5)),
1975 Err(Error::FrameTimeout { .. })
1976 ));
1977 done.send(()).unwrap();
1978 });
1979 // The writer remains held until the worker has returned: a stale
1980 // unbounded read would deadlock instead of finishing within budget.
1981 let result = completed.recv_timeout(Duration::from_secs(2));
1982 drop(exclusive);
1983 worker.join().unwrap();
1984 result.unwrap();
1985 });
1986 }
1987
1988 #[test]
1989 fn receiver_color_format_maps_to_exact_sdk_values() {
1990 assert_eq!(
1991 NDIlib_recv_color_format_e::from(ReceiverColorFormat::BGRX_BGRA),
1992 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA
1993 );
1994 assert_eq!(
1995 NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_BGRA),
1996 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_BGRA
1997 );
1998 assert_eq!(
1999 NDIlib_recv_color_format_e::from(ReceiverColorFormat::RGBX_RGBA),
2000 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA
2001 );
2002 assert_eq!(
2003 NDIlib_recv_color_format_e::from(ReceiverColorFormat::UYVY_RGBA),
2004 NDIlib_recv_color_format_e_NDIlib_recv_color_format_UYVY_RGBA
2005 );
2006 assert_eq!(
2007 NDIlib_recv_color_format_e::from(ReceiverColorFormat::Fastest),
2008 NDIlib_recv_color_format_e_NDIlib_recv_color_format_fastest
2009 );
2010 assert_eq!(
2011 NDIlib_recv_color_format_e::from(ReceiverColorFormat::Best),
2012 NDIlib_recv_color_format_e_NDIlib_recv_color_format_best
2013 );
2014 }
2015
2016 #[test]
2017 fn receiver_bandwidth_maps_to_exact_sdk_values() {
2018 assert_eq!(
2019 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::MetadataOnly),
2020 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only
2021 );
2022 assert_eq!(
2023 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::AudioOnly),
2024 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_audio_only
2025 );
2026 assert_eq!(
2027 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Lowest),
2028 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest
2029 );
2030 assert_eq!(
2031 NDIlib_recv_bandwidth_e::from(ReceiverBandwidth::Highest),
2032 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest
2033 );
2034 }
2035
2036 #[test]
2037 fn receiver_options_defaults_emit_operational_raw_modes() {
2038 let raw = ReceiverOptions::builder(test_source())
2039 .build()
2040 .to_raw()
2041 .unwrap();
2042
2043 assert_raw_receiver_modes(
2044 &raw,
2045 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
2046 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
2047 );
2048 assert!(raw.raw.allow_video_fields);
2049 }
2050
2051 #[test]
2052 fn snapshot_preset_emits_operational_raw_modes() {
2053 let raw = ReceiverOptionsBuilder::snapshot_preset(test_source())
2054 .build()
2055 .to_raw()
2056 .unwrap();
2057
2058 assert_raw_receiver_modes(
2059 &raw,
2060 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
2061 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_lowest,
2062 );
2063 assert!(!raw.raw.allow_video_fields);
2064 }
2065
2066 #[test]
2067 fn high_quality_preset_emits_operational_raw_modes() {
2068 let raw = ReceiverOptionsBuilder::high_quality_preset(test_source())
2069 .build()
2070 .to_raw()
2071 .unwrap();
2072
2073 assert_raw_receiver_modes(
2074 &raw,
2075 NDIlib_recv_color_format_e_NDIlib_recv_color_format_RGBX_RGBA,
2076 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_highest,
2077 );
2078 assert!(raw.raw.allow_video_fields);
2079 }
2080
2081 #[test]
2082 fn monitoring_preset_emits_operational_raw_modes() {
2083 let raw = ReceiverOptionsBuilder::monitoring_preset(test_source())
2084 .build()
2085 .to_raw()
2086 .unwrap();
2087
2088 assert_raw_receiver_modes(
2089 &raw,
2090 NDIlib_recv_color_format_e_NDIlib_recv_color_format_BGRX_BGRA,
2091 NDIlib_recv_bandwidth_e_NDIlib_recv_bandwidth_metadata_only,
2092 );
2093 assert!(!raw.raw.allow_video_fields);
2094 }
2095
2096 #[test]
2097 fn retry_caps_sub_poll_timeout_to_total_budget() {
2098 let clock = FakeClock::default();
2099 let policy = RetryPolicy {
2100 poll_interval: Duration::from_millis(100),
2101 sleep_between: Duration::from_millis(10),
2102 };
2103 let mut polls = Vec::new();
2104
2105 let result = retry_capture_with_clock(
2106 Duration::from_millis(25),
2107 &policy,
2108 |poll| {
2109 polls.push(poll);
2110 Ok(Some(42))
2111 },
2112 || clock.elapsed(),
2113 |_| {},
2114 );
2115
2116 assert_eq!(result.unwrap(), 42);
2117 assert_eq!(polls, [Duration::from_millis(25)]);
2118 }
2119
2120 #[test]
2121 fn retry_shrinks_later_poll_to_remaining_budget() {
2122 let clock = FakeClock::default();
2123 let policy = RetryPolicy {
2124 poll_interval: Duration::from_millis(40),
2125 sleep_between: Duration::from_millis(10),
2126 };
2127 let mut polls = Vec::new();
2128 let mut sleeps = Vec::new();
2129
2130 let result: Result<i32> = retry_capture_with_clock(
2131 Duration::from_millis(50),
2132 &policy,
2133 |poll| {
2134 polls.push(poll);
2135 if polls.len() == 1 {
2136 clock.advance(Duration::from_millis(5));
2137 } else {
2138 clock.advance(poll);
2139 }
2140 Ok(None)
2141 },
2142 || clock.elapsed(),
2143 |sleep| {
2144 sleeps.push(sleep);
2145 clock.advance(sleep);
2146 },
2147 );
2148
2149 assert!(matches!(
2150 result,
2151 Err(Error::FrameTimeout { attempts: 2, .. })
2152 ));
2153 assert_eq!(
2154 polls,
2155 [Duration::from_millis(40), Duration::from_millis(35)]
2156 );
2157 assert_eq!(sleeps, [Duration::from_millis(10)]);
2158 }
2159
2160 #[test]
2161 fn retry_zero_timeout_success_makes_one_nonblocking_attempt() {
2162 let clock = FakeClock::default();
2163 let mut polls = Vec::new();
2164
2165 let result = retry_capture_with_clock(
2166 Duration::ZERO,
2167 &RetryPolicy::default(),
2168 |poll| {
2169 polls.push(poll);
2170 Ok(Some(42))
2171 },
2172 || clock.elapsed(),
2173 |_| panic!("zero-timeout success should not sleep"),
2174 );
2175
2176 assert_eq!(result.unwrap(), 42);
2177 assert_eq!(polls, [Duration::ZERO]);
2178 }
2179
2180 #[test]
2181 fn retry_zero_timeout_miss_times_out_after_one_attempt() {
2182 let clock = FakeClock::default();
2183 let mut polls = Vec::new();
2184
2185 let result: Result<i32> = retry_capture_with_clock(
2186 Duration::ZERO,
2187 &RetryPolicy::default(),
2188 |poll| {
2189 polls.push(poll);
2190 Ok(None)
2191 },
2192 || clock.elapsed(),
2193 |_| panic!("zero-timeout miss should not sleep"),
2194 );
2195
2196 match result {
2197 Err(Error::FrameTimeout { attempts, elapsed }) => {
2198 assert_eq!(attempts, 1);
2199 assert_eq!(elapsed, Duration::ZERO);
2200 }
2201 _ => panic!("Expected FrameTimeout error"),
2202 }
2203 assert_eq!(polls, [Duration::ZERO]);
2204 }
2205
2206 #[test]
2207 fn retry_timeout_counts_only_actual_capture_attempts() {
2208 let clock = FakeClock::default();
2209 let policy = RetryPolicy {
2210 poll_interval: Duration::from_millis(8),
2211 sleep_between: Duration::from_millis(5),
2212 };
2213 let mut polls = Vec::new();
2214
2215 let result: Result<i32> = retry_capture_with_clock(
2216 Duration::from_millis(10),
2217 &policy,
2218 |poll| {
2219 polls.push(poll);
2220 clock.advance(Duration::from_millis(10));
2221 Ok(None)
2222 },
2223 || clock.elapsed(),
2224 |_| panic!("expired timeout should not sleep"),
2225 );
2226
2227 match result {
2228 Err(Error::FrameTimeout { attempts, elapsed }) => {
2229 assert_eq!(attempts, 1);
2230 assert_eq!(elapsed, Duration::from_millis(10));
2231 }
2232 _ => panic!("Expected FrameTimeout error"),
2233 }
2234 assert_eq!(polls, [Duration::from_millis(8)]);
2235 }
2236
2237 #[test]
2238 fn retry_caps_sleep_to_remaining_budget() {
2239 let clock = FakeClock::default();
2240 let policy = RetryPolicy {
2241 poll_interval: Duration::from_millis(8),
2242 sleep_between: Duration::from_millis(5),
2243 };
2244 let mut sleeps = Vec::new();
2245
2246 let result: Result<i32> = retry_capture_with_clock(
2247 Duration::from_millis(10),
2248 &policy,
2249 |poll| {
2250 assert_eq!(poll, Duration::from_millis(8));
2251 clock.advance(Duration::from_millis(8));
2252 Ok(None)
2253 },
2254 || clock.elapsed(),
2255 |sleep| {
2256 sleeps.push(sleep);
2257 clock.advance(sleep);
2258 },
2259 );
2260
2261 assert!(matches!(
2262 result,
2263 Err(Error::FrameTimeout { attempts: 1, .. })
2264 ));
2265 assert_eq!(sleeps, [Duration::from_millis(2)]);
2266 }
2267
2268 #[test]
2269 fn retry_validates_total_timeout_before_attempts() {
2270 let attempts = Cell::new(0);
2271
2272 let result: Result<i32> = retry_capture(
2273 crate::MAX_TIMEOUT + Duration::from_nanos(1),
2274 &RetryPolicy::default(),
2275 |_| {
2276 attempts.set(attempts.get() + 1);
2277 Ok(Some(42))
2278 },
2279 );
2280
2281 assert!(matches!(result, Err(Error::InvalidConfiguration(_))));
2282 assert_eq!(attempts.get(), 0);
2283 }
2284
2285 #[test]
2286 fn retry_succeeds_after_retry_before_timeout() {
2287 let clock = FakeClock::default();
2288 let attempts = Cell::new(0);
2289
2290 let result = retry_capture_with_clock(
2291 Duration::from_millis(50),
2292 &RetryPolicy::default(),
2293 |_| {
2294 attempts.set(attempts.get() + 1);
2295 if attempts.get() == 1 {
2296 Ok(None)
2297 } else {
2298 Ok(Some(42))
2299 }
2300 },
2301 || clock.elapsed(),
2302 |sleep| clock.advance(sleep),
2303 );
2304
2305 assert_eq!(result.unwrap(), 42);
2306 assert_eq!(attempts.get(), 2);
2307 }
2308
2309 #[test]
2310 fn retry_propagates_error() {
2311 let clock = FakeClock::default();
2312 let attempts = Cell::new(0);
2313 let mut sleeps = Vec::new();
2314
2315 let result: Result<i32> = retry_capture_with_clock(
2316 Duration::from_secs(1),
2317 &RetryPolicy::default(),
2318 |_| {
2319 attempts.set(attempts.get() + 1);
2320 Err(Error::CaptureFailed("test error".into()))
2321 },
2322 || clock.elapsed(),
2323 |sleep| sleeps.push(sleep),
2324 );
2325
2326 assert!(
2327 matches!(result, Err(Error::CaptureFailed(_))),
2328 "Should propagate CaptureFailed error"
2329 );
2330 assert_eq!(attempts.get(), 1);
2331 assert!(sleeps.is_empty());
2332 }
2333}