grafton_ndi/sender.rs
1//! NDI sending functionality for video, audio, and metadata.
2
3#[cfg(target_os = "windows")]
4use std::sync::Mutex;
5use std::{
6 ffi::{CStr, CString},
7 fmt, ptr,
8 sync::{Arc, OnceLock},
9 time::Duration,
10};
11
12#[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
13use std::os::raw::c_void;
14
15#[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
16use std::sync::atomic::{AtomicPtr, Ordering};
17
18#[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
19use crate::waitable_completion::WaitableCompletion;
20
21use crate::{
22 finder::Source,
23 frames::{
24 AudioFrame, LineStrideOrSize, MetadataFrame, PixelFormat, ScanType, ValidatedVideoLayout,
25 VideoFrame,
26 },
27 ndi_lib::*,
28 receiver::Tally,
29 to_ms_checked, Error, Result, NDI,
30};
31
32#[cfg(not(target_has_atomic = "ptr"))]
33compile_error!(
34 "This crate requires atomic pointer support. Please use a target with atomics enabled."
35);
36
37#[cfg(target_os = "windows")]
38static FLUSH_MUTEX: Mutex<()> = Mutex::new(());
39
40/// Flush the async video pipeline by passing a true NULL pointer.
41///
42/// The NDI SDK documentation specifies that calling
43/// `NDIlib_send_send_video_async_v2(instance, NULL)` (where the *frame pointer*
44/// is NULL, not merely a frame whose `p_data` is NULL) waits for any in-flight
45/// async buffer to be released and then returns.
46fn async_flush_frame_ptr() -> *const NDIlib_video_frame_v2_t {
47 ptr::null()
48}
49
50fn flush_null_frame(instance: NDIlib_send_instance_t) {
51 #[cfg(target_os = "windows")]
52 {
53 let _lock = FLUSH_MUTEX
54 .lock()
55 .unwrap_or_else(|poisoned| poisoned.into_inner());
56 unsafe {
57 NDIlib_send_send_video_async_v2(instance, async_flush_frame_ptr());
58 }
59 }
60
61 #[cfg(not(target_os = "windows"))]
62 unsafe {
63 NDIlib_send_send_video_async_v2(instance, async_flush_frame_ptr());
64 }
65}
66
67/// Internal state that is reference-counted and shared between SendInstance and tokens
68struct Inner {
69 instance: NDIlib_send_instance_t,
70 _name: CString,
71 _groups: Option<CString>,
72 async_state: AsyncState,
73 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
74 callback_ptr: AtomicPtr<c_void>,
75}
76
77#[derive(Debug)]
78pub struct Sender {
79 inner: Arc<Inner>,
80 _ndi: NDI,
81}
82
83type AsyncCallback = Box<dyn Fn(usize) + Send + Sync>;
84
85/// Async completion state for video frames
86struct AsyncState {
87 video_callback: OnceLock<AsyncCallback>,
88
89 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
90 completion: WaitableCompletion,
91}
92
93impl fmt::Debug for AsyncState {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 let mut dbg = f.debug_struct("AsyncState");
96 dbg.field("video_callback_set", &self.video_callback.get().is_some());
97
98 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
99 dbg.field("completed", &self.completion.is_complete());
100
101 dbg.finish()
102 }
103}
104
105impl fmt::Debug for Inner {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 let mut dbg = f.debug_struct("Inner");
108 dbg.field("instance", &self.instance)
109 .field("async_state", &self.async_state);
110
111 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
112 dbg.field("callback_ptr", &self.callback_ptr);
113
114 dbg.finish()
115 }
116}
117
118impl Default for AsyncState {
119 fn default() -> Self {
120 Self {
121 video_callback: OnceLock::new(),
122
123 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
124 completion: WaitableCompletion::new_completed(),
125 }
126 }
127}
128
129// SAFETY: All fields are thread-safe atomics or OnceLock
130unsafe impl Send for AsyncState {}
131unsafe impl Sync for AsyncState {}
132
133// SAFETY: Inner contains an NDI instance pointer which is thread-safe,
134// owned CStrings (Send+Sync), and thread-safe atomics.
135unsafe impl Send for Inner {}
136unsafe impl Sync for Inner {}
137
138/// A borrowed video frame that references external pixel data.
139/// Used for zero-copy async send operations.
140///
141/// Fields are private to enforce safety invariants. Use
142/// `try_from_uncompressed` for supported typed formats or
143/// `from_parts_unchecked` as the explicit unsafe escape hatch for unsupported
144/// SDK layouts.
145pub struct BorrowedVideoFrame<'buf> {
146 pub(crate) width: i32,
147 pub(crate) height: i32,
148 pub(crate) fourcc: u32,
149 pub(crate) pixel_format: Option<PixelFormat>,
150 pub(crate) frame_rate_n: i32,
151 pub(crate) frame_rate_d: i32,
152 pub(crate) picture_aspect_ratio: f32,
153 pub(crate) scan_type: ScanType,
154 pub(crate) timecode: i64,
155 pub(crate) data: &'buf [u8],
156 pub(crate) line_stride_or_size: LineStrideOrSize,
157 pub(crate) layout: Option<ValidatedVideoLayout>,
158 pub(crate) metadata: Option<&'buf CStr>,
159 pub(crate) timestamp: i64,
160}
161
162impl<'buf> BorrowedVideoFrame<'buf> {
163 /// Create a borrowed video frame from an uncompressed pixel buffer.
164 ///
165 /// This constructor validates that the buffer is large enough for the specified
166 /// dimensions and pixel format, returning an error if validation fails.
167 ///
168 /// # Arguments
169 ///
170 /// * `data` - Borrowed slice containing pixel data
171 /// * `width` - Frame width in pixels
172 /// * `height` - Frame height in pixels
173 /// * `pixel_format` - Uncompressed pixel format (BGRA, UYVY, etc.)
174 /// * `frame_rate_n` - Frame rate numerator (e.g., 60 for 60fps, 30000 for 29.97fps)
175 /// * `frame_rate_d` - Frame rate denominator (e.g., 1 for 60fps, 1001 for 29.97fps)
176 ///
177 /// # Errors
178 ///
179 /// Returns `Error::InvalidFrame` if the buffer is too small for the specified format.
180 ///
181 /// # Example
182 ///
183 /// ```no_run
184 /// # use grafton_ndi::{BorrowedVideoFrame, PixelFormat};
185 /// # fn main() -> Result<(), grafton_ndi::Error> {
186 /// let buffer = vec![0u8; 1920 * 1080 * 4]; // BGRA buffer
187 /// let frame = BorrowedVideoFrame::try_from_uncompressed(
188 /// &buffer,
189 /// 1920,
190 /// 1080,
191 /// PixelFormat::BGRA,
192 /// 30,
193 /// 1
194 /// )?;
195 /// # Ok(())
196 /// # }
197 /// ```
198 pub fn try_from_uncompressed(
199 data: &'buf [u8],
200 width: i32,
201 height: i32,
202 pixel_format: PixelFormat,
203 frame_rate_n: i32,
204 frame_rate_d: i32,
205 ) -> Result<Self> {
206 crate::frames::validate_video_frame_metadata(frame_rate_n, frame_rate_d, 16.0 / 9.0)?;
207 let layout = ValidatedVideoLayout::new_uncompressed(pixel_format, width, height, None)?;
208 let expected_len = layout.data_len_bytes;
209
210 if data.len() < expected_len {
211 return Err(Error::InvalidFrame(format!(
212 "Buffer too small for format {pixel_format:?}: got {actual} bytes, expected at least {expected_len} bytes \
213 (width={width}, height={height}, stride={stride:?})",
214 stride = layout.line_stride_or_size,
215 actual = data.len()
216 )));
217 }
218
219 Ok(BorrowedVideoFrame {
220 width,
221 height,
222 fourcc: pixel_format.into(),
223 pixel_format: Some(pixel_format),
224 frame_rate_n,
225 frame_rate_d,
226 picture_aspect_ratio: 16.0 / 9.0,
227 scan_type: ScanType::Progressive,
228 timecode: 0,
229 data,
230 line_stride_or_size: layout.line_stride_or_size,
231 layout: Some(layout),
232 metadata: None,
233 timestamp: 0,
234 })
235 }
236
237 /// Create a borrowed video frame without validation (unsafe).
238 ///
239 /// # Safety
240 ///
241 /// The caller must ensure all SDK-facing fields describe a valid frame:
242 /// - `fourcc` is a correct NDI video FourCC for the payload.
243 /// - The FourCC and `line_stride_or_size` union field are paired according
244 /// to the SDK contract: uncompressed formats use
245 /// [`LineStrideOrSize::LineStrideBytes`], compressed or opaque formats use
246 /// [`LineStrideOrSize::DataSizeBytes`].
247 /// - Dimensions are positive where required by the SDK format, and any
248 /// planar format dimension or stride requirements are satisfied.
249 /// - Line stride or data size is positive, fits the SDK field, and is
250 /// sufficient for every byte the SDK may read.
251 /// - `data` is live and large enough for the final layout for the full send
252 /// lifetime.
253 /// - `frame_rate_n` and `frame_rate_d` are positive, `picture_aspect_ratio`
254 /// is finite and positive, and `scan_type` matches a supported SDK scan
255 /// type.
256 /// - `metadata`, when present, remains valid, NUL-terminated, UTF-8, and
257 /// within the crate metadata size cap for the full send lifetime.
258 ///
259 /// Zero/default `timecode` and `timestamp` values are passed through to the
260 /// SDK as default timing values.
261 ///
262 /// Violating these invariants will cause the NDI SDK to read out of bounds through FFI,
263 /// leading to undefined behavior.
264 ///
265 /// # Example
266 ///
267 /// ```no_run
268 /// # use grafton_ndi::{BorrowedVideoFrame, PixelFormat, LineStrideOrSize};
269 /// let buffer = vec![0u8; 1920 * 1080 * 4];
270 /// let stride = PixelFormat::BGRA.try_line_stride(1920).unwrap();
271 ///
272 /// // SAFETY: Buffer is correctly sized for 1920x1080 BGRA
273 /// let frame = unsafe {
274 /// BorrowedVideoFrame::from_parts_unchecked(
275 /// &buffer,
276 /// 1920,
277 /// 1080,
278 /// PixelFormat::BGRA.into(),
279 /// 30,
280 /// 1,
281 /// 16.0 / 9.0,
282 /// grafton_ndi::ScanType::Progressive,
283 /// 0,
284 /// LineStrideOrSize::LineStrideBytes(stride),
285 /// None,
286 /// 0,
287 /// )
288 /// };
289 /// ```
290 #[allow(clippy::too_many_arguments)]
291 pub unsafe fn from_parts_unchecked(
292 data: &'buf [u8],
293 width: i32,
294 height: i32,
295 fourcc: u32,
296 frame_rate_n: i32,
297 frame_rate_d: i32,
298 picture_aspect_ratio: f32,
299 scan_type: ScanType,
300 timecode: i64,
301 line_stride_or_size: LineStrideOrSize,
302 metadata: Option<&'buf CStr>,
303 timestamp: i64,
304 ) -> Self {
305 BorrowedVideoFrame {
306 width,
307 height,
308 fourcc,
309 pixel_format: PixelFormat::try_from(fourcc).ok(),
310 frame_rate_n,
311 frame_rate_d,
312 picture_aspect_ratio,
313 scan_type,
314 timecode,
315 data,
316 line_stride_or_size,
317 layout: None,
318 metadata,
319 timestamp,
320 }
321 }
322
323 /// Get the frame width in pixels.
324 pub fn width(&self) -> i32 {
325 self.width
326 }
327
328 /// Get the frame height in pixels.
329 pub fn height(&self) -> i32 {
330 self.height
331 }
332
333 /// Get the supported typed pixel format, if the raw FourCC is one of the
334 /// crate's supported uncompressed formats.
335 pub fn pixel_format(&self) -> Option<PixelFormat> {
336 self.pixel_format
337 }
338
339 /// Get the raw SDK FourCC value.
340 pub fn fourcc(&self) -> u32 {
341 self.fourcc
342 }
343
344 /// Get the frame rate numerator.
345 pub fn frame_rate_n(&self) -> i32 {
346 self.frame_rate_n
347 }
348
349 /// Get the frame rate denominator.
350 pub fn frame_rate_d(&self) -> i32 {
351 self.frame_rate_d
352 }
353
354 /// Get the picture aspect ratio.
355 pub fn picture_aspect_ratio(&self) -> f32 {
356 self.picture_aspect_ratio
357 }
358
359 /// Get the scan type.
360 pub fn scan_type(&self) -> ScanType {
361 self.scan_type
362 }
363
364 /// Get the timecode.
365 pub fn timecode(&self) -> i64 {
366 self.timecode
367 }
368
369 /// Get a reference to the pixel data.
370 pub fn data(&self) -> &[u8] {
371 self.data
372 }
373
374 /// Get the line stride or data size.
375 pub fn line_stride_or_size(&self) -> LineStrideOrSize {
376 self.line_stride_or_size
377 }
378
379 /// Get the validated SDK data length for frames built through the safe
380 /// typed constructor.
381 ///
382 /// Returns `None` for frames created with [`Self::from_parts_unchecked`],
383 /// because those layouts are intentionally outside the crate's supported
384 /// typed model.
385 pub fn validated_data_len(&self) -> Option<usize> {
386 self.layout.map(|layout| layout.data_len_bytes)
387 }
388
389 /// Get the metadata as UTF-8 text, if any.
390 pub fn metadata(&self) -> Option<&str> {
391 self.metadata.map(|metadata| {
392 metadata
393 .to_str()
394 .expect("from_parts_unchecked requires UTF-8 metadata")
395 })
396 }
397
398 /// Get the timestamp.
399 pub fn timestamp(&self) -> i64 {
400 self.timestamp
401 }
402
403 fn to_raw(&self) -> NDIlib_video_frame_v2_t {
404 // Validation is now performed at construction time, so no runtime checks needed here
405 NDIlib_video_frame_v2_t {
406 xres: self.width,
407 yres: self.height,
408 FourCC: self.fourcc as NDIlib_FourCC_video_type_e,
409 frame_rate_N: self.frame_rate_n,
410 frame_rate_D: self.frame_rate_d,
411 picture_aspect_ratio: self.picture_aspect_ratio,
412 frame_format_type: self.scan_type.into(),
413 timecode: self.timecode,
414 p_data: self.data.as_ptr() as *mut u8,
415 __bindgen_anon_1: self.line_stride_or_size.into(),
416 p_metadata: self.metadata.map_or(ptr::null(), |m| m.as_ptr()),
417 timestamp: self.timestamp,
418 }
419 }
420}
421
422impl<'buf> From<&'buf VideoFrame> for BorrowedVideoFrame<'buf> {
423 fn from(frame: &'buf VideoFrame) -> Self {
424 let layout = frame.validated_layout();
425 BorrowedVideoFrame {
426 width: frame.width(),
427 height: frame.height(),
428 fourcc: frame.pixel_format().into(),
429 pixel_format: Some(frame.pixel_format()),
430 frame_rate_n: frame.frame_rate_n(),
431 frame_rate_d: frame.frame_rate_d(),
432 picture_aspect_ratio: frame.picture_aspect_ratio(),
433 scan_type: frame.scan_type(),
434 timecode: frame.timecode(),
435 data: frame.data(),
436 line_stride_or_size: layout.line_stride_or_size,
437 layout: Some(layout),
438 metadata: frame.metadata_cstr(),
439 timestamp: frame.timestamp(),
440 }
441 }
442}
443
444/// A token that tracks an async video send operation.
445///
446/// The token holds exclusive access to the sender and a borrow of the frame buffer,
447/// ensuring memory safety at compile time. Only one async send can be in-flight
448/// at a time in the non-advanced SDK build.
449///
450/// When the token is dropped, a flush is automatically performed to ensure the
451/// NDI SDK releases the buffer before the token's borrows expire.
452#[must_use = "AsyncVideoToken must be held to track the async operation"]
453pub struct AsyncVideoToken<'a, 'buf> {
454 inner: &'a Arc<Inner>,
455 _buffer: &'buf [u8],
456 _metadata: Option<&'buf CStr>,
457}
458
459impl Drop for AsyncVideoToken<'_, '_> {
460 fn drop(&mut self) {
461 // Callback-capable: try signal-based completion, fall back to null-frame flush
462 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
463 {
464 if self
465 .inner
466 .async_state
467 .completion
468 .wait_timeout(Duration::from_secs(5))
469 .is_err()
470 {
471 flush_null_frame(self.inner.instance);
472 }
473 }
474
475 // Non-callback: always null-frame flush (guaranteed completion)
476 #[cfg(not(all(feature = "advanced_sdk", has_async_completion_callback)))]
477 {
478 flush_null_frame(self.inner.instance);
479 }
480
481 // User callback: exactly once, after completion is guaranteed
482 if let Some(callback) = self.inner.async_state.video_callback.get() {
483 callback(self._buffer.len());
484 }
485 }
486}
487
488impl<'a, 'buf> AsyncVideoToken<'a, 'buf> {
489 /// Explicitly wait for the async video operation to complete.
490 ///
491 /// This method provides an explicit way to wait for completion instead of relying on `Drop`.
492 /// It consumes the token, ensuring the buffer is safe to reuse after this call returns.
493 ///
494 /// # Behavior by SDK Version
495 ///
496 /// - **Standard SDK**: Sends a NULL frame to flush the pipeline, blocking until all pending
497 /// async video operations complete. This is the same behavior as dropping the token.
498 /// - **Advanced SDK** (with `advanced_sdk` and `has_async_completion_callback`): Waits for the
499 /// SDK completion callback to signal that the buffer has been released, with a 5-second
500 /// timeout. Falls back to null-frame flush if the timeout elapses.
501 ///
502 /// # Errors
503 ///
504 /// Currently always returns `Ok(())`. The `Result` return type is preserved for forward
505 /// compatibility.
506 ///
507 /// # Examples
508 ///
509 /// ```no_run
510 /// # use grafton_ndi::{NDI, SenderOptions, PixelFormat, BorrowedVideoFrame};
511 /// # use std::time::Duration;
512 /// # fn main() -> Result<(), grafton_ndi::Error> {
513 /// let ndi = NDI::new()?;
514 /// let options = SenderOptions::builder("Test Sender").build();
515 /// let mut sender = grafton_ndi::Sender::new(&ndi, &options)?;
516 ///
517 /// let mut buffer = vec![0u8; 1920 * 1080 * 4];
518 /// let borrowed_frame = BorrowedVideoFrame::try_from_uncompressed(&buffer, 1920, 1080, PixelFormat::BGRA, 30, 1)?;
519 /// let token = sender.send_video_async(&borrowed_frame);
520 ///
521 /// // Explicitly wait for completion instead of relying on Drop
522 /// token.wait()?;
523 ///
524 /// // Now safe to reuse or drop the buffer
525 /// buffer.clear();
526 /// # Ok(())
527 /// # }
528 /// ```
529 pub fn wait(self) -> Result<()> {
530 drop(self);
531 Ok(())
532 }
533
534 /// Check if the async video operation has completed (advanced SDK with callback support only).
535 ///
536 /// This method is only available when the `advanced_sdk` feature is enabled and the SDK
537 /// provides async completion callbacks (`has_async_completion_callback` cfg).
538 ///
539 /// # Returns
540 ///
541 /// `true` if the NDI SDK has called the completion callback, indicating the buffer is no longer
542 /// in use. `false` if the operation is still pending.
543 ///
544 /// # Examples
545 ///
546 /// ```no_run
547 /// # #[cfg(feature = "advanced_sdk")]
548 /// # {
549 /// # use grafton_ndi::{NDI, SenderOptions, PixelFormat, BorrowedVideoFrame};
550 /// # fn main() -> Result<(), grafton_ndi::Error> {
551 /// let ndi = NDI::new()?;
552 /// let options = SenderOptions::builder("Test Sender").build();
553 /// let mut sender = grafton_ndi::Sender::new(&ndi, &options)?;
554 ///
555 /// let mut buffer = vec![0u8; 1920 * 1080 * 4];
556 /// let borrowed_frame = BorrowedVideoFrame::try_from_uncompressed(&buffer, 1920, 1080, PixelFormat::BGRA, 30, 1)?;
557 /// let token = sender.send_video_async(&borrowed_frame);
558 ///
559 /// // Poll for completion
560 /// while !token.is_complete() {
561 /// std::thread::sleep(std::time::Duration::from_millis(1));
562 /// }
563 /// # Ok(())
564 /// # }
565 /// # }
566 /// ```
567 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
568 pub fn is_complete(&self) -> bool {
569 self.inner.async_state.completion.is_complete()
570 }
571}
572
573impl Sender {
574 /// Creates a new NDI send instance.
575 ///
576 /// # Errors
577 ///
578 /// Returns an error if:
579 /// - The sender name is empty or contains only whitespace
580 /// - Both `clock_video` and `clock_audio` are false (at least one must be true)
581 /// - The sender name contains a null byte
582 /// - The groups string contains a null byte
583 /// - NDI fails to create the send instance
584 pub fn new(ndi: &NDI, create_settings: &SenderOptions) -> Result<Self> {
585 // Validate sender name
586 if create_settings.name.trim().is_empty() {
587 return Err(Error::InvalidConfiguration(
588 "Sender name cannot be empty or contain only whitespace".into(),
589 ));
590 }
591
592 // Validate that at least one clock is enabled
593 if !create_settings.clock_video && !create_settings.clock_audio {
594 return Err(Error::InvalidConfiguration(
595 "At least one of clock_video or clock_audio must be true".into(),
596 ));
597 }
598
599 let name_cstr =
600 CString::new(create_settings.name.clone()).map_err(Error::InvalidCString)?;
601 let groups_cstr = match &create_settings.groups {
602 Some(groups) => Some(CString::new(groups.clone()).map_err(Error::InvalidCString)?),
603 None => None,
604 };
605
606 let c_settings = NDIlib_send_create_t {
607 p_ndi_name: name_cstr.as_ptr(),
608 p_groups: groups_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
609 clock_video: create_settings.clock_video,
610 clock_audio: create_settings.clock_audio,
611 };
612
613 let instance = unsafe { NDIlib_send_create(&c_settings) };
614 if instance.is_null() {
615 Err(Error::InitializationFailed(
616 "Failed to create NDI send instance".into(),
617 ))
618 } else {
619 let inner = Arc::new(Inner {
620 instance,
621 _name: name_cstr,
622 _groups: groups_cstr,
623 async_state: AsyncState::default(),
624 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
625 callback_ptr: AtomicPtr::new(ptr::null_mut()),
626 });
627
628 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
629 {
630 // Store a non-owning pointer for the callback (no refcount increment)
631 // SAFETY: The pointer remains valid as long as the Arc<Inner> exists,
632 // which is guaranteed by our design: the callback is unregistered in Inner::drop
633 // before the last Arc reference is dropped.
634 let raw_inner = Arc::as_ptr(&inner) as *mut c_void;
635 inner.callback_ptr.store(raw_inner, Ordering::Release);
636
637 extern "C" fn video_done_cb(
638 opaque: *mut c_void,
639 _frame: *const NDIlib_video_frame_v2_t,
640 ) {
641 // SAFETY: opaque is a non-owning pointer to Inner, created via Arc::as_ptr.
642 // The pointer remains valid because:
643 // 1. Inner::drop flushes in-flight frames before unregistering the callback
644 // 2. The Arc<Inner> is kept alive by the Sender that registered this callback
645 let inner: &Inner = unsafe { &*(opaque as *const Inner) };
646 inner.async_state.completion.signal();
647 }
648
649 unsafe {
650 NDIlib_send_set_video_async_completion(
651 instance,
652 raw_inner,
653 Some(video_done_cb),
654 );
655 }
656 }
657
658 Ok(Self {
659 inner,
660 _ndi: ndi.clone(),
661 })
662 }
663 }
664
665 /// Send a video frame **synchronously** (NDI copies the buffer immediately).
666 pub fn send_video(&self, video_frame: &VideoFrame) {
667 unsafe {
668 NDIlib_send_send_video_v2(self.inner.instance, &video_frame.to_raw());
669 }
670 }
671
672 /// Send a video frame asynchronously with zero-copy.
673 ///
674 /// Uses `NDIlib_send_send_video_async_v2` for zero-copy transmission.
675 ///
676 /// **IMPORTANT**: This method requires a mutable borrow of the sender, which
677 /// enforces single-flight semantics at compile time. Only one async send can
678 /// be in-flight at a time.
679 ///
680 /// Returns an `AsyncVideoToken` that holds borrows of both the sender and the
681 /// frame buffer. The token must be kept alive until the frame has been transmitted.
682 /// When the token is dropped, a flush is automatically performed to ensure the
683 /// NDI SDK releases the buffer.
684 ///
685 /// # Type Safety
686 ///
687 /// The returned token holds:
688 /// - A borrow of the sender (preventing multiple concurrent async sends)
689 /// - A borrow of the frame buffer (preventing the buffer from being dropped)
690 ///
691 /// This ensures memory safety at compile time without runtime overhead.
692 ///
693 /// # Example
694 /// ```no_run
695 /// # use grafton_ndi::{NDI, SenderOptions, VideoFrame, BorrowedVideoFrame, PixelFormat};
696 /// # fn main() -> Result<(), grafton_ndi::Error> {
697 /// let ndi = NDI::new()?;
698 /// let send_options = SenderOptions::builder("MyCam")
699 /// .clock_video(true)
700 /// .clock_audio(true)
701 /// .build();
702 /// let mut sender = grafton_ndi::Sender::new(&ndi, &send_options)?;
703 ///
704 /// // Register callback to know when buffer is released
705 /// sender.on_async_video_done(|len| println!("Buffer released: {len} bytes"));
706 ///
707 /// // Use borrowed buffer directly (zero-copy, no allocation)
708 /// let mut buffer = vec![0u8; 1920 * 1080 * 4];
709 /// let borrowed_frame = BorrowedVideoFrame::try_from_uncompressed(&buffer, 1920, 1080, PixelFormat::BGRA, 30, 1)?;
710 /// let token = sender.send_video_async(&borrowed_frame);
711 ///
712 /// // Buffer is owned by SDK until token is dropped
713 /// drop(token); // This triggers automatic flush
714 /// // Now safe to reuse buffer
715 ///
716 /// # Ok(())
717 /// # }
718 /// ```
719 pub fn send_video_async<'b>(
720 &'b mut self,
721 video_frame: &BorrowedVideoFrame<'b>,
722 ) -> AsyncVideoToken<'b, 'b> {
723 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
724 {
725 self.inner.async_state.completion.reset();
726 }
727
728 unsafe {
729 NDIlib_send_send_video_async_v2(self.inner.instance, &video_frame.to_raw());
730 }
731
732 AsyncVideoToken {
733 inner: &self.inner,
734 _buffer: video_frame.data,
735 _metadata: video_frame.metadata,
736 }
737 }
738
739 /// Sends an audio frame synchronously.
740 ///
741 /// This function copies the audio data immediately and returns, making the buffer
742 /// available for reuse. The underlying NDI SDK function `NDIlib_send_send_audio_v3`
743 /// performs a synchronous copy of the data.
744 ///
745 /// See the NDI SDK documentation section on `NDIlib_send_send_audio_v3` for more details.
746 ///
747 /// # Example
748 ///
749 /// ```no_run
750 /// # use grafton_ndi::{NDI, SenderOptions, AudioFrame};
751 /// # fn main() -> Result<(), grafton_ndi::Error> {
752 /// # let ndi = NDI::new()?;
753 /// # let options = SenderOptions::builder("Test").build();
754 /// # let sender = grafton_ndi::Sender::new(&ndi, &options)?;
755 /// let mut audio_buffer = vec![0.0f32; 48000 * 2]; // 1 second of stereo audio
756 ///
757 /// // Fill buffer with audio data...
758 /// let frame = AudioFrame::builder()
759 /// .sample_rate(48000)
760 /// .channels(2)
761 /// .samples(48000)
762 /// .data(audio_buffer.clone())
763 /// .build()?;
764 /// sender.send_audio(&frame);
765 ///
766 /// // Buffer can be reused immediately
767 /// audio_buffer.fill(0.5);
768 /// let frame2 = AudioFrame::builder()
769 /// .sample_rate(48000)
770 /// .channels(2)
771 /// .samples(48000)
772 /// .data(audio_buffer)
773 /// .build()?;
774 /// sender.send_audio(&frame2);
775 /// # Ok(())
776 /// # }
777 /// ```
778 pub fn send_audio(&self, audio_frame: &AudioFrame) {
779 unsafe {
780 NDIlib_send_send_audio_v3(self.inner.instance, &audio_frame.to_raw());
781 }
782 }
783
784 /// Sends a metadata frame.
785 ///
786 /// # Errors
787 ///
788 /// Returns an error if the metadata contains an interior NUL byte, exceeds
789 /// the metadata size limit, or cannot fit the SDK length field.
790 pub fn send_metadata(&self, metadata_frame: &MetadataFrame) -> Result<()> {
791 let (_c_data, raw) = metadata_frame.to_raw()?;
792 unsafe {
793 NDIlib_send_send_metadata(self.inner.instance, &raw);
794 }
795 Ok(())
796 }
797
798 /// Get the current tally state for this sender.
799 ///
800 /// # Arguments
801 ///
802 /// * `timeout` - Maximum time to wait for tally information.
803 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
804 ///
805 /// # Returns
806 ///
807 /// `Ok(Some(tally))` if tally was successfully retrieved, `Ok(None)` on timeout.
808 ///
809 /// # Errors
810 ///
811 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
812 ///
813 /// # Examples
814 ///
815 /// ```no_run
816 /// # use grafton_ndi::{NDI, SenderOptions};
817 /// # use std::time::Duration;
818 /// # fn main() -> Result<(), grafton_ndi::Error> {
819 /// let ndi = NDI::new()?;
820 /// let options = SenderOptions::builder("Test Sender").build();
821 /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
822 ///
823 /// // Try to get tally with 1 second timeout
824 /// if let Some(tally) = sender.tally(Duration::from_secs(1))? {
825 /// println!("On program: {}, On preview: {}", tally.on_program, tally.on_preview);
826 /// } else {
827 /// println!("Tally request timed out");
828 /// }
829 /// # Ok(())
830 /// # }
831 /// ```
832 pub fn tally(&self, timeout: Duration) -> Result<Option<Tally>> {
833 let timeout_ms = to_ms_checked(timeout)?;
834 let mut raw_tally = Tally::new(false, false).to_raw();
835 let success =
836 unsafe { NDIlib_send_get_tally(self.inner.instance, &mut raw_tally, timeout_ms) };
837
838 if success {
839 Ok(Some(Tally {
840 on_program: raw_tally.on_program,
841 on_preview: raw_tally.on_preview,
842 }))
843 } else {
844 Ok(None)
845 }
846 }
847
848 /// Get the number of active connections to this sender.
849 ///
850 /// # Arguments
851 ///
852 /// * `timeout` - Maximum time to wait for connection count.
853 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
854 ///
855 /// # Returns
856 ///
857 /// Number of active connections as a `u32`.
858 ///
859 /// # Errors
860 ///
861 /// Returns [`Error::Timeout`] if the SDK returns a negative value (indicating timeout or error).
862 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
863 ///
864 /// # Examples
865 ///
866 /// ```no_run
867 /// # use grafton_ndi::{NDI, SenderOptions};
868 /// # use std::time::Duration;
869 /// # fn main() -> Result<(), grafton_ndi::Error> {
870 /// let ndi = NDI::new()?;
871 /// let options = SenderOptions::builder("Test Sender").build();
872 /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
873 ///
874 /// // Get connection count with 1 second timeout
875 /// let count = sender.connection_count(Duration::from_secs(1))?;
876 /// println!("Active connections: {}", count);
877 /// # Ok(())
878 /// # }
879 /// ```
880 pub fn connection_count(&self, timeout: Duration) -> Result<u32> {
881 let timeout_ms = to_ms_checked(timeout)?;
882 let count = unsafe { NDIlib_send_get_no_connections(self.inner.instance, timeout_ms) };
883
884 if count < 0 {
885 Err(Error::Timeout("Failed to obtain connection count".into()))
886 } else {
887 Ok(count as u32)
888 }
889 }
890
891 pub fn clear_connection_metadata(&self) {
892 unsafe { NDIlib_send_clear_connection_metadata(self.inner.instance) }
893 }
894
895 /// Adds connection metadata.
896 ///
897 /// # Errors
898 ///
899 /// Returns an error if the metadata contains an interior NUL byte, exceeds
900 /// the metadata size limit, or cannot fit the SDK length field.
901 pub fn add_connection_metadata(&self, metadata_frame: &MetadataFrame) -> Result<()> {
902 let (_c_data, raw) = metadata_frame.to_raw()?;
903 unsafe { NDIlib_send_add_connection_metadata(self.inner.instance, &raw) }
904 Ok(())
905 }
906
907 /// Sets failover source.
908 ///
909 /// # Errors
910 ///
911 /// Returns an error if source conversion fails.
912 pub fn set_failover(&self, source: &Source) -> Result<()> {
913 let raw_source = source.to_raw()?;
914 unsafe { NDIlib_send_set_failover(self.inner.instance, &raw_source.raw) }
915 Ok(())
916 }
917
918 /// Get the source information for this sender.
919 ///
920 /// # Errors
921 ///
922 /// Returns `Error::NullPointer` if the NDI SDK returns a null pointer or
923 /// if the source data contains null pointers.
924 ///
925 /// # Examples
926 ///
927 /// ```no_run
928 /// # use grafton_ndi::{NDI, SenderOptions};
929 /// # fn main() -> Result<(), grafton_ndi::Error> {
930 /// let ndi = NDI::new()?;
931 /// let options = SenderOptions::builder("Test Sender").build();
932 /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
933 /// let source = sender.source()?;
934 /// println!("Sender source: {source}");
935 /// # Ok(())
936 /// # }
937 /// ```
938 pub fn source(&self) -> Result<Source> {
939 let source_ptr = unsafe { NDIlib_send_get_source_name(self.inner.instance) };
940 Source::try_from_raw(source_ptr)
941 }
942
943 /// Register a handler that will be called once the SDK has released
944 /// the last buffer passed to `send_video_async`.
945 /// The callback receives the buffer length.
946 ///
947 /// **Note**: Due to the use of `OnceLock`, this callback can only be set once.
948 /// Subsequent calls to this method will be silently ignored.
949 pub fn on_async_video_done<F>(&self, handler: F)
950 where
951 F: Fn(usize) + Send + Sync + 'static,
952 {
953 let _ = self.inner.async_state.video_callback.set(Box::new(handler));
954 }
955
956 /// Flush pending async video operations synchronously.
957 ///
958 /// Sends a true NULL video frame pointer to the SDK, blocking until all
959 /// pending async video operations are complete.
960 ///
961 /// `AsyncVideoToken::drop` and [`AsyncVideoToken::wait`] already perform
962 /// this drain for ordinary safe `send_video_async` calls. Prefer waiting the
963 /// token when you hold one; use this method when you need an explicit
964 /// sender-level drain.
965 ///
966 /// # Buffer Lifetime
967 ///
968 /// After this function returns, all previously sent async video buffers
969 /// can be safely reused or freed.
970 ///
971 /// # Example
972 ///
973 /// ```no_run
974 /// # use grafton_ndi::{NDI, SenderOptions, BorrowedVideoFrame, PixelFormat};
975 /// # fn main() -> Result<(), grafton_ndi::Error> {
976 /// let ndi = NDI::new()?;
977 /// let options = SenderOptions::builder("Test").build();
978 /// let mut sender = grafton_ndi::Sender::new(&ndi, &options)?;
979 ///
980 /// let mut buffer = vec![0u8; 1920 * 1080 * 4];
981 /// let frame = BorrowedVideoFrame::try_from_uncompressed(&buffer, 1920, 1080, PixelFormat::BGRA, 30, 1)?;
982 /// let token = sender.send_video_async(&frame);
983 ///
984 /// // Prefer waiting the token for ordinary borrowed async sends.
985 /// token.wait()?;
986 ///
987 /// // Buffer can now be safely reused
988 /// buffer.fill(0);
989 /// # Ok(())
990 /// # }
991 /// ```
992 pub fn flush_async_blocking(&self) {
993 flush_null_frame(self.inner.instance);
994 }
995
996 /// Wait for pending async operations with timeout.
997 ///
998 /// With `advanced_sdk`, this waits up to the specified timeout for the
999 /// in-flight frame's completion callback. Without `advanced_sdk`, this
1000 /// calls `flush_async_blocking` to drain pending operations.
1001 ///
1002 /// # Returns
1003 ///
1004 /// - `Ok(())` if the operation completed within the timeout
1005 /// - `Err(Error::Timeout)` if the timeout elapsed (advanced_sdk only)
1006 ///
1007 /// # Example
1008 ///
1009 /// ```no_run
1010 /// # use grafton_ndi::{NDI, SenderOptions};
1011 /// # use std::time::Duration;
1012 /// # fn main() -> Result<(), grafton_ndi::Error> {
1013 /// let ndi = NDI::new()?;
1014 /// let options = SenderOptions::builder("Test").build();
1015 /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
1016 ///
1017 /// // ... send some async frames ...
1018 ///
1019 /// // Wait with timeout for completion
1020 /// sender.flush_async(Duration::from_secs(1))?;
1021 /// # Ok(())
1022 /// # }
1023 /// ```
1024 pub fn flush_async(&self, timeout: Duration) -> Result<()> {
1025 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
1026 {
1027 self.inner
1028 .async_state
1029 .completion
1030 .wait_timeout(timeout)
1031 .map_err(Error::Timeout)
1032 }
1033
1034 #[cfg(not(all(feature = "advanced_sdk", has_async_completion_callback)))]
1035 {
1036 let _ = timeout;
1037 self.flush_async_blocking();
1038 Ok(())
1039 }
1040 }
1041}
1042
1043impl Drop for Inner {
1044 fn drop(&mut self) {
1045 // 1. Flush any in-flight async video (guaranteed completion)
1046 flush_null_frame(self.instance);
1047
1048 // 2. Now safe to unregister the callback (no in-flight frame can trigger it)
1049 #[cfg(all(feature = "advanced_sdk", has_async_completion_callback))]
1050 unsafe {
1051 NDIlib_send_set_video_async_completion(self.instance, ptr::null_mut(), None);
1052 }
1053
1054 // 3. Destroy the instance
1055 unsafe {
1056 NDIlib_send_destroy(self.instance);
1057 }
1058
1059 // _name (CString) and _groups (Option<CString>) are dropped automatically
1060 // after this point, which is correct: the CStrings must outlive the NDI
1061 // instance but can be freed once it is destroyed.
1062 }
1063}
1064
1065/// # Safety
1066///
1067/// The NDI 6 SDK documentation specifically marks these send functions as thread-safe:
1068/// - `NDIlib_send_send_video_v2` and `NDIlib_send_send_video_async_v2`
1069/// - `NDIlib_send_send_audio_v3` (no async variant exists)
1070/// - `NDIlib_send_send_metadata` (no async variant exists)
1071/// - `NDIlib_send_get_tally`
1072/// - `NDIlib_send_get_no_connections`
1073///
1074/// The Advanced SDK provides `NDIlib_send_set_video_async_completion` for registering
1075/// buffer-release callbacks (not available in the standard SDK).
1076///
1077/// `Inner` holds an opaque NDI pointer and owned CStrings that are automatically
1078/// freed in Drop, making it safe to move between threads.
1079///
1080/// Functions like `NDIlib_send_create` and `NDIlib_send_destroy` should be called
1081/// from a single thread.
1082unsafe impl Send for Sender {}
1083
1084/// # Safety
1085///
1086/// The NDI 6 SDK guarantees that multiple threads can safely call send methods
1087/// concurrently. The SDK uses internal synchronization for:
1088/// - Video sending (both sync and async)
1089/// - Audio sending (sync only)
1090/// - Metadata sending
1091/// - Status queries (tally, connections)
1092///
1093/// Note: Creation and destruction (`NDIlib_send_create`/`NDIlib_send_destroy`)
1094/// are handled in our Rust wrapper to ensure single-threaded access.
1095unsafe impl Sync for Sender {}
1096
1097#[derive(Debug)]
1098pub struct SenderOptions {
1099 pub name: String,
1100 pub groups: Option<String>,
1101 pub clock_video: bool,
1102 pub clock_audio: bool,
1103}
1104
1105impl SenderOptions {
1106 /// Create a builder for configuring send options
1107 pub fn builder<S: Into<String>>(name: S) -> SenderOptionsBuilder {
1108 SenderOptionsBuilder::new(name)
1109 }
1110}
1111
1112/// Builder for configuring `SendOptions` with ergonomic method chaining
1113#[derive(Debug, Clone)]
1114pub struct SenderOptionsBuilder {
1115 name: String,
1116 groups: Option<String>,
1117 clock_video: Option<bool>,
1118 clock_audio: Option<bool>,
1119}
1120
1121impl SenderOptionsBuilder {
1122 /// Create a new builder with the specified name
1123 pub fn new<S: Into<String>>(name: S) -> Self {
1124 Self {
1125 name: name.into(),
1126 groups: None,
1127 clock_video: None,
1128 clock_audio: None,
1129 }
1130 }
1131
1132 /// Set the groups for this sender
1133 #[must_use]
1134 pub fn groups<S: Into<String>>(mut self, groups: S) -> Self {
1135 self.groups = Some(groups.into());
1136 self
1137 }
1138
1139 /// Configure whether to clock video
1140 #[must_use]
1141 pub fn clock_video(mut self, clock: bool) -> Self {
1142 self.clock_video = Some(clock);
1143 self
1144 }
1145
1146 /// Configure whether to clock audio
1147 #[must_use]
1148 pub fn clock_audio(mut self, clock: bool) -> Self {
1149 self.clock_audio = Some(clock);
1150 self
1151 }
1152
1153 /// Build the sender options
1154 ///
1155 /// This method is infallible and simply applies defaults for any unset options.
1156 /// Validation is performed when creating a `Sender` via `Sender::new()`.
1157 ///
1158 /// # Example
1159 ///
1160 /// ```no_run
1161 /// # use grafton_ndi::{NDI, SenderOptions, Sender};
1162 /// # fn main() -> Result<(), grafton_ndi::Error> {
1163 /// # let ndi = NDI::new()?;
1164 /// let options = SenderOptions::builder("My Sender").build();
1165 /// let sender = Sender::new(&ndi, &options)?;
1166 /// # Ok(())
1167 /// # }
1168 /// ```
1169 pub fn build(self) -> SenderOptions {
1170 let clock_video = self.clock_video.unwrap_or(true);
1171 let clock_audio = self.clock_audio.unwrap_or(true);
1172
1173 SenderOptions {
1174 name: self.name,
1175 groups: self.groups,
1176 clock_video,
1177 clock_audio,
1178 }
1179 }
1180}
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 #[test]
1186 fn test_async_flush_uses_null_frame_pointer() {
1187 assert!(async_flush_frame_ptr().is_null());
1188 }
1189
1190 #[test]
1191 fn test_try_from_uncompressed_exact_size() {
1192 // BGRA format: 1920x1080x4 bytes
1193 let buffer = vec![0u8; 1920 * 1080 * 4];
1194 let result = BorrowedVideoFrame::try_from_uncompressed(
1195 &buffer,
1196 1920,
1197 1080,
1198 PixelFormat::BGRA,
1199 30,
1200 1,
1201 );
1202 assert!(result.is_ok());
1203 }
1204
1205 #[test]
1206 fn test_try_from_uncompressed_oversized_buffer() {
1207 // Buffer larger than needed should succeed
1208 let buffer = vec![0u8; 1920 * 1080 * 4 + 1000];
1209 let result = BorrowedVideoFrame::try_from_uncompressed(
1210 &buffer,
1211 1920,
1212 1080,
1213 PixelFormat::BGRA,
1214 30,
1215 1,
1216 );
1217 assert!(result.is_ok());
1218 }
1219
1220 #[test]
1221 fn test_try_from_uncompressed_undersized_buffer() {
1222 // Buffer too small should fail
1223 let buffer = vec![0u8; 1920 * 1080 * 4 - 1];
1224 let result = BorrowedVideoFrame::try_from_uncompressed(
1225 &buffer,
1226 1920,
1227 1080,
1228 PixelFormat::BGRA,
1229 30,
1230 1,
1231 );
1232 assert!(result.is_err());
1233 if let Err(Error::InvalidFrame(msg)) = result {
1234 assert!(msg.contains("Buffer too small"));
1235 assert!(msg.contains("BGRA"));
1236 }
1237 }
1238
1239 #[test]
1240 fn test_try_from_uncompressed_uyvy() {
1241 // UYVY format: 1920x1080x2 bytes
1242 let expected_size = 1920 * 1080 * 2;
1243 let buffer = vec![0u8; expected_size];
1244 let result = BorrowedVideoFrame::try_from_uncompressed(
1245 &buffer,
1246 1920,
1247 1080,
1248 PixelFormat::UYVY,
1249 60,
1250 1,
1251 );
1252 assert!(result.is_ok());
1253 }
1254
1255 #[test]
1256 fn test_try_from_uncompressed_nv12() {
1257 // NV12 planar format: Y plane + UV plane
1258 let width = 1920;
1259 let height = 1080;
1260 let expected_size = PixelFormat::NV12.try_buffer_size(width, height).unwrap();
1261 let buffer = vec![0u8; expected_size];
1262
1263 let result = BorrowedVideoFrame::try_from_uncompressed(
1264 &buffer,
1265 width,
1266 height,
1267 PixelFormat::NV12,
1268 30,
1269 1,
1270 );
1271 assert!(result.is_ok());
1272 }
1273
1274 #[test]
1275 fn test_try_from_uncompressed_i420() {
1276 // I420 planar format
1277 let width = 640;
1278 let height = 480;
1279 let expected_size = PixelFormat::I420.try_buffer_size(width, height).unwrap();
1280 let buffer = vec![0u8; expected_size];
1281
1282 let result = BorrowedVideoFrame::try_from_uncompressed(
1283 &buffer,
1284 width,
1285 height,
1286 PixelFormat::I420,
1287 30,
1288 1,
1289 );
1290 assert!(result.is_ok());
1291 }
1292
1293 #[test]
1294 fn test_try_from_uncompressed_rejects_invalid_layout() {
1295 let buffer = vec![0u8; 4096];
1296
1297 assert!(matches!(
1298 BorrowedVideoFrame::try_from_uncompressed(&buffer, 0, 480, PixelFormat::BGRA, 30, 1),
1299 Err(Error::InvalidFrame(_))
1300 ));
1301 assert!(matches!(
1302 BorrowedVideoFrame::try_from_uncompressed(&buffer, 640, -1, PixelFormat::BGRA, 30, 1),
1303 Err(Error::InvalidFrame(_))
1304 ));
1305 assert!(matches!(
1306 BorrowedVideoFrame::try_from_uncompressed(&buffer, 641, 480, PixelFormat::I420, 30, 1),
1307 Err(Error::InvalidFrame(_))
1308 ));
1309 assert!(matches!(
1310 BorrowedVideoFrame::try_from_uncompressed(&buffer, 640, 480, PixelFormat::BGRA, 0, 1),
1311 Err(Error::InvalidFrame(_))
1312 ));
1313 }
1314
1315 #[test]
1316 fn test_from_parts_unchecked() {
1317 let buffer = vec![0u8; 1920 * 1080 * 4];
1318 let stride = PixelFormat::BGRA.try_line_stride(1920).unwrap();
1319
1320 // SAFETY: Buffer is correctly sized for 1920x1080 BGRA
1321 let frame = unsafe {
1322 BorrowedVideoFrame::from_parts_unchecked(
1323 &buffer,
1324 1920,
1325 1080,
1326 PixelFormat::BGRA.into(),
1327 30,
1328 1,
1329 16.0 / 9.0,
1330 ScanType::Progressive,
1331 0,
1332 LineStrideOrSize::LineStrideBytes(stride),
1333 None,
1334 0,
1335 )
1336 };
1337
1338 assert_eq!(frame.width(), 1920);
1339 assert_eq!(frame.height(), 1080);
1340 assert_eq!(frame.pixel_format(), Some(PixelFormat::BGRA));
1341 }
1342
1343 #[test]
1344 fn test_from_parts_unchecked_allows_raw_fourcc_escape_hatch() {
1345 let buffer = vec![0u8; 16];
1346
1347 // SAFETY: This test does not send the frame to the SDK; it only verifies
1348 // that the unsafe escape hatch can carry an unsupported raw FourCC and
1349 // data-size union field without exposing it through a safe constructor.
1350 let frame = unsafe {
1351 BorrowedVideoFrame::from_parts_unchecked(
1352 &buffer,
1353 1,
1354 1,
1355 0x1234_5678,
1356 30,
1357 1,
1358 1.0,
1359 ScanType::Progressive,
1360 0,
1361 LineStrideOrSize::DataSizeBytes(buffer.len() as i32),
1362 None,
1363 0,
1364 )
1365 };
1366
1367 assert_eq!(frame.fourcc(), 0x1234_5678);
1368 assert_eq!(frame.pixel_format(), None);
1369 assert_eq!(
1370 frame.line_stride_or_size(),
1371 LineStrideOrSize::DataSizeBytes(buffer.len() as i32)
1372 );
1373 assert_eq!(frame.validated_data_len(), None);
1374 }
1375
1376 #[test]
1377 fn test_getters() {
1378 let buffer = vec![0u8; 1920 * 1080 * 4];
1379 let frame = BorrowedVideoFrame::try_from_uncompressed(
1380 &buffer,
1381 1920,
1382 1080,
1383 PixelFormat::BGRA,
1384 60,
1385 1,
1386 )
1387 .unwrap();
1388
1389 assert_eq!(frame.width(), 1920);
1390 assert_eq!(frame.height(), 1080);
1391 assert_eq!(frame.pixel_format(), Some(PixelFormat::BGRA));
1392 assert_eq!(frame.frame_rate_n(), 60);
1393 assert_eq!(frame.frame_rate_d(), 1);
1394 assert_eq!(frame.picture_aspect_ratio(), 16.0 / 9.0);
1395 assert_eq!(frame.scan_type(), ScanType::Progressive);
1396 assert_eq!(frame.timecode(), 0);
1397 assert_eq!(frame.data().len(), buffer.len());
1398 assert!(frame.metadata().is_none());
1399 assert_eq!(frame.timestamp(), 0);
1400 }
1401
1402 #[test]
1403 fn test_all_pixel_formats_validation() {
1404 // Test that validation works correctly for all pixel formats
1405 let test_cases = vec![
1406 (PixelFormat::BGRA, 1920, 1080, 1920 * 1080 * 4),
1407 (PixelFormat::RGBA, 1920, 1080, 1920 * 1080 * 4),
1408 (PixelFormat::BGRX, 1920, 1080, 1920 * 1080 * 4),
1409 (PixelFormat::RGBX, 1920, 1080, 1920 * 1080 * 4),
1410 (PixelFormat::UYVY, 1920, 1080, 1920 * 1080 * 2),
1411 (PixelFormat::UYVA, 1920, 1080, 1920 * 1080 * 3),
1412 (PixelFormat::P216, 1920, 1080, 1920 * 1080 * 4),
1413 (PixelFormat::PA16, 1920, 1080, 1920 * 1080 * 4),
1414 ];
1415
1416 for (format, width, height, expected_min_size) in test_cases {
1417 // Exact size should work
1418 let buffer = vec![0u8; expected_min_size];
1419 let result =
1420 BorrowedVideoFrame::try_from_uncompressed(&buffer, width, height, format, 30, 1);
1421 assert!(result.is_ok(), "Failed for format {:?}", format);
1422
1423 // One byte too small should fail
1424 if expected_min_size > 0 {
1425 let buffer = vec![0u8; expected_min_size - 1];
1426 let result = BorrowedVideoFrame::try_from_uncompressed(
1427 &buffer, width, height, format, 30, 1,
1428 );
1429 assert!(result.is_err(), "Should fail for undersized {:?}", format);
1430 }
1431 }
1432 }
1433
1434 #[test]
1435 fn test_planar_formats() {
1436 // Test planar 4:2:0 formats (NV12, I420, YV12)
1437 let width = 1920;
1438 let height = 1080;
1439
1440 for format in [PixelFormat::NV12, PixelFormat::I420, PixelFormat::YV12] {
1441 let expected_size = format.try_buffer_size(width, height).unwrap();
1442
1443 let buffer = vec![0u8; expected_size];
1444 let result =
1445 BorrowedVideoFrame::try_from_uncompressed(&buffer, width, height, format, 30, 1);
1446 assert!(result.is_ok(), "Failed for planar format {:?}", format);
1447
1448 // One byte too small should fail
1449 if expected_size > 0 {
1450 let buffer = vec![0u8; expected_size - 1];
1451 let result = BorrowedVideoFrame::try_from_uncompressed(
1452 &buffer, width, height, format, 30, 1,
1453 );
1454 assert!(result.is_err(), "Should fail for undersized {:?}", format);
1455 }
1456 }
1457 }
1458}