Skip to main content

grafton_ndi/
sender.rs

1//! NDI sending functionality for video, audio, and metadata.
2
3use std::{
4    ffi::{CStr, CString},
5    fmt, ptr,
6    sync::Mutex,
7    time::Duration,
8};
9
10use crate::{
11    finder::Source,
12    frames::{
13        AudioFrame, LineStrideOrSize, MetadataFrame, PixelFormat, ScanType, ValidatedFrameMetadata,
14        ValidatedVideoLayout, VideoFrame,
15    },
16    ndi_lib::*,
17    receiver::Tally,
18    to_ms_checked, Error, Result, NDI,
19};
20
21#[cfg(not(target_has_atomic = "ptr"))]
22compile_error!(
23    "This crate requires atomic pointer support. Please use a target with atomics enabled."
24);
25
26#[cfg(target_os = "windows")]
27static FLUSH_MUTEX: Mutex<()> = Mutex::new(());
28
29/// Flush the async video pipeline by passing a true NULL pointer.
30///
31/// The NDI SDK documentation specifies that calling
32/// `NDIlib_send_send_video_async_v2(instance, NULL)` (where the *frame pointer*
33/// is NULL, not merely a frame whose `p_data` is NULL) waits for any in-flight
34/// async buffer to be released and then returns.
35fn async_flush_frame_ptr() -> *const NDIlib_video_frame_v2_t {
36    ptr::null()
37}
38
39fn flush_null_frame(instance: NDIlib_send_instance_t) {
40    #[cfg(target_os = "windows")]
41    {
42        let _lock = FLUSH_MUTEX
43            .lock()
44            .unwrap_or_else(|poisoned| poisoned.into_inner());
45        unsafe {
46            NDIlib_send_send_video_async_v2(instance, async_flush_frame_ptr());
47        }
48    }
49
50    #[cfg(not(target_os = "windows"))]
51    unsafe {
52        NDIlib_send_send_video_async_v2(instance, async_flush_frame_ptr());
53    }
54}
55
56/// SDK instance and the owned storage retained while it sends video.
57struct Inner {
58    instance: NDIlib_send_instance_t,
59    _name: CString,
60    _groups: Option<CString>,
61    video_callback: Option<AsyncCallback>,
62    // Dropped after Inner::drop drains the SDK.
63    video: Mutex<VideoState>,
64}
65
66#[derive(Debug)]
67pub struct Sender {
68    inner: Inner,
69    _ndi: NDI,
70}
71
72type AsyncCallback = Box<dyn Fn(usize) + Send + Sync>;
73
74#[derive(Default)]
75struct VideoState {
76    pending: Option<VideoFrame>,
77    in_flight: bool,
78}
79
80impl VideoState {
81    // Claim notification before invoking user code so panic/reentry cannot
82    // notify twice. The caller must first synchronize the SDK.
83    fn completed(&mut self) -> Option<usize> {
84        if std::mem::take(&mut self.in_flight) {
85            self.pending.as_ref().map(|frame| frame.data().len())
86        } else {
87            None
88        }
89    }
90}
91
92impl Inner {
93    fn notify(&self, len: Option<usize>) {
94        if let (Some(len), Some(callback)) = (len, self.video_callback.as_ref()) {
95            callback(len);
96        }
97    }
98
99    fn complete(&self) {
100        let len = {
101            let mut video = self.video.lock().unwrap_or_else(|e| e.into_inner());
102            if video.in_flight {
103                flush_null_frame(self.instance);
104            }
105            video.completed()
106        };
107        self.notify(len);
108    }
109}
110
111impl fmt::Debug for Inner {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.debug_struct("Inner")
114            .field("instance", &self.instance)
115            .field("video_callback_set", &self.video_callback.is_some())
116            .finish()
117    }
118}
119
120// SAFETY: SDK access is thread-safe; video ownership and synchronization are
121// serialized by the mutex. Other fields are owned strings and a Send + Sync callback.
122unsafe impl Send for Inner {}
123unsafe impl Sync for Inner {}
124
125/// A borrowed video frame that references external pixel data.
126/// [`Sender::send_video_async_copy`] copies its pixels and metadata into an
127/// owned frame; source buffers can be reused immediately after submission.
128///
129/// Fields are private to enforce safety invariants. Use
130/// [`Self::try_from_uncompressed`] for packed pixels, or [`Self::builder`] for
131/// explicit stride, metadata, and header options. Construction borrows without
132/// allocation and validates the entire supported uncompressed layout once.
133pub struct BorrowedVideoFrame<'buf> {
134    layout: ValidatedVideoLayout,
135    frame_rate_n: i32,
136    frame_rate_d: i32,
137    picture_aspect_ratio: f32,
138    scan_type: ScanType,
139    timecode: i64,
140    data: &'buf [u8],
141    metadata: Option<&'buf CStr>,
142    metadata_layout: ValidatedFrameMetadata,
143    timestamp: i64,
144}
145
146impl<'buf> BorrowedVideoFrame<'buf> {
147    /// Create a borrowed video frame from an uncompressed pixel buffer.
148    ///
149    /// This constructor validates that the buffer is large enough for the specified
150    /// dimensions and pixel format, returning an error if validation fails.
151    /// Width must be positive and even for every pixel format. Height must be
152    /// positive, and must also be even for 4:2:0 formats.
153    ///
154    /// # Arguments
155    ///
156    /// * `data` - Borrowed slice containing pixel data
157    /// * `width` - Frame width in pixels
158    /// * `height` - Frame height in pixels
159    /// * `pixel_format` - Uncompressed pixel format (BGRA, UYVY, etc.)
160    /// * `frame_rate_n` - Frame rate numerator (e.g., 60 for 60fps, 30000 for 29.97fps)
161    /// * `frame_rate_d` - Frame rate denominator (e.g., 1 for 60fps, 1001 for 29.97fps)
162    ///
163    /// # Errors
164    ///
165    /// Returns `Error::InvalidFrame` if the buffer is too small for the specified format.
166    ///
167    /// # Example
168    ///
169    /// ```no_run
170    /// # use grafton_ndi::{BorrowedVideoFrame, PixelFormat};
171    /// # fn main() -> Result<(), grafton_ndi::Error> {
172    /// let buffer = vec![0u8; 1920 * 1080 * 4]; // BGRA buffer
173    /// let frame = BorrowedVideoFrame::try_from_uncompressed(
174    ///     &buffer,
175    ///     1920,
176    ///     1080,
177    ///     PixelFormat::BGRA,
178    ///     30,
179    ///     1
180    /// )?;
181    /// # Ok(())
182    /// # }
183    /// ```
184    pub fn try_from_uncompressed(
185        data: &'buf [u8],
186        width: i32,
187        height: i32,
188        pixel_format: PixelFormat,
189        frame_rate_n: i32,
190        frame_rate_d: i32,
191    ) -> Result<Self> {
192        Self::builder(data, width, height, pixel_format)
193            .frame_rate(frame_rate_n, frame_rate_d)
194            .build()
195    }
196
197    /// Configure a checked borrowed frame, including padded first-plane stride.
198    /// Defaults match `try_from_uncompressed`, with a frame rate of 30/1.
199    /// All validation occurs in [`BorrowedVideoFrameBuilder::build`].
200    pub fn builder(
201        data: &'buf [u8],
202        width: i32,
203        height: i32,
204        pixel_format: PixelFormat,
205    ) -> BorrowedVideoFrameBuilder<'buf> {
206        BorrowedVideoFrameBuilder {
207            data,
208            width,
209            height,
210            pixel_format,
211            line_stride: None,
212            frame_rate_n: 30,
213            frame_rate_d: 1,
214            picture_aspect_ratio: 16.0 / 9.0,
215            scan_type: ScanType::Progressive,
216            timecode: NDIlib_send_timecode_synthesize,
217            metadata: None,
218            timestamp: 0,
219        }
220    }
221
222    /// Copy this borrowed frame into a validated owned frame.
223    ///
224    /// Active pixels are copied into tightly packed planes; row padding is
225    /// skipped and the owned stride can shrink. Metadata is copied separately;
226    /// no reference to the source is retained.
227    pub fn try_to_owned(&self) -> Result<VideoFrame> {
228        // SAFETY: The immutable slices retain initialized pixels and bounded UTF-8
229        // metadata for these cached proofs throughout the active-row copy.
230        unsafe { VideoFrame::from_raw_validated(&self.to_raw(), self.layout, self.metadata_layout) }
231    }
232
233    /// Get the frame width in pixels.
234    pub fn width(&self) -> i32 {
235        self.layout.width
236    }
237
238    /// Get the frame height in pixels.
239    pub fn height(&self) -> i32 {
240        self.layout.height
241    }
242
243    /// Get the validated uncompressed pixel format.
244    pub fn pixel_format(&self) -> PixelFormat {
245        self.layout.pixel_format
246    }
247
248    /// Get the raw SDK FourCC value.
249    pub fn fourcc(&self) -> u32 {
250        self.layout.pixel_format.into()
251    }
252
253    /// Get the frame rate numerator.
254    pub fn frame_rate_n(&self) -> i32 {
255        self.frame_rate_n
256    }
257
258    /// Get the frame rate denominator.
259    pub fn frame_rate_d(&self) -> i32 {
260        self.frame_rate_d
261    }
262
263    /// Get the picture aspect ratio.
264    pub fn picture_aspect_ratio(&self) -> f32 {
265        self.picture_aspect_ratio
266    }
267
268    /// Get the scan type.
269    pub fn scan_type(&self) -> ScanType {
270        self.scan_type
271    }
272
273    /// Get the timecode.
274    pub fn timecode(&self) -> i64 {
275        self.timecode
276    }
277
278    /// Get a reference to the pixel data.
279    pub fn data(&self) -> &[u8] {
280        self.data
281    }
282
283    /// Get the validated first-plane line stride.
284    pub fn line_stride_or_size(&self) -> LineStrideOrSize {
285        self.layout.line_stride_or_size
286    }
287
288    /// Complete validated storage extent, including row padding.
289    pub fn validated_data_len(&self) -> usize {
290        self.layout.data_len_bytes
291    }
292
293    /// Get cached UTF-8 metadata text, preserving absent versus present-empty.
294    pub fn metadata(&self) -> Option<&str> {
295        // SAFETY: Construction validates UTF-8 and retains the same immutable CStr.
296        unsafe {
297            crate::frames::frame_metadata_str(
298                self.metadata.map_or(ptr::null(), |m| m.as_ptr()),
299                self.metadata_layout,
300            )
301        }
302    }
303
304    /// Get the timestamp.
305    pub fn timestamp(&self) -> i64 {
306        self.timestamp
307    }
308
309    /// Set the timecode, in 100 ns units.
310    ///
311    /// [`try_from_uncompressed`](Self::try_from_uncompressed) leaves it at
312    /// `NDIlib_send_timecode_synthesize`, which asks the SDK to make one up.
313    ///
314    /// # Example
315    ///
316    /// ```no_run
317    /// # use grafton_ndi::{BorrowedVideoFrame, PixelFormat};
318    /// # fn main() -> Result<(), grafton_ndi::Error> {
319    /// let buffer = vec![0u8; 1920 * 1080 * 2];
320    /// let frame = BorrowedVideoFrame::try_from_uncompressed(
321    ///     &buffer, 1920, 1080, PixelFormat::UYVY, 25, 1,
322    /// )?
323    /// .with_timecode(10_000_000);
324    /// assert_eq!(frame.timecode(), 10_000_000);
325    /// # Ok(())
326    /// # }
327    /// ```
328    #[must_use]
329    pub fn with_timecode(mut self, timecode: i64) -> Self {
330        self.timecode = timecode;
331        self
332    }
333
334    /// Set the picture aspect ratio, width over height as displayed; `0.0`
335    /// means square pixels, whatever the resolution.
336    ///
337    /// [`try_from_uncompressed`](Self::try_from_uncompressed) assumes 16:9.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`Error::InvalidFrame`] if `ratio` is negative or not finite.
342    pub fn with_picture_aspect_ratio(mut self, ratio: f32) -> Result<Self> {
343        crate::frames::validate_video_frame_metadata(self.frame_rate_n, self.frame_rate_d, ratio)?;
344        self.picture_aspect_ratio = ratio;
345        Ok(self)
346    }
347
348    fn to_raw(&self) -> NDIlib_video_frame_v2_t {
349        // Validation is now performed at construction time, so no runtime checks needed here
350        NDIlib_video_frame_v2_t {
351            xres: self.layout.width,
352            yres: self.layout.height,
353            FourCC: self.layout.pixel_format.into(),
354            frame_rate_N: self.frame_rate_n,
355            frame_rate_D: self.frame_rate_d,
356            picture_aspect_ratio: self.picture_aspect_ratio,
357            frame_format_type: self.scan_type.into(),
358            timecode: self.timecode,
359            p_data: self.data.as_ptr() as *mut u8,
360            __bindgen_anon_1: self.layout.line_stride_or_size.into(),
361            p_metadata: self.metadata.map_or(ptr::null(), |m| m.as_ptr()),
362            timestamp: self.timestamp,
363        }
364    }
365}
366
367/// Builder for a borrowed uncompressed video frame. No pixels or metadata are copied.
368pub struct BorrowedVideoFrameBuilder<'buf> {
369    data: &'buf [u8],
370    width: i32,
371    height: i32,
372    pixel_format: PixelFormat,
373    line_stride: Option<i32>,
374    frame_rate_n: i32,
375    frame_rate_d: i32,
376    picture_aspect_ratio: f32,
377    scan_type: ScanType,
378    timecode: i64,
379    metadata: Option<&'buf CStr>,
380    timestamp: i64,
381}
382
383impl<'buf> BorrowedVideoFrameBuilder<'buf> {
384    /// Set first-plane stride in bytes. Zero selects the packed stride.
385    #[must_use]
386    pub fn line_stride(mut self, stride: i32) -> Self {
387        self.line_stride = Some(stride);
388        self
389    }
390    /// Set the positive frame-rate numerator and denominator.
391    #[must_use]
392    pub fn frame_rate(mut self, numerator: i32, denominator: i32) -> Self {
393        self.frame_rate_n = numerator;
394        self.frame_rate_d = denominator;
395        self
396    }
397    /// Set a finite nonnegative aspect ratio; zero selects square pixels.
398    #[must_use]
399    pub fn picture_aspect_ratio(mut self, ratio: f32) -> Self {
400        self.picture_aspect_ratio = ratio;
401        self
402    }
403    /// Select progressive, interlaced, or field scanning.
404    #[must_use]
405    pub fn scan_type(mut self, scan_type: ScanType) -> Self {
406        self.scan_type = scan_type;
407        self
408    }
409    /// Set the timecode in 100 ns units, including literal zero.
410    #[must_use]
411    pub fn timecode(mut self, timecode: i64) -> Self {
412        self.timecode = timecode;
413        self
414    }
415    /// Borrow NUL-terminated UTF-8 metadata, validated when built.
416    #[must_use]
417    pub fn metadata(mut self, metadata: &'buf CStr) -> Self {
418        self.metadata = Some(metadata);
419        self
420    }
421    /// Set the SDK timestamp, including literal zero.
422    #[must_use]
423    pub fn timestamp(mut self, timestamp: i64) -> Self {
424        self.timestamp = timestamp;
425        self
426    }
427
428    /// Validate dimensions, stride, initialized slice extent, headers, and metadata.
429    /// Successful construction allocates nothing. Invalid layout/header values or
430    /// metadata exceeding the 4 MiB cap return [`Error::InvalidFrame`]; invalid
431    /// metadata UTF-8 returns [`Error::InvalidUtf8`]. The input slices remain borrowed.
432    pub fn build(self) -> Result<BorrowedVideoFrame<'buf>> {
433        crate::frames::validate_video_frame_metadata(
434            self.frame_rate_n,
435            self.frame_rate_d,
436            self.picture_aspect_ratio,
437        )?;
438        let layout = ValidatedVideoLayout::new_uncompressed(
439            self.pixel_format,
440            self.width,
441            self.height,
442            self.line_stride,
443        )?;
444        if self.data.len() < layout.data_len_bytes {
445            return Err(Error::InvalidFrame(format!(
446                "Buffer too small for {:?}: got {} bytes, expected at least {}",
447                self.pixel_format,
448                self.data.len(),
449                layout.data_len_bytes,
450            )));
451        }
452        let metadata_layout = ValidatedFrameMetadata::from_cstr(self.metadata)?;
453        Ok(BorrowedVideoFrame {
454            layout,
455            data: self.data,
456            frame_rate_n: self.frame_rate_n,
457            frame_rate_d: self.frame_rate_d,
458            picture_aspect_ratio: self.picture_aspect_ratio,
459            scan_type: self.scan_type,
460            timecode: self.timecode,
461            metadata: self.metadata,
462            metadata_layout,
463            timestamp: self.timestamp,
464        })
465    }
466}
467
468impl<'buf> From<&'buf VideoFrame> for BorrowedVideoFrame<'buf> {
469    fn from(frame: &'buf VideoFrame) -> Self {
470        let layout = frame.validated_layout();
471        BorrowedVideoFrame {
472            frame_rate_n: frame.frame_rate_n(),
473            frame_rate_d: frame.frame_rate_d(),
474            picture_aspect_ratio: frame.picture_aspect_ratio(),
475            scan_type: frame.scan_type(),
476            timecode: frame.timecode(),
477            data: frame.data(),
478            layout,
479            metadata: frame.metadata_cstr(),
480            metadata_layout: frame.validated_metadata(),
481            timestamp: frame.timestamp(),
482        }
483    }
484}
485
486impl Sender {
487    /// Creates a new NDI send instance.
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if:
492    /// - The sender name contains a null byte
493    /// - The groups string contains a null byte
494    /// - NDI fails to create the send instance
495    pub fn new(ndi: &NDI, create_settings: &SenderOptions) -> Result<Self> {
496        let name_cstr =
497            CString::new(create_settings.name.clone()).map_err(Error::InvalidCString)?;
498        let groups_cstr = match &create_settings.groups {
499            Some(groups) => Some(CString::new(groups.clone()).map_err(Error::InvalidCString)?),
500            None => None,
501        };
502
503        let c_settings = NDIlib_send_create_t {
504            p_ndi_name: name_cstr.as_ptr(),
505            p_groups: groups_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
506            clock_video: create_settings.clock_video,
507            clock_audio: create_settings.clock_audio,
508        };
509
510        let instance = unsafe { NDIlib_send_create(&c_settings) };
511        if instance.is_null() {
512            Err(Error::InitializationFailed(
513                "Failed to create NDI send instance".into(),
514            ))
515        } else {
516            let inner = Inner {
517                instance,
518                _name: name_cstr,
519                _groups: groups_cstr,
520                video_callback: None,
521                video: Mutex::new(VideoState::default()),
522            };
523
524            Ok(Self {
525                inner,
526                _ndi: ndi.clone(),
527            })
528        }
529    }
530
531    /// Send a video frame synchronously.
532    ///
533    /// The SDK has released the input buffer when this returns; the caller may
534    /// then reuse or release it. This also completes a previous async video send.
535    pub fn send_video(&self, video_frame: &VideoFrame) {
536        let len = {
537            let mut video = self.inner.video.lock().unwrap_or_else(|e| e.into_inner());
538            // A synchronous video send also releases the previous async frame.
539            unsafe {
540                NDIlib_send_send_video_v2(self.inner.instance, &video_frame.to_raw());
541            }
542            video.completed()
543        };
544        self.inner.notify(len);
545    }
546
547    /// Submit an owned frame without copying its pixel or metadata allocations.
548    ///
549    /// The sender takes ownership before calling the SDK. The next async send
550    /// synchronizes the previous send and returns its owned frame for reuse.
551    /// The first send returns `None`. Owned and copied submissions share this
552    /// recovery behavior. Call [`Self::finish_video_async`] to recover the final frame.
553    ///
554    /// Synchronous sends and explicit flushes complete the operation but retain
555    /// the owned frame for recovery by the next async send or finish call.
556    /// No SDK completion callback is registered, including with Advanced SDK.
557    /// If a Rust completion handler panics, the new frame remains retained and
558    /// the synchronized previous frame is dropped rather than returned.
559    pub fn send_video_async(&mut self, frame: VideoFrame) -> Option<VideoFrame> {
560        let raw = frame.to_raw();
561        let (previous, len) = {
562            let mut video = self.inner.video.lock().unwrap_or_else(|e| e.into_inner());
563            let len = video.completed();
564            // Store the new owner before FFI, retaining the old owner locally
565            // until this call synchronizes its SDK access. Moving a frame does
566            // not move its Vec/CString allocations.
567            let previous = video.pending.replace(frame);
568            video.in_flight = true;
569            unsafe {
570                NDIlib_send_send_video_async_v2(self.inner.instance, &raw);
571            }
572            (previous, len)
573        };
574        // State is consistent and the mutex unlocked before user code. If it
575        // panics, previous is safe to drop and the sender retains the new frame.
576        self.inner.notify(len);
577        previous
578    }
579
580    /// Drain the SDK and return the final owned frame, if any.
581    ///
582    /// Frames submitted through either owned or copied sending are returned.
583    /// Repeated calls return `None` and do not repeat completion notifications.
584    /// If the completion handler panics, the completed frame is dropped instead
585    /// of returned.
586    pub fn finish_video_async(&mut self) -> Option<VideoFrame> {
587        let (previous, len) = {
588            let mut video = self.inner.video.lock().unwrap_or_else(|e| e.into_inner());
589            if video.in_flight {
590                flush_null_frame(self.inner.instance);
591            }
592            let len = video.completed();
593            (video.pending.take(), len)
594        };
595        self.inner.notify(len);
596        previous
597    }
598
599    /// Copy borrowed pixels and metadata, then submit the owned copy asynchronously.
600    ///
601    /// This uses the same pipeline as [`Self::send_video_async`], returning the
602    /// previous completed frame whether it was submitted by ownership or copying.
603    /// The caller may immediately reuse or release the source buffer and metadata.
604    /// Recover the final copy with [`Self::finish_video_async`].
605    ///
606    /// # Errors
607    ///
608    /// Returns an error if the borrowed frame cannot be represented as a supported
609    /// owned video layout. No pending send is changed when validation fails.
610    pub fn send_video_async_copy(
611        &mut self,
612        frame: &BorrowedVideoFrame<'_>,
613    ) -> Result<Option<VideoFrame>> {
614        let owned = frame.try_to_owned()?;
615        Ok(self.send_video_async(owned))
616    }
617
618    /// Sends an audio frame synchronously.
619    ///
620    /// The SDK has released the input buffer when this returns, so the caller
621    /// may then reuse or release it. This calls `NDIlib_send_send_audio_v3`.
622    ///
623    /// See the NDI SDK documentation section on `NDIlib_send_send_audio_v3` for more details.
624    ///
625    /// # Example
626    ///
627    /// ```no_run
628    /// # use grafton_ndi::{NDI, SenderOptions, AudioFrame};
629    /// # fn main() -> Result<(), grafton_ndi::Error> {
630    /// # let ndi = NDI::new()?;
631    /// # let options = SenderOptions::builder("Test").build();
632    /// # let sender = grafton_ndi::Sender::new(&ndi, &options)?;
633    /// let mut audio_buffer = vec![0.0f32; 48000 * 2]; // 1 second of stereo audio
634    ///
635    /// // Fill buffer with audio data...
636    /// let frame = AudioFrame::builder()
637    ///     .sample_rate(48000)
638    ///     .channels(2)
639    ///     .samples(48000)
640    ///     .data(audio_buffer.clone())
641    ///     .build()?;
642    /// sender.send_audio(&frame);
643    ///
644    /// // Buffer can be reused immediately
645    /// audio_buffer.fill(0.5);
646    /// let frame2 = AudioFrame::builder()
647    ///     .sample_rate(48000)
648    ///     .channels(2)
649    ///     .samples(48000)
650    ///     .data(audio_buffer)
651    ///     .build()?;
652    /// sender.send_audio(&frame2);
653    /// # Ok(())
654    /// # }
655    /// ```
656    pub fn send_audio(&self, audio_frame: &AudioFrame) {
657        unsafe {
658            NDIlib_send_send_audio_v3(self.inner.instance, &audio_frame.to_raw());
659        }
660    }
661
662    /// Sends a metadata frame.
663    ///
664    pub fn send_metadata(&self, metadata_frame: &MetadataFrame) {
665        let raw = metadata_frame.to_raw();
666        // The borrowed C string remains live through this synchronous SDK call.
667        unsafe {
668            NDIlib_send_send_metadata(self.inner.instance, &raw);
669        }
670    }
671
672    /// Get the current tally state for this sender.
673    ///
674    /// # Arguments
675    ///
676    /// * `timeout` - Maximum time to wait for tally information.
677    ///   Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
678    ///
679    /// # Returns
680    ///
681    /// `Ok(Some(tally))` if tally was successfully retrieved, `Ok(None)` on timeout.
682    ///
683    /// # Errors
684    ///
685    /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
686    ///
687    /// # Examples
688    ///
689    /// ```no_run
690    /// # use grafton_ndi::{NDI, SenderOptions};
691    /// # use std::time::Duration;
692    /// # fn main() -> Result<(), grafton_ndi::Error> {
693    /// let ndi = NDI::new()?;
694    /// let options = SenderOptions::builder("Test Sender").build();
695    /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
696    ///
697    /// // Try to get tally with 1 second timeout
698    /// if let Some(tally) = sender.tally(Duration::from_secs(1))? {
699    ///     println!("On program: {}, On preview: {}", tally.on_program, tally.on_preview);
700    /// } else {
701    ///     println!("Tally request timed out");
702    /// }
703    /// # Ok(())
704    /// # }
705    /// ```
706    pub fn tally(&self, timeout: Duration) -> Result<Option<Tally>> {
707        let timeout_ms = to_ms_checked(timeout)?;
708        let mut raw_tally = Tally::new(false, false).to_raw();
709        let success =
710            unsafe { NDIlib_send_get_tally(self.inner.instance, &mut raw_tally, timeout_ms) };
711
712        if success {
713            Ok(Some(Tally {
714                on_program: raw_tally.on_program,
715                on_preview: raw_tally.on_preview,
716            }))
717        } else {
718            Ok(None)
719        }
720    }
721
722    /// Get the number of active connections to this sender.
723    ///
724    /// # Arguments
725    ///
726    /// * `timeout` - Maximum time to wait for connection count.
727    ///   Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
728    ///
729    /// # Returns
730    ///
731    /// Number of active connections as a `u32`.
732    ///
733    /// # Errors
734    ///
735    /// Returns [`Error::Timeout`] if the SDK returns a negative value (indicating timeout or error).
736    /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
737    ///
738    /// # Examples
739    ///
740    /// ```no_run
741    /// # use grafton_ndi::{NDI, SenderOptions};
742    /// # use std::time::Duration;
743    /// # fn main() -> Result<(), grafton_ndi::Error> {
744    /// let ndi = NDI::new()?;
745    /// let options = SenderOptions::builder("Test Sender").build();
746    /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
747    ///
748    /// // Get connection count with 1 second timeout
749    /// let count = sender.connection_count(Duration::from_secs(1))?;
750    /// println!("Active connections: {}", count);
751    /// # Ok(())
752    /// # }
753    /// ```
754    pub fn connection_count(&self, timeout: Duration) -> Result<u32> {
755        let timeout_ms = to_ms_checked(timeout)?;
756        let count = unsafe { NDIlib_send_get_no_connections(self.inner.instance, timeout_ms) };
757
758        if count < 0 {
759            Err(Error::Timeout("Failed to obtain connection count".into()))
760        } else {
761            Ok(count as u32)
762        }
763    }
764
765    pub fn clear_connection_metadata(&self) {
766        unsafe { NDIlib_send_clear_connection_metadata(self.inner.instance) }
767    }
768
769    /// Adds connection metadata.
770    ///
771    pub fn add_connection_metadata(&self, metadata_frame: &MetadataFrame) {
772        let raw = metadata_frame.to_raw();
773        // The borrowed C string remains live through this synchronous SDK call.
774        unsafe { NDIlib_send_add_connection_metadata(self.inner.instance, &raw) }
775    }
776
777    /// Sets failover source.
778    ///
779    /// # Errors
780    ///
781    /// Returns an error if source conversion fails.
782    pub fn set_failover(&self, source: &Source) -> Result<()> {
783        let raw_source = source.to_raw()?;
784        unsafe { NDIlib_send_set_failover(self.inner.instance, &raw_source.raw) }
785        Ok(())
786    }
787
788    /// Clear the failover source previously configured with [`Self::set_failover`].
789    pub fn clear_failover(&self) {
790        unsafe { NDIlib_send_set_failover(self.inner.instance, ptr::null()) }
791    }
792
793    /// Get the source information for this sender.
794    ///
795    /// # Errors
796    ///
797    /// Returns `Error::NullPointer` if the NDI SDK returns a null pointer or
798    /// if the source data contains null pointers.
799    ///
800    /// # Examples
801    ///
802    /// ```no_run
803    /// # use grafton_ndi::{NDI, SenderOptions};
804    /// # fn main() -> Result<(), grafton_ndi::Error> {
805    /// let ndi = NDI::new()?;
806    /// let options = SenderOptions::builder("Test Sender").build();
807    /// let sender = grafton_ndi::Sender::new(&ndi, &options)?;
808    /// let source = sender.source()?;
809    /// println!("Sender source: {source}");
810    /// # Ok(())
811    /// # }
812    /// ```
813    pub fn source(&self) -> Result<Source> {
814        let source_ptr = unsafe { NDIlib_send_get_source_name(self.inner.instance) };
815        // SAFETY: the SDK sender owns this live descriptor and its strings
816        // through the synchronous copy performed here.
817        unsafe { Source::try_from_raw(source_ptr) }
818    }
819
820    /// Replace the completion handler receiving the submitted pixel-buffer length.
821    ///
822    /// It runs on the calling thread after SDK synchronization, outside internal
823    /// locks, at most once per submitted frame. Completion is observed by the
824    /// next video send, flush, finish, or sender destruction. Owned and copied sends share
825    /// this handler. State is updated before invocation, so panic or reentry cannot
826    /// repeat a notification. If sender destruction runs during unwinding, a
827    /// handler panic is suppressed to avoid a second panic.
828    ///
829    /// Replaces the previous handler. The current handler observes completion of
830    /// a frame still in flight, including one submitted before registration.
831    /// Frames completed before registration are not notified retroactively.
832    pub fn on_async_video_done<F>(&mut self, handler: F)
833    where
834        F: Fn(usize) + Send + Sync + 'static,
835    {
836        self.inner.video_callback = Some(Box::new(handler));
837    }
838
839    /// Remove the completion handler. Pending frame ownership is unchanged.
840    pub fn clear_async_video_done(&mut self) {
841        self.inner.video_callback = None;
842    }
843
844    /// Flush pending async video operations synchronously.
845    ///
846    /// Sends a true NULL video frame pointer to the SDK, blocking until all
847    /// pending async video operations are complete.
848    ///
849    /// After this returns, the SDK has released the pending buffer. The sender
850    /// retains the frame; use [`Self::finish_video_async`] or the next async
851    /// submission to recover it without copying.
852    pub fn flush_async_blocking(&self) {
853        self.inner.complete();
854    }
855}
856
857impl Drop for Inner {
858    fn drop(&mut self) {
859        let video = self.video.get_mut().unwrap_or_else(|e| e.into_inner());
860        if video.in_flight {
861            flush_null_frame(self.instance);
862        }
863        let len = video.completed();
864        // Destroy before running user code, so a callback panic cannot leak the
865        // SDK instance. Pending allocations remain alive through destruction.
866        unsafe { NDIlib_send_destroy(self.instance) };
867        if std::thread::panicking() {
868            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.notify(len)));
869        } else {
870            self.notify(len);
871        }
872    }
873}
874
875/// # Safety
876///
877/// The NDI 6 SDK documentation specifically marks these send functions as thread-safe:
878/// - `NDIlib_send_send_video_v2` and `NDIlib_send_send_video_async_v2`
879/// - `NDIlib_send_send_audio_v3` (no async variant exists)
880/// - `NDIlib_send_send_metadata` (no async variant exists)
881/// - `NDIlib_send_get_tally`
882/// - `NDIlib_send_get_no_connections`
883///
884/// `Inner` holds an opaque NDI pointer and owned CStrings that are automatically
885/// freed in Drop, making it safe to move between threads.
886///
887/// Ownership prevents destruction while SDK calls still borrow the sender;
888/// creation and destruction may occur on different threads.
889unsafe impl Send for Sender {}
890
891/// # Safety
892///
893/// The NDI 6 SDK guarantees that multiple threads can safely call send methods
894/// concurrently. The SDK uses internal synchronization for:
895/// - Video sending (both sync and async)
896/// - Audio sending (sync only)
897/// - Metadata sending
898/// - Status queries (tally, connections)
899///
900/// Video ownership and synchronizing SDK calls are serialized by the mutex.
901/// Exclusive ownership prevents concurrent instance destruction.
902unsafe impl Sync for Sender {}
903
904#[derive(Debug, Clone)]
905pub struct SenderOptions {
906    pub name: String,
907    pub groups: Option<String>,
908    pub clock_video: bool,
909    pub clock_audio: bool,
910}
911
912impl SenderOptions {
913    /// Create a builder for configuring send options
914    pub fn builder<S: Into<String>>(name: S) -> SenderOptionsBuilder {
915        SenderOptionsBuilder::new(name)
916    }
917}
918
919/// Builder for configuring [`SenderOptions`] with method chaining.
920#[derive(Debug, Clone)]
921pub struct SenderOptionsBuilder {
922    name: String,
923    groups: Option<String>,
924    clock_video: Option<bool>,
925    clock_audio: Option<bool>,
926}
927
928impl SenderOptionsBuilder {
929    /// Create a new builder with the specified name
930    pub fn new<S: Into<String>>(name: S) -> Self {
931        Self {
932            name: name.into(),
933            groups: None,
934            clock_video: None,
935            clock_audio: None,
936        }
937    }
938
939    /// Set the groups for this sender
940    #[must_use]
941    pub fn groups<S: Into<String>>(mut self, groups: S) -> Self {
942        self.groups = Some(groups.into());
943        self
944    }
945
946    /// Configure whether to clock video
947    #[must_use]
948    pub fn clock_video(mut self, clock: bool) -> Self {
949        self.clock_video = Some(clock);
950        self
951    }
952
953    /// Configure whether to clock audio
954    #[must_use]
955    pub fn clock_audio(mut self, clock: bool) -> Self {
956        self.clock_audio = Some(clock);
957        self
958    }
959
960    /// Build the sender options
961    ///
962    /// This method is infallible and simply applies defaults for any unset options.
963    /// Validation is performed when creating a `Sender` via `Sender::new()`.
964    ///
965    /// # Example
966    ///
967    /// ```no_run
968    /// # use grafton_ndi::{NDI, SenderOptions, Sender};
969    /// # fn main() -> Result<(), grafton_ndi::Error> {
970    /// # let ndi = NDI::new()?;
971    /// let options = SenderOptions::builder("My Sender").build();
972    /// let sender = Sender::new(&ndi, &options)?;
973    /// # Ok(())
974    /// # }
975    /// ```
976    pub fn build(self) -> SenderOptions {
977        let clock_video = self.clock_video.unwrap_or(true);
978        let clock_audio = self.clock_audio.unwrap_or(true);
979
980        SenderOptions {
981            name: self.name,
982            groups: self.groups,
983            clock_video,
984            clock_audio,
985        }
986    }
987}
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use std::sync::Arc;
992
993    fn test_owned_frame(value: u8) -> VideoFrame {
994        let mut frame = VideoFrame::builder()
995            .resolution(16, 16)
996            .pixel_format(PixelFormat::BGRA)
997            .frame_rate(30, 1)
998            .metadata(format!("<frame value='{value}'/>"))
999            .build()
1000            .unwrap();
1001        frame.data_mut().fill(value);
1002        frame
1003    }
1004
1005    fn test_sender() -> Sender {
1006        use std::sync::atomic::{AtomicUsize, Ordering};
1007        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
1008        let name = format!(
1009            "Owned async regression {} {}",
1010            std::process::id(),
1011            NEXT_ID.fetch_add(1, Ordering::Relaxed)
1012        );
1013        let ndi = NDI::new().unwrap();
1014        Sender::new(
1015            &ndi,
1016            &SenderOptions::builder(name)
1017                .clock_video(false)
1018                .clock_audio(false)
1019                .build(),
1020        )
1021        .unwrap()
1022    }
1023
1024    #[test]
1025    fn metadata_send_methods_reuse_validated_text_with_sdk() {
1026        let sender = test_sender();
1027        let frame = MetadataFrame::with_data("<ndi_text>é</ndi_text>", 42).unwrap();
1028        let expected_ptr = frame.to_raw().p_data;
1029        for _ in 0..3 {
1030            sender.send_metadata(&frame);
1031            assert_eq!(frame.to_raw().p_data, expected_ptr);
1032        }
1033        sender.add_connection_metadata(&frame);
1034        sender.clear_connection_metadata();
1035    }
1036
1037    #[test]
1038    fn owned_async_recycles_original_pixel_and_metadata_allocations() {
1039        let mut sender = test_sender();
1040        let first = test_owned_frame(11);
1041        let pixels = first.data().as_ptr();
1042        let metadata = first.metadata_cstr().unwrap().as_ptr();
1043        assert!(sender.send_video_async(first).is_none());
1044        {
1045            let video = sender.inner.video.lock().unwrap();
1046            let Some(frame) = &video.pending else {
1047                panic!("missing owned frame")
1048            };
1049            assert_eq!(frame.to_raw().p_data, pixels as *mut u8);
1050            assert_eq!(frame.to_raw().p_metadata, metadata);
1051        }
1052        let second = test_owned_frame(22);
1053        let second_pixels = second.data().as_ptr();
1054        let recycled = sender.send_video_async(second).unwrap();
1055        assert_eq!(recycled.data().as_ptr(), pixels);
1056        assert_eq!(recycled.metadata_cstr().unwrap().as_ptr(), metadata);
1057        assert_eq!(recycled.metadata(), Some("<frame value='11'/>"));
1058        let final_frame = sender.finish_video_async().unwrap();
1059        assert_eq!(final_frame.data().as_ptr(), second_pixels);
1060        assert!(sender.finish_video_async().is_none());
1061    }
1062
1063    #[test]
1064    fn mixed_sync_flush_and_copy_sends_notify_once_and_preserve_recovery() {
1065        use std::sync::atomic::{AtomicUsize, Ordering};
1066        let count = Arc::new(AtomicUsize::new(0));
1067        let observed = count.clone();
1068        let mut sender = test_sender();
1069        sender.on_async_video_done(move |len| {
1070            assert_eq!(len, 1024);
1071            observed.fetch_add(1, Ordering::SeqCst);
1072        });
1073        let frame = test_owned_frame(1);
1074        let pixels = frame.data().as_ptr();
1075        sender.send_video_async(frame);
1076        sender.send_video(&test_owned_frame(9));
1077        sender.flush_async_blocking();
1078        assert_eq!(count.load(Ordering::SeqCst), 1);
1079        assert_eq!(sender.finish_video_async().unwrap().data().as_ptr(), pixels);
1080        assert_eq!(count.load(Ordering::SeqCst), 1);
1081
1082        // A copied send recovers the previous owned frame after synchronization.
1083        sender.send_video_async(test_owned_frame(2));
1084        let data = vec![3; 1024];
1085        let metadata = CString::new("<copy/>").unwrap();
1086        {
1087            let borrowed = BorrowedVideoFrame::builder(&data, 16, 16, PixelFormat::BGRA)
1088                .metadata(&metadata)
1089                .build()
1090                .unwrap();
1091            assert_eq!(
1092                sender
1093                    .send_video_async_copy(&borrowed)
1094                    .unwrap()
1095                    .unwrap()
1096                    .data()[0],
1097                2
1098            );
1099        }
1100        {
1101            let video = sender.inner.video.lock().unwrap();
1102            let Some(copy) = &video.pending else {
1103                panic!("missing copied snapshot")
1104            };
1105            assert_ne!(copy.data().as_ptr(), data.as_ptr());
1106            assert_ne!(copy.metadata_cstr().unwrap().as_ptr(), metadata.as_ptr());
1107        }
1108        drop(data);
1109        drop(metadata);
1110        {
1111            let video = sender.inner.video.lock().unwrap();
1112            let Some(copy) = &video.pending else {
1113                panic!("missing copied snapshot")
1114            };
1115            assert_eq!(copy.data(), vec![3; 1024]);
1116            assert_eq!(copy.metadata().unwrap(), "<copy/>");
1117        }
1118        // Copies are ordinary owned frames and are returned for reuse too.
1119        let copied = sender.send_video_async(test_owned_frame(4)).unwrap();
1120        assert_eq!(copied.data()[0], 3);
1121        assert_eq!(copied.metadata(), Some("<copy/>"));
1122        sender.finish_video_async().unwrap();
1123        sender.flush_async_blocking();
1124        assert_eq!(count.load(Ordering::SeqCst), 4);
1125    }
1126
1127    #[test]
1128    fn callback_panic_after_submission_keeps_new_owned_frame_recoverable() {
1129        use std::sync::atomic::{AtomicUsize, Ordering};
1130        let count = Arc::new(AtomicUsize::new(0));
1131        let observed = count.clone();
1132        let mut sender = test_sender();
1133        sender.on_async_video_done(move |_| {
1134            if observed.fetch_add(1, Ordering::SeqCst) == 0 {
1135                panic!("intentional completion panic");
1136            }
1137        });
1138        sender.send_video_async(test_owned_frame(1));
1139        let second = test_owned_frame(2);
1140        let pixels = second.data().as_ptr();
1141        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1142            sender.send_video_async(second);
1143        }));
1144        assert!(result.is_err());
1145        assert_eq!(sender.finish_video_async().unwrap().data().as_ptr(), pixels);
1146        assert_eq!(count.load(Ordering::SeqCst), 2);
1147        assert!(sender.finish_video_async().is_none());
1148    }
1149
1150    #[test]
1151    fn completion_callback_can_reenter_shared_flush_without_deadlock() {
1152        use std::sync::{
1153            atomic::{AtomicUsize, Ordering},
1154            Weak,
1155        };
1156        let reference = Arc::new(Mutex::new(Weak::<Sender>::new()));
1157        let callback_reference = reference.clone();
1158        let count = Arc::new(AtomicUsize::new(0));
1159        let observed = count.clone();
1160        let mut sender = test_sender();
1161        sender.on_async_video_done(move |_| {
1162            observed.fetch_add(1, Ordering::SeqCst);
1163            let sender = callback_reference.lock().unwrap().upgrade().unwrap();
1164            sender.flush_async_blocking();
1165            sender.send_video(&test_owned_frame(7));
1166        });
1167        sender.send_video_async(test_owned_frame(5));
1168        let sender = Arc::new(sender);
1169        *reference.lock().unwrap() = Arc::downgrade(&sender);
1170        std::thread::scope(|scope| {
1171            for _ in 0..4 {
1172                scope.spawn(|| sender.flush_async_blocking());
1173            }
1174        });
1175        assert_eq!(count.load(Ordering::SeqCst), 1);
1176        let mut sender = Arc::try_unwrap(sender).unwrap();
1177        assert_eq!(sender.finish_video_async().unwrap().data()[0], 5);
1178    }
1179
1180    #[test]
1181    fn sender_drop_notifies_once_for_copy() {
1182        use std::sync::atomic::{AtomicUsize, Ordering};
1183        let count = Arc::new(AtomicUsize::new(0));
1184        let observed = count.clone();
1185        let mut sender = test_sender();
1186        sender.on_async_video_done(move |_| {
1187            observed.fetch_add(1, Ordering::SeqCst);
1188        });
1189        let data = vec![0; 1024];
1190        {
1191            let frame =
1192                BorrowedVideoFrame::try_from_uncompressed(&data, 16, 16, PixelFormat::BGRA, 30, 1)
1193                    .unwrap();
1194            assert!(sender.send_video_async_copy(&frame).unwrap().is_none());
1195        }
1196        drop(data);
1197        drop(sender);
1198        assert_eq!(count.load(Ordering::SeqCst), 1);
1199    }
1200
1201    #[test]
1202    fn completion_handler_can_be_replaced_and_cleared() {
1203        use std::sync::atomic::{AtomicUsize, Ordering};
1204        let count = Arc::new(AtomicUsize::new(0));
1205        let mut sender = test_sender();
1206        sender.on_async_video_done(|_| panic!("replaced handler must not run"));
1207        let observed = count.clone();
1208        sender.on_async_video_done(move |_| {
1209            observed.fetch_add(1, Ordering::SeqCst);
1210        });
1211        sender.send_video_async(test_owned_frame(1));
1212        sender.finish_video_async();
1213        sender.clear_async_video_done();
1214        sender.send_video_async(test_owned_frame(2));
1215        sender.finish_video_async();
1216        assert_eq!(count.load(Ordering::SeqCst), 1);
1217    }
1218
1219    #[test]
1220    fn test_async_flush_uses_null_frame_pointer() {
1221        assert!(async_flush_frame_ptr().is_null());
1222    }
1223
1224    #[test]
1225    fn borrowed_builder_validates_padded_layouts_and_preserves_headers_without_allocating() {
1226        for format in [
1227            PixelFormat::BGRA,
1228            PixelFormat::BGRX,
1229            PixelFormat::RGBA,
1230            PixelFormat::RGBX,
1231            PixelFormat::UYVY,
1232            PixelFormat::UYVA,
1233            PixelFormat::P216,
1234            PixelFormat::PA16,
1235            PixelFormat::I420,
1236            PixelFormat::YV12,
1237            PixelFormat::NV12,
1238        ] {
1239            for stride in [
1240                0,
1241                format.try_line_stride(4).unwrap(),
1242                format.try_line_stride(4).unwrap() + 8,
1243            ] {
1244                let layout =
1245                    ValidatedVideoLayout::new_uncompressed(format, 4, 4, Some(stride)).unwrap();
1246                let data = vec![19; layout.data_len_bytes];
1247                let metadata = c"<pixels>é</pixels>";
1248                let (borrowed, allocations) = crate::test_alloc::allocation_count(|| {
1249                    let frame = BorrowedVideoFrame::builder(&data, 4, 4, format)
1250                        .line_stride(stride)
1251                        .frame_rate(30000, 1001)
1252                        .picture_aspect_ratio(1.25)
1253                        .scan_type(ScanType::Interlaced)
1254                        .timecode(0)
1255                        .timestamp(67890)
1256                        .metadata(metadata)
1257                        .build()
1258                        .unwrap();
1259                    for _ in 0..3 {
1260                        assert_eq!(frame.metadata(), Some("<pixels>é</pixels>"));
1261                        assert_eq!(frame.validated_data_len(), data.len());
1262                        assert_eq!(frame.pixel_format(), format);
1263                    }
1264                    frame
1265                });
1266                assert_eq!(allocations, 0);
1267                let (owned, allocations) =
1268                    crate::test_alloc::allocation_count(|| borrowed.try_to_owned().unwrap());
1269                assert_eq!(allocations, 2);
1270                assert_eq!((owned.frame_rate_n(), owned.frame_rate_d()), (30000, 1001));
1271                assert_eq!(owned.picture_aspect_ratio(), 1.25);
1272                assert_eq!(owned.scan_type(), ScanType::Interlaced);
1273                assert_eq!((owned.timecode(), owned.timestamp()), (0, 67890));
1274                assert_eq!(owned.metadata(), borrowed.metadata());
1275                let (reborrowed, allocations) =
1276                    crate::test_alloc::allocation_count(|| BorrowedVideoFrame::from(&owned));
1277                assert_eq!(allocations, 0);
1278                assert_eq!(reborrowed.metadata(), borrowed.metadata());
1279                assert!(
1280                    BorrowedVideoFrame::builder(&data[..data.len() - 1], 4, 4, format)
1281                        .line_stride(stride)
1282                        .build()
1283                        .is_err()
1284                );
1285            }
1286            for stride in [-1, 1, i32::MAX] {
1287                let expected = ValidatedVideoLayout::new_uncompressed(format, 4, 4, Some(stride));
1288                let result = BorrowedVideoFrame::builder(&[], 4, 4, format)
1289                    .line_stride(stride)
1290                    .build();
1291                assert!(expected.is_err());
1292                assert!(result.is_err());
1293            }
1294        }
1295    }
1296
1297    #[test]
1298    fn borrowed_metadata_is_bounded_utf8_and_preserves_empty_presence() {
1299        let data = [0; 16];
1300        for metadata in [None, Some(c"")] {
1301            let mut builder = BorrowedVideoFrame::builder(&data, 2, 2, PixelFormat::BGRA);
1302            if let Some(metadata) = metadata {
1303                builder = builder.metadata(metadata);
1304            }
1305            let frame = builder.build().unwrap();
1306            let (owned, allocations) =
1307                crate::test_alloc::allocation_count(|| frame.try_to_owned().unwrap());
1308            assert_eq!(allocations, 1 + usize::from(metadata.is_some()));
1309            assert_eq!(owned.metadata(), metadata.map(|_| ""));
1310        }
1311        let invalid = c"\xff";
1312        assert!(matches!(
1313            BorrowedVideoFrame::builder(&data, 2, 2, PixelFormat::BGRA)
1314                .metadata(invalid)
1315                .build(),
1316            Err(Error::InvalidUtf8(_))
1317        ));
1318        for len in [
1319            crate::frames::MAX_METADATA_BYTES,
1320            crate::frames::MAX_METADATA_BYTES + 1,
1321        ] {
1322            let mut bytes = vec![b'x'; len];
1323            bytes[len - 1] = 0;
1324            let metadata = CStr::from_bytes_with_nul(&bytes).unwrap();
1325            let result = BorrowedVideoFrame::builder(&data, 2, 2, PixelFormat::BGRA)
1326                .metadata(metadata)
1327                .build();
1328            assert_eq!(result.is_ok(), len == crate::frames::MAX_METADATA_BYTES);
1329        }
1330    }
1331
1332    #[test]
1333    fn test_borrowed_frame_setters() {
1334        let buffer = vec![0u8; 64 * 36 * 2];
1335        let frame =
1336            BorrowedVideoFrame::try_from_uncompressed(&buffer, 64, 36, PixelFormat::UYVY, 25, 1)
1337                .unwrap();
1338        assert_eq!(frame.timecode(), NDIlib_send_timecode_synthesize);
1339        assert_eq!(frame.picture_aspect_ratio(), 16.0 / 9.0);
1340
1341        let frame = frame
1342            .with_timecode(1234)
1343            .with_picture_aspect_ratio(0.0)
1344            .unwrap();
1345        assert_eq!(frame.timecode(), 1234);
1346        assert_eq!(frame.picture_aspect_ratio(), 0.0);
1347        let raw = frame.to_raw();
1348        assert_eq!((raw.timecode, raw.picture_aspect_ratio), (1234, 0.0));
1349
1350        for bad in [-1.0, f32::NAN, f32::INFINITY] {
1351            let frame = BorrowedVideoFrame::try_from_uncompressed(
1352                &buffer,
1353                64,
1354                36,
1355                PixelFormat::UYVY,
1356                25,
1357                1,
1358            )
1359            .unwrap();
1360            assert!(frame.with_picture_aspect_ratio(bad).is_err(), "{bad}");
1361        }
1362    }
1363
1364    #[test]
1365    fn test_try_from_uncompressed_exact_size() {
1366        // BGRA format: 1920x1080x4 bytes
1367        let buffer = vec![0u8; 1920 * 1080 * 4];
1368        let result = BorrowedVideoFrame::try_from_uncompressed(
1369            &buffer,
1370            1920,
1371            1080,
1372            PixelFormat::BGRA,
1373            30,
1374            1,
1375        );
1376        assert!(result.is_ok());
1377    }
1378
1379    #[test]
1380    fn test_try_from_uncompressed_oversized_buffer() {
1381        // Buffer larger than needed should succeed
1382        let buffer = vec![0u8; 1920 * 1080 * 4 + 1000];
1383        let result = BorrowedVideoFrame::try_from_uncompressed(
1384            &buffer,
1385            1920,
1386            1080,
1387            PixelFormat::BGRA,
1388            30,
1389            1,
1390        );
1391        assert!(result.is_ok());
1392    }
1393
1394    #[test]
1395    fn test_try_from_uncompressed_undersized_buffer() {
1396        // Buffer too small should fail
1397        let buffer = vec![0u8; 1920 * 1080 * 4 - 1];
1398        let result = BorrowedVideoFrame::try_from_uncompressed(
1399            &buffer,
1400            1920,
1401            1080,
1402            PixelFormat::BGRA,
1403            30,
1404            1,
1405        );
1406        assert!(result.is_err());
1407        if let Err(Error::InvalidFrame(msg)) = result {
1408            assert!(msg.contains("Buffer too small"));
1409            assert!(msg.contains("BGRA"));
1410        }
1411    }
1412
1413    #[test]
1414    fn test_try_from_uncompressed_uyvy() {
1415        // UYVY format: 1920x1080x2 bytes
1416        let expected_size = 1920 * 1080 * 2;
1417        let buffer = vec![0u8; expected_size];
1418        let result = BorrowedVideoFrame::try_from_uncompressed(
1419            &buffer,
1420            1920,
1421            1080,
1422            PixelFormat::UYVY,
1423            60,
1424            1,
1425        );
1426        assert!(result.is_ok());
1427    }
1428
1429    #[test]
1430    fn test_try_from_uncompressed_nv12() {
1431        // NV12 planar format: Y plane + UV plane
1432        let width = 1920;
1433        let height = 1080;
1434        let expected_size = PixelFormat::NV12.try_buffer_size(width, height).unwrap();
1435        let buffer = vec![0u8; expected_size];
1436
1437        let result = BorrowedVideoFrame::try_from_uncompressed(
1438            &buffer,
1439            width,
1440            height,
1441            PixelFormat::NV12,
1442            30,
1443            1,
1444        );
1445        assert!(result.is_ok());
1446    }
1447
1448    #[test]
1449    fn test_try_from_uncompressed_i420() {
1450        // I420 planar format
1451        let width = 640;
1452        let height = 480;
1453        let expected_size = PixelFormat::I420.try_buffer_size(width, height).unwrap();
1454        let buffer = vec![0u8; expected_size];
1455
1456        let result = BorrowedVideoFrame::try_from_uncompressed(
1457            &buffer,
1458            width,
1459            height,
1460            PixelFormat::I420,
1461            30,
1462            1,
1463        );
1464        assert!(result.is_ok());
1465    }
1466
1467    #[test]
1468    fn test_try_from_uncompressed_rejects_invalid_layout() {
1469        let buffer = vec![0u8; 4096];
1470
1471        assert!(matches!(
1472            BorrowedVideoFrame::try_from_uncompressed(&buffer, 0, 480, PixelFormat::BGRA, 30, 1),
1473            Err(Error::InvalidFrame(_))
1474        ));
1475        assert!(matches!(
1476            BorrowedVideoFrame::try_from_uncompressed(&buffer, 640, -1, PixelFormat::BGRA, 30, 1),
1477            Err(Error::InvalidFrame(_))
1478        ));
1479        assert!(matches!(
1480            BorrowedVideoFrame::try_from_uncompressed(&buffer, 641, 480, PixelFormat::I420, 30, 1),
1481            Err(Error::InvalidFrame(_))
1482        ));
1483        assert!(matches!(
1484            BorrowedVideoFrame::try_from_uncompressed(&buffer, 640, 480, PixelFormat::BGRA, 0, 1),
1485            Err(Error::InvalidFrame(_))
1486        ));
1487    }
1488
1489    #[test]
1490    fn test_getters() {
1491        let buffer = vec![0u8; 1920 * 1080 * 4];
1492        let frame = BorrowedVideoFrame::try_from_uncompressed(
1493            &buffer,
1494            1920,
1495            1080,
1496            PixelFormat::BGRA,
1497            60,
1498            1,
1499        )
1500        .unwrap();
1501
1502        assert_eq!(frame.width(), 1920);
1503        assert_eq!(frame.height(), 1080);
1504        assert_eq!(frame.pixel_format(), PixelFormat::BGRA);
1505        assert_eq!(frame.frame_rate_n(), 60);
1506        assert_eq!(frame.frame_rate_d(), 1);
1507        assert_eq!(frame.picture_aspect_ratio(), 16.0 / 9.0);
1508        assert_eq!(frame.scan_type(), ScanType::Progressive);
1509        assert_eq!(frame.timecode(), NDIlib_send_timecode_synthesize);
1510        assert_eq!(frame.data().len(), buffer.len());
1511        assert!(frame.metadata().is_none());
1512        assert_eq!(frame.timestamp(), 0);
1513    }
1514
1515    #[test]
1516    fn test_all_pixel_formats_validation() {
1517        // Test that validation works correctly for all pixel formats
1518        let test_cases = vec![
1519            (PixelFormat::BGRA, 1920, 1080, 1920 * 1080 * 4),
1520            (PixelFormat::RGBA, 1920, 1080, 1920 * 1080 * 4),
1521            (PixelFormat::BGRX, 1920, 1080, 1920 * 1080 * 4),
1522            (PixelFormat::RGBX, 1920, 1080, 1920 * 1080 * 4),
1523            (PixelFormat::UYVY, 1920, 1080, 1920 * 1080 * 2),
1524            (PixelFormat::UYVA, 1920, 1080, 1920 * 1080 * 3),
1525            (PixelFormat::P216, 1920, 1080, 1920 * 1080 * 4),
1526            (PixelFormat::PA16, 1920, 1080, 1920 * 1080 * 6),
1527        ];
1528
1529        for (format, width, height, expected_min_size) in test_cases {
1530            // Exact size should work
1531            let buffer = vec![0u8; expected_min_size];
1532            let result =
1533                BorrowedVideoFrame::try_from_uncompressed(&buffer, width, height, format, 30, 1);
1534            assert!(result.is_ok(), "Failed for format {:?}", format);
1535
1536            // One byte too small should fail
1537            if expected_min_size > 0 {
1538                let buffer = vec![0u8; expected_min_size - 1];
1539                let result = BorrowedVideoFrame::try_from_uncompressed(
1540                    &buffer, width, height, format, 30, 1,
1541                );
1542                assert!(result.is_err(), "Should fail for undersized {:?}", format);
1543            }
1544        }
1545    }
1546
1547    #[test]
1548    fn test_planar_formats() {
1549        // Test planar 4:2:0 formats (NV12, I420, YV12)
1550        let width = 1920;
1551        let height = 1080;
1552
1553        for format in [PixelFormat::NV12, PixelFormat::I420, PixelFormat::YV12] {
1554            let expected_size = format.try_buffer_size(width, height).unwrap();
1555
1556            let buffer = vec![0u8; expected_size];
1557            let result =
1558                BorrowedVideoFrame::try_from_uncompressed(&buffer, width, height, format, 30, 1);
1559            assert!(result.is_ok(), "Failed for planar format {:?}", format);
1560
1561            // One byte too small should fail
1562            if expected_size > 0 {
1563                let buffer = vec![0u8; expected_size - 1];
1564                let result = BorrowedVideoFrame::try_from_uncompressed(
1565                    &buffer, width, height, format, 30, 1,
1566                );
1567                assert!(result.is_err(), "Should fail for undersized {:?}", format);
1568            }
1569        }
1570    }
1571}