Skip to main content

azul_core/
video.rs

1//! POD types for the video-playback surface
2//! (SUPER_PLAN_2 §4 Priority 6 + research).
3//!
4//! Same "dumb widget" architecture as camera/screencap
5//! (`azul_layout::widgets::video::VideoWidget`): a background thread decodes
6//! the source (vk-video - GPU decode + HTTP-range fetch) and its writeback
7//! uploads each frame into the shared GL-texture `ImageRef` + recomposites.
8//! Defined here in `azul-core` so the config crosses the FFI without
9//! `azul-layout` (or vk-video) as a dependency.
10//!
11//! Unlike the camera/screencap configs this carries a `source` string, so
12//! it's `Clone` but not `Copy`.
13
14use crate::resources::RawImageFormat;
15use crate::url::Url;
16use azul_css::{AzString, U8Vec};
17#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
18/// Where a video widget pulls its H.264/MP4 data from — strongly typed so the
19/// decode worker matches on it directly (no `RefAny` downcast). Mirrors
20/// [`crate::screencap::ScreenCaptureSource`].
21#[repr(C, u8)]
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[allow(clippy::large_enum_variant)] // #[repr(C,u8)] FFI enum: boxing a variant changes the C ABI/api.json
24pub enum VideoSource {
25    /// An HTTP(S) URL, fetched on the decode thread via an HTTP range request.
26    Url(Url),
27    /// A local filesystem path.
28    File(AzString),
29    /// Raw MP4 bytes already in memory.
30    Bytes(U8Vec),
31}
32
33impl Default for VideoSource {
34    fn default() -> Self {
35        Self::Url(Url::default())
36    }
37}
38
39/// Requested video-playback configuration.
40#[repr(C)]
41#[derive(Debug, Clone, PartialEq)]
42pub struct VideoConfig {
43    /// Where to load the video from (URL / file path / in-memory bytes).
44    pub source: VideoSource,
45    /// Seek / scrub position in seconds. Changing it across a relayout makes the
46    /// widget's merge callback tell the decode worker to seek (scrubbing
47    /// timeline) — the decoder survives relayout like the map's tile cache.
48    pub timestamp: f32,
49    /// Start playing automatically on mount.
50    pub autoplay: bool,
51    /// Restart from the beginning when the stream ends.
52    pub looping: bool,
53    /// Texture format the decoder delivers. `BGRA8` is the portable default;
54    /// `Nv12` (a later `RawImageFormat` addition) is the zero-copy path.
55    pub output_format: RawImageFormat,
56}
57
58impl Default for VideoConfig {
59    fn default() -> Self {
60        Self {
61            source: VideoSource::default(),
62            timestamp: 0.0,
63            autoplay: true,
64            looping: false,
65            output_format: RawImageFormat::BGRA8,
66        }
67    }
68}
69
70impl VideoConfig {
71    /// A default config playing `source` (autoplay on, no loop, BGRA8, t=0).
72    #[must_use] pub fn new(source: VideoSource) -> Self {
73        Self {
74            source,
75            ..Self::default()
76        }
77    }
78}
79
80/// One captured or decoded frame - tightly-packed RGBA8 pixels
81/// (`width * height * 4`).
82///
83/// The unit a capture/decode worker produces, the
84/// `set_on_frame` hook hands to user code (effects / save / send), and (P8)
85/// azul-meet sends over UDP. Defined here (like [`crate::audio::AudioFrame`])
86/// so it crosses the FFI without `azul-layout` as a dependency.
87#[repr(C)]
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct VideoFrame {
90    /// Frame width in px.
91    pub width: u32,
92    /// Frame height in px.
93    pub height: u32,
94    /// Tightly-packed RGBA8 pixel bytes (`width * height * 4`).
95    pub bytes: U8Vec,
96}
97
98impl VideoFrame {
99    /// A frame wrapping `bytes` (tightly-packed RGBA8, `width * height * 4`).
100    #[must_use] pub const fn new(width: u32, height: u32, bytes: U8Vec) -> Self {
101        Self {
102            width,
103            height,
104            bytes,
105        }
106    }
107}
108
109// FFI Option wrapper for a frame-pull hook / accessor. `copy = false` (U8Vec).
110impl_option!(VideoFrame, OptionVideoFrame, copy = false, [Clone, Debug]);
111
112// FFI `Vec<VideoFrame>` wrapper — the list a batch decode (`DecodedVideo`,
113// `dll::desktop::extra::video_codec::pipeline`) hands back across the C ABI.
114// `VideoFrame` derives Debug + Clone + PartialEq, so mirror exactly those Vec
115// trait impls (no PartialOrd: `VideoFrame` isn't `PartialOrd`).
116impl_vec!(VideoFrame, VideoFrameVec, VideoFrameVecDestructor, VideoFrameVecDestructorType, VideoFrameVecSlice, OptionVideoFrame);
117impl_vec_debug!(VideoFrame, VideoFrameVec);
118impl_vec_clone!(VideoFrame, VideoFrameVec, VideoFrameVecDestructor);
119impl_vec_partialeq!(VideoFrame, VideoFrameVec);
120
121#[cfg(test)]
122mod autotest_generated {
123    use alloc::{string::String, vec, vec::Vec};
124
125    use super::*;
126
127    // --- helpers ---------------------------------------------------------
128
129    /// Owned (`DefaultRust`-destructor) `U8Vec` — the shape a real decode
130    /// worker hands back, so clone/drop actually exercise the allocator.
131    fn u8v(bytes: Vec<u8>) -> U8Vec {
132        U8Vec::from_vec(bytes)
133    }
134
135    fn frame(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
136        VideoFrame::new(width, height, u8v(bytes))
137    }
138
139    /// The `bytes` of a `VideoSource::Bytes`; test-only, the other variants
140    /// are never passed here.
141    fn source_bytes(source: &VideoSource) -> &U8Vec {
142        match source {
143            VideoSource::Bytes(b) => b,
144            other => panic!("expected VideoSource::Bytes, got {other:?}"),
145        }
146    }
147
148    /// The documented `VideoFrame` size invariant, computed in u128 so that the
149    /// expectation itself cannot overflow (u64 does, at max dimensions).
150    fn declared_len(width: u32, height: u32) -> u128 {
151        u128::from(width) * u128::from(height) * 4
152    }
153
154    // --- VideoConfig::new (constructor) ----------------------------------
155
156    /// invariants_hold: every non-source field is the documented default
157    /// (autoplay on, no loop, BGRA8, t=0) and `source` is stored verbatim.
158    #[test]
159    fn config_new_fields_match_args() {
160        let url = Url::from_parts("https", "example.com", 443, "/movie.mp4");
161        let c = VideoConfig::new(VideoSource::Url(url.clone()));
162
163        assert_eq!(c.source, VideoSource::Url(url));
164        assert!((c.timestamp - 0.0).abs() < f32::EPSILON);
165        assert!(c.autoplay);
166        assert!(!c.looping);
167        assert_eq!(c.output_format, RawImageFormat::BGRA8);
168    }
169
170    /// invariants_hold: `new` sets ONLY `source` — every other field stays
171    /// bit-for-bit identical to `Default`, for each of the three variants.
172    #[test]
173    fn config_new_only_overrides_source() {
174        let d = VideoConfig::default();
175
176        for source in [
177            VideoSource::Url(Url::from_parts("http", "a.tld", 8080, "/v")),
178            VideoSource::File(AzString::from_const_str("/tmp/clip.mp4")),
179            VideoSource::Bytes(u8v(vec![0xFF; 8])),
180        ] {
181            let c = VideoConfig::new(source.clone());
182            assert_eq!(c.source, source, "source must round-trip unchanged");
183            assert_eq!(c.timestamp.to_bits(), d.timestamp.to_bits());
184            assert_eq!(c.autoplay, d.autoplay);
185            assert_eq!(c.looping, d.looping);
186            assert_eq!(c.output_format, d.output_format);
187        }
188    }
189
190    /// invariants_hold: the default source funnels back to the plain `Default`.
191    #[test]
192    fn config_new_with_default_source_equals_default() {
193        assert_eq!(VideoConfig::new(VideoSource::default()), VideoConfig::default());
194        assert_eq!(VideoSource::default(), VideoSource::Url(Url::default()));
195    }
196
197    /// unicode: a non-ASCII file path (emoji, CJK, RTL, combining marks) must
198    /// survive the `AzString` round-trip byte-for-byte — no truncation at a
199    /// multi-byte boundary, no re-encoding.
200    #[test]
201    fn config_new_unicode_file_path_roundtrip() {
202        let path = String::from("/tmp/vídeos/𝔘𝔫𝔦/影片 🎬/مقطع/e\u{0301}.mp4");
203        let c = VideoConfig::new(VideoSource::File(AzString::from(path.clone())));
204
205        match &c.source {
206            VideoSource::File(s) => {
207                assert_eq!(s.as_str(), path.as_str());
208                assert_eq!(s.as_str().len(), path.len(), "byte length preserved");
209                assert_eq!(s.as_str().chars().count(), path.chars().count());
210            }
211            other => panic!("expected File, got {other:?}"),
212        }
213    }
214
215    /// malformed: an interior NUL and control bytes are legal in a Rust `str`
216    /// and must be preserved verbatim (any C-side truncation is the FFI layer's
217    /// problem, not this constructor's).
218    #[test]
219    fn config_new_file_path_with_interior_nul_preserved() {
220        let path = String::from("/tmp/a\0b\r\n\t.mp4");
221        let c = VideoConfig::new(VideoSource::File(AzString::from(path.clone())));
222
223        match &c.source {
224            VideoSource::File(s) => {
225                assert_eq!(s.as_str(), path.as_str());
226                assert_eq!(s.as_str().len(), 15);
227                assert!(s.as_str().contains('\0'));
228            }
229            other => panic!("expected File, got {other:?}"),
230        }
231    }
232
233    /// boundary: an empty path / empty byte source must not panic and must stay empty.
234    #[test]
235    fn config_new_empty_sources_no_panic() {
236        let empty_file = VideoConfig::new(VideoSource::File(AzString::from(String::new())));
237        match &empty_file.source {
238            VideoSource::File(s) => assert_eq!(s.as_str(), ""),
239            other => panic!("expected File, got {other:?}"),
240        }
241
242        let empty_bytes = VideoConfig::new(VideoSource::Bytes(u8v(Vec::new())));
243        let b = source_bytes(&empty_bytes.source);
244        assert!(b.is_empty());
245        assert_eq!(b.len(), 0);
246        assert_eq!(b.as_ref(), &[] as &[u8]);
247    }
248
249    /// huge: a 1 MiB in-memory MP4 must move into the config with no copy loss —
250    /// check both ends of the buffer, not just the length.
251    #[test]
252    fn config_new_huge_bytes_source_preserved() {
253        const N: usize = 1 << 20;
254        let data: Vec<u8> = (0..N).map(|i| (i % 251) as u8).collect();
255
256        let c = VideoConfig::new(VideoSource::Bytes(u8v(data.clone())));
257        let b = source_bytes(&c.source);
258
259        let last = ((N - 1) % 251) as u8;
260        assert_eq!(b.len(), N);
261        assert_eq!(b.as_ref(), data.as_slice());
262        assert_eq!(b.get(0), Some(&0));
263        assert_eq!(b.get(N - 1), Some(&last));
264        assert_eq!(b.get(N), None, "one past the end must be None, not UB");
265    }
266
267    /// invariants_hold: `Clone` of a `Bytes` config is a DEEP copy — the clone
268    /// owns its own allocation and stays readable after the original is dropped
269    /// (a shallow copy here would be a double-free at the second drop).
270    #[test]
271    fn config_clone_of_bytes_is_deep_and_survives_original_drop() {
272        let original = VideoConfig::new(VideoSource::Bytes(u8v(vec![1, 2, 3, 4, 5])));
273        let original_ptr = source_bytes(&original.source).as_ptr();
274
275        let cloned = original.clone();
276        let cloned_ptr = source_bytes(&cloned.source).as_ptr();
277        assert_ne!(original_ptr, cloned_ptr, "clone must not alias the original buffer");
278
279        drop(original);
280
281        assert_eq!(source_bytes(&cloned.source).as_ref(), &[1, 2, 3, 4, 5]);
282    }
283
284    /// invariants_hold: the variants are genuinely distinct — a `File` path and
285    /// a `Url` with the same text are NOT equal, so the decode worker's match
286    /// can't be spoofed.
287    #[test]
288    fn config_source_variants_are_distinct() {
289        let text = "https://example.com/v.mp4";
290        let as_file = VideoConfig::new(VideoSource::File(AzString::from_const_str(text)));
291        let as_url = VideoConfig::new(VideoSource::Url(Url::from_parts(
292            "https",
293            "example.com",
294            443,
295            "/v.mp4",
296        )));
297
298        assert_ne!(as_file, as_url);
299        assert_ne!(as_file.source, as_url.source);
300        assert_eq!(as_url.source, as_url.source.clone());
301    }
302
303    /// numeric/limits: extreme scrub positions are stored verbatim (the POD does
304    /// no clamping) — infinities, subnormals and f32::MAX must not panic.
305    #[test]
306    fn config_extreme_timestamps_stored_verbatim() {
307        let base = VideoConfig::new(VideoSource::default());
308
309        for t in [
310            f32::INFINITY,
311            f32::NEG_INFINITY,
312            f32::MAX,
313            f32::MIN,
314            f32::MIN_POSITIVE,
315            f32::MIN_POSITIVE / 2.0, // subnormal
316            -1.0,
317            1e30,
318        ] {
319            let c = VideoConfig {
320                timestamp: t,
321                ..base.clone()
322            };
323            assert_eq!(c.timestamp.to_bits(), t.to_bits(), "timestamp {t} was altered");
324            assert_eq!(c, c.clone(), "non-NaN config must be self-equal");
325        }
326    }
327
328    /// numeric/NaN: derived `PartialEq` over an f32 field means a NaN timestamp
329    /// makes a config unequal to an identical config (reflexivity is broken).
330    /// Callers must not use `VideoConfig` equality to decide "did the seek
331    /// change?" if a NaN can reach `timestamp` — assert the hazard explicitly.
332    #[test]
333    fn config_nan_timestamp_breaks_reflexive_equality() {
334        let nan_a = VideoConfig {
335            timestamp: f32::NAN,
336            ..VideoConfig::new(VideoSource::default())
337        };
338        let nan_b = nan_a.clone();
339
340        assert!(nan_a.timestamp.is_nan());
341        assert_ne!(nan_a, nan_b, "NaN timestamp: derived PartialEq is not reflexive");
342
343        // ...and `new` itself never produces such a config.
344        let fresh = VideoConfig::new(VideoSource::default());
345        assert!(!fresh.timestamp.is_nan());
346        assert_eq!(fresh, fresh.clone());
347    }
348
349    /// numeric: -0.0 compares EQUAL to +0.0 (IEEE), even though the bits differ —
350    /// so a config scrubbed to -0.0 is `==` to a freshly constructed one.
351    #[test]
352    fn config_negative_zero_timestamp_equals_default() {
353        let fresh = VideoConfig::new(VideoSource::default());
354        let neg_zero = VideoConfig {
355            timestamp: -0.0,
356            ..VideoConfig::new(VideoSource::default())
357        };
358
359        assert!(neg_zero.timestamp.is_sign_negative());
360        assert_ne!(neg_zero.timestamp.to_bits(), fresh.timestamp.to_bits());
361        assert_eq!(neg_zero, fresh, "IEEE: -0.0 == +0.0");
362    }
363
364    // --- VideoFrame::new (constructor) -----------------------------------
365
366    /// invariants_hold: fields match the exact args and the byte buffer round-trips.
367    #[test]
368    fn frame_new_fields_match_args() {
369        let pixels: Vec<u8> = (0..(3 * 2 * 4)).map(|i| i as u8).collect();
370        let f = frame(3, 2, pixels.clone());
371
372        assert_eq!(f.width, 3);
373        assert_eq!(f.height, 2);
374        assert_eq!(f.bytes.as_ref(), pixels.as_slice());
375        assert_eq!(u128::from(f.bytes.len() as u64), declared_len(3, 2));
376    }
377
378    /// round-trip: all 256 byte values survive `Vec -> U8Vec -> slice` unchanged
379    /// (no sign-extension / no UTF-8 sanitisation on the pixel path).
380    #[test]
381    fn frame_bytes_roundtrip_all_byte_values() {
382        let pixels: Vec<u8> = (0..=255u8).collect(); // 256 B == 8x8 RGBA
383        let f = frame(8, 8, pixels.clone());
384
385        assert_eq!(f.bytes.as_ref(), pixels.as_slice());
386        assert_eq!(f.bytes.len(), 256);
387        assert_eq!(u128::from(f.bytes.len() as u64), declared_len(8, 8));
388        assert_eq!(f.bytes.get(0), Some(&0));
389        assert_eq!(f.bytes.get(255), Some(&255));
390        assert_eq!(f.bytes.get(256), None);
391        assert!(f.bytes.iter().copied().eq(pixels.iter().copied()));
392    }
393
394    /// boundary: a 0x0 frame with no bytes is legal and must not panic.
395    #[test]
396    fn frame_new_zero_dimensions_empty_bytes() {
397        let f = frame(0, 0, Vec::new());
398
399        assert_eq!(f.width, 0);
400        assert_eq!(f.height, 0);
401        assert!(f.bytes.is_empty());
402        assert_eq!(f.bytes.as_ref(), &[] as &[u8]);
403        assert_eq!(declared_len(0, 0), 0);
404    }
405
406    /// boundary: a degenerate strip (n x 0 / 0 x n) keeps its nonzero dimension.
407    #[test]
408    fn frame_new_degenerate_strips() {
409        let wide = frame(1920, 0, Vec::new());
410        assert_eq!((wide.width, wide.height), (1920, 0));
411        assert!(wide.bytes.is_empty());
412
413        let tall = frame(0, 1080, Vec::new());
414        assert_eq!((tall.width, tall.height), (0, 1080));
415        assert!(tall.bytes.is_empty());
416    }
417
418    /// numeric/overflow: `new` accepts u32::MAX dimensions without panicking or
419    /// touching `bytes` — it never computes `w * h * 4`. It is therefore the
420    /// CALLER that must do the size math in u128: at these dimensions the
421    /// declared buffer size overflows even u64.
422    #[test]
423    fn frame_new_max_dimensions_no_panic_and_size_math_overflows() {
424        let f = VideoFrame::new(u32::MAX, u32::MAX, U8Vec::from_vec(Vec::new()));
425
426        assert_eq!(f.width, u32::MAX);
427        assert_eq!(f.height, u32::MAX);
428        assert!(f.bytes.is_empty(), "new does no allocation of its own");
429
430        // The documented `width * height * 4` is unrepresentable here...
431        assert_eq!(u32::MAX.checked_mul(u32::MAX), None);
432        assert_eq!(
433            u64::from(u32::MAX)
434                .checked_mul(u64::from(u32::MAX))
435                .and_then(|px| px.checked_mul(4)),
436            None,
437            "even u64 overflows: callers must size-check before allocating"
438        );
439        // ...but u128 holds it, and that is what `declared_len` uses.
440        assert_eq!(declared_len(u32::MAX, u32::MAX), 4 * (u128::from(u32::MAX)).pow(2));
441    }
442
443    /// numeric/boundary: 32768x32768 is the smallest square frame whose RGBA byte
444    /// count (2^32) no longer fits in u32 — the constructor still accepts it.
445    #[test]
446    fn frame_new_u32_pixel_size_overflow_boundary() {
447        let f = VideoFrame::new(32_768, 32_768, U8Vec::from_vec(Vec::new()));
448        assert_eq!((f.width, f.height), (32_768, 32_768));
449
450        assert_eq!(
451            32_768u32.checked_mul(32_768).and_then(|px| px.checked_mul(4)),
452            None,
453            "2^30 px * 4 == 2^32 overflows u32"
454        );
455        assert_eq!(declared_len(32_768, 32_768), 1u128 << 32);
456        // One row narrower still fits.
457        assert_eq!(
458            32_767u32.checked_mul(32_768).and_then(|px| px.checked_mul(4)),
459            Some(4_294_836_224)
460        );
461    }
462
463    /// invariants_hold: `new` is a pure wrapper — it does NOT enforce
464    /// `bytes.len() == width * height * 4`. A short buffer is accepted as-is, so
465    /// consumers must validate before indexing (this is the documented contract,
466    /// pinned here so a future "validating" rewrite can't land silently).
467    #[test]
468    fn frame_new_does_not_validate_buffer_length() {
469        let short = frame(1, 1, vec![0xAA, 0xBB, 0xCC]); // 3 B, needs 4
470        assert_eq!(short.bytes.len(), 3);
471        assert_ne!(u128::from(short.bytes.len() as u64), declared_len(1, 1));
472
473        let long = frame(1, 1, vec![0; 64]); // 64 B, needs 4
474        assert_eq!(long.bytes.len(), 64);
475
476        // A frame claiming 4K but carrying nothing is likewise constructible.
477        let lying = frame(3840, 2160, Vec::new());
478        assert!(lying.bytes.is_empty());
479        assert_eq!(declared_len(3840, 2160), 33_177_600);
480    }
481
482    /// invariants_hold: `const fn new` really is usable in a const context.
483    #[test]
484    fn frame_new_is_const_evaluable() {
485        const F: VideoFrame = VideoFrame::new(1, 1, U8Vec::from_const_slice(&[1, 2, 3, 4]));
486
487        assert_eq!(F.width, 1);
488        assert_eq!(F.height, 1);
489        assert_eq!(F.bytes.as_ref(), &[1, 2, 3, 4]);
490        assert_eq!(u128::from(F.bytes.len() as u64), declared_len(1, 1));
491    }
492
493    /// invariants_hold: equality is field-wise — differing width, height or a
494    /// single differing pixel byte all compare unequal.
495    #[test]
496    fn frame_equality_is_fieldwise() {
497        let base = frame(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]);
498
499        assert_eq!(base, frame(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]));
500        assert_ne!(base, frame(1, 2, vec![1, 2, 3, 4, 5, 6, 7, 8]), "w/h swap differs");
501        assert_ne!(base, frame(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 9]), "last byte differs");
502        assert_ne!(base, frame(2, 1, vec![1, 2, 3, 4]), "truncated buffer differs");
503    }
504
505    /// invariants_hold: `Clone` deep-copies the pixels — the clone outlives the
506    /// original's drop and owns a distinct allocation.
507    #[test]
508    fn frame_clone_is_deep_and_survives_original_drop() {
509        let original = frame(2, 2, (0..16).collect());
510        let original_ptr = original.bytes.as_ptr();
511
512        let cloned = original.clone();
513        assert_ne!(original_ptr, cloned.bytes.as_ptr(), "clone must not alias");
514        assert_eq!(cloned, original);
515
516        drop(original);
517
518        assert_eq!(cloned.bytes.as_ref(), (0..16u8).collect::<Vec<_>>().as_slice());
519        assert_eq!(cloned.width, 2);
520        assert_eq!(cloned.height, 2);
521    }
522
523    // --- OptionVideoFrame / VideoFrameVec (FFI wrappers) ------------------
524
525    /// round-trip: `Option<VideoFrame> -> OptionVideoFrame -> Option<VideoFrame>`
526    /// is the identity for both `Some` and `None`.
527    #[test]
528    fn option_video_frame_roundtrip() {
529        let f = frame(1, 1, vec![9, 8, 7, 6]);
530
531        let some: OptionVideoFrame = Some(f.clone()).into();
532        assert!(some.is_some());
533        assert!(!some.is_none());
534        assert_eq!(some.as_ref(), Some(&f));
535        assert_eq!(Option::<VideoFrame>::from(some.clone()), Some(f.clone()));
536        assert_eq!(some.into_option(), Some(f));
537
538        let none: OptionVideoFrame = Option::<VideoFrame>::None.into();
539        assert!(none.is_none());
540        assert_eq!(none.as_ref(), None);
541        assert_eq!(Option::<VideoFrame>::from(none), None);
542        assert_eq!(OptionVideoFrame::default().into_option(), None);
543    }
544
545    /// invariants_hold: `replace` returns the PREVIOUS value (mem::replace semantics).
546    #[test]
547    fn option_video_frame_replace_returns_previous() {
548        let first = frame(1, 1, vec![1, 1, 1, 1]);
549        let second = frame(1, 1, vec![2, 2, 2, 2]);
550
551        let mut slot = OptionVideoFrame::None;
552        assert_eq!(slot.replace(first.clone()).into_option(), None);
553        assert_eq!(slot.replace(second.clone()).into_option(), Some(first));
554        assert_eq!(slot.into_option(), Some(second));
555    }
556
557    /// round-trip: `Vec<VideoFrame> -> VideoFrameVec -> slice` preserves order,
558    /// length and every frame, and out-of-bounds access returns None (not UB).
559    #[test]
560    fn frame_vec_roundtrip_and_bounds() {
561        let frames = vec![
562            frame(1, 1, vec![0, 0, 0, 255]),
563            frame(2, 1, vec![1; 8]),
564            frame(0, 0, Vec::new()),
565        ];
566
567        let v = VideoFrameVec::from_vec(frames.clone());
568
569        assert_eq!(v.len(), 3);
570        assert!(!v.is_empty());
571        assert_eq!(v.as_ref(), frames.as_slice());
572        assert_eq!(v.get(0), Some(&frames[0]));
573        assert_eq!(v.get(2), Some(&frames[2]));
574        assert_eq!(v.get(3), None, "out of bounds must be None");
575        assert_eq!(v.get(usize::MAX), None, "usize::MAX index must be None");
576        assert!(v.iter().eq(frames.iter()), "iteration order preserved");
577
578        // C-API accessor mirrors the Rust one.
579        assert_eq!(v.c_get(1).into_option(), Some(frames[1].clone()));
580        assert!(v.c_get(3).is_none());
581
582        // Deep clone: equal contents, distinct backing allocation.
583        let cloned = v.clone();
584        assert_eq!(cloned, v);
585        assert_ne!(cloned.as_ptr(), v.as_ptr());
586    }
587
588    /// boundary: the empty frame list is well-formed (empty slice, no panic).
589    #[test]
590    fn frame_vec_empty_is_well_formed() {
591        let v = VideoFrameVec::from_vec(Vec::new());
592
593        assert!(v.is_empty());
594        assert_eq!(v.len(), 0);
595        assert_eq!(v.as_ref(), &[] as &[VideoFrame]);
596        assert_eq!(v.get(0), None);
597        assert_eq!(v.clone(), v);
598        assert_eq!(VideoFrameVec::new(), v);
599    }
600
601    /// invariants_hold: a `'static`-backed (`NoDestructor`) frame list — the
602    /// const-data path across the FFI — reads back identically and its drop must
603    /// not free the static memory.
604    #[test]
605    fn frame_vec_from_const_slice_no_destructor() {
606        static FRAMES: [VideoFrame; 2] = [
607            VideoFrame::new(1, 1, U8Vec::from_const_slice(&[1, 2, 3, 4])),
608            VideoFrame::new(1, 1, U8Vec::from_const_slice(&[5, 6, 7, 8])),
609        ];
610
611        let v = VideoFrameVec::from_const_slice(&FRAMES);
612        assert_eq!(v.len(), 2);
613        assert_eq!(v.as_ref(), &FRAMES[..]);
614        assert_eq!(v.get(1).map(|f| f.bytes.as_ref()), Some(&[5, 6, 7, 8][..]));
615
616        // Cloning a const-slice vec yields an owned copy; both drop cleanly here.
617        let cloned = v.clone();
618        assert_eq!(cloned, v);
619        drop(v);
620        assert_eq!(cloned.len(), 2);
621    }
622}