Skip to main content

grafton_ndi/
frames.rs

1//! Frame types for video, audio, and metadata.
2
3use num_enum::{IntoPrimitive, TryFromPrimitive};
4
5#[cfg(feature = "image-encoding")]
6use std::borrow::Cow;
7use std::{
8    ffi::{CStr, CString},
9    fmt,
10    num::NonZeroUsize,
11    os::raw::c_char,
12    ptr, slice, str,
13};
14
15use crate::{
16    capture::{AudioKind, FrameFree, FrameSyncAudioFree, Guard, VideoKind},
17    ndi_lib::*,
18    recv_guard::{RecvAudioGuard, RecvMetadataGuard},
19    Error, Result,
20};
21
22/// Video pixel format identifiers (FourCC codes).
23///
24/// These represent the various pixel formats supported by NDI for video frames.
25/// The most common formats are BGRA/RGBA for full quality and UYVY for bandwidth-efficient streaming.
26///
27/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new formats
28/// without breaking existing code. Always use a wildcard pattern when matching.
29///
30/// # Examples
31///
32/// ```
33/// use grafton_ndi::PixelFormat;
34///
35/// // For maximum compatibility and quality
36/// let format = PixelFormat::BGRA;
37///
38/// // For bandwidth-efficient streaming
39/// let format = PixelFormat::UYVY;
40///
41/// // When matching, always include a wildcard for forward compatibility
42/// match format {
43///     PixelFormat::BGRA | PixelFormat::RGBA => println!("Full quality RGB"),
44///     PixelFormat::UYVY => println!("Compressed YUV"),
45///     _ => println!("Other format"),
46/// }
47/// ```
48#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50#[repr(u32)]
51pub enum PixelFormat {
52    /// YCbCr 4:2:2 format (16 bits per pixel) - bandwidth efficient.
53    UYVY = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_UYVY as _,
54    /// YCbCr 4:2:2 with alpha channel (24 bits per pixel).
55    UYVA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_UYVA as _,
56    /// 16-bit YCbCr 4:2:2 format.
57    P216 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_P216 as _,
58    /// 16-bit YCbCr 4:2:2 with alpha.
59    PA16 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_PA16 as _,
60    /// Planar YCbCr 4:2:0 format (12 bits per pixel).
61    YV12 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_YV12 as _,
62    /// Planar YCbCr 4:2:0 format (12 bits per pixel).
63    I420 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_I420 as _,
64    /// Semi-planar YCbCr 4:2:0 format (12 bits per pixel).
65    NV12 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_NV12 as _,
66    /// Blue-Green-Red-Alpha format (32 bits per pixel) - full quality.
67    BGRA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_BGRA as _,
68    /// Blue-Green-Red with padding (32 bits per pixel).
69    BGRX = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_BGRX as _,
70    /// Red-Green-Blue-Alpha format (32 bits per pixel) - full quality.
71    RGBA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_RGBA as _,
72    /// Red-Green-Blue with padding (32 bits per pixel).
73    RGBX = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_RGBX as _,
74}
75
76/// Category of pixel format for buffer calculation and union field access.
77///
78/// This enum distinguishes between different memory layouts:
79/// - **Packed**: All pixel components are interleaved in a single buffer
80/// - **Planar420**: Y, U, V planes stored separately with 4:2:0 chroma subsampling
81/// - **SemiPlanar420**: Y plane followed by interleaved UV plane (NV12)
82///
83/// This enum is marked `#[non_exhaustive]` because the NDI SDK's video FourCC
84/// namespace is open and grows across releases; future formats may introduce
85/// additional memory-layout categories.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum FormatCategory {
89    /// Packed formats: simple stride * height buffer.
90    Packed,
91    /// Planar 4:2:0 with separate U and V planes (YV12, I420).
92    Planar420,
93    /// Semi-planar 4:2:0 with interleaved UV plane (NV12).
94    SemiPlanar420,
95}
96
97/// Compile-time pixel format properties.
98///
99/// This struct encapsulates all format-specific knowledge in one location,
100/// providing a single source of truth for buffer size calculations, stride
101/// computation, and format category detection.
102///
103/// # Examples
104///
105/// ```
106/// use grafton_ndi::{PixelFormat, FormatCategory};
107///
108/// let info = PixelFormat::BGRA.info();
109/// assert_eq!(info.bytes_per_pixel(), 4);
110/// assert_eq!(info.category(), FormatCategory::Packed);
111///
112/// let info = PixelFormat::NV12.info();
113/// assert_eq!(info.bytes_per_pixel(), 1);
114/// assert_eq!(info.category(), FormatCategory::SemiPlanar420);
115/// ```
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct PixelFormatInfo {
118    /// Bytes per pixel for packed formats, or Y-plane bytes per pixel for planar formats.
119    bytes_per_pixel: u8,
120    /// Format category for union field access and buffer calculation.
121    category: FormatCategory,
122}
123
124impl PixelFormatInfo {
125    /// Get bytes per pixel for packed formats, or Y-plane bytes per pixel for planar.
126    #[must_use]
127    pub const fn bytes_per_pixel(&self) -> u8 {
128        self.bytes_per_pixel
129    }
130
131    /// Get the format category.
132    #[must_use]
133    pub const fn category(&self) -> FormatCategory {
134        self.category
135    }
136
137    /// Returns true if this is a planar 4:2:0 format (YV12, I420, or NV12).
138    #[must_use]
139    pub const fn is_planar_420(&self) -> bool {
140        matches!(
141            self.category,
142            FormatCategory::Planar420 | FormatCategory::SemiPlanar420
143        )
144    }
145
146    /// Calculate total buffer size for given dimensions and stride using
147    /// checked arithmetic.
148    ///
149    /// # Arguments
150    ///
151    /// * `y_stride` - The Y-plane line stride in bytes (for planar formats) or total line stride (for packed formats)
152    /// * `height` - Frame height in pixels
153    ///
154    /// # Errors
155    ///
156    /// Returns [`Error::InvalidFrame`] if `y_stride` or `height` is not
157    /// positive, if planar 4:2:0 stride/height requirements are not met, if
158    /// arithmetic overflows, or if the result exceeds the crate's maximum video
159    /// frame size.
160    ///
161    /// # Format-specific calculations
162    ///
163    /// - **Packed RGB/YUV** (BGRA/BGRX/RGBA/RGBX/UYVY/UYVA/P216/PA16): `y_stride * height`
164    /// - **Planar 4:2:0 YV12/I420**: requires even stride and height,
165    ///   then `Y + U + V` where:
166    ///   - Y plane: `y_stride * height`
167    ///   - U plane: `(y_stride/2) * (height/2)`
168    ///   - V plane: `(y_stride/2) * (height/2)`
169    /// - **Semi-planar 4:2:0 NV12**: requires even height, then `Y + UV` where:
170    ///   - Y plane: `y_stride * height`
171    ///   - UV plane: `y_stride * (height/2)`
172    pub fn try_buffer_len(&self, y_stride: i32, height: i32) -> Result<usize> {
173        if y_stride <= 0 {
174            return Err(Error::InvalidFrame(format!(
175                "Video line stride must be positive, got {y_stride}"
176            )));
177        }
178        if height <= 0 {
179            return Err(Error::InvalidFrame(format!(
180                "Video frame height must be positive, got {height}"
181            )));
182        }
183
184        let y_stride = usize::try_from(y_stride)
185            .map_err(|_| Error::InvalidFrame(format!("Invalid y_stride value: {y_stride}")))?;
186        let height = usize::try_from(height)
187            .map_err(|_| Error::InvalidFrame(format!("Invalid height value: {height}")))?;
188
189        let len = calculate_buffer_len_for_info_checked(*self, y_stride, height)?;
190        validate_video_data_len(len)?;
191        Ok(len)
192    }
193}
194
195impl PixelFormat {
196    /// Get compile-time format properties.
197    ///
198    /// This provides a single source of truth for all format-specific knowledge,
199    /// including bytes per pixel and format category.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use grafton_ndi::{PixelFormat, FormatCategory};
205    ///
206    /// // Get properties for BGRA (32 bpp packed)
207    /// let info = PixelFormat::BGRA.info();
208    /// assert_eq!(info.bytes_per_pixel(), 4);
209    /// assert_eq!(info.category(), FormatCategory::Packed);
210    ///
211    /// // Get properties for YV12 (planar 4:2:0)
212    /// let info = PixelFormat::YV12.info();
213    /// assert_eq!(info.bytes_per_pixel(), 1);
214    /// assert_eq!(info.category(), FormatCategory::Planar420);
215    /// ```
216    #[must_use]
217    pub const fn info(self) -> PixelFormatInfo {
218        match self {
219            Self::BGRA | Self::BGRX | Self::RGBA | Self::RGBX => PixelFormatInfo {
220                bytes_per_pixel: 4,
221                category: FormatCategory::Packed,
222            },
223            Self::UYVY => PixelFormatInfo {
224                bytes_per_pixel: 2,
225                category: FormatCategory::Packed,
226            },
227            Self::UYVA => PixelFormatInfo {
228                bytes_per_pixel: 3,
229                category: FormatCategory::Packed,
230            },
231            Self::P216 | Self::PA16 => PixelFormatInfo {
232                bytes_per_pixel: 4,
233                category: FormatCategory::Packed,
234            },
235            Self::YV12 | Self::I420 => PixelFormatInfo {
236                bytes_per_pixel: 1,
237                category: FormatCategory::Planar420,
238            },
239            Self::NV12 => PixelFormatInfo {
240                bytes_per_pixel: 1,
241                category: FormatCategory::SemiPlanar420,
242            },
243        }
244    }
245
246    /// Calculate line stride in bytes for a given width using checked
247    /// arithmetic.
248    ///
249    /// For packed formats, this returns the total bytes per row.
250    /// For planar formats, this returns the Y-plane stride.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use grafton_ndi::PixelFormat;
256    ///
257    /// // BGRA: 4 bytes per pixel
258    /// assert_eq!(PixelFormat::BGRA.try_line_stride(1920)?, 7680);
259    ///
260    /// // UYVY: 2 bytes per pixel
261    /// assert_eq!(PixelFormat::UYVY.try_line_stride(1920)?, 3840);
262    ///
263    /// // NV12: Y-plane has 1 byte per pixel
264    /// assert_eq!(PixelFormat::NV12.try_line_stride(1920)?, 1920);
265    /// # Ok::<(), grafton_ndi::Error>(())
266    /// ```
267    ///
268    /// # Errors
269    ///
270    /// Returns [`Error::InvalidFrame`] if the width is invalid for this format
271    /// or if the stride does not fit in `i32`.
272    pub fn try_line_stride(self, width: i32) -> Result<i32> {
273        validate_video_width_for_format(self, width)?;
274        let width_usize = usize::try_from(width)
275            .map_err(|_| Error::InvalidFrame(format!("Invalid width value: {width}")))?;
276        let stride = min_video_line_stride_checked(self, width_usize)?;
277        i32::try_from(stride).map_err(|_| {
278            Error::InvalidFrame(format!("Video line stride {stride} exceeds i32 range"))
279        })
280    }
281
282    /// Calculate the total buffer size needed for a frame with given dimensions
283    /// using checked arithmetic.
284    ///
285    /// This computes the validated minimum stride from the width and delegates
286    /// to the shared video layout validator.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use grafton_ndi::PixelFormat;
292    ///
293    /// // BGRA 1920x1080: 1920 * 4 * 1080 = 8,294,400 bytes
294    /// assert_eq!(PixelFormat::BGRA.try_buffer_size(1920, 1080)?, 8_294_400);
295    ///
296    /// // NV12 1920x1080: Y (1920*1080) + UV (1920*540) = 3,110,400 bytes
297    /// assert_eq!(PixelFormat::NV12.try_buffer_size(1920, 1080)?, 3_110_400);
298    /// # Ok::<(), grafton_ndi::Error>(())
299    /// ```
300    ///
301    /// # Errors
302    ///
303    /// Returns [`Error::InvalidFrame`] if dimensions are invalid for this
304    /// format, if arithmetic overflows, or if the result exceeds the crate's
305    /// maximum video frame size.
306    pub fn try_buffer_size(self, width: i32, height: i32) -> Result<usize> {
307        let layout = ValidatedVideoLayout::new_uncompressed(self, width, height, None)?;
308        Ok(layout.data_len_bytes)
309    }
310}
311
312impl From<PixelFormat> for i32 {
313    fn from(value: PixelFormat) -> Self {
314        let u32_value: u32 = value.into();
315        u32_value as i32
316    }
317}
318
319/// Video scan type (progressive, interlaced, or field-based).
320///
321/// This enum describes how video frames are scanned/displayed.
322/// Most modern content uses Progressive, while legacy broadcast may use Interlaced or field-based formats.
323///
324/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new scan types
325/// without breaking existing code. Always use a wildcard pattern when matching.
326///
327/// # Examples
328///
329/// ```
330/// use grafton_ndi::ScanType;
331///
332/// let scan = ScanType::Progressive;
333///
334/// // When matching, always include a wildcard for forward compatibility
335/// match scan {
336///     ScanType::Progressive => println!("Progressive scan"),
337///     ScanType::Interlaced => println!("Interlaced"),
338///     _ => println!("Field-based or other"),
339/// }
340/// ```
341#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
342#[non_exhaustive]
343#[repr(u32)]
344pub enum ScanType {
345    /// Progressive scan - full frames rendered sequentially.
346    Progressive = NDIlib_frame_format_type_e_NDIlib_frame_format_type_progressive as _,
347    /// Interlaced scan - alternating even/odd lines.
348    Interlaced = NDIlib_frame_format_type_e_NDIlib_frame_format_type_interleaved as _,
349    /// Field 0 only (first field of interlaced content).
350    Field0 = NDIlib_frame_format_type_e_NDIlib_frame_format_type_field_0 as _,
351    /// Field 1 only (second field of interlaced content).
352    Field1 = NDIlib_frame_format_type_e_NDIlib_frame_format_type_field_1 as _,
353}
354
355impl From<ScanType> for i32 {
356    fn from(value: ScanType) -> Self {
357        let u32_value: u32 = value.into();
358        u32_value as i32
359    }
360}
361
362/// Line stride or data size for video frames.
363///
364/// This enum represents the choice between line stride (for uncompressed formats)
365/// and total data size (for compressed or opaque formats). The discriminant is
366/// determined by the video format (FourCC).
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum LineStrideOrSize {
369    /// Line stride in bytes for uncompressed formats.
370    /// This is the number of bytes per row of video data.
371    LineStrideBytes(i32),
372    /// Total data size in bytes for compressed or opaque formats.
373    DataSizeBytes(i32),
374}
375
376impl From<LineStrideOrSize> for NDIlib_video_frame_v2_t__bindgen_ty_1 {
377    fn from(value: LineStrideOrSize) -> Self {
378        // Writing to a union field is safe when the field type implements Copy.
379        // We write exactly one field of the union based on the enum variant.
380        match value {
381            LineStrideOrSize::LineStrideBytes(stride) =>
382            {
383                #[allow(clippy::field_reassign_with_default)]
384                NDIlib_video_frame_v2_t__bindgen_ty_1 {
385                    line_stride_in_bytes: stride,
386                }
387            }
388            LineStrideOrSize::DataSizeBytes(size) => NDIlib_video_frame_v2_t__bindgen_ty_1 {
389                data_size_in_bytes: size,
390            },
391        }
392    }
393}
394
395pub struct VideoFrame {
396    layout: ValidatedVideoLayout,
397    frame_rate_n: i32,
398    frame_rate_d: i32,
399    picture_aspect_ratio: f32,
400    scan_type: ScanType,
401    timecode: i64,
402    data: Vec<u8>,
403    metadata: Option<FrameMetadata>,
404    timestamp: i64,
405}
406
407impl fmt::Debug for VideoFrame {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        f.debug_struct("VideoFrame")
410            .field("width", &self.width())
411            .field("height", &self.height())
412            .field("pixel_format", &self.pixel_format())
413            .field("frame_rate_n", &self.frame_rate_n)
414            .field("frame_rate_d", &self.frame_rate_d)
415            .field("picture_aspect_ratio", &self.picture_aspect_ratio)
416            .field("scan_type", &self.scan_type)
417            .field("timecode", &self.timecode)
418            .field("data (bytes)", &self.data.len())
419            .field("line_stride_or_size", &self.line_stride_or_size())
420            .field("metadata", &self.metadata())
421            .field("timestamp", &self.timestamp)
422            .finish()
423    }
424}
425
426impl Default for VideoFrame {
427    fn default() -> Self {
428        VideoFrame::builder()
429            .resolution(1920, 1080)
430            .pixel_format(PixelFormat::BGRA)
431            .frame_rate(60, 1)
432            .aspect_ratio(16.0 / 9.0)
433            .scan_type(ScanType::Interlaced)
434            .build()
435            .expect("Default VideoFrame should always succeed")
436    }
437}
438
439impl VideoFrame {
440    pub(crate) fn to_raw(&self) -> NDIlib_video_frame_v2_t {
441        NDIlib_video_frame_v2_t {
442            xres: self.layout.width,
443            yres: self.layout.height,
444            FourCC: self.layout.pixel_format.into(),
445            frame_rate_N: self.frame_rate_n,
446            frame_rate_D: self.frame_rate_d,
447            picture_aspect_ratio: self.picture_aspect_ratio,
448            frame_format_type: self.scan_type.into(),
449            timecode: self.timecode,
450            p_data: self.data.as_ptr() as *mut u8,
451            __bindgen_anon_1: self.layout.line_stride_or_size.into(),
452            p_metadata: self
453                .metadata
454                .as_ref()
455                .map_or(ptr::null(), FrameMetadata::as_ptr),
456            timestamp: self.timestamp,
457        }
458    }
459
460    /// Get the frame width in pixels.
461    pub fn width(&self) -> i32 {
462        self.layout.width
463    }
464
465    /// Get the frame height in pixels.
466    pub fn height(&self) -> i32 {
467        self.layout.height
468    }
469
470    /// Get the supported pixel format.
471    pub fn pixel_format(&self) -> PixelFormat {
472        self.layout.pixel_format
473    }
474
475    /// Get the frame rate numerator.
476    pub fn frame_rate_n(&self) -> i32 {
477        self.frame_rate_n
478    }
479
480    /// Get the frame rate denominator.
481    pub fn frame_rate_d(&self) -> i32 {
482        self.frame_rate_d
483    }
484
485    /// Get the picture aspect ratio.
486    pub fn picture_aspect_ratio(&self) -> f32 {
487        self.picture_aspect_ratio
488    }
489
490    /// Get the scan type.
491    pub fn scan_type(&self) -> ScanType {
492        self.scan_type
493    }
494
495    /// Get the timecode.
496    ///
497    /// A value of zero is passed through to the SDK as its default timestamp
498    /// behavior.
499    pub fn timecode(&self) -> i64 {
500        self.timecode
501    }
502
503    /// Get the timestamp.
504    ///
505    /// A value of zero is passed through to the SDK as its default timestamp
506    /// behavior.
507    pub fn timestamp(&self) -> i64 {
508        self.timestamp
509    }
510
511    /// Get the validated line stride or data size union field.
512    pub fn line_stride_or_size(&self) -> LineStrideOrSize {
513        self.layout.line_stride_or_size
514    }
515
516    pub(crate) fn validated_layout(&self) -> ValidatedVideoLayout {
517        self.layout
518    }
519
520    /// Get the frame data.
521    pub fn data(&self) -> &[u8] {
522        &self.data
523    }
524
525    /// Get mutable access to the frame data without changing the validated
526    /// layout.
527    pub fn data_mut(&mut self) -> &mut [u8] {
528        &mut self.data
529    }
530
531    /// Replace the owned frame data while preserving the validated layout.
532    ///
533    /// # Errors
534    ///
535    /// Returns [`Error::InvalidFrame`] if `data` is not exactly the validated
536    /// layout size.
537    pub fn replace_data(&mut self, data: Vec<u8>) -> Result<()> {
538        if data.len() != self.layout.data_len_bytes {
539            return Err(Error::InvalidFrame(format!(
540                "Video data length {}, expected {} bytes for validated layout",
541                data.len(),
542                self.layout.data_len_bytes
543            )));
544        }
545
546        self.data = data;
547        Ok(())
548    }
549
550    /// Get frame metadata as UTF-8 text, if present.
551    pub fn metadata(&self) -> Option<&str> {
552        self.metadata.as_ref().map(FrameMetadata::as_str)
553    }
554
555    pub(crate) fn metadata_cstr(&self) -> Option<&CStr> {
556        self.metadata.as_ref().map(FrameMetadata::as_cstr)
557    }
558
559    /// Replace frame metadata.
560    ///
561    /// # Errors
562    ///
563    /// Returns [`Error::InvalidCString`] if `metadata` contains an interior NUL
564    /// byte, or [`Error::InvalidFrame`] if the emitted C string would exceed
565    /// the metadata size cap.
566    pub fn set_metadata<S: Into<String>>(&mut self, metadata: Option<S>) -> Result<()> {
567        self.metadata = metadata.map(FrameMetadata::new).transpose()?;
568        Ok(())
569    }
570
571    /// Set the frame rate.
572    ///
573    /// # Errors
574    ///
575    /// Returns [`Error::InvalidFrame`] if the numerator or denominator is not
576    /// positive.
577    pub fn set_frame_rate(&mut self, numerator: i32, denominator: i32) -> Result<()> {
578        validate_video_frame_metadata(numerator, denominator, self.picture_aspect_ratio)?;
579        self.frame_rate_n = numerator;
580        self.frame_rate_d = denominator;
581        Ok(())
582    }
583
584    /// Set the picture aspect ratio.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`Error::InvalidFrame`] if `ratio` is not finite and positive.
589    pub fn set_picture_aspect_ratio(&mut self, ratio: f32) -> Result<()> {
590        validate_video_frame_metadata(self.frame_rate_n, self.frame_rate_d, ratio)?;
591        self.picture_aspect_ratio = ratio;
592        Ok(())
593    }
594
595    /// Encode the video frame as PNG bytes.
596    ///
597    /// This method encodes the frame to PNG format, automatically handling color format
598    /// conversion from the NDI frame format (BGRA/RGBA/etc.) to PNG-compatible RGBA.
599    ///
600    /// # Supported Formats
601    ///
602    /// - `RGBA`: Direct encoding when rows are tightly packed
603    /// - `BGRA`: Swaps red and blue channels and preserves alpha
604    /// - `RGBX`: Treats the fourth byte as padding and writes opaque alpha
605    /// - `BGRX`: Swaps red/blue and writes opaque alpha
606    /// - Other formats: Returns an error (unsupported for now)
607    ///
608    /// # Stride Handling
609    ///
610    /// This method consumes active pixels row-by-row according to the frame's
611    /// validated line stride. Valid row padding is skipped.
612    ///
613    /// # Errors
614    ///
615    /// Returns an error if:
616    /// - The frame format is not RGBA/RGBX/BGRA/BGRX
617    /// - The frame uses data-size layout instead of line-stride layout
618    /// - The backing data length does not match the validated layout
619    /// - PNG encoding fails
620    ///
621    /// # Example
622    ///
623    /// ```no_run
624    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat};
625    /// # use std::time::Duration;
626    /// # fn main() -> Result<(), grafton_ndi::Error> {
627    /// # let ndi = NDI::new()?;
628    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
629    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
630    /// # let sources = finder.current_sources()?;
631    /// # let options = ReceiverOptions::builder(sources[0].clone())
632    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
633    /// #     .build();
634    /// # let receiver = Receiver::new(&ndi, &options)?;
635    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
636    /// let png_bytes = video_frame.encode_png()?;
637    /// std::fs::write("frame.png", &png_bytes)?;
638    /// # Ok(())
639    /// # }
640    /// ```
641    #[cfg(feature = "image-encoding")]
642    pub fn encode_png(&self) -> Result<Vec<u8>> {
643        use png::{BitDepth, ColorType, Encoder};
644
645        let image = ImagePixelSource::new(self.layout, &self.data)?;
646        let (rgba_data, width, height) = image.png_rgba_input()?;
647
648        // Encode to PNG
649        let mut png_data = Vec::new();
650        let mut encoder = Encoder::new(&mut png_data, width, height);
651        encoder.set_color(ColorType::Rgba);
652        encoder.set_depth(BitDepth::Eight);
653
654        encoder
655            .write_header()
656            .and_then(|mut writer| writer.write_image_data(rgba_data.as_ref()))
657            .map_err(|e| Error::InvalidFrame(format!("PNG encoding failed: {e}")))?;
658
659        Ok(png_data)
660    }
661
662    /// Encode the video frame as JPEG bytes with the specified quality.
663    ///
664    /// This method encodes the frame to JPEG format, automatically handling color format
665    /// conversion from the NDI frame format to JPEG-compatible RGB.
666    ///
667    /// # Arguments
668    ///
669    /// * `quality` - JPEG quality from 1 (lowest) to 100 (highest). Typical values are 80-95.
670    ///
671    /// # Supported Formats
672    ///
673    /// - `RGBA` / `RGBX`: Emits RGB
674    /// - `BGRA` / `BGRX`: Swaps red/blue and emits RGB
675    /// - Other formats: Returns an error (unsupported for now)
676    ///
677    /// # Errors
678    ///
679    /// Returns an error if:
680    /// - The frame format is not RGBA/RGBX/BGRA/BGRX
681    /// - The frame uses data-size layout instead of line-stride layout
682    /// - The backing data length does not match the validated layout
683    /// - The quality is outside `1..=100`
684    /// - The dimensions exceed JPEG's `u16` width/height range
685    /// - JPEG encoding fails
686    ///
687    /// # Example
688    ///
689    /// ```no_run
690    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat};
691    /// # use std::time::Duration;
692    /// # fn main() -> Result<(), grafton_ndi::Error> {
693    /// # let ndi = NDI::new()?;
694    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
695    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
696    /// # let sources = finder.current_sources()?;
697    /// # let options = ReceiverOptions::builder(sources[0].clone())
698    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
699    /// #     .build();
700    /// # let receiver = Receiver::new(&ndi, &options)?;
701    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
702    /// let jpeg_bytes = video_frame.encode_jpeg(85)?;
703    /// std::fs::write("frame.jpg", &jpeg_bytes)?;
704    /// # Ok(())
705    /// # }
706    /// ```
707    #[cfg(feature = "image-encoding")]
708    pub fn encode_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
709        use jpeg_encoder::{ColorType as JpegColorType, Encoder as JpegEncoder};
710
711        let image = ImagePixelSource::new(self.layout, &self.data)?;
712        let (rgb_data, width, height) = image.jpeg_rgb_input(quality)?;
713
714        // Encode to JPEG
715        let mut jpeg_data = Vec::new();
716        let encoder = JpegEncoder::new(&mut jpeg_data, quality);
717        encoder
718            .encode(&rgb_data, width, height, JpegColorType::Rgb)
719            .map_err(|e| Error::InvalidFrame(format!("JPEG encoding failed: {e}")))?;
720
721        Ok(jpeg_data)
722    }
723
724    /// Encode the video frame as a base64 data URL for embedding in HTML/JSON.
725    ///
726    /// This produces a string in the format: `data:image/png;base64,...` or
727    /// `data:image/jpeg;base64,...` that can be directly used in HTML `<img>` tags
728    /// or stored in JSON.
729    ///
730    /// # Arguments
731    ///
732    /// * `format` - The image format to use (PNG or JPEG with quality)
733    ///
734    /// # Example
735    ///
736    /// ```no_run
737    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat, ImageFormat};
738    /// # use std::time::Duration;
739    /// # fn main() -> Result<(), grafton_ndi::Error> {
740    /// # let ndi = NDI::new()?;
741    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
742    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
743    /// # let sources = finder.current_sources()?;
744    /// # let options = ReceiverOptions::builder(sources[0].clone())
745    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
746    /// #     .build();
747    /// # let receiver = Receiver::new(&ndi, &options)?;
748    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
749    ///
750    /// // As PNG
751    /// let data_url = video_frame.encode_data_url(ImageFormat::Png)?;
752    /// println!("<img src=\"{}\">", data_url);
753    ///
754    /// // As JPEG with quality 90
755    /// let data_url = video_frame.encode_data_url(ImageFormat::Jpeg(90))?;
756    /// # Ok(())
757    /// # }
758    /// ```
759    #[cfg(feature = "image-encoding")]
760    pub fn encode_data_url(&self, format: ImageFormat) -> Result<String> {
761        use base64::{engine::general_purpose::STANDARD, Engine};
762
763        let (mime_type, image_bytes) = match format {
764            ImageFormat::Png => ("image/png", self.encode_png()?),
765            ImageFormat::Jpeg(quality) => ("image/jpeg", self.encode_jpeg(quality)?),
766        };
767
768        let base64_data = STANDARD.encode(&image_bytes);
769        Ok(format!("data:{mime_type};base64,{base64_data}"))
770    }
771
772    /// Creates a `VideoFrame` from a raw NDI video frame with owned data.
773    ///
774    /// # Safety
775    ///
776    /// This function assumes the given `NDIlib_video_frame_v2_t` is valid and correctly allocated.
777    /// This method copies the data, so the VideoFrame owns its data and can outlive the source.
778    pub unsafe fn from_raw(c_frame: &NDIlib_video_frame_v2_t) -> Result<VideoFrame> {
779        // Use the shared validation helper to validate and compute layout
780        let layout = validate_video_layout(c_frame)?;
781        let metadata_layout = unsafe { validate_frame_metadata(c_frame.p_metadata)? };
782
783        Self::from_raw_validated(c_frame, layout, metadata_layout)
784    }
785
786    pub(crate) unsafe fn from_raw_validated(
787        c_frame: &NDIlib_video_frame_v2_t,
788        layout: ValidatedVideoLayout,
789        metadata_layout: ValidatedFrameMetadata,
790    ) -> Result<VideoFrame> {
791        // Copy data for ownership
792        let slice = slice::from_raw_parts(c_frame.p_data, layout.data_len_bytes);
793        let data = slice.to_vec();
794
795        let metadata =
796            unsafe { FrameMetadata::copy_from_raw_validated(c_frame.p_metadata, metadata_layout) };
797
798        #[allow(clippy::unnecessary_cast)] // Required for Windows where frame_format_type is i32
799        let scan_type = ScanType::try_from(c_frame.frame_format_type as u32).map_err(|_| {
800            Error::InvalidFrame(format!(
801                "Unknown scan type: 0x{:08X}",
802                c_frame.frame_format_type
803            ))
804        })?;
805
806        Ok(VideoFrame {
807            layout,
808            frame_rate_n: c_frame.frame_rate_N,
809            frame_rate_d: c_frame.frame_rate_D,
810            picture_aspect_ratio: c_frame.picture_aspect_ratio,
811            scan_type,
812            timecode: c_frame.timecode,
813            data,
814            metadata,
815            timestamp: c_frame.timestamp,
816        })
817    }
818
819    /// Create a builder for configuring a video frame
820    pub fn builder() -> VideoFrameBuilder {
821        VideoFrameBuilder::new()
822    }
823}
824
825/// Builder for configuring a VideoFrame with ergonomic method chaining
826#[derive(Debug, Clone)]
827pub struct VideoFrameBuilder {
828    width: Option<i32>,
829    height: Option<i32>,
830    pixel_format: Option<PixelFormat>,
831    frame_rate_n: Option<i32>,
832    frame_rate_d: Option<i32>,
833    picture_aspect_ratio: Option<f32>,
834    scan_type: Option<ScanType>,
835    timecode: Option<i64>,
836    metadata: Option<String>,
837    timestamp: Option<i64>,
838}
839
840impl VideoFrameBuilder {
841    /// Create a new builder with no fields set
842    pub fn new() -> Self {
843        Self {
844            width: None,
845            height: None,
846            pixel_format: None,
847            frame_rate_n: None,
848            frame_rate_d: None,
849            picture_aspect_ratio: None,
850            scan_type: None,
851            timecode: None,
852            metadata: None,
853            timestamp: None,
854        }
855    }
856
857    /// Set the video resolution
858    #[must_use]
859    pub fn resolution(mut self, width: i32, height: i32) -> Self {
860        self.width = Some(width);
861        self.height = Some(height);
862        self
863    }
864
865    /// Set the pixel format
866    #[must_use]
867    pub fn pixel_format(mut self, pixel_format: PixelFormat) -> Self {
868        self.pixel_format = Some(pixel_format);
869        self
870    }
871
872    /// Set the frame rate as a fraction (e.g., 30000/1001 for 29.97fps)
873    #[must_use]
874    pub fn frame_rate(mut self, numerator: i32, denominator: i32) -> Self {
875        self.frame_rate_n = Some(numerator);
876        self.frame_rate_d = Some(denominator);
877        self
878    }
879
880    /// Set the picture aspect ratio
881    #[must_use]
882    pub fn aspect_ratio(mut self, ratio: f32) -> Self {
883        self.picture_aspect_ratio = Some(ratio);
884        self
885    }
886
887    /// Set the scan type (progressive, interlaced, etc.)
888    #[must_use]
889    pub fn scan_type(mut self, scan_type: ScanType) -> Self {
890        self.scan_type = Some(scan_type);
891        self
892    }
893
894    /// Set the timecode
895    #[must_use]
896    pub fn timecode(mut self, tc: i64) -> Self {
897        self.timecode = Some(tc);
898        self
899    }
900
901    /// Set metadata
902    #[must_use]
903    pub fn metadata<S: Into<String>>(mut self, meta: S) -> Self {
904        self.metadata = Some(meta.into());
905        self
906    }
907
908    /// Set the timestamp
909    #[must_use]
910    pub fn timestamp(mut self, ts: i64) -> Self {
911        self.timestamp = Some(ts);
912        self
913    }
914
915    /// Build the VideoFrame
916    pub fn build(self) -> Result<VideoFrame> {
917        let width = self.width.unwrap_or(1920);
918        let height = self.height.unwrap_or(1080);
919        let pixel_format = self.pixel_format.unwrap_or(PixelFormat::BGRA);
920        let frame_rate_n = self.frame_rate_n.unwrap_or(60);
921        let frame_rate_d = self.frame_rate_d.unwrap_or(1);
922        let picture_aspect_ratio = self.picture_aspect_ratio.unwrap_or(16.0 / 9.0);
923        let scan_type = self.scan_type.unwrap_or(ScanType::Progressive);
924
925        validate_video_frame_metadata(frame_rate_n, frame_rate_d, picture_aspect_ratio)?;
926        let layout = ValidatedVideoLayout::new_uncompressed(pixel_format, width, height, None)?;
927        let buffer_size = layout.data_len_bytes;
928        let data = vec![0u8; buffer_size];
929
930        let metadata = self.metadata.map(FrameMetadata::new).transpose()?;
931
932        Ok(VideoFrame {
933            layout,
934            frame_rate_n,
935            frame_rate_d,
936            picture_aspect_ratio,
937            scan_type,
938            timecode: self.timecode.unwrap_or(0),
939            data,
940            metadata,
941            timestamp: self.timestamp.unwrap_or(0),
942        })
943    }
944}
945
946impl Default for VideoFrameBuilder {
947    fn default() -> Self {
948        Self::new()
949    }
950}
951
952impl Drop for VideoFrame {
953    fn drop(&mut self) {
954        // With owned data, we don't need to free SDK pointers anymore
955        // The Vec<u8> handles memory cleanup automatically
956    }
957}
958
959#[derive(Debug)]
960pub struct AudioFrame {
961    layout: ValidatedAudioLayout,
962    timecode: i64,
963    data: Vec<f32>,
964    metadata: Option<FrameMetadata>,
965    timestamp: i64,
966}
967
968impl AudioFrame {
969    pub(crate) fn to_raw(&self) -> NDIlib_audio_frame_v3_t {
970        NDIlib_audio_frame_v3_t {
971            sample_rate: self.layout.sample_rate,
972            no_channels: self.num_channels(),
973            no_samples: self.num_samples(),
974            timecode: self.timecode,
975            FourCC: self.format().into(),
976            p_data: self.data.as_ptr() as *mut f32 as *mut u8,
977            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
978                channel_stride_in_bytes: self.layout.channel_stride_in_bytes,
979            },
980            p_metadata: self
981                .metadata
982                .as_ref()
983                .map_or(ptr::null(), FrameMetadata::as_ptr),
984            timestamp: self.timestamp,
985        }
986    }
987
988    #[cfg(test)]
989    pub(crate) fn from_raw(raw: NDIlib_audio_frame_v3_t) -> Result<AudioFrame> {
990        // Use the shared validation helper to validate and compute layout
991        let layout = validate_audio_layout(&raw)?;
992        let metadata_layout = unsafe { validate_frame_metadata(raw.p_metadata)? };
993
994        Self::from_raw_validated(raw, layout, metadata_layout)
995    }
996
997    pub(crate) fn from_raw_validated(
998        raw: NDIlib_audio_frame_v3_t,
999        layout: ValidatedAudioLayout,
1000        metadata_layout: ValidatedFrameMetadata,
1001    ) -> Result<AudioFrame> {
1002        if layout.is_empty() {
1003            return Err(Error::InvalidFrame(
1004                "Cannot create owned AudioFrame from an empty audio layout".into(),
1005            ));
1006        }
1007
1008        // Copy data for ownership
1009        let slice = unsafe { slice::from_raw_parts(raw.p_data as *const f32, layout.sample_count) };
1010        let data = slice.to_vec();
1011
1012        // Copy the string, don't take ownership - SDK will free the original.
1013        let metadata =
1014            unsafe { FrameMetadata::copy_from_raw_validated(raw.p_metadata, metadata_layout) };
1015
1016        Ok(AudioFrame {
1017            layout,
1018            timecode: raw.timecode,
1019            data,
1020            metadata,
1021            timestamp: raw.timestamp,
1022        })
1023    }
1024
1025    /// Create a builder for configuring an audio frame
1026    pub fn builder() -> AudioFrameBuilder {
1027        AudioFrameBuilder::new()
1028    }
1029
1030    /// Get the sample rate in Hz.
1031    pub fn sample_rate(&self) -> i32 {
1032        self.layout.sample_rate
1033    }
1034
1035    /// Get the number of audio channels.
1036    pub fn num_channels(&self) -> i32 {
1037        self.layout.no_channels as i32
1038    }
1039
1040    /// Get the number of samples per channel.
1041    pub fn num_samples(&self) -> i32 {
1042        self.layout.no_samples as i32
1043    }
1044
1045    /// Get the timecode.
1046    ///
1047    /// A value of zero is passed through to the SDK as its default timestamp
1048    /// behavior.
1049    pub fn timecode(&self) -> i64 {
1050        self.timecode
1051    }
1052
1053    /// Get the timestamp.
1054    ///
1055    /// A value of zero is passed through to the SDK as its default timestamp
1056    /// behavior.
1057    pub fn timestamp(&self) -> i64 {
1058        self.timestamp
1059    }
1060
1061    /// Get the audio format.
1062    pub fn format(&self) -> AudioFormat {
1063        self.layout
1064            .format()
1065            .expect("owned AudioFrame always has a concrete audio format")
1066    }
1067
1068    /// Get the channel stride in bytes.
1069    pub fn channel_stride_in_bytes(&self) -> i32 {
1070        self.layout.channel_stride_in_bytes
1071    }
1072
1073    /// Get audio data as 32-bit floats
1074    pub fn data(&self) -> &[f32] {
1075        &self.data
1076    }
1077
1078    /// Get mutable audio sample data without changing the validated layout.
1079    pub fn data_mut(&mut self) -> &mut [f32] {
1080        &mut self.data
1081    }
1082
1083    /// Replace the owned audio data while preserving the validated layout.
1084    ///
1085    /// # Errors
1086    ///
1087    /// Returns [`Error::InvalidFrame`] if `data` is not exactly the validated
1088    /// sample count.
1089    pub fn replace_data(&mut self, data: Vec<f32>) -> Result<()> {
1090        if data.len() != self.layout.sample_count {
1091            return Err(Error::InvalidFrame(format!(
1092                "Audio data length {}, expected {} samples for validated layout",
1093                data.len(),
1094                self.layout.sample_count
1095            )));
1096        }
1097
1098        self.data = data;
1099        Ok(())
1100    }
1101
1102    /// Get frame metadata as UTF-8 text, if present.
1103    pub fn metadata(&self) -> Option<&str> {
1104        self.metadata.as_ref().map(FrameMetadata::as_str)
1105    }
1106
1107    /// Replace frame metadata.
1108    ///
1109    /// # Errors
1110    ///
1111    /// Returns [`Error::InvalidCString`] if `metadata` contains an interior NUL
1112    /// byte, or [`Error::InvalidFrame`] if the emitted C string would exceed
1113    /// the metadata size cap.
1114    pub fn set_metadata<S: Into<String>>(&mut self, metadata: Option<S>) -> Result<()> {
1115        self.metadata = metadata.map(FrameMetadata::new).transpose()?;
1116        Ok(())
1117    }
1118
1119    /// Get audio data for a specific channel
1120    ///
1121    /// Data is always stored in planar format internally. If `AudioLayout::Interleaved`
1122    /// was specified at build time, the data was converted to planar during construction.
1123    pub fn channel_data(&self, channel: usize) -> Option<Vec<f32>> {
1124        let range = self.layout.channel_range(channel)?;
1125        Some(self.data[range].to_vec())
1126    }
1127}
1128
1129/// Builder for configuring an AudioFrame with ergonomic method chaining
1130#[derive(Debug, Clone)]
1131pub struct AudioFrameBuilder {
1132    sample_rate: Option<i32>,
1133    num_channels: Option<i32>,
1134    num_samples: Option<i32>,
1135    timecode: Option<i64>,
1136    format: Option<AudioFormat>,
1137    data: Option<Vec<f32>>,
1138    layout: Option<AudioLayout>,
1139    metadata: Option<String>,
1140    timestamp: Option<i64>,
1141}
1142
1143impl AudioFrameBuilder {
1144    /// Create a new builder with no fields set
1145    pub fn new() -> Self {
1146        Self {
1147            sample_rate: None,
1148            num_channels: None,
1149            num_samples: None,
1150            timecode: None,
1151            format: None,
1152            data: None,
1153            layout: None,
1154            metadata: None,
1155            timestamp: None,
1156        }
1157    }
1158
1159    /// Set the sample rate
1160    #[must_use]
1161    pub fn sample_rate(mut self, rate: i32) -> Self {
1162        self.sample_rate = Some(rate);
1163        self
1164    }
1165
1166    /// Set the number of audio channels
1167    #[must_use]
1168    pub fn channels(mut self, channels: i32) -> Self {
1169        self.num_channels = Some(channels);
1170        self
1171    }
1172
1173    /// Set the number of samples
1174    #[must_use]
1175    pub fn samples(mut self, samples: i32) -> Self {
1176        self.num_samples = Some(samples);
1177        self
1178    }
1179
1180    /// Set the timecode
1181    #[must_use]
1182    pub fn timecode(mut self, tc: i64) -> Self {
1183        self.timecode = Some(tc);
1184        self
1185    }
1186
1187    /// Set the audio format
1188    #[must_use]
1189    pub fn format(mut self, format: AudioFormat) -> Self {
1190        self.format = Some(format);
1191        self
1192    }
1193
1194    /// Set the audio data layout (planar or interleaved)
1195    ///
1196    /// - **Planar**: All samples for channel 0, then all for channel 1, etc.
1197    /// - **Interleaved**: Samples from all channels are interleaved.
1198    ///
1199    /// Defaults to `AudioLayout::Planar` which is the native format for FLTP.
1200    ///
1201    /// # Example
1202    /// ```
1203    /// use grafton_ndi::{AudioFrame, AudioLayout};
1204    ///
1205    /// // Planar layout (default)
1206    /// let frame = AudioFrame::builder()
1207    ///     .channels(2)
1208    ///     .samples(100)
1209    ///     .layout(AudioLayout::Planar)
1210    ///     .build()
1211    ///     .unwrap();
1212    /// ```
1213    #[must_use]
1214    pub fn layout(mut self, layout: AudioLayout) -> Self {
1215        self.layout = Some(layout);
1216        self
1217    }
1218
1219    /// Set the audio data as 32-bit floats
1220    ///
1221    /// The data length must equal `num_channels * num_samples`. The layout must match
1222    /// the configured `AudioLayout`:
1223    /// - **Planar**: `[C0S0, C0S1, ..., C1S0, C1S1, ...]`
1224    /// - **Interleaved**: `[C0S0, C1S0, C0S1, C1S1, ...]` (converted to planar at build time)
1225    #[must_use]
1226    pub fn data(mut self, data: Vec<f32>) -> Self {
1227        self.data = Some(data);
1228        self
1229    }
1230
1231    /// Set metadata
1232    #[must_use]
1233    pub fn metadata<S: Into<String>>(mut self, meta: S) -> Self {
1234        self.metadata = Some(meta.into());
1235        self
1236    }
1237
1238    /// Set the timestamp
1239    #[must_use]
1240    pub fn timestamp(mut self, ts: i64) -> Self {
1241        self.timestamp = Some(ts);
1242        self
1243    }
1244
1245    /// Build the AudioFrame
1246    ///
1247    /// When `AudioLayout::Interleaved` is set, the input data is converted to planar
1248    /// format using the NDI SDK utility function. The resulting frame always stores
1249    /// planar data with `channel_stride_in_bytes = num_samples * 4`.
1250    pub fn build(self) -> Result<AudioFrame> {
1251        let sample_rate = self.sample_rate.unwrap_or(48000);
1252        let num_channels = self.num_channels.unwrap_or(2);
1253        let num_samples = self.num_samples.unwrap_or(1024);
1254        let format = self.format.unwrap_or(AudioFormat::FLTP);
1255        let layout = self.layout.unwrap_or(AudioLayout::Planar);
1256        let timecode = self.timecode.unwrap_or(0);
1257        let audio_layout =
1258            validate_outbound_audio_layout(sample_rate, num_channels, num_samples, format)?;
1259        let sample_count = audio_layout.sample_count;
1260
1261        let data = if let Some(input_data) = self.data {
1262            if input_data.len() != sample_count {
1263                return Err(Error::InvalidFrame(format!(
1264                    "Audio data length {}, expected {} ({}ch x {}samples)",
1265                    input_data.len(),
1266                    sample_count,
1267                    num_channels,
1268                    num_samples
1269                )));
1270            }
1271
1272            match layout {
1273                AudioLayout::Planar => input_data,
1274                AudioLayout::Interleaved => {
1275                    let nc = audio_layout.no_channels;
1276                    let ns = audio_layout.no_samples;
1277                    let mut planar = vec![0.0f32; sample_count];
1278                    for ch in 0..nc {
1279                        for s in 0..ns {
1280                            let dst = ch
1281                                .checked_mul(ns)
1282                                .and_then(|idx| idx.checked_add(s))
1283                                .ok_or_else(|| {
1284                                    Error::InvalidFrame(
1285                                        "Audio planar conversion index overflow".into(),
1286                                    )
1287                                })?;
1288                            let src = s
1289                                .checked_mul(nc)
1290                                .and_then(|idx| idx.checked_add(ch))
1291                                .ok_or_else(|| {
1292                                    Error::InvalidFrame(
1293                                        "Audio interleaved conversion index overflow".into(),
1294                                    )
1295                                })?;
1296                            planar[dst] = input_data[src];
1297                        }
1298                    }
1299                    planar
1300                }
1301            }
1302        } else {
1303            vec![0.0f32; sample_count]
1304        };
1305
1306        let metadata = self.metadata.map(FrameMetadata::new).transpose()?;
1307
1308        Ok(AudioFrame {
1309            layout: audio_layout,
1310            timecode,
1311            data,
1312            metadata,
1313            timestamp: self.timestamp.unwrap_or(0),
1314        })
1315    }
1316}
1317
1318impl Default for AudioFrameBuilder {
1319    fn default() -> Self {
1320        Self::new()
1321    }
1322}
1323
1324impl Default for AudioFrame {
1325    fn default() -> Self {
1326        AudioFrame::builder()
1327            .build()
1328            .expect("Default AudioFrame should always succeed")
1329    }
1330}
1331
1332impl Drop for AudioFrame {
1333    fn drop(&mut self) {
1334        // With owned data, we don't need to free SDK pointers anymore
1335        // The Vec<f32> handles memory cleanup automatically
1336    }
1337}
1338
1339/// Audio format identifiers (FourCC codes).
1340///
1341/// Currently NDI primarily uses `FLTP` (32-bit floating point planar format).
1342///
1343/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new audio formats
1344/// without breaking existing code. Always use a wildcard pattern when matching.
1345///
1346/// # Examples
1347///
1348/// ```
1349/// use grafton_ndi::AudioFormat;
1350///
1351/// let format = AudioFormat::FLTP;
1352///
1353/// // When matching, always include a wildcard for forward compatibility
1354/// match format {
1355///     AudioFormat::FLTP => println!("32-bit float planar"),
1356///     _ => println!("Other format"),
1357/// }
1358/// ```
1359#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
1360#[non_exhaustive]
1361#[repr(u32)]
1362pub enum AudioFormat {
1363    /// 32-bit floating point planar audio (FLTP).
1364    FLTP = NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP as _,
1365}
1366
1367/// Audio data layout format
1368///
1369/// Determines how multi-channel audio samples are arranged in memory.
1370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1371pub enum AudioLayout {
1372    /// Planar format: All samples for channel 0, then all for channel 1, etc.
1373    ///
1374    /// Memory layout for 2 channels, 3 samples:
1375    /// `[C0S0, C0S1, C0S2, C1S0, C1S1, C1S2]`
1376    ///
1377    /// This is the native format for FLTP and is efficient for per-channel processing.
1378    Planar,
1379
1380    /// Interleaved format: Samples from all channels are interleaved.
1381    ///
1382    /// Input memory layout for 2 channels, 3 samples:
1383    /// `[C0S0, C1S0, C0S1, C1S1, C0S2, C1S2]`
1384    ///
1385    /// Interleaved data is converted to planar format at build time using the NDI SDK
1386    /// utility function, so the resulting `AudioFrame` always stores planar data.
1387    Interleaved,
1388}
1389
1390impl From<AudioFormat> for i32 {
1391    fn from(value: AudioFormat) -> Self {
1392        let u32_value: u32 = value.into();
1393        u32_value as i32
1394    }
1395}
1396
1397/// Maximum allowed size for supported video frame data (100 MiB).
1398const MAX_VIDEO_BYTES: usize = 100 * 1024 * 1024;
1399
1400/// Maximum allowed size for audio frame data (64 MiB).
1401/// Comfortably above typical NDI audio frames while preventing unbounded allocations.
1402const MAX_AUDIO_BYTES: usize = 64 * 1024 * 1024;
1403
1404/// Maximum allowed size for SDK metadata C strings (4 MiB), including the
1405/// trailing NUL terminator.
1406pub(crate) const MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
1407
1408#[derive(Debug, Clone)]
1409pub struct MetadataFrame {
1410    data: String, // Owned metadata (typically XML)
1411    timecode: i64,
1412}
1413
1414impl MetadataFrame {
1415    /// Create an empty metadata frame with default SDK timecode behavior.
1416    pub fn new() -> Self {
1417        MetadataFrame {
1418            data: String::new(),
1419            timecode: 0,
1420        }
1421    }
1422
1423    /// Create a metadata frame from UTF-8 text and a timecode.
1424    ///
1425    /// # Errors
1426    ///
1427    /// Returns [`Error::InvalidCString`] if `data` contains an interior NUL byte
1428    /// or [`Error::InvalidFrame`] if the SDK metadata length would exceed the
1429    /// crate's metadata size limit.
1430    pub fn with_data(data: impl Into<String>, timecode: i64) -> Result<Self> {
1431        let data = data.into();
1432        validate_metadata_text(&data)?;
1433        Ok(MetadataFrame { data, timecode })
1434    }
1435
1436    /// Get the metadata text.
1437    pub fn data(&self) -> &str {
1438        &self.data
1439    }
1440
1441    /// Consume the frame and return the owned metadata text.
1442    pub fn into_data(self) -> String {
1443        self.data
1444    }
1445
1446    /// Get the timecode.
1447    ///
1448    /// A value of zero is passed through to the SDK as its default timestamp
1449    /// behavior.
1450    pub fn timecode(&self) -> i64 {
1451        self.timecode
1452    }
1453
1454    /// Replace the metadata text.
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns [`Error::InvalidCString`] if `data` contains an interior NUL byte
1459    /// or [`Error::InvalidFrame`] if the SDK metadata length would exceed the
1460    /// crate's metadata size limit.
1461    pub fn set_data(&mut self, data: impl Into<String>) -> Result<()> {
1462        let data = data.into();
1463        validate_metadata_text(&data)?;
1464        self.data = data;
1465        Ok(())
1466    }
1467
1468    /// Set the timecode.
1469    pub fn set_timecode(&mut self, timecode: i64) {
1470        self.timecode = timecode;
1471    }
1472
1473    /// Return this frame with an updated timecode.
1474    pub fn with_timecode(mut self, timecode: i64) -> Self {
1475        self.timecode = timecode;
1476        self
1477    }
1478
1479    /// Convert to raw format for sending
1480    pub(crate) fn to_raw(&self) -> Result<(CString, NDIlib_metadata_frame_t)> {
1481        let c_data = CString::new(self.data.as_bytes()).map_err(Error::InvalidCString)?;
1482        let length = validate_metadata_len_with_nul(c_data.as_bytes_with_nul().len())?;
1483        let raw = NDIlib_metadata_frame_t {
1484            length,
1485            timecode: self.timecode,
1486            p_data: c_data.as_ptr() as *mut c_char,
1487        };
1488        Ok((c_data, raw))
1489    }
1490
1491    /// Create from raw NDI metadata frame (copies the data)
1492    ///
1493    /// # Safety
1494    ///
1495    /// `raw` must be an SDK-populated metadata frame whose `p_data` pointer is
1496    /// valid for `raw.length` bytes when `length > 0`.
1497    #[cfg(test)]
1498    pub(crate) unsafe fn from_raw(raw: &NDIlib_metadata_frame_t) -> Result<Self> {
1499        let layout = validate_metadata_layout(raw)?;
1500        Ok(Self::from_raw_validated(raw, layout))
1501    }
1502
1503    /// Create from a raw NDI metadata frame after its layout has been validated.
1504    ///
1505    /// # Safety
1506    ///
1507    /// `layout` must have been produced by `validate_metadata_layout(raw)` while
1508    /// the same `raw.p_data` allocation is still valid.
1509    pub(crate) unsafe fn from_raw_validated(
1510        raw: &NDIlib_metadata_frame_t,
1511        layout: ValidatedMetadataLayout,
1512    ) -> Self {
1513        let bytes = metadata_payload_bytes(raw, layout);
1514        let data = str::from_utf8_unchecked(bytes).to_owned();
1515
1516        Self {
1517            data,
1518            timecode: raw.timecode,
1519        }
1520    }
1521}
1522
1523impl Default for MetadataFrame {
1524    fn default() -> Self {
1525        Self::new()
1526    }
1527}
1528
1529/// Validated standalone metadata frame layout information.
1530///
1531/// The SDK `length` field includes the trailing NUL terminator. A
1532/// `len_with_nul` of zero represents the accepted empty null frame
1533/// (`length == 0 && p_data == NULL`) and never requires reading from `p_data`.
1534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1535pub(crate) struct ValidatedMetadataLayout {
1536    /// Total SDK metadata length including trailing NUL, or zero for the valid
1537    /// empty null frame.
1538    pub len_with_nul: usize,
1539    /// Public UTF-8 text payload length excluding the trailing NUL.
1540    pub text_len: usize,
1541}
1542
1543/// Validate standalone metadata frame layout from raw FFI fields.
1544///
1545/// This validates only memory layout and UTF-8. XML well-formedness is
1546/// intentionally not checked because NDI receivers are expected to tolerate
1547/// badly formed XML metadata.
1548pub(crate) fn validate_metadata_layout(
1549    raw: &NDIlib_metadata_frame_t,
1550) -> Result<ValidatedMetadataLayout> {
1551    if raw.length < 0 {
1552        return Err(Error::InvalidFrame(format!(
1553            "Metadata frame has negative length: {}",
1554            raw.length
1555        )));
1556    }
1557
1558    if raw.length == 0 {
1559        if raw.p_data.is_null() {
1560            return Ok(ValidatedMetadataLayout {
1561                len_with_nul: 0,
1562                text_len: 0,
1563            });
1564        }
1565
1566        return Err(Error::InvalidFrame(
1567            "Metadata frame uses lengthless non-null C-string data".into(),
1568        ));
1569    }
1570
1571    if raw.p_data.is_null() {
1572        return Err(Error::InvalidFrame(
1573            "Metadata frame has non-zero length with null data pointer".into(),
1574        ));
1575    }
1576
1577    let len_with_nul = usize::try_from(raw.length).map_err(|_| {
1578        Error::InvalidFrame(format!("Invalid metadata length value: {}", raw.length))
1579    })?;
1580    validate_metadata_len_with_nul(len_with_nul)?;
1581
1582    let bytes = unsafe { slice::from_raw_parts(raw.p_data.cast::<u8>(), len_with_nul) };
1583    if bytes[len_with_nul - 1] != 0 {
1584        return Err(Error::InvalidFrame(
1585            "Metadata frame length does not include a trailing NUL terminator".into(),
1586        ));
1587    }
1588
1589    let payload = &bytes[..len_with_nul - 1];
1590    if payload.contains(&0) {
1591        return Err(Error::InvalidFrame(
1592            "Metadata frame contains an interior NUL byte".into(),
1593        ));
1594    }
1595
1596    str::from_utf8(payload).map_err(|err| Error::InvalidUtf8(err.to_string()))?;
1597
1598    Ok(ValidatedMetadataLayout {
1599        len_with_nul,
1600        text_len: payload.len(),
1601    })
1602}
1603
1604fn validate_metadata_text(data: &str) -> Result<()> {
1605    let len_with_nul = data.len().checked_add(1).ok_or_else(|| {
1606        Error::InvalidFrame("Metadata length overflow while adding terminator".into())
1607    })?;
1608    validate_metadata_len_with_nul(len_with_nul)?;
1609    CString::new(data.as_bytes()).map_err(Error::InvalidCString)?;
1610    Ok(())
1611}
1612
1613fn validate_metadata_len_with_nul(len_with_nul: usize) -> Result<i32> {
1614    if len_with_nul == 0 {
1615        return Err(Error::InvalidFrame(
1616            "Metadata length must include a trailing NUL terminator".into(),
1617        ));
1618    }
1619
1620    if len_with_nul > MAX_METADATA_BYTES {
1621        return Err(Error::InvalidFrame(format!(
1622            "Metadata exceeds maximum size: {} bytes > {} bytes",
1623            len_with_nul, MAX_METADATA_BYTES
1624        )));
1625    }
1626
1627    metadata_len_to_i32(len_with_nul)
1628}
1629
1630fn metadata_len_to_i32(len_with_nul: usize) -> Result<i32> {
1631    i32::try_from(len_with_nul).map_err(|_| {
1632        Error::InvalidFrame(format!(
1633            "Metadata length {len_with_nul} exceeds SDK i32 range"
1634        ))
1635    })
1636}
1637
1638fn metadata_payload_bytes(raw: &NDIlib_metadata_frame_t, layout: ValidatedMetadataLayout) -> &[u8] {
1639    if layout.text_len == 0 {
1640        &[]
1641    } else {
1642        // SAFETY: `validate_metadata_layout` checked that `p_data` is non-null
1643        // for non-empty payloads and that `text_len` is bounded by `length`.
1644        unsafe { slice::from_raw_parts(raw.p_data.cast::<u8>(), layout.text_len) }
1645    }
1646}
1647
1648/// Owned video/audio per-frame metadata.
1649///
1650/// The wrapped C string is guaranteed to be valid UTF-8, contain no interior
1651/// NUL bytes, and fit within the crate metadata size cap including its trailing
1652/// terminator. `None` on a frame means a null SDK `p_metadata`; `Some("")`
1653/// means an explicit non-null empty C string.
1654#[derive(Clone, PartialEq, Eq)]
1655pub(crate) struct FrameMetadata {
1656    inner: CString,
1657}
1658
1659impl FrameMetadata {
1660    pub(crate) fn new<S: Into<String>>(metadata: S) -> Result<Self> {
1661        let metadata = metadata.into();
1662        let len_with_nul = metadata.len().checked_add(1).ok_or_else(|| {
1663            Error::InvalidFrame("Frame metadata length overflow while adding terminator".into())
1664        })?;
1665        validate_metadata_len_with_nul(len_with_nul)?;
1666
1667        Ok(Self {
1668            inner: CString::new(metadata).map_err(Error::InvalidCString)?,
1669        })
1670    }
1671
1672    pub(crate) fn as_str(&self) -> &str {
1673        self.inner
1674            .to_str()
1675            .expect("FrameMetadata validates UTF-8 at construction")
1676    }
1677
1678    pub(crate) fn as_cstr(&self) -> &CStr {
1679        self.inner.as_c_str()
1680    }
1681
1682    pub(crate) fn as_ptr(&self) -> *const c_char {
1683        self.inner.as_ptr()
1684    }
1685
1686    /// Copy per-frame metadata from a pointer that has already been validated
1687    /// with [`validate_frame_metadata`].
1688    ///
1689    /// # Safety
1690    ///
1691    /// `metadata_layout` must have been produced for this exact `p_metadata`
1692    /// pointer while the pointed-to SDK allocation is still valid.
1693    pub(crate) unsafe fn copy_from_raw_validated(
1694        p_metadata: *const c_char,
1695        metadata_layout: ValidatedFrameMetadata,
1696    ) -> Option<Self> {
1697        metadata_layout.len_with_nul?;
1698        debug_assert!(!p_metadata.is_null());
1699
1700        let mut bytes = if metadata_layout.text_len == 0 {
1701            Vec::with_capacity(1)
1702        } else {
1703            unsafe {
1704                slice::from_raw_parts(p_metadata.cast::<u8>(), metadata_layout.text_len).to_vec()
1705            }
1706        };
1707        bytes.push(0);
1708
1709        let inner = unsafe { CString::from_vec_with_nul_unchecked(bytes) };
1710        Some(Self { inner })
1711    }
1712}
1713
1714impl fmt::Debug for FrameMetadata {
1715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1716        f.debug_tuple("FrameMetadata")
1717            .field(&self.as_str())
1718            .finish()
1719    }
1720}
1721
1722/// Cached layout for video/audio per-frame `p_metadata`.
1723///
1724/// The SDK exposes these pointers as optional lengthless C strings. This type
1725/// records the first terminator found by a bounded scan so later accessors can
1726/// expose UTF-8 text without rescanning.
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728pub(crate) struct ValidatedFrameMetadata {
1729    /// `None` means the SDK pointer was null. `Some` includes the first trailing
1730    /// NUL terminator found within `MAX_METADATA_BYTES`.
1731    pub(crate) len_with_nul: Option<NonZeroUsize>,
1732    /// UTF-8 text payload length excluding the trailing NUL terminator.
1733    pub(crate) text_len: usize,
1734}
1735
1736/// Validate a video/audio per-frame `p_metadata` pointer with a bounded scan.
1737///
1738/// This continues to trust the NDI SDK that a non-null pointer is readable. The
1739/// validation does not make dangling or otherwise invalid pointers safe; it
1740/// only limits the scan to `MAX_METADATA_BYTES`, requires a terminator within
1741/// that cap, and validates UTF-8 before exposing safe text access.
1742///
1743/// # Safety
1744///
1745/// When non-null, `p_metadata` must be readable byte-by-byte until either the
1746/// first NUL terminator or `MAX_METADATA_BYTES` bytes have been read.
1747pub(crate) unsafe fn validate_frame_metadata(
1748    p_metadata: *const c_char,
1749) -> Result<ValidatedFrameMetadata> {
1750    if p_metadata.is_null() {
1751        return Ok(ValidatedFrameMetadata {
1752            len_with_nul: None,
1753            text_len: 0,
1754        });
1755    }
1756
1757    let metadata = p_metadata.cast::<u8>();
1758    for text_len in 0..MAX_METADATA_BYTES {
1759        let byte = unsafe { metadata.add(text_len).read() };
1760        if byte == 0 {
1761            if text_len > 0 {
1762                let payload = unsafe { slice::from_raw_parts(metadata, text_len) };
1763                str::from_utf8(payload).map_err(|err| Error::InvalidUtf8(err.to_string()))?;
1764            }
1765
1766            return Ok(ValidatedFrameMetadata {
1767                len_with_nul: NonZeroUsize::new(text_len + 1),
1768                text_len,
1769            });
1770        }
1771    }
1772
1773    Err(Error::InvalidFrame(format!(
1774        "Frame metadata is missing a NUL terminator within {MAX_METADATA_BYTES} bytes"
1775    )))
1776}
1777
1778/// Expose validated per-frame metadata as borrowed UTF-8 text.
1779///
1780/// # Safety
1781///
1782/// `metadata_layout` must have been produced for this exact `p_metadata`
1783/// pointer while the pointed-to SDK allocation is still valid.
1784pub(crate) unsafe fn frame_metadata_str<'a>(
1785    p_metadata: *const c_char,
1786    metadata_layout: ValidatedFrameMetadata,
1787) -> Option<&'a str> {
1788    metadata_layout.len_with_nul?;
1789    debug_assert!(!p_metadata.is_null());
1790
1791    if metadata_layout.text_len == 0 {
1792        return Some("");
1793    }
1794
1795    let bytes = unsafe { slice::from_raw_parts(p_metadata.cast::<u8>(), metadata_layout.text_len) };
1796    Some(unsafe { str::from_utf8_unchecked(bytes) })
1797}
1798
1799/// Image format specification for encoding video frames.
1800///
1801/// Used with [`VideoFrame::encode_data_url`] to specify the desired output format.
1802///
1803/// # Examples
1804///
1805/// ```
1806/// use grafton_ndi::ImageFormat;
1807///
1808/// // PNG format (lossless)
1809/// let png = ImageFormat::Png;
1810///
1811/// // JPEG with quality 85 (lossy, smaller file size)
1812/// let jpeg = ImageFormat::Jpeg(85);
1813/// ```
1814///
1815/// This enum is marked `#[non_exhaustive]` so that additional encoders can be
1816/// added without a breaking change. Match arms should include a wildcard `_`
1817/// pattern.
1818#[cfg(feature = "image-encoding")]
1819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1820#[non_exhaustive]
1821pub enum ImageFormat {
1822    /// PNG format (lossless compression)
1823    Png,
1824    /// JPEG format with quality setting (1-100, where 100 is highest quality)
1825    Jpeg(u8),
1826}
1827
1828#[cfg(feature = "image-encoding")]
1829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1830enum ImageChannelOrder {
1831    Rgba,
1832    Bgra,
1833}
1834
1835#[cfg(feature = "image-encoding")]
1836impl ImageChannelOrder {
1837    fn red_index(self) -> usize {
1838        match self {
1839            Self::Rgba => 0,
1840            Self::Bgra => 2,
1841        }
1842    }
1843
1844    fn blue_index(self) -> usize {
1845        match self {
1846            Self::Rgba => 2,
1847            Self::Bgra => 0,
1848        }
1849    }
1850}
1851
1852#[cfg(feature = "image-encoding")]
1853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854enum ImageAlphaPolicy {
1855    Preserve,
1856    Opaque,
1857}
1858
1859#[cfg(feature = "image-encoding")]
1860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1861struct ImagePixelFormat {
1862    channel_order: ImageChannelOrder,
1863    alpha_policy: ImageAlphaPolicy,
1864}
1865
1866#[cfg(feature = "image-encoding")]
1867impl ImagePixelFormat {
1868    const BYTES_PER_PIXEL: usize = 4;
1869
1870    fn from_pixel_format(pixel_format: PixelFormat) -> Result<Self> {
1871        match pixel_format {
1872            PixelFormat::RGBA => Ok(Self {
1873                channel_order: ImageChannelOrder::Rgba,
1874                alpha_policy: ImageAlphaPolicy::Preserve,
1875            }),
1876            PixelFormat::BGRA => Ok(Self {
1877                channel_order: ImageChannelOrder::Bgra,
1878                alpha_policy: ImageAlphaPolicy::Preserve,
1879            }),
1880            PixelFormat::RGBX => Ok(Self {
1881                channel_order: ImageChannelOrder::Rgba,
1882                alpha_policy: ImageAlphaPolicy::Opaque,
1883            }),
1884            PixelFormat::BGRX => Ok(Self {
1885                channel_order: ImageChannelOrder::Bgra,
1886                alpha_policy: ImageAlphaPolicy::Opaque,
1887            }),
1888            _ => Err(Error::InvalidFrame(format!(
1889                "Unsupported format for image encoding: {pixel_format:?}. Only RGBA/RGBX/BGRA/BGRX are supported."
1890            ))),
1891        }
1892    }
1893
1894    fn can_borrow_tightly_packed_png(self) -> bool {
1895        self.channel_order == ImageChannelOrder::Rgba
1896            && self.alpha_policy == ImageAlphaPolicy::Preserve
1897    }
1898
1899    fn rgba_pixel(self, pixel: &[u8]) -> [u8; 4] {
1900        [
1901            pixel[self.channel_order.red_index()],
1902            pixel[1],
1903            pixel[self.channel_order.blue_index()],
1904            match self.alpha_policy {
1905                ImageAlphaPolicy::Preserve => pixel[3],
1906                ImageAlphaPolicy::Opaque => 255,
1907            },
1908        ]
1909    }
1910
1911    fn rgb_pixel(self, pixel: &[u8]) -> [u8; 3] {
1912        [
1913            pixel[self.channel_order.red_index()],
1914            pixel[1],
1915            pixel[self.channel_order.blue_index()],
1916        ]
1917    }
1918}
1919
1920#[cfg(feature = "image-encoding")]
1921#[derive(Debug)]
1922struct ImagePixelSource<'a> {
1923    data: &'a [u8],
1924    width: usize,
1925    height: usize,
1926    line_stride: usize,
1927    active_row_bytes: usize,
1928    pixel_format: ImagePixelFormat,
1929}
1930
1931#[cfg(feature = "image-encoding")]
1932impl<'a> ImagePixelSource<'a> {
1933    fn new(layout: ValidatedVideoLayout, data: &'a [u8]) -> Result<Self> {
1934        let pixel_format = ImagePixelFormat::from_pixel_format(layout.pixel_format)?;
1935
1936        if data.len() != layout.data_len_bytes {
1937            return Err(Error::InvalidFrame(format!(
1938                "Video data length {}, expected {} bytes for validated layout",
1939                data.len(),
1940                layout.data_len_bytes
1941            )));
1942        }
1943
1944        let line_stride = match layout.line_stride_or_size {
1945            LineStrideOrSize::LineStrideBytes(stride) => {
1946                if stride <= 0 {
1947                    return Err(Error::InvalidFrame(format!(
1948                        "Image line stride must be positive, got {stride}"
1949                    )));
1950                }
1951
1952                usize::try_from(stride).map_err(|_| {
1953                    Error::InvalidFrame(format!("Invalid image line stride value: {stride}"))
1954                })?
1955            }
1956            LineStrideOrSize::DataSizeBytes(size) => {
1957                return Err(Error::InvalidFrame(format!(
1958                    "Cannot encode image from data-size frame ({size} bytes). Image encoding requires line_stride_in_bytes."
1959                )));
1960            }
1961        };
1962
1963        let width = usize::try_from(layout.width)
1964            .map_err(|_| Error::InvalidFrame(format!("Invalid image width: {}", layout.width)))?;
1965        let height = usize::try_from(layout.height)
1966            .map_err(|_| Error::InvalidFrame(format!("Invalid image height: {}", layout.height)))?;
1967
1968        if width == 0 || height == 0 {
1969            return Err(Error::InvalidFrame(format!(
1970                "Image dimensions must be positive, got {}x{}",
1971                layout.width, layout.height
1972            )));
1973        }
1974
1975        let active_row_bytes = width
1976            .checked_mul(ImagePixelFormat::BYTES_PER_PIXEL)
1977            .ok_or_else(|| {
1978                Error::InvalidFrame(format!(
1979                    "Image row size overflow for width {} and {} bytes per pixel",
1980                    width,
1981                    ImagePixelFormat::BYTES_PER_PIXEL
1982                ))
1983            })?;
1984
1985        if line_stride < active_row_bytes {
1986            return Err(Error::InvalidFrame(format!(
1987                "Image line stride {line_stride} is smaller than active row size {active_row_bytes}"
1988            )));
1989        }
1990
1991        let expected_data_len = line_stride.checked_mul(height).ok_or_else(|| {
1992            Error::InvalidFrame(format!(
1993                "Image data length overflow: {line_stride} stride x {height} height"
1994            ))
1995        })?;
1996
1997        if expected_data_len != layout.data_len_bytes {
1998            return Err(Error::InvalidFrame(format!(
1999                "Image layout data length {} does not match line stride x height ({expected_data_len})",
2000                layout.data_len_bytes
2001            )));
2002        }
2003
2004        Ok(Self {
2005            data,
2006            width,
2007            height,
2008            line_stride,
2009            active_row_bytes,
2010            pixel_format,
2011        })
2012    }
2013
2014    fn png_rgba_input(&self) -> Result<(Cow<'a, [u8]>, u32, u32)> {
2015        let width = u32::try_from(self.width).map_err(|_| {
2016            Error::InvalidFrame(format!("PNG width {} exceeds u32 range", self.width))
2017        })?;
2018        let height = u32::try_from(self.height).map_err(|_| {
2019            Error::InvalidFrame(format!("PNG height {} exceeds u32 range", self.height))
2020        })?;
2021
2022        if self.pixel_format.can_borrow_tightly_packed_png()
2023            && self.line_stride == self.active_row_bytes
2024        {
2025            return Ok((Cow::Borrowed(self.data), width, height));
2026        }
2027
2028        let mut rgba = Vec::with_capacity(self.output_len(4)?);
2029
2030        if self.pixel_format.can_borrow_tightly_packed_png() {
2031            for row in self.active_rows() {
2032                rgba.extend_from_slice(row);
2033            }
2034        } else {
2035            for row in self.active_rows() {
2036                for pixel in row.chunks_exact(ImagePixelFormat::BYTES_PER_PIXEL) {
2037                    rgba.extend_from_slice(&self.pixel_format.rgba_pixel(pixel));
2038                }
2039            }
2040        }
2041
2042        Ok((Cow::Owned(rgba), width, height))
2043    }
2044
2045    fn jpeg_rgb_input(&self, quality: u8) -> Result<(Vec<u8>, u16, u16)> {
2046        if !(1..=100).contains(&quality) {
2047            return Err(Error::InvalidFrame(format!(
2048                "JPEG quality must be in 1..=100, got {quality}"
2049            )));
2050        }
2051
2052        let width = u16::try_from(self.width).map_err(|_| {
2053            Error::InvalidFrame(format!(
2054                "JPEG width {} exceeds maximum supported value {}",
2055                self.width,
2056                u16::MAX
2057            ))
2058        })?;
2059        let height = u16::try_from(self.height).map_err(|_| {
2060            Error::InvalidFrame(format!(
2061                "JPEG height {} exceeds maximum supported value {}",
2062                self.height,
2063                u16::MAX
2064            ))
2065        })?;
2066
2067        let mut rgb = Vec::with_capacity(self.output_len(3)?);
2068        for row in self.active_rows() {
2069            for pixel in row.chunks_exact(ImagePixelFormat::BYTES_PER_PIXEL) {
2070                rgb.extend_from_slice(&self.pixel_format.rgb_pixel(pixel));
2071            }
2072        }
2073
2074        Ok((rgb, width, height))
2075    }
2076
2077    fn output_len(&self, channels: usize) -> Result<usize> {
2078        self.width
2079            .checked_mul(self.height)
2080            .and_then(|pixels| pixels.checked_mul(channels))
2081            .ok_or_else(|| {
2082                Error::InvalidFrame(format!(
2083                    "Image output buffer size overflow: {}x{}x{}",
2084                    self.width, self.height, channels
2085                ))
2086            })
2087    }
2088
2089    fn active_rows(&self) -> impl Iterator<Item = &'a [u8]> + '_ {
2090        (0..self.height).map(move |row| {
2091            let start = row * self.line_stride;
2092            let end = start + self.active_row_bytes;
2093            &self.data[start..end]
2094        })
2095    }
2096}
2097
2098// ============================================================================
2099// Frame layout validation helpers
2100// ============================================================================
2101
2102/// Validated video frame layout information.
2103///
2104/// This struct holds pre-validated layout information for a video frame,
2105/// including the computed buffer length and stride/size information.
2106/// Creating this struct performs all necessary bounds checking, so
2107/// consumers can safely use the cached values without re-validation.
2108#[derive(Debug, Clone, Copy)]
2109pub(crate) struct ValidatedVideoLayout {
2110    /// The validated width in pixels.
2111    pub width: i32,
2112    /// The validated height in pixels.
2113    pub height: i32,
2114    /// The validated pixel format.
2115    pub pixel_format: PixelFormat,
2116    /// The validated data buffer length in bytes.
2117    pub data_len_bytes: usize,
2118    /// The validated SDK union field. Supported safe video layouts always use
2119    /// line stride; the data-size variant is reserved for the unsafe escape
2120    /// hatch.
2121    pub line_stride_or_size: LineStrideOrSize,
2122}
2123
2124impl ValidatedVideoLayout {
2125    pub(crate) fn new_uncompressed(
2126        pixel_format: PixelFormat,
2127        width: i32,
2128        height: i32,
2129        line_stride: Option<i32>,
2130    ) -> Result<Self> {
2131        validate_video_dimensions_for_format(pixel_format, width, height)?;
2132
2133        let width_usize = usize::try_from(width)
2134            .map_err(|_| Error::InvalidFrame(format!("Invalid width value: {width}")))?;
2135        let height_usize = usize::try_from(height)
2136            .map_err(|_| Error::InvalidFrame(format!("Invalid height value: {height}")))?;
2137
2138        let min_stride = min_video_line_stride_checked(pixel_format, width_usize)?;
2139        let line_stride_usize = match line_stride {
2140            Some(stride) => {
2141                if stride <= 0 {
2142                    return Err(Error::InvalidFrame(format!(
2143                        "Uncompressed video frame has invalid line_stride_in_bytes: {stride}"
2144                    )));
2145                }
2146
2147                let stride_usize = usize::try_from(stride).map_err(|_| {
2148                    Error::InvalidFrame(format!("Invalid line_stride_in_bytes value: {stride}"))
2149                })?;
2150
2151                if stride_usize < min_stride {
2152                    return Err(Error::InvalidFrame(format!(
2153                        "Video line_stride_in_bytes {stride} is smaller than minimum row size {min_stride} for {pixel_format:?} width {width}"
2154                    )));
2155                }
2156
2157                stride_usize
2158            }
2159            None => min_stride,
2160        };
2161
2162        if pixel_format.info().is_planar_420() && !line_stride_usize.is_multiple_of(2) {
2163            return Err(Error::InvalidFrame(format!(
2164                "Planar 4:2:0 video frame has odd line_stride_in_bytes: {line_stride_usize}"
2165            )));
2166        }
2167
2168        let data_len_bytes =
2169            calculate_buffer_len_checked(pixel_format, line_stride_usize, height_usize)?;
2170        validate_video_data_len(data_len_bytes)?;
2171
2172        let line_stride_i32 = i32::try_from(line_stride_usize).map_err(|_| {
2173            Error::InvalidFrame(format!(
2174                "Video line stride {line_stride_usize} exceeds i32 range"
2175            ))
2176        })?;
2177
2178        Ok(Self {
2179            width,
2180            height,
2181            pixel_format,
2182            data_len_bytes,
2183            line_stride_or_size: LineStrideOrSize::LineStrideBytes(line_stride_i32),
2184        })
2185    }
2186}
2187
2188/// Validate video frame layout from raw FFI fields.
2189///
2190/// This function performs all necessary validation including:
2191/// - Null pointer check for `p_data`
2192/// - Valid pixel format (FourCC) conversion
2193/// - Checked arithmetic for buffer size calculation
2194/// - `MAX_VIDEO_BYTES` cap enforcement
2195///
2196/// # Arguments
2197///
2198/// * `raw` - Reference to the raw NDI video frame
2199///
2200/// # Returns
2201///
2202/// `Ok(ValidatedVideoLayout)` if the frame is valid, or `Err(Error::InvalidFrame(...))` otherwise.
2203pub(crate) fn validate_video_layout(raw: &NDIlib_video_frame_v2_t) -> Result<ValidatedVideoLayout> {
2204    if raw.p_data.is_null() {
2205        return Err(Error::InvalidFrame(
2206            "Video frame has null data pointer".into(),
2207        ));
2208    }
2209
2210    validate_video_frame_metadata(raw.frame_rate_N, raw.frame_rate_D, raw.picture_aspect_ratio)?;
2211
2212    #[allow(clippy::unnecessary_cast)]
2213    ScanType::try_from(raw.frame_format_type as u32).map_err(|_| {
2214        Error::InvalidFrame(format!(
2215            "Unknown scan type: 0x{:08X}",
2216            raw.frame_format_type
2217        ))
2218    })?;
2219
2220    #[allow(clippy::unnecessary_cast)] // Required for Windows where FourCC is i32
2221    let pixel_format = PixelFormat::try_from(raw.FourCC as u32).map_err(|_| {
2222        Error::InvalidFrame(format!("Unknown pixel format FourCC: 0x{:08X}", raw.FourCC))
2223    })?;
2224
2225    let line_stride = unsafe { raw.__bindgen_anon_1.line_stride_in_bytes };
2226    ValidatedVideoLayout::new_uncompressed(pixel_format, raw.xres, raw.yres, Some(line_stride))
2227}
2228
2229fn validate_video_dimensions_for_format(
2230    pixel_format: PixelFormat,
2231    width: i32,
2232    height: i32,
2233) -> Result<()> {
2234    validate_video_width_for_format(pixel_format, width)?;
2235    if height <= 0 {
2236        return Err(Error::InvalidFrame(format!(
2237            "Video frame has invalid height: {height}"
2238        )));
2239    }
2240
2241    if pixel_format.info().is_planar_420() && (width % 2 != 0 || height % 2 != 0) {
2242        return Err(Error::InvalidFrame(format!(
2243            "Planar 4:2:0 video frames require even dimensions, got {}x{}",
2244            width, height
2245        )));
2246    }
2247
2248    Ok(())
2249}
2250
2251fn validate_video_width_for_format(pixel_format: PixelFormat, width: i32) -> Result<()> {
2252    if width <= 0 {
2253        return Err(Error::InvalidFrame(format!(
2254            "Video frame has invalid width: {width}"
2255        )));
2256    }
2257
2258    if pixel_format.info().is_planar_420() && width % 2 != 0 {
2259        return Err(Error::InvalidFrame(format!(
2260            "Planar 4:2:0 video frames require even width, got {width}"
2261        )));
2262    }
2263
2264    Ok(())
2265}
2266
2267pub(crate) fn validate_video_frame_metadata(
2268    frame_rate_n: i32,
2269    frame_rate_d: i32,
2270    picture_aspect_ratio: f32,
2271) -> Result<()> {
2272    if frame_rate_n <= 0 {
2273        return Err(Error::InvalidFrame(format!(
2274            "Video frame has invalid frame rate numerator: {frame_rate_n}"
2275        )));
2276    }
2277    if frame_rate_d <= 0 {
2278        return Err(Error::InvalidFrame(format!(
2279            "Video frame has invalid frame rate denominator: {frame_rate_d}"
2280        )));
2281    }
2282    if !picture_aspect_ratio.is_finite() || picture_aspect_ratio <= 0.0 {
2283        return Err(Error::InvalidFrame(format!(
2284            "Video frame has invalid picture aspect ratio: {picture_aspect_ratio}"
2285        )));
2286    }
2287
2288    Ok(())
2289}
2290
2291fn validate_video_data_len(data_len_bytes: usize) -> Result<()> {
2292    if data_len_bytes == 0 {
2293        return Err(Error::InvalidFrame(
2294            "Video frame has zero calculated size".into(),
2295        ));
2296    }
2297
2298    if data_len_bytes > MAX_VIDEO_BYTES {
2299        return Err(Error::InvalidFrame(format!(
2300            "Video frame exceeds maximum size: {} bytes > {} bytes",
2301            data_len_bytes, MAX_VIDEO_BYTES
2302        )));
2303    }
2304
2305    Ok(())
2306}
2307
2308fn min_video_line_stride_checked(pixel_format: PixelFormat, width: usize) -> Result<usize> {
2309    width
2310        .checked_mul(pixel_format.info().bytes_per_pixel() as usize)
2311        .ok_or_else(|| {
2312            Error::InvalidFrame(format!(
2313                "Video line stride overflow for {:?} width {}",
2314                pixel_format, width
2315            ))
2316        })
2317}
2318
2319/// Calculate buffer length with checked arithmetic.
2320fn calculate_buffer_len_checked(
2321    pixel_format: PixelFormat,
2322    y_stride: usize,
2323    height: usize,
2324) -> Result<usize> {
2325    calculate_buffer_len_for_info_checked(pixel_format.info(), y_stride, height)
2326}
2327
2328fn calculate_buffer_len_for_info_checked(
2329    info: PixelFormatInfo,
2330    y_stride: usize,
2331    height: usize,
2332) -> Result<usize> {
2333    // Y plane size = y_stride * height
2334    let y_size = y_stride.checked_mul(height).ok_or_else(|| {
2335        Error::InvalidFrame(format!(
2336            "Video buffer size overflow: {} stride × {} height",
2337            y_stride, height
2338        ))
2339    })?;
2340
2341    match info.category() {
2342        FormatCategory::Packed => Ok(y_size),
2343        FormatCategory::Planar420 => {
2344            if !height.is_multiple_of(2) || !y_stride.is_multiple_of(2) {
2345                return Err(Error::InvalidFrame(
2346                    "Planar 4:2:0 video frames require even height and stride".into(),
2347                ));
2348            }
2349            // Planar 4:2:0: Y + U + V
2350            // U and V planes each have half width and half height
2351            let chroma_height = height / 2;
2352            let u_stride = y_stride / 2;
2353            let v_stride = y_stride / 2;
2354
2355            let u_size = u_stride
2356                .checked_mul(chroma_height)
2357                .ok_or_else(|| Error::InvalidFrame("Video U-plane size overflow".into()))?;
2358            let v_size = v_stride
2359                .checked_mul(chroma_height)
2360                .ok_or_else(|| Error::InvalidFrame("Video V-plane size overflow".into()))?;
2361
2362            let total = y_size
2363                .checked_add(u_size)
2364                .and_then(|s| s.checked_add(v_size))
2365                .ok_or_else(|| Error::InvalidFrame("Video total buffer size overflow".into()))?;
2366
2367            Ok(total)
2368        }
2369        FormatCategory::SemiPlanar420 => {
2370            if !height.is_multiple_of(2) {
2371                return Err(Error::InvalidFrame(
2372                    "Semi-planar 4:2:0 video frames require even height".into(),
2373                ));
2374            }
2375            // Semi-planar 4:2:0: Y + interleaved UV
2376            // UV plane has full width and half height
2377            let chroma_height = height / 2;
2378            let uv_size = y_stride
2379                .checked_mul(chroma_height)
2380                .ok_or_else(|| Error::InvalidFrame("Video UV-plane size overflow".into()))?;
2381
2382            let total = y_size
2383                .checked_add(uv_size)
2384                .ok_or_else(|| Error::InvalidFrame("Video total buffer size overflow".into()))?;
2385
2386            Ok(total)
2387        }
2388    }
2389}
2390
2391/// Validated audio frame layout information.
2392///
2393/// This struct holds pre-validated layout information for an audio frame,
2394/// including the channel stride and computed backing sample count. Creating
2395/// this struct performs all necessary bounds checking, so consumers can safely
2396/// use the cached values without re-validation.
2397#[derive(Debug, Clone, Copy)]
2398pub(crate) struct ValidatedAudioLayout {
2399    /// The validated audio format, or `None` for a query/no-source empty state.
2400    pub format: Option<AudioFormat>,
2401    /// The validated sample rate.
2402    pub sample_rate: i32,
2403    /// The validated channel count.
2404    pub no_channels: usize,
2405    /// The validated samples per channel.
2406    pub no_samples: usize,
2407    /// The validated channel stride in bytes.
2408    pub channel_stride_in_bytes: i32,
2409    /// The validated channel stride in `f32` samples.
2410    pub channel_stride_samples: usize,
2411    /// The validated backing sample count. For strided planar audio this may be
2412    /// larger than `no_channels * no_samples` because it includes inter-channel
2413    /// padding up to the last channel's final sample.
2414    pub sample_count: usize,
2415}
2416
2417impl ValidatedAudioLayout {
2418    pub(crate) fn is_empty(self) -> bool {
2419        self.sample_count == 0
2420    }
2421
2422    pub(crate) fn format(self) -> Option<AudioFormat> {
2423        self.format
2424    }
2425
2426    pub(crate) fn channel_range(self, channel: usize) -> Option<std::ops::Range<usize>> {
2427        if self.is_empty() || channel >= self.no_channels {
2428            return None;
2429        }
2430
2431        let start = channel.checked_mul(self.channel_stride_samples)?;
2432        let end = start.checked_add(self.no_samples)?;
2433
2434        (end <= self.sample_count).then_some(start..end)
2435    }
2436}
2437
2438fn validate_outbound_audio_layout(
2439    sample_rate: i32,
2440    no_channels: i32,
2441    no_samples: i32,
2442    format: AudioFormat,
2443) -> Result<ValidatedAudioLayout> {
2444    if sample_rate <= 0 {
2445        return Err(Error::InvalidFrame(format!(
2446            "Invalid sample rate: {sample_rate}"
2447        )));
2448    }
2449    if no_channels <= 0 {
2450        return Err(Error::InvalidFrame(format!(
2451            "Invalid number of channels: {no_channels}"
2452        )));
2453    }
2454    if no_samples <= 0 {
2455        return Err(Error::InvalidFrame(format!(
2456            "Invalid number of samples: {no_samples}"
2457        )));
2458    }
2459
2460    validate_audio_format(format.into())?;
2461
2462    let no_channels = usize::try_from(no_channels)
2463        .map_err(|_| Error::InvalidFrame(format!("Invalid no_channels value: {no_channels}")))?;
2464    let no_samples = usize::try_from(no_samples)
2465        .map_err(|_| Error::InvalidFrame(format!("Invalid no_samples value: {no_samples}")))?;
2466
2467    let channel_stride_bytes = no_samples
2468        .checked_mul(std::mem::size_of::<f32>())
2469        .ok_or_else(|| {
2470            Error::InvalidFrame(format!(
2471                "Audio channel stride overflow: {} samples × {} bytes",
2472                no_samples,
2473                std::mem::size_of::<f32>()
2474            ))
2475        })?;
2476    let channel_stride_in_bytes = i32::try_from(channel_stride_bytes).map_err(|_| {
2477        Error::InvalidFrame(format!(
2478            "Audio channel stride {channel_stride_bytes} exceeds i32 range"
2479        ))
2480    })?;
2481
2482    let sample_count = no_channels.checked_mul(no_samples).ok_or_else(|| {
2483        Error::InvalidFrame(format!(
2484            "Audio sample count overflow: {no_channels} channels × {no_samples} samples"
2485        ))
2486    })?;
2487    let byte_size = sample_count
2488        .checked_mul(std::mem::size_of::<f32>())
2489        .ok_or_else(|| {
2490            Error::InvalidFrame(format!(
2491                "Audio byte size overflow: {} samples × {} bytes",
2492                sample_count,
2493                std::mem::size_of::<f32>()
2494            ))
2495        })?;
2496
2497    if byte_size > MAX_AUDIO_BYTES {
2498        return Err(Error::InvalidFrame(format!(
2499            "Audio frame exceeds maximum size: {} bytes > {} bytes",
2500            byte_size, MAX_AUDIO_BYTES
2501        )));
2502    }
2503
2504    Ok(ValidatedAudioLayout {
2505        format: Some(format),
2506        sample_rate,
2507        no_channels,
2508        no_samples,
2509        channel_stride_in_bytes,
2510        channel_stride_samples: no_samples,
2511        sample_count,
2512    })
2513}
2514
2515/// Validate audio frame layout from raw FFI fields.
2516///
2517/// This function performs all necessary validation including:
2518/// - Null pointer check for `p_data`
2519/// - Valid audio format (FourCC) conversion
2520/// - Positive sample rate, channel count, and sample count
2521/// - Checked arithmetic for sample count multiplication
2522/// - `MAX_AUDIO_BYTES` cap enforcement
2523///
2524/// # Arguments
2525///
2526/// * `raw` - Reference to the raw NDI audio frame
2527///
2528/// # Returns
2529///
2530/// `Ok(ValidatedAudioLayout)` if the frame is valid, or `Err(Error::InvalidFrame(...))` otherwise.
2531pub(crate) fn validate_audio_layout(raw: &NDIlib_audio_frame_v3_t) -> Result<ValidatedAudioLayout> {
2532    validate_audio_layout_inner(raw, false)
2533}
2534
2535/// Validate a FrameSync audio frame that is allowed to be a documented
2536/// query/no-source zero-length state.
2537pub(crate) fn validate_audio_layout_allow_empty(
2538    raw: &NDIlib_audio_frame_v3_t,
2539) -> Result<ValidatedAudioLayout> {
2540    validate_audio_layout_inner(raw, true)
2541}
2542
2543fn validate_audio_layout_inner(
2544    raw: &NDIlib_audio_frame_v3_t,
2545    allow_empty: bool,
2546) -> Result<ValidatedAudioLayout> {
2547    if raw.no_samples == 0 {
2548        if allow_empty {
2549            return validate_empty_audio_layout(raw);
2550        }
2551
2552        return Err(Error::InvalidFrame("Invalid number of samples: 0".into()));
2553    }
2554
2555    if raw.p_data.is_null() {
2556        return Err(Error::InvalidFrame(
2557            "Audio frame has null data pointer".into(),
2558        ));
2559    }
2560
2561    if raw.sample_rate <= 0 {
2562        return Err(Error::InvalidFrame(format!(
2563            "Invalid sample rate: {}",
2564            raw.sample_rate
2565        )));
2566    }
2567
2568    if raw.no_channels <= 0 {
2569        return Err(Error::InvalidFrame(format!(
2570            "Invalid number of channels: {}",
2571            raw.no_channels
2572        )));
2573    }
2574
2575    if raw.no_samples <= 0 {
2576        return Err(Error::InvalidFrame(format!(
2577            "Invalid number of samples: {}",
2578            raw.no_samples
2579        )));
2580    }
2581
2582    let format = validate_audio_format(raw.FourCC)?;
2583    let channel_stride_in_bytes = unsafe { raw.__bindgen_anon_1.channel_stride_in_bytes };
2584    if channel_stride_in_bytes <= 0 {
2585        return Err(Error::InvalidFrame(format!(
2586            "Invalid channel_stride_in_bytes: {}",
2587            channel_stride_in_bytes
2588        )));
2589    }
2590
2591    // Use checked math to prevent overflow when computing sample count
2592    let no_samples = usize::try_from(raw.no_samples).map_err(|_| {
2593        Error::InvalidFrame(format!("Invalid no_samples value: {}", raw.no_samples))
2594    })?;
2595
2596    let no_channels = usize::try_from(raw.no_channels).map_err(|_| {
2597        Error::InvalidFrame(format!("Invalid no_channels value: {}", raw.no_channels))
2598    })?;
2599
2600    let channel_stride_bytes = usize::try_from(channel_stride_in_bytes).map_err(|_| {
2601        Error::InvalidFrame(format!(
2602            "Invalid channel_stride_in_bytes value: {}",
2603            channel_stride_in_bytes
2604        ))
2605    })?;
2606
2607    if channel_stride_bytes % std::mem::size_of::<f32>() != 0 {
2608        return Err(Error::InvalidFrame(format!(
2609            "channel_stride_in_bytes must be a multiple of {}, got {}",
2610            std::mem::size_of::<f32>(),
2611            channel_stride_in_bytes
2612        )));
2613    }
2614
2615    let minimum_channel_stride = no_samples
2616        .checked_mul(std::mem::size_of::<f32>())
2617        .ok_or_else(|| {
2618            Error::InvalidFrame(format!(
2619                "Audio channel stride overflow: {} samples × {} bytes",
2620                no_samples,
2621                std::mem::size_of::<f32>()
2622            ))
2623        })?;
2624
2625    if channel_stride_bytes < minimum_channel_stride {
2626        return Err(Error::InvalidFrame(format!(
2627            "channel_stride_in_bytes {} is smaller than one channel of audio samples {}",
2628            channel_stride_in_bytes, minimum_channel_stride
2629        )));
2630    }
2631
2632    let channel_stride_samples = channel_stride_bytes / std::mem::size_of::<f32>();
2633    let last_channel_offset = no_channels
2634        .checked_sub(1)
2635        .and_then(|last| last.checked_mul(channel_stride_samples))
2636        .ok_or_else(|| {
2637            Error::InvalidFrame(format!(
2638                "Audio channel offset overflow: {} channels × {} stride samples",
2639                no_channels, channel_stride_samples
2640            ))
2641        })?;
2642
2643    let sample_count = last_channel_offset.checked_add(no_samples).ok_or_else(|| {
2644        Error::InvalidFrame(format!(
2645            "Audio backing sample count overflow: channel offset {} + {} samples",
2646            last_channel_offset, no_samples
2647        ))
2648    })?;
2649
2650    // Check total byte size against limit
2651    let byte_size = sample_count
2652        .checked_mul(std::mem::size_of::<f32>())
2653        .ok_or_else(|| {
2654            Error::InvalidFrame(format!(
2655                "Audio byte size overflow: {} samples × {} bytes",
2656                sample_count,
2657                std::mem::size_of::<f32>()
2658            ))
2659        })?;
2660
2661    if byte_size > MAX_AUDIO_BYTES {
2662        return Err(Error::InvalidFrame(format!(
2663            "Audio frame exceeds maximum size: {} bytes > {} bytes",
2664            byte_size, MAX_AUDIO_BYTES
2665        )));
2666    }
2667
2668    Ok(ValidatedAudioLayout {
2669        format: Some(format),
2670        sample_rate: raw.sample_rate,
2671        no_channels,
2672        no_samples,
2673        channel_stride_in_bytes,
2674        channel_stride_samples,
2675        sample_count,
2676    })
2677}
2678
2679fn validate_empty_audio_layout(raw: &NDIlib_audio_frame_v3_t) -> Result<ValidatedAudioLayout> {
2680    if !raw.p_data.is_null() {
2681        return Err(Error::InvalidFrame(
2682            "Zero-length audio frame has non-null data pointer".into(),
2683        ));
2684    }
2685
2686    if raw.sample_rate < 0 {
2687        return Err(Error::InvalidFrame(format!(
2688            "Invalid sample rate: {}",
2689            raw.sample_rate
2690        )));
2691    }
2692
2693    if raw.no_channels < 0 {
2694        return Err(Error::InvalidFrame(format!(
2695            "Invalid number of channels: {}",
2696            raw.no_channels
2697        )));
2698    }
2699
2700    let channel_stride_in_bytes = unsafe { raw.__bindgen_anon_1.channel_stride_in_bytes };
2701    if channel_stride_in_bytes != 0 {
2702        return Err(Error::InvalidFrame(format!(
2703            "Zero-length audio frame has non-zero channel_stride_in_bytes: {}",
2704            channel_stride_in_bytes
2705        )));
2706    }
2707
2708    let no_source = raw.sample_rate == 0 && raw.no_channels == 0;
2709    let query_format = raw.sample_rate > 0 && raw.no_channels > 0;
2710    if !no_source && !query_format {
2711        return Err(Error::InvalidFrame(format!(
2712            "Invalid zero-length audio query state: sample_rate={}, no_channels={}",
2713            raw.sample_rate, raw.no_channels
2714        )));
2715    }
2716
2717    let format = if no_source && raw.FourCC == 0 {
2718        None
2719    } else {
2720        Some(validate_audio_format(raw.FourCC)?)
2721    };
2722
2723    let no_channels = usize::try_from(raw.no_channels).map_err(|_| {
2724        Error::InvalidFrame(format!("Invalid no_channels value: {}", raw.no_channels))
2725    })?;
2726
2727    Ok(ValidatedAudioLayout {
2728        format,
2729        sample_rate: raw.sample_rate,
2730        no_channels,
2731        no_samples: 0,
2732        channel_stride_in_bytes: 0,
2733        channel_stride_samples: 0,
2734        sample_count: 0,
2735    })
2736}
2737
2738fn validate_audio_format(fourcc: NDIlib_FourCC_audio_type_e) -> Result<AudioFormat> {
2739    match fourcc {
2740        NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP => Ok(AudioFormat::FLTP),
2741        _ => Err(Error::InvalidFrame(format!(
2742            "Unknown audio format FourCC: 0x{:08X}",
2743            fourcc
2744        ))),
2745    }
2746}
2747
2748// ============================================================================
2749// Zero-copy borrowed receive frames
2750// ============================================================================
2751
2752/// A zero-copy borrowed video frame, generic over its free strategy `S`.
2753///
2754/// This type wraps an RAII `Guard` that owns the NDI frame buffer lifetime,
2755/// exposing a safe, zero-copy view of the video data. The frame is automatically
2756/// freed when dropped, via whichever `NDIlib_*_free_video*` call the strategy `S`
2757/// encodes.
2758///
2759/// The two public spellings are type aliases over this one core, so every
2760/// accessor and the `Debug` impl are written exactly once:
2761/// - [`VideoFrameRef<'rx>`] — frames captured from a [`Receiver`](crate::Receiver)
2762///   (freed via `NDIlib_recv_free_video_v2`).
2763/// - [`FrameSyncVideoRef<'fs>`](crate::FrameSyncVideoRef) — frames captured from a
2764///   [`FrameSync`](crate::FrameSync) (freed via `NDIlib_framesync_free_video`).
2765///
2766/// **Key characteristics:**
2767/// - Zero allocations: References NDI SDK buffers directly
2768/// - Zero copies: No memcpy of pixel data
2769/// - RAII lifetime: Exactly one free per frame, enforced at compile time
2770/// - Not `Send`: Prevents accidental cross-thread use of FFI buffers
2771///
2772/// # Lifetime
2773///
2774/// The lifetime parameter `'a` ties this frame to the owner (a `Receiver` or a
2775/// `FrameSync`) that created it. The borrow checker ensures the owner cannot be
2776/// dropped while any frame references are alive, preventing use-after-free at
2777/// compile time with zero runtime cost. The underlying NDI buffer is freed when
2778/// the frame ref is dropped.
2779///
2780/// # Performance
2781///
2782/// For a 1920×1080 BGRA frame, this eliminates ~8.3 MB of memcpy compared to
2783/// the owned [`VideoFrame`]. At 60 fps, this saves ~475 MB/s of memory bandwidth.
2784pub struct VideoRef<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> {
2785    guard: Guard<'a, S>,
2786    /// Cached validated layout information (pixel format, data length, stride/size).
2787    /// Computed once at construction time; `data()` uses this cached value.
2788    layout: ValidatedVideoLayout,
2789    metadata: ValidatedFrameMetadata,
2790}
2791
2792/// A zero-copy borrowed video frame from a [`Receiver`](crate::Receiver) capture.
2793///
2794/// This is the receiver spelling of the generic [`VideoRef`]; the
2795/// [`FrameSync`](crate::FrameSync) spelling is
2796/// [`FrameSyncVideoRef`](crate::FrameSyncVideoRef). Both share one accessor and
2797/// `Debug` implementation.
2798pub type VideoFrameRef<'rx> = VideoRef<'rx, VideoKind>;
2799
2800impl<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> VideoRef<'a, S> {
2801    /// Create a borrowed video frame from an RAII guard.
2802    ///
2803    /// Validates the frame layout including:
2804    /// - Valid pixel format (FourCC)
2805    /// - Non-null data pointer
2806    /// - Valid dimensions and stride
2807    /// - Buffer size within `MAX_VIDEO_BYTES` limit
2808    ///
2809    /// The validated layout is cached so that `data()` can return slices
2810    /// without re-computation or unchecked arithmetic.
2811    ///
2812    /// # Safety
2813    ///
2814    /// The caller must ensure the guard was created from a valid NDI instance
2815    /// and contains a frame populated by the matching SDK capture call.
2816    pub(crate) unsafe fn new(guard: Guard<'a, S>) -> Result<Self> {
2817        let layout = validate_video_layout(guard.frame())?;
2818        let metadata = unsafe { validate_frame_metadata(guard.frame().p_metadata)? };
2819
2820        Ok(Self {
2821            guard,
2822            layout,
2823            metadata,
2824        })
2825    }
2826
2827    /// Get the frame width in pixels.
2828    pub fn width(&self) -> i32 {
2829        self.guard.frame().xres
2830    }
2831
2832    /// Get the frame height in pixels.
2833    pub fn height(&self) -> i32 {
2834        self.guard.frame().yres
2835    }
2836
2837    /// Get the pixel format (FourCC code).
2838    ///
2839    /// This is guaranteed to be a valid, supported format since it's validated during construction.
2840    pub fn pixel_format(&self) -> PixelFormat {
2841        self.layout.pixel_format
2842    }
2843
2844    /// Get the frame rate numerator.
2845    pub fn frame_rate_n(&self) -> i32 {
2846        self.guard.frame().frame_rate_N
2847    }
2848
2849    /// Get the frame rate denominator.
2850    pub fn frame_rate_d(&self) -> i32 {
2851        self.guard.frame().frame_rate_D
2852    }
2853
2854    /// Get the picture aspect ratio.
2855    pub fn picture_aspect_ratio(&self) -> f32 {
2856        self.guard.frame().picture_aspect_ratio
2857    }
2858
2859    /// Get the scan type (progressive, interlaced, etc.).
2860    ///
2861    /// This is guaranteed to be valid since it is checked during construction.
2862    pub fn scan_type(&self) -> ScanType {
2863        #[allow(clippy::unnecessary_cast)]
2864        ScanType::try_from(self.guard.frame().frame_format_type as u32)
2865            .expect("VideoFrameRef validates scan type during construction")
2866    }
2867
2868    /// Get the timecode.
2869    pub fn timecode(&self) -> i64 {
2870        self.guard.frame().timecode
2871    }
2872
2873    /// Get the timestamp.
2874    pub fn timestamp(&self) -> i64 {
2875        self.guard.frame().timestamp
2876    }
2877
2878    /// Get the line stride or data size.
2879    ///
2880    /// This returns the cached, validated value computed at construction time.
2881    pub fn line_stride_or_size(&self) -> LineStrideOrSize {
2882        self.layout.line_stride_or_size
2883    }
2884
2885    /// Get frame metadata as UTF-8 text, if present.
2886    pub fn metadata(&self) -> Option<&str> {
2887        unsafe { frame_metadata_str(self.guard.frame().p_metadata, self.metadata) }
2888    }
2889
2890    /// Get a zero-copy view of the frame data.
2891    ///
2892    /// This returns a slice directly into the NDI SDK's buffer.
2893    /// No allocation or memcpy is performed.
2894    ///
2895    /// For planar 4:2:0 formats (YV12/I420/NV12), this returns the full
2896    /// buffer including Y and UV planes.
2897    ///
2898    /// # Safety Guarantee
2899    ///
2900    /// The slice length is computed once at construction time using checked
2901    /// arithmetic and validated against `MAX_VIDEO_BYTES`. This eliminates
2902    /// the possibility of integer overflow or unbounded slice creation.
2903    pub fn data(&self) -> &[u8] {
2904        // SAFETY: The data pointer was validated as non-null during construction
2905        // (validate_video_layout returns Err if p_data is null).
2906        // The data length was computed with checked arithmetic and validated
2907        // against MAX_VIDEO_BYTES, so it's safe to create this slice.
2908        unsafe { slice::from_raw_parts(self.guard.frame().p_data, self.layout.data_len_bytes) }
2909    }
2910
2911    /// Convert this borrowed frame to an owned `VideoFrame`.
2912    ///
2913    /// This performs a single memcpy of the frame data and metadata,
2914    /// allowing the frame to outlive the NDI buffer and be sent across threads.
2915    pub fn to_owned(&self) -> Result<VideoFrame> {
2916        unsafe { VideoFrame::from_raw_validated(self.guard.frame(), self.layout, self.metadata) }
2917    }
2918}
2919
2920impl<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> fmt::Debug for VideoRef<'a, S> {
2921    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2922        f.debug_struct(S::REF_DEBUG_NAME)
2923            .field("width", &self.width())
2924            .field("height", &self.height())
2925            .field("pixel_format", &self.pixel_format())
2926            .field("frame_rate_n", &self.frame_rate_n())
2927            .field("frame_rate_d", &self.frame_rate_d())
2928            .field("picture_aspect_ratio", &self.picture_aspect_ratio())
2929            .field("scan_type", &self.scan_type())
2930            .field("timecode", &self.timecode())
2931            .field("data (bytes)", &self.data().len())
2932            .field("line_stride_or_size", &self.line_stride_or_size())
2933            .field("metadata", &self.metadata())
2934            .field("timestamp", &self.timestamp())
2935            .finish()
2936    }
2937}
2938
2939/// A zero-copy borrowed audio frame, generic over its free strategy `S`.
2940///
2941/// This type wraps an RAII `Guard` that owns the NDI frame buffer lifetime,
2942/// exposing a safe, zero-copy view of the audio data. The frame is automatically
2943/// freed when dropped, via whichever `NDIlib_*_free_audio*` call the strategy `S`
2944/// encodes.
2945///
2946/// The shared accessors and the `Debug` impl are written exactly once here; the
2947/// two public spellings are aliases that layer their differing format/owned/empty
2948/// policy on top:
2949/// - [`AudioFrameRef<'rx>`] — frames captured from a [`Receiver`](crate::Receiver)
2950///   (always concrete: `format() -> AudioFormat`, `to_owned() -> AudioFrame`).
2951/// - [`FrameSyncAudioRef<'fs>`](crate::FrameSyncAudioRef) — frames captured from a
2952///   [`FrameSync`](crate::FrameSync), which additionally model a validated empty
2953///   query/no-source state (`is_empty`, `Option`-returning `format`/`to_owned`).
2954///
2955/// **Key characteristics:**
2956/// - Zero allocations: References NDI SDK buffers directly
2957/// - Zero copies: No memcpy of audio samples
2958/// - RAII lifetime: Exactly one free per frame, enforced at compile time
2959/// - Not `Send`: Prevents accidental cross-thread use of FFI buffers
2960pub struct AudioRef<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> {
2961    guard: Guard<'a, S>,
2962    /// Cached validated layout information (format, sample count).
2963    /// Computed once at construction time; `data()` uses this cached value.
2964    layout: ValidatedAudioLayout,
2965    metadata: ValidatedFrameMetadata,
2966}
2967
2968/// A zero-copy borrowed audio frame from a [`Receiver`](crate::Receiver) capture.
2969///
2970/// This is the receiver spelling of the generic [`AudioRef`]; the
2971/// [`FrameSync`](crate::FrameSync) spelling is
2972/// [`FrameSyncAudioRef`](crate::FrameSyncAudioRef). The receiver path always
2973/// yields a concrete audio frame, so [`format`](AudioRef::format) returns an
2974/// [`AudioFormat`] and [`to_owned`](AudioRef::to_owned) an [`AudioFrame`]; the
2975/// FrameSync path additionally models a validated empty query/no-source state.
2976pub type AudioFrameRef<'rx> = AudioRef<'rx, AudioKind>;
2977
2978impl<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> AudioRef<'a, S> {
2979    /// Store a guard alongside an already-validated audio layout, validating and
2980    /// caching the frame metadata. The per-family constructors compute `layout`
2981    /// (concrete vs. empty-query) and funnel through here.
2982    ///
2983    /// # Safety
2984    ///
2985    /// The caller must ensure the guard was created from a valid NDI instance
2986    /// and that `layout` was validated from the same frame the guard owns.
2987    pub(crate) unsafe fn from_validated_layout(
2988        guard: Guard<'a, S>,
2989        layout: ValidatedAudioLayout,
2990    ) -> Result<Self> {
2991        let metadata = unsafe { validate_frame_metadata(guard.frame().p_metadata)? };
2992
2993        Ok(Self {
2994            guard,
2995            layout,
2996            metadata,
2997        })
2998    }
2999
3000    /// Get the sample rate in Hz.
3001    pub fn sample_rate(&self) -> i32 {
3002        self.layout.sample_rate
3003    }
3004
3005    /// Get the number of audio channels.
3006    pub fn num_channels(&self) -> i32 {
3007        self.layout.no_channels as i32
3008    }
3009
3010    /// Get the number of samples per channel.
3011    pub fn num_samples(&self) -> i32 {
3012        self.layout.no_samples as i32
3013    }
3014
3015    /// Get the timecode.
3016    pub fn timecode(&self) -> i64 {
3017        self.guard.frame().timecode
3018    }
3019
3020    /// Get the timestamp.
3021    pub fn timestamp(&self) -> i64 {
3022        self.guard.frame().timestamp
3023    }
3024
3025    /// Get the channel stride in bytes.
3026    pub fn channel_stride_in_bytes(&self) -> i32 {
3027        self.layout.channel_stride_in_bytes
3028    }
3029
3030    /// Get frame metadata as UTF-8 text, if present.
3031    pub fn metadata(&self) -> Option<&str> {
3032        unsafe { frame_metadata_str(self.guard.frame().p_metadata, self.metadata) }
3033    }
3034
3035    /// Get a zero-copy view of the audio data as 32-bit floats.
3036    ///
3037    /// This returns a slice directly into the NDI SDK's buffer.
3038    /// No allocation or memcpy is performed.
3039    ///
3040    /// # Safety Guarantee
3041    ///
3042    /// The slice length is computed once at construction time using checked
3043    /// arithmetic and validated against `MAX_AUDIO_BYTES`. This eliminates
3044    /// the possibility of integer overflow or unbounded slice creation. A
3045    /// validated empty (query/no-source) layout yields an empty slice.
3046    pub fn data(&self) -> &[f32] {
3047        if self.layout.is_empty() {
3048            return &[];
3049        }
3050
3051        // SAFETY: For a non-empty layout the data pointer was validated as
3052        // non-null during construction (the audio validators return Err if
3053        // p_data is null). The sample count was computed with checked arithmetic
3054        // and validated against MAX_AUDIO_BYTES, so it's safe to create this slice.
3055        unsafe {
3056            slice::from_raw_parts(
3057                self.guard.frame().p_data as *const f32,
3058                self.layout.sample_count,
3059            )
3060        }
3061    }
3062
3063    /// Get the zero-copy samples for a single channel.
3064    ///
3065    /// This respects `channel_stride_in_bytes`, so it works for tightly packed
3066    /// and strided planar FLTP audio.
3067    pub fn channel_data(&self, channel: usize) -> Option<&[f32]> {
3068        let range = self.layout.channel_range(channel)?;
3069        Some(&self.data()[range])
3070    }
3071}
3072
3073impl<'rx> AudioRef<'rx, AudioKind> {
3074    /// Create a borrowed audio frame from a receiver RAII guard.
3075    ///
3076    /// Validates the frame layout including:
3077    /// - Valid audio format (FourCC)
3078    /// - Non-null data pointer
3079    /// - Valid sample rate, channel count, and sample count
3080    /// - Buffer size within `MAX_AUDIO_BYTES` limit
3081    ///
3082    /// The validated layout is cached so that `data()` can return slices
3083    /// without re-computation or unchecked arithmetic.
3084    ///
3085    /// # Safety
3086    ///
3087    /// The caller must ensure the guard was created from a valid NDI receiver
3088    /// and contains a frame populated by `NDIlib_recv_capture_v3`.
3089    pub(crate) unsafe fn new(guard: RecvAudioGuard<'rx>) -> Result<Self> {
3090        let layout = validate_audio_layout(guard.frame())?;
3091        unsafe { Self::from_validated_layout(guard, layout) }
3092    }
3093
3094    /// Get the audio format (FourCC code).
3095    ///
3096    /// This is guaranteed to be a valid, supported format since it's validated during construction.
3097    pub fn format(&self) -> AudioFormat {
3098        self.layout
3099            .format()
3100            .expect("validate_audio_layout requires a concrete audio format")
3101    }
3102
3103    /// Convert this borrowed frame to an owned `AudioFrame`.
3104    ///
3105    /// This performs a single memcpy of the audio data and metadata,
3106    /// allowing the frame to outlive the NDI buffer and be sent across threads.
3107    pub fn to_owned(&self) -> Result<AudioFrame> {
3108        AudioFrame::from_raw_validated(*self.guard.frame(), self.layout, self.metadata)
3109    }
3110}
3111
3112impl<'fs> AudioRef<'fs, FrameSyncAudioFree> {
3113    /// Create a borrowed audio frame from a FrameSync RAII guard.
3114    ///
3115    /// When `query_input` is set, the SDK may report a validated empty
3116    /// query/no-source state (no samples); otherwise a concrete frame is
3117    /// required. A query that unexpectedly returns samples is rejected.
3118    ///
3119    /// # Safety
3120    ///
3121    /// The caller must ensure the guard was created from a valid FrameSync
3122    /// instance and contains a frame populated by `NDIlib_framesync_capture_audio_v2`.
3123    pub(crate) unsafe fn new(
3124        guard: Guard<'fs, FrameSyncAudioFree>,
3125        query_input: bool,
3126    ) -> Result<Self> {
3127        let layout = if query_input {
3128            if guard.frame().no_samples != 0 {
3129                return Err(Error::InvalidFrame(format!(
3130                    "FrameSync audio query returned {} samples",
3131                    guard.frame().no_samples
3132                )));
3133            }
3134            validate_audio_layout_allow_empty(guard.frame())?
3135        } else {
3136            validate_audio_layout(guard.frame())?
3137        };
3138        unsafe { Self::from_validated_layout(guard, layout) }
3139    }
3140
3141    /// Returns true for a valid query/no-source state with no sample buffer.
3142    pub fn is_empty(&self) -> bool {
3143        self.layout.is_empty()
3144    }
3145
3146    /// Get the audio format (FourCC code), or `None` for an empty query/no-source state.
3147    pub fn format(&self) -> Option<AudioFormat> {
3148        self.layout.format()
3149    }
3150
3151    /// Convert this borrowed frame to an owned `AudioFrame`.
3152    ///
3153    /// This performs a single memcpy of the audio data and metadata,
3154    /// allowing the frame to outlive the NDI buffer and be sent across threads.
3155    ///
3156    /// Returns `Ok(None)` for a valid query/no-source state with no sample
3157    /// buffer to own.
3158    pub fn to_owned(&self) -> Result<Option<AudioFrame>> {
3159        if self.layout.is_empty() {
3160            Ok(None)
3161        } else {
3162            AudioFrame::from_raw_validated(*self.guard.frame(), self.layout, self.metadata)
3163                .map(Some)
3164        }
3165    }
3166}
3167
3168impl<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> fmt::Debug for AudioRef<'a, S> {
3169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3170        f.debug_struct(S::REF_DEBUG_NAME)
3171            .field("sample_rate", &self.sample_rate())
3172            .field("num_channels", &self.num_channels())
3173            .field("num_samples", &self.num_samples())
3174            .field("timecode", &self.timecode())
3175            // Render the format from the cached layout (`Option<AudioFormat>`) so
3176            // this single impl serves both the receiver and FrameSync spellings,
3177            // whose public `format()` return types differ.
3178            .field("format", &self.layout.format())
3179            .field("data (samples)", &self.data().len())
3180            .field("channel_stride_in_bytes", &self.channel_stride_in_bytes())
3181            .field("metadata", &self.metadata())
3182            .field("timestamp", &self.timestamp())
3183            .finish()
3184    }
3185}
3186
3187/// A zero-copy borrowed metadata frame.
3188///
3189/// This type wraps an RAII guard that owns the NDI frame buffer lifetime,
3190/// exposing a safe, zero-copy view of the metadata string. The frame is automatically
3191/// freed when dropped via `NDIlib_recv_free_metadata`.
3192///
3193/// **Key characteristics:**
3194/// - Zero allocations: References NDI SDK buffers directly
3195/// - Zero copies: No string duplication
3196/// - RAII lifetime: Exactly one free per frame, enforced at compile time
3197/// - Not `Send`: Prevents accidental cross-thread use of FFI buffers
3198///
3199/// # Examples
3200///
3201/// ```no_run
3202/// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, Source, SourceAddress};
3203/// # use std::time::Duration;
3204/// # fn main() -> Result<(), grafton_ndi::Error> {
3205/// # let ndi = NDI::new()?;
3206/// # let source = Source { name: "Test".into(), address: SourceAddress::None };
3207/// # let options = ReceiverOptions::builder(source).build();
3208/// # let receiver = Receiver::new(&ndi, &options)?;
3209/// // Zero-copy capture
3210/// if let Some(frame) = receiver.metadata().try_capture_ref(Duration::from_millis(1000))? {
3211///     println!("Metadata: {}", frame.data());
3212///
3213///     // Frame is freed here when `frame` goes out of scope
3214/// }
3215/// # Ok(())
3216/// # }
3217/// ```
3218pub struct MetadataFrameRef<'rx> {
3219    guard: RecvMetadataGuard<'rx>,
3220    /// Cached validated layout information. Computed once at construction time;
3221    /// `data()` and `as_bytes()` use this cached value.
3222    layout: ValidatedMetadataLayout,
3223}
3224
3225impl<'rx> MetadataFrameRef<'rx> {
3226    /// Create a borrowed metadata frame from an RAII guard.
3227    ///
3228    /// # Safety
3229    ///
3230    /// The caller must ensure the guard was created from a valid NDI receiver
3231    /// and contains a frame populated by `NDIlib_recv_capture_v3`.
3232    pub(crate) unsafe fn new(guard: RecvMetadataGuard<'rx>) -> Result<Self> {
3233        let layout = validate_metadata_layout(guard.frame())?;
3234
3235        Ok(Self { guard, layout })
3236    }
3237
3238    /// Get the timecode.
3239    pub fn timecode(&self) -> i64 {
3240        self.guard.frame().timecode
3241    }
3242
3243    /// Get a zero-copy view of the metadata text.
3244    ///
3245    /// This returns a reference directly into the NDI SDK's buffer.
3246    /// No allocation or string copying is performed.
3247    pub fn data(&self) -> &str {
3248        let bytes = self.as_bytes();
3249        // SAFETY: `validate_metadata_layout` checked UTF-8 before this
3250        // borrowed frame was constructed.
3251        unsafe { str::from_utf8_unchecked(bytes) }
3252    }
3253
3254    /// Get a zero-copy view of the metadata UTF-8 payload bytes, excluding the
3255    /// SDK trailing NUL terminator.
3256    pub fn as_bytes(&self) -> &[u8] {
3257        metadata_payload_bytes(self.guard.frame(), self.layout)
3258    }
3259
3260    /// Convert this borrowed frame to an owned `MetadataFrame`.
3261    ///
3262    /// This performs a string copy, allowing the frame to outlive
3263    /// the NDI buffer and be sent across threads.
3264    pub fn to_owned(&self) -> MetadataFrame {
3265        unsafe { MetadataFrame::from_raw_validated(self.guard.frame(), self.layout) }
3266    }
3267}
3268
3269impl<'rx> fmt::Debug for MetadataFrameRef<'rx> {
3270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3271        f.debug_struct("MetadataFrameRef")
3272            .field("data", &self.data())
3273            .field("data (bytes)", &self.as_bytes().len())
3274            .field("timecode", &self.timecode())
3275            .finish()
3276    }
3277}
3278
3279#[cfg(test)]
3280mod tests {
3281    use super::*;
3282
3283    #[cfg(feature = "image-encoding")]
3284    fn image_test_frame(
3285        pixel_format: PixelFormat,
3286        width: i32,
3287        height: i32,
3288        line_stride: Option<i32>,
3289        data: Vec<u8>,
3290    ) -> VideoFrame {
3291        let layout =
3292            ValidatedVideoLayout::new_uncompressed(pixel_format, width, height, line_stride)
3293                .unwrap();
3294        assert_eq!(data.len(), layout.data_len_bytes);
3295
3296        VideoFrame {
3297            layout,
3298            frame_rate_n: 60,
3299            frame_rate_d: 1,
3300            picture_aspect_ratio: 16.0 / 9.0,
3301            scan_type: ScanType::Progressive,
3302            timecode: 0,
3303            data,
3304            metadata: None,
3305            timestamp: 0,
3306        }
3307    }
3308
3309    #[cfg(feature = "image-encoding")]
3310    fn decode_png_rgba(png_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
3311        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
3312        let mut reader = decoder.read_info().unwrap();
3313        let output_size = reader.output_buffer_size().unwrap();
3314        let mut output = vec![0; output_size];
3315        let info = reader.next_frame(&mut output).unwrap();
3316        output.truncate(info.buffer_size());
3317
3318        assert_eq!(info.color_type, png::ColorType::Rgba);
3319        assert_eq!(info.bit_depth, png::BitDepth::Eight);
3320
3321        (info.width, info.height, output)
3322    }
3323
3324    #[cfg(feature = "image-encoding")]
3325    fn assert_jpeg_markers(jpeg_bytes: &[u8]) {
3326        assert!(jpeg_bytes.len() >= 4);
3327        assert_eq!(&jpeg_bytes[..2], &[0xFF, 0xD8]);
3328        assert_eq!(&jpeg_bytes[jpeg_bytes.len() - 2..], &[0xFF, 0xD9]);
3329    }
3330
3331    #[cfg(feature = "image-encoding")]
3332    #[test]
3333    fn test_encode_png_decodes_exact_supported_pixels() {
3334        let cases = [
3335            (
3336                PixelFormat::RGBA,
3337                vec![10, 20, 30, 40, 50, 60, 70, 80],
3338                vec![10, 20, 30, 40, 50, 60, 70, 80],
3339            ),
3340            (
3341                PixelFormat::BGRA,
3342                vec![30, 20, 10, 40, 70, 60, 50, 80],
3343                vec![10, 20, 30, 40, 50, 60, 70, 80],
3344            ),
3345            (
3346                PixelFormat::RGBX,
3347                vec![10, 20, 30, 0, 50, 60, 70, 99],
3348                vec![10, 20, 30, 255, 50, 60, 70, 255],
3349            ),
3350            (
3351                PixelFormat::BGRX,
3352                vec![30, 20, 10, 0, 70, 60, 50, 99],
3353                vec![10, 20, 30, 255, 50, 60, 70, 255],
3354            ),
3355        ];
3356
3357        for (pixel_format, data, expected) in cases {
3358            let frame = image_test_frame(pixel_format, 2, 1, None, data);
3359            let png = frame.encode_png().unwrap();
3360            let (width, height, decoded) = decode_png_rgba(&png);
3361
3362            assert_eq!((width, height), (2, 1), "{pixel_format:?}");
3363            assert_eq!(decoded, expected, "{pixel_format:?}");
3364        }
3365    }
3366
3367    #[cfg(feature = "image-encoding")]
3368    #[test]
3369    fn test_encode_png_skips_padded_rows() {
3370        let data = vec![
3371            1, 2, 3, 0, 4, 5, 6, 0, 200, 201, 202, 203, 7, 8, 9, 0, 10, 11, 12, 0, 204, 205, 206,
3372            207,
3373        ];
3374        let frame = image_test_frame(PixelFormat::RGBX, 2, 2, Some(12), data);
3375
3376        let png = frame.encode_png().unwrap();
3377        let (width, height, decoded) = decode_png_rgba(&png);
3378
3379        assert_eq!((width, height), (2, 2));
3380        assert_eq!(
3381            decoded,
3382            vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255,]
3383        );
3384    }
3385
3386    #[cfg(feature = "image-encoding")]
3387    #[test]
3388    fn test_encode_jpeg_accepts_supported_image_formats() {
3389        let cases = [
3390            (PixelFormat::RGBA, vec![10, 20, 30, 40]),
3391            (PixelFormat::BGRA, vec![30, 20, 10, 40]),
3392            (PixelFormat::RGBX, vec![10, 20, 30, 0]),
3393            (PixelFormat::BGRX, vec![30, 20, 10, 0]),
3394        ];
3395
3396        for (pixel_format, pixel) in cases {
3397            let data = pixel.repeat(16);
3398            let frame = image_test_frame(pixel_format, 4, 4, None, data);
3399            let jpeg = frame.encode_jpeg(90).unwrap();
3400            assert_jpeg_markers(&jpeg);
3401        }
3402    }
3403
3404    #[cfg(feature = "image-encoding")]
3405    #[test]
3406    fn test_encode_jpeg_skips_padded_rows() {
3407        let data = vec![
3408            3, 2, 1, 0, 6, 5, 4, 0, 250, 251, 252, 253, 9, 8, 7, 0, 12, 11, 10, 0, 254, 255, 0, 1,
3409        ];
3410        let frame = image_test_frame(PixelFormat::BGRX, 2, 2, Some(12), data);
3411
3412        let jpeg = frame.encode_jpeg(85).unwrap();
3413        assert_jpeg_markers(&jpeg);
3414    }
3415
3416    #[cfg(feature = "image-encoding")]
3417    #[test]
3418    fn test_encode_jpeg_rejects_invalid_quality() {
3419        let frame = image_test_frame(PixelFormat::RGBA, 2, 2, None, vec![0; 16]);
3420
3421        for quality in [0, 101, 255] {
3422            let err = frame.encode_jpeg(quality).unwrap_err();
3423            let err_msg = err.to_string();
3424            assert!(
3425                matches!(&err, Error::InvalidFrame(message) if message.contains("quality")),
3426                "unexpected error for quality {quality}: {err_msg}"
3427            );
3428        }
3429    }
3430
3431    #[cfg(feature = "image-encoding")]
3432    #[test]
3433    fn test_encode_jpeg_rejects_oversized_dimensions_before_casting() {
3434        let wide = image_test_frame(PixelFormat::RGBA, 65_536, 1, None, vec![0; 65_536 * 4]);
3435        let err = wide.encode_jpeg(85).unwrap_err();
3436        let err_msg = err.to_string();
3437        assert!(
3438            matches!(&err, Error::InvalidFrame(message) if message.contains("JPEG width")),
3439            "unexpected error: {err_msg}"
3440        );
3441
3442        let tall = image_test_frame(PixelFormat::RGBA, 1, 65_536, None, vec![0; 65_536 * 4]);
3443        let err = tall.encode_jpeg(85).unwrap_err();
3444        let err_msg = err.to_string();
3445        assert!(
3446            matches!(&err, Error::InvalidFrame(message) if message.contains("JPEG height")),
3447            "unexpected error: {err_msg}"
3448        );
3449    }
3450
3451    #[cfg(feature = "image-encoding")]
3452    #[test]
3453    fn test_encode_jpeg_rejects_unsupported_format() {
3454        let frame = image_test_frame(PixelFormat::UYVY, 2, 2, None, vec![0; 8]);
3455
3456        let err = frame.encode_jpeg(85).unwrap_err();
3457        let err_msg = err.to_string();
3458        assert!(
3459            matches!(&err, Error::InvalidFrame(message) if message.contains("Unsupported format")),
3460            "unexpected error: {err_msg}"
3461        );
3462    }
3463
3464    #[cfg(feature = "image-encoding")]
3465    #[test]
3466    fn test_png_input_borrows_only_tightly_packed_rgba() {
3467        let rgba = image_test_frame(PixelFormat::RGBA, 2, 1, None, vec![1, 2, 3, 4, 5, 6, 7, 8]);
3468        let source = ImagePixelSource::new(rgba.layout, rgba.data()).unwrap();
3469        let (pixels, _, _) = source.png_rgba_input().unwrap();
3470        assert!(matches!(pixels, Cow::Borrowed(_)));
3471
3472        let padded_rgba = image_test_frame(
3473            PixelFormat::RGBA,
3474            2,
3475            1,
3476            Some(12),
3477            vec![1, 2, 3, 4, 5, 6, 7, 8, 200, 201, 202, 203],
3478        );
3479        let source = ImagePixelSource::new(padded_rgba.layout, padded_rgba.data()).unwrap();
3480        let (pixels, _, _) = source.png_rgba_input().unwrap();
3481        assert!(matches!(pixels, Cow::Owned(_)));
3482
3483        let rgbx = image_test_frame(PixelFormat::RGBX, 2, 1, None, vec![1, 2, 3, 0, 5, 6, 7, 0]);
3484        let source = ImagePixelSource::new(rgbx.layout, rgbx.data()).unwrap();
3485        let (pixels, _, _) = source.png_rgba_input().unwrap();
3486        assert!(matches!(pixels, Cow::Owned(_)));
3487    }
3488
3489    #[cfg(feature = "image-encoding")]
3490    #[test]
3491    fn test_image_pixel_source_rejects_data_size_layout_and_bad_data_len() {
3492        let data_size_layout = ValidatedVideoLayout {
3493            width: 2,
3494            height: 1,
3495            pixel_format: PixelFormat::RGBA,
3496            data_len_bytes: 8,
3497            line_stride_or_size: LineStrideOrSize::DataSizeBytes(8),
3498        };
3499        let err = ImagePixelSource::new(data_size_layout, &[0; 8]).unwrap_err();
3500        let err_msg = err.to_string();
3501        assert!(
3502            matches!(&err, Error::InvalidFrame(message) if message.contains("data-size frame")),
3503            "unexpected error: {err_msg}"
3504        );
3505
3506        let layout = ValidatedVideoLayout::new_uncompressed(PixelFormat::RGBA, 2, 1, None).unwrap();
3507        let err = ImagePixelSource::new(layout, &[0; 7]).unwrap_err();
3508        let err_msg = err.to_string();
3509        assert!(
3510            matches!(&err, Error::InvalidFrame(message) if message.contains("Video data length")),
3511            "unexpected error: {err_msg}"
3512        );
3513    }
3514
3515    /// Test PixelFormatInfo for packed RGB formats (32 bpp)
3516    #[test]
3517    fn test_pixel_format_info_packed_rgb() {
3518        let formats = [
3519            PixelFormat::BGRA,
3520            PixelFormat::BGRX,
3521            PixelFormat::RGBA,
3522            PixelFormat::RGBX,
3523        ];
3524
3525        for fmt in formats {
3526            let info = fmt.info();
3527            assert_eq!(
3528                info.bytes_per_pixel(),
3529                4,
3530                "Format {:?} bytes per pixel",
3531                fmt
3532            );
3533            assert_eq!(
3534                info.category(),
3535                FormatCategory::Packed,
3536                "Format {:?} category",
3537                fmt
3538            );
3539            assert!(
3540                !info.is_planar_420(),
3541                "Format {:?} should not be planar",
3542                fmt
3543            );
3544
3545            // 1920x1080, stride = 1920 * 4 = 7680
3546            let len = info.try_buffer_len(7680, 1080).unwrap();
3547            assert_eq!(len, 7680 * 1080, "Format {:?} even dimensions", fmt);
3548
3549            // Odd dimensions: 1921x1081
3550            let len = info.try_buffer_len(7684, 1081).unwrap();
3551            assert_eq!(len, 7684 * 1081, "Format {:?} odd dimensions", fmt);
3552        }
3553    }
3554
3555    /// Test PixelFormatInfo for packed YUV formats
3556    #[test]
3557    fn test_pixel_format_info_packed_yuv() {
3558        // UYVY: 16 bpp = 2 bytes per pixel
3559        let info = PixelFormat::UYVY.info();
3560        assert_eq!(info.bytes_per_pixel(), 2);
3561        assert_eq!(info.category(), FormatCategory::Packed);
3562        let len = info.try_buffer_len(3840, 1080).unwrap();
3563        assert_eq!(len, 3840 * 1080);
3564
3565        // UYVA: 24 bpp = 3 bytes per pixel
3566        let info = PixelFormat::UYVA.info();
3567        assert_eq!(info.bytes_per_pixel(), 3);
3568        assert_eq!(info.category(), FormatCategory::Packed);
3569        let len = info.try_buffer_len(5760, 1080).unwrap();
3570        assert_eq!(len, 5760 * 1080);
3571
3572        // P216/PA16: 32 bpp = 4 bytes per pixel
3573        let info = PixelFormat::P216.info();
3574        assert_eq!(info.bytes_per_pixel(), 4);
3575        assert_eq!(info.category(), FormatCategory::Packed);
3576        let len = info.try_buffer_len(7680, 1080).unwrap();
3577        assert_eq!(len, 7680 * 1080);
3578
3579        let info = PixelFormat::PA16.info();
3580        assert_eq!(info.bytes_per_pixel(), 4);
3581        let len = info.try_buffer_len(7680, 1080).unwrap();
3582        assert_eq!(len, 7680 * 1080);
3583    }
3584
3585    /// Test PixelFormatInfo for planar YV12/I420 with even dimensions
3586    #[test]
3587    fn test_pixel_format_info_planar_420_even() {
3588        // 1920x1080 YV12/I420
3589        // Y: 1920 * 1080 = 2,073,600
3590        // U: (1920/2) * (1080/2) = 960 * 540 = 518,400
3591        // V: (1920/2) * (1080/2) = 960 * 540 = 518,400
3592        // Total: 2,073,600 + 518,400 + 518,400 = 3,110,400
3593        let y_stride = 1920;
3594
3595        let info = PixelFormat::YV12.info();
3596        assert_eq!(info.bytes_per_pixel(), 1);
3597        assert_eq!(info.category(), FormatCategory::Planar420);
3598        assert!(info.is_planar_420());
3599        let len = info.try_buffer_len(y_stride, 1080).unwrap();
3600        assert_eq!(len, 3_110_400, "YV12 1920x1080");
3601
3602        let info = PixelFormat::I420.info();
3603        assert_eq!(info.category(), FormatCategory::Planar420);
3604        let len = info.try_buffer_len(y_stride, 1080).unwrap();
3605        assert_eq!(len, 3_110_400, "I420 1920x1080");
3606    }
3607
3608    /// Test PixelFormatInfo rejects planar YV12/I420 with odd layout inputs
3609    #[test]
3610    fn test_pixel_format_info_planar_420_odd() {
3611        let y_stride = 1921;
3612
3613        assert!(PixelFormat::YV12
3614            .info()
3615            .try_buffer_len(y_stride, 1081)
3616            .is_err());
3617        assert!(PixelFormat::I420
3618            .info()
3619            .try_buffer_len(y_stride, 1081)
3620            .is_err());
3621    }
3622
3623    /// Test PixelFormatInfo for semi-planar NV12 with even dimensions
3624    #[test]
3625    fn test_pixel_format_info_nv12_even() {
3626        // 1920x1080 NV12
3627        // Y: 1920 * 1080 = 2,073,600
3628        // UV: 1920 * (1080/2) = 1920 * 540 = 1,036,800
3629        // Total: 2,073,600 + 1,036,800 = 3,110,400
3630        let y_stride = 1920;
3631
3632        let info = PixelFormat::NV12.info();
3633        assert_eq!(info.bytes_per_pixel(), 1);
3634        assert_eq!(info.category(), FormatCategory::SemiPlanar420);
3635        assert!(info.is_planar_420());
3636        let len = info.try_buffer_len(y_stride, 1080).unwrap();
3637        assert_eq!(len, 3_110_400, "NV12 1920x1080");
3638    }
3639
3640    /// Test PixelFormatInfo rejects semi-planar NV12 with odd layout inputs
3641    #[test]
3642    fn test_pixel_format_info_nv12_odd() {
3643        let y_stride = 1921;
3644        assert!(PixelFormat::NV12
3645            .info()
3646            .try_buffer_len(y_stride, 1081)
3647            .is_err());
3648    }
3649
3650    /// Test PixelFormat::line_stride for all formats
3651    #[test]
3652    fn test_pixel_format_line_stride() {
3653        // Packed RGB: 4 bytes per pixel
3654        assert_eq!(PixelFormat::BGRA.try_line_stride(1920).unwrap(), 7680);
3655        assert_eq!(PixelFormat::BGRX.try_line_stride(1920).unwrap(), 7680);
3656        assert_eq!(PixelFormat::RGBA.try_line_stride(1920).unwrap(), 7680);
3657        assert_eq!(PixelFormat::RGBX.try_line_stride(1920).unwrap(), 7680);
3658
3659        // UYVY: 2 bytes per pixel
3660        assert_eq!(PixelFormat::UYVY.try_line_stride(1920).unwrap(), 3840);
3661
3662        // UYVA: 3 bytes per pixel
3663        assert_eq!(PixelFormat::UYVA.try_line_stride(1920).unwrap(), 5760);
3664
3665        // P216/PA16: 4 bytes per pixel
3666        assert_eq!(PixelFormat::P216.try_line_stride(1920).unwrap(), 7680);
3667        assert_eq!(PixelFormat::PA16.try_line_stride(1920).unwrap(), 7680);
3668
3669        // Planar 4:2:0: Y-plane stride = 1 byte per pixel
3670        assert_eq!(PixelFormat::YV12.try_line_stride(1920).unwrap(), 1920);
3671        assert_eq!(PixelFormat::I420.try_line_stride(1920).unwrap(), 1920);
3672        assert_eq!(PixelFormat::NV12.try_line_stride(1920).unwrap(), 1920);
3673    }
3674
3675    /// Test PixelFormat::buffer_size for all formats
3676    #[test]
3677    fn test_pixel_format_buffer_size() {
3678        // Packed RGB: width * 4 * height
3679        assert_eq!(
3680            PixelFormat::BGRA.try_buffer_size(1920, 1080).unwrap(),
3681            8_294_400
3682        );
3683        assert_eq!(
3684            PixelFormat::RGBA.try_buffer_size(1920, 1080).unwrap(),
3685            8_294_400
3686        );
3687
3688        // Planar 4:2:0: Y + U + V = 1.5 * width * height
3689        assert_eq!(
3690            PixelFormat::YV12.try_buffer_size(1920, 1080).unwrap(),
3691            3_110_400
3692        );
3693        assert_eq!(
3694            PixelFormat::I420.try_buffer_size(1920, 1080).unwrap(),
3695            3_110_400
3696        );
3697
3698        // Semi-planar 4:2:0: Y + UV = 1.5 * width * height
3699        assert_eq!(
3700            PixelFormat::NV12.try_buffer_size(1920, 1080).unwrap(),
3701            3_110_400
3702        );
3703    }
3704
3705    /// Test PixelFormatInfo::is_planar_420 helper
3706    #[test]
3707    fn test_pixel_format_info_is_planar_420() {
3708        assert!(PixelFormat::YV12.info().is_planar_420());
3709        assert!(PixelFormat::I420.info().is_planar_420());
3710        assert!(PixelFormat::NV12.info().is_planar_420());
3711
3712        assert!(!PixelFormat::BGRA.info().is_planar_420());
3713        assert!(!PixelFormat::RGBA.info().is_planar_420());
3714        assert!(!PixelFormat::UYVY.info().is_planar_420());
3715        assert!(!PixelFormat::UYVA.info().is_planar_420());
3716    }
3717
3718    /// Test VideoFrame builder with planar formats produces correct buffer sizes
3719    #[test]
3720    fn test_videoframe_builder_planar_even() {
3721        let frame = VideoFrame::builder()
3722            .resolution(1920, 1080)
3723            .pixel_format(PixelFormat::NV12)
3724            .build()
3725            .expect("Builder should succeed");
3726
3727        assert_eq!(frame.width(), 1920);
3728        assert_eq!(frame.height(), 1080);
3729        assert_eq!(frame.pixel_format(), PixelFormat::NV12);
3730        assert_eq!(frame.data().len(), 3_110_400, "NV12 1920x1080 buffer size");
3731    }
3732
3733    /// Test VideoFrame builder rejects planar formats with odd dimensions
3734    #[test]
3735    fn test_videoframe_builder_planar_odd() {
3736        let result = VideoFrame::builder()
3737            .resolution(1921, 1081)
3738            .pixel_format(PixelFormat::I420)
3739            .build();
3740
3741        assert!(
3742            matches!(result, Err(Error::InvalidFrame(_))),
3743            "Planar 4:2:0 odd dimensions should be rejected"
3744        );
3745    }
3746
3747    /// Test VideoFrame builder with packed format (regression test)
3748    #[test]
3749    fn test_videoframe_builder_packed() {
3750        let frame = VideoFrame::builder()
3751            .resolution(1920, 1080)
3752            .pixel_format(PixelFormat::BGRA)
3753            .build()
3754            .expect("Builder should succeed");
3755
3756        assert_eq!(frame.width(), 1920);
3757        assert_eq!(frame.height(), 1080);
3758        assert_eq!(frame.pixel_format(), PixelFormat::BGRA);
3759        assert_eq!(
3760            frame.data().len(),
3761            1920 * 1080 * 4,
3762            "BGRA buffer size unchanged"
3763        );
3764    }
3765
3766    /// Test VideoFrame::from_raw with synthetic NV12 frame
3767    #[test]
3768    fn test_videoframe_from_raw_nv12() {
3769        // Create a synthetic NV12 frame
3770        let width = 1920;
3771        let height = 1080;
3772        let y_stride = 1920;
3773        let expected_size = 3_110_400; // Y + UV for NV12
3774
3775        let mut data = vec![0u8; expected_size];
3776        // Mark the last byte to verify it's copied
3777        data[expected_size - 1] = 0xFF;
3778
3779        let c_frame = NDIlib_video_frame_v2_t {
3780            xres: width,
3781            yres: height,
3782            FourCC: PixelFormat::NV12.into(),
3783            frame_rate_N: 60,
3784            frame_rate_D: 1,
3785            picture_aspect_ratio: 16.0 / 9.0,
3786            frame_format_type: ScanType::Progressive.into(),
3787            timecode: 0,
3788            p_data: data.as_mut_ptr(),
3789            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3790                line_stride_in_bytes: y_stride,
3791            },
3792            p_metadata: ptr::null(),
3793            timestamp: 0,
3794        };
3795
3796        let frame = unsafe { VideoFrame::from_raw(&c_frame) }.expect("from_raw should succeed");
3797
3798        assert_eq!(frame.width(), width);
3799        assert_eq!(frame.height(), height);
3800        assert_eq!(frame.pixel_format(), PixelFormat::NV12);
3801        assert_eq!(
3802            frame.data().len(),
3803            expected_size,
3804            "Should copy full Y+UV buffer"
3805        );
3806        assert_eq!(
3807            frame.data()[expected_size - 1],
3808            0xFF,
3809            "Last byte should be copied"
3810        );
3811    }
3812
3813    /// Test VideoFrame::from_raw rejects synthetic I420 frame with odd dimensions
3814    #[test]
3815    fn test_videoframe_from_raw_i420_odd() {
3816        let width = 1921;
3817        let height = 1081;
3818        let y_stride = 1921;
3819        let expected_size = 3_116_403; // Y + U + V with ceiling division
3820
3821        let mut data = vec![0u8; expected_size];
3822
3823        let c_frame = NDIlib_video_frame_v2_t {
3824            xres: width,
3825            yres: height,
3826            FourCC: PixelFormat::I420.into(),
3827            frame_rate_N: 30,
3828            frame_rate_D: 1,
3829            picture_aspect_ratio: 16.0 / 9.0,
3830            frame_format_type: ScanType::Progressive.into(),
3831            timecode: 0,
3832            p_data: data.as_mut_ptr(),
3833            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3834                line_stride_in_bytes: y_stride,
3835            },
3836            p_metadata: ptr::null(),
3837            timestamp: 0,
3838        };
3839
3840        let result = unsafe { VideoFrame::from_raw(&c_frame) };
3841        assert!(
3842            matches!(result, Err(Error::InvalidFrame(_))),
3843            "Planar 4:2:0 odd dimensions should be rejected"
3844        );
3845    }
3846
3847    /// Regression test: VideoFrame::from_raw with packed format should be unchanged
3848    #[test]
3849    fn test_videoframe_from_raw_packed_regression() {
3850        let width = 1920;
3851        let height = 1080;
3852        let stride = 1920 * 4; // BGRA
3853        let expected_size = (stride * height) as usize;
3854
3855        let mut data = vec![0u8; expected_size];
3856
3857        let c_frame = NDIlib_video_frame_v2_t {
3858            xres: width,
3859            yres: height,
3860            FourCC: PixelFormat::BGRA.into(),
3861            frame_rate_N: 60,
3862            frame_rate_D: 1,
3863            picture_aspect_ratio: 16.0 / 9.0,
3864            frame_format_type: ScanType::Progressive.into(),
3865            timecode: 0,
3866            p_data: data.as_mut_ptr(),
3867            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3868                line_stride_in_bytes: stride,
3869            },
3870            p_metadata: ptr::null(),
3871            timestamp: 0,
3872        };
3873
3874        let frame = unsafe { VideoFrame::from_raw(&c_frame) }.expect("from_raw should succeed");
3875        assert_eq!(
3876            frame.data().len(),
3877            expected_size,
3878            "BGRA buffer size unchanged"
3879        );
3880    }
3881
3882    /// Test that VideoFrameRef::new rejects unknown FourCC
3883    #[test]
3884    fn test_videoframeref_unknown_fourcc() {
3885        use crate::capture::{Guard, VideoKind};
3886
3887        let width = 1920;
3888        let height = 1080;
3889        let stride = 1920 * 4;
3890        let expected_size = (stride * height) as usize;
3891        let mut data = vec![0u8; expected_size];
3892
3893        // Use an unknown FourCC value (0xDEADBEEF)
3894        // On Windows FourCC is i32, on Linux it's u32
3895        #[allow(clippy::unnecessary_cast)]
3896        let c_frame = NDIlib_video_frame_v2_t {
3897            xres: width,
3898            yres: height,
3899            #[cfg(target_os = "windows")]
3900            FourCC: 0xDEADBEEFu32 as i32, // Unknown FourCC
3901            #[cfg(not(target_os = "windows"))]
3902            FourCC: 0xDEADBEEF, // Unknown FourCC
3903            frame_rate_N: 60,
3904            frame_rate_D: 1,
3905            picture_aspect_ratio: 16.0 / 9.0,
3906            frame_format_type: ScanType::Progressive.into(),
3907            timecode: 0,
3908            p_data: data.as_mut_ptr(),
3909            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3910                line_stride_in_bytes: stride,
3911            },
3912            p_metadata: ptr::null(),
3913            timestamp: 0,
3914        };
3915
3916        // Create a mock receiver instance (null is fine for this test since we don't free)
3917        let mock_instance = ptr::null_mut();
3918        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
3919
3920        // VideoFrameRef::new should return an error for unknown FourCC
3921        let result = unsafe { VideoFrameRef::new(guard) };
3922        assert!(result.is_err(), "Should reject unknown FourCC");
3923
3924        if let Err(Error::InvalidFrame(ref msg)) = result {
3925            assert!(
3926                msg.contains("0xDEADBEEF"),
3927                "Error message should include FourCC: {}",
3928                msg
3929            );
3930        } else {
3931            panic!("Expected InvalidFrame error");
3932        }
3933
3934        // Manually free to prevent guard from calling NDI free on null instance
3935        std::mem::forget(result);
3936    }
3937
3938    /// Test that VideoFrameRef::new accepts known FourCC and stores validated format
3939    #[test]
3940    fn test_videoframeref_known_fourcc() {
3941        use crate::capture::{Guard, VideoKind};
3942
3943        let width = 1920;
3944        let height = 1080;
3945        let stride = 1920 * 4;
3946        let expected_size = (stride * height) as usize;
3947        let mut data = vec![0u8; expected_size];
3948
3949        let c_frame = NDIlib_video_frame_v2_t {
3950            xres: width,
3951            yres: height,
3952            FourCC: PixelFormat::BGRA.into(),
3953            frame_rate_N: 60,
3954            frame_rate_D: 1,
3955            picture_aspect_ratio: 16.0 / 9.0,
3956            frame_format_type: ScanType::Progressive.into(),
3957            timecode: 0,
3958            p_data: data.as_mut_ptr(),
3959            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3960                line_stride_in_bytes: stride,
3961            },
3962            p_metadata: ptr::null(),
3963            timestamp: 0,
3964        };
3965
3966        let mock_instance = ptr::null_mut();
3967        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
3968
3969        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("Should accept BGRA FourCC");
3970        assert_eq!(
3971            frame_ref.pixel_format(),
3972            PixelFormat::BGRA,
3973            "Should store validated pixel format"
3974        );
3975
3976        // Manually free to prevent guard from calling NDI free on null instance
3977        std::mem::forget(frame_ref);
3978    }
3979
3980    /// Test that AudioFrameRef::new rejects unknown FourCC
3981    #[test]
3982    fn test_audioframeref_unknown_fourcc() {
3983        use crate::recv_guard::RecvAudioGuard;
3984
3985        let num_samples = 1024;
3986        let num_channels = 2;
3987        let sample_count = (num_samples * num_channels) as usize;
3988        let mut data = vec![0.0f32; sample_count];
3989
3990        // Use an unknown FourCC value (0xBADC0DE)
3991        let c_frame = NDIlib_audio_frame_v3_t {
3992            sample_rate: 48000,
3993            no_channels: num_channels,
3994            no_samples: num_samples,
3995            timecode: 0,
3996            FourCC: 0xBADC0DE, // Unknown audio FourCC
3997            p_data: data.as_mut_ptr() as *mut u8,
3998            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
3999                channel_stride_in_bytes: num_samples * 4,
4000            },
4001            p_metadata: ptr::null(),
4002            timestamp: 0,
4003        };
4004
4005        let mock_instance = ptr::null_mut();
4006        let guard = unsafe { RecvAudioGuard::new(mock_instance, c_frame) };
4007
4008        let result = unsafe { AudioFrameRef::new(guard) };
4009        assert!(result.is_err(), "Should reject unknown audio FourCC");
4010
4011        if let Err(Error::InvalidFrame(ref msg)) = result {
4012            assert!(
4013                msg.contains("0x0BADC0DE"),
4014                "Error message should include FourCC: {}",
4015                msg
4016            );
4017        } else {
4018            panic!("Expected InvalidFrame error");
4019        }
4020
4021        std::mem::forget(result);
4022    }
4023
4024    /// Test that AudioFrameRef::new accepts known FourCC and stores validated format
4025    #[test]
4026    fn test_audioframeref_known_fourcc() {
4027        use crate::recv_guard::RecvAudioGuard;
4028
4029        let num_samples = 1024;
4030        let num_channels = 2;
4031        let sample_count = (num_samples * num_channels) as usize;
4032        let mut data = vec![0.0f32; sample_count];
4033
4034        let c_frame = NDIlib_audio_frame_v3_t {
4035            sample_rate: 48000,
4036            no_channels: num_channels,
4037            no_samples: num_samples,
4038            timecode: 0,
4039            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
4040            p_data: data.as_mut_ptr() as *mut u8,
4041            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
4042                channel_stride_in_bytes: num_samples * 4,
4043            },
4044            p_metadata: ptr::null(),
4045            timestamp: 0,
4046        };
4047
4048        let mock_instance = ptr::null_mut();
4049        let guard = unsafe { RecvAudioGuard::new(mock_instance, c_frame) };
4050
4051        let frame_ref = unsafe { AudioFrameRef::new(guard) }.expect("Should accept FLTP FourCC");
4052        assert_eq!(
4053            frame_ref.format(),
4054            AudioFormat::FLTP,
4055            "Should store validated audio format"
4056        );
4057
4058        std::mem::forget(frame_ref);
4059    }
4060
4061    /// Test that VideoFrameRef correctly uses validated format for data size calculation
4062    #[test]
4063    fn test_videoframeref_data_uses_validated_format() {
4064        use crate::capture::{Guard, VideoKind};
4065
4066        // Test with uncompressed format (BGRA)
4067        let width = 1920;
4068        let height = 1080;
4069        let stride = 1920 * 4;
4070        let expected_size = (stride * height) as usize;
4071        let mut data = vec![0xAB_u8; expected_size];
4072
4073        let c_frame = NDIlib_video_frame_v2_t {
4074            xres: width,
4075            yres: height,
4076            FourCC: PixelFormat::BGRA.into(),
4077            frame_rate_N: 60,
4078            frame_rate_D: 1,
4079            picture_aspect_ratio: 16.0 / 9.0,
4080            frame_format_type: ScanType::Progressive.into(),
4081            timecode: 0,
4082            p_data: data.as_mut_ptr(),
4083            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4084                line_stride_in_bytes: stride,
4085            },
4086            p_metadata: ptr::null(),
4087            timestamp: 0,
4088        };
4089
4090        let mock_instance = ptr::null_mut();
4091        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
4092        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("Should create frame ref");
4093
4094        // Verify data() returns correct size based on validated format
4095        assert_eq!(
4096            frame_ref.data().len(),
4097            expected_size,
4098            "data() should use validated pixel format for size calculation"
4099        );
4100
4101        // Verify line_stride_or_size() uses validated format
4102        assert_eq!(
4103            frame_ref.line_stride_or_size(),
4104            LineStrideOrSize::LineStrideBytes(stride),
4105            "line_stride_or_size() should use validated format"
4106        );
4107
4108        std::mem::forget(frame_ref);
4109    }
4110
4111    /// Regression lock against the receiver and FrameSync video refs re-diverging:
4112    /// given byte-identical raw frames, every shared accessor (including the
4113    /// previously-divergent `scan_type`) must return identical results now that
4114    /// both spellings share one [`VideoRef`] core.
4115    #[test]
4116    fn test_receiver_and_framesync_video_refs_agree() {
4117        use crate::capture::{FrameSyncVideoFree, Guard, VideoKind};
4118
4119        let width = 16;
4120        let height = 8;
4121        let stride = width * 4;
4122        let len = (stride * height) as usize;
4123
4124        // Two independent buffers with identical content, so each ref owns a
4125        // distinct (non-aliasing) frame while still comparing equal.
4126        let mut recv_data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
4127        let mut fs_data = recv_data.clone();
4128        let mut recv_meta = b"shared frame\0".to_vec();
4129        let mut fs_meta = recv_meta.clone();
4130
4131        let raw = |data: &mut [u8], meta: &mut [u8]| NDIlib_video_frame_v2_t {
4132            xres: width,
4133            yres: height,
4134            FourCC: PixelFormat::BGRA.into(),
4135            frame_rate_N: 30,
4136            frame_rate_D: 1,
4137            picture_aspect_ratio: 16.0 / 9.0,
4138            // A non-default scan type: the old FrameSync copy used
4139            // `unwrap_or(Progressive)`, so this also guards that divergence.
4140            frame_format_type: ScanType::Interlaced.into(),
4141            timecode: 123,
4142            p_data: data.as_mut_ptr(),
4143            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4144                line_stride_in_bytes: stride,
4145            },
4146            p_metadata: meta.as_mut_ptr().cast(),
4147            timestamp: 456,
4148        };
4149
4150        let recv_raw = raw(&mut recv_data, &mut recv_meta);
4151        let fs_raw = raw(&mut fs_data, &mut fs_meta);
4152
4153        let recv_guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), recv_raw) };
4154        let recv = unsafe { VideoFrameRef::new(recv_guard) }.expect("receiver video ref");
4155        let fs_guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), fs_raw) };
4156        let fs =
4157            unsafe { VideoRef::<FrameSyncVideoFree>::new(fs_guard) }.expect("framesync video ref");
4158
4159        assert_eq!(recv.width(), fs.width());
4160        assert_eq!(recv.height(), fs.height());
4161        assert_eq!(recv.pixel_format(), fs.pixel_format());
4162        assert_eq!(recv.frame_rate_n(), fs.frame_rate_n());
4163        assert_eq!(recv.frame_rate_d(), fs.frame_rate_d());
4164        assert_eq!(recv.picture_aspect_ratio(), fs.picture_aspect_ratio());
4165        assert_eq!(recv.scan_type(), fs.scan_type());
4166        assert_eq!(recv.scan_type(), ScanType::Interlaced);
4167        assert_eq!(recv.timecode(), fs.timecode());
4168        assert_eq!(recv.timestamp(), fs.timestamp());
4169        assert_eq!(recv.line_stride_or_size(), fs.line_stride_or_size());
4170        assert_eq!(recv.metadata(), fs.metadata());
4171        assert_eq!(recv.data(), fs.data());
4172
4173        // The receiver guard would call the SDK free with a null instance; leak
4174        // both refs to keep the unit test off the FFI free path.
4175        std::mem::forget(recv);
4176        std::mem::forget(fs);
4177    }
4178
4179    /// An out-of-range `frame_format_type` must be rejected by `validate_video_layout`
4180    /// before either video ref is constructed. This is what makes the unified
4181    /// `scan_type()` `.expect(...)` policy sound: the unknown branch is unreachable.
4182    #[test]
4183    fn test_video_ref_rejects_out_of_range_scan_type() {
4184        use crate::capture::{FrameSyncVideoFree, Guard, VideoKind};
4185
4186        let width = 16;
4187        let height = 8;
4188        let stride = width * 4;
4189        let len = (stride * height) as usize;
4190        let mut recv_data = vec![0u8; len];
4191        let mut fs_data = vec![0u8; len];
4192
4193        let raw = |data: &mut [u8]| NDIlib_video_frame_v2_t {
4194            xres: width,
4195            yres: height,
4196            FourCC: PixelFormat::BGRA.into(),
4197            frame_rate_N: 30,
4198            frame_rate_D: 1,
4199            picture_aspect_ratio: 16.0 / 9.0,
4200            frame_format_type: 0x0BAD_F00D, // not a valid NDIlib_frame_format_type_e
4201            timecode: 0,
4202            p_data: data.as_mut_ptr(),
4203            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4204                line_stride_in_bytes: stride,
4205            },
4206            p_metadata: ptr::null(),
4207            timestamp: 0,
4208        };
4209
4210        // Layout validation rejects it directly...
4211        assert!(matches!(
4212            validate_video_layout(&raw(&mut recv_data)),
4213            Err(Error::InvalidFrame(_))
4214        ));
4215
4216        // ...and so both ref constructors fail before any `scan_type()` call.
4217        let recv_guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw(&mut recv_data)) };
4218        assert!(matches!(
4219            unsafe { VideoFrameRef::new(recv_guard) },
4220            Err(Error::InvalidFrame(_))
4221        ));
4222
4223        let fs_guard =
4224            unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw(&mut fs_data)) };
4225        assert!(matches!(
4226            unsafe { VideoRef::<FrameSyncVideoFree>::new(fs_guard) },
4227            Err(Error::InvalidFrame(_))
4228        ));
4229    }
4230
4231    /// Test that MAX_VIDEO_BYTES constant is properly defined
4232    #[test]
4233    fn test_max_video_bytes_constant() {
4234        // Verify the constant is set to 100 MiB as specified
4235        assert_eq!(MAX_VIDEO_BYTES, 100 * 1024 * 1024);
4236    }
4237
4238    /// Test that MAX_AUDIO_BYTES constant is properly defined
4239    #[test]
4240    fn test_max_audio_bytes_constant() {
4241        // Verify the constant is set to 64 MiB as specified
4242        assert_eq!(MAX_AUDIO_BYTES, 64 * 1024 * 1024);
4243    }
4244
4245    /// Test that MAX_METADATA_BYTES constant is properly defined.
4246    #[test]
4247    fn test_max_metadata_bytes_constant() {
4248        assert_eq!(MAX_METADATA_BYTES, 4 * 1024 * 1024);
4249    }
4250
4251    fn metadata_raw_from_bytes(data: &mut [u8], length: i32) -> NDIlib_metadata_frame_t {
4252        NDIlib_metadata_frame_t {
4253            length,
4254            timecode: 12345,
4255            p_data: data.as_mut_ptr().cast::<c_char>(),
4256        }
4257    }
4258
4259    fn null_metadata_raw(length: i32) -> NDIlib_metadata_frame_t {
4260        NDIlib_metadata_frame_t {
4261            length,
4262            timecode: 12345,
4263            p_data: ptr::null_mut(),
4264        }
4265    }
4266
4267    #[test]
4268    fn test_validate_metadata_layout_accepts_empty_null_frame() {
4269        let raw = null_metadata_raw(0);
4270        let layout = validate_metadata_layout(&raw).expect("empty null frame is valid");
4271
4272        assert_eq!(
4273            layout,
4274            ValidatedMetadataLayout {
4275                len_with_nul: 0,
4276                text_len: 0,
4277            }
4278        );
4279    }
4280
4281    #[test]
4282    fn test_validate_metadata_layout_accepts_one_byte_empty_payload() {
4283        let mut data = vec![0u8];
4284        let raw = metadata_raw_from_bytes(&mut data, 1);
4285        let layout = validate_metadata_layout(&raw).expect("explicit empty payload is valid");
4286
4287        assert_eq!(layout.len_with_nul, 1);
4288        assert_eq!(layout.text_len, 0);
4289    }
4290
4291    #[test]
4292    fn test_validate_metadata_layout_accepts_utf8_with_bounded_length() {
4293        let mut data = "<ndi tally=\"preview\"/>".as_bytes().to_vec();
4294        data.push(0);
4295        let length = data.len() as i32;
4296        let raw = metadata_raw_from_bytes(&mut data, length);
4297        let layout = validate_metadata_layout(&raw).expect("valid UTF-8 metadata");
4298
4299        assert_eq!(layout.len_with_nul, data.len());
4300        assert_eq!(layout.text_len, data.len() - 1);
4301    }
4302
4303    #[test]
4304    fn test_validate_metadata_layout_ignores_bytes_after_length() {
4305        let mut data = b"ok\0\0\xFF".to_vec();
4306        let raw = metadata_raw_from_bytes(&mut data, 3);
4307        let layout = validate_metadata_layout(&raw).expect("extra bytes after length ignored");
4308
4309        assert_eq!(layout.text_len, 2);
4310        let owned = unsafe { MetadataFrame::from_raw_validated(&raw, layout) };
4311        assert_eq!(owned.data(), "ok");
4312    }
4313
4314    #[test]
4315    fn test_validate_metadata_layout_rejects_negative_length() {
4316        let raw = null_metadata_raw(-1);
4317
4318        assert!(matches!(
4319            validate_metadata_layout(&raw),
4320            Err(Error::InvalidFrame(_))
4321        ));
4322    }
4323
4324    #[test]
4325    fn test_validate_metadata_layout_rejects_lengthless_non_null_data() {
4326        let mut data = vec![0u8];
4327        let raw = metadata_raw_from_bytes(&mut data, 0);
4328
4329        assert!(matches!(
4330            validate_metadata_layout(&raw),
4331            Err(Error::InvalidFrame(_))
4332        ));
4333    }
4334
4335    #[test]
4336    fn test_validate_metadata_layout_rejects_nonzero_length_null_data() {
4337        let raw = null_metadata_raw(1);
4338
4339        assert!(matches!(
4340            validate_metadata_layout(&raw),
4341            Err(Error::InvalidFrame(_))
4342        ));
4343    }
4344
4345    #[test]
4346    fn test_validate_metadata_layout_rejects_missing_trailing_nul() {
4347        let mut data = b"abc".to_vec();
4348        let length = data.len() as i32;
4349        let raw = metadata_raw_from_bytes(&mut data, length);
4350
4351        assert!(matches!(
4352            validate_metadata_layout(&raw),
4353            Err(Error::InvalidFrame(_))
4354        ));
4355    }
4356
4357    #[test]
4358    fn test_validate_metadata_layout_rejects_interior_nul() {
4359        let mut data = b"a\0b\0".to_vec();
4360        let length = data.len() as i32;
4361        let raw = metadata_raw_from_bytes(&mut data, length);
4362
4363        assert!(matches!(
4364            validate_metadata_layout(&raw),
4365            Err(Error::InvalidFrame(_))
4366        ));
4367    }
4368
4369    #[test]
4370    fn test_validate_metadata_layout_rejects_oversized_length_before_reading() {
4371        let mut data = vec![0u8];
4372        let raw = metadata_raw_from_bytes(&mut data, (MAX_METADATA_BYTES + 1) as i32);
4373
4374        assert!(matches!(
4375            validate_metadata_layout(&raw),
4376            Err(Error::InvalidFrame(_))
4377        ));
4378    }
4379
4380    #[test]
4381    fn test_validate_metadata_layout_rejects_invalid_utf8() {
4382        let mut data = vec![0xFF, 0];
4383        let length = data.len() as i32;
4384        let raw = metadata_raw_from_bytes(&mut data, length);
4385
4386        assert!(matches!(
4387            validate_metadata_layout(&raw),
4388            Err(Error::InvalidUtf8(_))
4389        ));
4390    }
4391
4392    #[test]
4393    fn test_metadata_frame_constructor_and_accessors_preserve_text() {
4394        let frame = MetadataFrame::with_data("<ndi_product/>", 9876).unwrap();
4395
4396        assert_eq!(frame.data(), "<ndi_product/>");
4397        assert_eq!(frame.timecode(), 9876);
4398        assert_eq!(frame.clone().into_data(), "<ndi_product/>");
4399    }
4400
4401    #[test]
4402    fn test_metadata_frame_rejects_interior_nul_input() {
4403        assert!(matches!(
4404            MetadataFrame::with_data("bad\0metadata", 0),
4405            Err(Error::InvalidCString(_))
4406        ));
4407
4408        let mut frame = MetadataFrame::new();
4409        assert!(matches!(
4410            frame.set_data("bad\0metadata"),
4411            Err(Error::InvalidCString(_))
4412        ));
4413    }
4414
4415    #[test]
4416    fn test_metadata_frame_rejects_oversized_input() {
4417        let oversized = "x".repeat(MAX_METADATA_BYTES);
4418
4419        assert!(matches!(
4420            MetadataFrame::with_data(oversized, 0),
4421            Err(Error::InvalidFrame(_))
4422        ));
4423    }
4424
4425    #[test]
4426    fn test_metadata_frame_setters_preserve_invariants() {
4427        let mut frame = MetadataFrame::new();
4428
4429        frame.set_data("updated").unwrap();
4430        frame.set_timecode(42);
4431
4432        assert_eq!(frame.data(), "updated");
4433        assert_eq!(frame.timecode(), 42);
4434        assert_eq!(MetadataFrame::new().with_timecode(7).timecode(), 7);
4435    }
4436
4437    #[test]
4438    fn test_metadata_frame_from_raw_validated_copies_only_payload() {
4439        let mut data = b"copy-me\0\0\xFF".to_vec();
4440        let raw = metadata_raw_from_bytes(&mut data, 8);
4441        let layout = validate_metadata_layout(&raw).unwrap();
4442        let frame = unsafe { MetadataFrame::from_raw_validated(&raw, layout) };
4443
4444        assert_eq!(frame.data(), "copy-me");
4445        assert_eq!(frame.timecode(), 12345);
4446    }
4447
4448    #[test]
4449    fn test_metadata_frame_from_raw_reports_invalid_utf8() {
4450        let mut data = vec![0xFF, 0];
4451        let raw = metadata_raw_from_bytes(&mut data, 2);
4452
4453        assert!(matches!(
4454            unsafe { MetadataFrame::from_raw(&raw) },
4455            Err(Error::InvalidUtf8(_))
4456        ));
4457    }
4458
4459    #[test]
4460    fn test_metadata_frame_to_raw_includes_trailing_nul() {
4461        let frame = MetadataFrame::with_data("abc", 101).unwrap();
4462        let (c_data, raw) = frame.to_raw().unwrap();
4463
4464        assert_eq!(raw.length, 4);
4465        assert_eq!(raw.timecode, 101);
4466        assert_eq!(c_data.as_bytes_with_nul(), b"abc\0");
4467    }
4468
4469    #[test]
4470    fn test_empty_metadata_frame_to_raw_sends_explicit_nul() {
4471        let frame = MetadataFrame::new();
4472        let (c_data, raw) = frame.to_raw().unwrap();
4473
4474        assert_eq!(raw.length, 1);
4475        assert_eq!(c_data.as_bytes_with_nul(), b"\0");
4476    }
4477
4478    #[test]
4479    fn test_metadata_raw_length_conversion_is_checked() {
4480        assert!(metadata_len_to_i32(i32::MAX as usize).is_ok());
4481        assert!(matches!(
4482            metadata_len_to_i32(i32::MAX as usize + 1),
4483            Err(Error::InvalidFrame(_))
4484        ));
4485    }
4486
4487    #[test]
4488    fn test_metadata_frame_ref_uses_cached_layout() {
4489        use crate::capture::RecvMetadataGuard;
4490
4491        let mut data = b"zero-copy\0\0\xFF".to_vec();
4492        let raw = metadata_raw_from_bytes(&mut data, 10);
4493        let guard = unsafe { RecvMetadataGuard::new(ptr::null_mut(), raw) };
4494        let frame_ref = unsafe { MetadataFrameRef::new(guard) }.expect("valid metadata ref");
4495
4496        assert_eq!(frame_ref.data(), "zero-copy");
4497        assert_eq!(frame_ref.as_bytes(), b"zero-copy");
4498        assert_eq!(frame_ref.layout.text_len, 9);
4499
4500        let owned = frame_ref.to_owned();
4501        assert_eq!(owned.data(), "zero-copy");
4502        assert_eq!(owned.timecode(), 12345);
4503
4504        std::mem::forget(frame_ref);
4505    }
4506
4507    #[test]
4508    fn test_validate_frame_metadata_accepts_null_without_allocation_state() {
4509        let layout = unsafe { validate_frame_metadata(ptr::null()) }.expect("null is valid");
4510
4511        assert_eq!(
4512            layout,
4513            ValidatedFrameMetadata {
4514                len_with_nul: None,
4515                text_len: 0,
4516            }
4517        );
4518    }
4519
4520    #[test]
4521    fn test_validate_frame_metadata_accepts_explicit_empty() {
4522        let mut data = b"\0".to_vec();
4523        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
4524            .expect("explicit empty metadata is valid");
4525
4526        assert_eq!(layout.len_with_nul.unwrap().get(), 1);
4527        assert_eq!(layout.text_len, 0);
4528        assert_eq!(
4529            unsafe { frame_metadata_str(data.as_ptr().cast::<c_char>(), layout) },
4530            Some("")
4531        );
4532    }
4533
4534    #[test]
4535    fn test_validate_frame_metadata_accepts_utf8_and_ignores_after_first_nul() {
4536        let mut data = b"hello metadata\0\0\xFF".to_vec();
4537        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
4538            .expect("valid UTF-8 metadata");
4539
4540        assert_eq!(layout.text_len, "hello metadata".len());
4541        assert_eq!(
4542            unsafe { frame_metadata_str(data.as_ptr().cast::<c_char>(), layout) },
4543            Some("hello metadata")
4544        );
4545    }
4546
4547    #[test]
4548    fn test_validate_frame_metadata_accepts_max_boundary() {
4549        let mut data = vec![b'x'; MAX_METADATA_BYTES];
4550        data[MAX_METADATA_BYTES - 1] = 0;
4551
4552        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
4553            .expect("metadata exactly at cap including terminator is valid");
4554
4555        assert_eq!(layout.len_with_nul.unwrap().get(), MAX_METADATA_BYTES);
4556        assert_eq!(layout.text_len, MAX_METADATA_BYTES - 1);
4557    }
4558
4559    #[test]
4560    fn test_validate_frame_metadata_rejects_missing_nul_within_cap() {
4561        let mut data = vec![b'x'; MAX_METADATA_BYTES];
4562
4563        assert!(matches!(
4564            unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) },
4565            Err(Error::InvalidFrame(_))
4566        ));
4567    }
4568
4569    #[test]
4570    fn test_validate_frame_metadata_rejects_invalid_utf8_before_nul() {
4571        let mut data = vec![0xFF, 0];
4572
4573        assert!(matches!(
4574            unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) },
4575            Err(Error::InvalidUtf8(_))
4576        ));
4577    }
4578
4579    #[test]
4580    fn test_video_frame_ref_metadata_is_cached_and_owned_conversion_appends_nul() {
4581        use crate::capture::{Guard, VideoKind};
4582
4583        let width = 16;
4584        let height = 8;
4585        let stride = width * 4;
4586        let expected_len = (stride * height) as usize;
4587        let data = vec![0u8; expected_len];
4588        let mut metadata = b"cached\0\xFF".to_vec();
4589
4590        let raw = NDIlib_video_frame_v2_t {
4591            xres: width,
4592            yres: height,
4593            FourCC: PixelFormat::BGRA.into(),
4594            frame_rate_N: 60,
4595            frame_rate_D: 1,
4596            picture_aspect_ratio: 16.0 / 9.0,
4597            frame_format_type: ScanType::Progressive.into(),
4598            timecode: 123,
4599            p_data: data.as_ptr() as *mut u8,
4600            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4601                line_stride_in_bytes: stride,
4602            },
4603            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
4604            timestamp: 456,
4605        };
4606
4607        let guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw) };
4608        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("valid video ref");
4609
4610        assert_eq!(frame_ref.metadata(), Some("cached"));
4611        metadata[6] = b'!';
4612        assert_eq!(frame_ref.metadata(), Some("cached"));
4613        assert!(format!("{frame_ref:?}").contains("metadata: Some(\"cached\")"));
4614
4615        let owned = frame_ref.to_owned().expect("owned conversion");
4616        assert_eq!(owned.metadata(), Some("cached"));
4617        assert_eq!(owned.data().len(), expected_len);
4618
4619        std::mem::forget(frame_ref);
4620    }
4621
4622    #[test]
4623    fn test_audio_frame_ref_metadata_is_text_and_owned_conversion_preserves_it() {
4624        use crate::capture::RecvAudioGuard;
4625
4626        let no_samples = 4;
4627        let no_channels = 2;
4628        let sample_count = (no_samples * no_channels) as usize;
4629        let data = vec![0.25f32; sample_count];
4630        let mut metadata = b"audio meta\0".to_vec();
4631
4632        let raw = NDIlib_audio_frame_v3_t {
4633            sample_rate: 48000,
4634            no_channels,
4635            no_samples,
4636            timecode: 123,
4637            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
4638            p_data: data.as_ptr() as *mut u8,
4639            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
4640                channel_stride_in_bytes: no_samples * 4,
4641            },
4642            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
4643            timestamp: 456,
4644        };
4645
4646        let guard = unsafe { RecvAudioGuard::new(ptr::null_mut(), raw) };
4647        let frame_ref = unsafe { AudioFrameRef::new(guard) }.expect("valid audio ref");
4648
4649        assert_eq!(frame_ref.metadata(), Some("audio meta"));
4650        let owned = frame_ref.to_owned().expect("owned conversion");
4651        assert_eq!(owned.metadata(), Some("audio meta"));
4652        assert_eq!(owned.data().len(), sample_count);
4653
4654        std::mem::forget(frame_ref);
4655    }
4656
4657    #[test]
4658    fn test_owned_video_from_raw_rejects_malformed_frame_metadata() {
4659        let width = 16;
4660        let height = 8;
4661        let stride = width * 4;
4662        let expected_len = (stride * height) as usize;
4663        let mut data = vec![0u8; expected_len];
4664        let mut metadata = vec![b'x'; MAX_METADATA_BYTES];
4665
4666        let raw = NDIlib_video_frame_v2_t {
4667            xres: width,
4668            yres: height,
4669            FourCC: PixelFormat::BGRA.into(),
4670            frame_rate_N: 60,
4671            frame_rate_D: 1,
4672            picture_aspect_ratio: 16.0 / 9.0,
4673            frame_format_type: ScanType::Progressive.into(),
4674            timecode: 0,
4675            p_data: data.as_mut_ptr(),
4676            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4677                line_stride_in_bytes: stride,
4678            },
4679            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
4680            timestamp: 0,
4681        };
4682
4683        assert!(matches!(
4684            unsafe { VideoFrame::from_raw(&raw) },
4685            Err(Error::InvalidFrame(_))
4686        ));
4687
4688        metadata[0] = 0xFF;
4689        metadata[1] = 0;
4690        assert!(matches!(
4691            unsafe { VideoFrame::from_raw(&raw) },
4692            Err(Error::InvalidUtf8(_))
4693        ));
4694    }
4695
4696    #[test]
4697    fn test_owned_frame_metadata_builders_setters_and_raw_conversion() {
4698        let mut video = VideoFrame::builder().metadata("").build().unwrap();
4699        assert_eq!(video.metadata(), Some(""));
4700        let raw = video.to_raw();
4701        assert!(!raw.p_metadata.is_null());
4702        assert_eq!(
4703            unsafe { slice::from_raw_parts(raw.p_metadata.cast::<u8>(), 1) },
4704            b"\0"
4705        );
4706
4707        video.set_metadata(Some("video meta")).unwrap();
4708        assert_eq!(video.metadata(), Some("video meta"));
4709        let raw = video.to_raw();
4710        assert_eq!(
4711            unsafe { slice::from_raw_parts(raw.p_metadata.cast::<u8>(), 11) },
4712            b"video meta\0"
4713        );
4714
4715        video.set_metadata(Option::<String>::None).unwrap();
4716        assert!(video.metadata().is_none());
4717        assert!(video.to_raw().p_metadata.is_null());
4718
4719        let mut audio = AudioFrame::builder().metadata("").build().unwrap();
4720        assert_eq!(audio.metadata(), Some(""));
4721        assert!(!audio.to_raw().p_metadata.is_null());
4722        audio.set_metadata(Some("audio meta")).unwrap();
4723        assert_eq!(audio.metadata(), Some("audio meta"));
4724
4725        assert!(matches!(
4726            VideoFrame::builder().metadata("bad\0metadata").build(),
4727            Err(Error::InvalidCString(_))
4728        ));
4729        assert!(matches!(
4730            AudioFrame::builder().metadata("bad\0metadata").build(),
4731            Err(Error::InvalidCString(_))
4732        ));
4733        assert!(matches!(
4734            video.set_metadata(Some("bad\0metadata")),
4735            Err(Error::InvalidCString(_))
4736        ));
4737        assert!(matches!(
4738            audio.set_metadata(Some("bad\0metadata")),
4739            Err(Error::InvalidCString(_))
4740        ));
4741
4742        let oversized = "x".repeat(MAX_METADATA_BYTES);
4743        assert!(matches!(
4744            VideoFrame::builder().metadata(oversized.clone()).build(),
4745            Err(Error::InvalidFrame(_))
4746        ));
4747        assert!(matches!(
4748            AudioFrame::builder().metadata(oversized.clone()).build(),
4749            Err(Error::InvalidFrame(_))
4750        ));
4751        assert!(matches!(
4752            video.set_metadata(Some(oversized.clone())),
4753            Err(Error::InvalidFrame(_))
4754        ));
4755        assert!(matches!(
4756            audio.set_metadata(Some(oversized)),
4757            Err(Error::InvalidFrame(_))
4758        ));
4759    }
4760
4761    /// Test that audio frames with overflow in checked_mul are rejected
4762    #[test]
4763    fn test_audio_overflow_checked_mul() {
4764        // Use an extreme channel count to exceed the maximum backing buffer size.
4765        let no_samples = 1024;
4766        let no_channels = i32::MAX;
4767        let mut dummy_data = vec![0f32; 1024]; // Small buffer, won't be used due to guard
4768
4769        let raw = NDIlib_audio_frame_v3_t {
4770            sample_rate: 48000,
4771            no_channels,
4772            no_samples,
4773            timecode: 0,
4774            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
4775            p_data: dummy_data.as_mut_ptr() as *mut u8,
4776            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
4777                channel_stride_in_bytes: no_samples * 4,
4778            },
4779            p_metadata: ptr::null(),
4780            timestamp: 0,
4781        };
4782
4783        let result = AudioFrame::from_raw(raw);
4784        assert!(
4785            result.is_err(),
4786            "Should reject audio frame with sample count overflow or exceeding size limit"
4787        );
4788
4789        if let Err(Error::InvalidFrame(msg)) = result {
4790            // Accept either overflow or size limit error - both are correct guards
4791            assert!(
4792                msg.contains("overflow") || msg.contains("exceeds maximum size"),
4793                "Error message should mention overflow or size limit, got: {msg}"
4794            );
4795        } else {
4796            panic!("Expected InvalidFrame error");
4797        }
4798    }
4799
4800    /// Test that normal audio frames within bounds succeed
4801    #[test]
4802    fn test_audio_within_bounds_succeeds() {
4803        // Typical audio frame: 48kHz, 2 channels, 1024 samples
4804        let sample_rate = 48000;
4805        let no_channels = 2;
4806        let no_samples = 1024;
4807        let sample_count = (no_samples * no_channels) as usize;
4808        let mut data = vec![0.5f32; sample_count];
4809
4810        let raw = NDIlib_audio_frame_v3_t {
4811            sample_rate,
4812            no_channels,
4813            no_samples,
4814            timecode: 12345,
4815            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
4816            p_data: data.as_mut_ptr() as *mut u8,
4817            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
4818                channel_stride_in_bytes: no_samples * 4,
4819            },
4820            p_metadata: ptr::null(),
4821            timestamp: 67890,
4822        };
4823
4824        let result = AudioFrame::from_raw(raw);
4825        assert!(
4826            result.is_ok(),
4827            "Should accept normal audio frame within bounds"
4828        );
4829
4830        let frame = result.unwrap();
4831        assert_eq!(frame.data().len(), sample_count);
4832        assert_eq!(frame.num_samples(), no_samples);
4833        assert_eq!(frame.num_channels(), no_channels);
4834    }
4835
4836    #[test]
4837    fn test_audio_builder_rejects_invalid_dimensions() {
4838        assert!(matches!(
4839            AudioFrame::builder().sample_rate(0).build(),
4840            Err(Error::InvalidFrame(_))
4841        ));
4842        assert!(matches!(
4843            AudioFrame::builder().channels(0).build(),
4844            Err(Error::InvalidFrame(_))
4845        ));
4846        assert!(matches!(
4847            AudioFrame::builder().samples(0).build(),
4848            Err(Error::InvalidFrame(_))
4849        ));
4850        assert!(matches!(
4851            AudioFrame::builder().samples(-1).build(),
4852            Err(Error::InvalidFrame(_))
4853        ));
4854    }
4855
4856    #[test]
4857    fn test_audio_builder_rejects_oversized_layout() {
4858        let samples = (MAX_AUDIO_BYTES / std::mem::size_of::<f32>()) as i32 + 1;
4859        let result = AudioFrame::builder().channels(1).samples(samples).build();
4860
4861        assert!(matches!(result, Err(Error::InvalidFrame(_))));
4862    }
4863
4864    #[test]
4865    fn test_video_builder_rejects_invalid_send_metadata() {
4866        assert!(matches!(
4867            VideoFrame::builder().frame_rate(0, 1).build(),
4868            Err(Error::InvalidFrame(_))
4869        ));
4870        assert!(matches!(
4871            VideoFrame::builder().frame_rate(30, 0).build(),
4872            Err(Error::InvalidFrame(_))
4873        ));
4874        assert!(matches!(
4875            VideoFrame::builder().aspect_ratio(f32::NAN).build(),
4876            Err(Error::InvalidFrame(_))
4877        ));
4878        assert!(matches!(
4879            VideoFrame::builder().aspect_ratio(0.0).build(),
4880            Err(Error::InvalidFrame(_))
4881        ));
4882    }
4883
4884    #[test]
4885    fn test_owned_frame_data_replacement_preserves_layout_size() {
4886        let mut video = VideoFrame::builder()
4887            .resolution(16, 16)
4888            .pixel_format(PixelFormat::BGRA)
4889            .build()
4890            .unwrap();
4891        assert!(video.replace_data(vec![0; video.data().len() - 1]).is_err());
4892
4893        let mut audio = AudioFrame::builder()
4894            .channels(2)
4895            .samples(16)
4896            .build()
4897            .unwrap();
4898        assert!(audio
4899            .replace_data(vec![0.0; audio.data().len() + 1])
4900            .is_err());
4901    }
4902
4903    /// Test that uncompressed video uses MAX_VIDEO_BYTES constant for bounds check
4904    #[test]
4905    fn test_video_uncompressed_uses_constant_cap() {
4906        // Create an uncompressed frame that would exceed MAX_VIDEO_BYTES
4907        // 8K resolution: 7680 x 4320, BGRA = 4 bytes per pixel
4908        // Total: 7680 * 4320 * 4 = 132,710,400 bytes > 100 MiB
4909        let width = 7680;
4910        let height = 4320;
4911        let stride = width * 4;
4912        let expected_size = (stride * height) as usize;
4913        let mut data = vec![0u8; expected_size];
4914
4915        let c_frame = NDIlib_video_frame_v2_t {
4916            xres: width,
4917            yres: height,
4918            FourCC: PixelFormat::BGRA.into(),
4919            frame_rate_N: 60,
4920            frame_rate_D: 1,
4921            picture_aspect_ratio: 16.0 / 9.0,
4922            frame_format_type: ScanType::Progressive.into(),
4923            timecode: 0,
4924            p_data: data.as_mut_ptr(),
4925            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4926                line_stride_in_bytes: stride,
4927            },
4928            p_metadata: ptr::null(),
4929            timestamp: 0,
4930        };
4931
4932        let result = unsafe { VideoFrame::from_raw(&c_frame) };
4933        assert!(
4934            result.is_err(),
4935            "Should reject uncompressed video frame exceeding MAX_VIDEO_BYTES"
4936        );
4937
4938        if let Err(Error::InvalidFrame(msg)) = result {
4939            assert!(
4940                msg.contains("exceeds maximum size"),
4941                "Error message should mention size limit, got: {msg}"
4942            );
4943        } else {
4944            panic!("Expected InvalidFrame error");
4945        }
4946    }
4947
4948    // =========================================================================
4949    // Tests for frame layout validation helpers
4950    // =========================================================================
4951
4952    /// Test validate_video_layout with valid uncompressed frame
4953    #[test]
4954    fn test_validate_video_layout_valid_uncompressed() {
4955        let width = 1920;
4956        let height = 1080;
4957        let stride = width * 4; // BGRA
4958        let expected_size = (stride * height) as usize;
4959        let mut data = vec![0u8; expected_size];
4960
4961        let raw = NDIlib_video_frame_v2_t {
4962            xres: width,
4963            yres: height,
4964            FourCC: PixelFormat::BGRA.into(),
4965            frame_rate_N: 60,
4966            frame_rate_D: 1,
4967            picture_aspect_ratio: 16.0 / 9.0,
4968            frame_format_type: ScanType::Progressive.into(),
4969            timecode: 0,
4970            p_data: data.as_mut_ptr(),
4971            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4972                line_stride_in_bytes: stride,
4973            },
4974            p_metadata: ptr::null(),
4975            timestamp: 0,
4976        };
4977
4978        let result = validate_video_layout(&raw);
4979        assert!(result.is_ok(), "Should validate valid uncompressed frame");
4980
4981        let layout = result.unwrap();
4982        assert_eq!(layout.pixel_format, PixelFormat::BGRA);
4983        assert_eq!(layout.data_len_bytes, expected_size);
4984        assert_eq!(
4985            layout.line_stride_or_size,
4986            LineStrideOrSize::LineStrideBytes(stride)
4987        );
4988    }
4989
4990    /// Test validate_video_layout rejects null data pointer
4991    #[test]
4992    fn test_validate_video_layout_null_pointer() {
4993        let raw = NDIlib_video_frame_v2_t {
4994            xres: 1920,
4995            yres: 1080,
4996            FourCC: PixelFormat::BGRA.into(),
4997            frame_rate_N: 60,
4998            frame_rate_D: 1,
4999            picture_aspect_ratio: 16.0 / 9.0,
5000            frame_format_type: ScanType::Progressive.into(),
5001            timecode: 0,
5002            p_data: ptr::null_mut(),
5003            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5004                line_stride_in_bytes: 7680,
5005            },
5006            p_metadata: ptr::null(),
5007            timestamp: 0,
5008        };
5009
5010        let result = validate_video_layout(&raw);
5011        assert!(result.is_err(), "Should reject null data pointer");
5012
5013        if let Err(Error::InvalidFrame(msg)) = result {
5014            assert!(
5015                msg.contains("null data pointer"),
5016                "Error should mention null pointer, got: {msg}"
5017            );
5018        } else {
5019            panic!("Expected InvalidFrame error");
5020        }
5021    }
5022
5023    /// Test validate_video_layout rejects invalid line_stride
5024    #[test]
5025    fn test_validate_video_layout_invalid_stride() {
5026        let mut data = vec![0u8; 1024];
5027
5028        let raw = NDIlib_video_frame_v2_t {
5029            xres: 1920,
5030            yres: 1080,
5031            FourCC: PixelFormat::BGRA.into(),
5032            frame_rate_N: 60,
5033            frame_rate_D: 1,
5034            picture_aspect_ratio: 16.0 / 9.0,
5035            frame_format_type: ScanType::Progressive.into(),
5036            timecode: 0,
5037            p_data: data.as_mut_ptr(),
5038            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5039                line_stride_in_bytes: 0, // Invalid stride
5040            },
5041            p_metadata: ptr::null(),
5042            timestamp: 0,
5043        };
5044
5045        let result = validate_video_layout(&raw);
5046        assert!(result.is_err(), "Should reject zero line_stride");
5047
5048        if let Err(Error::InvalidFrame(msg)) = result {
5049            assert!(
5050                msg.contains("invalid line_stride_in_bytes"),
5051                "Error should mention invalid stride, got: {msg}"
5052            );
5053        } else {
5054            panic!("Expected InvalidFrame error");
5055        }
5056    }
5057
5058    /// Test validate_video_layout rejects strides smaller than one row.
5059    #[test]
5060    fn test_validate_video_layout_rejects_short_stride() {
5061        let mut data = vec![0u8; 1024];
5062
5063        let raw = NDIlib_video_frame_v2_t {
5064            xres: 1920,
5065            yres: 1080,
5066            FourCC: PixelFormat::BGRA.into(),
5067            frame_rate_N: 60,
5068            frame_rate_D: 1,
5069            picture_aspect_ratio: 16.0 / 9.0,
5070            frame_format_type: ScanType::Progressive.into(),
5071            timecode: 0,
5072            p_data: data.as_mut_ptr(),
5073            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5074                line_stride_in_bytes: 1919 * 4,
5075            },
5076            p_metadata: ptr::null(),
5077            timestamp: 0,
5078        };
5079
5080        let result = validate_video_layout(&raw);
5081        assert!(matches!(result, Err(Error::InvalidFrame(_))));
5082    }
5083
5084    /// Test validate_video_layout rejects odd dimensions for planar 4:2:0.
5085    #[test]
5086    fn test_validate_video_layout_rejects_planar_odd_dimensions() {
5087        let mut data = vec![0u8; 4096];
5088
5089        let raw = NDIlib_video_frame_v2_t {
5090            xres: 641,
5091            yres: 480,
5092            FourCC: PixelFormat::I420.into(),
5093            frame_rate_N: 60,
5094            frame_rate_D: 1,
5095            picture_aspect_ratio: 4.0 / 3.0,
5096            frame_format_type: ScanType::Progressive.into(),
5097            timecode: 0,
5098            p_data: data.as_mut_ptr(),
5099            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5100                line_stride_in_bytes: 642,
5101            },
5102            p_metadata: ptr::null(),
5103            timestamp: 0,
5104        };
5105
5106        let result = validate_video_layout(&raw);
5107        assert!(matches!(result, Err(Error::InvalidFrame(_))));
5108    }
5109
5110    /// Test validate_video_layout rejects negative dimensions
5111    #[test]
5112    fn test_validate_video_layout_negative_dimensions() {
5113        let mut data = vec![0u8; 1024];
5114
5115        let raw = NDIlib_video_frame_v2_t {
5116            xres: 1920,
5117            yres: -1, // Negative height
5118            FourCC: PixelFormat::BGRA.into(),
5119            frame_rate_N: 60,
5120            frame_rate_D: 1,
5121            picture_aspect_ratio: 16.0 / 9.0,
5122            frame_format_type: ScanType::Progressive.into(),
5123            timecode: 0,
5124            p_data: data.as_mut_ptr(),
5125            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5126                line_stride_in_bytes: 7680,
5127            },
5128            p_metadata: ptr::null(),
5129            timestamp: 0,
5130        };
5131
5132        let result = validate_video_layout(&raw);
5133        assert!(result.is_err(), "Should reject negative height");
5134
5135        if let Err(Error::InvalidFrame(msg)) = result {
5136            assert!(
5137                msg.contains("invalid height"),
5138                "Error should mention invalid height, got: {msg}"
5139            );
5140        } else {
5141            panic!("Expected InvalidFrame error");
5142        }
5143    }
5144
5145    /// Test validate_video_layout rejects oversized frames
5146    #[test]
5147    fn test_validate_video_layout_exceeds_max() {
5148        // 8K resolution: 7680 x 4320, BGRA = 4 bytes per pixel
5149        // Total: 7680 * 4320 * 4 = 132,710,400 bytes > 100 MiB (MAX_VIDEO_BYTES)
5150        let width = 7680;
5151        let height = 4320;
5152        let stride = width * 4;
5153        let expected_size = (stride * height) as usize;
5154        let mut data = vec![0u8; expected_size];
5155
5156        let raw = NDIlib_video_frame_v2_t {
5157            xres: width,
5158            yres: height,
5159            FourCC: PixelFormat::BGRA.into(),
5160            frame_rate_N: 60,
5161            frame_rate_D: 1,
5162            picture_aspect_ratio: 16.0 / 9.0,
5163            frame_format_type: ScanType::Progressive.into(),
5164            timecode: 0,
5165            p_data: data.as_mut_ptr(),
5166            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5167                line_stride_in_bytes: stride,
5168            },
5169            p_metadata: ptr::null(),
5170            timestamp: 0,
5171        };
5172
5173        let result = validate_video_layout(&raw);
5174        assert!(result.is_err(), "Should reject oversized frame");
5175
5176        if let Err(Error::InvalidFrame(msg)) = result {
5177            assert!(
5178                msg.contains("exceeds maximum size"),
5179                "Error should mention size limit, got: {msg}"
5180            );
5181        } else {
5182            panic!("Expected InvalidFrame error");
5183        }
5184    }
5185
5186    /// Test validate_audio_layout with valid frame
5187    #[test]
5188    fn test_validate_audio_layout_valid() {
5189        let no_samples = 1024;
5190        let no_channels = 2;
5191        let sample_count = (no_samples * no_channels) as usize;
5192        let mut data = vec![0.0f32; sample_count];
5193
5194        let raw = NDIlib_audio_frame_v3_t {
5195            sample_rate: 48000,
5196            no_channels,
5197            no_samples,
5198            timecode: 0,
5199            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5200            p_data: data.as_mut_ptr() as *mut u8,
5201            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5202                channel_stride_in_bytes: no_samples * 4,
5203            },
5204            p_metadata: ptr::null(),
5205            timestamp: 0,
5206        };
5207
5208        let result = validate_audio_layout(&raw);
5209        assert!(result.is_ok(), "Should validate valid audio frame");
5210
5211        let layout = result.unwrap();
5212        assert_eq!(layout.format, Some(AudioFormat::FLTP));
5213        assert_eq!(layout.sample_count, sample_count);
5214    }
5215
5216    /// Test validate_audio_layout supports strided planar FLTP audio.
5217    #[test]
5218    fn test_validate_audio_layout_strided_planar() {
5219        let no_samples = 4;
5220        let no_channels = 2;
5221        let stride_samples = 6;
5222        let backing_samples = (stride_samples + no_samples) as usize;
5223        let mut data = vec![0.0f32; backing_samples];
5224
5225        let raw = NDIlib_audio_frame_v3_t {
5226            sample_rate: 48000,
5227            no_channels,
5228            no_samples,
5229            timecode: 0,
5230            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5231            p_data: data.as_mut_ptr() as *mut u8,
5232            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5233                channel_stride_in_bytes: stride_samples * 4,
5234            },
5235            p_metadata: ptr::null(),
5236            timestamp: 0,
5237        };
5238
5239        let layout = validate_audio_layout(&raw).expect("strided FLTP should validate");
5240        assert_eq!(layout.sample_count, backing_samples);
5241        assert_eq!(layout.channel_stride_samples, stride_samples as usize);
5242        assert_eq!(layout.channel_range(1), Some(6..10));
5243    }
5244
5245    /// Test validate_audio_layout_allow_empty accepts the documented no-source query state.
5246    #[test]
5247    fn test_validate_audio_layout_empty_query_no_source() {
5248        let raw = NDIlib_audio_frame_v3_t::default();
5249
5250        let layout =
5251            validate_audio_layout_allow_empty(&raw).expect("all-zero query state should validate");
5252        assert!(layout.is_empty());
5253        assert_eq!(layout.format(), None);
5254        assert_eq!(layout.sample_count, 0);
5255    }
5256
5257    /// Test validate_audio_layout_allow_empty accepts source-format query without samples.
5258    #[test]
5259    fn test_validate_audio_layout_empty_query_with_source_format() {
5260        let raw = NDIlib_audio_frame_v3_t {
5261            sample_rate: 48000,
5262            no_channels: 2,
5263            no_samples: 0,
5264            timecode: 0,
5265            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5266            p_data: ptr::null_mut(),
5267            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5268                channel_stride_in_bytes: 0,
5269            },
5270            p_metadata: ptr::null(),
5271            timestamp: 0,
5272        };
5273
5274        let layout = validate_audio_layout_allow_empty(&raw)
5275            .expect("query source format should validate without samples");
5276        assert!(layout.is_empty());
5277        assert_eq!(layout.format(), Some(AudioFormat::FLTP));
5278        assert_eq!(layout.sample_rate, 48000);
5279        assert_eq!(layout.no_channels, 2);
5280    }
5281
5282    /// Test empty audio validation rejects partial query/no-source states.
5283    #[test]
5284    fn test_validate_audio_layout_rejects_partial_empty_query() {
5285        let raw = NDIlib_audio_frame_v3_t {
5286            sample_rate: 48000,
5287            no_channels: 0,
5288            no_samples: 0,
5289            timecode: 0,
5290            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5291            p_data: ptr::null_mut(),
5292            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5293                channel_stride_in_bytes: 0,
5294            },
5295            p_metadata: ptr::null(),
5296            timestamp: 0,
5297        };
5298
5299        let result = validate_audio_layout_allow_empty(&raw);
5300        assert!(matches!(result, Err(Error::InvalidFrame(_))));
5301    }
5302
5303    /// Test validate_audio_layout rejects invalid channel stride.
5304    #[test]
5305    fn test_validate_audio_layout_rejects_invalid_channel_stride() {
5306        let mut data = vec![0.0f32; 2048];
5307
5308        let raw = NDIlib_audio_frame_v3_t {
5309            sample_rate: 48000,
5310            no_channels: 2,
5311            no_samples: 1024,
5312            timecode: 0,
5313            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5314            p_data: data.as_mut_ptr() as *mut u8,
5315            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5316                channel_stride_in_bytes: 1024 * 4 - 4,
5317            },
5318            p_metadata: ptr::null(),
5319            timestamp: 0,
5320        };
5321
5322        let result = validate_audio_layout(&raw);
5323        assert!(matches!(result, Err(Error::InvalidFrame(_))));
5324    }
5325
5326    /// Test validate_audio_layout rejects null data pointer
5327    #[test]
5328    fn test_validate_audio_layout_null_pointer() {
5329        let raw = NDIlib_audio_frame_v3_t {
5330            sample_rate: 48000,
5331            no_channels: 2,
5332            no_samples: 1024,
5333            timecode: 0,
5334            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5335            p_data: ptr::null_mut(),
5336            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5337                channel_stride_in_bytes: 1024 * 4,
5338            },
5339            p_metadata: ptr::null(),
5340            timestamp: 0,
5341        };
5342
5343        let result = validate_audio_layout(&raw);
5344        assert!(result.is_err(), "Should reject null data pointer");
5345
5346        if let Err(Error::InvalidFrame(msg)) = result {
5347            assert!(
5348                msg.contains("null data pointer"),
5349                "Error should mention null pointer, got: {msg}"
5350            );
5351        } else {
5352            panic!("Expected InvalidFrame error");
5353        }
5354    }
5355
5356    /// Test validate_audio_layout rejects negative sample count
5357    #[test]
5358    fn test_validate_audio_layout_negative_samples() {
5359        let mut data = vec![0.0f32; 1024];
5360
5361        let raw = NDIlib_audio_frame_v3_t {
5362            sample_rate: 48000,
5363            no_channels: 2,
5364            no_samples: -1, // Negative
5365            timecode: 0,
5366            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5367            p_data: data.as_mut_ptr() as *mut u8,
5368            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5369                channel_stride_in_bytes: 1024 * 4,
5370            },
5371            p_metadata: ptr::null(),
5372            timestamp: 0,
5373        };
5374
5375        let result = validate_audio_layout(&raw);
5376        assert!(result.is_err(), "Should reject negative sample count");
5377
5378        if let Err(Error::InvalidFrame(msg)) = result {
5379            assert!(
5380                msg.contains("Invalid number of samples"),
5381                "Error should mention invalid samples, got: {msg}"
5382            );
5383        } else {
5384            panic!("Expected InvalidFrame error");
5385        }
5386    }
5387
5388    /// Test validate_audio_layout rejects overflow scenario
5389    #[test]
5390    fn test_validate_audio_layout_overflow() {
5391        let mut data = vec![0.0f32; 1024];
5392
5393        let raw = NDIlib_audio_frame_v3_t {
5394            sample_rate: 48000,
5395            no_channels: i32::MAX,
5396            no_samples: 1024,
5397            timecode: 0,
5398            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5399            p_data: data.as_mut_ptr() as *mut u8,
5400            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5401                channel_stride_in_bytes: 1024 * 4,
5402            },
5403            p_metadata: ptr::null(),
5404            timestamp: 0,
5405        };
5406
5407        let result = validate_audio_layout(&raw);
5408        assert!(
5409            result.is_err(),
5410            "Should reject audio frame with overflow potential"
5411        );
5412
5413        if let Err(Error::InvalidFrame(msg)) = result {
5414            assert!(
5415                msg.contains("overflow") || msg.contains("exceeds maximum size"),
5416                "Error should mention overflow or size limit, got: {msg}"
5417            );
5418        } else {
5419            panic!("Expected InvalidFrame error");
5420        }
5421    }
5422
5423    /// Test calculate_buffer_len_checked matches PixelFormatInfo::try_buffer_len for valid inputs
5424    #[test]
5425    fn test_calculate_buffer_len_checked_matches_public_helper() {
5426        let test_cases = [
5427            (PixelFormat::BGRA, 7680usize, 1080usize),
5428            (PixelFormat::UYVY, 3840usize, 1080usize),
5429            (PixelFormat::NV12, 1920usize, 1080usize),
5430            (PixelFormat::YV12, 1920usize, 1080usize),
5431            (PixelFormat::I420, 1920usize, 1080usize),
5432        ];
5433
5434        for (format, stride, height) in test_cases {
5435            let expected = format
5436                .info()
5437                .try_buffer_len(stride as i32, height as i32)
5438                .unwrap();
5439            let result = calculate_buffer_len_checked(format, stride, height);
5440
5441            assert!(
5442                result.is_ok(),
5443                "Should succeed for valid inputs: {:?}",
5444                format
5445            );
5446            assert_eq!(
5447                result.unwrap(),
5448                expected,
5449                "Checked calculation should match unchecked for {:?}",
5450                format
5451            );
5452        }
5453    }
5454
5455    /// Test that VideoFrameRef::data() uses cached length
5456    #[test]
5457    fn test_video_frame_ref_uses_cached_length() {
5458        use crate::capture::{Guard, VideoKind};
5459
5460        // Create a mock video frame for testing
5461        let width = 1920;
5462        let height = 1080;
5463        let stride = width * 4;
5464        let expected_size = (stride * height) as usize;
5465        let data = vec![0u8; expected_size];
5466
5467        let raw = NDIlib_video_frame_v2_t {
5468            xres: width,
5469            yres: height,
5470            FourCC: PixelFormat::BGRA.into(),
5471            frame_rate_N: 60,
5472            frame_rate_D: 1,
5473            picture_aspect_ratio: 16.0 / 9.0,
5474            frame_format_type: ScanType::Progressive.into(),
5475            timecode: 12345,
5476            p_data: data.as_ptr() as *mut u8,
5477            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5478                line_stride_in_bytes: stride,
5479            },
5480            p_metadata: ptr::null(),
5481            timestamp: 67890,
5482        };
5483
5484        // Create a guard with a null receiver instance (we won't use the free function)
5485        // This is safe because we'll forget the guard before it drops
5486        let guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw) };
5487
5488        // Create the VideoFrameRef
5489        let frame_ref = unsafe { VideoFrameRef::new(guard) };
5490        assert!(frame_ref.is_ok(), "Should create valid VideoFrameRef");
5491
5492        let frame_ref = frame_ref.unwrap();
5493
5494        // Verify the cached length is used
5495        assert_eq!(
5496            frame_ref.data().len(),
5497            expected_size,
5498            "data() should return slice with cached length"
5499        );
5500        assert_eq!(
5501            frame_ref.layout.data_len_bytes, expected_size,
5502            "Cached data_len_bytes should match expected"
5503        );
5504
5505        // Forget the guard to prevent calling the free function with null instance
5506        std::mem::forget(frame_ref);
5507    }
5508
5509    /// Test that AudioFrameRef::data() uses cached sample count
5510    #[test]
5511    fn test_audio_frame_ref_uses_cached_sample_count() {
5512        use crate::capture::RecvAudioGuard;
5513
5514        let no_samples = 1024;
5515        let no_channels = 2;
5516        let sample_count = (no_samples * no_channels) as usize;
5517        let data = vec![0.5f32; sample_count];
5518
5519        let raw = NDIlib_audio_frame_v3_t {
5520            sample_rate: 48000,
5521            no_channels,
5522            no_samples,
5523            timecode: 12345,
5524            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5525            p_data: data.as_ptr() as *mut u8,
5526            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5527                channel_stride_in_bytes: no_samples * 4,
5528            },
5529            p_metadata: ptr::null(),
5530            timestamp: 67890,
5531        };
5532
5533        // Create a guard with a null receiver instance (we won't use the free function)
5534        let guard = unsafe { RecvAudioGuard::new(ptr::null_mut(), raw) };
5535
5536        // Create the AudioFrameRef
5537        let frame_ref = unsafe { AudioFrameRef::new(guard) };
5538        assert!(frame_ref.is_ok(), "Should create valid AudioFrameRef");
5539
5540        let frame_ref = frame_ref.unwrap();
5541
5542        // Verify the cached sample count is used
5543        assert_eq!(
5544            frame_ref.data().len(),
5545            sample_count,
5546            "data() should return slice with cached sample count"
5547        );
5548        assert_eq!(
5549            frame_ref.layout.sample_count, sample_count,
5550            "Cached sample_count should match expected"
5551        );
5552
5553        // Forget the guard to prevent calling the free function with null instance
5554        std::mem::forget(frame_ref);
5555    }
5556}