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}