Skip to main content

grafton_ndi/
framesync.rs

1//! Frame synchronization for clock-corrected video/audio capture.
2//!
3//! The [`FrameSync`] type provides a "pull" interface for receiving NDI streams with
4//! automatic time-base correction. This transforms the NDI SDK's push-based capture
5//! model into a pull model suitable for:
6//!
7//! - Video playback synced to GPU v-sync
8//! - Audio playback synced to sound card clock
9//! - Multi-source mixing with a common output clock
10//!
11//! # When to Use FrameSync vs Raw Capture
12//!
13//! | Use Case | Raw Receiver | FrameSync |
14//! |----------|-------------|-----------|
15//! | Recording (preserving timing) | ✓ | |
16//! | Playback to GPU | | ✓ |
17//! | Playback to sound card | | ✓ |
18//! | Multi-source mixing | | ✓ |
19//! | Analysis/processing pipeline | ✓ | |
20//! | Low-latency monitoring | | ✓ |
21//!
22//! # Why FrameSync Exists
23//!
24//! Computer clocks drift. The NDI SDK documentation explains:
25//!
26//! > Computer clocks rely on crystals which while all rated for the same frequency
27//! > are still not exact. If your sending computer has an audio clock that it "thinks"
28//! > is 48000Hz, to the receiver computer that has a different audio clock this might
29//! > be 48001Hz or 47998Hz.
30//!
31//! Without time-base correction, this causes:
32//! - **Video jitter**: When sender/receiver clocks are nearly aligned, naive frame
33//!   buffering causes visible jitter as frames occasionally repeat or skip.
34//! - **Audio drift**: Accumulated clock difference causes audio to fall out of sync
35//!   or cause glitches as the receiver runs out of or accumulates too many samples.
36//!
37//! FrameSync solves these by:
38//! - Using hysteresis-based video timing to determine optimal frame repeat/skip points
39//! - Dynamically resampling audio with high-order filters to track clock differences
40//!
41//! # Example
42//!
43//! ```no_run
44//! use grafton_ndi::{
45//!     NDI, Finder, FinderOptions, ReceiverOptions, Receiver, FrameSync,
46//!     FrameSyncAudioRequest, ScanType,
47//! };
48//! use std::time::Duration;
49//!
50//! fn main() -> Result<(), grafton_ndi::Error> {
51//!     let ndi = NDI::new()?;
52//!     let finder = Finder::new(&ndi, &FinderOptions::default())?;
53//!     finder.wait_for_sources(Duration::from_secs(1))?;
54//!     let sources = finder.current_sources()?;
55//!
56//!     let options = ReceiverOptions::builder(sources[0].clone()).build();
57//!     let receiver = Receiver::new(&ndi, &options)?;
58//!
59//!     // Create frame-sync from receiver
60//!     let framesync = FrameSync::new(receiver)?;
61//!
62//!     // Capture video synced to your output timing
63//!     if let Some(frame) = framesync.capture_video(ScanType::Progressive)? {
64//!         println!("{}x{} frame", frame.width(), frame.height());
65//!     }
66//!
67//!     // Capture audio at your sound card's rate
68//!     let request = FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1_024)?;
69//!     let audio = framesync.capture_audio(request)?;
70//!     println!("{} audio samples", audio.num_samples());
71//!
72//!     Ok(())
73//! }
74//! ```
75
76use std::fmt;
77
78use crate::{
79    capture::{FrameSyncAudioFree, FrameSyncVideoFree, Guard},
80    frames::{
81        validate_packed_audio_dimensions, AudioFrame, AudioRef, ScanType, VideoFrame, VideoRef,
82    },
83    ndi_lib::*,
84    receiver::{FrameSyncAttached, Receiver},
85    Error, Result,
86};
87
88/// A zero-copy borrowed video frame from a [`FrameSync`] capture.
89///
90/// This is the FrameSync spelling of the generic
91/// [`VideoRef`]; the [`Receiver`]
92/// spelling is [`VideoFrameRef`](crate::VideoFrameRef). Both share one accessor
93/// and `Debug` implementation. The frame is automatically freed when dropped via
94/// `NDIlib_framesync_free_video`.
95/// This owning reference is neither `Send` nor `Sync`; its immutable plane/row
96/// views support scoped worker reads while it stays borrowed on this thread.
97pub type FrameSyncVideoRef<'fs> = VideoRef<'fs, FrameSyncVideoFree>;
98
99/// A zero-copy borrowed audio frame from a [`FrameSync`] capture.
100///
101/// This is the FrameSync spelling of the generic
102/// [`AudioRef`]; the [`Receiver`]
103/// spelling is [`AudioFrameRef`](crate::AudioFrameRef). The FrameSync path can
104/// produce a validated empty query/no-source state, so
105/// [`is_empty`](AudioRef::is_empty) and an `Option`-returning
106/// [`format`](AudioRef::format)/[`to_owned`](AudioRef::to_owned) are available.
107/// The frame is automatically freed when dropped via
108/// `NDIlib_framesync_free_audio_v2`.
109pub type FrameSyncAudioRef<'fs> = AudioRef<'fs, FrameSyncAudioFree>;
110
111/// A validated, reusable audio operation for [`FrameSync::capture_audio`].
112///
113/// [`capture`](Self::capture) and [`capture_with`](Self::capture_with) require
114/// explicit positive output channels and samples per channel. Construction checks
115/// the SDK's signed byte stride and the 64 MiB packed sample payload limit before
116/// any native work. This bounds requested samples, not SDK internal memory use.
117/// Successful construction and request reuse allocate nothing in the wrapper;
118/// submitting a request performs no dimension checks, locks, or extra SDK queries.
119/// Returned descriptors and metadata are still validated after each capture.
120///
121/// [`query_input`](Self::query_input) queries the current input format without
122/// requesting samples. To follow source channels, query first, wait for a positive
123/// channel count, then build a capture request with that concrete count. Re-query
124/// and rebuild when the application chooses to follow format changes. The SDK
125/// converts to the explicit output count even if the source changes meanwhile.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct FrameSyncAudioRequest {
128    sample_rate: i32,
129    channels: i32,
130    samples: i32,
131}
132
133impl FrameSyncAudioRequest {
134    /// Query the current incoming audio format without requesting sample data.
135    /// The result has zero sample rate and channels if no input format is known.
136    pub const fn query_input() -> Self {
137        Self {
138            sample_rate: 0,
139            channels: 0,
140            samples: 0,
141        }
142    }
143
144    /// Capture samples using the source sample rate and explicit output channels.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`Error::InvalidFrame`] for nonpositive dimensions, an
149    /// unrepresentable signed byte stride, arithmetic overflow, or a sample
150    /// payload exceeding 64 MiB. Validation does not allocate sample storage.
151    pub fn capture(channels: i32, samples: i32) -> Result<Self> {
152        Self::capture_with(None, channels, samples)
153    }
154
155    /// Capture samples with an optional source-derived sample rate.
156    ///
157    /// `None` uses the current source rate. `Some(rate)` must be positive.
158    /// Channels and samples per channel must always be explicitly positive.
159    /// A request can be copied and reused across borrowed and owned captures.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`Error::InvalidFrame`] for a nonpositive explicit sample rate or
164    /// invalid dimensions as described in [`capture`](Self::capture).
165    pub fn capture_with(sample_rate: Option<i32>, channels: i32, samples: i32) -> Result<Self> {
166        if let Some(rate) = sample_rate {
167            if rate <= 0 {
168                return Err(Error::InvalidFrame(format!("Invalid sample rate: {rate}")));
169            }
170        }
171        validate_packed_audio_dimensions(channels, samples)?;
172        Ok(Self {
173            sample_rate: sample_rate.unwrap_or(0),
174            channels,
175            samples,
176        })
177    }
178}
179
180/// Frame synchronizer for clock-corrected capture.
181///
182/// Converts push-based NDI streams into pull-based capture with automatic
183/// time-base correction and dynamic audio resampling.
184///
185/// # Ownership
186///
187/// `FrameSync` takes ownership of the [`Receiver`] (similar to how `BufWriter`
188/// wraps a `Write`). Use [`receiver()`](Self::receiver) to access the underlying
189/// receiver for tally, PTZ, or status operations. Use
190/// [`into_receiver()`](Self::into_receiver) to recover the receiver when done.
191///
192/// # Thread Safety
193///
194/// `FrameSync` is `Send + Sync` like `Receiver`, as the underlying NDI SDK
195/// frame-sync functions are thread-safe. Frames returned by capture methods
196/// borrow from the FrameSync and are neither `Send` nor `Sync`. Convert them
197/// to owned frames to transfer ownership to another thread. Immutable video
198/// plane/row views can instead be moved or shared with scoped workers while
199/// the frame guard stays on its capture thread until those workers finish.
200///
201/// Raw video capture through the underlying receiver is disabled while this
202/// FrameSync exists. [`Self::into_receiver`] destroys the synchronizer and
203/// restores raw video capture before returning the receiver.
204///
205/// # Example
206///
207/// ```no_run
208/// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress, ScanType};
209/// # fn main() -> Result<(), grafton_ndi::Error> {
210/// # let ndi = NDI::new()?;
211/// # let source = Source { name: "Test".into(), address: SourceAddress::None };
212/// # let options = ReceiverOptions::builder(source).build();
213/// # let receiver = Receiver::new(&ndi, &options)?;
214/// let framesync = FrameSync::new(receiver)?;
215///
216/// // Sample available data without waiting for a new network frame
217/// if let Some(video) = framesync.capture_video(ScanType::Progressive)? {
218///     println!("Video: {}x{}", video.width(), video.height());
219/// }
220///
221/// // Audio capture fixes the output dimensions before entering the SDK.
222/// let audio = framesync.capture_audio(FrameSyncAudioRequest::capture_with(
223///     Some(48_000), 2, 1_024,
224/// )?)?;
225/// println!("Audio: {} samples", audio.num_samples());
226/// # Ok(())
227/// # }
228/// ```
229pub struct FrameSync {
230    // Rust drops fields in declaration order. The SDK requires FrameSync to be
231    // destroyed before its receiver (Processing.NDI.FrameSync.h, create contract).
232    resource: FrameSyncResource,
233    receiver: Receiver<FrameSyncAttached>,
234}
235
236struct FrameSyncResource {
237    instance: NDIlib_framesync_instance_t,
238    // Retain the runtime directly until after native destruction.
239    _ndi: crate::NDI,
240}
241
242impl Drop for FrameSyncResource {
243    fn drop(&mut self) {
244        // SAFETY: Creation checked for null; exclusive ownership and borrowed
245        // guards prevent destruction while capture buffers remain live.
246        unsafe { NDIlib_framesync_destroy(self.instance) };
247        #[cfg(test)]
248        crate::receiver::lifecycle_test_events::record("framesync");
249    }
250}
251
252impl FrameSync {
253    /// Create a frame synchronizer from a receiver.
254    ///
255    /// Takes ownership of the receiver. Use [`receiver()`](Self::receiver)
256    /// to access the underlying receiver for tally, PTZ, or status operations.
257    /// Use [`into_receiver()`](Self::into_receiver) to recover the receiver.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the NDI SDK fails to create the frame-sync instance.
262    ///
263    /// # Example
264    ///
265    /// ```no_run
266    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
267    /// # fn main() -> Result<(), grafton_ndi::Error> {
268    /// # let ndi = NDI::new()?;
269    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
270    /// # let options = ReceiverOptions::builder(source).build();
271    /// let receiver = Receiver::new(&ndi, &options)?;
272    /// let framesync = FrameSync::new(receiver)?;
273    /// # Ok(())
274    /// # }
275    /// ```
276    pub fn new(receiver: Receiver) -> Result<Self> {
277        // SAFETY: The owned receiver retains a live SDK resource through creation;
278        // successful creation transfers both resources into the ordered owners.
279        Self::new_with(receiver, |instance| unsafe {
280            NDIlib_framesync_create(instance)
281        })
282    }
283
284    fn new_with(
285        receiver: Receiver,
286        create: impl FnOnce(NDIlib_recv_instance_t) -> NDIlib_framesync_instance_t,
287    ) -> Result<Self> {
288        let instance = create(receiver.core.instance);
289        if instance.is_null() {
290            // The untouched raw receiver drops normally on creation failure.
291            return Err(Error::InitializationFailed(
292                "Failed to create NDI framesync instance".into(),
293            ));
294        }
295        let resource = FrameSyncResource {
296            instance,
297            _ndi: receiver.core._ndi.clone(),
298        };
299        Ok(Self {
300            resource,
301            receiver: receiver.into_mode(),
302        })
303    }
304
305    /// Access the underlying receiver.
306    ///
307    /// Use this to access receiver functionality like tally, PTZ, or status
308    /// queries while the FrameSync is active. Tally control publishes state
309    /// upstream; it does not query incoming tally. This receiver mode has no
310    /// raw-video method; use FrameSync capture methods or
311    /// recover the receiver with [`Self::into_receiver`] first.
312    pub fn receiver(&self) -> &Receiver<FrameSyncAttached> {
313        &self.receiver
314    }
315
316    /// Consume the FrameSync and recover the underlying Receiver.
317    ///
318    /// This destroys the frame synchronizer and returns the receiver for
319    /// continued use with raw capture or for creating a new FrameSync.
320    pub fn into_receiver(self) -> Receiver {
321        let Self { resource, receiver } = self;
322        drop(resource);
323        receiver.into_mode()
324    }
325
326    /// Capture video with time-base correction.
327    ///
328    /// This does not wait for a new network frame. It returns the best frame for
329    /// the current output timing, handling clock drift and jitter automatically.
330    /// The same frame may be returned multiple times when capture rate exceeds
331    /// source frame rate. Wrapper synchronization and SDK processing still take time.
332    ///
333    /// # Arguments
334    ///
335    /// * `field_type` - The desired field format. Use `ScanType::Progressive` for
336    ///   most applications. For interlaced output, use the appropriate field type
337    ///   to maintain correct field ordering.
338    ///
339    /// # Returns
340    ///
341    /// * `Ok(Some(FrameSyncVideoRef))` - A validated zero-copy reference to the captured frame
342    /// * `Ok(None)` - The SDK returned the documented all-zero "no video yet" state
343    /// * `Err(_)` - The SDK returned an unsupported format or malformed frame
344    ///   descriptor, including invalid dimensions, stride, buffers, or metadata
345    ///
346    /// # Example
347    ///
348    /// ```no_run
349    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress, ScanType};
350    /// # fn main() -> Result<(), grafton_ndi::Error> {
351    /// # let ndi = NDI::new()?;
352    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
353    /// # let options = ReceiverOptions::builder(source).build();
354    /// # let receiver = Receiver::new(&ndi, &options)?;
355    /// let framesync = FrameSync::new(receiver)?;
356    ///
357    /// // Capture loop - call at your output frame rate
358    /// loop {
359    ///     if let Some(frame) = framesync.capture_video(ScanType::Progressive)? {
360    ///         // Process/display frame
361    ///         println!("{}x{} @ {}/{} fps",
362    ///             frame.width(), frame.height(),
363    ///             frame.frame_rate_n(), frame.frame_rate_d());
364    ///     } else {
365    ///         // No video received yet - display placeholder
366    ///         println!("Waiting for video...");
367    ///     }
368    ///     # break;
369    /// }
370    /// # Ok(())
371    /// # }
372    /// ```
373    pub fn capture_video(&self, field_type: ScanType) -> Result<Option<FrameSyncVideoRef<'_>>> {
374        let mut frame = NDIlib_video_frame_v2_t::default();
375
376        unsafe {
377            NDIlib_framesync_capture_video(self.resource.instance, &mut frame, field_type.into());
378        }
379
380        // Per SDK docs: Returns zeroed struct if no video received yet
381        if is_framesync_video_empty(&frame) {
382            return Ok(None);
383        }
384
385        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(self.resource.instance, frame) };
386        let frame = unsafe { FrameSyncVideoRef::new(guard)? };
387
388        Ok(Some(frame))
389    }
390
391    /// Capture video and convert to an owned frame.
392    ///
393    /// This is a convenience method that captures video and immediately converts
394    /// it to an owned [`VideoFrame`] that can be sent across threads.
395    ///
396    /// # Arguments
397    ///
398    /// * `field_type` - The desired field format.
399    ///
400    /// # Returns
401    ///
402    /// * `Ok(Some(VideoFrame))` - An owned copy of the captured frame
403    /// * `Ok(None)` - No video has been received yet
404    ///
405    /// # Errors
406    ///
407    /// Propagates format and frame-validation errors from [`Self::capture_video`]
408    /// or conversion to an owned frame.
409    pub fn capture_video_owned(&self, field_type: ScanType) -> Result<Option<VideoFrame>> {
410        self.capture_video(field_type)?
411            .map(|frame| frame.to_owned())
412            .transpose()
413    }
414
415    /// Capture audio with dynamic resampling.
416    ///
417    /// This does not wait for new network audio. Capture requests ask the NDI
418    /// SDK to resample incoming audio to match the requested sample rate,
419    /// channel count, and sample count. Query requests return the current input
420    /// format without requesting samples. Wrapper synchronization and resampling
421    /// still take time.
422    ///
423    /// Call this at the rate you need audio - the SDK will adapt the incoming
424    /// signal to match your output timing using dynamic audio sampling.
425    ///
426    /// # Arguments
427    ///
428    /// * `request` - Explicit capture or query operation. For capture requests,
429    ///   `None` sample rate means "use the current source rate"; channels are explicit.
430    ///
431    /// # Querying Input Format
432    ///
433    /// Use [`FrameSyncAudioRequest::query_input`] to query the current input
434    /// format without capturing samples. The returned frame contains the
435    /// input's sample rate and channel count, or a validated empty no-source
436    /// state when no audio format has been received yet.
437    ///
438    /// # Errors
439    ///
440    /// Returns frame-validation errors if the SDK returns malformed audio
441    /// dimensions, sample layout, buffers, or metadata. Request dimensions were
442    /// already validated at construction.
443    ///
444    /// # Example
445    ///
446    /// ```no_run
447    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress};
448    /// # fn main() -> Result<(), grafton_ndi::Error> {
449    /// # let ndi = NDI::new()?;
450    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
451    /// # let options = ReceiverOptions::builder(source).build();
452    /// # let receiver = Receiver::new(&ndi, &options)?;
453    /// let framesync = FrameSync::new(receiver)?;
454    ///
455    /// // Audio callback - called by sound card at its rate
456    /// let request = FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1_024)?;
457    ///
458    /// loop {
459    ///     // Request 1024 stereo samples at 48kHz
460    ///     let audio = framesync.capture_audio(request)?;
461    ///
462    ///     // Process each channel without reading inter-channel padding.
463    ///     for samples in audio.channels() {
464    ///         println!("Got {} samples in a channel", samples.len());
465    ///     }
466    ///
467    ///     // Check for a validated query/no-source empty state
468    ///     if audio.is_empty() {
469    ///         println!("No audio source yet");
470    ///     }
471    ///     # break;
472    /// }
473    /// # Ok(())
474    /// # }
475    /// ```
476    pub fn capture_audio(&self, request: FrameSyncAudioRequest) -> Result<FrameSyncAudioRef<'_>> {
477        let mut frame = NDIlib_audio_frame_v3_t::default();
478
479        #[cfg(test)]
480        audio_test_calls::capture();
481        // SAFETY: The live FrameSync owns its receiver/runtime. Request fields
482        // are private: capture construction checked the packed FLTP payload and
483        // signed byte stride; only query_input() can submit zero samples/channels.
484        // Processing.NDI.FrameSync.h permits explicit output channels independent
485        // of input and all-zero format queries. No source-channel sentinel can
486        // race a format change here. The SDK owns the returned allocation until
487        // Guard frees it through this same instance, including validation errors.
488        unsafe {
489            NDIlib_framesync_capture_audio_v2(
490                self.resource.instance,
491                &mut frame,
492                request.sample_rate,
493                request.channels,
494                request.samples,
495            );
496        }
497
498        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(self.resource.instance, frame) };
499        unsafe { FrameSyncAudioRef::new(guard, request.samples == 0) }
500    }
501
502    /// Capture audio and convert to an owned frame.
503    ///
504    /// This is a convenience method that captures audio and immediately converts
505    /// it to an owned [`AudioFrame`] that can be sent across threads. Query or
506    /// no-source states that contain no sample buffer return `Ok(None)`.
507    ///
508    /// # Arguments
509    ///
510    /// * `request` - Explicit capture or query operation.
511    ///
512    /// # Errors
513    ///
514    /// Propagates returned-frame validation and owned-copy errors from
515    /// [`Self::capture_audio`] and [`AudioRef::to_owned`].
516    pub fn capture_audio_owned(
517        &self,
518        request: FrameSyncAudioRequest,
519    ) -> Result<Option<AudioFrame>> {
520        self.capture_audio(request)?.to_owned()
521    }
522
523    /// Query the current audio queue depth.
524    ///
525    /// Returns the approximate number of audio samples currently buffered.
526    /// This can be useful when using an inaccurate timer for audio playback.
527    ///
528    /// **Note:** This value may change immediately after being read as new
529    /// samples are continuously received. For most applications, simply call
530    /// `capture_audio` at your desired rate and let the SDK handle timing.
531    ///
532    /// # Example
533    ///
534    /// ```no_run
535    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
536    /// # fn main() -> Result<(), grafton_ndi::Error> {
537    /// # let ndi = NDI::new()?;
538    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
539    /// # let options = ReceiverOptions::builder(source).build();
540    /// # let receiver = Receiver::new(&ndi, &options)?;
541    /// let framesync = FrameSync::new(receiver)?;
542    ///
543    /// // Check available samples before capture
544    /// let available = framesync.audio_queue_depth();
545    /// println!("Audio samples available: {}", available);
546    /// # Ok(())
547    /// # }
548    /// ```
549    pub fn audio_queue_depth(&self) -> i32 {
550        unsafe { NDIlib_framesync_audio_queue_depth(self.resource.instance) }
551    }
552}
553
554/// # Safety
555///
556/// The NDI SDK documentation states that framesync operations are thread-safe.
557/// The FrameSync owns its receiver, keeping the SDK runtime alive. Borrowed
558/// frame guards cannot be moved between threads and keep this owner borrowed
559/// until their buffers are freed; destruction requires exclusive ownership.
560unsafe impl Send for FrameSync {}
561
562/// # Safety
563///
564/// The NDI SDK guarantees that framesync capture functions are internally
565/// synchronized and can be called concurrently from multiple threads. Receiver
566/// attachment disables raw video capture until the synchronizer is destroyed,
567/// including when the receiver is accessed through [`FrameSync::receiver`].
568unsafe impl Sync for FrameSync {}
569
570impl fmt::Debug for FrameSync {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        f.debug_struct("FrameSync")
573            .field("instance", &self.resource.instance)
574            .field("audio_queue_depth", &self.audio_queue_depth())
575            .finish()
576    }
577}
578
579pub(crate) fn is_framesync_video_empty(frame: &NDIlib_video_frame_v2_t) -> bool {
580    frame.xres == 0
581        && frame.yres == 0
582        && frame.FourCC == 0
583        && frame.frame_rate_N == 0
584        && frame.frame_rate_D == 0
585        && frame.picture_aspect_ratio == 0.0
586        && frame.frame_format_type == 0
587        && frame.timecode == 0
588        && frame.p_data.is_null()
589        && unsafe { frame.__bindgen_anon_1.data_size_in_bytes } == 0
590        && frame.p_metadata.is_null()
591        && frame.timestamp == 0
592}
593
594// A thread-local seam at the actual SDK capture/free boundaries. This lets tests
595// exercise both public entry points without replacing SDK-owned frame storage.
596#[cfg(test)]
597pub(crate) mod audio_test_calls {
598    use std::cell::Cell;
599    std::thread_local! {
600        static CALLS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
601    }
602    pub(super) fn capture() {
603        CALLS.with(|calls| {
604            let (captures, frees) = calls.get();
605            calls.set((captures + 1, frees));
606        });
607    }
608    pub(crate) fn free() {
609        CALLS.with(|calls| {
610            let (captures, frees) = calls.get();
611            calls.set((captures, frees + 1));
612        });
613    }
614    pub(super) fn take() -> (usize, usize) {
615        CALLS.with(|calls| calls.replace((0, 0)))
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::frames::{AudioFormat, LineStrideOrSize, PixelFormat};
623    use std::ptr;
624
625    #[test]
626    fn receiver_modes_preserve_resource_order_and_exactly_once_destruction() {
627        use crate::receiver::lifecycle_test_events::track;
628        for recover in [false, true] {
629            let events = track(|| {
630                let ndi = crate::NDI::new().unwrap();
631                let mut receiver = Receiver::new(&ndi, &crate::ReceiverOptions::default()).unwrap();
632                let original = receiver.core.instance;
633                drop(ndi);
634                for _ in 0..3 {
635                    let fs = FrameSync::new(receiver).unwrap();
636                    assert_eq!(fs.receiver().core.instance, original);
637                    receiver = fs.into_receiver();
638                    assert_eq!(receiver.core.instance, original);
639                    assert!(receiver
640                        .video()
641                        .try_capture(std::time::Duration::ZERO)
642                        .is_ok());
643                }
644                let fs = FrameSync::new(receiver).unwrap();
645                if recover {
646                    drop(fs.into_receiver());
647                } else {
648                    drop(fs);
649                }
650            });
651            assert_eq!(
652                events,
653                [
654                    "framesync",
655                    "framesync",
656                    "framesync",
657                    "framesync",
658                    "receiver"
659                ]
660            );
661        }
662        let events = track(|| {
663            let ndi = crate::NDI::new().unwrap();
664            let receiver = Receiver::new(&ndi, &crate::ReceiverOptions::default()).unwrap();
665            drop(ndi);
666            assert!(matches!(
667                FrameSync::new_with(receiver, |_| ptr::null_mut()),
668                Err(Error::InitializationFailed(_))
669            ));
670        });
671        assert_eq!(events, ["receiver"]);
672    }
673
674    #[test]
675    fn audio_requests_reject_invalid_rates_and_dimensions_at_construction() {
676        for rate in [i32::MIN, -48_000, -1, 0] {
677            assert!(FrameSyncAudioRequest::capture_with(Some(rate), 2, 1024).is_err());
678        }
679        for (channels, samples) in [
680            (0, 1024),
681            (-1, 1024),
682            (2, 0),
683            (2, -1),
684            (i32::MIN, i32::MIN),
685            (2, 8_388_609),
686            (2, 536_870_912),
687            (1, 1_073_741_825),
688            (i32::MAX, 1),
689            (i32::MAX, i32::MAX),
690        ] {
691            assert!(FrameSyncAudioRequest::capture(channels, samples).is_err());
692            assert!(FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples).is_err());
693        }
694        // Exact cap, with no sample allocation or native call.
695        assert!(FrameSyncAudioRequest::capture(2, 8_388_608).is_ok());
696        assert!(FrameSyncAudioRequest::capture(1, 16_777_216).is_ok());
697        assert!(FrameSyncAudioRequest::capture(1, 16_777_217).is_err());
698    }
699
700    #[test]
701    fn audio_request_reuse_and_rejection_at_both_sdk_entry_points() {
702        let ndi = crate::NDI::new().unwrap();
703        let receiver = Receiver::new(
704            &ndi,
705            &crate::ReceiverOptions::builder(crate::Source::default()).build(),
706        )
707        .unwrap();
708        let fs = FrameSync::new(receiver).unwrap();
709        audio_test_calls::take();
710        for (channels, samples) in [
711            (2, 8_388_609),
712            (2, 536_870_912),
713            (1, 1_073_741_825),
714            (i32::MAX, 1),
715        ] {
716            assert!(
717                FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples)
718                    .and_then(|request| fs.capture_audio(request))
719                    .is_err()
720            );
721            assert!(
722                FrameSyncAudioRequest::capture_with(Some(48_000), channels, samples)
723                    .and_then(|request| fs.capture_audio_owned(request))
724                    .is_err()
725            );
726            assert_eq!(audio_test_calls::take(), (0, 0));
727        }
728
729        let ((), allocations) = crate::test_alloc::allocation_count(|| {
730            for _ in 0..1000 {
731                std::hint::black_box(FrameSyncAudioRequest::capture(2, 8_388_608).unwrap());
732                std::hint::black_box(FrameSyncAudioRequest::query_input());
733            }
734        });
735        assert_eq!(allocations, 0);
736        let (request, allocations) = crate::test_alloc::allocation_count(|| {
737            FrameSyncAudioRequest::capture_with(Some(48_000), 2, 1024).unwrap()
738        });
739        assert_eq!(allocations, 0);
740        for _ in 0..3 {
741            let ((), allocations) = crate::test_alloc::allocation_count(|| {
742                let frame = fs.capture_audio(std::hint::black_box(request)).unwrap();
743                assert_eq!(
744                    (
745                        frame.sample_rate(),
746                        frame.num_channels(),
747                        frame.num_samples()
748                    ),
749                    (48_000, 2, 1024)
750                );
751                assert!(frame
752                    .channels()
753                    .all(|channel| channel.iter().all(|&s| s == 0.0)));
754            });
755            assert_eq!(allocations, 0);
756            assert_eq!(audio_test_calls::take(), (1, 1));
757            assert!(fs.capture_audio_owned(request).unwrap().is_some());
758            assert_eq!(audio_test_calls::take(), (1, 1));
759        }
760
761        // No source rate is known: response validation must still run and free
762        // the SDK descriptor on its error path, for both entry points.
763        let unknown_rate = FrameSyncAudioRequest::capture(2, 1024).unwrap();
764        assert!(fs.capture_audio(unknown_rate).is_err());
765        assert_eq!(audio_test_calls::take(), (1, 1));
766        assert!(fs.capture_audio_owned(unknown_rate).is_err());
767        assert_eq!(audio_test_calls::take(), (1, 1));
768        assert!(fs
769            .capture_audio(FrameSyncAudioRequest::query_input())
770            .unwrap()
771            .is_empty());
772        assert_eq!(audio_test_calls::take(), (1, 1));
773        assert!(fs
774            .capture_audio_owned(FrameSyncAudioRequest::query_input())
775            .unwrap()
776            .is_none());
777        assert_eq!(audio_test_calls::take(), (1, 1));
778    }
779
780    #[test]
781    fn test_framesync_size() {
782        // FrameSync contains an opaque pointer + the owned Receiver
783        assert_eq!(
784            std::mem::size_of::<FrameSync>(),
785            std::mem::size_of::<NDIlib_framesync_instance_t>() + std::mem::size_of::<Receiver>()
786        );
787    }
788
789    #[test]
790    fn test_video_ref_size() {
791        // FrameSyncVideoRef includes a reference, frame struct, and pixel_format
792        let size = std::mem::size_of::<FrameSyncVideoRef>();
793        assert!(size > 0, "FrameSyncVideoRef should have non-zero size");
794    }
795
796    #[test]
797    fn test_audio_ref_size() {
798        // FrameSyncAudioRef includes a reference and frame struct
799        let size = std::mem::size_of::<FrameSyncAudioRef>();
800        assert!(size > 0, "FrameSyncAudioRef should have non-zero size");
801    }
802
803    #[test]
804    fn test_video_empty_classifier_accepts_only_all_zero() {
805        let empty = NDIlib_video_frame_v2_t::default();
806        assert!(is_framesync_video_empty(&empty));
807
808        let mut partial = NDIlib_video_frame_v2_t {
809            xres: 1920,
810            ..NDIlib_video_frame_v2_t::default()
811        };
812        assert!(!is_framesync_video_empty(&partial));
813
814        let mut byte = 0u8;
815        partial = NDIlib_video_frame_v2_t {
816            p_data: &mut byte as *mut u8,
817            ..NDIlib_video_frame_v2_t::default()
818        };
819        assert!(!is_framesync_video_empty(&partial));
820    }
821
822    #[test]
823    fn test_framesync_video_ref_uses_validated_layout() {
824        let width = 16;
825        let height = 8;
826        let stride = width * 4;
827        let expected_len = (stride * height) as usize;
828        let mut data = vec![0u8; expected_len];
829        let mut metadata = b"framesync video\0".to_vec();
830
831        let raw = NDIlib_video_frame_v2_t {
832            xres: width,
833            yres: height,
834            FourCC: PixelFormat::BGRA.into(),
835            frame_rate_N: 60,
836            frame_rate_D: 1,
837            picture_aspect_ratio: 16.0 / 9.0,
838            frame_format_type: ScanType::Progressive.into(),
839            timecode: 0,
840            p_data: data.as_mut_ptr(),
841            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
842                line_stride_in_bytes: stride,
843            },
844            p_metadata: metadata.as_mut_ptr().cast(),
845            timestamp: 0,
846        };
847
848        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
849        let frame = unsafe { FrameSyncVideoRef::new(guard) }.expect("valid video frame");
850
851        assert_eq!(frame.metadata(), Some("framesync video"));
852        assert_eq!(frame.data().unwrap().len(), expected_len);
853        assert_eq!(
854            frame.line_stride_or_size(),
855            LineStrideOrSize::LineStrideBytes(stride)
856        );
857        let owned = frame.to_owned().expect("owned conversion");
858        assert_eq!(owned.metadata(), Some("framesync video"));
859        assert!(format!("{frame:?}").contains("FrameSyncVideoRef"));
860    }
861
862    #[test]
863    fn test_framesync_video_ref_rejects_malformed_metadata() {
864        let width = 16;
865        let height = 8;
866        let stride = width * 4;
867        let expected_len = (stride * height) as usize;
868        let mut data = vec![0u8; expected_len];
869        let mut metadata = vec![b'x'; crate::frames::MAX_METADATA_BYTES];
870
871        let raw = NDIlib_video_frame_v2_t {
872            xres: width,
873            yres: height,
874            FourCC: PixelFormat::BGRA.into(),
875            frame_rate_N: 60,
876            frame_rate_D: 1,
877            picture_aspect_ratio: 16.0 / 9.0,
878            frame_format_type: ScanType::Progressive.into(),
879            timecode: 0,
880            p_data: data.as_mut_ptr(),
881            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
882                line_stride_in_bytes: stride,
883            },
884            p_metadata: metadata.as_mut_ptr().cast(),
885            timestamp: 0,
886        };
887
888        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
889        assert!(matches!(
890            unsafe { FrameSyncVideoRef::new(guard) },
891            Err(Error::InvalidFrame(_))
892        ));
893    }
894
895    #[test]
896    fn test_framesync_video_ref_rejects_partial_empty() {
897        let raw = NDIlib_video_frame_v2_t {
898            xres: 16,
899            yres: 8,
900            FourCC: PixelFormat::BGRA.into(),
901            frame_rate_N: 60,
902            frame_rate_D: 1,
903            picture_aspect_ratio: 16.0 / 9.0,
904            frame_format_type: ScanType::Progressive.into(),
905            timecode: 0,
906            p_data: ptr::null_mut(),
907            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
908                line_stride_in_bytes: 16 * 4,
909            },
910            p_metadata: ptr::null(),
911            timestamp: 0,
912        };
913
914        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
915        let result = unsafe { FrameSyncVideoRef::new(guard) };
916        assert!(matches!(result, Err(Error::InvalidFrame(_))));
917    }
918
919    #[test]
920    fn test_framesync_audio_ref_query_no_source_empty() {
921        let raw = NDIlib_audio_frame_v3_t::default();
922        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
923        let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("empty query frame");
924
925        assert!(frame.is_empty());
926        assert_eq!(frame.metadata(), None);
927        assert!(frame.data().unwrap().is_empty());
928        assert_eq!(frame.format(), None);
929        assert!(frame.to_owned().expect("owned conversion").is_none());
930        assert!(format!("{frame:?}").contains("FrameSyncAudioRef"));
931    }
932
933    #[test]
934    fn test_framesync_audio_ref_query_source_format_empty() {
935        let raw = NDIlib_audio_frame_v3_t {
936            sample_rate: 48000,
937            no_channels: 2,
938            no_samples: 0,
939            timecode: 0,
940            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
941            p_data: ptr::null_mut(),
942            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
943                channel_stride_in_bytes: 0,
944            },
945            p_metadata: ptr::null(),
946            timestamp: 0,
947        };
948
949        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
950        let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("query source frame");
951
952        assert!(frame.is_empty());
953        assert_eq!(frame.sample_rate(), 48000);
954        assert_eq!(frame.num_channels(), 2);
955        assert_eq!(frame.format(), Some(AudioFormat::FLTP));
956    }
957
958    #[test]
959    fn test_framesync_audio_ref_capture_rejects_empty() {
960        let raw = NDIlib_audio_frame_v3_t::default();
961        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
962        let result = unsafe { FrameSyncAudioRef::new(guard, false) };
963
964        assert!(matches!(result, Err(Error::InvalidFrame(_))));
965    }
966
967    #[test]
968    fn test_framesync_audio_ref_supports_strided_planar_data() {
969        let no_samples = 4;
970        let no_channels = 2;
971        let stride_samples = 6;
972        let mut data = vec![0.0f32; 10];
973        let mut metadata = b"framesync audio\0".to_vec();
974        data[0..4].copy_from_slice(&[1.0, 2.0, 3.0, 4.0]);
975        data[6..10].copy_from_slice(&[10.0, 20.0, 30.0, 40.0]);
976
977        let raw = NDIlib_audio_frame_v3_t {
978            sample_rate: 48000,
979            no_channels,
980            no_samples,
981            timecode: 0,
982            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
983            p_data: data.as_mut_ptr() as *mut u8,
984            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
985                channel_stride_in_bytes: stride_samples * 4,
986            },
987            p_metadata: metadata.as_mut_ptr().cast(),
988            timestamp: 0,
989        };
990
991        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
992        let frame = unsafe { FrameSyncAudioRef::new(guard, false) }.expect("strided audio frame");
993
994        assert_eq!(frame.metadata(), Some("framesync audio"));
995        assert!(frame.data().is_none());
996        assert_eq!(frame.channel_data(0), Some(&[1.0, 2.0, 3.0, 4.0][..]));
997        assert_eq!(frame.channel_data(1), Some(&[10.0, 20.0, 30.0, 40.0][..]));
998        assert_eq!(frame.channel_stride_in_bytes(), stride_samples * 4);
999
1000        let owned = frame
1001            .to_owned()
1002            .expect("owned conversion")
1003            .expect("samples");
1004        assert_eq!(owned.metadata(), Some("framesync audio"));
1005        assert_eq!(owned.data().len(), 8);
1006        assert_eq!(owned.channel_stride_in_bytes(), 16);
1007        assert_eq!(owned.channel_data(1), Some(&[10.0, 20.0, 30.0, 40.0][..]));
1008    }
1009
1010    #[test]
1011    fn test_framesync_audio_ref_rejects_invalid_utf8_metadata() {
1012        let no_samples = 4;
1013        let no_channels = 2;
1014        let mut data = vec![0.0f32; (no_samples * no_channels) as usize];
1015        let mut metadata = vec![0xFF, 0];
1016
1017        let raw = NDIlib_audio_frame_v3_t {
1018            sample_rate: 48000,
1019            no_channels,
1020            no_samples,
1021            timecode: 0,
1022            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
1023            p_data: data.as_mut_ptr() as *mut u8,
1024            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
1025                channel_stride_in_bytes: no_samples * 4,
1026            },
1027            p_metadata: metadata.as_mut_ptr().cast(),
1028            timestamp: 0,
1029        };
1030
1031        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
1032        assert!(matches!(
1033            unsafe { FrameSyncAudioRef::new(guard, false) },
1034            Err(Error::InvalidUtf8(_))
1035        ));
1036    }
1037}