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

            
14
use crate::resources::RawImageFormat;
15
use crate::url::Url;
16
use 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
24
pub 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

            
33
impl Default for VideoSource {
34
478
    fn default() -> Self {
35
478
        Self::Url(Url::default())
36
478
    }
37
}
38

            
39
/// Requested video-playback configuration.
40
#[repr(C)]
41
#[derive(Debug, Clone, PartialEq)]
42
pub 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

            
58
impl Default for VideoConfig {
59
471
    fn default() -> Self {
60
471
        Self {
61
471
            source: VideoSource::default(),
62
471
            timestamp: 0.0,
63
471
            autoplay: true,
64
471
            looping: false,
65
471
            output_format: RawImageFormat::BGRA8,
66
471
        }
67
471
    }
68
}
69

            
70
impl VideoConfig {
71
    /// A default config playing `source` (autoplay on, no loop, BGRA8, t=0).
72
18
    #[must_use] pub fn new(source: VideoSource) -> Self {
73
18
        Self {
74
18
            source,
75
18
            ..Self::default()
76
18
        }
77
18
    }
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)]
89
pub 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

            
98
impl VideoFrame {
99
    /// A frame wrapping `bytes` (tightly-packed RGBA8, `width * height * 4`).
100
66
    #[must_use] pub const fn new(width: u32, height: u32, bytes: U8Vec) -> Self {
101
66
        Self {
102
66
            width,
103
66
            height,
104
66
            bytes,
105
66
        }
106
66
    }
107
}
108

            
109
// FFI Option wrapper for a frame-pull hook / accessor. `copy = false` (U8Vec).
110
impl_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`).
116
impl_vec!(VideoFrame, VideoFrameVec, VideoFrameVecDestructor, VideoFrameVecDestructorType, VideoFrameVecSlice, OptionVideoFrame);
117
impl_vec_debug!(VideoFrame, VideoFrameVec);
118
impl_vec_clone!(VideoFrame, VideoFrameVec, VideoFrameVecDestructor);
119
impl_vec_partialeq!(VideoFrame, VideoFrameVec);
120

            
121
#[cfg(test)]
122
mod 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
}