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::{num::NonZeroI32, 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 audio = framesync.capture_audio(FrameSyncAudioRequest::Capture {
69//!         sample_rate: Some(NonZeroI32::new(48_000).unwrap()),
70//!         channels: Some(NonZeroI32::new(2).unwrap()),
71//!         samples: NonZeroI32::new(1_024).unwrap(),
72//!     })?;
73//!     println!("{} audio samples", audio.num_samples());
74//!
75//!     Ok(())
76//! }
77//! ```
78
79use std::{fmt, mem::ManuallyDrop, num::NonZeroI32, ptr};
80
81use crate::{
82    capture::{FrameSyncAudioFree, FrameSyncVideoFree, Guard},
83    frames::{AudioFrame, AudioRef, ScanType, VideoFrame, VideoRef},
84    ndi_lib::*,
85    receiver::Receiver,
86    Error, Result,
87};
88
89/// A zero-copy borrowed video frame from a [`FrameSync`] capture.
90///
91/// This is the FrameSync spelling of the generic
92/// [`VideoRef`]; the [`Receiver`]
93/// spelling is [`VideoFrameRef`](crate::VideoFrameRef). Both share one accessor
94/// and `Debug` implementation. The frame is automatically freed when dropped via
95/// `NDIlib_framesync_free_video`.
96pub type FrameSyncVideoRef<'fs> = VideoRef<'fs, FrameSyncVideoFree>;
97
98/// A zero-copy borrowed audio frame from a [`FrameSync`] capture.
99///
100/// This is the FrameSync spelling of the generic
101/// [`AudioRef`]; the [`Receiver`]
102/// spelling is [`AudioFrameRef`](crate::AudioFrameRef). The FrameSync path can
103/// produce a validated empty query/no-source state, so
104/// [`is_empty`](AudioRef::is_empty) and an `Option`-returning
105/// [`format`](AudioRef::format)/[`to_owned`](AudioRef::to_owned) are available.
106/// The frame is automatically freed when dropped via
107/// `NDIlib_framesync_free_audio_v2`.
108pub type FrameSyncAudioRef<'fs> = AudioRef<'fs, FrameSyncAudioFree>;
109
110/// Explicit audio operation for [`FrameSync::capture_audio`].
111///
112/// FrameSync audio has two distinct SDK modes:
113/// - [`QueryInput`](Self::QueryInput) asks the SDK for the current input audio
114///   format without requesting samples.
115/// - [`Capture`](Self::Capture) asks the SDK for a positive number of samples,
116///   optionally using the source sample rate and/or source channel count.
117///
118/// `None` for `sample_rate` or `channels` maps to the SDK's `0` value, meaning
119/// "use the current source value". The `samples` field must be positive.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum FrameSyncAudioRequest {
122    /// Query the current incoming audio format without requesting sample data.
123    QueryInput,
124    /// Capture audio samples with optional source-derived output parameters.
125    Capture {
126        /// Requested output sample rate. `None` uses the current source rate.
127        sample_rate: Option<NonZeroI32>,
128        /// Requested output channel count. `None` uses the current source count.
129        channels: Option<NonZeroI32>,
130        /// Number of samples to capture per channel.
131        samples: NonZeroI32,
132    },
133}
134
135impl FrameSyncAudioRequest {
136    /// Capture samples using the source sample rate and source channel count.
137    pub fn capture(samples: NonZeroI32) -> Self {
138        Self::Capture {
139            sample_rate: None,
140            channels: None,
141            samples,
142        }
143    }
144
145    /// Capture samples with explicit optional sample-rate and channel requests.
146    pub fn capture_with(
147        sample_rate: Option<NonZeroI32>,
148        channels: Option<NonZeroI32>,
149        samples: NonZeroI32,
150    ) -> Self {
151        Self::Capture {
152            sample_rate,
153            channels,
154            samples,
155        }
156    }
157
158    fn to_raw(self) -> Result<FrameSyncAudioRawRequest> {
159        match self {
160            Self::QueryInput => Ok(FrameSyncAudioRawRequest {
161                sample_rate: 0,
162                channels: 0,
163                samples: 0,
164                query_input: true,
165            }),
166            Self::Capture {
167                sample_rate,
168                channels,
169                samples,
170            } => Ok(FrameSyncAudioRawRequest {
171                sample_rate: positive_optional_i32("sample_rate", sample_rate)?,
172                channels: positive_optional_i32("channels", channels)?,
173                samples: positive_i32("samples", samples)?,
174                query_input: false,
175            }),
176        }
177    }
178}
179
180#[derive(Debug, Clone, Copy)]
181struct FrameSyncAudioRawRequest {
182    sample_rate: i32,
183    channels: i32,
184    samples: i32,
185    query_input: bool,
186}
187
188fn positive_optional_i32(field: &str, value: Option<NonZeroI32>) -> Result<i32> {
189    value.map_or(Ok(0), |value| positive_i32(field, value))
190}
191
192fn positive_i32(field: &str, value: NonZeroI32) -> Result<i32> {
193    let value = value.get();
194    if value > 0 {
195        Ok(value)
196    } else {
197        Err(Error::InvalidConfiguration(format!(
198            "FrameSync audio {field} must be positive, got {value}"
199        )))
200    }
201}
202
203/// Frame synchronizer for clock-corrected capture.
204///
205/// Converts push-based NDI streams into pull-based capture with automatic
206/// time-base correction and dynamic audio resampling.
207///
208/// # Ownership
209///
210/// `FrameSync` takes ownership of the [`Receiver`] (similar to how `BufWriter`
211/// wraps a `Write`). Use [`receiver()`](Self::receiver) to access the underlying
212/// receiver for tally, PTZ, or status operations. Use
213/// [`into_receiver()`](Self::into_receiver) to recover the receiver when done.
214///
215/// # Thread Safety
216///
217/// `FrameSync` is `Send + Sync` like `Receiver`, as the underlying NDI SDK
218/// frame-sync functions are thread-safe. However, frames returned by capture
219/// methods borrow from the FrameSync and are not `Send`.
220///
221/// # Example
222///
223/// ```no_run
224/// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress, ScanType};
225/// # use std::num::NonZeroI32;
226/// # fn main() -> Result<(), grafton_ndi::Error> {
227/// # let ndi = NDI::new()?;
228/// # let source = Source { name: "Test".into(), address: SourceAddress::None };
229/// # let options = ReceiverOptions::builder(source).build();
230/// # let receiver = Receiver::new(&ndi, &options)?;
231/// let framesync = FrameSync::new(receiver)?;
232///
233/// // FrameSync captures always return immediately
234/// if let Some(video) = framesync.capture_video(ScanType::Progressive)? {
235///     println!("Video: {}x{}", video.width(), video.height());
236/// }
237///
238/// // Audio capture uses an explicit request; None means "use source value".
239/// let audio = framesync.capture_audio(FrameSyncAudioRequest::Capture {
240///     sample_rate: Some(NonZeroI32::new(48_000).unwrap()),
241///     channels: Some(NonZeroI32::new(2).unwrap()),
242///     samples: NonZeroI32::new(1_024).unwrap(),
243/// })?;
244/// println!("Audio: {} samples", audio.num_samples());
245/// # Ok(())
246/// # }
247/// ```
248pub struct FrameSync {
249    instance: NDIlib_framesync_instance_t,
250    receiver: Receiver,
251}
252
253impl FrameSync {
254    /// Create a frame synchronizer from a receiver.
255    ///
256    /// Takes ownership of the receiver. Use [`receiver()`](Self::receiver)
257    /// to access the underlying receiver for tally, PTZ, or status operations.
258    /// Use [`into_receiver()`](Self::into_receiver) to recover the receiver.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the NDI SDK fails to create the frame-sync instance.
263    ///
264    /// # Example
265    ///
266    /// ```no_run
267    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
268    /// # fn main() -> Result<(), grafton_ndi::Error> {
269    /// # let ndi = NDI::new()?;
270    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
271    /// # let options = ReceiverOptions::builder(source).build();
272    /// let receiver = Receiver::new(&ndi, &options)?;
273    /// let framesync = FrameSync::new(receiver)?;
274    /// # Ok(())
275    /// # }
276    /// ```
277    pub fn new(receiver: Receiver) -> Result<Self> {
278        let instance = unsafe { NDIlib_framesync_create(receiver.instance) };
279
280        if instance.is_null() {
281            return Err(Error::InitializationFailed(
282                "Failed to create NDI framesync instance".into(),
283            ));
284        }
285
286        Ok(Self { instance, receiver })
287    }
288
289    /// Access the underlying receiver.
290    ///
291    /// Use this to access receiver functionality like tally, PTZ, or status
292    /// queries while the FrameSync is active.
293    pub fn receiver(&self) -> &Receiver {
294        &self.receiver
295    }
296
297    /// Consume the FrameSync and recover the underlying Receiver.
298    ///
299    /// This destroys the frame synchronizer and returns the receiver for
300    /// continued use with raw capture or for creating a new FrameSync.
301    pub fn into_receiver(self) -> Receiver {
302        // Destroy the framesync instance first, then extract the receiver
303        // without running Drop (which would double-destroy the framesync).
304        let this = ManuallyDrop::new(self);
305        unsafe {
306            NDIlib_framesync_destroy(this.instance);
307        }
308        // SAFETY: We will not use `this` again after reading the receiver field,
309        // and ManuallyDrop prevents the Drop impl from running.
310        unsafe { ptr::read(&this.receiver) }
311    }
312
313    /// Capture video with time-base correction.
314    ///
315    /// This function always returns immediately. It returns the best frame for
316    /// the current output timing, handling clock drift and jitter automatically.
317    /// The same frame may be returned multiple times when capture rate exceeds
318    /// source frame rate.
319    ///
320    /// # Arguments
321    ///
322    /// * `field_type` - The desired field format. Use `ScanType::Progressive` for
323    ///   most applications. For interlaced output, use the appropriate field type
324    ///   to maintain correct field ordering.
325    ///
326    /// # Returns
327    ///
328    /// * `Ok(Some(FrameSyncVideoRef))` - A validated zero-copy reference to the captured frame
329    /// * `Ok(None)` - The SDK returned the documented all-zero "no video yet" state
330    /// * `Err(Error::InvalidFrame(_))` - The SDK returned non-empty malformed metadata
331    ///
332    /// # Example
333    ///
334    /// ```no_run
335    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress, ScanType};
336    /// # fn main() -> Result<(), grafton_ndi::Error> {
337    /// # let ndi = NDI::new()?;
338    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
339    /// # let options = ReceiverOptions::builder(source).build();
340    /// # let receiver = Receiver::new(&ndi, &options)?;
341    /// let framesync = FrameSync::new(receiver)?;
342    ///
343    /// // Capture loop - call at your output frame rate
344    /// loop {
345    ///     if let Some(frame) = framesync.capture_video(ScanType::Progressive)? {
346    ///         // Process/display frame
347    ///         println!("{}x{} @ {}/{} fps",
348    ///             frame.width(), frame.height(),
349    ///             frame.frame_rate_n(), frame.frame_rate_d());
350    ///     } else {
351    ///         // No video received yet - display placeholder
352    ///         println!("Waiting for video...");
353    ///     }
354    ///     # break;
355    /// }
356    /// # Ok(())
357    /// # }
358    /// ```
359    pub fn capture_video(&self, field_type: ScanType) -> Result<Option<FrameSyncVideoRef<'_>>> {
360        let mut frame = NDIlib_video_frame_v2_t::default();
361
362        unsafe {
363            NDIlib_framesync_capture_video(self.instance, &mut frame, field_type.into());
364        }
365
366        // Per SDK docs: Returns zeroed struct if no video received yet
367        if is_framesync_video_empty(&frame) {
368            return Ok(None);
369        }
370
371        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(self.instance, frame) };
372        let frame = unsafe { FrameSyncVideoRef::new(guard)? };
373
374        Ok(Some(frame))
375    }
376
377    /// Capture video and convert to an owned frame.
378    ///
379    /// This is a convenience method that captures video and immediately converts
380    /// it to an owned [`VideoFrame`] that can be sent across threads.
381    ///
382    /// # Arguments
383    ///
384    /// * `field_type` - The desired field format.
385    ///
386    /// # Returns
387    ///
388    /// * `Ok(Some(VideoFrame))` - An owned copy of the captured frame
389    /// * `Ok(None)` - No video has been received yet
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the SDK returns non-empty malformed frame metadata.
394    pub fn capture_video_owned(&self, field_type: ScanType) -> Result<Option<VideoFrame>> {
395        self.capture_video(field_type)?
396            .map(|frame| frame.to_owned())
397            .transpose()
398    }
399
400    /// Capture audio with dynamic resampling.
401    ///
402    /// This function always returns immediately. Capture requests ask the NDI
403    /// SDK to resample incoming audio to match the requested sample rate,
404    /// channel count, and sample count. Query requests return the current input
405    /// format without requesting samples.
406    ///
407    /// Call this at the rate you need audio - the SDK will adapt the incoming
408    /// signal to match your output timing using dynamic audio sampling.
409    ///
410    /// # Arguments
411    ///
412    /// * `request` - Explicit capture or query operation. For capture requests,
413    ///   `None` sample rate or channels means "use the current source value".
414    ///
415    /// # Querying Input Format
416    ///
417    /// Use [`FrameSyncAudioRequest::QueryInput`] to query the current input
418    /// format without capturing samples. The returned frame contains the
419    /// input's sample rate and channel count, or a validated empty no-source
420    /// state when no audio format has been received yet.
421    ///
422    /// # Errors
423    ///
424    /// Returns [`Error::InvalidFrame`] if the SDK returns malformed audio
425    /// metadata, or [`Error::InvalidConfiguration`] if the request contains a
426    /// negative `NonZeroI32` value.
427    ///
428    /// # Example
429    ///
430    /// ```no_run
431    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, FrameSyncAudioRequest, Source, SourceAddress};
432    /// # use std::num::NonZeroI32;
433    /// # fn main() -> Result<(), grafton_ndi::Error> {
434    /// # let ndi = NDI::new()?;
435    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
436    /// # let options = ReceiverOptions::builder(source).build();
437    /// # let receiver = Receiver::new(&ndi, &options)?;
438    /// let framesync = FrameSync::new(receiver)?;
439    ///
440    /// // Audio callback - called by sound card at its rate
441    /// let request = FrameSyncAudioRequest::Capture {
442    ///     sample_rate: Some(NonZeroI32::new(48_000).unwrap()),
443    ///     channels: Some(NonZeroI32::new(2).unwrap()),
444    ///     samples: NonZeroI32::new(1_024).unwrap(),
445    /// };
446    ///
447    /// loop {
448    ///     // Request 1024 stereo samples at 48kHz
449    ///     let audio = framesync.capture_audio(request)?;
450    ///
451    ///     // Process audio samples
452    ///     let samples = audio.data();
453    ///     println!("Got {} samples", samples.len());
454    ///
455    ///     // Check for a validated query/no-source empty state
456    ///     if audio.is_empty() {
457    ///         println!("No audio source yet");
458    ///     }
459    ///     # break;
460    /// }
461    /// # Ok(())
462    /// # }
463    /// ```
464    pub fn capture_audio(&self, request: FrameSyncAudioRequest) -> Result<FrameSyncAudioRef<'_>> {
465        let request = request.to_raw()?;
466        let mut frame = NDIlib_audio_frame_v3_t::default();
467
468        unsafe {
469            NDIlib_framesync_capture_audio_v2(
470                self.instance,
471                &mut frame,
472                request.sample_rate,
473                request.channels,
474                request.samples,
475            );
476        }
477
478        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(self.instance, frame) };
479        unsafe { FrameSyncAudioRef::new(guard, request.query_input) }
480    }
481
482    /// Capture audio and convert to an owned frame.
483    ///
484    /// This is a convenience method that captures audio and immediately converts
485    /// it to an owned [`AudioFrame`] that can be sent across threads. Query or
486    /// no-source states that contain no sample buffer return `Ok(None)`.
487    ///
488    /// # Arguments
489    ///
490    /// * `request` - Explicit capture or query operation.
491    ///
492    /// # Errors
493    ///
494    /// Returns an error if the request is invalid or the SDK returns malformed
495    /// audio metadata.
496    pub fn capture_audio_owned(
497        &self,
498        request: FrameSyncAudioRequest,
499    ) -> Result<Option<AudioFrame>> {
500        self.capture_audio(request)?.to_owned()
501    }
502
503    /// Query the current audio queue depth.
504    ///
505    /// Returns the approximate number of audio samples currently buffered.
506    /// This can be useful when using an inaccurate timer for audio playback.
507    ///
508    /// **Note:** This value may change immediately after being read as new
509    /// samples are continuously received. For most applications, simply call
510    /// `capture_audio` at your desired rate and let the SDK handle timing.
511    ///
512    /// # Example
513    ///
514    /// ```no_run
515    /// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, FrameSync, Source, SourceAddress};
516    /// # fn main() -> Result<(), grafton_ndi::Error> {
517    /// # let ndi = NDI::new()?;
518    /// # let source = Source { name: "Test".into(), address: SourceAddress::None };
519    /// # let options = ReceiverOptions::builder(source).build();
520    /// # let receiver = Receiver::new(&ndi, &options)?;
521    /// let framesync = FrameSync::new(receiver)?;
522    ///
523    /// // Check available samples before capture
524    /// let available = framesync.audio_queue_depth();
525    /// println!("Audio samples available: {}", available);
526    /// # Ok(())
527    /// # }
528    /// ```
529    pub fn audio_queue_depth(&self) -> i32 {
530        unsafe { NDIlib_framesync_audio_queue_depth(self.instance) }
531    }
532}
533
534impl Drop for FrameSync {
535    fn drop(&mut self) {
536        unsafe {
537            NDIlib_framesync_destroy(self.instance);
538        }
539    }
540}
541
542/// # Safety
543///
544/// The NDI SDK documentation states that framesync operations are thread-safe.
545/// The FrameSync struct only holds an opaque pointer returned by the SDK.
546unsafe impl Send for FrameSync {}
547
548/// # Safety
549///
550/// The NDI SDK guarantees that framesync capture functions are internally
551/// synchronized and can be called concurrently from multiple threads.
552unsafe impl Sync for FrameSync {}
553
554impl fmt::Debug for FrameSync {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        f.debug_struct("FrameSync")
557            .field("instance", &self.instance)
558            .field("audio_queue_depth", &self.audio_queue_depth())
559            .finish()
560    }
561}
562
563pub(crate) fn is_framesync_video_empty(frame: &NDIlib_video_frame_v2_t) -> bool {
564    frame.xres == 0
565        && frame.yres == 0
566        && frame.FourCC == 0
567        && frame.frame_rate_N == 0
568        && frame.frame_rate_D == 0
569        && frame.picture_aspect_ratio == 0.0
570        && frame.frame_format_type == 0
571        && frame.timecode == 0
572        && frame.p_data.is_null()
573        && unsafe { frame.__bindgen_anon_1.data_size_in_bytes } == 0
574        && frame.p_metadata.is_null()
575        && frame.timestamp == 0
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::frames::{AudioFormat, LineStrideOrSize, PixelFormat};
582    use std::ptr;
583
584    #[test]
585    fn test_framesync_size() {
586        // FrameSync contains an opaque pointer + the owned Receiver
587        assert_eq!(
588            std::mem::size_of::<FrameSync>(),
589            std::mem::size_of::<NDIlib_framesync_instance_t>() + std::mem::size_of::<Receiver>()
590        );
591    }
592
593    #[test]
594    fn test_video_ref_size() {
595        // FrameSyncVideoRef includes a reference, frame struct, and pixel_format
596        let size = std::mem::size_of::<FrameSyncVideoRef>();
597        assert!(size > 0, "FrameSyncVideoRef should have non-zero size");
598    }
599
600    #[test]
601    fn test_audio_ref_size() {
602        // FrameSyncAudioRef includes a reference and frame struct
603        let size = std::mem::size_of::<FrameSyncAudioRef>();
604        assert!(size > 0, "FrameSyncAudioRef should have non-zero size");
605    }
606
607    #[test]
608    fn test_video_empty_classifier_accepts_only_all_zero() {
609        let empty = NDIlib_video_frame_v2_t::default();
610        assert!(is_framesync_video_empty(&empty));
611
612        let mut partial = NDIlib_video_frame_v2_t {
613            xres: 1920,
614            ..NDIlib_video_frame_v2_t::default()
615        };
616        assert!(!is_framesync_video_empty(&partial));
617
618        let mut byte = 0u8;
619        partial = NDIlib_video_frame_v2_t {
620            p_data: &mut byte as *mut u8,
621            ..NDIlib_video_frame_v2_t::default()
622        };
623        assert!(!is_framesync_video_empty(&partial));
624    }
625
626    #[test]
627    fn test_framesync_video_ref_uses_validated_layout() {
628        let width = 16;
629        let height = 8;
630        let stride = width * 4;
631        let expected_len = (stride * height) as usize;
632        let mut data = vec![0u8; expected_len];
633        let mut metadata = b"framesync video\0".to_vec();
634
635        let raw = NDIlib_video_frame_v2_t {
636            xres: width,
637            yres: height,
638            FourCC: PixelFormat::BGRA.into(),
639            frame_rate_N: 60,
640            frame_rate_D: 1,
641            picture_aspect_ratio: 16.0 / 9.0,
642            frame_format_type: ScanType::Progressive.into(),
643            timecode: 0,
644            p_data: data.as_mut_ptr(),
645            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
646                line_stride_in_bytes: stride,
647            },
648            p_metadata: metadata.as_mut_ptr().cast(),
649            timestamp: 0,
650        };
651
652        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
653        let frame = unsafe { FrameSyncVideoRef::new(guard) }.expect("valid video frame");
654
655        assert_eq!(frame.metadata(), Some("framesync video"));
656        assert_eq!(frame.data().len(), expected_len);
657        assert_eq!(
658            frame.line_stride_or_size(),
659            LineStrideOrSize::LineStrideBytes(stride)
660        );
661        let owned = frame.to_owned().expect("owned conversion");
662        assert_eq!(owned.metadata(), Some("framesync video"));
663        assert!(format!("{frame:?}").contains("FrameSyncVideoRef"));
664    }
665
666    #[test]
667    fn test_framesync_video_ref_rejects_malformed_metadata() {
668        let width = 16;
669        let height = 8;
670        let stride = width * 4;
671        let expected_len = (stride * height) as usize;
672        let mut data = vec![0u8; expected_len];
673        let mut metadata = vec![b'x'; crate::frames::MAX_METADATA_BYTES];
674
675        let raw = NDIlib_video_frame_v2_t {
676            xres: width,
677            yres: height,
678            FourCC: PixelFormat::BGRA.into(),
679            frame_rate_N: 60,
680            frame_rate_D: 1,
681            picture_aspect_ratio: 16.0 / 9.0,
682            frame_format_type: ScanType::Progressive.into(),
683            timecode: 0,
684            p_data: data.as_mut_ptr(),
685            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
686                line_stride_in_bytes: stride,
687            },
688            p_metadata: metadata.as_mut_ptr().cast(),
689            timestamp: 0,
690        };
691
692        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
693        assert!(matches!(
694            unsafe { FrameSyncVideoRef::new(guard) },
695            Err(Error::InvalidFrame(_))
696        ));
697    }
698
699    #[test]
700    fn test_framesync_video_ref_rejects_partial_empty() {
701        let raw = NDIlib_video_frame_v2_t {
702            xres: 16,
703            yres: 8,
704            FourCC: PixelFormat::BGRA.into(),
705            frame_rate_N: 60,
706            frame_rate_D: 1,
707            picture_aspect_ratio: 16.0 / 9.0,
708            frame_format_type: ScanType::Progressive.into(),
709            timecode: 0,
710            p_data: ptr::null_mut(),
711            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
712                line_stride_in_bytes: 16 * 4,
713            },
714            p_metadata: ptr::null(),
715            timestamp: 0,
716        };
717
718        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw) };
719        let result = unsafe { FrameSyncVideoRef::new(guard) };
720        assert!(matches!(result, Err(Error::InvalidFrame(_))));
721    }
722
723    #[test]
724    fn test_framesync_audio_request_rejects_negative_values() {
725        let request = FrameSyncAudioRequest::Capture {
726            sample_rate: Some(NonZeroI32::new(-48_000).unwrap()),
727            channels: Some(NonZeroI32::new(2).unwrap()),
728            samples: NonZeroI32::new(1_024).unwrap(),
729        };
730
731        assert!(matches!(
732            request.to_raw(),
733            Err(Error::InvalidConfiguration(_))
734        ));
735    }
736
737    #[test]
738    fn test_framesync_audio_ref_query_no_source_empty() {
739        let raw = NDIlib_audio_frame_v3_t::default();
740        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
741        let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("empty query frame");
742
743        assert!(frame.is_empty());
744        assert_eq!(frame.metadata(), None);
745        assert!(frame.data().is_empty());
746        assert_eq!(frame.format(), None);
747        assert!(frame.to_owned().expect("owned conversion").is_none());
748        assert!(format!("{frame:?}").contains("FrameSyncAudioRef"));
749    }
750
751    #[test]
752    fn test_framesync_audio_ref_query_source_format_empty() {
753        let raw = NDIlib_audio_frame_v3_t {
754            sample_rate: 48000,
755            no_channels: 2,
756            no_samples: 0,
757            timecode: 0,
758            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
759            p_data: ptr::null_mut(),
760            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
761                channel_stride_in_bytes: 0,
762            },
763            p_metadata: ptr::null(),
764            timestamp: 0,
765        };
766
767        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
768        let frame = unsafe { FrameSyncAudioRef::new(guard, true) }.expect("query source frame");
769
770        assert!(frame.is_empty());
771        assert_eq!(frame.sample_rate(), 48000);
772        assert_eq!(frame.num_channels(), 2);
773        assert_eq!(frame.format(), Some(AudioFormat::FLTP));
774    }
775
776    #[test]
777    fn test_framesync_audio_ref_capture_rejects_empty() {
778        let raw = NDIlib_audio_frame_v3_t::default();
779        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
780        let result = unsafe { FrameSyncAudioRef::new(guard, false) };
781
782        assert!(matches!(result, Err(Error::InvalidFrame(_))));
783    }
784
785    #[test]
786    fn test_framesync_audio_ref_supports_strided_planar_data() {
787        let no_samples = 4;
788        let no_channels = 2;
789        let stride_samples = 6;
790        let mut data = vec![0.0f32; 10];
791        let mut metadata = b"framesync audio\0".to_vec();
792        data[0..4].copy_from_slice(&[1.0, 2.0, 3.0, 4.0]);
793        data[6..10].copy_from_slice(&[10.0, 20.0, 30.0, 40.0]);
794
795        let raw = NDIlib_audio_frame_v3_t {
796            sample_rate: 48000,
797            no_channels,
798            no_samples,
799            timecode: 0,
800            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
801            p_data: data.as_mut_ptr() as *mut u8,
802            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
803                channel_stride_in_bytes: stride_samples * 4,
804            },
805            p_metadata: metadata.as_mut_ptr().cast(),
806            timestamp: 0,
807        };
808
809        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
810        let frame = unsafe { FrameSyncAudioRef::new(guard, false) }.expect("strided audio frame");
811
812        assert_eq!(frame.metadata(), Some("framesync audio"));
813        assert_eq!(frame.data().len(), 10);
814        assert_eq!(frame.channel_data(0), Some(&[1.0, 2.0, 3.0, 4.0][..]));
815        assert_eq!(frame.channel_data(1), Some(&[10.0, 20.0, 30.0, 40.0][..]));
816        assert_eq!(frame.channel_stride_in_bytes(), stride_samples * 4);
817
818        let owned = frame
819            .to_owned()
820            .expect("owned conversion")
821            .expect("samples");
822        assert_eq!(owned.metadata(), Some("framesync audio"));
823        assert_eq!(owned.data().len(), 10);
824        assert_eq!(owned.channel_data(1), Some(vec![10.0, 20.0, 30.0, 40.0]));
825    }
826
827    #[test]
828    fn test_framesync_audio_ref_rejects_invalid_utf8_metadata() {
829        let no_samples = 4;
830        let no_channels = 2;
831        let mut data = vec![0.0f32; (no_samples * no_channels) as usize];
832        let mut metadata = vec![0xFF, 0];
833
834        let raw = NDIlib_audio_frame_v3_t {
835            sample_rate: 48000,
836            no_channels,
837            no_samples,
838            timecode: 0,
839            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
840            p_data: data.as_mut_ptr() as *mut u8,
841            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
842                channel_stride_in_bytes: no_samples * 4,
843            },
844            p_metadata: metadata.as_mut_ptr().cast(),
845            timestamp: 0,
846        };
847
848        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
849        assert!(matches!(
850            unsafe { FrameSyncAudioRef::new(guard, false) },
851            Err(Error::InvalidUtf8(_))
852        ));
853    }
854}