Skip to main content

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}