Skip to main content

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}