Skip to main content

grafton_ndi/
frames.rs

1//! Frame types for video, audio, and metadata.
2
3use num_enum::{IntoPrimitive, TryFromPrimitive};
4
5use std::{
6    ffi::{CStr, CString},
7    fmt,
8    marker::PhantomData,
9    num::NonZeroUsize,
10    os::raw::c_char,
11    ptr, slice, str,
12};
13
14use crate::{
15    capture::{AudioKind, FrameFree, FrameSyncAudioFree, Guard, VideoKind},
16    ndi_lib::*,
17    recv_guard::{RecvAudioGuard, RecvMetadataGuard},
18    Error, Result,
19};
20
21/// Video pixel format identifiers (FourCC codes).
22///
23/// These represent the various pixel formats supported by NDI for video frames.
24/// The most common formats are BGRA/RGBA for full quality and UYVY for bandwidth-efficient streaming.
25///
26/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new formats
27/// without breaking existing code. Always use a wildcard pattern when matching.
28///
29/// # Examples
30///
31/// ```
32/// use grafton_ndi::PixelFormat;
33///
34/// // For maximum compatibility and quality
35/// let format = PixelFormat::BGRA;
36///
37/// // For bandwidth-efficient streaming
38/// let format = PixelFormat::UYVY;
39///
40/// // When matching, always include a wildcard for forward compatibility
41/// match format {
42///     PixelFormat::BGRA | PixelFormat::RGBA => println!("Full quality RGB"),
43///     PixelFormat::UYVY => println!("Compressed YUV"),
44///     _ => println!("Other format"),
45/// }
46/// ```
47#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49#[repr(u32)]
50pub enum PixelFormat {
51    /// YCbCr 4:2:2 format (16 bits per pixel) - bandwidth efficient.
52    UYVY = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_UYVY as _,
53    /// YCbCr 4:2:2 with alpha channel (24 bits per pixel).
54    UYVA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_UYVA as _,
55    /// 16-bit YCbCr 4:2:2 format.
56    P216 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_P216 as _,
57    /// 16-bit YCbCr 4:2:2 with alpha.
58    PA16 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_PA16 as _,
59    /// Planar YCbCr 4:2:0 format (12 bits per pixel).
60    YV12 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_YV12 as _,
61    /// Planar YCbCr 4:2:0 format (12 bits per pixel).
62    I420 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_I420 as _,
63    /// Semi-planar YCbCr 4:2:0 format (12 bits per pixel).
64    NV12 = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_NV12 as _,
65    /// Blue-Green-Red-Alpha format (32 bits per pixel) - full quality.
66    BGRA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_BGRA as _,
67    /// Blue-Green-Red with padding (32 bits per pixel).
68    BGRX = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_BGRX as _,
69    /// Red-Green-Blue-Alpha format (32 bits per pixel) - full quality.
70    RGBA = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_RGBA as _,
71    /// Red-Green-Blue with padding (32 bits per pixel).
72    RGBX = NDIlib_FourCC_video_type_e_NDIlib_FourCC_video_type_RGBX as _,
73}
74
75/// Category of pixel format for buffer calculation and union field access.
76///
77/// This enum distinguishes between different memory layouts:
78/// - **Packed**: All pixel components are interleaved in a single buffer
79/// - **Planar420**: Y, U, V planes stored separately with 4:2:0 chroma subsampling
80/// - **SemiPlanar420**: Y plane followed by interleaved UV plane (NV12)
81/// - **PackedAlpha**: Packed 4:2:2 plane followed by an 8-bit alpha plane (UYVA)
82/// - **SemiPlanar422**: 16-bit Y plane followed by interleaved CbCr plane (P216)
83/// - **SemiPlanar422Alpha**: [`FormatCategory::SemiPlanar422`] followed by a
84///   16-bit alpha plane (PA16)
85///
86/// This enum is marked `#[non_exhaustive]` because the NDI SDK's video FourCC
87/// namespace is open and grows across releases; future formats may introduce
88/// additional memory-layout categories.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum FormatCategory {
92    /// Packed formats: simple stride * height buffer.
93    Packed,
94    /// Planar 4:2:0 with separate U and V planes (YV12, I420).
95    Planar420,
96    /// Semi-planar 4:2:0 with interleaved UV plane (NV12).
97    SemiPlanar420,
98    /// Packed UYVY plane followed by an 8-bit alpha plane whose stride is half
99    /// the line stride (UYVA).
100    PackedAlpha,
101    /// Semi-planar 4:2:2 with a 16-bit Y plane followed by an interleaved
102    /// 16-bit CbCr plane, both full height at the line stride (P216).
103    SemiPlanar422,
104    /// [`FormatCategory::SemiPlanar422`] followed by a 16-bit alpha plane at
105    /// the line stride (PA16).
106    SemiPlanar422Alpha,
107}
108
109/// Compile-time pixel format properties.
110///
111/// This struct encapsulates all format-specific knowledge in one location,
112/// providing a single source of truth for buffer size calculations, stride
113/// computation, and format category detection.
114///
115/// # Examples
116///
117/// ```
118/// use grafton_ndi::{PixelFormat, FormatCategory};
119///
120/// let info = PixelFormat::BGRA.info();
121/// assert_eq!(info.bytes_per_pixel(), 4);
122/// assert_eq!(info.category(), FormatCategory::Packed);
123///
124/// let info = PixelFormat::NV12.info();
125/// assert_eq!(info.bytes_per_pixel(), 1);
126/// assert_eq!(info.category(), FormatCategory::SemiPlanar420);
127/// ```
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct PixelFormatInfo {
130    /// Bytes per pixel for packed formats, or first-plane bytes per pixel for
131    /// planar and semi-planar formats.
132    bytes_per_pixel: u8,
133    /// Format category for union field access and buffer calculation.
134    category: FormatCategory,
135}
136
137impl PixelFormatInfo {
138    /// Get bytes per pixel for packed formats, or first-plane bytes per pixel
139    /// for planar and semi-planar formats (the Y plane, or the UYVY plane of
140    /// UYVA).
141    #[must_use]
142    pub const fn bytes_per_pixel(&self) -> u8 {
143        self.bytes_per_pixel
144    }
145
146    /// Get the format category.
147    #[must_use]
148    pub const fn category(&self) -> FormatCategory {
149        self.category
150    }
151
152    /// Returns true if this is a planar 4:2:0 format (YV12, I420, or NV12).
153    #[must_use]
154    pub const fn is_planar_420(&self) -> bool {
155        matches!(
156            self.category,
157            FormatCategory::Planar420 | FormatCategory::SemiPlanar420
158        )
159    }
160
161    /// Calculate total buffer size for given dimensions and stride using
162    /// checked arithmetic.
163    ///
164    /// # Arguments
165    ///
166    /// * `y_stride` - The first-plane line stride in bytes (for planar formats) or total line stride (for packed formats)
167    /// * `height` - Frame height in pixels
168    ///
169    /// # Errors
170    ///
171    /// Returns [`Error::InvalidFrame`] if `y_stride` or `height` is not
172    /// positive, if planar 4:2:0 stride/height requirements are not met, if
173    /// arithmetic overflows, or if the result exceeds the crate's maximum video
174    /// frame size.
175    ///
176    /// # Format-specific calculations
177    ///
178    /// - **Packed RGB/YUV** (BGRA/BGRX/RGBA/RGBX/UYVY): `y_stride * height`
179    /// - **Packed 4:2:2 with alpha UYVA**: requires even stride, then
180    ///   `UYVY + A` where:
181    ///   - UYVY plane: `y_stride * height`
182    ///   - Alpha plane: `(y_stride/2) * height`
183    /// - **Semi-planar 4:2:2 P216**: `Y + CbCr` where each plane is
184    ///   `y_stride * height`
185    /// - **Semi-planar 4:2:2 with alpha PA16**: `Y + CbCr + A` where each
186    ///   plane is `y_stride * height`
187    /// - **Planar 4:2:0 YV12/I420**: requires even stride and height,
188    ///   then `Y + U + V` where:
189    ///   - Y plane: `y_stride * height`
190    ///   - U plane: `(y_stride/2) * (height/2)`
191    ///   - V plane: `(y_stride/2) * (height/2)`
192    /// - **Semi-planar 4:2:0 NV12**: requires even height, then `Y + UV` where:
193    ///   - Y plane: `y_stride * height`
194    ///   - UV plane: `y_stride * (height/2)`
195    pub fn try_buffer_len(&self, y_stride: i32, height: i32) -> Result<usize> {
196        if y_stride <= 0 {
197            return Err(Error::InvalidFrame(format!(
198                "Video line stride must be positive, got {y_stride}"
199            )));
200        }
201        if height <= 0 {
202            return Err(Error::InvalidFrame(format!(
203                "Video frame height must be positive, got {height}"
204            )));
205        }
206
207        let y_stride = usize::try_from(y_stride)
208            .map_err(|_| Error::InvalidFrame(format!("Invalid y_stride value: {y_stride}")))?;
209        let height = usize::try_from(height)
210            .map_err(|_| Error::InvalidFrame(format!("Invalid height value: {height}")))?;
211
212        let len = calculate_buffer_len_for_info_checked(*self, y_stride, height)?;
213        validate_video_data_len(len)?;
214        Ok(len)
215    }
216}
217
218impl PixelFormat {
219    /// Get compile-time format properties.
220    ///
221    /// This provides a single source of truth for all format-specific knowledge,
222    /// including bytes per pixel and format category.
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use grafton_ndi::{PixelFormat, FormatCategory};
228    ///
229    /// // Get properties for BGRA (32 bpp packed)
230    /// let info = PixelFormat::BGRA.info();
231    /// assert_eq!(info.bytes_per_pixel(), 4);
232    /// assert_eq!(info.category(), FormatCategory::Packed);
233    ///
234    /// // Get properties for YV12 (planar 4:2:0)
235    /// let info = PixelFormat::YV12.info();
236    /// assert_eq!(info.bytes_per_pixel(), 1);
237    /// assert_eq!(info.category(), FormatCategory::Planar420);
238    /// ```
239    #[must_use]
240    pub const fn info(self) -> PixelFormatInfo {
241        match self {
242            Self::BGRA | Self::BGRX | Self::RGBA | Self::RGBX => PixelFormatInfo {
243                bytes_per_pixel: 4,
244                category: FormatCategory::Packed,
245            },
246            Self::UYVY => PixelFormatInfo {
247                bytes_per_pixel: 2,
248                category: FormatCategory::Packed,
249            },
250            Self::UYVA => PixelFormatInfo {
251                bytes_per_pixel: 2,
252                category: FormatCategory::PackedAlpha,
253            },
254            Self::P216 => PixelFormatInfo {
255                bytes_per_pixel: 2,
256                category: FormatCategory::SemiPlanar422,
257            },
258            Self::PA16 => PixelFormatInfo {
259                bytes_per_pixel: 2,
260                category: FormatCategory::SemiPlanar422Alpha,
261            },
262            Self::YV12 | Self::I420 => PixelFormatInfo {
263                bytes_per_pixel: 1,
264                category: FormatCategory::Planar420,
265            },
266            Self::NV12 => PixelFormatInfo {
267                bytes_per_pixel: 1,
268                category: FormatCategory::SemiPlanar420,
269            },
270        }
271    }
272
273    /// Calculate line stride in bytes for a given width using checked
274    /// arithmetic.
275    ///
276    /// For packed formats, this returns the total bytes per row.
277    /// For planar and semi-planar formats, this returns the first-plane
278    /// stride, which is what the SDK expects in `line_stride_in_bytes`.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use grafton_ndi::PixelFormat;
284    ///
285    /// // BGRA: 4 bytes per pixel
286    /// assert_eq!(PixelFormat::BGRA.try_line_stride(1920)?, 7680);
287    ///
288    /// // UYVY: 2 bytes per pixel
289    /// assert_eq!(PixelFormat::UYVY.try_line_stride(1920)?, 3840);
290    ///
291    /// // NV12: Y-plane has 1 byte per pixel
292    /// assert_eq!(PixelFormat::NV12.try_line_stride(1920)?, 1920);
293    ///
294    /// // P216: Y-plane has 16 bits per pixel
295    /// assert_eq!(PixelFormat::P216.try_line_stride(1920)?, 3840);
296    /// # Ok::<(), grafton_ndi::Error>(())
297    /// ```
298    ///
299    /// # Errors
300    ///
301    /// Returns [`Error::InvalidFrame`] if the width is nonpositive or odd
302    /// or if the stride does not fit in `i32`.
303    pub fn try_line_stride(self, width: i32) -> Result<i32> {
304        validate_video_width(width)?;
305        let width_usize = usize::try_from(width)
306            .map_err(|_| Error::InvalidFrame(format!("Invalid width value: {width}")))?;
307        let stride = min_video_line_stride_checked(self, width_usize)?;
308        i32::try_from(stride).map_err(|_| {
309            Error::InvalidFrame(format!("Video line stride {stride} exceeds i32 range"))
310        })
311    }
312
313    /// Calculate the total buffer size needed for a frame with given dimensions
314    /// using checked arithmetic.
315    ///
316    /// This computes the validated minimum stride from the width and delegates
317    /// to the shared video layout validator.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use grafton_ndi::PixelFormat;
323    ///
324    /// // BGRA 1920x1080: 1920 * 4 * 1080 = 8,294,400 bytes
325    /// assert_eq!(PixelFormat::BGRA.try_buffer_size(1920, 1080)?, 8_294_400);
326    ///
327    /// // NV12 1920x1080: Y (1920*1080) + UV (1920*540) = 3,110,400 bytes
328    /// assert_eq!(PixelFormat::NV12.try_buffer_size(1920, 1080)?, 3_110_400);
329    /// # Ok::<(), grafton_ndi::Error>(())
330    /// ```
331    ///
332    /// # Errors
333    ///
334    /// Returns [`Error::InvalidFrame`] if dimensions are invalid for this
335    /// format, if arithmetic overflows, or if the result exceeds the crate's
336    /// maximum video frame size.
337    pub fn try_buffer_size(self, width: i32, height: i32) -> Result<usize> {
338        let layout = ValidatedVideoLayout::new_uncompressed(self, width, height, None)?;
339        Ok(layout.data_len_bytes)
340    }
341}
342
343impl From<PixelFormat> for i32 {
344    fn from(value: PixelFormat) -> Self {
345        let u32_value: u32 = value.into();
346        u32_value as i32
347    }
348}
349
350/// Video scan type (progressive, interlaced, or field-based).
351///
352/// This enum describes how video frames are scanned/displayed.
353/// Most modern content uses Progressive, while legacy broadcast may use Interlaced or field-based formats.
354///
355/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new scan types
356/// without breaking existing code. Always use a wildcard pattern when matching.
357///
358/// # Examples
359///
360/// ```
361/// use grafton_ndi::ScanType;
362///
363/// let scan = ScanType::Progressive;
364///
365/// // When matching, always include a wildcard for forward compatibility
366/// match scan {
367///     ScanType::Progressive => println!("Progressive scan"),
368///     ScanType::Interlaced => println!("Interlaced"),
369///     _ => println!("Field-based or other"),
370/// }
371/// ```
372#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
373#[non_exhaustive]
374#[repr(u32)]
375pub enum ScanType {
376    /// Progressive scan - full frames rendered sequentially.
377    Progressive = NDIlib_frame_format_type_e_NDIlib_frame_format_type_progressive as _,
378    /// Interlaced scan - alternating even/odd lines.
379    Interlaced = NDIlib_frame_format_type_e_NDIlib_frame_format_type_interleaved as _,
380    /// Field 0 only (first field of interlaced content).
381    Field0 = NDIlib_frame_format_type_e_NDIlib_frame_format_type_field_0 as _,
382    /// Field 1 only (second field of interlaced content).
383    Field1 = NDIlib_frame_format_type_e_NDIlib_frame_format_type_field_1 as _,
384}
385
386impl From<ScanType> for i32 {
387    fn from(value: ScanType) -> Self {
388        let u32_value: u32 = value.into();
389        u32_value as i32
390    }
391}
392
393/// First-plane line stride for the supported uncompressed video formats.
394/// Compressed and opaque payloads are not represented by safe frame types.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum LineStrideOrSize {
397    /// Line stride in bytes for uncompressed formats.
398    /// This is the number of bytes per row of video data.
399    LineStrideBytes(i32),
400}
401
402impl From<LineStrideOrSize> for NDIlib_video_frame_v2_t__bindgen_ty_1 {
403    fn from(value: LineStrideOrSize) -> Self {
404        // Writing to a union field is safe when the field type implements Copy.
405        // We write exactly one field of the union based on the enum variant.
406        match value {
407            LineStrideOrSize::LineStrideBytes(stride) =>
408            {
409                #[allow(clippy::field_reassign_with_default)]
410                NDIlib_video_frame_v2_t__bindgen_ty_1 {
411                    line_stride_in_bytes: stride,
412                }
413            }
414        }
415    }
416}
417
418/// An owned video frame with validated dimensions, format, stride, and buffer size.
419///
420/// Received frames retain their SDK stride and padding. Encoding processes only
421/// active pixels. Use [`Self::builder`] for tightly packed outbound storage.
422pub struct VideoFrame {
423    layout: ValidatedVideoLayout,
424    frame_rate_n: i32,
425    frame_rate_d: i32,
426    picture_aspect_ratio: f32,
427    scan_type: ScanType,
428    timecode: i64,
429    data: Vec<u8>,
430    metadata: Option<FrameMetadata>,
431    timestamp: i64,
432}
433
434impl fmt::Debug for VideoFrame {
435    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
436        f.debug_struct("VideoFrame")
437            .field("width", &self.width())
438            .field("height", &self.height())
439            .field("pixel_format", &self.pixel_format())
440            .field("frame_rate_n", &self.frame_rate_n)
441            .field("frame_rate_d", &self.frame_rate_d)
442            .field("picture_aspect_ratio", &self.picture_aspect_ratio)
443            .field("scan_type", &self.scan_type)
444            .field("timecode", &self.timecode)
445            .field("data (bytes)", &self.data.len())
446            .field("line_stride_or_size", &self.line_stride_or_size())
447            .field("metadata", &self.metadata())
448            .field("timestamp", &self.timestamp)
449            .finish()
450    }
451}
452
453impl Default for VideoFrame {
454    fn default() -> Self {
455        VideoFrame::builder()
456            .build()
457            .expect("Default VideoFrame should always succeed")
458    }
459}
460
461impl VideoFrame {
462    pub(crate) fn to_raw(&self) -> NDIlib_video_frame_v2_t {
463        NDIlib_video_frame_v2_t {
464            xres: self.layout.width,
465            yres: self.layout.height,
466            FourCC: self.layout.pixel_format.into(),
467            frame_rate_N: self.frame_rate_n,
468            frame_rate_D: self.frame_rate_d,
469            picture_aspect_ratio: self.picture_aspect_ratio,
470            frame_format_type: self.scan_type.into(),
471            timecode: self.timecode,
472            p_data: self.data.as_ptr() as *mut u8,
473            __bindgen_anon_1: self.layout.line_stride_or_size.into(),
474            p_metadata: self
475                .metadata
476                .as_ref()
477                .map_or(ptr::null(), FrameMetadata::as_ptr),
478            timestamp: self.timestamp,
479        }
480    }
481
482    /// Get the frame width in pixels.
483    pub fn width(&self) -> i32 {
484        self.layout.width
485    }
486
487    /// Get the frame height in pixels.
488    pub fn height(&self) -> i32 {
489        self.layout.height
490    }
491
492    /// Get the supported pixel format.
493    pub fn pixel_format(&self) -> PixelFormat {
494        self.layout.pixel_format
495    }
496
497    /// Get the frame rate numerator.
498    pub fn frame_rate_n(&self) -> i32 {
499        self.frame_rate_n
500    }
501
502    /// Get the frame rate denominator.
503    pub fn frame_rate_d(&self) -> i32 {
504        self.frame_rate_d
505    }
506
507    /// Get the picture aspect ratio.
508    pub fn picture_aspect_ratio(&self) -> f32 {
509        self.picture_aspect_ratio
510    }
511
512    /// Get the scan type.
513    pub fn scan_type(&self) -> ScanType {
514        self.scan_type
515    }
516
517    /// Get the timecode in 100 ns units.
518    ///
519    /// Defaults to the SDK's synthesized-timecode sentinel. An explicit zero
520    /// is passed through as a literal timecode.
521    pub fn timecode(&self) -> i64 {
522        self.timecode
523    }
524
525    /// Set the frame timecode in 100 ns units without reallocating its pixels.
526    /// Use the SDK synthesized-timecode sentinel to request SDK clocking.
527    pub fn set_timecode(&mut self, timecode: i64) {
528        self.timecode = timecode;
529    }
530
531    /// Set the scan type without changing the frame's validated layout.
532    pub fn set_scan_type(&mut self, scan_type: ScanType) {
533        self.scan_type = scan_type;
534    }
535
536    /// Set the timestamp in 100 ns units. The SDK fills receive timestamps;
537    /// outgoing timestamp values are passed through unchanged.
538    pub fn set_timestamp(&mut self, timestamp: i64) {
539        self.timestamp = timestamp;
540    }
541
542    /// Get the receive timestamp in 100 ns units since the Unix epoch.
543    ///
544    /// On receive the SDK supplies this value, or its undefined-timestamp
545    /// sentinel when unavailable. Outbound values are passed through unchanged;
546    /// the SDK defines this field for receiving.
547    pub fn timestamp(&self) -> i64 {
548        self.timestamp
549    }
550
551    /// Get the validated line stride or data size union field.
552    pub fn line_stride_or_size(&self) -> LineStrideOrSize {
553        self.layout.line_stride_or_size
554    }
555
556    pub(crate) fn validated_layout(&self) -> ValidatedVideoLayout {
557        self.layout
558    }
559
560    /// Get the frame data.
561    pub fn data(&self) -> &[u8] {
562        &self.data
563    }
564
565    /// Get mutable access to the frame data without changing the validated
566    /// layout.
567    pub fn data_mut(&mut self) -> &mut [u8] {
568        &mut self.data
569    }
570
571    /// Replace the owned frame data while preserving the validated layout.
572    ///
573    /// # Errors
574    ///
575    /// Returns [`Error::InvalidFrame`] if `data` is not exactly the validated
576    /// layout size.
577    pub fn replace_data(&mut self, data: Vec<u8>) -> Result<()> {
578        if data.len() != self.layout.data_len_bytes {
579            return Err(Error::InvalidFrame(format!(
580                "Video data length {}, expected {} bytes for validated layout",
581                data.len(),
582                self.layout.data_len_bytes
583            )));
584        }
585
586        self.data = data;
587        Ok(())
588    }
589
590    /// Get frame metadata as UTF-8 text, if present.
591    pub fn metadata(&self) -> Option<&str> {
592        self.metadata.as_ref().map(FrameMetadata::as_str)
593    }
594
595    pub(crate) fn metadata_cstr(&self) -> Option<&CStr> {
596        self.metadata.as_ref().map(FrameMetadata::as_cstr)
597    }
598
599    pub(crate) fn validated_metadata(&self) -> ValidatedFrameMetadata {
600        ValidatedFrameMetadata {
601            len_with_nul: self
602                .metadata_cstr()
603                .and_then(|m| NonZeroUsize::new(m.to_bytes_with_nul().len())),
604            text_len: self.metadata().map_or(0, str::len),
605        }
606    }
607
608    /// Replace frame metadata.
609    ///
610    /// # Errors
611    ///
612    /// Returns [`Error::InvalidCString`] if `metadata` contains an interior NUL
613    /// byte, or [`Error::InvalidFrame`] if the emitted C string would exceed
614    /// the metadata size cap.
615    pub fn set_metadata<S: Into<String>>(&mut self, metadata: Option<S>) -> Result<()> {
616        self.metadata = metadata.map(FrameMetadata::new).transpose()?;
617        Ok(())
618    }
619
620    /// Set the frame rate.
621    ///
622    /// # Errors
623    ///
624    /// Returns [`Error::InvalidFrame`] if the numerator or denominator is not
625    /// positive.
626    pub fn set_frame_rate(&mut self, numerator: i32, denominator: i32) -> Result<()> {
627        validate_video_frame_metadata(numerator, denominator, self.picture_aspect_ratio)?;
628        self.frame_rate_n = numerator;
629        self.frame_rate_d = denominator;
630        Ok(())
631    }
632
633    /// Set the picture aspect ratio.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`Error::InvalidFrame`] if `ratio` is not finite and positive.
638    pub fn set_picture_aspect_ratio(&mut self, ratio: f32) -> Result<()> {
639        validate_video_frame_metadata(self.frame_rate_n, self.frame_rate_d, ratio)?;
640        self.picture_aspect_ratio = ratio;
641        Ok(())
642    }
643
644    /// Encode the video frame as PNG bytes.
645    ///
646    /// This method encodes the frame to PNG format, automatically handling color format
647    /// conversion from the NDI frame format (BGRA/RGBA/etc.) to PNG-compatible RGBA.
648    ///
649    /// # Supported Formats
650    ///
651    /// - `RGBA`: Direct active-row encoding without a pixel copy
652    /// - `BGRA`: Swaps red and blue channels and preserves alpha
653    /// - `RGBX`: Treats the fourth byte as padding and writes opaque alpha
654    /// - `BGRX`: Swaps red/blue and writes opaque alpha
655    /// - Other formats: Returns an error (unsupported for now)
656    ///
657    /// # Stride Handling
658    ///
659    /// This method consumes active pixels row-by-row according to the frame's
660    /// validated line stride. Valid row padding is skipped. RGBA rows borrow
661    /// directly; other supported formats reuse at most `width * 4` scratch bytes.
662    /// Codec buffers and encoded output require additional memory.
663    ///
664    /// # Errors
665    ///
666    /// Returns an error if:
667    /// - The frame format is not RGBA/RGBX/BGRA/BGRX
668    /// - The frame uses data-size layout instead of line-stride layout
669    /// - The backing data length does not match the validated layout
670    /// - PNG encoding fails
671    ///
672    /// # Example
673    ///
674    /// ```no_run
675    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat};
676    /// # use std::time::Duration;
677    /// # fn main() -> Result<(), grafton_ndi::Error> {
678    /// # let ndi = NDI::new()?;
679    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
680    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
681    /// # let sources = finder.current_sources()?;
682    /// # let Some(source) = sources.first() else { return Ok(()); };
683    /// # let options = ReceiverOptions::builder(source.clone())
684    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
685    /// #     .build();
686    /// # let receiver = Receiver::new(&ndi, &options)?;
687    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
688    /// let png_bytes = video_frame.encode_png()?;
689    /// std::fs::write("frame.png", &png_bytes)?;
690    /// # Ok(())
691    /// # }
692    /// ```
693    #[cfg(feature = "image-encoding")]
694    pub fn encode_png(&self) -> Result<Vec<u8>> {
695        ImagePixelSource::new(self.layout, &self.data)?.encode_png()
696    }
697
698    /// Encode the video frame as JPEG bytes with the specified quality.
699    ///
700    /// This method encodes the frame to JPEG format, automatically handling color format
701    /// conversion from the NDI frame format into codec component rows without
702    /// a full-frame RGB staging buffer. Codec/output storage is additional.
703    ///
704    /// # Arguments
705    ///
706    /// * `quality` - JPEG quality from 1 (lowest) to 100 (highest). Typical values are 80-95.
707    ///
708    /// # Supported Formats
709    ///
710    /// - `RGBA` / `RGBX`: Emits RGB
711    /// - `BGRA` / `BGRX`: Swaps red/blue and emits RGB
712    /// - Other formats: Returns an error (unsupported for now)
713    ///
714    /// # Errors
715    ///
716    /// Returns an error if:
717    /// - The frame format is not RGBA/RGBX/BGRA/BGRX
718    /// - The frame uses data-size layout instead of line-stride layout
719    /// - The backing data length does not match the validated layout
720    /// - The quality is outside `1..=100`
721    /// - The dimensions exceed JPEG's `u16` width/height range
722    /// - JPEG encoding fails
723    ///
724    /// # Example
725    ///
726    /// ```no_run
727    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat};
728    /// # use std::time::Duration;
729    /// # fn main() -> Result<(), grafton_ndi::Error> {
730    /// # let ndi = NDI::new()?;
731    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
732    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
733    /// # let sources = finder.current_sources()?;
734    /// # let Some(source) = sources.first() else { return Ok(()); };
735    /// # let options = ReceiverOptions::builder(source.clone())
736    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
737    /// #     .build();
738    /// # let receiver = Receiver::new(&ndi, &options)?;
739    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
740    /// let jpeg_bytes = video_frame.encode_jpeg(85)?;
741    /// std::fs::write("frame.jpg", &jpeg_bytes)?;
742    /// # Ok(())
743    /// # }
744    /// ```
745    #[cfg(feature = "image-encoding")]
746    pub fn encode_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
747        ImagePixelSource::new(self.layout, &self.data)?.encode_jpeg(quality)
748    }
749
750    /// Encode the video frame as a base64 data URL for embedding in HTML/JSON.
751    ///
752    /// This produces a string in the format: `data:image/png;base64,...` or
753    /// `data:image/jpeg;base64,...` that can be directly used in HTML `<img>` tags
754    /// or stored in JSON.
755    ///
756    /// # Arguments
757    ///
758    /// * `format` - The image format to use (PNG or JPEG with quality)
759    ///
760    /// # Example
761    ///
762    /// ```no_run
763    /// # use grafton_ndi::{NDI, Finder, FinderOptions, ReceiverOptions, Receiver, ReceiverColorFormat, ImageFormat};
764    /// # use std::time::Duration;
765    /// # fn main() -> Result<(), grafton_ndi::Error> {
766    /// # let ndi = NDI::new()?;
767    /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
768    /// # finder.wait_for_sources(Duration::from_millis(1000))?;
769    /// # let sources = finder.current_sources()?;
770    /// # let Some(source) = sources.first() else { return Ok(()); };
771    /// # let options = ReceiverOptions::builder(source.clone())
772    /// #     .color(ReceiverColorFormat::RGBX_RGBA)
773    /// #     .build();
774    /// # let receiver = Receiver::new(&ndi, &options)?;
775    /// let video_frame = receiver.video().capture(Duration::from_secs(5))?;
776    ///
777    /// // As PNG
778    /// let data_url = video_frame.encode_data_url(ImageFormat::Png)?;
779    /// println!("<img src=\"{}\">", data_url);
780    ///
781    /// // As JPEG with quality 90
782    /// let data_url = video_frame.encode_data_url(ImageFormat::Jpeg(90))?;
783    /// # Ok(())
784    /// # }
785    /// ```
786    #[cfg(feature = "image-encoding")]
787    pub fn encode_data_url(&self, format: ImageFormat) -> Result<String> {
788        ImagePixelSource::new(self.layout, &self.data)?.encode_data_url(format)
789    }
790
791    /// Creates a `VideoFrame` from a raw NDI video frame with owned data.
792    ///
793    /// # Safety
794    ///
795    /// The descriptor must refer to one live allocation covering its SDK plane
796    /// layout. All active row bytes must be initialized and remain immutable
797    /// throughout this call; row padding may be uninitialized. Non-null metadata
798    /// must satisfy the SDK's readable, NUL-terminated string contract.
799    /// Only active pixels are copied, so the tightly packed result can outlive
800    /// the source allocation.
801    pub unsafe fn from_raw(c_frame: &NDIlib_video_frame_v2_t) -> Result<VideoFrame> {
802        // Use the shared validation helper to validate and compute layout
803        let layout = validate_video_layout(c_frame)?;
804        let metadata_layout = unsafe { validate_frame_metadata(c_frame.p_metadata)? };
805
806        Self::from_raw_validated(c_frame, layout, metadata_layout)
807    }
808
809    pub(crate) unsafe fn from_raw_validated(
810        c_frame: &NDIlib_video_frame_v2_t,
811        layout: ValidatedVideoLayout,
812        metadata_layout: ValidatedFrameMetadata,
813    ) -> Result<VideoFrame> {
814        let owned_layout = ValidatedVideoLayout::new_uncompressed(
815            layout.pixel_format,
816            layout.width,
817            layout.height,
818            None,
819        )?;
820        let mut data = Vec::with_capacity(layout.payload_len_bytes);
821        // SAFETY: The caller retains the source allocation through this copy.
822        // Only active rows are initialized by the SDK; never read stride padding.
823        if layout.is_contiguous() {
824            data.extend_from_slice(unsafe {
825                slice::from_raw_parts(c_frame.p_data, layout.payload_len_bytes)
826            });
827        } else {
828            for plane_layout in layout.planes() {
829                let plane = unsafe { VideoPlane::new(c_frame.p_data, *plane_layout) };
830                for row in plane.rows() {
831                    data.extend_from_slice(row);
832                }
833            }
834        }
835        let layout = owned_layout;
836
837        let metadata =
838            unsafe { FrameMetadata::copy_from_raw_validated(c_frame.p_metadata, metadata_layout) };
839
840        #[allow(clippy::unnecessary_cast)] // Required for Windows where frame_format_type is i32
841        let scan_type = ScanType::try_from(c_frame.frame_format_type as u32).map_err(|_| {
842            Error::InvalidFrame(format!(
843                "Unknown scan type: 0x{:08X}",
844                c_frame.frame_format_type
845            ))
846        })?;
847
848        Ok(VideoFrame {
849            layout,
850            frame_rate_n: c_frame.frame_rate_N,
851            frame_rate_d: c_frame.frame_rate_D,
852            picture_aspect_ratio: c_frame.picture_aspect_ratio,
853            scan_type,
854            timecode: c_frame.timecode,
855            data,
856            metadata,
857            timestamp: c_frame.timestamp,
858        })
859    }
860
861    /// Create a builder for configuring a video frame
862    pub fn builder() -> VideoFrameBuilder {
863        VideoFrameBuilder::new()
864    }
865}
866
867/// Builder for configuring a VideoFrame with ergonomic method chaining
868#[derive(Debug, Clone)]
869pub struct VideoFrameBuilder {
870    width: Option<i32>,
871    height: Option<i32>,
872    pixel_format: Option<PixelFormat>,
873    frame_rate_n: Option<i32>,
874    frame_rate_d: Option<i32>,
875    picture_aspect_ratio: Option<f32>,
876    scan_type: Option<ScanType>,
877    timecode: Option<i64>,
878    metadata: Option<String>,
879    timestamp: Option<i64>,
880    data: Option<Vec<u8>>,
881}
882
883impl VideoFrameBuilder {
884    /// Create a new builder with no fields set
885    pub fn new() -> Self {
886        Self {
887            width: None,
888            height: None,
889            pixel_format: None,
890            frame_rate_n: None,
891            frame_rate_d: None,
892            picture_aspect_ratio: None,
893            scan_type: None,
894            timecode: None,
895            metadata: None,
896            timestamp: None,
897            data: None,
898        }
899    }
900
901    /// Set the video resolution. Width must be positive and even; height must
902    /// be positive (and even for 4:2:0 formats).
903    #[must_use]
904    pub fn resolution(mut self, width: i32, height: i32) -> Self {
905        self.width = Some(width);
906        self.height = Some(height);
907        self
908    }
909
910    /// Set the pixel format
911    #[must_use]
912    pub fn pixel_format(mut self, pixel_format: PixelFormat) -> Self {
913        self.pixel_format = Some(pixel_format);
914        self
915    }
916
917    /// Set the frame rate as a fraction (e.g., 30000/1001 for 29.97fps)
918    #[must_use]
919    pub fn frame_rate(mut self, numerator: i32, denominator: i32) -> Self {
920        self.frame_rate_n = Some(numerator);
921        self.frame_rate_d = Some(denominator);
922        self
923    }
924
925    /// Set the picture aspect ratio
926    #[must_use]
927    pub fn aspect_ratio(mut self, ratio: f32) -> Self {
928        self.picture_aspect_ratio = Some(ratio);
929        self
930    }
931
932    /// Set the scan type (progressive, interlaced, etc.)
933    #[must_use]
934    pub fn scan_type(mut self, scan_type: ScanType) -> Self {
935        self.scan_type = Some(scan_type);
936        self
937    }
938
939    /// Set the timecode in 100 ns units (or the SDK synthesis sentinel).
940    #[must_use]
941    pub fn timecode(mut self, tc: i64) -> Self {
942        self.timecode = Some(tc);
943        self
944    }
945
946    /// Set metadata
947    #[must_use]
948    pub fn metadata<S: Into<String>>(mut self, meta: S) -> Self {
949        self.metadata = Some(meta.into());
950        self
951    }
952
953    /// Set the timestamp field in 100 ns units. This field is filled by the SDK on receive.
954    #[must_use]
955    pub fn timestamp(mut self, ts: i64) -> Self {
956        self.timestamp = Some(ts);
957        self
958    }
959
960    /// Supply owned pixels without an intermediate zero-filled allocation.
961    ///
962    /// `build` requires exactly the validated layout size and retains this
963    /// allocation unchanged. Use `data_mut` on the resulting frame for reuse.
964    #[must_use]
965    pub fn data(mut self, data: Vec<u8>) -> Self {
966        self.data = Some(data);
967        self
968    }
969
970    /// Build the video frame.
971    ///
972    /// Defaults to 1920x1080 BGRA, 60/1 fps, 16:9 picture aspect ratio,
973    /// progressive scan, synthesized timecode, and zero-filled pixels.
974    pub fn build(self) -> Result<VideoFrame> {
975        let width = self.width.unwrap_or(1920);
976        let height = self.height.unwrap_or(1080);
977        let pixel_format = self.pixel_format.unwrap_or(PixelFormat::BGRA);
978        let frame_rate_n = self.frame_rate_n.unwrap_or(60);
979        let frame_rate_d = self.frame_rate_d.unwrap_or(1);
980        let picture_aspect_ratio = self.picture_aspect_ratio.unwrap_or(16.0 / 9.0);
981        let scan_type = self.scan_type.unwrap_or(ScanType::Progressive);
982
983        validate_video_frame_metadata(frame_rate_n, frame_rate_d, picture_aspect_ratio)?;
984        let layout = ValidatedVideoLayout::new_uncompressed(pixel_format, width, height, None)?;
985        let metadata = self.metadata.map(FrameMetadata::new).transpose()?;
986        let data = match self.data {
987            Some(data) => {
988                if data.len() != layout.data_len_bytes {
989                    return Err(Error::InvalidFrame(format!(
990                        "Video data length {}, expected {} bytes for validated layout",
991                        data.len(),
992                        layout.data_len_bytes
993                    )));
994                }
995                data
996            }
997            None => vec![0u8; layout.data_len_bytes],
998        };
999
1000        Ok(VideoFrame {
1001            layout,
1002            frame_rate_n,
1003            frame_rate_d,
1004            picture_aspect_ratio,
1005            scan_type,
1006            timecode: self.timecode.unwrap_or(NDIlib_send_timecode_synthesize),
1007            data,
1008            metadata,
1009            timestamp: self.timestamp.unwrap_or(0),
1010        })
1011    }
1012}
1013
1014impl Default for VideoFrameBuilder {
1015    fn default() -> Self {
1016        Self::new()
1017    }
1018}
1019
1020impl Drop for VideoFrame {
1021    fn drop(&mut self) {
1022        // With owned data, we don't need to free SDK pointers anymore
1023        // The Vec<u8> handles memory cleanup automatically
1024    }
1025}
1026
1027/// Owned, tightly packed planar floating-point audio with a validated layout.
1028///
1029/// Channel samples are contiguous, with channels stored one after another.
1030/// Conversion from borrowed SDK audio omits inter-channel padding.
1031#[derive(Debug)]
1032pub struct AudioFrame {
1033    layout: ValidatedAudioLayout,
1034    timecode: i64,
1035    data: Vec<f32>,
1036    metadata: Option<FrameMetadata>,
1037    timestamp: i64,
1038}
1039
1040impl AudioFrame {
1041    pub(crate) fn to_raw(&self) -> NDIlib_audio_frame_v3_t {
1042        NDIlib_audio_frame_v3_t {
1043            sample_rate: self.layout.sample_rate,
1044            no_channels: self.num_channels(),
1045            no_samples: self.num_samples(),
1046            timecode: self.timecode,
1047            FourCC: self.format().into(),
1048            p_data: self.data.as_ptr() as *mut f32 as *mut u8,
1049            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
1050                channel_stride_in_bytes: self.layout.channel_stride_in_bytes,
1051            },
1052            p_metadata: self
1053                .metadata
1054                .as_ref()
1055                .map_or(ptr::null(), FrameMetadata::as_ptr),
1056            timestamp: self.timestamp,
1057        }
1058    }
1059
1060    #[cfg(test)]
1061    /// # Safety
1062    /// `raw` must reference live SDK sample and metadata allocations with
1063    /// initialized active samples through this ownership copy.
1064    pub(crate) unsafe fn from_raw(raw: NDIlib_audio_frame_v3_t) -> Result<AudioFrame> {
1065        // Use the shared validation helper to validate and compute layout
1066        let layout = validate_audio_layout(&raw)?;
1067        let metadata_layout = unsafe { validate_frame_metadata(raw.p_metadata)? };
1068
1069        unsafe { Self::from_raw_validated(raw, layout, metadata_layout) }
1070    }
1071
1072    /// # Safety
1073    /// `layout` must have been validated from this exact `raw` descriptor.
1074    /// Its active samples must remain live, aligned, and initialized through
1075    /// copying; `metadata_layout` must describe its live metadata pointer.
1076    pub(crate) unsafe fn from_raw_validated(
1077        raw: NDIlib_audio_frame_v3_t,
1078        layout: ValidatedAudioLayout,
1079        metadata_layout: ValidatedFrameMetadata,
1080    ) -> Result<AudioFrame> {
1081        if layout.is_empty() {
1082            return Err(Error::InvalidFrame(
1083                "Cannot create owned AudioFrame from an empty audio layout".into(),
1084            ));
1085        }
1086
1087        // Only channel samples are initialized audio. Never form a typed slice
1088        // over inter-channel padding supplied by the SDK.
1089        let packed_layout = validate_outbound_audio_layout(
1090            layout.sample_rate,
1091            layout.no_channels as i32,
1092            layout.no_samples as i32,
1093            layout.format().expect("non-empty audio has a format"),
1094        )?;
1095        let mut data = Vec::with_capacity(packed_layout.sample_count);
1096        for channel in 0..layout.no_channels {
1097            let range = layout.channel_range(channel).expect("validated channel");
1098            // SAFETY: the SDK owns an aligned FLTP buffer with no_samples
1099            // initialized floats at each validated channel offset.
1100            let samples = unsafe {
1101                slice::from_raw_parts(raw.p_data.cast::<f32>().add(range.start), layout.no_samples)
1102            };
1103            data.extend_from_slice(samples);
1104        }
1105
1106        // Copy the string, don't take ownership - SDK will free the original.
1107        let metadata =
1108            unsafe { FrameMetadata::copy_from_raw_validated(raw.p_metadata, metadata_layout) };
1109
1110        Ok(AudioFrame {
1111            layout: packed_layout,
1112            timecode: raw.timecode,
1113            data,
1114            metadata,
1115            timestamp: raw.timestamp,
1116        })
1117    }
1118
1119    /// Create a builder for configuring an audio frame
1120    pub fn builder() -> AudioFrameBuilder {
1121        AudioFrameBuilder::new()
1122    }
1123
1124    /// Get the sample rate in Hz.
1125    pub fn sample_rate(&self) -> i32 {
1126        self.layout.sample_rate
1127    }
1128
1129    /// Get the number of audio channels.
1130    pub fn num_channels(&self) -> i32 {
1131        self.layout.no_channels as i32
1132    }
1133
1134    /// Get the number of samples per channel.
1135    pub fn num_samples(&self) -> i32 {
1136        self.layout.no_samples as i32
1137    }
1138
1139    /// Get the timecode in 100 ns units.
1140    ///
1141    /// Defaults to the SDK's synthesized-timecode sentinel. An explicit zero
1142    /// is passed through as a literal timecode.
1143    pub fn timecode(&self) -> i64 {
1144        self.timecode
1145    }
1146
1147    /// Get the receive timestamp in 100 ns units since the Unix epoch.
1148    ///
1149    /// On receive the SDK supplies this value, or its undefined-timestamp
1150    /// sentinel when unavailable. Outbound values are passed through unchanged;
1151    /// the SDK defines this field for receiving.
1152    pub fn timestamp(&self) -> i64 {
1153        self.timestamp
1154    }
1155
1156    /// Get the audio format.
1157    pub fn format(&self) -> AudioFormat {
1158        self.layout
1159            .format()
1160            .expect("owned AudioFrame always has a concrete audio format")
1161    }
1162
1163    /// Get the channel stride in bytes.
1164    pub fn channel_stride_in_bytes(&self) -> i32 {
1165        self.layout.channel_stride_in_bytes
1166    }
1167
1168    /// Get tightly packed planar audio as 32-bit floats.
1169    /// Each channel's samples are contiguous; channels follow one another.
1170    pub fn data(&self) -> &[f32] {
1171        &self.data
1172    }
1173
1174    /// Get mutable audio sample data without changing the validated layout.
1175    pub fn data_mut(&mut self) -> &mut [f32] {
1176        &mut self.data
1177    }
1178
1179    /// Replace the owned audio data while preserving the validated layout.
1180    ///
1181    /// # Errors
1182    ///
1183    /// Returns [`Error::InvalidFrame`] if `data` is not exactly the validated
1184    /// sample count.
1185    pub fn replace_data(&mut self, data: Vec<f32>) -> Result<()> {
1186        if data.len() != self.layout.sample_count {
1187            return Err(Error::InvalidFrame(format!(
1188                "Audio data length {}, expected {} samples for validated layout",
1189                data.len(),
1190                self.layout.sample_count
1191            )));
1192        }
1193
1194        self.data = data;
1195        Ok(())
1196    }
1197
1198    /// Get frame metadata as UTF-8 text, if present.
1199    pub fn metadata(&self) -> Option<&str> {
1200        self.metadata.as_ref().map(FrameMetadata::as_str)
1201    }
1202
1203    /// Replace frame metadata.
1204    ///
1205    /// # Errors
1206    ///
1207    /// Returns [`Error::InvalidCString`] if `metadata` contains an interior NUL
1208    /// byte, or [`Error::InvalidFrame`] if the emitted C string would exceed
1209    /// the metadata size cap.
1210    pub fn set_metadata<S: Into<String>>(&mut self, metadata: Option<S>) -> Result<()> {
1211        self.metadata = metadata.map(FrameMetadata::new).transpose()?;
1212        Ok(())
1213    }
1214
1215    /// Get audio data for a specific channel
1216    ///
1217    /// Data is always stored in planar format internally. If `AudioLayout::Interleaved`
1218    /// was specified at build time, the data was converted to planar during construction.
1219    pub fn channel_data(&self, channel: usize) -> Option<&[f32]> {
1220        let range = self.layout.channel_range(channel)?;
1221        Some(&self.data[range])
1222    }
1223
1224    /// Get mutable samples for one channel without allocating.
1225    pub fn channel_data_mut(&mut self, channel: usize) -> Option<&mut [f32]> {
1226        let range = self.layout.channel_range(channel)?;
1227        Some(&mut self.data[range])
1228    }
1229}
1230
1231/// Builder for configuring an AudioFrame with ergonomic method chaining
1232#[derive(Debug, Clone)]
1233pub struct AudioFrameBuilder {
1234    sample_rate: Option<i32>,
1235    num_channels: Option<i32>,
1236    num_samples: Option<i32>,
1237    timecode: Option<i64>,
1238    format: Option<AudioFormat>,
1239    data: Option<Vec<f32>>,
1240    layout: Option<AudioLayout>,
1241    metadata: Option<String>,
1242    timestamp: Option<i64>,
1243}
1244
1245impl AudioFrameBuilder {
1246    /// Create a new builder with no fields set
1247    pub fn new() -> Self {
1248        Self {
1249            sample_rate: None,
1250            num_channels: None,
1251            num_samples: None,
1252            timecode: None,
1253            format: None,
1254            data: None,
1255            layout: None,
1256            metadata: None,
1257            timestamp: None,
1258        }
1259    }
1260
1261    /// Set the sample rate
1262    #[must_use]
1263    pub fn sample_rate(mut self, rate: i32) -> Self {
1264        self.sample_rate = Some(rate);
1265        self
1266    }
1267
1268    /// Set the number of audio channels
1269    #[must_use]
1270    pub fn channels(mut self, channels: i32) -> Self {
1271        self.num_channels = Some(channels);
1272        self
1273    }
1274
1275    /// Set the number of samples
1276    #[must_use]
1277    pub fn samples(mut self, samples: i32) -> Self {
1278        self.num_samples = Some(samples);
1279        self
1280    }
1281
1282    /// Set the timecode in 100 ns units (or the SDK synthesis sentinel).
1283    #[must_use]
1284    pub fn timecode(mut self, tc: i64) -> Self {
1285        self.timecode = Some(tc);
1286        self
1287    }
1288
1289    /// Set the audio format
1290    #[must_use]
1291    pub fn format(mut self, format: AudioFormat) -> Self {
1292        self.format = Some(format);
1293        self
1294    }
1295
1296    /// Set the audio data layout (planar or interleaved)
1297    ///
1298    /// - **Planar**: All samples for channel 0, then all for channel 1, etc.
1299    /// - **Interleaved**: Samples from all channels are interleaved.
1300    ///
1301    /// Defaults to `AudioLayout::Planar` which is the native format for FLTP.
1302    ///
1303    /// # Example
1304    /// ```
1305    /// use grafton_ndi::{AudioFrame, AudioLayout};
1306    ///
1307    /// // Planar layout (default)
1308    /// let frame = AudioFrame::builder()
1309    ///     .channels(2)
1310    ///     .samples(100)
1311    ///     .layout(AudioLayout::Planar)
1312    ///     .build()
1313    ///     .unwrap();
1314    /// ```
1315    #[must_use]
1316    pub fn layout(mut self, layout: AudioLayout) -> Self {
1317        self.layout = Some(layout);
1318        self
1319    }
1320
1321    /// Set the audio data as 32-bit floats
1322    ///
1323    /// The data length must equal `num_channels * num_samples`. The layout must match
1324    /// the configured `AudioLayout`:
1325    /// - **Planar**: `[C0S0, C0S1, ..., C1S0, C1S1, ...]`
1326    /// - **Interleaved**: `[C0S0, C1S0, C0S1, C1S1, ...]` (converted to planar at build time)
1327    #[must_use]
1328    pub fn data(mut self, data: Vec<f32>) -> Self {
1329        self.data = Some(data);
1330        self
1331    }
1332
1333    /// Set metadata
1334    #[must_use]
1335    pub fn metadata<S: Into<String>>(mut self, meta: S) -> Self {
1336        self.metadata = Some(meta.into());
1337        self
1338    }
1339
1340    /// Set the timestamp field in 100 ns units. This field is filled by the SDK on receive.
1341    #[must_use]
1342    pub fn timestamp(mut self, ts: i64) -> Self {
1343        self.timestamp = Some(ts);
1344        self
1345    }
1346
1347    /// Build the audio frame.
1348    ///
1349    /// Defaults to 48 kHz stereo FLTP, 1024 samples per channel, planar input,
1350    /// synthesized timecode, and silent samples.
1351    ///
1352    /// When `AudioLayout::Interleaved` is set, the input data is converted to planar
1353    /// format in Rust. The resulting frame always stores
1354    /// planar data with `channel_stride_in_bytes = num_samples * 4`.
1355    pub fn build(self) -> Result<AudioFrame> {
1356        let sample_rate = self.sample_rate.unwrap_or(48000);
1357        let num_channels = self.num_channels.unwrap_or(2);
1358        let num_samples = self.num_samples.unwrap_or(1024);
1359        let format = self.format.unwrap_or(AudioFormat::FLTP);
1360        let layout = self.layout.unwrap_or(AudioLayout::Planar);
1361        let timecode = self.timecode.unwrap_or(NDIlib_send_timecode_synthesize);
1362        let audio_layout =
1363            validate_outbound_audio_layout(sample_rate, num_channels, num_samples, format)?;
1364        let sample_count = audio_layout.sample_count;
1365
1366        let data = if let Some(input_data) = self.data {
1367            if input_data.len() != sample_count {
1368                return Err(Error::InvalidFrame(format!(
1369                    "Audio data length {}, expected {} ({}ch x {}samples)",
1370                    input_data.len(),
1371                    sample_count,
1372                    num_channels,
1373                    num_samples
1374                )));
1375            }
1376
1377            match layout {
1378                AudioLayout::Planar => input_data,
1379                AudioLayout::Interleaved => {
1380                    let nc = audio_layout.no_channels;
1381                    let ns = audio_layout.no_samples;
1382                    let mut planar = vec![0.0f32; sample_count];
1383                    for ch in 0..nc {
1384                        for s in 0..ns {
1385                            let dst = ch
1386                                .checked_mul(ns)
1387                                .and_then(|idx| idx.checked_add(s))
1388                                .ok_or_else(|| {
1389                                    Error::InvalidFrame(
1390                                        "Audio planar conversion index overflow".into(),
1391                                    )
1392                                })?;
1393                            let src = s
1394                                .checked_mul(nc)
1395                                .and_then(|idx| idx.checked_add(ch))
1396                                .ok_or_else(|| {
1397                                    Error::InvalidFrame(
1398                                        "Audio interleaved conversion index overflow".into(),
1399                                    )
1400                                })?;
1401                            planar[dst] = input_data[src];
1402                        }
1403                    }
1404                    planar
1405                }
1406            }
1407        } else {
1408            vec![0.0f32; sample_count]
1409        };
1410
1411        let metadata = self.metadata.map(FrameMetadata::new).transpose()?;
1412
1413        Ok(AudioFrame {
1414            layout: audio_layout,
1415            timecode,
1416            data,
1417            metadata,
1418            timestamp: self.timestamp.unwrap_or(0),
1419        })
1420    }
1421}
1422
1423impl Default for AudioFrameBuilder {
1424    fn default() -> Self {
1425        Self::new()
1426    }
1427}
1428
1429impl Default for AudioFrame {
1430    fn default() -> Self {
1431        AudioFrame::builder()
1432            .build()
1433            .expect("Default AudioFrame should always succeed")
1434    }
1435}
1436
1437impl Drop for AudioFrame {
1438    fn drop(&mut self) {
1439        // With owned data, we don't need to free SDK pointers anymore
1440        // The Vec<f32> handles memory cleanup automatically
1441    }
1442}
1443
1444/// Audio format identifiers (FourCC codes).
1445///
1446/// Currently NDI primarily uses `FLTP` (32-bit floating point planar format).
1447///
1448/// This enum is marked `#[non_exhaustive]` to allow future NDI SDK versions to add new audio formats
1449/// without breaking existing code. Always use a wildcard pattern when matching.
1450///
1451/// # Examples
1452///
1453/// ```
1454/// use grafton_ndi::AudioFormat;
1455///
1456/// let format = AudioFormat::FLTP;
1457///
1458/// // When matching, always include a wildcard for forward compatibility
1459/// match format {
1460///     AudioFormat::FLTP => println!("32-bit float planar"),
1461///     _ => println!("Other format"),
1462/// }
1463/// ```
1464#[derive(Debug, TryFromPrimitive, IntoPrimitive, Clone, Copy, PartialEq, Eq)]
1465#[non_exhaustive]
1466#[repr(u32)]
1467pub enum AudioFormat {
1468    /// 32-bit floating point planar audio (FLTP).
1469    FLTP = NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP as _,
1470}
1471
1472/// Audio data layout format
1473///
1474/// Determines how multi-channel audio samples are arranged in memory.
1475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1476pub enum AudioLayout {
1477    /// Planar format: All samples for channel 0, then all for channel 1, etc.
1478    ///
1479    /// Memory layout for 2 channels, 3 samples:
1480    /// `[C0S0, C0S1, C0S2, C1S0, C1S1, C1S2]`
1481    ///
1482    /// This is the native format for FLTP and is efficient for per-channel processing.
1483    Planar,
1484
1485    /// Interleaved format: Samples from all channels are interleaved.
1486    ///
1487    /// Input memory layout for 2 channels, 3 samples:
1488    /// `[C0S0, C1S0, C0S1, C1S1, C0S2, C1S2]`
1489    ///
1490    /// Interleaved data is converted to planar format in Rust at build time,
1491    /// so the resulting `AudioFrame` always stores planar data.
1492    Interleaved,
1493}
1494
1495impl From<AudioFormat> for i32 {
1496    fn from(value: AudioFormat) -> Self {
1497        let u32_value: u32 = value.into();
1498        u32_value as i32
1499    }
1500}
1501
1502/// Maximum allowed size for supported video frame data (256 MiB).
1503const MAX_VIDEO_BYTES: usize = 256 * 1024 * 1024;
1504
1505/// Maximum allowed size for audio frame data (64 MiB).
1506/// Comfortably above typical NDI audio frames while preventing unbounded allocations.
1507const MAX_AUDIO_BYTES: usize = 64 * 1024 * 1024;
1508
1509/// Maximum allowed size for SDK metadata C strings (4 MiB), including the
1510/// trailing NUL terminator.
1511pub(crate) const MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
1512
1513/// Owned UTF-8 metadata text and its timecode.
1514///
1515/// NDI expects XML when sending; callers are responsible for XML syntax. Text
1516/// cannot contain NUL bytes and is limited to 4 MiB including the SDK terminator.
1517#[derive(Debug, Clone)]
1518pub struct MetadataFrame {
1519    data: FrameMetadata,
1520    timecode: i64,
1521}
1522
1523impl MetadataFrame {
1524    /// Create an empty metadata frame with default SDK timecode behavior.
1525    pub fn new() -> Self {
1526        MetadataFrame {
1527            data: FrameMetadata::new("").expect("empty metadata is valid"),
1528            timecode: NDIlib_send_timecode_synthesize,
1529        }
1530    }
1531
1532    /// Create a metadata frame from UTF-8 text and a timecode.
1533    ///
1534    /// # Errors
1535    ///
1536    /// Returns [`Error::InvalidCString`] if `data` contains an interior NUL byte
1537    /// or [`Error::InvalidFrame`] if the SDK metadata length would exceed the
1538    /// crate's metadata size limit.
1539    pub fn with_data(data: impl Into<String>, timecode: i64) -> Result<Self> {
1540        let data = FrameMetadata::new(data)?;
1541        Ok(MetadataFrame { data, timecode })
1542    }
1543
1544    /// Get the metadata text.
1545    pub fn data(&self) -> &str {
1546        self.data.as_str()
1547    }
1548
1549    /// Consume the frame and return the owned metadata text.
1550    pub fn into_data(self) -> String {
1551        self.data.into_string()
1552    }
1553
1554    /// Get the timecode in 100 ns units.
1555    ///
1556    /// Defaults to the SDK's synthesized-timecode sentinel. An explicit zero
1557    /// is passed through as a literal timecode.
1558    pub fn timecode(&self) -> i64 {
1559        self.timecode
1560    }
1561
1562    /// Replace the metadata text.
1563    ///
1564    /// # Errors
1565    ///
1566    /// Returns [`Error::InvalidCString`] if `data` contains an interior NUL byte
1567    /// or [`Error::InvalidFrame`] if the SDK metadata length would exceed the
1568    /// crate's metadata size limit.
1569    pub fn set_data(&mut self, data: impl Into<String>) -> Result<()> {
1570        self.data = FrameMetadata::new(data)?;
1571        Ok(())
1572    }
1573
1574    /// Set the timecode.
1575    pub fn set_timecode(&mut self, timecode: i64) {
1576        self.timecode = timecode;
1577    }
1578
1579    /// Return this frame with an updated timecode.
1580    pub fn with_timecode(mut self, timecode: i64) -> Self {
1581        self.timecode = timecode;
1582        self
1583    }
1584
1585    /// Borrow the retained C string for a synchronous SDK call. The caller
1586    /// must keep this frame alive until the SDK call returns.
1587    pub(crate) fn to_raw(&self) -> NDIlib_metadata_frame_t {
1588        NDIlib_metadata_frame_t {
1589            length: self.data.inner.as_bytes_with_nul().len() as i32,
1590            timecode: self.timecode,
1591            p_data: self.data.as_ptr() as *mut c_char,
1592        }
1593    }
1594
1595    /// Create from raw NDI metadata frame (copies the data)
1596    ///
1597    /// # Safety
1598    ///
1599    /// `raw` must be an SDK-populated metadata frame whose `p_data` pointer is
1600    /// valid for `raw.length` bytes when `length > 0`. For zero length with a
1601    /// non-null pointer, it must be readable through its first NUL terminator
1602    /// or the crate's metadata cap, as required by `validate_frame_metadata`.
1603    #[cfg(test)]
1604    pub(crate) unsafe fn from_raw(raw: &NDIlib_metadata_frame_t) -> Result<Self> {
1605        let layout = unsafe { validate_metadata_layout(raw)? };
1606        Ok(unsafe { Self::from_raw_validated(raw, layout) })
1607    }
1608
1609    /// Create from a raw NDI metadata frame after its layout has been validated.
1610    ///
1611    /// # Safety
1612    ///
1613    /// `layout` must have been produced by `validate_metadata_layout(raw)` while
1614    /// the same `raw.p_data` allocation is still valid.
1615    pub(crate) unsafe fn from_raw_validated(
1616        raw: &NDIlib_metadata_frame_t,
1617        layout: ValidatedMetadataLayout,
1618    ) -> Self {
1619        let data = if layout.len_with_nul == 0 {
1620            FrameMetadata::new("").expect("empty metadata is valid")
1621        } else {
1622            unsafe {
1623                FrameMetadata::copy_from_raw_validated(
1624                    raw.p_data,
1625                    ValidatedFrameMetadata {
1626                        len_with_nul: std::num::NonZeroUsize::new(layout.len_with_nul),
1627                        text_len: layout.text_len,
1628                    },
1629                )
1630                .expect("non-null metadata")
1631            }
1632        };
1633
1634        Self {
1635            data,
1636            timecode: raw.timecode,
1637        }
1638    }
1639}
1640
1641impl Default for MetadataFrame {
1642    fn default() -> Self {
1643        Self::new()
1644    }
1645}
1646
1647/// Validated standalone metadata frame layout information.
1648///
1649/// The SDK `length` field includes the trailing NUL terminator. A
1650/// `len_with_nul` of zero represents the accepted empty null frame
1651/// (`length == 0 && p_data == NULL`) and never requires reading from `p_data`.
1652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1653pub(crate) struct ValidatedMetadataLayout {
1654    /// Total SDK metadata length including trailing NUL, or zero for the valid
1655    /// empty null frame.
1656    pub len_with_nul: usize,
1657    /// Public UTF-8 text payload length excluding the trailing NUL.
1658    pub text_len: usize,
1659}
1660
1661/// Validate standalone metadata frame layout from raw FFI fields.
1662///
1663/// This validates only memory layout and UTF-8. XML well-formedness is
1664/// intentionally not checked because NDI receivers are expected to tolerate
1665/// badly formed XML metadata.
1666/// # Safety
1667/// `raw.p_data`, when non-null, must reference readable storage for the
1668/// declared length or a NUL-terminated string within the metadata cap.
1669pub(crate) unsafe fn validate_metadata_layout(
1670    raw: &NDIlib_metadata_frame_t,
1671) -> Result<ValidatedMetadataLayout> {
1672    if raw.length < 0 {
1673        return Err(Error::InvalidFrame(format!(
1674            "Metadata frame has negative length: {}",
1675            raw.length
1676        )));
1677    }
1678
1679    if raw.length == 0 {
1680        if raw.p_data.is_null() {
1681            return Ok(ValidatedMetadataLayout {
1682                len_with_nul: 0,
1683                text_len: 0,
1684            });
1685        }
1686
1687        // The SDK permits zero length as a C-string sentinel. Apply the same
1688        // bounded scan and SDK pointer-validity contract as per-frame metadata.
1689        let metadata = unsafe { validate_frame_metadata(raw.p_data)? };
1690        return Ok(ValidatedMetadataLayout {
1691            len_with_nul: metadata.len_with_nul.expect("non-null metadata").get(),
1692            text_len: metadata.text_len,
1693        });
1694    }
1695
1696    if raw.p_data.is_null() {
1697        return Err(Error::InvalidFrame(
1698            "Metadata frame has non-zero length with null data pointer".into(),
1699        ));
1700    }
1701
1702    let len_with_nul = usize::try_from(raw.length).map_err(|_| {
1703        Error::InvalidFrame(format!("Invalid metadata length value: {}", raw.length))
1704    })?;
1705    validate_metadata_len_with_nul(len_with_nul)?;
1706
1707    let bytes = unsafe { slice::from_raw_parts(raw.p_data.cast::<u8>(), len_with_nul) };
1708    if bytes[len_with_nul - 1] != 0 {
1709        return Err(Error::InvalidFrame(
1710            "Metadata frame length does not include a trailing NUL terminator".into(),
1711        ));
1712    }
1713
1714    let payload = &bytes[..len_with_nul - 1];
1715    if payload.contains(&0) {
1716        return Err(Error::InvalidFrame(
1717            "Metadata frame contains an interior NUL byte".into(),
1718        ));
1719    }
1720
1721    str::from_utf8(payload).map_err(|err| Error::InvalidUtf8(err.to_string()))?;
1722
1723    Ok(ValidatedMetadataLayout {
1724        len_with_nul,
1725        text_len: payload.len(),
1726    })
1727}
1728
1729fn validate_metadata_len_with_nul(len_with_nul: usize) -> Result<i32> {
1730    if len_with_nul == 0 {
1731        return Err(Error::InvalidFrame(
1732            "Metadata length must include a trailing NUL terminator".into(),
1733        ));
1734    }
1735
1736    if len_with_nul > MAX_METADATA_BYTES {
1737        return Err(Error::InvalidFrame(format!(
1738            "Metadata exceeds maximum size: {} bytes > {} bytes",
1739            len_with_nul, MAX_METADATA_BYTES
1740        )));
1741    }
1742
1743    metadata_len_to_i32(len_with_nul)
1744}
1745
1746fn metadata_len_to_i32(len_with_nul: usize) -> Result<i32> {
1747    i32::try_from(len_with_nul).map_err(|_| {
1748        Error::InvalidFrame(format!(
1749            "Metadata length {len_with_nul} exceeds SDK i32 range"
1750        ))
1751    })
1752}
1753
1754/// # Safety
1755/// `layout` must describe this exact live `raw.p_data` allocation, which must
1756/// remain immutable for the returned borrow.
1757unsafe fn metadata_payload_bytes(
1758    raw: &NDIlib_metadata_frame_t,
1759    layout: ValidatedMetadataLayout,
1760) -> &[u8] {
1761    if layout.text_len == 0 {
1762        &[]
1763    } else {
1764        // SAFETY: `validate_metadata_layout` checked that `p_data` is non-null
1765        // for non-empty payloads and that `text_len` is bounded by `length`.
1766        unsafe { slice::from_raw_parts(raw.p_data.cast::<u8>(), layout.text_len) }
1767    }
1768}
1769
1770/// Owned video/audio per-frame metadata.
1771///
1772/// The wrapped C string is guaranteed to be valid UTF-8, contain no interior
1773/// NUL bytes, and fit within the crate metadata size cap including its trailing
1774/// terminator. `None` on a frame means a null SDK `p_metadata`; `Some("")`
1775/// means an explicit non-null empty C string.
1776#[derive(Clone, PartialEq, Eq)]
1777pub(crate) struct FrameMetadata {
1778    inner: CString,
1779}
1780
1781impl FrameMetadata {
1782    pub(crate) fn new<S: Into<String>>(metadata: S) -> Result<Self> {
1783        let metadata = metadata.into();
1784        let len_with_nul = metadata.len().checked_add(1).ok_or_else(|| {
1785            Error::InvalidFrame("Frame metadata length overflow while adding terminator".into())
1786        })?;
1787        validate_metadata_len_with_nul(len_with_nul)?;
1788
1789        Ok(Self {
1790            inner: CString::new(metadata).map_err(Error::InvalidCString)?,
1791        })
1792    }
1793
1794    pub(crate) fn as_str(&self) -> &str {
1795        // SAFETY: construction accepts String and validated SDK UTF-8 only.
1796        unsafe { str::from_utf8_unchecked(self.inner.as_bytes()) }
1797    }
1798
1799    pub(crate) fn into_string(self) -> String {
1800        self.inner
1801            .into_string()
1802            .expect("FrameMetadata validates UTF-8 at construction")
1803    }
1804
1805    pub(crate) fn as_cstr(&self) -> &CStr {
1806        self.inner.as_c_str()
1807    }
1808
1809    pub(crate) fn as_ptr(&self) -> *const c_char {
1810        self.inner.as_ptr()
1811    }
1812
1813    /// Copy per-frame metadata from a pointer that has already been validated
1814    /// with [`validate_frame_metadata`].
1815    ///
1816    /// # Safety
1817    ///
1818    /// `metadata_layout` must have been produced for this exact `p_metadata`
1819    /// pointer while the pointed-to SDK allocation is still valid.
1820    pub(crate) unsafe fn copy_from_raw_validated(
1821        p_metadata: *const c_char,
1822        metadata_layout: ValidatedFrameMetadata,
1823    ) -> Option<Self> {
1824        metadata_layout.len_with_nul?;
1825        debug_assert!(!p_metadata.is_null());
1826
1827        let mut bytes = Vec::with_capacity(metadata_layout.text_len + 1);
1828        if metadata_layout.text_len != 0 {
1829            bytes.extend_from_slice(unsafe {
1830                slice::from_raw_parts(p_metadata.cast::<u8>(), metadata_layout.text_len)
1831            });
1832        }
1833        bytes.push(0);
1834
1835        let inner = unsafe { CString::from_vec_with_nul_unchecked(bytes) };
1836        Some(Self { inner })
1837    }
1838}
1839
1840impl fmt::Debug for FrameMetadata {
1841    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1842        f.debug_tuple("FrameMetadata")
1843            .field(&self.as_str())
1844            .finish()
1845    }
1846}
1847
1848/// Cached layout for video/audio per-frame `p_metadata`.
1849///
1850/// The SDK exposes these pointers as optional lengthless C strings. This type
1851/// records the first terminator found by a bounded scan so later accessors can
1852/// expose UTF-8 text without rescanning.
1853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1854pub(crate) struct ValidatedFrameMetadata {
1855    /// `None` means the SDK pointer was null. `Some` includes the first trailing
1856    /// NUL terminator found within `MAX_METADATA_BYTES`.
1857    pub(crate) len_with_nul: Option<NonZeroUsize>,
1858    /// UTF-8 text payload length excluding the trailing NUL terminator.
1859    pub(crate) text_len: usize,
1860}
1861
1862impl ValidatedFrameMetadata {
1863    /// Bounded application CStr validation uses the known slice length, never a raw scan.
1864    pub(crate) fn from_cstr(metadata: Option<&CStr>) -> Result<Self> {
1865        let Some(metadata) = metadata else {
1866            return Ok(Self {
1867                len_with_nul: None,
1868                text_len: 0,
1869            });
1870        };
1871        let len = metadata.to_bytes_with_nul().len();
1872        validate_metadata_len_with_nul(len)?;
1873        metadata
1874            .to_str()
1875            .map_err(|e| Error::InvalidUtf8(e.to_string()))?;
1876        Ok(Self {
1877            len_with_nul: NonZeroUsize::new(len),
1878            text_len: len - 1,
1879        })
1880    }
1881}
1882
1883/// Validate a video/audio per-frame `p_metadata` pointer with a bounded scan.
1884///
1885/// This continues to trust the NDI SDK that a non-null pointer is readable. The
1886/// validation does not make dangling or otherwise invalid pointers safe; it
1887/// only limits the scan to `MAX_METADATA_BYTES`, requires a terminator within
1888/// that cap, and validates UTF-8 before exposing safe text access.
1889///
1890/// # Safety
1891///
1892/// When non-null, `p_metadata` must be readable byte-by-byte until either the
1893/// first NUL terminator or `MAX_METADATA_BYTES` bytes have been read.
1894pub(crate) unsafe fn validate_frame_metadata(
1895    p_metadata: *const c_char,
1896) -> Result<ValidatedFrameMetadata> {
1897    if p_metadata.is_null() {
1898        return Ok(ValidatedFrameMetadata {
1899            len_with_nul: None,
1900            text_len: 0,
1901        });
1902    }
1903
1904    let metadata = p_metadata.cast::<u8>();
1905    for text_len in 0..MAX_METADATA_BYTES {
1906        let byte = unsafe { metadata.add(text_len).read() };
1907        if byte == 0 {
1908            if text_len > 0 {
1909                let payload = unsafe { slice::from_raw_parts(metadata, text_len) };
1910                str::from_utf8(payload).map_err(|err| Error::InvalidUtf8(err.to_string()))?;
1911            }
1912
1913            return Ok(ValidatedFrameMetadata {
1914                len_with_nul: NonZeroUsize::new(text_len + 1),
1915                text_len,
1916            });
1917        }
1918    }
1919
1920    Err(Error::InvalidFrame(format!(
1921        "Frame metadata is missing a NUL terminator within {MAX_METADATA_BYTES} bytes"
1922    )))
1923}
1924
1925/// Expose validated per-frame metadata as borrowed UTF-8 text.
1926///
1927/// # Safety
1928///
1929/// `metadata_layout` must have been produced for this exact `p_metadata`
1930/// pointer while the pointed-to SDK allocation is still valid.
1931pub(crate) unsafe fn frame_metadata_str<'a>(
1932    p_metadata: *const c_char,
1933    metadata_layout: ValidatedFrameMetadata,
1934) -> Option<&'a str> {
1935    metadata_layout.len_with_nul?;
1936    debug_assert!(!p_metadata.is_null());
1937
1938    if metadata_layout.text_len == 0 {
1939        return Some("");
1940    }
1941
1942    let bytes = unsafe { slice::from_raw_parts(p_metadata.cast::<u8>(), metadata_layout.text_len) };
1943    Some(unsafe { str::from_utf8_unchecked(bytes) })
1944}
1945
1946/// Image format specification for encoding video frames.
1947///
1948/// Used with [`VideoFrame::encode_data_url`] to specify the desired output format.
1949///
1950/// # Examples
1951///
1952/// ```
1953/// use grafton_ndi::ImageFormat;
1954///
1955/// // PNG format (lossless)
1956/// let png = ImageFormat::Png;
1957///
1958/// // JPEG with quality 85 (lossy, smaller file size)
1959/// let jpeg = ImageFormat::Jpeg(85);
1960/// ```
1961///
1962/// This enum is marked `#[non_exhaustive]` so that additional encoders can be
1963/// added without a breaking change. Match arms should include a wildcard `_`
1964/// pattern.
1965#[cfg(feature = "image-encoding")]
1966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1967#[non_exhaustive]
1968pub enum ImageFormat {
1969    /// PNG format (lossless compression)
1970    Png,
1971    /// JPEG format with quality setting (1-100, where 100 is highest quality)
1972    Jpeg(u8),
1973}
1974
1975#[cfg(feature = "image-encoding")]
1976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1977enum ImageChannelOrder {
1978    Rgba,
1979    Bgra,
1980}
1981
1982#[cfg(feature = "image-encoding")]
1983impl ImageChannelOrder {
1984    fn red_index(self) -> usize {
1985        match self {
1986            Self::Rgba => 0,
1987            Self::Bgra => 2,
1988        }
1989    }
1990
1991    fn blue_index(self) -> usize {
1992        match self {
1993            Self::Rgba => 2,
1994            Self::Bgra => 0,
1995        }
1996    }
1997}
1998
1999#[cfg(feature = "image-encoding")]
2000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2001enum ImageAlphaPolicy {
2002    Preserve,
2003    Opaque,
2004}
2005
2006#[cfg(feature = "image-encoding")]
2007#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2008struct ImagePixelFormat {
2009    channel_order: ImageChannelOrder,
2010    alpha_policy: ImageAlphaPolicy,
2011}
2012
2013#[cfg(feature = "image-encoding")]
2014impl ImagePixelFormat {
2015    const BYTES_PER_PIXEL: usize = 4;
2016
2017    fn from_pixel_format(pixel_format: PixelFormat) -> Result<Self> {
2018        match pixel_format {
2019            PixelFormat::RGBA => Ok(Self {
2020                channel_order: ImageChannelOrder::Rgba,
2021                alpha_policy: ImageAlphaPolicy::Preserve,
2022            }),
2023            PixelFormat::BGRA => Ok(Self {
2024                channel_order: ImageChannelOrder::Bgra,
2025                alpha_policy: ImageAlphaPolicy::Preserve,
2026            }),
2027            PixelFormat::RGBX => Ok(Self {
2028                channel_order: ImageChannelOrder::Rgba,
2029                alpha_policy: ImageAlphaPolicy::Opaque,
2030            }),
2031            PixelFormat::BGRX => Ok(Self {
2032                channel_order: ImageChannelOrder::Bgra,
2033                alpha_policy: ImageAlphaPolicy::Opaque,
2034            }),
2035            _ => Err(Error::InvalidFrame(format!(
2036                "Unsupported format for image encoding: {pixel_format:?}. Only RGBA/RGBX/BGRA/BGRX are supported."
2037            ))),
2038        }
2039    }
2040
2041    fn can_borrow_png_rows(self) -> bool {
2042        self.channel_order == ImageChannelOrder::Rgba
2043            && self.alpha_policy == ImageAlphaPolicy::Preserve
2044    }
2045
2046    fn rgba_pixel(self, pixel: &[u8]) -> [u8; 4] {
2047        [
2048            pixel[self.channel_order.red_index()],
2049            pixel[1],
2050            pixel[self.channel_order.blue_index()],
2051            match self.alpha_policy {
2052                ImageAlphaPolicy::Preserve => pixel[3],
2053                ImageAlphaPolicy::Opaque => 255,
2054            },
2055        ]
2056    }
2057
2058    fn rgb_pixel(self, pixel: &[u8]) -> [u8; 3] {
2059        [
2060            pixel[self.channel_order.red_index()],
2061            pixel[1],
2062            pixel[self.channel_order.blue_index()],
2063        ]
2064    }
2065}
2066
2067#[cfg(feature = "image-encoding")]
2068#[derive(Debug)]
2069struct ImagePixelSource<'a> {
2070    plane: VideoPlane<'a>,
2071    width: usize,
2072    height: usize,
2073    pixel_format: ImagePixelFormat,
2074}
2075
2076#[cfg(feature = "image-encoding")]
2077impl<'a> ImagePixelSource<'a> {
2078    fn encode_png(&self) -> Result<Vec<u8>> {
2079        use png::{BitDepth, ColorType, Encoder};
2080
2081        use std::io::Write;
2082
2083        let width = u32::try_from(self.width).map_err(|_| {
2084            Error::InvalidFrame(format!("PNG width {} exceeds u32 range", self.width))
2085        })?;
2086        let height = u32::try_from(self.height).map_err(|_| {
2087            Error::InvalidFrame(format!("PNG height {} exceeds u32 range", self.height))
2088        })?;
2089        let mut conversion = self.png_conversion_row()?;
2090
2091        // Encode to PNG
2092        let mut png_data = Vec::new();
2093        let mut encoder = Encoder::new(&mut png_data, width, height);
2094        encoder.set_color(ColorType::Rgba);
2095        encoder.set_depth(BitDepth::Eight);
2096
2097        let png_error = |e| Error::InvalidFrame(format!("PNG encoding failed: {e}"));
2098        let mut writer = encoder.write_header().map_err(png_error)?;
2099        {
2100            let mut stream = writer.stream_writer().map_err(png_error)?;
2101            for row in self.plane.rows() {
2102                let rgba = if self.pixel_format.can_borrow_png_rows() {
2103                    row
2104                } else {
2105                    for (out, pixel) in conversion
2106                        .as_chunks_mut::<4>()
2107                        .0
2108                        .iter_mut()
2109                        .zip(row.as_chunks::<{ ImagePixelFormat::BYTES_PER_PIXEL }>().0)
2110                    {
2111                        *out = self.pixel_format.rgba_pixel(pixel);
2112                    }
2113                    &conversion
2114                };
2115                stream
2116                    .write_all(rgba)
2117                    .map_err(|e| Error::InvalidFrame(format!("PNG encoding failed: {e}")))?;
2118            }
2119            stream.finish().map_err(png_error)?;
2120        }
2121        writer.finish().map_err(png_error)?;
2122
2123        Ok(png_data)
2124    }
2125
2126    fn encode_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
2127        use jpeg_encoder::Encoder as JpegEncoder;
2128
2129        let (width, height) = self.jpeg_dimensions(quality)?;
2130
2131        // Encode to JPEG
2132        let mut jpeg_data = Vec::new();
2133        let encoder = JpegEncoder::new(&mut jpeg_data, quality);
2134        encoder
2135            .encode_image(JpegRows {
2136                source: self,
2137                width,
2138                height,
2139            })
2140            .map_err(|e| Error::InvalidFrame(format!("JPEG encoding failed: {e}")))?;
2141
2142        Ok(jpeg_data)
2143    }
2144
2145    fn encode_data_url(&self, format: ImageFormat) -> Result<String> {
2146        use base64::{engine::general_purpose::STANDARD, Engine};
2147
2148        let (mime_type, image_bytes) = match format {
2149            ImageFormat::Png => ("image/png", self.encode_png()?),
2150            ImageFormat::Jpeg(quality) => ("image/jpeg", self.encode_jpeg(quality)?),
2151        };
2152
2153        let base64_data = STANDARD.encode(&image_bytes);
2154        Ok(format!("data:{mime_type};base64,{base64_data}"))
2155    }
2156
2157    fn new(layout: ValidatedVideoLayout, data: &'a [u8]) -> Result<Self> {
2158        if data.len() != layout.data_len_bytes {
2159            return Err(Error::InvalidFrame(format!(
2160                "Video data length {}, expected {} bytes for validated layout",
2161                data.len(),
2162                layout.data_len_bytes
2163            )));
2164        }
2165
2166        // SAFETY: The owned slice covers the validated storage extent and
2167        // remains borrowed for 'a. Active rows are a subset of that slice.
2168        let plane = unsafe { VideoPlane::new(data.as_ptr(), layout.plane_layouts[0]) };
2169        Self::from_plane(layout, plane)
2170    }
2171
2172    fn from_plane(layout: ValidatedVideoLayout, plane: VideoPlane<'a>) -> Result<Self> {
2173        let pixel_format = ImagePixelFormat::from_pixel_format(layout.pixel_format)?;
2174        Ok(Self {
2175            plane,
2176            width: layout.width as usize,
2177            height: layout.height as usize,
2178            pixel_format,
2179        })
2180    }
2181
2182    fn png_conversion_row(&self) -> Result<Vec<u8>> {
2183        if self.pixel_format.can_borrow_png_rows() {
2184            return Ok(Vec::new());
2185        }
2186        let len = self.width.checked_mul(4).ok_or_else(|| {
2187            Error::InvalidFrame(format!("PNG row size overflow: {}x4", self.width))
2188        })?;
2189        Ok(vec![0; len])
2190    }
2191
2192    fn jpeg_dimensions(&self, quality: u8) -> Result<(u16, u16)> {
2193        if !(1..=100).contains(&quality) {
2194            return Err(Error::InvalidFrame(format!(
2195                "JPEG quality must be in 1..=100, got {quality}"
2196            )));
2197        }
2198
2199        let width = u16::try_from(self.width).map_err(|_| {
2200            Error::InvalidFrame(format!(
2201                "JPEG width {} exceeds maximum supported value {}",
2202                self.width,
2203                u16::MAX
2204            ))
2205        })?;
2206        let height = u16::try_from(self.height).map_err(|_| {
2207            Error::InvalidFrame(format!(
2208                "JPEG height {} exceeds maximum supported value {}",
2209                self.height,
2210                u16::MAX
2211            ))
2212        })?;
2213
2214        Ok((width, height))
2215    }
2216}
2217
2218/// Reads validated active rows directly into the codec's component buffers.
2219/// No wrapper RGB staging allocation, including for padded captured frames.
2220#[cfg(feature = "image-encoding")]
2221struct JpegRows<'s, 'a> {
2222    source: &'s ImagePixelSource<'a>,
2223    width: u16,
2224    height: u16,
2225}
2226
2227#[cfg(feature = "image-encoding")]
2228impl jpeg_encoder::ImageBuffer for JpegRows<'_, '_> {
2229    fn get_jpeg_color_type(&self) -> jpeg_encoder::JpegColorType {
2230        jpeg_encoder::JpegColorType::Ycbcr
2231    }
2232
2233    fn width(&self) -> u16 {
2234        self.width
2235    }
2236    fn height(&self) -> u16 {
2237        self.height
2238    }
2239
2240    fn fill_buffers(&self, row: u16, buffers: &mut [Vec<u8>; 4]) {
2241        let row = self
2242            .source
2243            .plane
2244            .row(usize::from(row))
2245            .expect("codec row is in bounds");
2246        // Like the codec's slice adapter, batch 16 pixels so LLVM can vectorize
2247        // conversion and append components without per-pixel Vec growth checks.
2248        for chunk in row.as_chunks::<64>().0 {
2249            let mut components = [[0; 16]; 3];
2250            for (i, pixel) in chunk.as_chunks::<4>().0.iter().enumerate() {
2251                let [r, g, b] = self.source.pixel_format.rgb_pixel(pixel);
2252                let (y, cb, cr) = jpeg_encoder::rgb_to_ycbcr(r, g, b);
2253                components[0][i] = y;
2254                components[1][i] = cb;
2255                components[2][i] = cr;
2256            }
2257            for (buffer, values) in buffers.iter_mut().zip(components) {
2258                buffer.extend_from_slice(&values);
2259            }
2260        }
2261        for pixel in row.as_chunks::<64>().1.as_chunks::<4>().0 {
2262            let [r, g, b] = self.source.pixel_format.rgb_pixel(pixel);
2263            let (y, cb, cr) = jpeg_encoder::rgb_to_ycbcr(r, g, b);
2264            buffers[0].push(y);
2265            buffers[1].push(cb);
2266            buffers[2].push(cr);
2267        }
2268    }
2269}
2270
2271// ============================================================================
2272// Frame layout validation helpers
2273// ============================================================================
2274
2275/// Validated video frame layout information.
2276///
2277/// This struct holds pre-validated layout information for a video frame,
2278/// including the computed buffer length and stride/size information.
2279/// Creating this struct performs all necessary bounds checking, so
2280/// consumers can safely use the cached values without re-validation.
2281#[derive(Debug, Clone, Copy)]
2282pub(crate) struct ValidatedVideoLayout {
2283    /// The validated width in pixels.
2284    pub width: i32,
2285    /// The validated height in pixels.
2286    pub height: i32,
2287    /// The validated pixel format.
2288    pub pixel_format: PixelFormat,
2289    /// Complete storage extent in bytes; SDK padding need not be initialized.
2290    pub data_len_bytes: usize,
2291    /// Initialized active payload, excluding all row padding.
2292    payload_len_bytes: usize,
2293    plane_layouts: [VideoPlaneLayout; 3],
2294    plane_count: usize,
2295    /// The validated first-plane SDK line stride.
2296    pub line_stride_or_size: LineStrideOrSize,
2297}
2298
2299impl ValidatedVideoLayout {
2300    pub(crate) fn new_uncompressed(
2301        pixel_format: PixelFormat,
2302        width: i32,
2303        height: i32,
2304        line_stride: Option<i32>,
2305    ) -> Result<Self> {
2306        validate_video_dimensions_for_format(pixel_format, width, height)?;
2307
2308        let width_usize = usize::try_from(width)
2309            .map_err(|_| Error::InvalidFrame(format!("Invalid width value: {width}")))?;
2310        let height_usize = usize::try_from(height)
2311            .map_err(|_| Error::InvalidFrame(format!("Invalid height value: {height}")))?;
2312
2313        let min_stride = min_video_line_stride_checked(pixel_format, width_usize)?;
2314        let line_stride_usize = match line_stride {
2315            Some(0) => min_stride,
2316            Some(stride) => {
2317                if stride <= 0 {
2318                    return Err(Error::InvalidFrame(format!(
2319                        "Uncompressed video frame has invalid line_stride_in_bytes: {stride}"
2320                    )));
2321                }
2322
2323                let stride_usize = usize::try_from(stride).map_err(|_| {
2324                    Error::InvalidFrame(format!("Invalid line_stride_in_bytes value: {stride}"))
2325                })?;
2326
2327                if stride_usize < min_stride {
2328                    return Err(Error::InvalidFrame(format!(
2329                        "Video line_stride_in_bytes {stride} is smaller than minimum row size {min_stride} for {pixel_format:?} width {width}"
2330                    )));
2331                }
2332
2333                stride_usize
2334            }
2335            None => min_stride,
2336        };
2337
2338        if pixel_format.info().is_planar_420() && !line_stride_usize.is_multiple_of(2) {
2339            return Err(Error::InvalidFrame(format!(
2340                "Planar 4:2:0 video frame has odd line_stride_in_bytes: {line_stride_usize}"
2341            )));
2342        }
2343
2344        let (plane_layouts, plane_count, data_len_bytes, payload_len_bytes) = video_plane_layouts(
2345            pixel_format.info(),
2346            line_stride_usize,
2347            min_stride,
2348            height_usize,
2349        )?;
2350        validate_video_data_len(data_len_bytes)?;
2351
2352        let line_stride_i32 = i32::try_from(line_stride_usize).map_err(|_| {
2353            Error::InvalidFrame(format!(
2354                "Video line stride {line_stride_usize} exceeds i32 range"
2355            ))
2356        })?;
2357
2358        Ok(Self {
2359            width,
2360            height,
2361            pixel_format,
2362            data_len_bytes,
2363            payload_len_bytes,
2364            plane_layouts,
2365            plane_count,
2366            line_stride_or_size: LineStrideOrSize::LineStrideBytes(line_stride_i32),
2367        })
2368    }
2369
2370    fn planes(&self) -> &[VideoPlaneLayout] {
2371        &self.plane_layouts[..self.plane_count]
2372    }
2373
2374    fn is_contiguous(&self) -> bool {
2375        // Validated planes are adjacent storage regions with active <= stride.
2376        // Their last active endpoint equals payload length exactly when there
2377        // are no intervening gaps. Trailing padding need not be included.
2378        let last = self.planes().last().expect("validated video has a plane");
2379        last.offset + (last.row_count - 1) * last.row_stride + last.active_row_bytes
2380            == self.payload_len_bytes
2381    }
2382}
2383
2384/// Validate video frame layout from raw FFI fields.
2385///
2386/// This function performs all necessary validation including:
2387/// - Null pointer check for `p_data`
2388/// - Valid pixel format (FourCC) conversion
2389/// - Checked arithmetic for buffer size calculation
2390/// - `MAX_VIDEO_BYTES` cap enforcement
2391///
2392/// # Arguments
2393///
2394/// * `raw` - Reference to the raw NDI video frame
2395///
2396/// # Returns
2397///
2398/// `Ok(ValidatedVideoLayout)` if the frame is valid, or `Err(Error::InvalidFrame(...))` otherwise.
2399pub(crate) fn validate_video_layout(raw: &NDIlib_video_frame_v2_t) -> Result<ValidatedVideoLayout> {
2400    if raw.p_data.is_null() {
2401        return Err(Error::InvalidFrame(
2402            "Video frame has null data pointer".into(),
2403        ));
2404    }
2405
2406    validate_video_frame_metadata(raw.frame_rate_N, raw.frame_rate_D, raw.picture_aspect_ratio)?;
2407
2408    #[allow(clippy::unnecessary_cast)]
2409    ScanType::try_from(raw.frame_format_type as u32).map_err(|_| {
2410        Error::InvalidFrame(format!(
2411            "Unknown scan type: 0x{:08X}",
2412            raw.frame_format_type
2413        ))
2414    })?;
2415
2416    #[allow(clippy::unnecessary_cast)] // Required for Windows where FourCC is i32
2417    let pixel_format = PixelFormat::try_from(raw.FourCC as u32).map_err(|_| {
2418        Error::InvalidFrame(format!("Unknown pixel format FourCC: 0x{:08X}", raw.FourCC))
2419    })?;
2420
2421    let line_stride = unsafe { raw.__bindgen_anon_1.line_stride_in_bytes };
2422    ValidatedVideoLayout::new_uncompressed(pixel_format, raw.xres, raw.yres, Some(line_stride))
2423}
2424
2425fn validate_video_dimensions_for_format(
2426    pixel_format: PixelFormat,
2427    width: i32,
2428    height: i32,
2429) -> Result<()> {
2430    validate_video_width(width)?;
2431    if height <= 0 {
2432        return Err(Error::InvalidFrame(format!(
2433            "Video frame has invalid height: {height}"
2434        )));
2435    }
2436
2437    if pixel_format.info().is_planar_420() && height % 2 != 0 {
2438        return Err(Error::InvalidFrame(format!(
2439            "Planar 4:2:0 video frames require even dimensions, got {}x{}",
2440            width, height
2441        )));
2442    }
2443
2444    Ok(())
2445}
2446
2447fn validate_video_width(width: i32) -> Result<()> {
2448    if width <= 0 {
2449        return Err(Error::InvalidFrame(format!(
2450            "Video frame has invalid width: {width}"
2451        )));
2452    }
2453
2454    // The SDK uses 4:2:2 internally, including for RGB input/output.
2455    // https://docs.ndi.video/all/developing-with-ndi/sdk/frame-types
2456    if width % 2 != 0 {
2457        return Err(Error::InvalidFrame(format!(
2458            "NDI video frames require even width, got {width}"
2459        )));
2460    }
2461
2462    Ok(())
2463}
2464
2465pub(crate) fn validate_video_frame_metadata(
2466    frame_rate_n: i32,
2467    frame_rate_d: i32,
2468    picture_aspect_ratio: f32,
2469) -> Result<()> {
2470    if frame_rate_n <= 0 {
2471        return Err(Error::InvalidFrame(format!(
2472            "Video frame has invalid frame rate numerator: {frame_rate_n}"
2473        )));
2474    }
2475    if frame_rate_d <= 0 {
2476        return Err(Error::InvalidFrame(format!(
2477            "Video frame has invalid frame rate denominator: {frame_rate_d}"
2478        )));
2479    }
2480    if !picture_aspect_ratio.is_finite() || picture_aspect_ratio < 0.0 {
2481        return Err(Error::InvalidFrame(format!(
2482            "Video frame has invalid picture aspect ratio: {picture_aspect_ratio}"
2483        )));
2484    }
2485
2486    Ok(())
2487}
2488
2489fn validate_video_data_len(data_len_bytes: usize) -> Result<()> {
2490    if data_len_bytes == 0 {
2491        return Err(Error::InvalidFrame(
2492            "Video frame has zero calculated size".into(),
2493        ));
2494    }
2495
2496    if data_len_bytes > MAX_VIDEO_BYTES {
2497        return Err(Error::InvalidFrame(format!(
2498            "Video frame exceeds maximum size: {} bytes > {} bytes",
2499            data_len_bytes, MAX_VIDEO_BYTES
2500        )));
2501    }
2502
2503    Ok(())
2504}
2505
2506fn min_video_line_stride_checked(pixel_format: PixelFormat, width: usize) -> Result<usize> {
2507    width
2508        .checked_mul(pixel_format.info().bytes_per_pixel() as usize)
2509        .ok_or_else(|| {
2510            Error::InvalidFrame(format!(
2511                "Video line stride overflow for {:?} width {}",
2512                pixel_format, width
2513            ))
2514        })
2515}
2516
2517/// Calculate buffer length with checked arithmetic.
2518#[cfg(test)]
2519fn calculate_buffer_len_checked(
2520    pixel_format: PixelFormat,
2521    y_stride: usize,
2522    height: usize,
2523) -> Result<usize> {
2524    calculate_buffer_len_for_info_checked(pixel_format.info(), y_stride, height)
2525}
2526
2527fn calculate_buffer_len_for_info_checked(
2528    info: PixelFormatInfo,
2529    y_stride: usize,
2530    height: usize,
2531) -> Result<usize> {
2532    video_plane_layouts(info, y_stride, y_stride, height).map(|(_, _, storage, _)| storage)
2533}
2534
2535#[derive(Debug, Clone, Copy, Default)]
2536struct VideoPlaneLayout {
2537    offset: usize,
2538    row_stride: usize,
2539    active_row_bytes: usize,
2540    row_count: usize,
2541}
2542
2543/// Derive SDK plane offsets and active row sizes together. See
2544/// https://docs.ndi.video/all/developing-with-ndi/sdk/frame-types .
2545/// Plane offsets include preceding storage padding; payload lengths do not.
2546fn video_plane_layouts(
2547    info: PixelFormatInfo,
2548    stride: usize,
2549    active: usize,
2550    height: usize,
2551) -> Result<([VideoPlaneLayout; 3], usize, usize, usize)> {
2552    let mut dimensions = [(stride, active, height); 3];
2553    let count = match info.category() {
2554        FormatCategory::Packed => 1,
2555        FormatCategory::Planar420 => {
2556            if !height.is_multiple_of(2) || !stride.is_multiple_of(2) {
2557                return Err(Error::InvalidFrame(
2558                    "Planar 4:2:0 video frames require even height and stride".into(),
2559                ));
2560            }
2561            dimensions[1] = (stride / 2, active / 2, height / 2);
2562            dimensions[2] = dimensions[1];
2563            3
2564        }
2565        FormatCategory::SemiPlanar420 => {
2566            if !height.is_multiple_of(2) {
2567                return Err(Error::InvalidFrame(
2568                    "Semi-planar 4:2:0 video frames require even height".into(),
2569                ));
2570            }
2571            dimensions[1] = (stride, active, height / 2);
2572            2
2573        }
2574        FormatCategory::PackedAlpha => {
2575            if !stride.is_multiple_of(2) {
2576                return Err(Error::InvalidFrame(
2577                    "UYVA video frames require even stride".into(),
2578                ));
2579            }
2580            dimensions[1] = (stride / 2, active / 2, height);
2581            2
2582        }
2583        FormatCategory::SemiPlanar422 => 2,
2584        FormatCategory::SemiPlanar422Alpha => 3,
2585    };
2586    let mut planes = [VideoPlaneLayout::default(); 3];
2587    let mut storage = 0usize;
2588    let mut payload = 0usize;
2589    for (plane, &(row_stride, active_row_bytes, row_count)) in
2590        planes.iter_mut().zip(&dimensions[..count])
2591    {
2592        *plane = VideoPlaneLayout {
2593            offset: storage,
2594            row_stride,
2595            active_row_bytes,
2596            row_count,
2597        };
2598        storage = row_stride
2599            .checked_mul(row_count)
2600            .and_then(|n| storage.checked_add(n))
2601            .ok_or_else(|| Error::InvalidFrame("Video buffer size overflow".into()))?;
2602        payload = active_row_bytes
2603            .checked_mul(row_count)
2604            .and_then(|n| payload.checked_add(n))
2605            .ok_or_else(|| Error::InvalidFrame("Video payload size overflow".into()))?;
2606    }
2607    Ok((planes, count, storage, payload))
2608}
2609
2610/// Validated audio frame layout information.
2611///
2612/// This struct holds pre-validated layout information for an audio frame,
2613/// including the channel stride and computed backing sample count. Creating
2614/// this struct performs all necessary bounds checking, so consumers can safely
2615/// use the cached values without re-validation.
2616#[derive(Debug, Clone, Copy)]
2617pub(crate) struct ValidatedAudioLayout {
2618    /// The validated audio format, or `None` for a query/no-source empty state.
2619    pub format: Option<AudioFormat>,
2620    /// The validated sample rate.
2621    pub sample_rate: i32,
2622    /// The validated channel count.
2623    pub no_channels: usize,
2624    /// The validated samples per channel.
2625    pub no_samples: usize,
2626    /// The validated channel stride in bytes.
2627    pub channel_stride_in_bytes: i32,
2628    /// The validated channel stride in `f32` samples.
2629    pub channel_stride_samples: usize,
2630    /// The validated backing sample count. For strided planar audio this may be
2631    /// larger than `no_channels * no_samples` because it includes inter-channel
2632    /// padding up to the last channel's final sample.
2633    pub sample_count: usize,
2634}
2635
2636impl ValidatedAudioLayout {
2637    pub(crate) fn is_empty(self) -> bool {
2638        self.sample_count == 0
2639    }
2640
2641    pub(crate) fn format(self) -> Option<AudioFormat> {
2642        self.format
2643    }
2644
2645    pub(crate) fn channel_range(self, channel: usize) -> Option<std::ops::Range<usize>> {
2646        if self.is_empty() || channel >= self.no_channels {
2647            return None;
2648        }
2649
2650        let start = channel.checked_mul(self.channel_stride_samples)?;
2651        let end = start.checked_add(self.no_samples)?;
2652
2653        (end <= self.sample_count).then_some(start..end)
2654    }
2655}
2656
2657fn validate_outbound_audio_layout(
2658    sample_rate: i32,
2659    no_channels: i32,
2660    no_samples: i32,
2661    format: AudioFormat,
2662) -> Result<ValidatedAudioLayout> {
2663    if sample_rate <= 0 {
2664        return Err(Error::InvalidFrame(format!(
2665            "Invalid sample rate: {sample_rate}"
2666        )));
2667    }
2668    validate_audio_format(format.into())?;
2669    let packed = validate_packed_audio_dimensions(no_channels, no_samples)?;
2670
2671    Ok(ValidatedAudioLayout {
2672        format: Some(format),
2673        sample_rate,
2674        no_channels: packed.no_channels,
2675        no_samples: packed.no_samples,
2676        channel_stride_in_bytes: packed.channel_stride_in_bytes,
2677        channel_stride_samples: packed.no_samples,
2678        sample_count: packed.sample_count,
2679    })
2680}
2681
2682/// Packed FLTP dimensions shared by owned frames and FrameSync requests.
2683/// Checking this layout allocates nothing on success and never allocates samples.
2684pub(crate) struct PackedAudioDimensions {
2685    no_channels: usize,
2686    no_samples: usize,
2687    channel_stride_in_bytes: i32,
2688    sample_count: usize,
2689}
2690
2691pub(crate) fn validate_packed_audio_dimensions(
2692    no_channels: i32,
2693    no_samples: i32,
2694) -> Result<PackedAudioDimensions> {
2695    if no_channels <= 0 {
2696        return Err(Error::InvalidFrame(format!(
2697            "Invalid number of channels: {no_channels}"
2698        )));
2699    }
2700    if no_samples <= 0 {
2701        return Err(Error::InvalidFrame(format!(
2702            "Invalid number of samples: {no_samples}"
2703        )));
2704    }
2705
2706    let no_channels = usize::try_from(no_channels)
2707        .map_err(|_| Error::InvalidFrame(format!("Invalid no_channels value: {no_channels}")))?;
2708    let no_samples = usize::try_from(no_samples)
2709        .map_err(|_| Error::InvalidFrame(format!("Invalid no_samples value: {no_samples}")))?;
2710
2711    let channel_stride_bytes = no_samples
2712        .checked_mul(std::mem::size_of::<f32>())
2713        .ok_or_else(|| {
2714            Error::InvalidFrame(format!(
2715                "Audio channel stride overflow: {} samples × {} bytes",
2716                no_samples,
2717                std::mem::size_of::<f32>()
2718            ))
2719        })?;
2720    let channel_stride_in_bytes = i32::try_from(channel_stride_bytes).map_err(|_| {
2721        Error::InvalidFrame(format!(
2722            "Audio channel stride {channel_stride_bytes} exceeds i32 range"
2723        ))
2724    })?;
2725
2726    let sample_count = no_channels.checked_mul(no_samples).ok_or_else(|| {
2727        Error::InvalidFrame(format!(
2728            "Audio sample count overflow: {no_channels} channels × {no_samples} samples"
2729        ))
2730    })?;
2731    let byte_size = sample_count
2732        .checked_mul(std::mem::size_of::<f32>())
2733        .ok_or_else(|| {
2734            Error::InvalidFrame(format!(
2735                "Audio byte size overflow: {} samples × {} bytes",
2736                sample_count,
2737                std::mem::size_of::<f32>()
2738            ))
2739        })?;
2740
2741    if byte_size > MAX_AUDIO_BYTES {
2742        return Err(Error::InvalidFrame(format!(
2743            "Audio frame exceeds maximum size: {} bytes > {} bytes",
2744            byte_size, MAX_AUDIO_BYTES
2745        )));
2746    }
2747
2748    Ok(PackedAudioDimensions {
2749        no_channels,
2750        no_samples,
2751        channel_stride_in_bytes,
2752        sample_count,
2753    })
2754}
2755
2756/// Validate audio frame layout from raw FFI fields.
2757///
2758/// This function performs all necessary validation including:
2759/// - Null pointer check for `p_data`
2760/// - Valid audio format (FourCC) conversion
2761/// - Positive sample rate, channel count, and sample count
2762/// - Checked arithmetic for sample count multiplication
2763/// - `MAX_AUDIO_BYTES` cap enforcement
2764///
2765/// # Arguments
2766///
2767/// * `raw` - Reference to the raw NDI audio frame
2768///
2769/// # Returns
2770///
2771/// `Ok(ValidatedAudioLayout)` if the frame is valid, or `Err(Error::InvalidFrame(...))` otherwise.
2772pub(crate) fn validate_audio_layout(raw: &NDIlib_audio_frame_v3_t) -> Result<ValidatedAudioLayout> {
2773    validate_audio_layout_inner(raw, false)
2774}
2775
2776/// Validate a FrameSync audio frame that is allowed to be a documented
2777/// query/no-source zero-length state.
2778pub(crate) fn validate_audio_layout_allow_empty(
2779    raw: &NDIlib_audio_frame_v3_t,
2780) -> Result<ValidatedAudioLayout> {
2781    validate_audio_layout_inner(raw, true)
2782}
2783
2784fn validate_audio_layout_inner(
2785    raw: &NDIlib_audio_frame_v3_t,
2786    allow_empty: bool,
2787) -> Result<ValidatedAudioLayout> {
2788    if raw.no_samples == 0 {
2789        if allow_empty {
2790            return validate_empty_audio_layout(raw);
2791        }
2792
2793        return Err(Error::InvalidFrame("Invalid number of samples: 0".into()));
2794    }
2795
2796    if raw.p_data.is_null() {
2797        return Err(Error::InvalidFrame(
2798            "Audio frame has null data pointer".into(),
2799        ));
2800    }
2801
2802    if !raw.p_data.cast::<f32>().is_aligned() {
2803        return Err(Error::InvalidFrame(
2804            "Audio data pointer is not aligned for f32".into(),
2805        ));
2806    }
2807
2808    if raw.sample_rate <= 0 {
2809        return Err(Error::InvalidFrame(format!(
2810            "Invalid sample rate: {}",
2811            raw.sample_rate
2812        )));
2813    }
2814
2815    if raw.no_channels <= 0 {
2816        return Err(Error::InvalidFrame(format!(
2817            "Invalid number of channels: {}",
2818            raw.no_channels
2819        )));
2820    }
2821
2822    if raw.no_samples <= 0 {
2823        return Err(Error::InvalidFrame(format!(
2824            "Invalid number of samples: {}",
2825            raw.no_samples
2826        )));
2827    }
2828
2829    let format = validate_audio_format(raw.FourCC)?;
2830    let channel_stride_in_bytes = unsafe { raw.__bindgen_anon_1.channel_stride_in_bytes };
2831    if channel_stride_in_bytes <= 0 {
2832        return Err(Error::InvalidFrame(format!(
2833            "Invalid channel_stride_in_bytes: {}",
2834            channel_stride_in_bytes
2835        )));
2836    }
2837
2838    // Use checked math to prevent overflow when computing sample count
2839    let no_samples = usize::try_from(raw.no_samples).map_err(|_| {
2840        Error::InvalidFrame(format!("Invalid no_samples value: {}", raw.no_samples))
2841    })?;
2842
2843    let no_channels = usize::try_from(raw.no_channels).map_err(|_| {
2844        Error::InvalidFrame(format!("Invalid no_channels value: {}", raw.no_channels))
2845    })?;
2846
2847    let channel_stride_bytes = usize::try_from(channel_stride_in_bytes).map_err(|_| {
2848        Error::InvalidFrame(format!(
2849            "Invalid channel_stride_in_bytes value: {}",
2850            channel_stride_in_bytes
2851        ))
2852    })?;
2853
2854    if channel_stride_bytes % std::mem::size_of::<f32>() != 0 {
2855        return Err(Error::InvalidFrame(format!(
2856            "channel_stride_in_bytes must be a multiple of {}, got {}",
2857            std::mem::size_of::<f32>(),
2858            channel_stride_in_bytes
2859        )));
2860    }
2861
2862    let minimum_channel_stride = no_samples
2863        .checked_mul(std::mem::size_of::<f32>())
2864        .ok_or_else(|| {
2865            Error::InvalidFrame(format!(
2866                "Audio channel stride overflow: {} samples × {} bytes",
2867                no_samples,
2868                std::mem::size_of::<f32>()
2869            ))
2870        })?;
2871
2872    if channel_stride_bytes < minimum_channel_stride {
2873        return Err(Error::InvalidFrame(format!(
2874            "channel_stride_in_bytes {} is smaller than one channel of audio samples {}",
2875            channel_stride_in_bytes, minimum_channel_stride
2876        )));
2877    }
2878
2879    let channel_stride_samples = channel_stride_bytes / std::mem::size_of::<f32>();
2880    let last_channel_offset = no_channels
2881        .checked_sub(1)
2882        .and_then(|last| last.checked_mul(channel_stride_samples))
2883        .ok_or_else(|| {
2884            Error::InvalidFrame(format!(
2885                "Audio channel offset overflow: {} channels × {} stride samples",
2886                no_channels, channel_stride_samples
2887            ))
2888        })?;
2889
2890    let sample_count = last_channel_offset.checked_add(no_samples).ok_or_else(|| {
2891        Error::InvalidFrame(format!(
2892            "Audio backing sample count overflow: channel offset {} + {} samples",
2893            last_channel_offset, no_samples
2894        ))
2895    })?;
2896
2897    // Check total byte size against limit
2898    let byte_size = sample_count
2899        .checked_mul(std::mem::size_of::<f32>())
2900        .ok_or_else(|| {
2901            Error::InvalidFrame(format!(
2902                "Audio byte size overflow: {} samples × {} bytes",
2903                sample_count,
2904                std::mem::size_of::<f32>()
2905            ))
2906        })?;
2907
2908    if byte_size > MAX_AUDIO_BYTES {
2909        return Err(Error::InvalidFrame(format!(
2910            "Audio frame exceeds maximum size: {} bytes > {} bytes",
2911            byte_size, MAX_AUDIO_BYTES
2912        )));
2913    }
2914
2915    Ok(ValidatedAudioLayout {
2916        format: Some(format),
2917        sample_rate: raw.sample_rate,
2918        no_channels,
2919        no_samples,
2920        channel_stride_in_bytes,
2921        channel_stride_samples,
2922        sample_count,
2923    })
2924}
2925
2926fn validate_empty_audio_layout(raw: &NDIlib_audio_frame_v3_t) -> Result<ValidatedAudioLayout> {
2927    if !raw.p_data.is_null() {
2928        return Err(Error::InvalidFrame(
2929            "Zero-length audio frame has non-null data pointer".into(),
2930        ));
2931    }
2932
2933    if raw.sample_rate < 0 {
2934        return Err(Error::InvalidFrame(format!(
2935            "Invalid sample rate: {}",
2936            raw.sample_rate
2937        )));
2938    }
2939
2940    if raw.no_channels < 0 {
2941        return Err(Error::InvalidFrame(format!(
2942            "Invalid number of channels: {}",
2943            raw.no_channels
2944        )));
2945    }
2946
2947    let channel_stride_in_bytes = unsafe { raw.__bindgen_anon_1.channel_stride_in_bytes };
2948    if channel_stride_in_bytes != 0 {
2949        return Err(Error::InvalidFrame(format!(
2950            "Zero-length audio frame has non-zero channel_stride_in_bytes: {}",
2951            channel_stride_in_bytes
2952        )));
2953    }
2954
2955    let no_source = raw.sample_rate == 0 && raw.no_channels == 0;
2956    let query_format = raw.sample_rate > 0 && raw.no_channels > 0;
2957    if !no_source && !query_format {
2958        return Err(Error::InvalidFrame(format!(
2959            "Invalid zero-length audio query state: sample_rate={}, no_channels={}",
2960            raw.sample_rate, raw.no_channels
2961        )));
2962    }
2963
2964    let format = if no_source && raw.FourCC == 0 {
2965        None
2966    } else {
2967        Some(validate_audio_format(raw.FourCC)?)
2968    };
2969
2970    let no_channels = usize::try_from(raw.no_channels).map_err(|_| {
2971        Error::InvalidFrame(format!("Invalid no_channels value: {}", raw.no_channels))
2972    })?;
2973
2974    Ok(ValidatedAudioLayout {
2975        format,
2976        sample_rate: raw.sample_rate,
2977        no_channels,
2978        no_samples: 0,
2979        channel_stride_in_bytes: 0,
2980        channel_stride_samples: 0,
2981        sample_count: 0,
2982    })
2983}
2984
2985fn validate_audio_format(fourcc: NDIlib_FourCC_audio_type_e) -> Result<AudioFormat> {
2986    match fourcc {
2987        NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP => Ok(AudioFormat::FLTP),
2988        _ => Err(Error::InvalidFrame(format!(
2989            "Unknown audio format FourCC: 0x{:08X}",
2990            fourcc
2991        ))),
2992    }
2993}
2994
2995// ============================================================================
2996// Zero-copy borrowed receive frames
2997// ============================================================================
2998
2999/// An allocation-free view of one video plane's initialized active bytes.
3000///
3001/// Plane order follows the pixel format: packed pixels; UYVY/A for UYVA;
3002/// Y/CbCr[/A] for P216/PA16; Y/V/U for YV12; Y/U/V for I420; Y/UV for NV12.
3003/// The view borrows its captured frame, which keeps the SDK allocation alive.
3004/// Immutable pixel views are `Send + Sync`, including views borrowed from
3005/// FrameSync. Scoped workers must finish before the frame is freed. This does
3006/// not transfer the SDK-owning frame reference or change its freeing thread.
3007///
3008/// ```no_run
3009/// # fn read(frame: &grafton_ndi::FrameSyncVideoRef<'_>) {
3010/// let plane = frame.plane(0).unwrap();
3011/// let rows = plane.rows();
3012/// std::thread::scope(|scope| {
3013///     let worker = scope.spawn(move || {
3014///         rows.flatten().map(|&byte| u64::from(byte)).sum::<u64>()
3015///     });
3016///     assert_eq!(worker.join().unwrap(),
3017///         plane.rows().flatten().map(|&byte| u64::from(byte)).sum::<u64>());
3018/// });
3019/// # }
3020/// ```
3021#[derive(Debug, Clone, Copy)]
3022pub struct VideoPlane<'a> {
3023    data: *const u8,
3024    layout: VideoPlaneLayout,
3025    lifetime: PhantomData<&'a [u8]>,
3026}
3027
3028// SAFETY: Every private construction site retains one allocation for 'a and
3029// validates all offsets, strides, and active lengths before constructing views.
3030// Active bytes are initialized and immutable for 'a; padding is never sliced.
3031// Reads require only u8 alignment (1) and expose the same &'a [u8] already
3032// returned by row/data. Copying or sharing a view grants no mutation or SDK
3033// access. Views have no destructor/free operation. Their borrow retains the
3034// captured guard until all workers finish, so FrameSync still frees its frame
3035// on the capture thread. Owned-slice construction likewise retains its borrow.
3036// SDK ownership/free boundary: Processing.NDI.Recv.h (capture_v3/free_video_v2)
3037// and Processing.NDI.FrameSync.h (capture_video/free_video).
3038unsafe impl Send for VideoPlane<'_> {}
3039// SAFETY: Same immutable access and lifetime proof as Send above.
3040unsafe impl Sync for VideoPlane<'_> {}
3041
3042impl<'a> VideoPlane<'a> {
3043    /// Caller must retain an allocation covering the validated layout for 'a,
3044    /// with every active row readable, initialized, and immutable for that lifetime.
3045    unsafe fn new(data: *const u8, layout: VideoPlaneLayout) -> Self {
3046        Self {
3047            data,
3048            layout,
3049            lifetime: PhantomData,
3050        }
3051    }
3052
3053    /// Distance between row starts in bytes, including padding.
3054    pub fn row_stride(&self) -> usize {
3055        self.layout.row_stride
3056    }
3057
3058    /// Number of initialized bytes in each row.
3059    pub fn active_row_bytes(&self) -> usize {
3060        self.layout.active_row_bytes
3061    }
3062
3063    /// Number of rows in this plane.
3064    pub fn row_count(&self) -> usize {
3065        self.layout.row_count
3066    }
3067
3068    /// Get an active row, excluding padding, or `None` for an invalid index.
3069    pub fn row(&self, index: usize) -> Option<&'a [u8]> {
3070        if index >= self.layout.row_count {
3071            return None;
3072        }
3073        // SAFETY: Construction ties the allocation to 'a. Checked plane/row
3074        // arithmetic bounds this range within storage. The SDK initializes the
3075        // active bytes; padding is never included. The guard prevents freeing.
3076        Some(unsafe {
3077            slice::from_raw_parts(
3078                self.data
3079                    .add(self.layout.offset + index * self.layout.row_stride),
3080                self.layout.active_row_bytes,
3081            )
3082        })
3083    }
3084
3085    /// Iterate active rows without allocating or copying pixels.
3086    pub fn rows(&self) -> VideoRows<'a> {
3087        VideoRows {
3088            plane: *self,
3089            indices: 0..self.row_count(),
3090        }
3091    }
3092
3093    /// Borrow all active bytes when contiguous, excluding trailing padding.
3094    /// Returns `None` when padding separates rows.
3095    pub fn data(&self) -> Option<&'a [u8]> {
3096        if self.row_count() != 1 && self.row_stride() != self.active_row_bytes() {
3097            return None;
3098        }
3099        // SAFETY: All rows are adjacent initialized ranges in the retained
3100        // allocation; the final row's padding is excluded.
3101        Some(unsafe {
3102            slice::from_raw_parts(
3103                self.data.add(self.layout.offset),
3104                self.row_count() * self.active_row_bytes(),
3105            )
3106        })
3107    }
3108}
3109
3110/// Active video rows, borrowing the frame that owns the SDK allocation.
3111/// `Send + Sync` through its immutable plane; iteration never calls the SDK.
3112#[derive(Debug, Clone)]
3113pub struct VideoRows<'a> {
3114    plane: VideoPlane<'a>,
3115    indices: std::ops::Range<usize>,
3116}
3117
3118impl<'a> Iterator for VideoRows<'a> {
3119    type Item = &'a [u8];
3120    fn next(&mut self) -> Option<Self::Item> {
3121        self.plane.row(self.indices.next()?)
3122    }
3123    fn size_hint(&self) -> (usize, Option<usize>) {
3124        self.indices.size_hint()
3125    }
3126}
3127impl DoubleEndedIterator for VideoRows<'_> {
3128    fn next_back(&mut self) -> Option<Self::Item> {
3129        self.plane.row(self.indices.next_back()?)
3130    }
3131}
3132impl ExactSizeIterator for VideoRows<'_> {}
3133impl std::iter::FusedIterator for VideoRows<'_> {}
3134
3135/// Video planes in SDK memory order, with no allocation or pixel copies.
3136/// `Send + Sync` for scoped reads; the owning frame's thread rules still apply.
3137#[derive(Debug, Clone)]
3138pub struct VideoPlanes<'a> {
3139    data: *const u8,
3140    layouts: slice::Iter<'a, VideoPlaneLayout>,
3141}
3142// SAFETY: Constructed only by VideoRef::planes from a validated live frame.
3143// The shared layout slice and data borrow last for 'a; each yielded plane
3144// satisfies VideoPlane's immutable, initialized, bounded u8 access proof above.
3145// Iterator state is exclusively mutated, clones are independent, and neither
3146// iteration nor dropping it accesses an SDK handle or frees the allocation.
3147unsafe impl Send for VideoPlanes<'_> {}
3148// SAFETY: Sharing the iterator exposes only immutable layouts and bytes.
3149unsafe impl Sync for VideoPlanes<'_> {}
3150impl<'a> Iterator for VideoPlanes<'a> {
3151    type Item = VideoPlane<'a>;
3152    fn next(&mut self) -> Option<Self::Item> {
3153        let layout = *self.layouts.next()?;
3154        // SAFETY: Created only from a live frame with validated active rows.
3155        Some(unsafe { VideoPlane::new(self.data, layout) })
3156    }
3157    fn size_hint(&self) -> (usize, Option<usize>) {
3158        self.layouts.size_hint()
3159    }
3160}
3161impl ExactSizeIterator for VideoPlanes<'_> {}
3162impl std::iter::FusedIterator for VideoPlanes<'_> {}
3163
3164/// A zero-copy borrowed video frame, generic over its free strategy `S`.
3165///
3166/// This type wraps an RAII `Guard` that owns the NDI frame buffer lifetime,
3167/// exposing a safe, zero-copy view of the video data. The frame is automatically
3168/// freed when dropped, via whichever `NDIlib_*_free_video*` call the strategy `S`
3169/// encodes.
3170///
3171/// The two public spellings are type aliases over this one core, so every
3172/// accessor and the `Debug` impl are written exactly once:
3173/// - [`VideoFrameRef<'rx>`] — frames captured from a [`Receiver`](crate::Receiver)
3174///   (freed via `NDIlib_recv_free_video_v2`).
3175/// - [`FrameSyncVideoRef<'fs>`](crate::FrameSyncVideoRef) — frames captured from a
3176///   [`FrameSync`](crate::FrameSync) (freed via `NDIlib_framesync_free_video`).
3177///
3178/// **Key characteristics:**
3179/// - Zero allocations: References NDI SDK buffers directly
3180/// - Zero copies: No memcpy of pixel data
3181/// - RAII lifetime: Exactly one free per frame, enforced at compile time
3182/// - Receiver refs are `Send + Sync`; FrameSync refs stay on their capture thread
3183/// - Immutable plane/row views support scoped worker reads from either family
3184///
3185/// # Lifetime
3186///
3187/// The lifetime parameter `'a` ties this frame to the owner (a `Receiver` or a
3188/// `FrameSync`) that created it. The borrow checker ensures the owner cannot be
3189/// dropped while any frame references are alive, preventing use-after-free at
3190/// compile time with zero runtime cost. The underlying NDI buffer is freed when
3191/// the frame ref is dropped.
3192///
3193/// # Performance
3194///
3195/// For a 1920×1080 BGRA frame, this eliminates ~8.3 MB of memcpy compared to
3196/// the owned [`VideoFrame`]. At 60 fps, this saves ~475 MB/s of memory bandwidth.
3197#[allow(private_bounds)]
3198pub struct VideoRef<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> {
3199    guard: Guard<'a, S>,
3200    /// Cached validated layout information (pixel format, data length, stride/size).
3201    /// Computed once at construction time; `data()` uses this cached value.
3202    layout: ValidatedVideoLayout,
3203    metadata: ValidatedFrameMetadata,
3204}
3205
3206/// A zero-copy borrowed video frame from a [`Receiver`](crate::Receiver) capture.
3207///
3208/// This is the receiver spelling of the generic [`VideoRef`]; the
3209/// [`FrameSync`](crate::FrameSync) spelling is
3210/// [`FrameSyncVideoRef`](crate::FrameSyncVideoRef). Both share one accessor and
3211/// `Debug` implementation.
3212pub type VideoFrameRef<'rx> = VideoRef<'rx, VideoKind>;
3213
3214#[allow(private_bounds)]
3215impl<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> VideoRef<'a, S> {
3216    /// Create a borrowed video frame from an RAII guard.
3217    ///
3218    /// Validates the frame layout including:
3219    /// - Valid pixel format (FourCC)
3220    /// - Non-null data pointer
3221    /// - Valid dimensions and stride
3222    /// - Buffer size within `MAX_VIDEO_BYTES` limit
3223    ///
3224    /// The validated layout is cached so that `data()` can return slices
3225    /// without re-computation or unchecked arithmetic.
3226    ///
3227    /// # Safety
3228    ///
3229    /// The caller must ensure the guard was created from a valid NDI instance
3230    /// and contains a frame populated by the matching SDK capture call.
3231    pub(crate) unsafe fn new(guard: Guard<'a, S>) -> Result<Self> {
3232        let layout = validate_video_layout(guard.frame())?;
3233        let metadata = unsafe { validate_frame_metadata(guard.frame().p_metadata)? };
3234
3235        Ok(Self {
3236            guard,
3237            layout,
3238            metadata,
3239        })
3240    }
3241
3242    /// Get the frame width in pixels.
3243    pub fn width(&self) -> i32 {
3244        self.guard.frame().xres
3245    }
3246
3247    /// Get the frame height in pixels.
3248    pub fn height(&self) -> i32 {
3249        self.guard.frame().yres
3250    }
3251
3252    /// Get the pixel format (FourCC code).
3253    ///
3254    /// This is guaranteed to be a valid, supported format since it's validated during construction.
3255    pub fn pixel_format(&self) -> PixelFormat {
3256        self.layout.pixel_format
3257    }
3258
3259    /// Get the frame rate numerator.
3260    pub fn frame_rate_n(&self) -> i32 {
3261        self.guard.frame().frame_rate_N
3262    }
3263
3264    /// Get the frame rate denominator.
3265    pub fn frame_rate_d(&self) -> i32 {
3266        self.guard.frame().frame_rate_D
3267    }
3268
3269    /// Get the picture aspect ratio.
3270    pub fn picture_aspect_ratio(&self) -> f32 {
3271        self.guard.frame().picture_aspect_ratio
3272    }
3273
3274    /// Get the scan type (progressive, interlaced, etc.).
3275    ///
3276    /// This is guaranteed to be valid since it is checked during construction.
3277    pub fn scan_type(&self) -> ScanType {
3278        #[allow(clippy::unnecessary_cast)]
3279        ScanType::try_from(self.guard.frame().frame_format_type as u32)
3280            .expect("VideoFrameRef validates scan type during construction")
3281    }
3282
3283    /// Get the timecode in 100 ns units.
3284    pub fn timecode(&self) -> i64 {
3285        self.guard.frame().timecode
3286    }
3287
3288    /// Get the receive timestamp in 100 ns units since the Unix epoch.
3289    pub fn timestamp(&self) -> i64 {
3290        self.guard.frame().timestamp
3291    }
3292
3293    /// Get the line stride or data size.
3294    ///
3295    /// This returns the cached, validated value computed at construction time.
3296    pub fn line_stride_or_size(&self) -> LineStrideOrSize {
3297        self.layout.line_stride_or_size
3298    }
3299
3300    /// Get frame metadata as UTF-8 text, if present.
3301    pub fn metadata(&self) -> Option<&str> {
3302        unsafe { frame_metadata_str(self.guard.frame().p_metadata, self.metadata) }
3303    }
3304
3305    /// Borrow all active pixels when contiguous, in SDK plane order.
3306    ///
3307    /// Returns `None` if padding separates active rows or planes. Use
3308    /// [`Self::planes`] / [`Self::plane`] for zero-copy padded access, or
3309    /// [`Self::to_owned`] for a tightly packed owned buffer. A single packed
3310    /// row can be borrowed even when its stride includes trailing padding.
3311    pub fn data(&self) -> Option<&[u8]> {
3312        if !self.layout.is_contiguous() {
3313            return None;
3314        }
3315        // SAFETY: The guard retains the SDK allocation for this borrow. Layout
3316        // validation bounds the range; contiguity proves every byte is active,
3317        // initialized payload, with no intervening or trailing padding.
3318        Some(unsafe {
3319            slice::from_raw_parts(self.guard.frame().p_data, self.layout.payload_len_bytes)
3320        })
3321    }
3322
3323    /// Get one plane in SDK memory order, or `None` for an invalid index.
3324    pub fn plane(&self, index: usize) -> Option<VideoPlane<'_>> {
3325        let layout = *self.layout.planes().get(index)?;
3326        // SAFETY: The returned lifetime borrows this guard, which retains the
3327        // allocation and its initialized, immutable active rows.
3328        Some(unsafe { VideoPlane::new(self.guard.frame().p_data, layout) })
3329    }
3330
3331    /// Iterate planes in SDK memory order without allocating or copying.
3332    pub fn planes(&self) -> VideoPlanes<'_> {
3333        VideoPlanes {
3334            data: self.guard.frame().p_data,
3335            layouts: self.layout.planes().iter(),
3336        }
3337    }
3338
3339    /// Encode PNG directly from the borrowed SDK pixels, without first
3340    /// copying the frame into an owned `VideoFrame`. Uses the same supported
3341    /// formats and padding handling as [`VideoFrame::encode_png`].
3342    #[cfg(feature = "image-encoding")]
3343    pub fn encode_png(&self) -> Result<Vec<u8>> {
3344        ImagePixelSource::from_plane(self.layout, self.plane(0).expect("validated video plane"))?
3345            .encode_png()
3346    }
3347
3348    /// Encode JPEG directly from borrowed SDK pixels. Quality must be in
3349    /// `1..=100`; see [`VideoFrame::encode_jpeg`] for format restrictions.
3350    #[cfg(feature = "image-encoding")]
3351    pub fn encode_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
3352        ImagePixelSource::from_plane(self.layout, self.plane(0).expect("validated video plane"))?
3353            .encode_jpeg(quality)
3354    }
3355
3356    /// Encode borrowed SDK pixels as a PNG or JPEG base64 data URL without an
3357    /// intermediate owned frame. See [`VideoFrame::encode_data_url`].
3358    #[cfg(feature = "image-encoding")]
3359    pub fn encode_data_url(&self, format: ImageFormat) -> Result<String> {
3360        ImagePixelSource::from_plane(self.layout, self.plane(0).expect("validated video plane"))?
3361            .encode_data_url(format)
3362    }
3363
3364    /// Convert this borrowed frame to an owned `VideoFrame`.
3365    ///
3366    /// Copies only active rows into one tightly packed pixel allocation and
3367    /// copies metadata separately. The owned stride can be smaller than the
3368    /// SDK stride. Preserves format, timing, aspect ratio, and scan type.
3369    pub fn to_owned(&self) -> Result<VideoFrame> {
3370        unsafe { VideoFrame::from_raw_validated(self.guard.frame(), self.layout, self.metadata) }
3371    }
3372}
3373
3374#[allow(private_bounds)]
3375impl<'a, S: FrameFree<RawFrame = NDIlib_video_frame_v2_t>> fmt::Debug for VideoRef<'a, S> {
3376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3377        f.debug_struct(S::REF_DEBUG_NAME)
3378            .field("width", &self.width())
3379            .field("height", &self.height())
3380            .field("pixel_format", &self.pixel_format())
3381            .field("frame_rate_n", &self.frame_rate_n())
3382            .field("frame_rate_d", &self.frame_rate_d())
3383            .field("picture_aspect_ratio", &self.picture_aspect_ratio())
3384            .field("scan_type", &self.scan_type())
3385            .field("timecode", &self.timecode())
3386            .field("active data (bytes)", &self.layout.payload_len_bytes)
3387            .field("line_stride_or_size", &self.line_stride_or_size())
3388            .field("metadata", &self.metadata())
3389            .field("timestamp", &self.timestamp())
3390            .finish()
3391    }
3392}
3393
3394/// A zero-copy borrowed audio frame, generic over its free strategy `S`.
3395///
3396/// This type wraps an RAII `Guard` that owns the NDI frame buffer lifetime,
3397/// exposing a safe, zero-copy view of the audio data. The frame is automatically
3398/// freed when dropped, via whichever `NDIlib_*_free_audio*` call the strategy `S`
3399/// encodes.
3400///
3401/// The shared accessors and the `Debug` impl are written exactly once here; the
3402/// two public spellings are aliases that layer their differing format/owned/empty
3403/// policy on top:
3404/// - [`AudioFrameRef<'rx>`] — frames captured from a [`Receiver`](crate::Receiver)
3405///   (always concrete: `format() -> AudioFormat`, `to_owned() -> AudioFrame`).
3406/// - [`FrameSyncAudioRef<'fs>`](crate::FrameSyncAudioRef) — frames captured from a
3407///   [`FrameSync`](crate::FrameSync), which additionally model a validated empty
3408///   query/no-source state (`is_empty`, `Option`-returning `format`/`to_owned`).
3409///
3410/// **Key characteristics:**
3411/// - Zero allocations: References NDI SDK buffers directly
3412/// - Zero copies: No memcpy of audio samples
3413/// - RAII lifetime: Exactly one free per frame, enforced at compile time
3414/// - Receiver refs are `Send + Sync`; FrameSync refs stay on their capture thread
3415#[allow(private_bounds)]
3416pub struct AudioRef<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> {
3417    guard: Guard<'a, S>,
3418    /// Cached validated layout information (format, sample count).
3419    /// Computed once at construction time; `data()` uses this cached value.
3420    layout: ValidatedAudioLayout,
3421    metadata: ValidatedFrameMetadata,
3422}
3423
3424/// A zero-copy borrowed audio frame from a [`Receiver`](crate::Receiver) capture.
3425///
3426/// This is the receiver spelling of the generic [`AudioRef`]; the
3427/// [`FrameSync`](crate::FrameSync) spelling is
3428/// [`FrameSyncAudioRef`](crate::FrameSyncAudioRef). The receiver path always
3429/// yields a concrete audio frame, so [`format`](AudioRef::format) returns an
3430/// [`AudioFormat`] and [`to_owned`](AudioRef::to_owned) an [`AudioFrame`]; the
3431/// FrameSync path additionally models a validated empty query/no-source state.
3432pub type AudioFrameRef<'rx> = AudioRef<'rx, AudioKind>;
3433
3434#[allow(private_bounds)]
3435impl<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> AudioRef<'a, S> {
3436    /// Store a guard alongside an already-validated audio layout, validating and
3437    /// caching the frame metadata. The per-family constructors compute `layout`
3438    /// (concrete vs. empty-query) and funnel through here.
3439    ///
3440    /// # Safety
3441    ///
3442    /// The caller must ensure the guard was created from a valid NDI instance
3443    /// and that `layout` was validated from the same frame the guard owns.
3444    pub(crate) unsafe fn from_validated_layout(
3445        guard: Guard<'a, S>,
3446        layout: ValidatedAudioLayout,
3447    ) -> Result<Self> {
3448        let metadata = unsafe { validate_frame_metadata(guard.frame().p_metadata)? };
3449
3450        Ok(Self {
3451            guard,
3452            layout,
3453            metadata,
3454        })
3455    }
3456
3457    /// Get the sample rate in Hz.
3458    pub fn sample_rate(&self) -> i32 {
3459        self.layout.sample_rate
3460    }
3461
3462    /// Get the number of audio channels.
3463    pub fn num_channels(&self) -> i32 {
3464        self.layout.no_channels as i32
3465    }
3466
3467    /// Get the number of samples per channel.
3468    pub fn num_samples(&self) -> i32 {
3469        self.layout.no_samples as i32
3470    }
3471
3472    /// Get the timecode in 100 ns units.
3473    pub fn timecode(&self) -> i64 {
3474        self.guard.frame().timecode
3475    }
3476
3477    /// Get the receive timestamp in 100 ns units since the Unix epoch.
3478    pub fn timestamp(&self) -> i64 {
3479        self.guard.frame().timestamp
3480    }
3481
3482    /// Get the channel stride in bytes.
3483    pub fn channel_stride_in_bytes(&self) -> i32 {
3484        self.layout.channel_stride_in_bytes
3485    }
3486
3487    /// Get frame metadata as UTF-8 text, if present.
3488    pub fn metadata(&self) -> Option<&str> {
3489        unsafe { frame_metadata_str(self.guard.frame().p_metadata, self.metadata) }
3490    }
3491
3492    /// Get a zero-copy slice when channels are tightly packed.
3493    ///
3494    /// Returns `None` for multi-channel buffers with padding between channels.
3495    /// Use [`channel_data`](Self::channel_data) or [`channels`](Self::channels)
3496    /// for those buffers. An empty query/no-source frame returns `Some(&[])`.
3497    pub fn data(&self) -> Option<&[f32]> {
3498        if self.layout.is_empty() {
3499            return Some(&[]);
3500        }
3501        if self.layout.no_channels > 1
3502            && self.layout.channel_stride_samples != self.layout.no_samples
3503        {
3504            return None;
3505        }
3506        // SAFETY: a packed FLTP buffer contains only initialized samples;
3507        // construction checked dimensions, byte bounds, and float alignment.
3508        Some(unsafe {
3509            slice::from_raw_parts(
3510                self.guard.frame().p_data.cast::<f32>(),
3511                self.layout.sample_count,
3512            )
3513        })
3514    }
3515
3516    /// Get the zero-copy samples for a single channel, excluding padding.
3517    pub fn channel_data(&self, channel: usize) -> Option<&[f32]> {
3518        let range = self.layout.channel_range(channel)?;
3519        // SAFETY: only this channel's initialized sample region is borrowed.
3520        Some(unsafe {
3521            slice::from_raw_parts(
3522                self.guard.frame().p_data.cast::<f32>().add(range.start),
3523                self.layout.no_samples,
3524            )
3525        })
3526    }
3527
3528    /// Iterate over channel slices without reading inter-channel padding.
3529    /// Empty query/no-source frames yield no channels.
3530    pub fn channels(&self) -> impl ExactSizeIterator<Item = &[f32]> + '_ {
3531        let count = if self.layout.is_empty() {
3532            0
3533        } else {
3534            self.layout.no_channels
3535        };
3536        (0..count).map(|channel| self.channel_data(channel).expect("validated channel"))
3537    }
3538}
3539
3540impl<'rx> AudioRef<'rx, AudioKind> {
3541    /// Create a borrowed audio frame from a receiver RAII guard.
3542    ///
3543    /// Validates the frame layout including:
3544    /// - Valid audio format (FourCC)
3545    /// - Non-null data pointer
3546    /// - Valid sample rate, channel count, and sample count
3547    /// - Buffer size within `MAX_AUDIO_BYTES` limit
3548    ///
3549    /// The validated layout is cached so that `data()` can return slices
3550    /// without re-computation or unchecked arithmetic.
3551    ///
3552    /// # Safety
3553    ///
3554    /// The caller must ensure the guard was created from a valid NDI receiver
3555    /// and contains a frame populated by `NDIlib_recv_capture_v3`.
3556    pub(crate) unsafe fn new(guard: RecvAudioGuard<'rx>) -> Result<Self> {
3557        let layout = validate_audio_layout(guard.frame())?;
3558        unsafe { Self::from_validated_layout(guard, layout) }
3559    }
3560
3561    /// Get the audio format (FourCC code).
3562    ///
3563    /// This is guaranteed to be a valid, supported format since it's validated during construction.
3564    pub fn format(&self) -> AudioFormat {
3565        self.layout
3566            .format()
3567            .expect("validate_audio_layout requires a concrete audio format")
3568    }
3569
3570    /// Convert this borrowed frame to an owned `AudioFrame`.
3571    ///
3572    /// Copies channel samples into tightly packed planar storage and copies
3573    /// metadata, allowing the frame to outlive the SDK buffer. Padding is omitted.
3574    pub fn to_owned(&self) -> Result<AudioFrame> {
3575        // SAFETY: the receive guard owns the exact validated frame through copying.
3576        unsafe { AudioFrame::from_raw_validated(*self.guard.frame(), self.layout, self.metadata) }
3577    }
3578}
3579
3580impl<'fs> AudioRef<'fs, FrameSyncAudioFree> {
3581    /// Create a borrowed audio frame from a FrameSync RAII guard.
3582    ///
3583    /// When `query_input` is set, the SDK may report a validated empty
3584    /// query/no-source state (no samples); otherwise a concrete frame is
3585    /// required. A query that unexpectedly returns samples is rejected.
3586    ///
3587    /// # Safety
3588    ///
3589    /// The caller must ensure the guard was created from a valid FrameSync
3590    /// instance and contains a frame populated by `NDIlib_framesync_capture_audio_v2`.
3591    pub(crate) unsafe fn new(
3592        guard: Guard<'fs, FrameSyncAudioFree>,
3593        query_input: bool,
3594    ) -> Result<Self> {
3595        let layout = if query_input {
3596            if guard.frame().no_samples != 0 {
3597                return Err(Error::InvalidFrame(format!(
3598                    "FrameSync audio query returned {} samples",
3599                    guard.frame().no_samples
3600                )));
3601            }
3602            validate_audio_layout_allow_empty(guard.frame())?
3603        } else {
3604            validate_audio_layout(guard.frame())?
3605        };
3606        unsafe { Self::from_validated_layout(guard, layout) }
3607    }
3608
3609    /// Returns true for a valid query/no-source state with no sample buffer.
3610    pub fn is_empty(&self) -> bool {
3611        self.layout.is_empty()
3612    }
3613
3614    /// Get the audio format (FourCC code), or `None` for an empty query/no-source state.
3615    pub fn format(&self) -> Option<AudioFormat> {
3616        self.layout.format()
3617    }
3618
3619    /// Convert this borrowed frame to an owned `AudioFrame`.
3620    ///
3621    /// Copies channel samples into tightly packed planar storage and copies
3622    /// metadata, allowing the frame to outlive the SDK buffer. Padding is omitted.
3623    ///
3624    /// Returns `Ok(None)` for a valid query/no-source state with no sample
3625    /// buffer to own.
3626    pub fn to_owned(&self) -> Result<Option<AudioFrame>> {
3627        if self.layout.is_empty() {
3628            Ok(None)
3629        } else {
3630            // SAFETY: the FrameSync guard owns the exact validated frame through copying.
3631            unsafe {
3632                AudioFrame::from_raw_validated(*self.guard.frame(), self.layout, self.metadata)
3633            }
3634            .map(Some)
3635        }
3636    }
3637}
3638
3639#[allow(private_bounds)]
3640impl<'a, S: FrameFree<RawFrame = NDIlib_audio_frame_v3_t>> fmt::Debug for AudioRef<'a, S> {
3641    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3642        f.debug_struct(S::REF_DEBUG_NAME)
3643            .field("sample_rate", &self.sample_rate())
3644            .field("num_channels", &self.num_channels())
3645            .field("num_samples", &self.num_samples())
3646            .field("timecode", &self.timecode())
3647            // Render the format from the cached layout (`Option<AudioFormat>`) so
3648            // this single impl serves both the receiver and FrameSync spellings,
3649            // whose public `format()` return types differ.
3650            .field("format", &self.layout.format())
3651            .field("samples per channel", &self.layout.no_samples)
3652            .field("channel_stride_in_bytes", &self.channel_stride_in_bytes())
3653            .field("metadata", &self.metadata())
3654            .field("timestamp", &self.timestamp())
3655            .finish()
3656    }
3657}
3658
3659/// A zero-copy borrowed metadata frame.
3660///
3661/// This type wraps an RAII guard that owns the NDI frame buffer lifetime,
3662/// exposing a safe, zero-copy view of the metadata string. The frame is automatically
3663/// freed when dropped via `NDIlib_recv_free_metadata`.
3664///
3665/// **Key characteristics:**
3666/// - Zero allocations: References NDI SDK buffers directly
3667/// - Zero copies: No string duplication
3668/// - RAII lifetime: Exactly one free per frame, enforced at compile time
3669/// - `Send + Sync`: Captured metadata can be processed on a scoped worker thread
3670///
3671/// # Examples
3672///
3673/// ```no_run
3674/// # use grafton_ndi::{NDI, ReceiverOptions, Receiver, Source, SourceAddress};
3675/// # use std::time::Duration;
3676/// # fn main() -> Result<(), grafton_ndi::Error> {
3677/// # let ndi = NDI::new()?;
3678/// # let source = Source { name: "Test".into(), address: SourceAddress::None };
3679/// # let options = ReceiverOptions::builder(source).build();
3680/// # let receiver = Receiver::new(&ndi, &options)?;
3681/// // Zero-copy capture
3682/// if let Some(frame) = receiver.metadata().try_capture_ref(Duration::from_millis(1000))? {
3683///     println!("Metadata: {}", frame.data());
3684///
3685///     // Frame is freed here when `frame` goes out of scope
3686/// }
3687/// # Ok(())
3688/// # }
3689/// ```
3690pub struct MetadataFrameRef<'rx> {
3691    guard: RecvMetadataGuard<'rx>,
3692    /// Cached validated layout information. Computed once at construction time;
3693    /// `data()` and `as_bytes()` use this cached value.
3694    layout: ValidatedMetadataLayout,
3695}
3696
3697impl<'rx> MetadataFrameRef<'rx> {
3698    /// Create a borrowed metadata frame from an RAII guard.
3699    ///
3700    /// # Safety
3701    ///
3702    /// The caller must ensure the guard was created from a valid NDI receiver
3703    /// and contains a frame populated by `NDIlib_recv_capture_v3`.
3704    pub(crate) unsafe fn new(guard: RecvMetadataGuard<'rx>) -> Result<Self> {
3705        // SAFETY: the receive guard owns the SDK metadata buffer through validation.
3706        let layout = unsafe { validate_metadata_layout(guard.frame())? };
3707
3708        Ok(Self { guard, layout })
3709    }
3710
3711    /// Get the timecode in 100 ns units.
3712    pub fn timecode(&self) -> i64 {
3713        self.guard.frame().timecode
3714    }
3715
3716    /// Get a zero-copy view of the metadata text.
3717    ///
3718    /// This returns a reference directly into the NDI SDK's buffer.
3719    /// No allocation or string copying is performed.
3720    pub fn data(&self) -> &str {
3721        let bytes = self.as_bytes();
3722        // SAFETY: `validate_metadata_layout` checked UTF-8 before this
3723        // borrowed frame was constructed.
3724        unsafe { str::from_utf8_unchecked(bytes) }
3725    }
3726
3727    /// Get a zero-copy view of the metadata UTF-8 payload bytes, excluding the
3728    /// SDK trailing NUL terminator.
3729    pub fn as_bytes(&self) -> &[u8] {
3730        // SAFETY: the receive guard owns the exact validated buffer for this borrow.
3731        unsafe { metadata_payload_bytes(self.guard.frame(), self.layout) }
3732    }
3733
3734    /// Convert this borrowed frame to an owned `MetadataFrame`.
3735    ///
3736    /// This performs a string copy, allowing the frame to outlive
3737    /// the NDI buffer and be sent across threads.
3738    pub fn to_owned(&self) -> MetadataFrame {
3739        unsafe { MetadataFrame::from_raw_validated(self.guard.frame(), self.layout) }
3740    }
3741}
3742
3743impl<'rx> fmt::Debug for MetadataFrameRef<'rx> {
3744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3745        f.debug_struct("MetadataFrameRef")
3746            .field("data", &self.data())
3747            .field("data (bytes)", &self.as_bytes().len())
3748            .field("timecode", &self.timecode())
3749            .finish()
3750    }
3751}
3752
3753#[cfg(test)]
3754mod tests {
3755    use super::*;
3756
3757    #[test]
3758    fn packed_audio_dimensions_match_wide_oracle_and_owned_validation() {
3759        fn check(channels: i32, samples: i32) {
3760            let bytes = i128::from(channels) * i128::from(samples) * 4;
3761            let stride = i128::from(samples) * 4;
3762            let valid = channels > 0
3763                && samples > 0
3764                && stride <= i128::from(i32::MAX)
3765                && bytes <= MAX_AUDIO_BYTES as i128;
3766            let packed = validate_packed_audio_dimensions(channels, samples);
3767            let owned =
3768                validate_outbound_audio_layout(48_000, channels, samples, AudioFormat::FLTP);
3769            let request = crate::FrameSyncAudioRequest::capture(channels, samples);
3770            assert_eq!(
3771                packed.is_ok(),
3772                valid,
3773                "channels={channels}, samples={samples}"
3774            );
3775            assert_eq!(owned.is_ok(), valid);
3776            assert_eq!(request.is_ok(), valid);
3777            if let (Ok(packed), Ok(owned)) = (packed, owned) {
3778                assert_eq!(packed.sample_count as i128 * 4, bytes);
3779                assert_eq!(i128::from(packed.channel_stride_in_bytes), stride);
3780                assert_eq!(packed.no_channels, owned.no_channels);
3781                assert_eq!(packed.no_samples, owned.no_samples);
3782                assert_eq!(
3783                    packed.channel_stride_in_bytes,
3784                    owned.channel_stride_in_bytes
3785                );
3786                assert_eq!(packed.sample_count, owned.sample_count);
3787            }
3788        }
3789        let values = [
3790            i32::MIN,
3791            -1,
3792            0,
3793            1,
3794            2,
3795            3,
3796            16,
3797            1024,
3798            8_388_608,
3799            8_388_609,
3800            16_777_216,
3801            16_777_217,
3802            i32::MAX / 4,
3803            i32::MAX / 4 + 1,
3804            1_073_741_825,
3805            i32::MAX,
3806        ];
3807        for channels in values {
3808            for samples in values {
3809                check(channels, samples);
3810            }
3811        }
3812        // Deterministic property coverage around the payload boundary, plus the
3813        // full signed SDK input range. No sample buffers are allocated.
3814        let mut seed = 102u64;
3815        for _ in 0..4096 {
3816            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
3817            let channels = (seed >> 32) as i32;
3818            check(channels, seed as i32);
3819            let channels = ((seed >> 32) % 4096 + 1) as i32;
3820            let boundary = (MAX_AUDIO_BYTES / 4) as i32 / channels;
3821            for samples in [boundary - 1, boundary, boundary + 1] {
3822                check(channels, samples);
3823            }
3824        }
3825        // The signed stride boundary is rejected independently of the cap.
3826        let at_stride_limit = validate_packed_audio_dimensions(1, i32::MAX / 4);
3827        assert!(at_stride_limit
3828            .err()
3829            .unwrap()
3830            .to_string()
3831            .contains("maximum size"));
3832        let over_stride_limit = validate_packed_audio_dimensions(1, i32::MAX / 4 + 1);
3833        assert!(over_stride_limit
3834            .err()
3835            .unwrap()
3836            .to_string()
3837            .contains("stride"));
3838    }
3839
3840    /// Compare the old full-span copy with normalized active-row copies using
3841    /// initialized storage. Run with `cargo test --release --lib
3842    /// video_padding_throughput -- --ignored --nocapture`.
3843    #[test]
3844    #[ignore = "Manual optimized video throughput comparison"]
3845    fn video_padding_throughput() {
3846        use crate::capture::{FrameSyncVideoFree, Guard};
3847        use std::{hint::black_box, time::Instant};
3848        for (width, height) in [(1920, 1080), (3840, 2160)] {
3849            for padding in [0, 64] {
3850                let stride = width * 4 + padding;
3851                let mut storage = vec![17u8; (stride * height) as usize];
3852                let raw = NDIlib_video_frame_v2_t {
3853                    xres: width,
3854                    yres: height,
3855                    FourCC: PixelFormat::BGRA.into(),
3856                    frame_rate_N: 60,
3857                    frame_rate_D: 1,
3858                    frame_format_type: ScanType::Progressive.into(),
3859                    p_data: storage.as_mut_ptr(),
3860                    __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3861                        line_stride_in_bytes: stride,
3862                    },
3863                    ..Default::default()
3864                };
3865                let frame = unsafe {
3866                    VideoRef::new(Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw))
3867                }
3868                .unwrap();
3869                let iterations = 100;
3870                // Warm allocation and page caches for both implementations.
3871                black_box(storage.clone());
3872                black_box(frame.to_owned().unwrap());
3873                let start = Instant::now();
3874                for _ in 0..iterations {
3875                    black_box(black_box(&storage).clone());
3876                }
3877                let baseline = start.elapsed();
3878                let start = Instant::now();
3879                for _ in 0..iterations {
3880                    black_box(black_box(&frame).to_owned().unwrap());
3881                }
3882                let packed = start.elapsed();
3883                let start = Instant::now();
3884                for _ in 0..iterations * 100 {
3885                    for plane in black_box(&frame).planes() {
3886                        for row in plane.rows() {
3887                            black_box(row);
3888                        }
3889                    }
3890                }
3891                let traversal = start.elapsed();
3892                eprintln!("{width}x{height} padding={padding}: old storage copy {:.3} ms/frame, active copy {:.3} ms/frame, row traversal {:.3} us/frame, copied {} active bytes",
3893                    baseline.as_secs_f64() * 1000.0 / f64::from(iterations),
3894                    packed.as_secs_f64() * 1000.0 / f64::from(iterations),
3895                    traversal.as_secs_f64() * 1_000_000.0 / f64::from(iterations * 100),
3896                    frame.layout.payload_len_bytes);
3897            }
3898        }
3899    }
3900
3901    #[test]
3902    fn video_padding_all_formats_expose_and_copy_only_initialized_rows() {
3903        use crate::capture::{FrameSyncVideoFree, Guard};
3904        use std::mem::MaybeUninit;
3905        // Independently specified active plane dimensions for 4x4 images.
3906        let cases: &[(PixelFormat, &[(usize, usize)])] = &[
3907            (PixelFormat::BGRA, &[(16, 4)]),
3908            (PixelFormat::BGRX, &[(16, 4)]),
3909            (PixelFormat::RGBA, &[(16, 4)]),
3910            (PixelFormat::RGBX, &[(16, 4)]),
3911            (PixelFormat::UYVY, &[(8, 4)]),
3912            (PixelFormat::UYVA, &[(8, 4), (4, 4)]),
3913            (PixelFormat::P216, &[(8, 4), (8, 4)]),
3914            (PixelFormat::PA16, &[(8, 4), (8, 4), (8, 4)]),
3915            (PixelFormat::YV12, &[(4, 4), (2, 2), (2, 2)]),
3916            (PixelFormat::I420, &[(4, 4), (2, 2), (2, 2)]),
3917            (PixelFormat::NV12, &[(4, 4), (4, 2)]),
3918        ];
3919        for &(format, dimensions) in cases {
3920            for padding in [0, 4, 8] {
3921                let stride = dimensions[0].0 + padding;
3922                let mut storage = vec![MaybeUninit::<u8>::uninit(); 512];
3923                let mut expected = Vec::new();
3924                let mut offsets = Vec::new();
3925                let mut offset = 0;
3926                for (i, &(active, rows)) in dimensions.iter().enumerate() {
3927                    let plane_stride = stride * active / dimensions[0].0;
3928                    offsets.push(offset);
3929                    for row in 0..rows {
3930                        for byte in 0..active {
3931                            let value = (i * 71 + row * 17 + byte) as u8;
3932                            storage[offset + row * plane_stride + byte].write(value);
3933                            expected.push(value);
3934                        }
3935                    }
3936                    offset += plane_stride * rows;
3937                }
3938                let metadata = CString::new("<video-padding/>").unwrap();
3939                let raw = NDIlib_video_frame_v2_t {
3940                    xres: 4,
3941                    yres: 4,
3942                    FourCC: format.into(),
3943                    frame_rate_N: 30000,
3944                    frame_rate_D: 1001,
3945                    picture_aspect_ratio: 1.25,
3946                    frame_format_type: ScanType::Interlaced.into(),
3947                    timecode: 12345,
3948                    timestamp: 67890,
3949                    p_data: storage.as_mut_ptr().cast(),
3950                    p_metadata: metadata.as_ptr(),
3951                    __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
3952                        line_stride_in_bytes: stride as i32,
3953                    },
3954                };
3955                // Null FrameSync guards skip SDK calls; storage and metadata
3956                // live until both independent guards and their views are gone.
3957                let make = || unsafe {
3958                    VideoRef::new(Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw)).unwrap()
3959                };
3960                let (frame, allocations) = crate::test_alloc::allocation_count(make);
3961                assert_eq!(allocations, 0);
3962                let second = make();
3963                let ((), allocations) = crate::test_alloc::allocation_count(|| {
3964                    assert_eq!(frame.planes().len(), dimensions.len());
3965                    assert!(frame.plane(dimensions.len()).is_none());
3966                    assert_eq!(frame.data().is_some(), padding == 0);
3967                    if let Some(data) = frame.data() {
3968                        assert_eq!(data, expected);
3969                    }
3970                    let mut position = 0;
3971                    for (i, plane) in frame.planes().enumerate() {
3972                        let (active, rows) = dimensions[i];
3973                        assert_eq!(plane.row_count(), rows);
3974                        assert_eq!(plane.active_row_bytes(), active);
3975                        assert_eq!(plane.row_stride(), stride * active / dimensions[0].0);
3976                        assert_eq!(plane.data().is_some(), padding == 0);
3977                        assert!(plane.row(rows).is_none());
3978                        let mut iterator = plane.rows();
3979                        assert_eq!(iterator.len(), rows);
3980                        assert_eq!(iterator.next_back(), plane.row(rows - 1));
3981                        assert_eq!(iterator.len(), rows - 1);
3982                        for (row_index, row) in plane.rows().enumerate() {
3983                            assert_eq!(row.as_ptr(), unsafe {
3984                                storage
3985                                    .as_ptr()
3986                                    .cast::<u8>()
3987                                    .add(offsets[i] + row_index * plane.row_stride())
3988                            });
3989                            assert_eq!(row, &expected[position..position + active]);
3990                            position += active;
3991                        }
3992                    }
3993                    assert_eq!(position, expected.len());
3994                });
3995                assert_eq!(allocations, 0);
3996                // Preconstructed views and independent iterator clones can be
3997                // moved/shared while this thread retains the FrameSync guard.
3998                let planes = frame.planes();
3999                let moved_planes = planes.clone();
4000                let expected_sum: u64 = expected.iter().map(|&b| u64::from(b)).sum();
4001                std::thread::scope(|scope| {
4002                    let shared = &planes;
4003                    let reader = scope.spawn(move || {
4004                        shared
4005                            .clone()
4006                            .flat_map(|p| p.rows())
4007                            .flatten()
4008                            .map(|&b| u64::from(b))
4009                            .sum::<u64>()
4010                    });
4011                    let moved = scope.spawn(move || {
4012                        moved_planes
4013                            .flat_map(|p| p.rows().rev())
4014                            .flatten()
4015                            .map(|&b| u64::from(b))
4016                            .sum::<u64>()
4017                    });
4018                    assert_eq!(reader.join().unwrap(), expected_sum);
4019                    assert_eq!(moved.join().unwrap(), expected_sum);
4020                });
4021                for plane in frame.planes() {
4022                    let rows = plane.rows();
4023                    let clone = rows.clone();
4024                    let serial = rows.clone().flatten().copied().collect::<Vec<_>>();
4025                    std::thread::scope(|scope| {
4026                        let shared = &plane;
4027                        let reader = scope
4028                            .spawn(move || shared.rows().flatten().copied().collect::<Vec<_>>());
4029                        let moved =
4030                            scope.spawn(move || rows.flatten().copied().collect::<Vec<_>>());
4031                        let reversed = scope.spawn(move || {
4032                            clone.rev().rev().flatten().copied().collect::<Vec<_>>()
4033                        });
4034                        assert_eq!(reader.join().unwrap(), serial);
4035                        assert_eq!(moved.join().unwrap(), serial);
4036                        assert_eq!(reversed.join().unwrap(), serial);
4037                    });
4038                }
4039                assert_eq!(frame.layout.data_len_bytes, offset);
4040                assert_eq!(frame.layout.payload_len_bytes, expected.len());
4041                let owned = frame.to_owned().unwrap();
4042                let raw_owned = unsafe { VideoFrame::from_raw(&raw) }.unwrap();
4043                assert_eq!(owned.data(), expected);
4044                assert_eq!(raw_owned.data(), expected);
4045                assert_eq!(owned.pixel_format(), format);
4046                assert_eq!(
4047                    owned.line_stride_or_size(),
4048                    LineStrideOrSize::LineStrideBytes(dimensions[0].0 as i32)
4049                );
4050                assert_eq!(owned.metadata(), Some("<video-padding/>"));
4051                assert_eq!((owned.frame_rate_n(), owned.frame_rate_d()), (30000, 1001));
4052                assert_eq!(owned.picture_aspect_ratio(), 1.25);
4053                assert_eq!(owned.scan_type(), ScanType::Interlaced);
4054                assert_eq!((owned.timecode(), owned.timestamp()), (12345, 67890));
4055                drop(frame);
4056                assert_eq!(
4057                    second.plane(0).unwrap().row(0).unwrap(),
4058                    &expected[..dimensions[0].0]
4059                );
4060                // No metadata: exactly one allocation for active owned pixels.
4061                let raw = NDIlib_video_frame_v2_t {
4062                    p_metadata: ptr::null(),
4063                    ..raw
4064                };
4065                let (copied, allocations) = crate::test_alloc::allocation_count(|| {
4066                    unsafe { VideoFrame::from_raw(&raw) }.unwrap()
4067                });
4068                assert_eq!(allocations, 1);
4069                assert_eq!(copied.data(), expected);
4070                assert!(VideoFrame::builder()
4071                    .resolution(4, 4)
4072                    .pixel_format(format)
4073                    .data(vec![0; expected.len() - 1])
4074                    .build()
4075                    .is_err());
4076                assert!(crate::BorrowedVideoFrame::try_from_uncompressed(
4077                    &vec![0; expected.len() - 1],
4078                    4,
4079                    4,
4080                    format,
4081                    30,
4082                    1,
4083                )
4084                .is_err());
4085                // Copied-send conversion must use the same normalization.
4086                // This send-side fixture initializes padding too, because its
4087                // constructor takes an already initialized application slice.
4088                let mut initialized = vec![99; offset];
4089                let mut position = 0;
4090                for (i, &(active, rows)) in dimensions.iter().enumerate() {
4091                    let plane_stride = stride * active / dimensions[0].0;
4092                    for row in 0..rows {
4093                        let start = offsets[i] + row * plane_stride;
4094                        initialized[start..start + active]
4095                            .copy_from_slice(&expected[position..position + active]);
4096                        position += active;
4097                    }
4098                }
4099                let send = crate::BorrowedVideoFrame::builder(&initialized, 4, 4, format)
4100                    .frame_rate(30000, 1001)
4101                    .picture_aspect_ratio(1.25)
4102                    .scan_type(ScanType::Interlaced)
4103                    .timecode(12345)
4104                    .line_stride(stride as i32)
4105                    .metadata(&metadata)
4106                    .timestamp(67890)
4107                    .build()
4108                    .unwrap();
4109                let normalized = send.try_to_owned().unwrap();
4110                assert_eq!(normalized.data(), expected);
4111                assert_eq!(
4112                    normalized.line_stride_or_size(),
4113                    copied.line_stride_or_size()
4114                );
4115                assert_eq!(normalized.metadata(), Some("<video-padding/>"));
4116            }
4117        }
4118    }
4119
4120    #[test]
4121    fn video_padding_single_rows_and_zero_stride() {
4122        use crate::capture::{FrameSyncVideoFree, Guard};
4123        use std::mem::MaybeUninit;
4124        for format in [
4125            PixelFormat::BGRA,
4126            PixelFormat::BGRX,
4127            PixelFormat::RGBA,
4128            PixelFormat::RGBX,
4129            PixelFormat::UYVY,
4130            PixelFormat::UYVA,
4131            PixelFormat::P216,
4132            PixelFormat::PA16,
4133        ] {
4134            let tight = ValidatedVideoLayout::new_uncompressed(format, 2, 1, Some(0)).unwrap();
4135            assert!(tight.is_contiguous());
4136            let active = format.try_line_stride(2).unwrap();
4137            let padded =
4138                ValidatedVideoLayout::new_uncompressed(format, 2, 1, Some(active + 4)).unwrap();
4139            let mut storage = [MaybeUninit::<u8>::uninit(); 64];
4140            for plane in padded.planes() {
4141                for byte in &mut storage[plane.offset..plane.offset + plane.active_row_bytes] {
4142                    byte.write(42);
4143                }
4144            }
4145            let raw = NDIlib_video_frame_v2_t {
4146                xres: 2,
4147                yres: 1,
4148                FourCC: format.into(),
4149                frame_rate_N: 30,
4150                frame_rate_D: 1,
4151                p_data: storage.as_mut_ptr().cast(),
4152                frame_format_type: ScanType::Progressive.into(),
4153                __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4154                    line_stride_in_bytes: active + 4,
4155                },
4156                ..Default::default()
4157            };
4158            let frame =
4159                unsafe { VideoRef::new(Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw)) }
4160                    .unwrap();
4161            assert_eq!(frame.data().is_some(), padded.plane_count == 1);
4162            for plane in frame.planes() {
4163                assert_eq!(plane.data().unwrap(), vec![42; plane.active_row_bytes()]);
4164            }
4165            assert_eq!(
4166                frame.to_owned().unwrap().data(),
4167                vec![42; tight.payload_len_bytes]
4168            );
4169        }
4170        for format in [PixelFormat::YV12, PixelFormat::I420, PixelFormat::NV12] {
4171            let tight = ValidatedVideoLayout::new_uncompressed(format, 4, 2, Some(0)).unwrap();
4172            assert!(tight.is_contiguous());
4173            let padded = ValidatedVideoLayout::new_uncompressed(format, 4, 2, Some(8)).unwrap();
4174            assert!(!padded.is_contiguous()); // Chroma is one row, but plane gaps remain.
4175        }
4176    }
4177
4178    #[cfg(feature = "image-encoding")]
4179    #[test]
4180    fn video_padding_encoders_never_read_uninitialized_gaps() {
4181        use crate::capture::{FrameSyncVideoFree, Guard};
4182        use std::mem::MaybeUninit;
4183        for format in [
4184            PixelFormat::BGRA,
4185            PixelFormat::BGRX,
4186            PixelFormat::RGBA,
4187            PixelFormat::RGBX,
4188        ] {
4189            let mut storage = [MaybeUninit::<u8>::uninit(); 24];
4190            let pixels = [1, 2, 3, 255, 5, 6, 7, 255, 9, 10, 11, 255, 13, 14, 15, 255];
4191            for row in 0..2 {
4192                for byte in 0..8 {
4193                    storage[row * 12 + byte].write(pixels[row * 8 + byte]);
4194                }
4195            }
4196            let raw = NDIlib_video_frame_v2_t {
4197                xres: 2,
4198                yres: 2,
4199                FourCC: format.into(),
4200                frame_rate_N: 30,
4201                frame_rate_D: 1,
4202                frame_format_type: ScanType::Progressive.into(),
4203                p_data: storage.as_mut_ptr().cast(),
4204                __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4205                    line_stride_in_bytes: 12,
4206                },
4207                ..Default::default()
4208            };
4209            let borrowed =
4210                unsafe { VideoRef::new(Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw)) }
4211                    .unwrap();
4212            let packed = image_test_frame(format, 2, 2, None, pixels.to_vec());
4213            let png = borrowed.encode_png().unwrap();
4214            assert_eq!(
4215                decode_png_rgba(&png),
4216                decode_png_rgba(&packed.encode_png().unwrap())
4217            );
4218            assert_eq!(
4219                borrowed.encode_jpeg(85).unwrap(),
4220                packed.encode_jpeg(85).unwrap()
4221            );
4222            assert_eq!(
4223                borrowed.encode_data_url(ImageFormat::Png).unwrap(),
4224                packed.encode_data_url(ImageFormat::Png).unwrap()
4225            );
4226            assert_eq!(borrowed.to_owned().unwrap().data(), pixels);
4227        }
4228    }
4229
4230    #[test]
4231    fn video_padding_reported_642_pixel_reproduction() {
4232        use crate::capture::{FrameSyncVideoFree, Guard};
4233        use std::mem::MaybeUninit;
4234        let mut storage = vec![MaybeUninit::<u8>::uninit(); 2592 * 32];
4235        for row in 0..32 {
4236            for byte in &mut storage[row * 2592..row * 2592 + 2568] {
4237                byte.write(17);
4238            }
4239        }
4240        let raw = NDIlib_video_frame_v2_t {
4241            xres: 642,
4242            yres: 32,
4243            FourCC: PixelFormat::BGRA.into(),
4244            frame_rate_N: 30,
4245            frame_rate_D: 1,
4246            frame_format_type: ScanType::Progressive.into(),
4247            p_data: storage.as_mut_ptr().cast(),
4248            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
4249                line_stride_in_bytes: 2592,
4250            },
4251            ..Default::default()
4252        };
4253        let frame =
4254            unsafe { VideoRef::new(Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw)) }
4255                .unwrap();
4256        assert!(frame.data().is_none());
4257        let owned = frame.to_owned().unwrap();
4258        assert_eq!(owned.data().len(), 82_176);
4259        assert_eq!(
4260            owned.line_stride_or_size(),
4261            LineStrideOrSize::LineStrideBytes(2568)
4262        );
4263        assert!(owned.data().iter().all(|&byte| byte == 17));
4264    }
4265
4266    #[test]
4267    fn video_default_matches_builder_and_reuse_updates_preserve_pixels() {
4268        let default = VideoFrame::default();
4269        let built = VideoFrame::builder().build().unwrap();
4270        assert_eq!(default.scan_type(), built.scan_type());
4271        assert_eq!(default.scan_type(), ScanType::Progressive);
4272        assert_eq!(default.frame_rate_n(), built.frame_rate_n());
4273        assert_eq!(default.timecode(), built.timecode());
4274        let mut frame = VideoFrame::builder().resolution(2, 2).build().unwrap();
4275        let pixels = frame.data().as_ptr();
4276        frame.set_timecode(123);
4277        frame.set_timestamp(456);
4278        frame.set_scan_type(ScanType::Field1);
4279        let raw = frame.to_raw();
4280        assert_eq!(raw.timecode, 123);
4281        assert_eq!(raw.timestamp, 456);
4282        assert_eq!(frame.scan_type(), ScanType::Field1);
4283        assert_eq!(frame.data().as_ptr(), pixels);
4284    }
4285
4286    #[test]
4287    fn video_builder_retains_supplied_allocation_and_rejects_wrong_lengths() {
4288        let data = vec![37; 16];
4289        let pixels = data.as_ptr();
4290        let frame = VideoFrame::builder()
4291            .resolution(2, 2)
4292            .data(data)
4293            .build()
4294            .unwrap();
4295        assert_eq!(frame.data().as_ptr(), pixels);
4296        assert_eq!(frame.data(), &[37; 16]);
4297        for len in [0, 15, 17] {
4298            assert!(VideoFrame::builder()
4299                .resolution(2, 2)
4300                .data(vec![0; len])
4301                .build()
4302                .is_err());
4303        }
4304    }
4305
4306    #[cfg(feature = "image-encoding")]
4307    #[test]
4308    fn borrowed_video_encoding_uses_padded_pixels_without_owned_conversion() {
4309        use crate::capture::{FrameSyncVideoFree, Guard};
4310        let frame = image_test_frame(
4311            PixelFormat::BGRA,
4312            2,
4313            2,
4314            Some(12),
4315            vec![
4316                0, 0, 255, 255, 0, 255, 0, 128, 99, 99, 99, 99, 255, 0, 0, 64, 255, 255, 255, 0,
4317                99, 99, 99, 99,
4318            ],
4319        );
4320        // The null-instance FrameSync strategy never frees these test-owned
4321        // pixels. The owned source stays alive for the borrowed view's use.
4322        let guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), frame.to_raw()) };
4323        let borrowed = unsafe { VideoRef::new(guard) }.unwrap();
4324        assert!(borrowed.data().is_none());
4325        assert_eq!(
4326            borrowed.plane(0).unwrap().row(0).unwrap().as_ptr(),
4327            frame.data().as_ptr()
4328        );
4329        let png = borrowed.encode_png().unwrap();
4330        assert_eq!(
4331            decode_png_rgba(&png),
4332            (
4333                2,
4334                2,
4335                vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 64, 255, 255, 255, 0,]
4336            )
4337        );
4338        assert_eq!(png, frame.encode_png().unwrap());
4339        assert_eq!(
4340            borrowed.encode_jpeg(85).unwrap(),
4341            frame.encode_jpeg(85).unwrap()
4342        );
4343        assert_eq!(
4344            borrowed.encode_data_url(ImageFormat::Png).unwrap(),
4345            frame.encode_data_url(ImageFormat::Png).unwrap()
4346        );
4347        assert_eq!(
4348            borrowed.encode_data_url(ImageFormat::Jpeg(85)).unwrap(),
4349            frame.encode_data_url(ImageFormat::Jpeg(85)).unwrap()
4350        );
4351        assert!(borrowed.encode_jpeg(0).is_err());
4352
4353        let unsupported = VideoFrame::builder()
4354            .resolution(2, 2)
4355            .pixel_format(PixelFormat::UYVY)
4356            .build()
4357            .unwrap();
4358        let guard =
4359            unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), unsupported.to_raw()) };
4360        let borrowed = unsafe { VideoRef::new(guard) }.unwrap();
4361        assert!(borrowed.encode_png().is_err());
4362        assert!(borrowed.encode_jpeg(85).is_err());
4363    }
4364
4365    #[cfg(feature = "image-encoding")]
4366    fn image_test_frame(
4367        pixel_format: PixelFormat,
4368        width: i32,
4369        height: i32,
4370        line_stride: Option<i32>,
4371        data: Vec<u8>,
4372    ) -> VideoFrame {
4373        let layout =
4374            ValidatedVideoLayout::new_uncompressed(pixel_format, width, height, line_stride)
4375                .unwrap();
4376        assert_eq!(data.len(), layout.data_len_bytes);
4377
4378        VideoFrame {
4379            layout,
4380            frame_rate_n: 60,
4381            frame_rate_d: 1,
4382            picture_aspect_ratio: 16.0 / 9.0,
4383            scan_type: ScanType::Progressive,
4384            timecode: 0,
4385            data,
4386            metadata: None,
4387            timestamp: 0,
4388        }
4389    }
4390
4391    #[cfg(feature = "image-encoding")]
4392    fn decode_png_rgba(png_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
4393        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
4394        let mut reader = decoder.read_info().unwrap();
4395        let output_size = reader.output_buffer_size().unwrap();
4396        let mut output = vec![0; output_size];
4397        let info = reader.next_frame(&mut output).unwrap();
4398        output.truncate(info.buffer_size());
4399
4400        assert_eq!(info.color_type, png::ColorType::Rgba);
4401        assert_eq!(info.bit_depth, png::BitDepth::Eight);
4402
4403        (info.width, info.height, output)
4404    }
4405
4406    #[cfg(feature = "image-encoding")]
4407    fn assert_jpeg_markers(jpeg_bytes: &[u8]) {
4408        assert!(jpeg_bytes.len() >= 4);
4409        assert_eq!(&jpeg_bytes[..2], &[0xFF, 0xD8]);
4410        assert_eq!(&jpeg_bytes[jpeg_bytes.len() - 2..], &[0xFF, 0xD9]);
4411    }
4412
4413    #[cfg(feature = "image-encoding")]
4414    #[test]
4415    fn test_encode_png_decodes_exact_supported_pixels() {
4416        let cases = [
4417            (
4418                PixelFormat::RGBA,
4419                vec![10, 20, 30, 40, 50, 60, 70, 80],
4420                vec![10, 20, 30, 40, 50, 60, 70, 80],
4421            ),
4422            (
4423                PixelFormat::BGRA,
4424                vec![30, 20, 10, 40, 70, 60, 50, 80],
4425                vec![10, 20, 30, 40, 50, 60, 70, 80],
4426            ),
4427            (
4428                PixelFormat::RGBX,
4429                vec![10, 20, 30, 0, 50, 60, 70, 99],
4430                vec![10, 20, 30, 255, 50, 60, 70, 255],
4431            ),
4432            (
4433                PixelFormat::BGRX,
4434                vec![30, 20, 10, 0, 70, 60, 50, 99],
4435                vec![10, 20, 30, 255, 50, 60, 70, 255],
4436            ),
4437        ];
4438
4439        for (pixel_format, data, expected) in cases {
4440            let frame = image_test_frame(pixel_format, 2, 1, None, data);
4441            let png = frame.encode_png().unwrap();
4442            let (width, height, decoded) = decode_png_rgba(&png);
4443
4444            assert_eq!((width, height), (2, 1), "{pixel_format:?}");
4445            assert_eq!(decoded, expected, "{pixel_format:?}");
4446        }
4447    }
4448
4449    #[cfg(feature = "image-encoding")]
4450    #[test]
4451    fn test_encode_png_skips_padded_rows() {
4452        let data = vec![
4453            1, 2, 3, 0, 4, 5, 6, 0, 200, 201, 202, 203, 7, 8, 9, 0, 10, 11, 12, 0, 204, 205, 206,
4454            207,
4455        ];
4456        let frame = image_test_frame(PixelFormat::RGBX, 2, 2, Some(12), data);
4457
4458        let png = frame.encode_png().unwrap();
4459        let (width, height, decoded) = decode_png_rgba(&png);
4460
4461        assert_eq!((width, height), (2, 2));
4462        assert_eq!(
4463            decoded,
4464            vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255,]
4465        );
4466    }
4467
4468    #[cfg(feature = "image-encoding")]
4469    #[test]
4470    fn test_encode_jpeg_accepts_supported_image_formats() {
4471        let cases = [
4472            (PixelFormat::RGBA, vec![10, 20, 30, 40]),
4473            (PixelFormat::BGRA, vec![30, 20, 10, 40]),
4474            (PixelFormat::RGBX, vec![10, 20, 30, 0]),
4475            (PixelFormat::BGRX, vec![30, 20, 10, 0]),
4476        ];
4477
4478        for (pixel_format, pixel) in cases {
4479            let data = pixel.repeat(16);
4480            let frame = image_test_frame(pixel_format, 4, 4, None, data);
4481            let jpeg = frame.encode_jpeg(90).unwrap();
4482            assert_jpeg_markers(&jpeg);
4483        }
4484    }
4485
4486    #[cfg(feature = "image-encoding")]
4487    #[test]
4488    fn test_encode_jpeg_skips_padded_rows() {
4489        let data = vec![
4490            3, 2, 1, 0, 6, 5, 4, 0, 250, 251, 252, 253, 9, 8, 7, 0, 12, 11, 10, 0, 254, 255, 0, 1,
4491        ];
4492        let frame = image_test_frame(PixelFormat::BGRX, 2, 2, Some(12), data);
4493
4494        let jpeg = frame.encode_jpeg(85).unwrap();
4495        assert_jpeg_markers(&jpeg);
4496    }
4497
4498    #[cfg(feature = "image-encoding")]
4499    #[test]
4500    fn test_encode_jpeg_rejects_invalid_quality() {
4501        let frame = image_test_frame(PixelFormat::RGBA, 2, 2, None, vec![0; 16]);
4502
4503        for quality in [0, 101, 255] {
4504            let err = frame.encode_jpeg(quality).unwrap_err();
4505            let err_msg = err.to_string();
4506            assert!(
4507                matches!(&err, Error::InvalidFrame(message) if message.contains("quality")),
4508                "unexpected error for quality {quality}: {err_msg}"
4509            );
4510        }
4511    }
4512
4513    #[cfg(feature = "image-encoding")]
4514    #[test]
4515    fn test_encode_jpeg_rejects_oversized_dimensions_before_casting() {
4516        let wide = image_test_frame(PixelFormat::RGBA, 65_536, 1, None, vec![0; 65_536 * 4]);
4517        let err = wide.encode_jpeg(85).unwrap_err();
4518        let err_msg = err.to_string();
4519        assert!(
4520            matches!(&err, Error::InvalidFrame(message) if message.contains("JPEG width")),
4521            "unexpected error: {err_msg}"
4522        );
4523
4524        let tall = image_test_frame(PixelFormat::RGBA, 2, 65_536, None, vec![0; 65_536 * 8]);
4525        let err = tall.encode_jpeg(85).unwrap_err();
4526        let err_msg = err.to_string();
4527        assert!(
4528            matches!(&err, Error::InvalidFrame(message) if message.contains("JPEG height")),
4529            "unexpected error: {err_msg}"
4530        );
4531    }
4532
4533    #[cfg(feature = "image-encoding")]
4534    #[test]
4535    fn test_encode_jpeg_rejects_unsupported_format() {
4536        let frame = image_test_frame(PixelFormat::UYVY, 2, 2, None, vec![0; 8]);
4537
4538        let err = frame.encode_jpeg(85).unwrap_err();
4539        let err_msg = err.to_string();
4540        assert!(
4541            matches!(&err, Error::InvalidFrame(message) if message.contains("Unsupported format")),
4542            "unexpected error: {err_msg}"
4543        );
4544    }
4545
4546    #[cfg(feature = "image-encoding")]
4547    #[test]
4548    fn png_conversion_scratch_is_bounded_by_one_row() {
4549        for format in [
4550            PixelFormat::RGBA,
4551            PixelFormat::BGRA,
4552            PixelFormat::RGBX,
4553            PixelFormat::BGRX,
4554        ] {
4555            for height in [1, 19, 257] {
4556                let frame =
4557                    image_test_frame(format, 18, height, Some(84), vec![42; 84 * height as usize]);
4558                let source = ImagePixelSource::new(frame.layout, frame.data()).unwrap();
4559                let (scratch, allocations) =
4560                    crate::test_alloc::allocation_count(|| source.png_conversion_row().unwrap());
4561                let expected = if format == PixelFormat::RGBA {
4562                    0
4563                } else {
4564                    18 * 4
4565                };
4566                assert_eq!(scratch.len(), expected);
4567                assert_eq!(scratch.capacity(), expected);
4568                assert_eq!(allocations, usize::from(expected != 0));
4569                // The JPEG adapter borrows only the source; all component buffers
4570                // belong to the codec. Pre-reserving them proves conversion itself
4571                // allocates nothing, independently of image height.
4572                let mut buffers = std::array::from_fn(|_| Vec::with_capacity(18));
4573                let adapter = JpegRows {
4574                    source: &source,
4575                    width: 18,
4576                    height: height as u16,
4577                };
4578                let (_, allocations) = crate::test_alloc::allocation_count(|| {
4579                    for row in 0..height as u16 {
4580                        for buffer in &mut buffers {
4581                            buffer.clear();
4582                        }
4583                        jpeg_encoder::ImageBuffer::fill_buffers(&adapter, row, &mut buffers);
4584                    }
4585                });
4586                assert_eq!(allocations, 0);
4587            }
4588        }
4589    }
4590
4591    /// Manual resource comparison with the pre-2.0 row-staging implementation.
4592    /// `cargo test --release --lib image_encoding_resources -- --ignored --nocapture`
4593    #[cfg(feature = "image-encoding")]
4594    #[test]
4595    #[ignore = "Manual optimized image time/peak-allocation comparison"]
4596    fn image_encoding_resources() {
4597        use std::{hint::black_box, time::Instant};
4598        fn staged(source: &ImagePixelSource<'_>, format: ImageFormat) -> Vec<u8> {
4599            match format {
4600                ImageFormat::Png => {
4601                    let mut pixels = Vec::with_capacity(source.width * source.height * 4);
4602                    for row in source.plane.rows() {
4603                        for pixel in row.as_chunks::<4>().0 {
4604                            pixels.extend_from_slice(&source.pixel_format.rgba_pixel(pixel));
4605                        }
4606                    }
4607                    let mut output = Vec::new();
4608                    let mut encoder =
4609                        png::Encoder::new(&mut output, source.width as u32, source.height as u32);
4610                    encoder.set_color(png::ColorType::Rgba);
4611                    encoder.set_depth(png::BitDepth::Eight);
4612                    encoder
4613                        .write_header()
4614                        .unwrap()
4615                        .write_image_data(&pixels)
4616                        .unwrap();
4617                    output
4618                }
4619                ImageFormat::Jpeg(quality) => {
4620                    let mut pixels = Vec::with_capacity(source.width * source.height * 3);
4621                    for row in source.plane.rows() {
4622                        for pixel in row.as_chunks::<4>().0 {
4623                            pixels.extend_from_slice(&source.pixel_format.rgb_pixel(pixel));
4624                        }
4625                    }
4626                    let mut output = Vec::new();
4627                    jpeg_encoder::Encoder::new(&mut output, quality)
4628                        .encode(
4629                            &pixels,
4630                            source.width as u16,
4631                            source.height as u16,
4632                            jpeg_encoder::ColorType::Rgb,
4633                        )
4634                        .unwrap();
4635                    output
4636                }
4637            }
4638        }
4639        eprintln!(
4640            "width,height,padding,entropy,format,path,median_ms,peak_encode_bytes,output_bytes"
4641        );
4642        for (width, height) in [(1920, 1080), (3840, 2160), (7680, 4320)] {
4643            for padding in [0, 64] {
4644                for entropy in [false, true] {
4645                    let stride = width * 4 + padding;
4646                    let mut data = vec![42; stride * height];
4647                    if entropy {
4648                        let mut seed = 118u64;
4649                        for row in data.chunks_exact_mut(stride) {
4650                            for pixel in row[..width * 4].as_chunks_mut::<4>().0 {
4651                                for byte in &mut pixel[..3] {
4652                                    seed ^= seed << 13;
4653                                    seed ^= seed >> 7;
4654                                    seed ^= seed << 17;
4655                                    *byte = seed as u8;
4656                                }
4657                                pixel[3] = 255;
4658                            }
4659                        }
4660                    }
4661                    let frame = image_test_frame(
4662                        PixelFormat::BGRA,
4663                        width as i32,
4664                        height as i32,
4665                        Some(stride as i32),
4666                        data,
4667                    );
4668                    let source = ImagePixelSource::new(frame.layout, frame.data()).unwrap();
4669                    for format in [
4670                        ImageFormat::Png,
4671                        ImageFormat::Jpeg(1),
4672                        ImageFormat::Jpeg(85),
4673                        ImageFormat::Jpeg(100),
4674                    ] {
4675                        for old in [true, false] {
4676                            let mut times = [0.0f64; 3];
4677                            let mut peak = 0;
4678                            let mut output_bytes = 0;
4679                            for time in &mut times {
4680                                let start = Instant::now();
4681                                let (output, bytes) = crate::test_alloc::peak_bytes(|| {
4682                                    if old {
4683                                        staged(black_box(&source), format)
4684                                    } else {
4685                                        match format {
4686                                            ImageFormat::Png => source.encode_png().unwrap(),
4687                                            ImageFormat::Jpeg(q) => source.encode_jpeg(q).unwrap(),
4688                                        }
4689                                    }
4690                                });
4691                                *time = start.elapsed().as_secs_f64() * 1000.0;
4692                                peak = peak.max(bytes);
4693                                output_bytes = output.len();
4694                                black_box(output);
4695                            }
4696                            times.sort_by(f64::total_cmp);
4697                            eprintln!("{width},{height},{padding},{entropy},{format:?},{},{:.3},{peak},{output_bytes}", if old { "staged" } else { "rows" }, times[1]);
4698                        }
4699                    }
4700                }
4701            }
4702        }
4703    }
4704
4705    #[cfg(feature = "image-encoding")]
4706    #[test]
4707    fn streamed_images_match_independently_packed_pixels() {
4708        for format in [
4709            PixelFormat::RGBA,
4710            PixelFormat::BGRA,
4711            PixelFormat::RGBX,
4712            PixelFormat::BGRX,
4713        ] {
4714            for (width, height) in [(2, 1), (2, 2), (18, 19), (64, 33)] {
4715                for padding in [0, 12] {
4716                    let stride = width * 4 + padding;
4717                    let mut data = vec![0xCD; stride * height];
4718                    let mut rgba = Vec::new();
4719                    let mut rgb = Vec::new();
4720                    for y in 0..height {
4721                        for x in 0..width {
4722                            let r = (x * 17 + y * 7) as u8;
4723                            let g = (x * 31 + y * 13) as u8;
4724                            let b = (x * 43 + y * 19) as u8;
4725                            let a = (x * 59 + y * 23) as u8;
4726                            let pixel = if matches!(format, PixelFormat::BGRA | PixelFormat::BGRX) {
4727                                [b, g, r, a]
4728                            } else {
4729                                [r, g, b, a]
4730                            };
4731                            data[y * stride + x * 4..y * stride + x * 4 + 4]
4732                                .copy_from_slice(&pixel);
4733                            rgba.extend_from_slice(&[
4734                                r,
4735                                g,
4736                                b,
4737                                if matches!(format, PixelFormat::RGBX | PixelFormat::BGRX) {
4738                                    255
4739                                } else {
4740                                    a
4741                                },
4742                            ]);
4743                            rgb.extend_from_slice(&[r, g, b]);
4744                        }
4745                    }
4746                    let frame = image_test_frame(
4747                        format,
4748                        width as i32,
4749                        height as i32,
4750                        Some(stride as i32),
4751                        data,
4752                    );
4753                    assert_eq!(
4754                        decode_png_rgba(&frame.encode_png().unwrap()),
4755                        (width as u32, height as u32, rgba)
4756                    );
4757                    for quality in [1, 85, 100] {
4758                        let mut baseline = Vec::new();
4759                        jpeg_encoder::Encoder::new(&mut baseline, quality)
4760                            .encode(
4761                                &rgb,
4762                                width as u16,
4763                                height as u16,
4764                                jpeg_encoder::ColorType::Rgb,
4765                            )
4766                            .unwrap();
4767                        assert_eq!(
4768                            frame.encode_jpeg(quality).unwrap(),
4769                            baseline,
4770                            "{format:?} {width}x{height} padding={padding} quality={quality}"
4771                        );
4772                    }
4773                    // X and row padding have no effect on any encoded pixels.
4774                    let mut changed = image_test_frame(
4775                        format,
4776                        width as i32,
4777                        height as i32,
4778                        Some(stride as i32),
4779                        frame.data().to_vec(),
4780                    );
4781                    for y in 0..height {
4782                        changed.data_mut()[y * stride + width * 4..(y + 1) * stride].fill(0x12);
4783                        if matches!(format, PixelFormat::RGBX | PixelFormat::BGRX) {
4784                            for x in 0..width {
4785                                changed.data_mut()[y * stride + x * 4 + 3] ^= 0xFF;
4786                            }
4787                        }
4788                    }
4789                    assert_eq!(frame.encode_png().unwrap(), changed.encode_png().unwrap());
4790                    assert_eq!(
4791                        frame.encode_jpeg(85).unwrap(),
4792                        changed.encode_jpeg(85).unwrap()
4793                    );
4794                }
4795            }
4796        }
4797    }
4798
4799    #[cfg(feature = "image-encoding")]
4800    #[test]
4801    fn test_image_pixel_source_rejects_bad_data_len() {
4802        let layout = ValidatedVideoLayout::new_uncompressed(PixelFormat::RGBA, 2, 1, None).unwrap();
4803        let err = ImagePixelSource::new(layout, &[0; 7]).unwrap_err();
4804        let err_msg = err.to_string();
4805        assert!(
4806            matches!(&err, Error::InvalidFrame(message) if message.contains("Video data length")),
4807            "unexpected error: {err_msg}"
4808        );
4809    }
4810
4811    /// Test PixelFormatInfo for packed RGB formats (32 bpp)
4812    #[test]
4813    fn test_pixel_format_info_packed_rgb() {
4814        let formats = [
4815            PixelFormat::BGRA,
4816            PixelFormat::BGRX,
4817            PixelFormat::RGBA,
4818            PixelFormat::RGBX,
4819        ];
4820
4821        for fmt in formats {
4822            let info = fmt.info();
4823            assert_eq!(
4824                info.bytes_per_pixel(),
4825                4,
4826                "Format {:?} bytes per pixel",
4827                fmt
4828            );
4829            assert_eq!(
4830                info.category(),
4831                FormatCategory::Packed,
4832                "Format {:?} category",
4833                fmt
4834            );
4835            assert!(
4836                !info.is_planar_420(),
4837                "Format {:?} should not be planar",
4838                fmt
4839            );
4840
4841            // 1920x1080, stride = 1920 * 4 = 7680
4842            let len = info.try_buffer_len(7680, 1080).unwrap();
4843            assert_eq!(len, 7680 * 1080, "Format {:?} even dimensions", fmt);
4844
4845            // Odd dimensions: 1921x1081
4846            let len = info.try_buffer_len(7684, 1081).unwrap();
4847            assert_eq!(len, 7684 * 1081, "Format {:?} odd dimensions", fmt);
4848        }
4849    }
4850
4851    /// Test PixelFormatInfo for packed YUV formats
4852    #[test]
4853    fn test_pixel_format_info_packed_yuv() {
4854        // UYVY: 16 bpp = 2 bytes per pixel
4855        let info = PixelFormat::UYVY.info();
4856        assert_eq!(info.bytes_per_pixel(), 2);
4857        assert_eq!(info.category(), FormatCategory::Packed);
4858        let len = info.try_buffer_len(3840, 1080).unwrap();
4859        assert_eq!(len, 3840 * 1080);
4860
4861        // UYVA: UYVY plane (2 bytes per pixel) + alpha plane at half stride
4862        let info = PixelFormat::UYVA.info();
4863        assert_eq!(info.bytes_per_pixel(), 2);
4864        assert_eq!(info.category(), FormatCategory::PackedAlpha);
4865        assert!(!info.is_planar_420());
4866        let len = info.try_buffer_len(3840, 1080).unwrap();
4867        assert_eq!(len, 3840 * 1080 + 1920 * 1080);
4868        // Padded stride: alpha plane follows at stride / 2
4869        let len = info.try_buffer_len(4096, 1080).unwrap();
4870        assert_eq!(len, 4096 * 1080 + 2048 * 1080);
4871        assert!(info.try_buffer_len(3841, 1080).is_err());
4872
4873        // P216: 16-bit Y plane + 16-bit CbCr plane, both at the line stride
4874        let info = PixelFormat::P216.info();
4875        assert_eq!(info.bytes_per_pixel(), 2);
4876        assert_eq!(info.category(), FormatCategory::SemiPlanar422);
4877        assert!(!info.is_planar_420());
4878        let len = info.try_buffer_len(3840, 1080).unwrap();
4879        assert_eq!(len, 3840 * 1080 * 2);
4880
4881        // PA16: P216 + 16-bit alpha plane at the line stride
4882        let info = PixelFormat::PA16.info();
4883        assert_eq!(info.bytes_per_pixel(), 2);
4884        assert_eq!(info.category(), FormatCategory::SemiPlanar422Alpha);
4885        assert!(!info.is_planar_420());
4886        let len = info.try_buffer_len(3840, 1080).unwrap();
4887        assert_eq!(len, 3840 * 1080 * 3);
4888    }
4889
4890    /// Test PixelFormatInfo for planar YV12/I420 with even dimensions
4891    #[test]
4892    fn test_pixel_format_info_planar_420_even() {
4893        // 1920x1080 YV12/I420
4894        // Y: 1920 * 1080 = 2,073,600
4895        // U: (1920/2) * (1080/2) = 960 * 540 = 518,400
4896        // V: (1920/2) * (1080/2) = 960 * 540 = 518,400
4897        // Total: 2,073,600 + 518,400 + 518,400 = 3,110,400
4898        let y_stride = 1920;
4899
4900        let info = PixelFormat::YV12.info();
4901        assert_eq!(info.bytes_per_pixel(), 1);
4902        assert_eq!(info.category(), FormatCategory::Planar420);
4903        assert!(info.is_planar_420());
4904        let len = info.try_buffer_len(y_stride, 1080).unwrap();
4905        assert_eq!(len, 3_110_400, "YV12 1920x1080");
4906
4907        let info = PixelFormat::I420.info();
4908        assert_eq!(info.category(), FormatCategory::Planar420);
4909        let len = info.try_buffer_len(y_stride, 1080).unwrap();
4910        assert_eq!(len, 3_110_400, "I420 1920x1080");
4911    }
4912
4913    /// Test PixelFormatInfo rejects planar YV12/I420 with odd layout inputs
4914    #[test]
4915    fn test_pixel_format_info_planar_420_odd() {
4916        let y_stride = 1921;
4917
4918        assert!(PixelFormat::YV12
4919            .info()
4920            .try_buffer_len(y_stride, 1081)
4921            .is_err());
4922        assert!(PixelFormat::I420
4923            .info()
4924            .try_buffer_len(y_stride, 1081)
4925            .is_err());
4926    }
4927
4928    /// Test PixelFormatInfo for semi-planar NV12 with even dimensions
4929    #[test]
4930    fn test_pixel_format_info_nv12_even() {
4931        // 1920x1080 NV12
4932        // Y: 1920 * 1080 = 2,073,600
4933        // UV: 1920 * (1080/2) = 1920 * 540 = 1,036,800
4934        // Total: 2,073,600 + 1,036,800 = 3,110,400
4935        let y_stride = 1920;
4936
4937        let info = PixelFormat::NV12.info();
4938        assert_eq!(info.bytes_per_pixel(), 1);
4939        assert_eq!(info.category(), FormatCategory::SemiPlanar420);
4940        assert!(info.is_planar_420());
4941        let len = info.try_buffer_len(y_stride, 1080).unwrap();
4942        assert_eq!(len, 3_110_400, "NV12 1920x1080");
4943    }
4944
4945    /// Test PixelFormatInfo rejects semi-planar NV12 with odd layout inputs
4946    #[test]
4947    fn test_pixel_format_info_nv12_odd() {
4948        let y_stride = 1921;
4949        assert!(PixelFormat::NV12
4950            .info()
4951            .try_buffer_len(y_stride, 1081)
4952            .is_err());
4953    }
4954
4955    /// Test PixelFormat::line_stride for all formats
4956    #[test]
4957    fn test_pixel_format_line_stride() {
4958        // Packed RGB: 4 bytes per pixel
4959        assert_eq!(PixelFormat::BGRA.try_line_stride(1920).unwrap(), 7680);
4960        assert_eq!(PixelFormat::BGRX.try_line_stride(1920).unwrap(), 7680);
4961        assert_eq!(PixelFormat::RGBA.try_line_stride(1920).unwrap(), 7680);
4962        assert_eq!(PixelFormat::RGBX.try_line_stride(1920).unwrap(), 7680);
4963
4964        // UYVY: 2 bytes per pixel
4965        assert_eq!(PixelFormat::UYVY.try_line_stride(1920).unwrap(), 3840);
4966
4967        // UYVA: UYVY plane stride = 2 bytes per pixel
4968        assert_eq!(PixelFormat::UYVA.try_line_stride(1920).unwrap(), 3840);
4969
4970        // P216/PA16: Y-plane stride = 2 bytes per pixel
4971        assert_eq!(PixelFormat::P216.try_line_stride(1920).unwrap(), 3840);
4972        assert_eq!(PixelFormat::PA16.try_line_stride(1920).unwrap(), 3840);
4973
4974        // Planar 4:2:0: Y-plane stride = 1 byte per pixel
4975        assert_eq!(PixelFormat::YV12.try_line_stride(1920).unwrap(), 1920);
4976        assert_eq!(PixelFormat::I420.try_line_stride(1920).unwrap(), 1920);
4977        assert_eq!(PixelFormat::NV12.try_line_stride(1920).unwrap(), 1920);
4978    }
4979
4980    /// Test PixelFormat::buffer_size for all formats
4981    #[test]
4982    fn test_pixel_format_buffer_size() {
4983        // Packed RGB: width * 4 * height
4984        assert_eq!(
4985            PixelFormat::BGRA.try_buffer_size(1920, 1080).unwrap(),
4986            8_294_400
4987        );
4988        assert_eq!(
4989            PixelFormat::RGBA.try_buffer_size(1920, 1080).unwrap(),
4990            8_294_400
4991        );
4992
4993        // Planar 4:2:0: Y + U + V = 1.5 * width * height
4994        assert_eq!(
4995            PixelFormat::YV12.try_buffer_size(1920, 1080).unwrap(),
4996            3_110_400
4997        );
4998        assert_eq!(
4999            PixelFormat::I420.try_buffer_size(1920, 1080).unwrap(),
5000            3_110_400
5001        );
5002
5003        // Semi-planar 4:2:0: Y + UV = 1.5 * width * height
5004        assert_eq!(
5005            PixelFormat::NV12.try_buffer_size(1920, 1080).unwrap(),
5006            3_110_400
5007        );
5008
5009        // UYVA: UYVY + alpha = 3 * width * height
5010        assert_eq!(
5011            PixelFormat::UYVA.try_buffer_size(1920, 1080).unwrap(),
5012            6_220_800
5013        );
5014
5015        // Semi-planar 4:2:2 16-bit: Y + CbCr = 4 * width * height
5016        assert_eq!(
5017            PixelFormat::P216.try_buffer_size(1920, 1080).unwrap(),
5018            8_294_400
5019        );
5020
5021        // Semi-planar 4:2:2 16-bit with alpha: Y + CbCr + A = 6 * width * height
5022        assert_eq!(
5023            PixelFormat::PA16.try_buffer_size(1920, 1080).unwrap(),
5024            12_441_600
5025        );
5026    }
5027
5028    /// Test PixelFormatInfo::is_planar_420 helper
5029    #[test]
5030    fn test_pixel_format_info_is_planar_420() {
5031        assert!(PixelFormat::YV12.info().is_planar_420());
5032        assert!(PixelFormat::I420.info().is_planar_420());
5033        assert!(PixelFormat::NV12.info().is_planar_420());
5034
5035        assert!(!PixelFormat::BGRA.info().is_planar_420());
5036        assert!(!PixelFormat::RGBA.info().is_planar_420());
5037        assert!(!PixelFormat::UYVY.info().is_planar_420());
5038        assert!(!PixelFormat::UYVA.info().is_planar_420());
5039    }
5040
5041    /// Test VideoFrame builder with planar formats produces correct buffer sizes
5042    #[test]
5043    fn test_videoframe_builder_planar_even() {
5044        let frame = VideoFrame::builder()
5045            .resolution(1920, 1080)
5046            .pixel_format(PixelFormat::NV12)
5047            .build()
5048            .expect("Builder should succeed");
5049
5050        assert_eq!(frame.width(), 1920);
5051        assert_eq!(frame.height(), 1080);
5052        assert_eq!(frame.pixel_format(), PixelFormat::NV12);
5053        assert_eq!(frame.data().len(), 3_110_400, "NV12 1920x1080 buffer size");
5054    }
5055
5056    #[test]
5057    fn test_video_dimensions_across_all_formats() {
5058        let formats = [
5059            PixelFormat::BGRA,
5060            PixelFormat::BGRX,
5061            PixelFormat::RGBA,
5062            PixelFormat::RGBX,
5063            PixelFormat::UYVY,
5064            PixelFormat::UYVA,
5065            PixelFormat::P216,
5066            PixelFormat::PA16,
5067            PixelFormat::YV12,
5068            PixelFormat::I420,
5069            PixelFormat::NV12,
5070        ];
5071        let mut data = vec![0u8; 4096];
5072        for format in formats {
5073            for (width, height) in [(0, 2), (-2, 2), (1, 2), (3, 2), (2, 0), (2, -1)] {
5074                assert!(
5075                    format.try_buffer_size(width, height).is_err(),
5076                    "{format:?} {width}x{height}"
5077                );
5078                assert!(VideoFrame::builder()
5079                    .resolution(width, height)
5080                    .pixel_format(format)
5081                    .build()
5082                    .is_err());
5083                assert!(crate::BorrowedVideoFrame::try_from_uncompressed(
5084                    &data, width, height, format, 30, 1
5085                )
5086                .is_err());
5087                let raw = NDIlib_video_frame_v2_t {
5088                    xres: width,
5089                    yres: height,
5090                    FourCC: format.into(),
5091                    frame_rate_N: 30,
5092                    frame_rate_D: 1,
5093                    picture_aspect_ratio: 0.0,
5094                    frame_format_type: ScanType::Progressive.into(),
5095                    timecode: 0,
5096                    p_data: data.as_mut_ptr(),
5097                    __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5098                        line_stride_in_bytes: 128,
5099                    },
5100                    p_metadata: ptr::null(),
5101                    timestamp: 0,
5102                };
5103                assert!(matches!(
5104                    validate_video_layout(&raw),
5105                    Err(Error::InvalidFrame(_))
5106                ));
5107                // Reject before forming a slice or copying received bytes.
5108                assert!(unsafe { VideoFrame::from_raw(&raw) }.is_err());
5109            }
5110            for width in [0, -2, 1, 3] {
5111                assert!(
5112                    format.try_line_stride(width).is_err(),
5113                    "{format:?} width={width}"
5114                );
5115            }
5116            for height in [1, 2, 3] {
5117                let valid = !format.info().is_planar_420() || height % 2 == 0;
5118                assert_eq!(format.try_buffer_size(2, height).is_ok(), valid);
5119                assert_eq!(
5120                    VideoFrame::builder()
5121                        .resolution(2, height)
5122                        .pixel_format(format)
5123                        .build()
5124                        .is_ok(),
5125                    valid
5126                );
5127                assert_eq!(
5128                    crate::BorrowedVideoFrame::try_from_uncompressed(
5129                        &data, 2, height, format, 30, 1
5130                    )
5131                    .is_ok(),
5132                    valid
5133                );
5134            }
5135            // Even width does not imply a larger alignment requirement.
5136            assert!(format.try_line_stride(6).is_ok());
5137            assert!(ValidatedVideoLayout::new_uncompressed(format, 6, 2, Some(128)).is_ok());
5138        }
5139        // RGB row padding need not be divisible by two or by a pixel size.
5140        assert!(ValidatedVideoLayout::new_uncompressed(PixelFormat::BGRA, 2, 3, Some(9)).is_ok());
5141    }
5142
5143    /// Test VideoFrame builder rejects planar formats with odd dimensions
5144    #[test]
5145    fn test_videoframe_builder_planar_odd() {
5146        let result = VideoFrame::builder()
5147            .resolution(1921, 1081)
5148            .pixel_format(PixelFormat::I420)
5149            .build();
5150
5151        assert!(
5152            matches!(result, Err(Error::InvalidFrame(_))),
5153            "Planar 4:2:0 odd dimensions should be rejected"
5154        );
5155    }
5156
5157    /// Test VideoFrame builder with packed format (regression test)
5158    #[test]
5159    fn test_videoframe_builder_packed() {
5160        let frame = VideoFrame::builder()
5161            .resolution(1920, 1080)
5162            .pixel_format(PixelFormat::BGRA)
5163            .build()
5164            .expect("Builder should succeed");
5165
5166        assert_eq!(frame.width(), 1920);
5167        assert_eq!(frame.height(), 1080);
5168        assert_eq!(frame.pixel_format(), PixelFormat::BGRA);
5169        assert_eq!(
5170            frame.data().len(),
5171            1920 * 1080 * 4,
5172            "BGRA buffer size unchanged"
5173        );
5174    }
5175
5176    /// Test VideoFrame builder hands the SDK the first-plane stride for 4:2:2
5177    /// formats with extra planes
5178    #[test]
5179    fn test_videoframe_builder_semi_planar_422_and_alpha() {
5180        for (format, expected_len) in [
5181            (PixelFormat::UYVA, 256 * 2 * 64 + 256 * 64),
5182            (PixelFormat::P216, 256 * 2 * 64 * 2),
5183            (PixelFormat::PA16, 256 * 2 * 64 * 3),
5184        ] {
5185            let frame = VideoFrame::builder()
5186                .resolution(256, 64)
5187                .pixel_format(format)
5188                .build()
5189                .expect("Builder should succeed");
5190
5191            assert_eq!(
5192                frame.line_stride_or_size(),
5193                LineStrideOrSize::LineStrideBytes(256 * 2),
5194                "{format:?} line stride"
5195            );
5196            assert_eq!(frame.data().len(), expected_len, "{format:?} buffer size");
5197        }
5198    }
5199
5200    /// Test VideoFrame::from_raw with synthetic NV12 frame
5201    #[test]
5202    fn test_videoframe_from_raw_nv12() {
5203        // Create a synthetic NV12 frame
5204        let width = 1920;
5205        let height = 1080;
5206        let y_stride = 1920;
5207        let expected_size = 3_110_400; // Y + UV for NV12
5208
5209        let mut data = vec![0u8; expected_size];
5210        // Mark the last byte to verify it's copied
5211        data[expected_size - 1] = 0xFF;
5212
5213        let c_frame = NDIlib_video_frame_v2_t {
5214            xres: width,
5215            yres: height,
5216            FourCC: PixelFormat::NV12.into(),
5217            frame_rate_N: 60,
5218            frame_rate_D: 1,
5219            picture_aspect_ratio: 16.0 / 9.0,
5220            frame_format_type: ScanType::Progressive.into(),
5221            timecode: 0,
5222            p_data: data.as_mut_ptr(),
5223            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5224                line_stride_in_bytes: y_stride,
5225            },
5226            p_metadata: ptr::null(),
5227            timestamp: 0,
5228        };
5229
5230        let frame = unsafe { VideoFrame::from_raw(&c_frame) }.expect("from_raw should succeed");
5231
5232        assert_eq!(frame.width(), width);
5233        assert_eq!(frame.height(), height);
5234        assert_eq!(frame.pixel_format(), PixelFormat::NV12);
5235        assert_eq!(
5236            frame.data().len(),
5237            expected_size,
5238            "Should copy full Y+UV buffer"
5239        );
5240        assert_eq!(
5241            frame.data()[expected_size - 1],
5242            0xFF,
5243            "Last byte should be copied"
5244        );
5245    }
5246
5247    /// Test VideoFrame::from_raw rejects synthetic I420 frame with odd dimensions
5248    #[test]
5249    fn test_videoframe_from_raw_i420_odd() {
5250        let width = 1921;
5251        let height = 1081;
5252        let y_stride = 1921;
5253        let expected_size = 3_116_403; // Y + U + V with ceiling division
5254
5255        let mut data = vec![0u8; expected_size];
5256
5257        let c_frame = NDIlib_video_frame_v2_t {
5258            xres: width,
5259            yres: height,
5260            FourCC: PixelFormat::I420.into(),
5261            frame_rate_N: 30,
5262            frame_rate_D: 1,
5263            picture_aspect_ratio: 16.0 / 9.0,
5264            frame_format_type: ScanType::Progressive.into(),
5265            timecode: 0,
5266            p_data: data.as_mut_ptr(),
5267            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5268                line_stride_in_bytes: y_stride,
5269            },
5270            p_metadata: ptr::null(),
5271            timestamp: 0,
5272        };
5273
5274        let result = unsafe { VideoFrame::from_raw(&c_frame) };
5275        assert!(
5276            matches!(result, Err(Error::InvalidFrame(_))),
5277            "Planar 4:2:0 odd dimensions should be rejected"
5278        );
5279    }
5280
5281    /// Regression test: VideoFrame::from_raw with packed format should be unchanged
5282    #[test]
5283    fn test_videoframe_from_raw_packed_regression() {
5284        let width = 1920;
5285        let height = 1080;
5286        let stride = 1920 * 4; // BGRA
5287        let expected_size = (stride * height) as usize;
5288
5289        let mut data = vec![0u8; expected_size];
5290
5291        let c_frame = NDIlib_video_frame_v2_t {
5292            xres: width,
5293            yres: height,
5294            FourCC: PixelFormat::BGRA.into(),
5295            frame_rate_N: 60,
5296            frame_rate_D: 1,
5297            picture_aspect_ratio: 16.0 / 9.0,
5298            frame_format_type: ScanType::Progressive.into(),
5299            timecode: 0,
5300            p_data: data.as_mut_ptr(),
5301            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5302                line_stride_in_bytes: stride,
5303            },
5304            p_metadata: ptr::null(),
5305            timestamp: 0,
5306        };
5307
5308        let frame = unsafe { VideoFrame::from_raw(&c_frame) }.expect("from_raw should succeed");
5309        assert_eq!(
5310            frame.data().len(),
5311            expected_size,
5312            "BGRA buffer size unchanged"
5313        );
5314    }
5315
5316    /// Test that VideoFrameRef::new rejects unknown FourCC
5317    #[test]
5318    fn test_videoframeref_unknown_fourcc() {
5319        use crate::capture::{Guard, VideoKind};
5320
5321        let width = 1920;
5322        let height = 1080;
5323        let stride = 1920 * 4;
5324        let expected_size = (stride * height) as usize;
5325        let mut data = vec![0u8; expected_size];
5326
5327        // Use an unknown FourCC value (0xDEADBEEF)
5328        // On Windows FourCC is i32, on Linux it's u32
5329        #[allow(clippy::unnecessary_cast)]
5330        let c_frame = NDIlib_video_frame_v2_t {
5331            xres: width,
5332            yres: height,
5333            #[cfg(target_os = "windows")]
5334            FourCC: 0xDEADBEEFu32 as i32, // Unknown FourCC
5335            #[cfg(not(target_os = "windows"))]
5336            FourCC: 0xDEADBEEF, // Unknown FourCC
5337            frame_rate_N: 60,
5338            frame_rate_D: 1,
5339            picture_aspect_ratio: 16.0 / 9.0,
5340            frame_format_type: ScanType::Progressive.into(),
5341            timecode: 0,
5342            p_data: data.as_mut_ptr(),
5343            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5344                line_stride_in_bytes: stride,
5345            },
5346            p_metadata: ptr::null(),
5347            timestamp: 0,
5348        };
5349
5350        // Create a mock receiver instance (null is fine for this test since we don't free)
5351        let mock_instance = ptr::null_mut();
5352        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
5353
5354        // VideoFrameRef::new should return an error for unknown FourCC
5355        let result = unsafe { VideoFrameRef::new(guard) };
5356        assert!(result.is_err(), "Should reject unknown FourCC");
5357
5358        if let Err(Error::InvalidFrame(ref msg)) = result {
5359            assert!(
5360                msg.contains("0xDEADBEEF"),
5361                "Error message should include FourCC: {}",
5362                msg
5363            );
5364        } else {
5365            panic!("Expected InvalidFrame error");
5366        }
5367
5368        // Manually free to prevent guard from calling NDI free on null instance
5369        std::mem::forget(result);
5370    }
5371
5372    /// Test that VideoFrameRef::new accepts known FourCC and stores validated format
5373    #[test]
5374    fn test_videoframeref_known_fourcc() {
5375        use crate::capture::{Guard, VideoKind};
5376
5377        let width = 1920;
5378        let height = 1080;
5379        let stride = 1920 * 4;
5380        let expected_size = (stride * height) as usize;
5381        let mut data = vec![0u8; expected_size];
5382
5383        let c_frame = NDIlib_video_frame_v2_t {
5384            xres: width,
5385            yres: height,
5386            FourCC: PixelFormat::BGRA.into(),
5387            frame_rate_N: 60,
5388            frame_rate_D: 1,
5389            picture_aspect_ratio: 16.0 / 9.0,
5390            frame_format_type: ScanType::Progressive.into(),
5391            timecode: 0,
5392            p_data: data.as_mut_ptr(),
5393            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5394                line_stride_in_bytes: stride,
5395            },
5396            p_metadata: ptr::null(),
5397            timestamp: 0,
5398        };
5399
5400        let mock_instance = ptr::null_mut();
5401        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
5402
5403        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("Should accept BGRA FourCC");
5404        assert_eq!(
5405            frame_ref.pixel_format(),
5406            PixelFormat::BGRA,
5407            "Should store validated pixel format"
5408        );
5409
5410        // Manually free to prevent guard from calling NDI free on null instance
5411        std::mem::forget(frame_ref);
5412    }
5413
5414    /// Test that AudioFrameRef::new rejects unknown FourCC
5415    #[test]
5416    fn test_audioframeref_unknown_fourcc() {
5417        use crate::recv_guard::RecvAudioGuard;
5418
5419        let num_samples = 1024;
5420        let num_channels = 2;
5421        let sample_count = (num_samples * num_channels) as usize;
5422        let mut data = vec![0.0f32; sample_count];
5423
5424        // Use an unknown FourCC value (0xBADC0DE)
5425        let c_frame = NDIlib_audio_frame_v3_t {
5426            sample_rate: 48000,
5427            no_channels: num_channels,
5428            no_samples: num_samples,
5429            timecode: 0,
5430            FourCC: 0xBADC0DE, // Unknown audio FourCC
5431            p_data: data.as_mut_ptr() as *mut u8,
5432            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5433                channel_stride_in_bytes: num_samples * 4,
5434            },
5435            p_metadata: ptr::null(),
5436            timestamp: 0,
5437        };
5438
5439        let mock_instance = ptr::null_mut();
5440        let guard = unsafe { RecvAudioGuard::new(mock_instance, c_frame) };
5441
5442        let result = unsafe { AudioFrameRef::new(guard) };
5443        assert!(result.is_err(), "Should reject unknown audio FourCC");
5444
5445        if let Err(Error::InvalidFrame(ref msg)) = result {
5446            assert!(
5447                msg.contains("0x0BADC0DE"),
5448                "Error message should include FourCC: {}",
5449                msg
5450            );
5451        } else {
5452            panic!("Expected InvalidFrame error");
5453        }
5454
5455        std::mem::forget(result);
5456    }
5457
5458    /// Test that AudioFrameRef::new accepts known FourCC and stores validated format
5459    #[test]
5460    fn test_audioframeref_known_fourcc() {
5461        use crate::recv_guard::RecvAudioGuard;
5462
5463        let num_samples = 1024;
5464        let num_channels = 2;
5465        let sample_count = (num_samples * num_channels) as usize;
5466        let mut data = vec![0.0f32; sample_count];
5467
5468        let c_frame = NDIlib_audio_frame_v3_t {
5469            sample_rate: 48000,
5470            no_channels: num_channels,
5471            no_samples: num_samples,
5472            timecode: 0,
5473            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
5474            p_data: data.as_mut_ptr() as *mut u8,
5475            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
5476                channel_stride_in_bytes: num_samples * 4,
5477            },
5478            p_metadata: ptr::null(),
5479            timestamp: 0,
5480        };
5481
5482        let mock_instance = ptr::null_mut();
5483        let guard = unsafe { RecvAudioGuard::new(mock_instance, c_frame) };
5484
5485        let frame_ref = unsafe { AudioFrameRef::new(guard) }.expect("Should accept FLTP FourCC");
5486        assert_eq!(
5487            frame_ref.format(),
5488            AudioFormat::FLTP,
5489            "Should store validated audio format"
5490        );
5491
5492        std::mem::forget(frame_ref);
5493    }
5494
5495    /// Test that VideoFrameRef correctly uses validated format for data size calculation
5496    #[test]
5497    fn test_videoframeref_data_uses_validated_format() {
5498        use crate::capture::{Guard, VideoKind};
5499
5500        // Test with uncompressed format (BGRA)
5501        let width = 1920;
5502        let height = 1080;
5503        let stride = 1920 * 4;
5504        let expected_size = (stride * height) as usize;
5505        let mut data = vec![0xAB_u8; expected_size];
5506
5507        let c_frame = NDIlib_video_frame_v2_t {
5508            xres: width,
5509            yres: height,
5510            FourCC: PixelFormat::BGRA.into(),
5511            frame_rate_N: 60,
5512            frame_rate_D: 1,
5513            picture_aspect_ratio: 16.0 / 9.0,
5514            frame_format_type: ScanType::Progressive.into(),
5515            timecode: 0,
5516            p_data: data.as_mut_ptr(),
5517            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5518                line_stride_in_bytes: stride,
5519            },
5520            p_metadata: ptr::null(),
5521            timestamp: 0,
5522        };
5523
5524        let mock_instance = ptr::null_mut();
5525        let guard = unsafe { Guard::<VideoKind>::new(mock_instance, c_frame) };
5526        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("Should create frame ref");
5527
5528        // Verify data() returns correct size based on validated format
5529        assert_eq!(
5530            frame_ref.data().unwrap().len(),
5531            expected_size,
5532            "data() should use validated pixel format for size calculation"
5533        );
5534
5535        // Verify line_stride_or_size() uses validated format
5536        assert_eq!(
5537            frame_ref.line_stride_or_size(),
5538            LineStrideOrSize::LineStrideBytes(stride),
5539            "line_stride_or_size() should use validated format"
5540        );
5541
5542        std::mem::forget(frame_ref);
5543    }
5544
5545    /// Regression lock against the receiver and FrameSync video refs re-diverging:
5546    /// given byte-identical raw frames, every shared accessor (including the
5547    /// previously-divergent `scan_type`) must return identical results now that
5548    /// both spellings share one [`VideoRef`] core.
5549    #[test]
5550    fn test_receiver_and_framesync_video_refs_agree() {
5551        use crate::capture::{FrameSyncVideoFree, Guard, VideoKind};
5552
5553        let width = 16;
5554        let height = 8;
5555        let stride = width * 4;
5556        let len = (stride * height) as usize;
5557
5558        // Two independent buffers with identical content, so each ref owns a
5559        // distinct (non-aliasing) frame while still comparing equal.
5560        let mut recv_data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
5561        let mut fs_data = recv_data.clone();
5562        let mut recv_meta = b"shared frame\0".to_vec();
5563        let mut fs_meta = recv_meta.clone();
5564
5565        let raw = |data: &mut [u8], meta: &mut [u8]| NDIlib_video_frame_v2_t {
5566            xres: width,
5567            yres: height,
5568            FourCC: PixelFormat::BGRA.into(),
5569            frame_rate_N: 30,
5570            frame_rate_D: 1,
5571            picture_aspect_ratio: 16.0 / 9.0,
5572            // A non-default scan type: the old FrameSync copy used
5573            // `unwrap_or(Progressive)`, so this also guards that divergence.
5574            frame_format_type: ScanType::Interlaced.into(),
5575            timecode: 123,
5576            p_data: data.as_mut_ptr(),
5577            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5578                line_stride_in_bytes: stride,
5579            },
5580            p_metadata: meta.as_mut_ptr().cast(),
5581            timestamp: 456,
5582        };
5583
5584        let recv_raw = raw(&mut recv_data, &mut recv_meta);
5585        let fs_raw = raw(&mut fs_data, &mut fs_meta);
5586
5587        let recv_guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), recv_raw) };
5588        let recv = unsafe { VideoFrameRef::new(recv_guard) }.expect("receiver video ref");
5589        let fs_guard = unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), fs_raw) };
5590        let fs =
5591            unsafe { VideoRef::<FrameSyncVideoFree>::new(fs_guard) }.expect("framesync video ref");
5592
5593        assert_eq!(recv.width(), fs.width());
5594        assert_eq!(recv.height(), fs.height());
5595        assert_eq!(recv.pixel_format(), fs.pixel_format());
5596        assert_eq!(recv.frame_rate_n(), fs.frame_rate_n());
5597        assert_eq!(recv.frame_rate_d(), fs.frame_rate_d());
5598        assert_eq!(recv.picture_aspect_ratio(), fs.picture_aspect_ratio());
5599        assert_eq!(recv.scan_type(), fs.scan_type());
5600        assert_eq!(recv.scan_type(), ScanType::Interlaced);
5601        assert_eq!(recv.timecode(), fs.timecode());
5602        assert_eq!(recv.timestamp(), fs.timestamp());
5603        assert_eq!(recv.line_stride_or_size(), fs.line_stride_or_size());
5604        assert_eq!(recv.metadata(), fs.metadata());
5605        assert_eq!(recv.data(), fs.data());
5606
5607        // The receiver guard would call the SDK free with a null instance; leak
5608        // both refs to keep the unit test off the FFI free path.
5609        std::mem::forget(recv);
5610        std::mem::forget(fs);
5611    }
5612
5613    /// An out-of-range `frame_format_type` must be rejected by `validate_video_layout`
5614    /// before either video ref is constructed. This is what makes the unified
5615    /// `scan_type()` `.expect(...)` policy sound: the unknown branch is unreachable.
5616    #[test]
5617    fn test_video_ref_rejects_out_of_range_scan_type() {
5618        use crate::capture::{FrameSyncVideoFree, Guard, VideoKind};
5619
5620        let width = 16;
5621        let height = 8;
5622        let stride = width * 4;
5623        let len = (stride * height) as usize;
5624        let mut recv_data = vec![0u8; len];
5625        let mut fs_data = vec![0u8; len];
5626
5627        let raw = |data: &mut [u8]| NDIlib_video_frame_v2_t {
5628            xres: width,
5629            yres: height,
5630            FourCC: PixelFormat::BGRA.into(),
5631            frame_rate_N: 30,
5632            frame_rate_D: 1,
5633            picture_aspect_ratio: 16.0 / 9.0,
5634            frame_format_type: 0x0BAD_F00D, // not a valid NDIlib_frame_format_type_e
5635            timecode: 0,
5636            p_data: data.as_mut_ptr(),
5637            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
5638                line_stride_in_bytes: stride,
5639            },
5640            p_metadata: ptr::null(),
5641            timestamp: 0,
5642        };
5643
5644        // Layout validation rejects it directly...
5645        assert!(matches!(
5646            validate_video_layout(&raw(&mut recv_data)),
5647            Err(Error::InvalidFrame(_))
5648        ));
5649
5650        // ...and so both ref constructors fail before any `scan_type()` call.
5651        let recv_guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw(&mut recv_data)) };
5652        assert!(matches!(
5653            unsafe { VideoFrameRef::new(recv_guard) },
5654            Err(Error::InvalidFrame(_))
5655        ));
5656
5657        let fs_guard =
5658            unsafe { Guard::<FrameSyncVideoFree>::new(ptr::null_mut(), raw(&mut fs_data)) };
5659        assert!(matches!(
5660            unsafe { VideoRef::<FrameSyncVideoFree>::new(fs_guard) },
5661            Err(Error::InvalidFrame(_))
5662        ));
5663    }
5664
5665    /// Test that MAX_VIDEO_BYTES constant is properly defined
5666    #[test]
5667    fn test_max_video_bytes_constant() {
5668        // Verify the crate resource cap is set to 256 MiB
5669        assert_eq!(MAX_VIDEO_BYTES, 256 * 1024 * 1024);
5670    }
5671
5672    /// Test that MAX_AUDIO_BYTES constant is properly defined
5673    #[test]
5674    fn test_max_audio_bytes_constant() {
5675        // Verify the constant is set to 64 MiB as specified
5676        assert_eq!(MAX_AUDIO_BYTES, 64 * 1024 * 1024);
5677    }
5678
5679    /// Test that MAX_METADATA_BYTES constant is properly defined.
5680    #[test]
5681    fn test_max_metadata_bytes_constant() {
5682        assert_eq!(MAX_METADATA_BYTES, 4 * 1024 * 1024);
5683    }
5684
5685    fn metadata_raw_from_bytes(data: &mut [u8], length: i32) -> NDIlib_metadata_frame_t {
5686        NDIlib_metadata_frame_t {
5687            length,
5688            timecode: 12345,
5689            p_data: data.as_mut_ptr().cast::<c_char>(),
5690        }
5691    }
5692
5693    fn null_metadata_raw(length: i32) -> NDIlib_metadata_frame_t {
5694        NDIlib_metadata_frame_t {
5695            length,
5696            timecode: 12345,
5697            p_data: ptr::null_mut(),
5698        }
5699    }
5700
5701    #[test]
5702    fn test_validate_metadata_layout_accepts_empty_null_frame() {
5703        let raw = null_metadata_raw(0);
5704        let layout = unsafe { validate_metadata_layout(&raw) }.expect("empty null frame is valid");
5705
5706        assert_eq!(
5707            layout,
5708            ValidatedMetadataLayout {
5709                len_with_nul: 0,
5710                text_len: 0,
5711            }
5712        );
5713    }
5714
5715    #[test]
5716    fn test_validate_metadata_layout_accepts_one_byte_empty_payload() {
5717        let mut data = vec![0u8];
5718        let raw = metadata_raw_from_bytes(&mut data, 1);
5719        let layout =
5720            unsafe { validate_metadata_layout(&raw) }.expect("explicit empty payload is valid");
5721
5722        assert_eq!(layout.len_with_nul, 1);
5723        assert_eq!(layout.text_len, 0);
5724    }
5725
5726    #[test]
5727    fn test_validate_metadata_layout_accepts_utf8_with_bounded_length() {
5728        let mut data = "<ndi tally=\"preview\"/>".as_bytes().to_vec();
5729        data.push(0);
5730        let length = data.len() as i32;
5731        let raw = metadata_raw_from_bytes(&mut data, length);
5732        let layout = unsafe { validate_metadata_layout(&raw) }.expect("valid UTF-8 metadata");
5733
5734        assert_eq!(layout.len_with_nul, data.len());
5735        assert_eq!(layout.text_len, data.len() - 1);
5736    }
5737
5738    #[test]
5739    fn test_validate_metadata_layout_ignores_bytes_after_length() {
5740        let mut data = b"ok\0\0\xFF".to_vec();
5741        let raw = metadata_raw_from_bytes(&mut data, 3);
5742        let layout =
5743            unsafe { validate_metadata_layout(&raw) }.expect("extra bytes after length ignored");
5744
5745        assert_eq!(layout.text_len, 2);
5746        let owned = unsafe { MetadataFrame::from_raw_validated(&raw, layout) };
5747        assert_eq!(owned.data(), "ok");
5748    }
5749
5750    #[test]
5751    fn test_validate_metadata_layout_rejects_negative_length() {
5752        let raw = null_metadata_raw(-1);
5753
5754        assert!(matches!(
5755            unsafe { validate_metadata_layout(&raw) },
5756            Err(Error::InvalidFrame(_))
5757        ));
5758    }
5759
5760    #[test]
5761    fn test_validate_metadata_layout_accepts_lengthless_non_null_data() {
5762        let mut data = b"<ndi/>\0ignored".to_vec();
5763        let raw = metadata_raw_from_bytes(&mut data, 0);
5764        let layout = unsafe { validate_metadata_layout(&raw) }.expect("SDK C-string sentinel");
5765        assert_eq!(layout.text_len, 6);
5766        assert_eq!(layout.len_with_nul, 7);
5767        let owned = unsafe { MetadataFrame::from_raw_validated(&raw, layout) };
5768        assert_eq!(owned.data(), "<ndi/>");
5769    }
5770
5771    #[test]
5772    fn test_lengthless_metadata_rejects_invalid_utf8_and_missing_nul() {
5773        let mut invalid = vec![0xff, 0];
5774        assert!(matches!(
5775            unsafe { validate_metadata_layout(&metadata_raw_from_bytes(&mut invalid, 0)) },
5776            Err(Error::InvalidUtf8(_))
5777        ));
5778        let mut unterminated = vec![b'x'; MAX_METADATA_BYTES];
5779        assert!(unsafe {
5780            validate_metadata_layout(&metadata_raw_from_bytes(&mut unterminated, 0))
5781        }
5782        .is_err());
5783    }
5784
5785    #[test]
5786    fn test_validate_metadata_layout_rejects_nonzero_length_null_data() {
5787        let raw = null_metadata_raw(1);
5788
5789        assert!(matches!(
5790            unsafe { validate_metadata_layout(&raw) },
5791            Err(Error::InvalidFrame(_))
5792        ));
5793    }
5794
5795    #[test]
5796    fn test_validate_metadata_layout_rejects_missing_trailing_nul() {
5797        let mut data = b"abc".to_vec();
5798        let length = data.len() as i32;
5799        let raw = metadata_raw_from_bytes(&mut data, length);
5800
5801        assert!(matches!(
5802            unsafe { validate_metadata_layout(&raw) },
5803            Err(Error::InvalidFrame(_))
5804        ));
5805    }
5806
5807    #[test]
5808    fn test_validate_metadata_layout_rejects_interior_nul() {
5809        let mut data = b"a\0b\0".to_vec();
5810        let length = data.len() as i32;
5811        let raw = metadata_raw_from_bytes(&mut data, length);
5812
5813        assert!(matches!(
5814            unsafe { validate_metadata_layout(&raw) },
5815            Err(Error::InvalidFrame(_))
5816        ));
5817    }
5818
5819    #[test]
5820    fn test_validate_metadata_layout_rejects_oversized_length_before_reading() {
5821        let mut data = vec![0u8];
5822        let raw = metadata_raw_from_bytes(&mut data, (MAX_METADATA_BYTES + 1) as i32);
5823
5824        assert!(matches!(
5825            unsafe { validate_metadata_layout(&raw) },
5826            Err(Error::InvalidFrame(_))
5827        ));
5828    }
5829
5830    #[test]
5831    fn test_validate_metadata_layout_rejects_invalid_utf8() {
5832        let mut data = vec![0xFF, 0];
5833        let length = data.len() as i32;
5834        let raw = metadata_raw_from_bytes(&mut data, length);
5835
5836        assert!(matches!(
5837            unsafe { validate_metadata_layout(&raw) },
5838            Err(Error::InvalidUtf8(_))
5839        ));
5840    }
5841
5842    #[test]
5843    fn test_metadata_frame_constructor_and_accessors_preserve_text() {
5844        let frame = MetadataFrame::with_data("<ndi_product/>", 9876).unwrap();
5845
5846        assert_eq!(frame.data(), "<ndi_product/>");
5847        assert_eq!(frame.timecode(), 9876);
5848        assert_eq!(frame.clone().into_data(), "<ndi_product/>");
5849    }
5850
5851    #[test]
5852    fn test_metadata_frame_rejects_interior_nul_input() {
5853        assert!(matches!(
5854            MetadataFrame::with_data("bad\0metadata", 0),
5855            Err(Error::InvalidCString(_))
5856        ));
5857
5858        let mut frame = MetadataFrame::new();
5859        assert!(matches!(
5860            frame.set_data("bad\0metadata"),
5861            Err(Error::InvalidCString(_))
5862        ));
5863    }
5864
5865    #[test]
5866    fn test_metadata_frame_rejects_oversized_input() {
5867        let oversized = "x".repeat(MAX_METADATA_BYTES);
5868
5869        assert!(matches!(
5870            MetadataFrame::with_data(oversized, 0),
5871            Err(Error::InvalidFrame(_))
5872        ));
5873    }
5874
5875    #[test]
5876    fn test_metadata_frame_setters_preserve_invariants() {
5877        let mut frame = MetadataFrame::new();
5878
5879        frame.set_data("updated").unwrap();
5880        frame.set_timecode(42);
5881
5882        assert_eq!(frame.data(), "updated");
5883        assert_eq!(frame.timecode(), 42);
5884        assert_eq!(MetadataFrame::new().with_timecode(7).timecode(), 7);
5885        assert!(frame.set_data("bad\0text").is_err());
5886        assert_eq!(frame.data(), "updated");
5887    }
5888
5889    #[test]
5890    fn validated_metadata_reuses_storage_and_copies_once() {
5891        let text = "é".repeat(550);
5892        let frame = MetadataFrame::with_data(text, 9).unwrap();
5893        let expected = frame.data.as_ptr() as *mut c_char;
5894        let ((), allocations) = crate::test_alloc::allocation_count(|| {
5895            for _ in 0..100 {
5896                let raw = frame.to_raw();
5897                assert_eq!(raw.p_data, expected);
5898                assert_eq!(raw.length, 1101);
5899                assert_eq!(frame.data().len(), 1100);
5900            }
5901        });
5902        assert_eq!(allocations, 0);
5903
5904        let bytes = frame.data.inner.as_bytes_with_nul();
5905        let layout = ValidatedFrameMetadata {
5906            len_with_nul: NonZeroUsize::new(bytes.len()),
5907            text_len: bytes.len() - 1,
5908        };
5909        let (copy, allocations) = crate::test_alloc::allocation_count(|| unsafe {
5910            FrameMetadata::copy_from_raw_validated(bytes.as_ptr().cast(), layout).unwrap()
5911        });
5912        assert_eq!(allocations, 1);
5913        assert_eq!(copy.as_str(), frame.data());
5914        let (text, allocations) = crate::test_alloc::allocation_count(|| copy.into_string());
5915        assert_eq!(allocations, 0);
5916        assert_eq!(text, frame.data());
5917    }
5918
5919    #[test]
5920    fn test_metadata_frame_from_raw_validated_copies_only_payload() {
5921        let mut data = b"copy-me\0\0\xFF".to_vec();
5922        let raw = metadata_raw_from_bytes(&mut data, 8);
5923        let layout = unsafe { validate_metadata_layout(&raw) }.unwrap();
5924        let frame = unsafe { MetadataFrame::from_raw_validated(&raw, layout) };
5925
5926        assert_eq!(frame.data(), "copy-me");
5927        assert_eq!(frame.timecode(), 12345);
5928    }
5929
5930    #[test]
5931    fn test_metadata_frame_from_raw_reports_invalid_utf8() {
5932        let mut data = vec![0xFF, 0];
5933        let raw = metadata_raw_from_bytes(&mut data, 2);
5934
5935        assert!(matches!(
5936            unsafe { MetadataFrame::from_raw(&raw) },
5937            Err(Error::InvalidUtf8(_))
5938        ));
5939    }
5940
5941    #[test]
5942    fn test_metadata_frame_to_raw_includes_trailing_nul() {
5943        let frame = MetadataFrame::with_data("abc", 101).unwrap();
5944        let raw = frame.to_raw();
5945
5946        assert_eq!(raw.length, 4);
5947        assert_eq!(raw.timecode, 101);
5948        assert_eq!(raw.p_data, frame.data.as_ptr() as *mut c_char);
5949        assert_eq!(frame.data.inner.as_bytes_with_nul(), b"abc\0");
5950    }
5951
5952    #[test]
5953    fn test_empty_metadata_frame_to_raw_sends_explicit_nul() {
5954        let frame = MetadataFrame::new();
5955        let raw = frame.to_raw();
5956
5957        assert_eq!(raw.length, 1);
5958        assert_eq!(frame.data.inner.as_bytes_with_nul(), b"\0");
5959    }
5960
5961    #[test]
5962    fn test_metadata_raw_length_conversion_is_checked() {
5963        assert!(metadata_len_to_i32(i32::MAX as usize).is_ok());
5964        assert!(matches!(
5965            metadata_len_to_i32(i32::MAX as usize + 1),
5966            Err(Error::InvalidFrame(_))
5967        ));
5968    }
5969
5970    #[test]
5971    fn test_metadata_frame_ref_uses_cached_layout() {
5972        use crate::capture::RecvMetadataGuard;
5973
5974        let mut data = b"zero-copy\0\0\xFF".to_vec();
5975        let raw = metadata_raw_from_bytes(&mut data, 10);
5976        let guard = unsafe { RecvMetadataGuard::new(ptr::null_mut(), raw) };
5977        let frame_ref = unsafe { MetadataFrameRef::new(guard) }.expect("valid metadata ref");
5978
5979        assert_eq!(frame_ref.data(), "zero-copy");
5980        assert_eq!(frame_ref.as_bytes(), b"zero-copy");
5981        assert_eq!(frame_ref.layout.text_len, 9);
5982
5983        let owned = frame_ref.to_owned();
5984        assert_eq!(owned.data(), "zero-copy");
5985        assert_eq!(owned.timecode(), 12345);
5986
5987        std::mem::forget(frame_ref);
5988    }
5989
5990    #[test]
5991    fn test_validate_frame_metadata_accepts_null_without_allocation_state() {
5992        let layout = unsafe { validate_frame_metadata(ptr::null()) }.expect("null is valid");
5993
5994        assert_eq!(
5995            layout,
5996            ValidatedFrameMetadata {
5997                len_with_nul: None,
5998                text_len: 0,
5999            }
6000        );
6001    }
6002
6003    #[test]
6004    fn test_validate_frame_metadata_accepts_explicit_empty() {
6005        let mut data = b"\0".to_vec();
6006        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
6007            .expect("explicit empty metadata is valid");
6008
6009        assert_eq!(layout.len_with_nul.unwrap().get(), 1);
6010        assert_eq!(layout.text_len, 0);
6011        assert_eq!(
6012            unsafe { frame_metadata_str(data.as_ptr().cast::<c_char>(), layout) },
6013            Some("")
6014        );
6015    }
6016
6017    #[test]
6018    fn test_validate_frame_metadata_accepts_utf8_and_ignores_after_first_nul() {
6019        let mut data = b"hello metadata\0\0\xFF".to_vec();
6020        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
6021            .expect("valid UTF-8 metadata");
6022
6023        assert_eq!(layout.text_len, "hello metadata".len());
6024        assert_eq!(
6025            unsafe { frame_metadata_str(data.as_ptr().cast::<c_char>(), layout) },
6026            Some("hello metadata")
6027        );
6028    }
6029
6030    #[test]
6031    fn test_validate_frame_metadata_accepts_max_boundary() {
6032        let mut data = vec![b'x'; MAX_METADATA_BYTES];
6033        data[MAX_METADATA_BYTES - 1] = 0;
6034
6035        let layout = unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) }
6036            .expect("metadata exactly at cap including terminator is valid");
6037
6038        assert_eq!(layout.len_with_nul.unwrap().get(), MAX_METADATA_BYTES);
6039        assert_eq!(layout.text_len, MAX_METADATA_BYTES - 1);
6040    }
6041
6042    #[test]
6043    fn test_validate_frame_metadata_rejects_missing_nul_within_cap() {
6044        let mut data = vec![b'x'; MAX_METADATA_BYTES];
6045
6046        assert!(matches!(
6047            unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) },
6048            Err(Error::InvalidFrame(_))
6049        ));
6050    }
6051
6052    #[test]
6053    fn test_validate_frame_metadata_rejects_invalid_utf8_before_nul() {
6054        let mut data = vec![0xFF, 0];
6055
6056        assert!(matches!(
6057            unsafe { validate_frame_metadata(data.as_mut_ptr().cast::<c_char>()) },
6058            Err(Error::InvalidUtf8(_))
6059        ));
6060    }
6061
6062    #[test]
6063    fn test_video_frame_ref_metadata_is_cached_and_owned_conversion_appends_nul() {
6064        use crate::capture::{Guard, VideoKind};
6065
6066        let width = 16;
6067        let height = 8;
6068        let stride = width * 4;
6069        let expected_len = (stride * height) as usize;
6070        let data = vec![0u8; expected_len];
6071        let mut metadata = b"cached\0\xFF".to_vec();
6072
6073        let raw = NDIlib_video_frame_v2_t {
6074            xres: width,
6075            yres: height,
6076            FourCC: PixelFormat::BGRA.into(),
6077            frame_rate_N: 60,
6078            frame_rate_D: 1,
6079            picture_aspect_ratio: 16.0 / 9.0,
6080            frame_format_type: ScanType::Progressive.into(),
6081            timecode: 123,
6082            p_data: data.as_ptr() as *mut u8,
6083            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6084                line_stride_in_bytes: stride,
6085            },
6086            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
6087            timestamp: 456,
6088        };
6089
6090        let guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw) };
6091        let frame_ref = unsafe { VideoFrameRef::new(guard) }.expect("valid video ref");
6092
6093        assert_eq!(frame_ref.metadata(), Some("cached"));
6094        metadata[6] = b'!';
6095        assert_eq!(frame_ref.metadata(), Some("cached"));
6096        assert!(format!("{frame_ref:?}").contains("metadata: Some(\"cached\")"));
6097
6098        let owned = frame_ref.to_owned().expect("owned conversion");
6099        assert_eq!(owned.metadata(), Some("cached"));
6100        assert_eq!(owned.data().len(), expected_len);
6101
6102        std::mem::forget(frame_ref);
6103    }
6104
6105    #[test]
6106    fn test_audio_frame_ref_metadata_is_text_and_owned_conversion_preserves_it() {
6107        use crate::capture::RecvAudioGuard;
6108
6109        let no_samples = 4;
6110        let no_channels = 2;
6111        let sample_count = (no_samples * no_channels) as usize;
6112        let data = vec![0.25f32; sample_count];
6113        let mut metadata = b"audio meta\0".to_vec();
6114
6115        let raw = NDIlib_audio_frame_v3_t {
6116            sample_rate: 48000,
6117            no_channels,
6118            no_samples,
6119            timecode: 123,
6120            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6121            p_data: data.as_ptr() as *mut u8,
6122            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6123                channel_stride_in_bytes: no_samples * 4,
6124            },
6125            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
6126            timestamp: 456,
6127        };
6128
6129        let guard = unsafe { RecvAudioGuard::new(ptr::null_mut(), raw) };
6130        let frame_ref = unsafe { AudioFrameRef::new(guard) }.expect("valid audio ref");
6131
6132        assert_eq!(frame_ref.metadata(), Some("audio meta"));
6133        let owned = frame_ref.to_owned().expect("owned conversion");
6134        assert_eq!(owned.metadata(), Some("audio meta"));
6135        assert_eq!(owned.data().len(), sample_count);
6136
6137        std::mem::forget(frame_ref);
6138    }
6139
6140    #[test]
6141    fn test_owned_video_from_raw_rejects_malformed_frame_metadata() {
6142        let width = 16;
6143        let height = 8;
6144        let stride = width * 4;
6145        let expected_len = (stride * height) as usize;
6146        let mut data = vec![0u8; expected_len];
6147        let mut metadata = vec![b'x'; MAX_METADATA_BYTES];
6148
6149        let raw = NDIlib_video_frame_v2_t {
6150            xres: width,
6151            yres: height,
6152            FourCC: PixelFormat::BGRA.into(),
6153            frame_rate_N: 60,
6154            frame_rate_D: 1,
6155            picture_aspect_ratio: 16.0 / 9.0,
6156            frame_format_type: ScanType::Progressive.into(),
6157            timecode: 0,
6158            p_data: data.as_mut_ptr(),
6159            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6160                line_stride_in_bytes: stride,
6161            },
6162            p_metadata: metadata.as_mut_ptr().cast::<c_char>(),
6163            timestamp: 0,
6164        };
6165
6166        assert!(matches!(
6167            unsafe { VideoFrame::from_raw(&raw) },
6168            Err(Error::InvalidFrame(_))
6169        ));
6170
6171        metadata[0] = 0xFF;
6172        metadata[1] = 0;
6173        assert!(matches!(
6174            unsafe { VideoFrame::from_raw(&raw) },
6175            Err(Error::InvalidUtf8(_))
6176        ));
6177    }
6178
6179    #[test]
6180    fn test_owned_frame_metadata_builders_setters_and_raw_conversion() {
6181        let mut video = VideoFrame::builder().metadata("").build().unwrap();
6182        assert_eq!(video.metadata(), Some(""));
6183        let raw = video.to_raw();
6184        assert!(!raw.p_metadata.is_null());
6185        assert_eq!(
6186            unsafe { slice::from_raw_parts(raw.p_metadata.cast::<u8>(), 1) },
6187            b"\0"
6188        );
6189
6190        video.set_metadata(Some("video meta")).unwrap();
6191        assert_eq!(video.metadata(), Some("video meta"));
6192        let raw = video.to_raw();
6193        assert_eq!(
6194            unsafe { slice::from_raw_parts(raw.p_metadata.cast::<u8>(), 11) },
6195            b"video meta\0"
6196        );
6197
6198        video.set_metadata(Option::<String>::None).unwrap();
6199        assert!(video.metadata().is_none());
6200        assert!(video.to_raw().p_metadata.is_null());
6201
6202        let mut audio = AudioFrame::builder().metadata("").build().unwrap();
6203        assert_eq!(audio.metadata(), Some(""));
6204        assert!(!audio.to_raw().p_metadata.is_null());
6205        audio.set_metadata(Some("audio meta")).unwrap();
6206        assert_eq!(audio.metadata(), Some("audio meta"));
6207
6208        assert!(matches!(
6209            VideoFrame::builder().metadata("bad\0metadata").build(),
6210            Err(Error::InvalidCString(_))
6211        ));
6212        assert!(matches!(
6213            AudioFrame::builder().metadata("bad\0metadata").build(),
6214            Err(Error::InvalidCString(_))
6215        ));
6216        assert!(matches!(
6217            video.set_metadata(Some("bad\0metadata")),
6218            Err(Error::InvalidCString(_))
6219        ));
6220        assert!(matches!(
6221            audio.set_metadata(Some("bad\0metadata")),
6222            Err(Error::InvalidCString(_))
6223        ));
6224
6225        let oversized = "x".repeat(MAX_METADATA_BYTES);
6226        assert!(matches!(
6227            VideoFrame::builder().metadata(oversized.clone()).build(),
6228            Err(Error::InvalidFrame(_))
6229        ));
6230        assert!(matches!(
6231            AudioFrame::builder().metadata(oversized.clone()).build(),
6232            Err(Error::InvalidFrame(_))
6233        ));
6234        assert!(matches!(
6235            video.set_metadata(Some(oversized.clone())),
6236            Err(Error::InvalidFrame(_))
6237        ));
6238        assert!(matches!(
6239            audio.set_metadata(Some(oversized)),
6240            Err(Error::InvalidFrame(_))
6241        ));
6242    }
6243
6244    /// Test that audio frames with overflow in checked_mul are rejected
6245    #[test]
6246    fn test_audio_overflow_checked_mul() {
6247        // Use an extreme channel count to exceed the maximum backing buffer size.
6248        let no_samples = 1024;
6249        let no_channels = i32::MAX;
6250        let mut dummy_data = vec![0f32; 1024]; // Small buffer, won't be used due to guard
6251
6252        let raw = NDIlib_audio_frame_v3_t {
6253            sample_rate: 48000,
6254            no_channels,
6255            no_samples,
6256            timecode: 0,
6257            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6258            p_data: dummy_data.as_mut_ptr() as *mut u8,
6259            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6260                channel_stride_in_bytes: no_samples * 4,
6261            },
6262            p_metadata: ptr::null(),
6263            timestamp: 0,
6264        };
6265
6266        let result = unsafe { AudioFrame::from_raw(raw) };
6267        assert!(
6268            result.is_err(),
6269            "Should reject audio frame with sample count overflow or exceeding size limit"
6270        );
6271
6272        if let Err(Error::InvalidFrame(msg)) = result {
6273            // Accept either overflow or size limit error - both are correct guards
6274            assert!(
6275                msg.contains("overflow") || msg.contains("exceeds maximum size"),
6276                "Error message should mention overflow or size limit, got: {msg}"
6277            );
6278        } else {
6279            panic!("Expected InvalidFrame error");
6280        }
6281    }
6282
6283    /// Test that normal audio frames within bounds succeed
6284    #[test]
6285    fn test_audio_within_bounds_succeeds() {
6286        // Typical audio frame: 48kHz, 2 channels, 1024 samples
6287        let sample_rate = 48000;
6288        let no_channels = 2;
6289        let no_samples = 1024;
6290        let sample_count = (no_samples * no_channels) as usize;
6291        let mut data = vec![0.5f32; sample_count];
6292
6293        let raw = NDIlib_audio_frame_v3_t {
6294            sample_rate,
6295            no_channels,
6296            no_samples,
6297            timecode: 12345,
6298            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6299            p_data: data.as_mut_ptr() as *mut u8,
6300            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6301                channel_stride_in_bytes: no_samples * 4,
6302            },
6303            p_metadata: ptr::null(),
6304            timestamp: 67890,
6305        };
6306
6307        let result = unsafe { AudioFrame::from_raw(raw) };
6308        assert!(
6309            result.is_ok(),
6310            "Should accept normal audio frame within bounds"
6311        );
6312
6313        let frame = result.unwrap();
6314        assert_eq!(frame.data().len(), sample_count);
6315        assert_eq!(frame.num_samples(), no_samples);
6316        assert_eq!(frame.num_channels(), no_channels);
6317    }
6318
6319    #[test]
6320    fn test_audio_builder_rejects_invalid_dimensions() {
6321        assert!(matches!(
6322            AudioFrame::builder().sample_rate(0).build(),
6323            Err(Error::InvalidFrame(_))
6324        ));
6325        assert!(matches!(
6326            AudioFrame::builder().channels(0).build(),
6327            Err(Error::InvalidFrame(_))
6328        ));
6329        assert!(matches!(
6330            AudioFrame::builder().samples(0).build(),
6331            Err(Error::InvalidFrame(_))
6332        ));
6333        assert!(matches!(
6334            AudioFrame::builder().samples(-1).build(),
6335            Err(Error::InvalidFrame(_))
6336        ));
6337    }
6338
6339    #[test]
6340    fn test_audio_builder_rejects_oversized_layout() {
6341        let samples = (MAX_AUDIO_BYTES / std::mem::size_of::<f32>()) as i32 + 1;
6342        let result = AudioFrame::builder().channels(1).samples(samples).build();
6343
6344        assert!(matches!(result, Err(Error::InvalidFrame(_))));
6345    }
6346
6347    #[test]
6348    fn test_default_outbound_timecodes_are_synthesized() {
6349        assert_eq!(
6350            VideoFrame::builder()
6351                .resolution(16, 16)
6352                .build()
6353                .unwrap()
6354                .timecode(),
6355            NDIlib_send_timecode_synthesize
6356        );
6357        assert_eq!(
6358            AudioFrame::builder().samples(1).build().unwrap().timecode(),
6359            NDIlib_send_timecode_synthesize
6360        );
6361        assert_eq!(
6362            MetadataFrame::new().timecode(),
6363            NDIlib_send_timecode_synthesize
6364        );
6365        assert_eq!(
6366            VideoFrame::builder()
6367                .resolution(16, 16)
6368                .timecode(0)
6369                .build()
6370                .unwrap()
6371                .timecode(),
6372            0
6373        );
6374        assert_eq!(
6375            AudioFrame::builder()
6376                .samples(1)
6377                .timecode(0)
6378                .build()
6379                .unwrap()
6380                .timecode(),
6381            0
6382        );
6383        assert_eq!(MetadataFrame::with_data("<x/>", 0).unwrap().timecode(), 0);
6384    }
6385
6386    #[test]
6387    fn test_video_builder_rejects_invalid_send_metadata() {
6388        assert!(matches!(
6389            VideoFrame::builder().frame_rate(0, 1).build(),
6390            Err(Error::InvalidFrame(_))
6391        ));
6392        assert!(matches!(
6393            VideoFrame::builder().frame_rate(30, 0).build(),
6394            Err(Error::InvalidFrame(_))
6395        ));
6396        assert!(matches!(
6397            VideoFrame::builder().aspect_ratio(f32::NAN).build(),
6398            Err(Error::InvalidFrame(_))
6399        ));
6400        assert!(matches!(
6401            VideoFrame::builder().aspect_ratio(-1.0).build(),
6402            Err(Error::InvalidFrame(_))
6403        ));
6404    }
6405
6406    #[test]
6407    fn test_zero_aspect_ratio_uses_sdk_square_pixel_sentinel() {
6408        let frame = VideoFrame::builder()
6409            .resolution(16, 16)
6410            .aspect_ratio(0.0)
6411            .build()
6412            .unwrap();
6413        assert_eq!(frame.picture_aspect_ratio(), 0.0);
6414        assert_eq!(frame.to_raw().picture_aspect_ratio, 0.0);
6415    }
6416
6417    #[test]
6418    fn test_owned_frame_data_replacement_preserves_layout_size() {
6419        let mut video = VideoFrame::builder()
6420            .resolution(16, 16)
6421            .pixel_format(PixelFormat::BGRA)
6422            .build()
6423            .unwrap();
6424        assert!(video.replace_data(vec![0; video.data().len() - 1]).is_err());
6425
6426        let mut audio = AudioFrame::builder()
6427            .channels(2)
6428            .samples(16)
6429            .build()
6430            .unwrap();
6431        assert!(audio
6432            .replace_data(vec![0.0; audio.data().len() + 1])
6433            .is_err());
6434    }
6435
6436    #[test]
6437    fn test_video_cap_allows_8k_bgra_without_allocating() {
6438        let layout =
6439            ValidatedVideoLayout::new_uncompressed(PixelFormat::BGRA, 7680, 4320, None).unwrap();
6440        assert_eq!(layout.data_len_bytes, 132_710_400);
6441        assert!(matches!(
6442            ValidatedVideoLayout::new_uncompressed(PixelFormat::BGRA, 16_384, 8_640, None),
6443            Err(Error::InvalidFrame(_))
6444        ));
6445    }
6446
6447    // =========================================================================
6448    // Tests for frame layout validation helpers
6449    // =========================================================================
6450
6451    /// Test validate_video_layout with valid uncompressed frame
6452    #[test]
6453    fn test_validate_video_layout_valid_uncompressed() {
6454        let width = 1920;
6455        let height = 1080;
6456        let stride = width * 4; // BGRA
6457        let expected_size = (stride * height) as usize;
6458        let mut data = vec![0u8; expected_size];
6459
6460        let raw = NDIlib_video_frame_v2_t {
6461            xres: width,
6462            yres: height,
6463            FourCC: PixelFormat::BGRA.into(),
6464            frame_rate_N: 60,
6465            frame_rate_D: 1,
6466            picture_aspect_ratio: 16.0 / 9.0,
6467            frame_format_type: ScanType::Progressive.into(),
6468            timecode: 0,
6469            p_data: data.as_mut_ptr(),
6470            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6471                line_stride_in_bytes: stride,
6472            },
6473            p_metadata: ptr::null(),
6474            timestamp: 0,
6475        };
6476
6477        let result = validate_video_layout(&raw);
6478        assert!(result.is_ok(), "Should validate valid uncompressed frame");
6479
6480        let layout = result.unwrap();
6481        assert_eq!(layout.pixel_format, PixelFormat::BGRA);
6482        assert_eq!(layout.data_len_bytes, expected_size);
6483        assert_eq!(
6484            layout.line_stride_or_size,
6485            LineStrideOrSize::LineStrideBytes(stride)
6486        );
6487    }
6488
6489    /// Test validate_video_layout accepts the first-plane strides the SDK
6490    /// delivers for UYVA, P216 and PA16
6491    #[test]
6492    fn test_validate_video_layout_semi_planar_422_and_alpha() {
6493        let width = 256;
6494        let height = 64;
6495        let stride = width * 2;
6496        let plane = (stride * height) as usize;
6497
6498        for (format, expected_size) in [
6499            (PixelFormat::UYVA, plane + plane / 2),
6500            (PixelFormat::P216, plane * 2),
6501            (PixelFormat::PA16, plane * 3),
6502        ] {
6503            let mut data = vec![0u8; expected_size];
6504
6505            let raw = NDIlib_video_frame_v2_t {
6506                xres: width,
6507                yres: height,
6508                FourCC: format.into(),
6509                frame_rate_N: 60,
6510                frame_rate_D: 1,
6511                picture_aspect_ratio: 16.0 / 9.0,
6512                frame_format_type: ScanType::Progressive.into(),
6513                timecode: 0,
6514                p_data: data.as_mut_ptr(),
6515                __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6516                    line_stride_in_bytes: stride,
6517                },
6518                p_metadata: ptr::null(),
6519                timestamp: 0,
6520            };
6521
6522            let layout = validate_video_layout(&raw)
6523                .unwrap_or_else(|e| panic!("{format:?} stride {stride} rejected: {e}"));
6524            assert_eq!(layout.pixel_format, format);
6525            assert_eq!(layout.data_len_bytes, expected_size, "{format:?}");
6526            assert_eq!(
6527                layout.line_stride_or_size,
6528                LineStrideOrSize::LineStrideBytes(stride)
6529            );
6530        }
6531    }
6532
6533    /// Test validate_video_layout rejects an odd UYVA stride, since the alpha
6534    /// plane stride is half the line stride
6535    #[test]
6536    fn test_validate_video_layout_rejects_odd_uyva_stride() {
6537        let width = 256;
6538        let height = 64;
6539        let stride = width * 2 + 1;
6540        let mut data = vec![0u8; (stride * height * 2) as usize];
6541
6542        let raw = NDIlib_video_frame_v2_t {
6543            xres: width,
6544            yres: height,
6545            FourCC: PixelFormat::UYVA.into(),
6546            frame_rate_N: 60,
6547            frame_rate_D: 1,
6548            picture_aspect_ratio: 16.0 / 9.0,
6549            frame_format_type: ScanType::Progressive.into(),
6550            timecode: 0,
6551            p_data: data.as_mut_ptr(),
6552            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6553                line_stride_in_bytes: stride,
6554            },
6555            p_metadata: ptr::null(),
6556            timestamp: 0,
6557        };
6558
6559        assert!(matches!(
6560            validate_video_layout(&raw),
6561            Err(Error::InvalidFrame(_))
6562        ));
6563    }
6564
6565    /// Test validate_video_layout rejects null data pointer
6566    #[test]
6567    fn test_validate_video_layout_null_pointer() {
6568        let raw = NDIlib_video_frame_v2_t {
6569            xres: 1920,
6570            yres: 1080,
6571            FourCC: PixelFormat::BGRA.into(),
6572            frame_rate_N: 60,
6573            frame_rate_D: 1,
6574            picture_aspect_ratio: 16.0 / 9.0,
6575            frame_format_type: ScanType::Progressive.into(),
6576            timecode: 0,
6577            p_data: ptr::null_mut(),
6578            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6579                line_stride_in_bytes: 7680,
6580            },
6581            p_metadata: ptr::null(),
6582            timestamp: 0,
6583        };
6584
6585        let result = validate_video_layout(&raw);
6586        assert!(result.is_err(), "Should reject null data pointer");
6587
6588        if let Err(Error::InvalidFrame(msg)) = result {
6589            assert!(
6590                msg.contains("null data pointer"),
6591                "Error should mention null pointer, got: {msg}"
6592            );
6593        } else {
6594            panic!("Expected InvalidFrame error");
6595        }
6596    }
6597
6598    /// Test validate_video_layout rejects invalid line_stride
6599    #[test]
6600    fn test_validate_video_layout_invalid_stride() {
6601        let mut data = vec![0u8; 1024];
6602
6603        let raw = NDIlib_video_frame_v2_t {
6604            xres: 1920,
6605            yres: 1080,
6606            FourCC: PixelFormat::BGRA.into(),
6607            frame_rate_N: 60,
6608            frame_rate_D: 1,
6609            picture_aspect_ratio: 16.0 / 9.0,
6610            frame_format_type: ScanType::Progressive.into(),
6611            timecode: 0,
6612            p_data: data.as_mut_ptr(),
6613            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6614                line_stride_in_bytes: -1, // Invalid stride
6615            },
6616            p_metadata: ptr::null(),
6617            timestamp: 0,
6618        };
6619
6620        let result = validate_video_layout(&raw);
6621        assert!(result.is_err(), "Should reject negative line_stride");
6622
6623        if let Err(Error::InvalidFrame(msg)) = result {
6624            assert!(
6625                msg.contains("invalid line_stride_in_bytes"),
6626                "Error should mention invalid stride, got: {msg}"
6627            );
6628        } else {
6629            panic!("Expected InvalidFrame error");
6630        }
6631    }
6632
6633    #[test]
6634    fn test_validate_video_layout_accepts_default_zero_stride() {
6635        let mut data = vec![0u8; 16 * 16 * 4];
6636        let raw = NDIlib_video_frame_v2_t {
6637            xres: 16,
6638            yres: 16,
6639            FourCC: PixelFormat::BGRA.into(),
6640            frame_rate_N: 60,
6641            frame_rate_D: 1,
6642            picture_aspect_ratio: 1.0,
6643            frame_format_type: ScanType::Progressive.into(),
6644            timecode: 0,
6645            p_data: data.as_mut_ptr(),
6646            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6647                line_stride_in_bytes: 0,
6648            },
6649            p_metadata: ptr::null(),
6650            timestamp: 0,
6651        };
6652        let layout = validate_video_layout(&raw).unwrap();
6653        assert_eq!(layout.data_len_bytes, data.len());
6654        assert_eq!(
6655            layout.line_stride_or_size,
6656            LineStrideOrSize::LineStrideBytes(64)
6657        );
6658        let owned = unsafe { VideoFrame::from_raw(&raw) }.unwrap();
6659        assert_eq!(owned.data().len(), data.len());
6660    }
6661
6662    /// Test validate_video_layout rejects strides smaller than one row.
6663    #[test]
6664    fn test_validate_video_layout_rejects_short_stride() {
6665        let mut data = vec![0u8; 1024];
6666
6667        let raw = NDIlib_video_frame_v2_t {
6668            xres: 1920,
6669            yres: 1080,
6670            FourCC: PixelFormat::BGRA.into(),
6671            frame_rate_N: 60,
6672            frame_rate_D: 1,
6673            picture_aspect_ratio: 16.0 / 9.0,
6674            frame_format_type: ScanType::Progressive.into(),
6675            timecode: 0,
6676            p_data: data.as_mut_ptr(),
6677            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6678                line_stride_in_bytes: 1919 * 4,
6679            },
6680            p_metadata: ptr::null(),
6681            timestamp: 0,
6682        };
6683
6684        let result = validate_video_layout(&raw);
6685        assert!(matches!(result, Err(Error::InvalidFrame(_))));
6686    }
6687
6688    /// Test validate_video_layout rejects odd dimensions for planar 4:2:0.
6689    #[test]
6690    fn test_validate_video_layout_rejects_planar_odd_dimensions() {
6691        let mut data = vec![0u8; 4096];
6692
6693        let raw = NDIlib_video_frame_v2_t {
6694            xres: 641,
6695            yres: 480,
6696            FourCC: PixelFormat::I420.into(),
6697            frame_rate_N: 60,
6698            frame_rate_D: 1,
6699            picture_aspect_ratio: 4.0 / 3.0,
6700            frame_format_type: ScanType::Progressive.into(),
6701            timecode: 0,
6702            p_data: data.as_mut_ptr(),
6703            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6704                line_stride_in_bytes: 642,
6705            },
6706            p_metadata: ptr::null(),
6707            timestamp: 0,
6708        };
6709
6710        let result = validate_video_layout(&raw);
6711        assert!(matches!(result, Err(Error::InvalidFrame(_))));
6712    }
6713
6714    /// Test validate_video_layout rejects negative dimensions
6715    #[test]
6716    fn test_validate_video_layout_negative_dimensions() {
6717        let mut data = vec![0u8; 1024];
6718
6719        let raw = NDIlib_video_frame_v2_t {
6720            xres: 1920,
6721            yres: -1, // Negative height
6722            FourCC: PixelFormat::BGRA.into(),
6723            frame_rate_N: 60,
6724            frame_rate_D: 1,
6725            picture_aspect_ratio: 16.0 / 9.0,
6726            frame_format_type: ScanType::Progressive.into(),
6727            timecode: 0,
6728            p_data: data.as_mut_ptr(),
6729            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6730                line_stride_in_bytes: 7680,
6731            },
6732            p_metadata: ptr::null(),
6733            timestamp: 0,
6734        };
6735
6736        let result = validate_video_layout(&raw);
6737        assert!(result.is_err(), "Should reject negative height");
6738
6739        if let Err(Error::InvalidFrame(msg)) = result {
6740            assert!(
6741                msg.contains("invalid height"),
6742                "Error should mention invalid height, got: {msg}"
6743            );
6744        } else {
6745            panic!("Expected InvalidFrame error");
6746        }
6747    }
6748
6749    /// Test validate_video_layout rejects oversized frames
6750    #[test]
6751    fn test_validate_video_layout_exceeds_max() {
6752        // Validation rejects before reading data, so no large allocation is needed.
6753        let width = 16_384;
6754        let height = 8_640;
6755        let stride = width * 4;
6756        let mut data = [0u8; 1];
6757
6758        let raw = NDIlib_video_frame_v2_t {
6759            xres: width,
6760            yres: height,
6761            FourCC: PixelFormat::BGRA.into(),
6762            frame_rate_N: 60,
6763            frame_rate_D: 1,
6764            picture_aspect_ratio: 16.0 / 9.0,
6765            frame_format_type: ScanType::Progressive.into(),
6766            timecode: 0,
6767            p_data: data.as_mut_ptr(),
6768            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
6769                line_stride_in_bytes: stride,
6770            },
6771            p_metadata: ptr::null(),
6772            timestamp: 0,
6773        };
6774
6775        let result = validate_video_layout(&raw);
6776        assert!(result.is_err(), "Should reject oversized frame");
6777
6778        if let Err(Error::InvalidFrame(msg)) = result {
6779            assert!(
6780                msg.contains("exceeds maximum size"),
6781                "Error should mention size limit, got: {msg}"
6782            );
6783        } else {
6784            panic!("Expected InvalidFrame error");
6785        }
6786    }
6787
6788    /// Test validate_audio_layout with valid frame
6789    #[test]
6790    fn test_validate_audio_layout_valid() {
6791        let no_samples = 1024;
6792        let no_channels = 2;
6793        let sample_count = (no_samples * no_channels) as usize;
6794        let mut data = vec![0.0f32; sample_count];
6795
6796        let raw = NDIlib_audio_frame_v3_t {
6797            sample_rate: 48000,
6798            no_channels,
6799            no_samples,
6800            timecode: 0,
6801            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6802            p_data: data.as_mut_ptr() as *mut u8,
6803            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6804                channel_stride_in_bytes: no_samples * 4,
6805            },
6806            p_metadata: ptr::null(),
6807            timestamp: 0,
6808        };
6809
6810        let result = validate_audio_layout(&raw);
6811        assert!(result.is_ok(), "Should validate valid audio frame");
6812
6813        let layout = result.unwrap();
6814        assert_eq!(layout.format, Some(AudioFormat::FLTP));
6815        assert_eq!(layout.sample_count, sample_count);
6816    }
6817
6818    #[test]
6819    fn test_audio_channel_views_and_owned_conversion_skip_padding() {
6820        use crate::capture::FrameSyncAudioFree;
6821        use std::mem::MaybeUninit;
6822        // Only samples are initialized; gap slots are not audio data.
6823        let mut storage = [MaybeUninit::<f32>::uninit(); 10];
6824        for (index, value) in [(0, 1.0), (1, 2.0), (2, 3.0), (6, 4.0), (7, 5.0), (8, 6.0)] {
6825            storage[index].write(value);
6826        }
6827        let raw = NDIlib_audio_frame_v3_t {
6828            sample_rate: 48000,
6829            no_channels: 2,
6830            no_samples: 3,
6831            FourCC: AudioFormat::FLTP.into(),
6832            p_data: storage.as_mut_ptr().cast(),
6833            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6834                channel_stride_in_bytes: 24,
6835            },
6836            timecode: 123,
6837            timestamp: 456,
6838            p_metadata: ptr::null(),
6839        };
6840        let guard = unsafe { Guard::<FrameSyncAudioFree>::new(ptr::null_mut(), raw) };
6841        let frame = unsafe { AudioRef::<FrameSyncAudioFree>::new(guard, false) }.unwrap();
6842        assert!(frame.data().is_none());
6843        assert_eq!(frame.channel_data(0).unwrap(), &[1.0, 2.0, 3.0]);
6844        assert_eq!(frame.channel_data(1).unwrap(), &[4.0, 5.0, 6.0]);
6845        assert_eq!(frame.channel_data(1).unwrap().as_ptr(), unsafe {
6846            storage.as_ptr().add(6).cast::<f32>()
6847        });
6848        assert!(frame.channel_data(2).is_none());
6849        assert_eq!(frame.channels().len(), 2);
6850        assert_eq!(
6851            frame.channels().flatten().copied().collect::<Vec<_>>(),
6852            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
6853        );
6854        let mut owned = frame.to_owned().unwrap().unwrap();
6855        assert_eq!(owned.channel_stride_in_bytes(), 12);
6856        assert_eq!(owned.data(), &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
6857        assert_eq!(owned.timecode(), 123);
6858        assert_eq!(owned.timestamp(), 456);
6859        assert_eq!(owned.channel_data(1).unwrap().as_ptr(), unsafe {
6860            owned.data().as_ptr().add(3)
6861        });
6862        owned.channel_data_mut(1).unwrap()[0] = 99.0;
6863        assert_eq!(owned.data()[3], 99.0);
6864        assert!(owned.channel_data_mut(2).is_none());
6865        assert!(owned.replace_data(vec![0.0; 10]).is_err());
6866    }
6867
6868    #[test]
6869    fn test_audio_rejects_unaligned_float_pointer_before_access() {
6870        let mut storage = [0.0f32; 4];
6871        let raw = NDIlib_audio_frame_v3_t {
6872            sample_rate: 48000,
6873            no_channels: 1,
6874            no_samples: 1,
6875            FourCC: AudioFormat::FLTP.into(),
6876            p_data: unsafe { storage.as_mut_ptr().cast::<u8>().add(1) },
6877            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6878                channel_stride_in_bytes: 4,
6879            },
6880            timecode: 0,
6881            timestamp: 0,
6882            p_metadata: ptr::null(),
6883        };
6884        assert!(matches!(
6885            validate_audio_layout(&raw),
6886            Err(Error::InvalidFrame(_))
6887        ));
6888    }
6889
6890    /// Test validate_audio_layout supports strided planar FLTP audio.
6891    #[test]
6892    fn test_validate_audio_layout_strided_planar() {
6893        let no_samples = 4;
6894        let no_channels = 2;
6895        let stride_samples = 6;
6896        let backing_samples = (stride_samples + no_samples) as usize;
6897        let mut data = vec![0.0f32; backing_samples];
6898
6899        let raw = NDIlib_audio_frame_v3_t {
6900            sample_rate: 48000,
6901            no_channels,
6902            no_samples,
6903            timecode: 0,
6904            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6905            p_data: data.as_mut_ptr() as *mut u8,
6906            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6907                channel_stride_in_bytes: stride_samples * 4,
6908            },
6909            p_metadata: ptr::null(),
6910            timestamp: 0,
6911        };
6912
6913        let layout = validate_audio_layout(&raw).expect("strided FLTP should validate");
6914        assert_eq!(layout.sample_count, backing_samples);
6915        assert_eq!(layout.channel_stride_samples, stride_samples as usize);
6916        assert_eq!(layout.channel_range(1), Some(6..10));
6917    }
6918
6919    /// Test validate_audio_layout_allow_empty accepts the documented no-source query state.
6920    #[test]
6921    fn test_validate_audio_layout_empty_query_no_source() {
6922        let raw = NDIlib_audio_frame_v3_t::default();
6923
6924        let layout =
6925            validate_audio_layout_allow_empty(&raw).expect("all-zero query state should validate");
6926        assert!(layout.is_empty());
6927        assert_eq!(layout.format(), None);
6928        assert_eq!(layout.sample_count, 0);
6929    }
6930
6931    /// Test validate_audio_layout_allow_empty accepts source-format query without samples.
6932    #[test]
6933    fn test_validate_audio_layout_empty_query_with_source_format() {
6934        let raw = NDIlib_audio_frame_v3_t {
6935            sample_rate: 48000,
6936            no_channels: 2,
6937            no_samples: 0,
6938            timecode: 0,
6939            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6940            p_data: ptr::null_mut(),
6941            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6942                channel_stride_in_bytes: 0,
6943            },
6944            p_metadata: ptr::null(),
6945            timestamp: 0,
6946        };
6947
6948        let layout = validate_audio_layout_allow_empty(&raw)
6949            .expect("query source format should validate without samples");
6950        assert!(layout.is_empty());
6951        assert_eq!(layout.format(), Some(AudioFormat::FLTP));
6952        assert_eq!(layout.sample_rate, 48000);
6953        assert_eq!(layout.no_channels, 2);
6954    }
6955
6956    /// Test empty audio validation rejects partial query/no-source states.
6957    #[test]
6958    fn test_validate_audio_layout_rejects_partial_empty_query() {
6959        let raw = NDIlib_audio_frame_v3_t {
6960            sample_rate: 48000,
6961            no_channels: 0,
6962            no_samples: 0,
6963            timecode: 0,
6964            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6965            p_data: ptr::null_mut(),
6966            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6967                channel_stride_in_bytes: 0,
6968            },
6969            p_metadata: ptr::null(),
6970            timestamp: 0,
6971        };
6972
6973        let result = validate_audio_layout_allow_empty(&raw);
6974        assert!(matches!(result, Err(Error::InvalidFrame(_))));
6975    }
6976
6977    /// Test validate_audio_layout rejects invalid channel stride.
6978    #[test]
6979    fn test_validate_audio_layout_rejects_invalid_channel_stride() {
6980        let mut data = vec![0.0f32; 2048];
6981
6982        let raw = NDIlib_audio_frame_v3_t {
6983            sample_rate: 48000,
6984            no_channels: 2,
6985            no_samples: 1024,
6986            timecode: 0,
6987            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
6988            p_data: data.as_mut_ptr() as *mut u8,
6989            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
6990                channel_stride_in_bytes: 1024 * 4 - 4,
6991            },
6992            p_metadata: ptr::null(),
6993            timestamp: 0,
6994        };
6995
6996        let result = validate_audio_layout(&raw);
6997        assert!(matches!(result, Err(Error::InvalidFrame(_))));
6998    }
6999
7000    /// Test validate_audio_layout rejects null data pointer
7001    #[test]
7002    fn test_validate_audio_layout_null_pointer() {
7003        let raw = NDIlib_audio_frame_v3_t {
7004            sample_rate: 48000,
7005            no_channels: 2,
7006            no_samples: 1024,
7007            timecode: 0,
7008            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
7009            p_data: ptr::null_mut(),
7010            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
7011                channel_stride_in_bytes: 1024 * 4,
7012            },
7013            p_metadata: ptr::null(),
7014            timestamp: 0,
7015        };
7016
7017        let result = validate_audio_layout(&raw);
7018        assert!(result.is_err(), "Should reject null data pointer");
7019
7020        if let Err(Error::InvalidFrame(msg)) = result {
7021            assert!(
7022                msg.contains("null data pointer"),
7023                "Error should mention null pointer, got: {msg}"
7024            );
7025        } else {
7026            panic!("Expected InvalidFrame error");
7027        }
7028    }
7029
7030    /// Test validate_audio_layout rejects negative sample count
7031    #[test]
7032    fn test_validate_audio_layout_negative_samples() {
7033        let mut data = vec![0.0f32; 1024];
7034
7035        let raw = NDIlib_audio_frame_v3_t {
7036            sample_rate: 48000,
7037            no_channels: 2,
7038            no_samples: -1, // Negative
7039            timecode: 0,
7040            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
7041            p_data: data.as_mut_ptr() as *mut u8,
7042            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
7043                channel_stride_in_bytes: 1024 * 4,
7044            },
7045            p_metadata: ptr::null(),
7046            timestamp: 0,
7047        };
7048
7049        let result = validate_audio_layout(&raw);
7050        assert!(result.is_err(), "Should reject negative sample count");
7051
7052        if let Err(Error::InvalidFrame(msg)) = result {
7053            assert!(
7054                msg.contains("Invalid number of samples"),
7055                "Error should mention invalid samples, got: {msg}"
7056            );
7057        } else {
7058            panic!("Expected InvalidFrame error");
7059        }
7060    }
7061
7062    /// Test validate_audio_layout rejects overflow scenario
7063    #[test]
7064    fn test_validate_audio_layout_overflow() {
7065        let mut data = vec![0.0f32; 1024];
7066
7067        let raw = NDIlib_audio_frame_v3_t {
7068            sample_rate: 48000,
7069            no_channels: i32::MAX,
7070            no_samples: 1024,
7071            timecode: 0,
7072            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
7073            p_data: data.as_mut_ptr() as *mut u8,
7074            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
7075                channel_stride_in_bytes: 1024 * 4,
7076            },
7077            p_metadata: ptr::null(),
7078            timestamp: 0,
7079        };
7080
7081        let result = validate_audio_layout(&raw);
7082        assert!(
7083            result.is_err(),
7084            "Should reject audio frame with overflow potential"
7085        );
7086
7087        if let Err(Error::InvalidFrame(msg)) = result {
7088            assert!(
7089                msg.contains("overflow") || msg.contains("exceeds maximum size"),
7090                "Error should mention overflow or size limit, got: {msg}"
7091            );
7092        } else {
7093            panic!("Expected InvalidFrame error");
7094        }
7095    }
7096
7097    /// Test calculate_buffer_len_checked matches PixelFormatInfo::try_buffer_len for valid inputs
7098    #[test]
7099    fn test_calculate_buffer_len_checked_matches_public_helper() {
7100        let test_cases = [
7101            (PixelFormat::BGRA, 7680usize, 1080usize),
7102            (PixelFormat::UYVY, 3840usize, 1080usize),
7103            (PixelFormat::NV12, 1920usize, 1080usize),
7104            (PixelFormat::YV12, 1920usize, 1080usize),
7105            (PixelFormat::I420, 1920usize, 1080usize),
7106        ];
7107
7108        for (format, stride, height) in test_cases {
7109            let expected = format
7110                .info()
7111                .try_buffer_len(stride as i32, height as i32)
7112                .unwrap();
7113            let result = calculate_buffer_len_checked(format, stride, height);
7114
7115            assert!(
7116                result.is_ok(),
7117                "Should succeed for valid inputs: {:?}",
7118                format
7119            );
7120            assert_eq!(
7121                result.unwrap(),
7122                expected,
7123                "Checked calculation should match unchecked for {:?}",
7124                format
7125            );
7126        }
7127    }
7128
7129    /// Test that VideoFrameRef::data() uses cached length
7130    #[test]
7131    fn test_video_frame_ref_uses_cached_length() {
7132        use crate::capture::{Guard, VideoKind};
7133
7134        // Create a mock video frame for testing
7135        let width = 1920;
7136        let height = 1080;
7137        let stride = width * 4;
7138        let expected_size = (stride * height) as usize;
7139        let data = vec![0u8; expected_size];
7140
7141        let raw = NDIlib_video_frame_v2_t {
7142            xres: width,
7143            yres: height,
7144            FourCC: PixelFormat::BGRA.into(),
7145            frame_rate_N: 60,
7146            frame_rate_D: 1,
7147            picture_aspect_ratio: 16.0 / 9.0,
7148            frame_format_type: ScanType::Progressive.into(),
7149            timecode: 12345,
7150            p_data: data.as_ptr() as *mut u8,
7151            __bindgen_anon_1: NDIlib_video_frame_v2_t__bindgen_ty_1 {
7152                line_stride_in_bytes: stride,
7153            },
7154            p_metadata: ptr::null(),
7155            timestamp: 67890,
7156        };
7157
7158        // Create a guard with a null receiver instance (we won't use the free function)
7159        // This is safe because we'll forget the guard before it drops
7160        let guard = unsafe { Guard::<VideoKind>::new(ptr::null_mut(), raw) };
7161
7162        // Create the VideoFrameRef
7163        let frame_ref = unsafe { VideoFrameRef::new(guard) };
7164        assert!(frame_ref.is_ok(), "Should create valid VideoFrameRef");
7165
7166        let frame_ref = frame_ref.unwrap();
7167
7168        // Verify the cached length is used
7169        assert_eq!(
7170            frame_ref.data().unwrap().len(),
7171            expected_size,
7172            "data() should return slice with cached length"
7173        );
7174        assert_eq!(
7175            frame_ref.layout.data_len_bytes, expected_size,
7176            "Cached data_len_bytes should match expected"
7177        );
7178
7179        // Forget the guard to prevent calling the free function with null instance
7180        std::mem::forget(frame_ref);
7181    }
7182
7183    /// Test that AudioFrameRef::data() uses cached sample count
7184    #[test]
7185    fn test_audio_frame_ref_uses_cached_sample_count() {
7186        use crate::capture::RecvAudioGuard;
7187
7188        let no_samples = 1024;
7189        let no_channels = 2;
7190        let sample_count = (no_samples * no_channels) as usize;
7191        let data = vec![0.5f32; sample_count];
7192
7193        let raw = NDIlib_audio_frame_v3_t {
7194            sample_rate: 48000,
7195            no_channels,
7196            no_samples,
7197            timecode: 12345,
7198            FourCC: NDIlib_FourCC_audio_type_e_NDIlib_FourCC_audio_type_FLTP,
7199            p_data: data.as_ptr() as *mut u8,
7200            __bindgen_anon_1: NDIlib_audio_frame_v3_t__bindgen_ty_1 {
7201                channel_stride_in_bytes: no_samples * 4,
7202            },
7203            p_metadata: ptr::null(),
7204            timestamp: 67890,
7205        };
7206
7207        // Create a guard with a null receiver instance (we won't use the free function)
7208        let guard = unsafe { RecvAudioGuard::new(ptr::null_mut(), raw) };
7209
7210        // Create the AudioFrameRef
7211        let frame_ref = unsafe { AudioFrameRef::new(guard) };
7212        assert!(frame_ref.is_ok(), "Should create valid AudioFrameRef");
7213
7214        let frame_ref = frame_ref.unwrap();
7215
7216        // Verify the cached sample count is used
7217        assert_eq!(
7218            frame_ref.data().unwrap().len(),
7219            sample_count,
7220            "data() should return slice with cached sample count"
7221        );
7222        assert_eq!(
7223            frame_ref.layout.sample_count, sample_count,
7224            "Cached sample_count should match expected"
7225        );
7226
7227        // Forget the guard to prevent calling the free function with null instance
7228        std::mem::forget(frame_ref);
7229    }
7230}