1
//! Video-playback widget - a "dumb widget" identical in architecture to the
2
//! [`CameraWidget`](super::camera) / [`ScreenCaptureWidget`](super::screencap),
3
//! only the source differs (a video URL/file decoded via vk-video).
4
//! SUPER_PLAN_2 §4 P6, widget pivot.
5
//!
6
//! `VideoWidget::create(config).dom()` → an `<img>` a background decode thread
7
//! keeps fed; each frame goes through [`super::capture_common::present_frame`]
8
//! (GL-texture install-once / re-upload + recomposite). Shared core in
9
//! `capture_common`; this widget is its config + worker. Test-pattern worker
10
//! (scrolling SMPTE colour bars) stands in for the real vk-video decode worker.
11

            
12
use alloc::vec::Vec;
13

            
14
use azul_core::callbacks::{Update, VirtualViewCallbackInfo, VirtualViewReturn};
15
use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter, OptionDom};
16
use azul_core::geom::LogicalPosition;
17
use azul_core::refany::{OptionRefAny, RefAny};
18
use azul_core::resources::{ImageRef, RawImage, RawImageData, RawImageFormat};
19
use azul_core::task::{ThreadId, ThreadReceiver, ThreadSendMsg};
20
use azul_core::video::{VideoConfig, VideoFrame};
21

            
22
use super::capture_common::{
23
    invoke_on_frame, OnVideoFrame, OnVideoFrameCallback, OptionOnVideoFrame,
24
};
25
use crate::callbacks::{Callback, CallbackInfo, CallbackType};
26
use crate::thread::{
27
    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
28
};
29

            
30
/// Default decode size for the test pattern (the real decoder reports the
31
/// stream's actual size).
32
const DEFAULT_W: u32 = 1280;
33
const DEFAULT_H: u32 = 720;
34

            
35
/// Live state for one video widget, carried across relayout by
36
/// [`merge_video_state`].
37
#[derive(Debug)]
38
pub struct VideoWidgetState {
39
    /// The requested playback configuration (source + autoplay/loop).
40
    pub config: VideoConfig,
41
    /// `true` once the decode thread has been started.
42
    pub started: bool,
43
    /// The stable external GL texture id once installed.
44
    pub gl_texture_id: Option<u32>,
45
    /// Optional user hook invoked with each decoded frame (effects / save /
46
    /// send). Re-set on every fresh build (see [`merge_video_state`]).
47
    pub on_frame: OptionOnVideoFrame,
48
    /// Optional pre-decoded frames to replay (a `RefAny` holding a
49
    /// `Vec<VideoFrame>`); when set, the replay worker cycles these instead of
50
    /// the built-in test pattern. Carried forward by [`merge_video_state`].
51
    pub frames: OptionRefAny,
52
    /// The off-main-thread streaming decode worker (mirrors the map widget's
53
    /// `fetch_callback`). Set via [`VideoWidget::dom_with_decoder`]. When present,
54
    /// `AfterMount` spawns it on a background `Thread` instead of the replay /
55
    /// test-pattern workers, so the VK decode runs off the main thread.
56
    pub decode_callback: Option<ThreadCallback>,
57
    /// The latest decoded frame to display, as a CPU `ImageRef` (RGBA8). The
58
    /// `VirtualView` render callback ([`video_widget_render`]) reads this on each
59
    /// re-render; [`video_writeback`] stores it and triggers an in-place
60
    /// `VirtualView` re-render - so the frame renders on cpurender AND webrender,
61
    /// exactly like the map widget's tile cache. (Replaces the GL `present_frame`
62
    /// path for video; camera/screencap still use `present_frame`.)
63
    pub current_frame: Option<ImageRef>,
64
    /// The decode worker's `ThreadId` (set by `AfterMount`). Lets the resize callback
65
    /// message the running worker (`info.get_thread(id).sender.send(..)`) so it can
66
    /// re-target the decoder to the new physical-pixel size - a cheap image swap, no
67
    /// relayout. Carried across relayout by [`merge_video_state`].
68
    pub thread_id: Option<ThreadId>,
69
    /// Clone of the worker's main→worker `Sender` (set by `AfterMount`, carried by
70
    /// merge). Lets [`merge_video_state`] - which has no `CallbackInfo` - push a
71
    /// seek to the running worker when `config.timestamp` changes (scrubbing).
72
    pub seek_sender: Option<std::sync::mpsc::Sender<ThreadSendMsg>>,
73
}
74

            
75
/// A video-playback widget. `create(config).dom()` yields an `<img>` the
76
/// decode thread keeps fed.
77
#[repr(C)]
78
#[derive(Debug)]
79
pub struct VideoWidget {
80
    /// Source URL + autoplay/loop + format.
81
    pub config: VideoConfig,
82
    /// Optional per-frame user hook (effects / save / send - azul-meet).
83
    pub on_frame: OptionOnVideoFrame,
84
    /// Optional pre-decoded frames to replay (a `RefAny` holding a
85
    /// `Vec<VideoFrame>`); set via [`with_frames`](Self::with_frames). When
86
    /// present the widget cycles these instead of the test pattern.
87
    pub frames: OptionRefAny,
88
}
89

            
90
impl VideoWidget {
91
    /// Create a video widget for the given config.
92
51
    #[must_use] pub const fn create(config: VideoConfig) -> Self {
93
51
        Self {
94
51
            config,
95
51
            on_frame: OptionOnVideoFrame::None,
96
51
            frames: OptionRefAny::None,
97
51
        }
98
51
    }
99

            
100
    /// Set a hook invoked with every decoded frame - for live effects, saving
101
    /// frames into your data model, or sending them over the network
102
    /// (azul-meet). The backreference DI pattern (see `architecture.md`).
103
15
    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
104
15
        self.on_frame = Some(OnVideoFrame {
105
15
            refany: data,
106
15
            callback: on_frame.into(),
107
15
        })
108
15
        .into();
109
15
    }
110

            
111
    /// Builder form of [`set_on_frame`](Self::set_on_frame).
112
    #[must_use]
113
13
    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
114
13
        mut self,
115
13
        data: RefAny,
116
13
        on_frame: C,
117
13
    ) -> Self {
118
13
        self.set_on_frame(data, on_frame);
119
13
        self
120
13
    }
121

            
122
    /// Replay a list of already-decoded frames instead of the built-in test
123
    /// pattern: `frames` is a [`RefAny`] holding a `Vec<VideoFrame>`. The
124
    /// background worker cycles them through the shared GL presenter (the same
125
    /// `present_frame` path the camera/screencap widgets use), so callers that
126
    /// decode a clip up front (e.g. `decode_mp4_h264_bytes`) get real pixels on
127
    /// screen. The `RefAny` must carry a `Vec<VideoFrame>`, else playback is
128
    /// skipped and the test pattern shows instead.
129
17
    #[must_use] pub fn with_frames(mut self, frames: RefAny) -> Self {
130
17
        self.frames = Some(frames).into();
131
17
        self
132
17
    }
133

            
134
17
    fn build_dom(self, decode_cb: Option<ThreadCallback>) -> Dom {
135
17
        let state = VideoWidgetState {
136
17
            config: self.config,
137
17
            started: false,
138
17
            gl_texture_id: None,
139
17
            on_frame: self.on_frame,
140
17
            frames: self.frames,
141
17
            decode_callback: decode_cb,
142
17
            current_frame: None,
143
17
            thread_id: None,
144
17
            seek_sender: None,
145
17
        };
146
17
        let dataset = RefAny::new(state);
147
17
        let vv_data = dataset.clone();
148

            
149
        // The body is a VirtualView (exactly like the map widget): its render
150
        // callback re-reads `current_frame` from the dataset each re-render and
151
        // builds the `<img>`, so streamed frames render on BOTH cpurender and
152
        // webrender. The background decode worker is started on AfterMount and
153
        // `WriteBack`s frames into `current_frame` + triggers a VirtualView
154
        // re-render in place (no DOM rebuild) — see `video_writeback`. The caller
155
        // sizes the outer node via `.with_css(...)` on the returned Dom.
156
17
        Dom::create_div()
157
17
            .with_dataset(OptionRefAny::Some(dataset.clone()))
158
17
            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_video_state))
159
17
            .with_callback(
160
17
                EventFilter::Component(ComponentEventFilter::AfterMount),
161
17
                dataset.clone(),
162
17
                Callback::from_ptr(video_on_after_mount),
163
            )
164
            // Window/layout resize → re-target the decoder to the new physical size
165
            // (a cheap image swap, no relayout). See `video_on_resize`.
166
17
            .with_callback(
167
17
                EventFilter::Component(ComponentEventFilter::NodeResized),
168
17
                dataset,
169
17
                Callback::from_ptr(video_on_resize),
170
            )
171
17
            .with_child(
172
17
                Dom::create_virtual_view(
173
17
                    vv_data,
174
17
                    azul_core::callbacks::VirtualViewCallback::create(video_widget_render),
175
                )
176
17
                .with_css("width: 100%; height: 100%; overflow: hidden;"),
177
            )
178
17
    }
179

            
180
    /// Build the widget's DOM: a single `<img>` node a background thread keeps
181
    /// fed. Replays pre-decoded [`with_frames`](Self::with_frames) if given, else
182
    /// shows the built-in test pattern.
183
14
    #[must_use] pub fn dom(self) -> Dom {
184
14
        self.build_dom(None)
185
14
    }
186

            
187
    /// Build the widget's DOM and wire a background **streaming** decode worker -
188
    /// mirrors `MapWidget::dom_with_fetch`. `cb` runs on a framework `Thread` OFF
189
    /// the main thread: it reads the `VideoConfig` (its typed `VideoSource` -
190
    /// URL / file / bytes), runs the VK decode incrementally (no up-front decode),
191
    /// and `WriteBack`s frames to the `<img>` paced by wall-clock (dropping late
192
    /// frames). The standard worker is
193
    /// `azul_dll::desktop::extra::video_codec::stream::video_decode_worker`; wrap
194
    /// it in a `ThreadCallback` to pass it here.
195
3
    #[must_use] pub fn dom_with_decoder(self, cb: ThreadCallback) -> Dom {
196
3
        self.build_dom(Some(cb))
197
3
    }
198
}
199

            
200
/// `VirtualView` render callback (mirrors `map_widget_render`): build the `<img>`
201
/// for the latest decoded frame, re-read from the widget's dataset on every
202
/// re-render. The decode worker stores frames into `current_frame` and triggers
203
/// the re-render in place (see [`video_writeback`]), so this renders on both the
204
/// CPU and GPU renderers with no DOM rebuild.
205
25
extern "C" fn video_widget_render(
206
25
    mut data: RefAny,
207
25
    info: VirtualViewCallbackInfo,
208
25
) -> VirtualViewReturn {
209
25
    let bounds = info.get_bounds().get_logical_size();
210
25
    if std::env::var("AZ_VIDEO_FRAMELOG").is_ok() {
211
        eprintln!("[vrender] bounds {}x{}", bounds.width, bounds.height);
212
25
    }
213
    // Defensive (like map_widget_render): a non-finite / non-positive box (layout
214
    // not yet settled, e.g. flex-grow before the parent height resolves) would
215
    // produce a garbage `<img>` size — render nothing until it settles.
216
25
    let dom = if !bounds.width.is_finite()
217
22
        || !bounds.height.is_finite()
218
20
        || bounds.width <= 0.0
219
16
        || bounds.height <= 0.0
220
    {
221
10
        OptionDom::None
222
    } else {
223
15
        data.downcast_ref::<VideoWidgetState>().map_or(OptionDom::None, |s| {
224
14
            s.current_frame.as_ref().map_or_else(
225
2
                || {
226
                    // Poster / "no signal" placeholder. Returning None here
227
                    // rendered NOTHING, so a decoder that never produced a
228
                    // frame (missing video-native feature, non-x86_64 target,
229
                    // Vulkan init failure, network stall) was
230
                    // indistinguishable from a black video — the shipped
231
                    // azul-video "black frame" bug. A dead pipeline must be
232
                    // visibly dead.
233
2
                    OptionDom::Some(Dom::create_div().with_css(
234
2
                        "width: 100%; height: 100%; background: #2a2a30; \
235
2
                         border: 1px solid #44444c;",
236
2
                    ))
237
2
                },
238
12
                |img| {
239
12
                    OptionDom::Some(
240
12
                        Dom::create_image(img.clone()).with_css("width: 100%; height: 100%;"),
241
12
                    )
242
12
                },
243
            )
244
14
        })
245
    };
246
25
    VirtualViewReturn {
247
25
        dom,
248
25
        materialized: azul_core::geom::LogicalRect::new(LogicalPosition::zero(), bounds),
249
25
        virtual_rect: azul_core::geom::LogicalRect::new(LogicalPosition::zero(), bounds),
250
25
    }
251
25
}
252

            
253
/// `AfterMount`: start the background decode thread exactly once.
254
8
extern "C" fn video_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
255
    // Mark started exactly once; pull out the streaming decode worker (if any),
256
    // its source, and any pre-decoded replay frames.
257
5
    let (decode_cb, config, frames) = {
258
8
        let Some(mut s) = data.downcast_mut::<VideoWidgetState>() else {
259
1
            return Update::DoNothing;
260
        };
261
7
        if s.started {
262
2
            return Update::DoNothing;
263
5
        }
264
5
        s.started = true;
265
5
        let frames = match &s.frames {
266
3
            OptionRefAny::Some(f) => Some(f.clone()),
267
2
            OptionRefAny::None => None,
268
        };
269
5
        (s.decode_callback.clone(), s.config.clone(), frames)
270
    };
271
    // Priority: off-main streaming decode worker > replay pre-decoded frames >
272
    // built-in test pattern. All feed the same WriteBack -> video_writeback path.
273
5
    if let Some(cb) = decode_cb {
274
        // The worker's thread-init is the `VideoConfig` itself: it matches on
275
        // `config.source` (typed — no RefAny downcast) and reads `config.timestamp`.
276
3
        let init = RefAny::new(config);
277
3
        let tid = ThreadId::unique();
278
3
        let thread = Thread::create(init, data.clone(), cb);
279
        // Grab the main→worker sender BEFORE add_thread moves the Thread, so the
280
        // merge callback can push seeks to the worker (scrubbing).
281
3
        let seek_sender = thread.clone_sender();
282
3
        info.add_thread(tid, thread);
283
        // Remember the worker's id (resize messaging) + sender (seek messaging).
284
3
        if let Some(mut s) = data.downcast_mut::<VideoWidgetState>() {
285
3
            s.thread_id = Some(tid);
286
3
            s.seek_sender = seek_sender;
287
3
        }
288
2
    } else if let Some(frames) = frames {
289
2
        info.add_thread(
290
2
            ThreadId::unique(),
291
2
            Thread::create(frames, data.clone(), ThreadCallback::new(video_replay_worker)),
292
2
        );
293
2
    } else {
294
        info.add_thread(
295
            ThreadId::unique(),
296
            Thread::create(
297
                RefAny::new(()),
298
                data.clone(),
299
                ThreadCallback::new(video_test_worker),
300
            ),
301
        );
302
    }
303
5
    Update::DoNothing
304
8
}
305

            
306
/// `NodeResized`: the video box changed physical size (window resize / relayout). Tell
307
/// the running decode worker the new target size via its `ThreadSender` so it scales
308
/// frames to fit OFF the main thread - the UI then does a cheap image swap with no
309
/// interpolation. This is a message, NOT a relayout: returns `DoNothing`.
310
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
311
7
extern "C" fn video_on_resize(mut data: RefAny, mut info: CallbackInfo) -> Update {
312
7
    let tid = match data.downcast_ref::<VideoWidgetState>() {
313
6
        Some(s) => s.thread_id,
314
1
        None => return Update::DoNothing,
315
    };
316
6
    let Some(tid) = tid else {
317
1
        return Update::DoNothing;
318
    };
319
5
    let node = info.get_hit_node();
320
5
    let Some(size) = info.get_node_size(node) else {
321
5
        return Update::DoNothing;
322
    };
323
    let target = (size.width.max(1.0) as u32, size.height.max(1.0) as u32);
324
    if let Some(thread) = info.get_thread(&tid) {
325
        // Best-effort resize notification: if the decode worker has already
326
        // exited, the send fails and there is nothing to do here.
327
        let _ = thread.send_message(ThreadSendMsg::Custom(RefAny::new(target)));
328
    }
329
    Update::DoNothing
330
7
}
331

            
332
/// Background worker (test pattern): SMPTE-style colour bars scrolling
333
/// horizontally ~30x/s. Replaced by the real vk-video decode worker later.
334
#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
335
7
extern "C" fn video_test_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
336
    const BARS: [[u8; 3]; 7] = [
337
        [235, 235, 235],
338
        [235, 235, 16],
339
        [16, 235, 235],
340
        [16, 235, 16],
341
        [235, 16, 235],
342
        [235, 16, 16],
343
        [16, 16, 235],
344
    ];
345
7
    let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
346
7
    let mut tick: u32 = 0;
347
    loop {
348
10
        let shift = (tick as usize / 4) % 7;
349
10
        let mut bytes = Vec::with_capacity(w * h * 4);
350
7200
        for _y in 0..h {
351
9216000
            for x in 0..w {
352
9216000
                let c = BARS[((x * 7 / w) + shift) % 7];
353
9216000
                bytes.extend_from_slice(&[c[0], c[1], c[2], 255]);
354
9216000
            }
355
        }
356
10
        let frame = VideoFrame {
357
10
            width: w as u32,
358
10
            height: h as u32,
359
10
            bytes: bytes.into(),
360
10
        };
361
10
        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
362
10
            WriteBackCallback::new(video_writeback),
363
10
            RefAny::new(frame),
364
10
        )));
365
10
        if !sent {
366
7
            break;
367
3
        }
368
3
        std::thread::sleep(std::time::Duration::from_millis(33));
369
3
        tick = tick.wrapping_add(2);
370
    }
371
7
}
372

            
373
/// Background worker (replay): cycle a caller-supplied `Vec<VideoFrame>` (e.g. a
374
/// clip decoded up front via `decode_mp4_h264_bytes`) ~30x/s through the same
375
/// `WriteBack` -> [`video_writeback`] -> [`super::capture_common::present_frame`]
376
/// path as the test pattern, so real decoded pixels land in the shared GL
377
/// texture. `init` is the `RefAny` handed to
378
/// [`VideoWidget::with_frames`](VideoWidget::with_frames); if it doesn't hold a
379
/// non-empty `Vec<VideoFrame>` the worker just returns.
380
10
extern "C" fn video_replay_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
381
10
    let frames: Vec<VideoFrame> = match init.downcast_ref::<Vec<VideoFrame>>() {
382
5
        Some(f) => f.clone(),
383
5
        None => return,
384
    };
385
5
    if frames.is_empty() {
386
2
        return;
387
3
    }
388
3
    let mut idx: usize = 0;
389
    loop {
390
12
        let frame = frames[idx % frames.len()].clone();
391
12
        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
392
12
            WriteBackCallback::new(video_writeback),
393
12
            RefAny::new(frame),
394
12
        )));
395
12
        if !sent {
396
3
            break;
397
9
        }
398
9
        std::thread::sleep(std::time::Duration::from_millis(33));
399
9
        idx = idx.wrapping_add(1);
400
    }
401
10
}
402

            
403
/// Writeback (main thread): store the decoded frame as the widget's
404
/// `current_frame` (a CPU `ImageRef`) and re-render the `VirtualView` in place so it
405
/// re-reads it - exactly like `map_tile_writeback`.
406
///
407
/// Renders on cpurender AND
408
/// webrender (no GL `present_frame`, no DOM rebuild).
409
16
#[must_use] pub extern "C" fn video_writeback(
410
16
    mut writeback_data: RefAny,
411
16
    mut frame_data: RefAny,
412
16
    mut info: CallbackInfo,
413
16
) -> Update {
414
16
    let hook = writeback_data.downcast_ref::<VideoWidgetState>().map_or_else(|| OptionOnVideoFrame::None, |s| s.on_frame.clone());
415
16
    let mut user_update = Update::DoNothing;
416
16
    match frame_data.downcast_ref::<VideoFrame>() {
417
15
        Some(frame) => {
418
            // Guard against dimensions whose RGBA byte count overflows `usize`
419
            // before `ImageRef::new_rawimage` validates it against the buffer:
420
            // `width * height * 4` wraps in release (e.g. 2^31 x 2^31 -> 0) so an
421
            // empty buffer would spuriously "match" and store a bogus frame. A
422
            // `checked_mul` that overflows drops the frame — the hook is still
423
            // notified below, exactly as for a byte-count mismatch.
424
15
            let fits = (frame.width as usize)
425
15
                .checked_mul(frame.height as usize)
426
15
                .and_then(|px| px.checked_mul(4))
427
15
                .is_some();
428
15
            if fits {
429
14
                if let Some(img) = ImageRef::new_rawimage(RawImage {
430
14
                    pixels: RawImageData::U8(frame.bytes.clone()),
431
14
                    width: frame.width as usize,
432
14
                    height: frame.height as usize,
433
14
                    premultiplied_alpha: false,
434
14
                    data_format: RawImageFormat::RGBA8,
435
14
                    tag: b"azul-video-frame".to_vec().into(),
436
14
                }) {
437
7
                    if let Some(mut s) = writeback_data.downcast_mut::<VideoWidgetState>() {
438
6
                        s.current_frame = Some(img);
439
6
                    }
440
7
                }
441
1
            }
442
15
            user_update = invoke_on_frame(&hook, &mut info, &frame);
443
        }
444
1
        None => return Update::DoNothing,
445
    }
446
    // Re-render the VirtualView(s) in place so the content callback re-reads the
447
    // freshly-stored `current_frame` (NOT RefreshDom — that would rebuild the DOM
448
    // and orphan the worker's dataset clone). Same trick as `map_tile_writeback`.
449
15
    info.trigger_all_virtual_view_rerender();
450
15
    user_update
451
16
}
452

            
453
/// Carry live state forward across relayout.
454
#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
455
18
extern "C" fn merge_video_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
456
    // Return the OLD allocation, adopting config forward — the same rule
457
    // `merge_map_tile_cache` documents. The decode worker holds a clone of
458
    // the OLD RefAny (handed over at spawn) and writes every decoded frame
459
    // into it; returning `new_data` re-pointed the DOM at a fresh allocation
460
    // nobody wrote to, so the picture froze on whatever frame existed at
461
    // merge time — the AzVideo demo hit it on the FIRST timeline click
462
    // (its callback returns RefreshDom).
463
18
    let merged_into_old = {
464
18
        let new_guard = new_data.downcast_ref::<VideoWidgetState>();
465
18
        let old_guard = old_data.downcast_mut::<VideoWidgetState>();
466
18
        if let (Some(new_g), Some(mut old_g)) = (new_guard, old_guard) {
467
            // Scrubbing: a changed `config.timestamp` across this relayout → tell the
468
            // worker to seek. Cheap wall-clock reposition (the worker already has the
469
            // decoded frames), result comes back as an image swap — no re-decode here.
470
15
            if old_g.config.timestamp != new_g.config.timestamp {
471
5
                if let Some(snd) = old_g.seek_sender.as_ref() {
472
4
                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.timestamp))));
473
4
                }
474
10
            }
475
            // Input-source change → tell the worker to re-init the decode (it
476
            // re-resolves/demuxes/decodes the new source); the frame swaps in when ready.
477
15
            if old_g.config.source != new_g.config.source {
478
5
                if let Some(snd) = old_g.seek_sender.as_ref() {
479
5
                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.source.clone()))));
480
5
                }
481
10
            }
482
            // Adopt the app-driven config; keep every worker-facing field
483
            // (frames, current_frame, thread_id, seek_sender, started) in the
484
            // allocation the worker actually writes to.
485
15
            old_g.config = new_g.config.clone();
486
15
            old_g.on_frame = new_g.on_frame.clone();
487
15
            true
488
        } else {
489
            // Foreign / mismatched payloads (one side is not this widget's
490
            // state): hand back the NEW payload untouched — there is no
491
            // persistent allocation to preserve, and returning a
492
            // wrong-typed old dataset would poison the node.
493
3
            false
494
        }
495
    };
496
18
    if merged_into_old {
497
15
        old_data
498
    } else {
499
3
        new_data
500
    }
501
18
}
502

            
503
// ============================================================================
504
// Generated adversarial tests
505
// ============================================================================
506

            
507
#[cfg(test)]
508
#[allow(
509
    clippy::too_many_lines,
510
    clippy::cast_possible_truncation,
511
    clippy::float_cmp,
512
    clippy::items_after_statements,
513
    clippy::let_and_return
514
)]
515
mod autotest_generated {
516
    use std::{
517
        collections::BTreeMap,
518
        panic::{catch_unwind, AssertUnwindSafe},
519
        sync::{
520
            atomic::{AtomicUsize, Ordering},
521
            mpsc::{channel, Receiver, Sender},
522
            Arc, Mutex, PoisonError,
523
        },
524
    };
525

            
526
    use azul_core::{
527
        callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
528
        dom::{DomId, DomNodeId, NodeType},
529
        geom::{LogicalSize, OptionLogicalPosition},
530
        gl::OptionGlContextPtr,
531
        hit_test::ScrollPosition,
532
        resources::{DecodedImage, DpiScaleFactor, ImageCache, RendererResources},
533
        styled_dom::NodeHierarchyItemId,
534
        task::{
535
            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
536
            ThreadRecvCallback,
537
        },
538
        video::VideoSource,
539
        window::{MonitorVec, RawWindowHandle, WindowTheme},
540
    };
541
    use azul_css::{system::SystemStyle, AzString};
542
    use rust_fontconfig::FcFontCache;
543

            
544
    use super::*;
545
    #[cfg(feature = "icu")]
546
    use crate::icu::IcuLocalizerHandle;
547
    use crate::{
548
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
549
        thread::{
550
            ThreadCallbackType, ThreadSendCallback, ThreadSenderDestructorCallback,
551
            ThreadSenderInner,
552
        },
553
        widgets::capture_common::OnVideoFrameCallbackType,
554
        window::LayoutWindow,
555
        window_state::FullWindowState,
556
    };
557

            
558
    // ==================================================================
559
    // Config fixtures
560
    // ==================================================================
561

            
562
    /// A config with an explicit source + scrub position (everything else
563
    /// pinned so a test only varies what it names).
564
    fn config(source: VideoSource, timestamp: f32) -> VideoConfig {
565
        VideoConfig {
566
            source,
567
            timestamp,
568
            autoplay: true,
569
            looping: false,
570
            output_format: RawImageFormat::BGRA8,
571
        }
572
    }
573

            
574
    fn url_source(host: &str, path: &str) -> VideoSource {
575
        VideoSource::Url(azul_core::url::Url::from_parts("https", host, 443, path))
576
    }
577

            
578
    fn file_source(path: &'static str) -> VideoSource {
579
        VideoSource::File(AzString::from_const_str(path))
580
    }
581

            
582
    fn bytes_source(bytes: Vec<u8>) -> VideoSource {
583
        VideoSource::Bytes(bytes.into())
584
    }
585

            
586
    /// Representative + hostile configs: every `VideoSource` variant (empty and
587
    /// large payloads), a non-ASCII path, and every f32 boundary a scrub
588
    /// position can take (NaN / ±inf / ±0 / MIN / MAX).
589
    fn all_configs() -> Vec<VideoConfig> {
590
        vec![
591
            VideoConfig::default(),
592
            config(url_source("example.com", "/clip.mp4"), 0.0),
593
            config(url_source("", ""), f32::MAX),
594
            config(file_source("/tmp/clip.mp4"), -1.0),
595
            // unicode: emoji + CJK + RTL + a combining mark in the path.
596
            config(
597
                file_source("/tmp/\u{1F3AC}-\u{5F71}\u{7247}-\u{0631}\u{0645}\u{0632}-e\u{0301}.mp4"),
598
                f32::NAN,
599
            ),
600
            config(bytes_source(Vec::new()), f32::INFINITY),
601
            config(bytes_source(vec![0xFF; 8192]), f32::NEG_INFINITY),
602
            config(bytes_source(vec![0x00]), f32::MIN),
603
            VideoConfig {
604
                source: file_source("x"),
605
                timestamp: -0.0,
606
                autoplay: false,
607
                looping: true,
608
                output_format: RawImageFormat::R8,
609
            },
610
        ]
611
    }
612

            
613
    /// `VideoConfig` is only `PartialEq`, so a NaN scrub position never compares
614
    /// equal to itself - compare the timestamp bit-exactly instead.
615
    fn assert_same_config(actual: &VideoConfig, expected: &VideoConfig) {
616
        assert_eq!(actual.source, expected.source, "source must round-trip");
617
        assert_eq!(
618
            actual.timestamp.to_bits(),
619
            expected.timestamp.to_bits(),
620
            "timestamp must survive bit-exactly (NaN included)"
621
        );
622
        assert_eq!(actual.autoplay, expected.autoplay);
623
        assert_eq!(actual.looping, expected.looping);
624
        assert_eq!(actual.output_format, expected.output_format);
625
    }
626

            
627
    const CONST_CONFIG: VideoConfig = VideoConfig {
628
        source: VideoSource::File(AzString::from_const_str("/tmp/const-clip.mp4")),
629
        timestamp: 2.5,
630
        autoplay: false,
631
        looping: true,
632
        output_format: RawImageFormat::RGBA8,
633
    };
634

            
635
    /// Compile-time proof that `create` really is a `const fn` - the `const`
636
    /// qualifier is part of the public API, so a non-const `create` must break
637
    /// this file.
638
    const CONST_WIDGET: VideoWidget = VideoWidget::create(CONST_CONFIG);
639

            
640
    // ==================================================================
641
    // State fixtures
642
    // ==================================================================
643

            
644
    /// A freshly-built widget state (exactly what `build_dom` stores).
645
    fn base_state(config: VideoConfig) -> VideoWidgetState {
646
        VideoWidgetState {
647
            config,
648
            started: false,
649
            gl_texture_id: None,
650
            on_frame: OptionOnVideoFrame::None,
651
            frames: OptionRefAny::None,
652
            decode_callback: None,
653
            current_frame: None,
654
            thread_id: None,
655
            seek_sender: None,
656
        }
657
    }
658

            
659
    fn state(config: VideoConfig) -> RefAny {
660
        RefAny::new(base_state(config))
661
    }
662

            
663
    /// Everything a test needs to know about a `VideoWidgetState`, read out in
664
    /// one borrow (`downcast_ref` takes `&mut self`, so overlapping reads would
665
    /// otherwise have to nest).
666
    #[derive(Debug, Clone, PartialEq)]
667
    struct StateSummary {
668
        started: bool,
669
        gl_texture_id: Option<u32>,
670
        has_hook: bool,
671
        has_frames: bool,
672
        decode_cb: Option<usize>,
673
        current_frame_id: Option<u64>,
674
        thread_id: Option<ThreadId>,
675
        has_seek_sender: bool,
676
    }
677

            
678
    fn read_state(data: &mut RefAny) -> StateSummary {
679
        let s = data
680
            .downcast_ref::<VideoWidgetState>()
681
            .expect("payload must still be a VideoWidgetState");
682
        StateSummary {
683
            started: s.started,
684
            gl_texture_id: s.gl_texture_id,
685
            has_hook: matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
686
            has_frames: matches!(s.frames, OptionRefAny::Some(_)),
687
            decode_cb: s.decode_callback.as_ref().map(|c| c.cb as usize),
688
            current_frame_id: s.current_frame.as_ref().map(|i| i.id),
689
            thread_id: s.thread_id,
690
            has_seek_sender: s.seek_sender.is_some(),
691
        }
692
    }
693

            
694
    fn read_config(data: &mut RefAny) -> VideoConfig {
695
        data.downcast_ref::<VideoWidgetState>()
696
            .expect("payload must still be a VideoWidgetState")
697
            .config
698
            .clone()
699
    }
700

            
701
    /// The `(width, height)` of every frame in the state's replay list, or
702
    /// `None` when there is no list / it does not hold a `Vec<VideoFrame>`.
703
    fn state_frames(data: &mut RefAny) -> Option<Vec<(u32, u32)>> {
704
        let inner = {
705
            let s = data.downcast_ref::<VideoWidgetState>()?;
706
            match &s.frames {
707
                OptionRefAny::Some(f) => Some(f.clone()),
708
                OptionRefAny::None => None,
709
            }
710
        };
711
        let mut inner = inner?;
712
        let v = inner.downcast_ref::<Vec<VideoFrame>>()?;
713
        Some(v.iter().map(|f| (f.width, f.height)).collect())
714
    }
715

            
716
    /// The `(width, height)` of a widget's `frames` `RefAny` (same shape as
717
    /// `state_frames`, but for the builder-side `VideoWidget`).
718
    fn widget_frames(widget: &VideoWidget) -> Option<Vec<(u32, u32)>> {
719
        let OptionRefAny::Some(f) = &widget.frames else {
720
            return None;
721
        };
722
        let mut f = f.clone();
723
        let v = f.downcast_ref::<Vec<VideoFrame>>()?;
724
        Some(v.iter().map(|fr| (fr.width, fr.height)).collect())
725
    }
726

            
727
    // ---- frames / images --------------------------------------------------
728

            
729
    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
730
    fn frame(width: u32, height: u32) -> VideoFrame {
731
        let px = (width as usize) * (height as usize);
732
        VideoFrame {
733
            width,
734
            height,
735
            bytes: vec![7u8; px * 4].into(),
736
        }
737
    }
738

            
739
    /// A frame whose declared dimensions need NOT match its byte count.
740
    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
741
        VideoFrame {
742
            width,
743
            height,
744
            bytes: bytes.into(),
745
        }
746
    }
747

            
748
    /// A zero-allocation stand-in for an already-decoded frame.
749
    fn placeholder_image(tag: &[u8]) -> ImageRef {
750
        ImageRef::null_image(4, 4, RawImageFormat::BGRA8, tag.to_vec())
751
    }
752

            
753
    /// `(width, height)` of the raw CPU image a writeback stored, or `None` if
754
    /// the stored image is not a raw one.
755
    fn raw_dims(img: &ImageRef) -> Option<(usize, usize)> {
756
        match img.get_data() {
757
            DecodedImage::Raw((descriptor, _)) => Some((descriptor.width, descriptor.height)),
758
            _ => None,
759
        }
760
    }
761

            
762
    fn current_frame_dims(data: &mut RefAny) -> Option<(usize, usize)> {
763
        let s = data.downcast_ref::<VideoWidgetState>()?;
764
        raw_dims(s.current_frame.as_ref()?)
765
    }
766

            
767
    // ---- frame hook -------------------------------------------------------
768

            
769
    /// Records every frame the widget's `on_frame` hook is handed, and answers
770
    /// with a caller-chosen `Update`.
771
    struct FrameLog {
772
        seen: Vec<(u32, u32, usize)>,
773
        reply: Update,
774
    }
775

            
776
    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
777
        let mut reply = Update::DoNothing;
778
        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
779
            log.seen
780
                .push((frame.width, frame.height, frame.bytes.as_ref().len()));
781
            reply = log.reply;
782
        }
783
        reply
784
    }
785

            
786
    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
787
        // A distinct body so the linker cannot fold this onto `record_frame`
788
        // and make the fn-pointer identity assertions vacuous.
789
        core::hint::black_box(Update::DoNothing)
790
    }
791

            
792
    fn frame_log(reply: Update) -> RefAny {
793
        RefAny::new(FrameLog {
794
            seen: Vec::new(),
795
            reply,
796
        })
797
    }
798

            
799
    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
800
        data.downcast_ref::<FrameLog>()
801
            .expect("payload must still be a FrameLog")
802
            .seen
803
            .clone()
804
    }
805

            
806
    fn hook_into(log: &RefAny) -> OptionOnVideoFrame {
807
        Some(OnVideoFrame {
808
            refany: log.clone(),
809
            callback: (record_frame as OnVideoFrameCallbackType).into(),
810
        })
811
        .into()
812
    }
813

            
814
    // ---- thread workers ---------------------------------------------------
815

            
816
    /// A decode worker that returns immediately. Used wherever a test must let
817
    /// `AfterMount` really spawn a `Thread`: the framework's thread destructor
818
    /// *joins*, so only a worker that returns on its own can be joined safely.
819
    extern "C" fn noop_decode_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
820

            
821
    extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {
822
        core::hint::black_box(());
823
    }
824

            
825
    // ==================================================================
826
    // CallbackInfo harness
827
    // ==================================================================
828

            
829
    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no
830
    /// GL context, no laid-out nodes). Returns `f`'s value plus every
831
    /// `CallbackChange` the callback recorded.
832
    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
833
        let layout_window =
834
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
835
        let renderer_resources = RendererResources::default();
836
        let previous_window_state: Option<FullWindowState> = None;
837
        let current_window_state = FullWindowState::default();
838
        let gl_context = OptionGlContextPtr::None;
839
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
840
            BTreeMap::new();
841
        let window_handle = RawWindowHandle::Unsupported;
842
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
843

            
844
        let ref_data = CallbackInfoRefData {
845
            layout_window: &layout_window,
846
            renderer_resources: &renderer_resources,
847
            previous_window_state: &previous_window_state,
848
            current_window_state: &current_window_state,
849
            gl_context: &gl_context,
850
            current_scroll_manager: &scroll_states,
851
            current_window_handle: &window_handle,
852
            system_callbacks: &system_callbacks,
853
            system_style: Arc::new(SystemStyle::default()),
854
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
855
            #[cfg(feature = "icu")]
856
            icu_localizer: IcuLocalizerHandle::default(),
857
            ctx: OptionRefAny::None,
858
        };
859

            
860
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
861

            
862
        let info = CallbackInfo::new(
863
            &ref_data,
864
            &changes,
865
            DomNodeId {
866
                dom: DomId::ROOT_ID,
867
                node: NodeHierarchyItemId::NONE,
868
            },
869
            OptionLogicalPosition::None,
870
            OptionLogicalPosition::None,
871
        );
872

            
873
        let out = f(info);
874
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
875
        (out, recorded)
876
    }
877

            
878
    fn count_virtual_view_rerenders(changes: &[CallbackChange]) -> usize {
879
        changes
880
            .iter()
881
            .filter(|c| matches!(c, CallbackChange::UpdateAllVirtualViews))
882
            .count()
883
    }
884

            
885
    /// The `ThreadId` of the single `AddThread` change, or `None`.
886
    fn added_thread_id(changes: &[CallbackChange]) -> Option<ThreadId> {
887
        changes.iter().find_map(|c| match c {
888
            CallbackChange::AddThread { thread_id, .. } => Some(*thread_id),
889
            _ => None,
890
        })
891
    }
892

            
893
    // ==================================================================
894
    // VirtualViewCallbackInfo harness
895
    // ==================================================================
896

            
897
    /// Runs `f` against a `VirtualViewCallbackInfo` reporting `w x h` bounds.
898
    fn with_virtual_view_info<R>(w: f32, h: f32, f: impl FnOnce(VirtualViewCallbackInfo) -> R) -> R {
899
        let fonts = FcFontCache::default();
900
        let images = ImageCache::default();
901
        let size = LogicalSize::new(w, h);
902
        let info = VirtualViewCallbackInfo::new(
903
            VirtualViewCallbackReason::InitialRender,
904
            &fonts,
905
            &images,
906
            WindowTheme::LightMode,
907
            HidpiAdjustedBounds {
908
                logical_size: size,
909
                hidpi_factor: DpiScaleFactor::new(1.0),
910
            },
911
            azul_core::geom::LogicalRect::new(LogicalPosition::zero(), size),
912
            azul_core::geom::LogicalRect::new(LogicalPosition::zero(), size),
913
            LogicalPosition::zero(),
914
        );
915
        f(info)
916
    }
917

            
918
    /// The `ImageRef` id of the `<img>` a render pass emitted, or `None` when it
919
    /// emitted no DOM at all.
920
    fn rendered_image_id(ret: &VirtualViewReturn) -> Option<u64> {
921
        let OptionDom::Some(dom) = &ret.dom else {
922
            return None;
923
        };
924
        match dom.root.get_node_type() {
925
            NodeType::Image(img) => Some(img.id),
926
            other => panic!("the video VirtualView must render an <img>, got {other:?}"),
927
        }
928
    }
929

            
930
    fn rendered_nothing(ret: &VirtualViewReturn) -> bool {
931
        matches!(ret.dom, OptionDom::None)
932
    }
933

            
934
    // ==================================================================
935
    // Worker harness
936
    // ==================================================================
937

            
938
    /// One frame a worker pushed, summarised so the (multi-megabyte) pixel
939
    /// buffer never has to be cloned into the log.
940
    #[derive(Debug, Clone, PartialEq, Eq)]
941
    struct SentFrame {
942
        width: u32,
943
        height: u32,
944
        len: usize,
945
        /// Distinct RGBA pixels in the first scanline, in first-seen order
946
        /// (capped, so a pathological frame cannot blow up the log).
947
        row0_palette: Vec<[u8; 4]>,
948
        /// Every scanline is byte-identical to the first.
949
        rows_identical: bool,
950
        /// The whole pixel buffer - captured only for frames small enough to
951
        /// compare byte-for-byte (the replay fixtures).
952
        small_bytes: Option<Vec<u8>>,
953
    }
954

            
955
    fn summarise(f: &VideoFrame) -> SentFrame {
956
        let bytes = f.bytes.as_ref();
957
        let row_len = (f.width as usize).saturating_mul(4);
958
        let mut row0_palette: Vec<[u8; 4]> = Vec::new();
959
        if row_len > 0 && bytes.len() >= row_len {
960
            for px in bytes[..row_len].chunks_exact(4) {
961
                let px = [px[0], px[1], px[2], px[3]];
962
                if row0_palette.len() < 32 && !row0_palette.contains(&px) {
963
                    row0_palette.push(px);
964
                }
965
            }
966
        }
967
        let rows_identical = row_len == 0
968
            || bytes.len() < row_len
969
            || bytes.chunks_exact(row_len).all(|row| row == &bytes[..row_len]);
970
        SentFrame {
971
            width: f.width,
972
            height: f.height,
973
            len: bytes.len(),
974
            row0_palette,
975
            rows_identical,
976
            small_bytes: (bytes.len() <= 4096).then(|| bytes.to_vec()),
977
        }
978
    }
979

            
980
    /// Guarded by `WORKER_GATE`: a worker's send callback is a plain C fn
981
    /// pointer, so it has nowhere but a static to put its result.
982
    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
983
    static WORKER_GATE: Mutex<()> = Mutex::new(());
984
    /// How many more sends are accepted before the harness reports "the main
985
    /// thread is gone" - the only signal these workers ever stop on.
986
    static ACCEPT_BUDGET: AtomicUsize = AtomicUsize::new(0);
987

            
988
    extern "C" fn record_then_maybe_stop(
989
        _sender: *const core::ffi::c_void,
990
        msg: ThreadReceiveMsg,
991
    ) -> bool {
992
        let ThreadReceiveMsg::WriteBack(mut wb) = msg else {
993
            return false;
994
        };
995
        if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
996
            WORKER_LOG
997
                .lock()
998
                .unwrap_or_else(PoisonError::into_inner)
999
                .push(summarise(&f));
        }
        let left = ACCEPT_BUDGET.load(Ordering::SeqCst);
        if left == 0 {
            return false;
        }
        ACCEPT_BUDGET.store(left - 1, Ordering::SeqCst);
        true
    }
    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
        OptionThreadSendMsg::None
    }
    /// A receiver that answers *every* poll with "terminate now".
    extern "C" fn recv_terminate(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
        OptionThreadSendMsg::Some(ThreadSendMsg::TerminateThread)
    }
    fn logging_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
        let (tx, rx) = channel::<ThreadReceiveMsg>();
        let sender = ThreadSender::new(ThreadSenderInner {
            ptr: Box::new(tx),
            send_fn: ThreadSendCallback {
                cb: record_then_maybe_stop,
            },
            destructor: ThreadSenderDestructorCallback {
                cb: sender_drop_noop,
            },
        });
        (rx, sender)
    }
    fn receiver(terminate: bool) -> (Sender<ThreadSendMsg>, ThreadReceiver) {
        let (tx, rx) = channel::<ThreadSendMsg>();
        let cb: extern "C" fn(*const core::ffi::c_void) -> OptionThreadSendMsg =
            if terminate { recv_terminate } else { recv_nothing };
        let receiver = ThreadReceiver::new(ThreadReceiverInner {
            ptr: Box::new(rx),
            recv_fn: ThreadRecvCallback { cb },
            destructor: ThreadReceiverDestructorCallback {
                cb: receiver_drop_noop,
            },
        });
        (tx, receiver)
    }
    /// Runs `worker` in-process against a sender that accepts `accept` frames
    /// and then reports failure, and returns everything it managed to send.
    /// `terminate` decides whether its receiver answers every poll with
    /// `TerminateThread`.
    fn run_worker(
        worker: ThreadCallbackType,
        init: RefAny,
        accept: usize,
        terminate: bool,
    ) -> Vec<SentFrame> {
        let _gate = WORKER_GATE
            .lock()
            .unwrap_or_else(PoisonError::into_inner);
        WORKER_LOG
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clear();
        ACCEPT_BUDGET.store(accept, Ordering::SeqCst);
        let (_rx, sender) = logging_sender();
        let (_tx, recv) = receiver(terminate);
        worker(init, sender, recv);
        WORKER_LOG
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone()
    }
    /// The SMPTE bars `video_test_worker` hard-codes (duplicated here on
    /// purpose: the palette is observable output, so a silent change to it must
    /// break a test).
    const EXPECTED_BARS: [[u8; 4]; 7] = [
        [235, 235, 235, 255],
        [235, 235, 16, 255],
        [16, 235, 235, 255],
        [16, 235, 16, 255],
        [235, 16, 235, 255],
        [235, 16, 16, 255],
        [16, 16, 235, 255],
    ];
    // ==================================================================
    // Seek-channel helpers (merge_video_state)
    // ==================================================================
    fn custom_f32(msg: &ThreadSendMsg) -> Option<f32> {
        let ThreadSendMsg::Custom(r) = msg else {
            return None;
        };
        let mut r = r.clone();
        let out = r.downcast_ref::<f32>().map(|v| *v);
        out
    }
    fn custom_source(msg: &ThreadSendMsg) -> Option<VideoSource> {
        let ThreadSendMsg::Custom(r) = msg else {
            return None;
        };
        let mut r = r.clone();
        let out = r.downcast_ref::<VideoSource>().map(|v| (*v).clone());
        out
    }
    // ==================================================================
    // VideoWidget::create  (constructor)
    // ==================================================================
    #[test]
    fn create_stores_the_config_verbatim_and_leaves_every_hook_unset() {
        for cfg in all_configs() {
            let widget = VideoWidget::create(cfg.clone());
            assert_same_config(&widget.config, &cfg);
            assert!(
                matches!(widget.on_frame, OptionOnVideoFrame::None),
                "a fresh widget has no frame hook"
            );
            assert!(
                matches!(widget.frames, OptionRefAny::None),
                "a fresh widget has no replay list"
            );
        }
    }
    #[test]
    fn create_is_usable_in_a_const_context() {
        let widget = CONST_WIDGET;
        assert_same_config(&widget.config, &CONST_CONFIG);
        assert!(matches!(widget.on_frame, OptionOnVideoFrame::None));
        assert!(matches!(widget.frames, OptionRefAny::None));
    }
    // ==================================================================
    // VideoWidget::set_on_frame / with_on_frame  (constructor)
    // ==================================================================
    #[test]
    fn with_on_frame_installs_the_hook_and_keeps_the_config() {
        for cfg in all_configs() {
            let widget = VideoWidget::create(cfg.clone())
                .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType);
            assert_same_config(&widget.config, &cfg);
            let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
                panic!("with_on_frame must install a hook");
            };
            assert_eq!(
                hook.callback.cb as usize,
                record_frame as OnVideoFrameCallbackType as usize,
                "the installed hook must be exactly the one handed in"
            );
            assert!(
                matches!(widget.frames, OptionRefAny::None),
                "with_on_frame must not invent a replay list"
            );
        }
    }
    #[test]
    fn set_on_frame_twice_keeps_only_the_last_hook() {
        let mut widget = VideoWidget::create(VideoConfig::default());
        widget.set_on_frame(
            RefAny::new(0_usize),
            record_frame as OnVideoFrameCallbackType,
        );
        widget.set_on_frame(
            RefAny::new(1_usize),
            frame_do_nothing as OnVideoFrameCallbackType,
        );
        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
            panic!("hook must still be set");
        };
        assert_eq!(
            hook.callback.cb as usize,
            frame_do_nothing as OnVideoFrameCallbackType as usize,
            "the second set_on_frame must replace the first"
        );
        let mut data = hook.refany.clone();
        assert_eq!(
            data.downcast_ref::<usize>().map(|v| *v),
            Some(1),
            "the second hook's data must replace the first hook's, not merge with it"
        );
    }
    #[test]
    fn set_on_frame_accepts_a_refany_that_is_also_the_widgets_replay_list() {
        // Aliasing the same RefAny into two slots must not panic or deadlock -
        // both are plain shared handles.
        let shared = RefAny::new(vec![frame(1, 1)]);
        let widget = VideoWidget::create(VideoConfig::default())
            .with_frames(shared.clone())
            .with_on_frame(shared, record_frame as OnVideoFrameCallbackType);
        assert!(matches!(widget.on_frame, OptionOnVideoFrame::Some(_)));
        assert_eq!(widget_frames(&widget), Some(vec![(1, 1)]));
    }
    // ==================================================================
    // VideoWidget::with_frames  (constructor)
    // ==================================================================
    #[test]
    fn with_frames_stores_the_list_and_keeps_everything_else() {
        for cfg in all_configs() {
            let widget = VideoWidget::create(cfg.clone())
                .with_frames(RefAny::new(vec![frame(2, 3), frame(4, 5)]));
            assert_same_config(&widget.config, &cfg);
            assert_eq!(widget_frames(&widget), Some(vec![(2, 3), (4, 5)]));
            assert!(
                matches!(widget.on_frame, OptionOnVideoFrame::None),
                "with_frames must not invent a hook"
            );
        }
    }
    #[test]
    fn with_frames_twice_keeps_only_the_last_list() {
        let widget = VideoWidget::create(VideoConfig::default())
            .with_frames(RefAny::new(vec![frame(1, 1)]))
            .with_frames(RefAny::new(vec![frame(9, 9), frame(8, 8)]));
        assert_eq!(widget_frames(&widget), Some(vec![(9, 9), (8, 8)]));
    }
    #[test]
    fn with_frames_accepts_an_empty_and_a_wrong_typed_payload_without_complaint() {
        // Documented: a `RefAny` that does not carry a `Vec<VideoFrame>` is
        // accepted here and only *skipped* later, by the replay worker.
        let empty = VideoWidget::create(VideoConfig::default())
            .with_frames(RefAny::new(Vec::<VideoFrame>::new()));
        assert_eq!(widget_frames(&empty), Some(Vec::new()));
        let foreign =
            VideoWidget::create(VideoConfig::default()).with_frames(RefAny::new(0_u32));
        assert!(
            matches!(foreign.frames, OptionRefAny::Some(_)),
            "the builder stores whatever it is given"
        );
        assert_eq!(
            widget_frames(&foreign),
            None,
            "...but it is not a frame list"
        );
    }
    #[test]
    fn builder_order_does_not_matter() {
        let a = VideoWidget::create(config(file_source("/a.mp4"), 1.0))
            .with_frames(RefAny::new(vec![frame(3, 3)]))
            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType);
        let b = VideoWidget::create(config(file_source("/a.mp4"), 1.0))
            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType)
            .with_frames(RefAny::new(vec![frame(3, 3)]));
        assert_same_config(&a.config, &b.config);
        assert_eq!(widget_frames(&a), widget_frames(&b));
        assert!(matches!(a.on_frame, OptionOnVideoFrame::Some(_)));
        assert!(matches!(b.on_frame, OptionOnVideoFrame::Some(_)));
    }
    // ==================================================================
    // VideoWidget::dom / dom_with_decoder / build_dom
    // ==================================================================
    #[test]
    fn dom_builds_a_div_with_one_virtual_view_child() {
        let dom = VideoWidget::create(VideoConfig::default()).dom();
        assert!(
            matches!(dom.root.get_node_type(), NodeType::Div),
            "the widget root is a plain div (the <img> lives in the VirtualView)"
        );
        assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
        assert!(
            matches!(
                dom.children.as_slice()[0].root.get_node_type(),
                NodeType::VirtualView
            ),
            "the child must be the VirtualView the decode worker re-renders"
        );
    }
    #[test]
    fn dom_wires_after_mount_node_resized_a_dataset_and_a_merge_callback() {
        let dom = VideoWidget::create(VideoConfig::default()).dom();
        let events: Vec<EventFilter> = dom
            .root
            .get_callbacks()
            .as_ref()
            .iter()
            .map(|c| c.event)
            .collect();
        assert_eq!(events.len(), 2, "exactly two component callbacks");
        assert!(events.contains(&EventFilter::Component(ComponentEventFilter::AfterMount)));
        assert!(events.contains(&EventFilter::Component(ComponentEventFilter::NodeResized)));
        assert!(
            dom.root.get_merge_callback().is_some(),
            "live state must survive relayout"
        );
        assert!(
            dom.root.get_dataset().is_some(),
            "the widget div must carry its VideoWidgetState"
        );
    }
    #[test]
    fn dom_stores_a_pristine_state_for_every_config() {
        for cfg in all_configs() {
            let dom = VideoWidget::create(cfg.clone()).dom();
            let mut dataset = dom
                .root
                .get_dataset()
                .cloned()
                .expect("the node must carry its VideoWidgetState");
            assert_same_config(&read_config(&mut dataset), &cfg);
            assert_eq!(
                read_state(&mut dataset),
                StateSummary {
                    started: false,
                    gl_texture_id: None,
                    has_hook: false,
                    has_frames: false,
                    decode_cb: None,
                    current_frame_id: None,
                    thread_id: None,
                    has_seek_sender: false,
                },
                "dom() must not start anything - AfterMount does that"
            );
        }
    }
    #[test]
    fn dom_moves_the_hook_and_the_replay_list_into_the_state() {
        let dom = VideoWidget::create(VideoConfig::default())
            .with_frames(RefAny::new(vec![frame(6, 7)]))
            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType)
            .dom();
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
        let summary = read_state(&mut dataset);
        assert!(summary.has_hook, "dom() must carry the user hook forward");
        assert!(summary.has_frames);
        assert_eq!(state_frames(&mut dataset), Some(vec![(6, 7)]));
    }
    #[test]
    fn dom_with_decoder_records_exactly_the_worker_it_was_given() {
        let dom = VideoWidget::create(VideoConfig::default())
            .dom_with_decoder(ThreadCallback::new(noop_decode_worker));
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
        assert_eq!(
            read_state(&mut dataset).decode_cb,
            Some(noop_decode_worker as ThreadCallbackType as usize)
        );
        // A different worker must be distinguishable (no fn-pointer folding).
        let other = VideoWidget::create(VideoConfig::default())
            .dom_with_decoder(ThreadCallback::new(other_noop_worker));
        let mut other_dataset = other.root.get_dataset().cloned().expect("dataset");
        assert_ne!(
            read_state(&mut other_dataset).decode_cb,
            read_state(&mut dataset).decode_cb
        );
    }
    #[test]
    fn dom_and_dom_with_decoder_agree_on_everything_but_the_worker() {
        let plain = VideoWidget::create(config(bytes_source(vec![1, 2, 3]), -0.5)).dom();
        let with_cb = VideoWidget::create(config(bytes_source(vec![1, 2, 3]), -0.5))
            .dom_with_decoder(ThreadCallback::new(noop_decode_worker));
        assert_eq!(
            plain.children.as_slice().len(),
            with_cb.children.as_slice().len()
        );
        let mut a = plain.root.get_dataset().cloned().expect("dataset");
        let mut b = with_cb.root.get_dataset().cloned().expect("dataset");
        assert_same_config(&read_config(&mut a), &read_config(&mut b));
        let (sa, sb) = (read_state(&mut a), read_state(&mut b));
        assert_eq!(sa.decode_cb, None);
        assert!(sb.decode_cb.is_some());
        assert_eq!(
            StateSummary {
                decode_cb: None,
                ..sb
            },
            sa,
            "only the decode callback may differ"
        );
    }
    #[test]
    fn dom_survives_a_huge_in_memory_source_without_copying_it_into_the_tree() {
        // 4 MiB of "MP4 bytes": the widget must move them into the state, not
        // choke on them.
        let widget = VideoWidget::create(config(bytes_source(vec![0xAB; 4 * 1024 * 1024]), 0.0));
        let dom = widget.dom();
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
        match read_config(&mut dataset).source {
            VideoSource::Bytes(b) => assert_eq!(b.as_ref().len(), 4 * 1024 * 1024),
            other => panic!("the source must survive verbatim, got {other:?}"),
        }
    }
    // ==================================================================
    // video_widget_render  (VirtualView callback)
    // ==================================================================
    #[test]
    fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
        let mut s = base_state(VideoConfig::default());
        s.current_frame = Some(placeholder_image(b"ready"));
        let dataset = RefAny::new(s);
        for (w, h) in [
            (0.0_f32, 0.0_f32),
            (0.0, 600.0),
            (800.0, 0.0),
            (-800.0, -600.0),
            (-1.0, 600.0),
            (f32::NAN, 600.0),
            (800.0, f32::NAN),
            (f32::INFINITY, 600.0),
            (800.0, f32::NEG_INFINITY),
        ] {
            let ret = with_virtual_view_info(w, h, |info| video_widget_render(dataset.clone(), info));
            assert!(
                rendered_nothing(&ret),
                "bounds {w}x{h} must render nothing until layout settles - even with a frame ready"
            );
        }
    }
    #[test]
    fn render_with_a_wrong_typed_dataset_emits_no_dom() {
        let dataset = RefAny::new(0_u32);
        let ret =
            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
        assert!(rendered_nothing(&ret));
    }
    #[test]
    fn render_before_the_first_frame_emits_the_no_signal_poster() {
        // PIN FLIPPED (2026-07-31, deliberately): rendering NOTHING before
        // the first frame made a dead decode pipeline (missing feature,
        // unsupported target, Vulkan init failure, network stall)
        // indistinguishable from a black video — the shipped azul-video
        // "black frame" bug. A decoder that has produced no frame must be
        // VISIBLY "no signal".
        let dataset = state(VideoConfig::default());
        let ret =
            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
        assert!(
            !rendered_nothing(&ret),
            "no decoded frame yet -> a visible no-signal poster, NOT an \
             invisible tile"
        );
    }
    #[test]
    fn render_emits_the_stored_frame_as_an_image() {
        let img = placeholder_image(b"azul-video-frame");
        let expected_id = img.id;
        let mut s = base_state(VideoConfig::default());
        s.current_frame = Some(img);
        let dataset = RefAny::new(s);
        let ret =
            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
        assert_eq!(
            rendered_image_id(&ret),
            Some(expected_id),
            "the <img> must show exactly the frame the writeback stored"
        );
    }
    #[test]
    fn render_reports_the_bounds_back_as_the_scroll_size() {
        let dataset = state(VideoConfig::default());
        let ret =
            with_virtual_view_info(640.0, 480.0, |info| video_widget_render(dataset.clone(), info));
        assert_eq!(ret.materialized.size.width, 640.0);
        assert_eq!(ret.materialized.size.height, 480.0);
        assert_eq!(ret.virtual_rect.size.width, 640.0);
        assert_eq!(ret.virtual_rect.size.height, 480.0);
        assert_eq!((ret.materialized.origin.x, ret.materialized.origin.y), (0.0, 0.0));
        assert_eq!(
            (ret.virtual_rect.origin.x, ret.virtual_rect.origin.y),
            (0.0, 0.0)
        );
    }
    #[test]
    fn render_echoes_even_a_nan_bound_into_the_scroll_size() {
        // The early-out only suppresses the DOM: the reported scroll size is
        // still whatever layout handed in, NaN included.
        let dataset = state(VideoConfig::default());
        let ret = with_virtual_view_info(f32::NAN, 480.0, |info| {
            video_widget_render(dataset.clone(), info)
        });
        assert!(rendered_nothing(&ret));
        assert!(ret.materialized.size.width.is_nan());
        assert_eq!(ret.materialized.size.height, 480.0);
    }
    #[test]
    fn render_is_pure_and_repeatable() {
        let img = placeholder_image(b"stable");
        let expected_id = img.id;
        let mut s = base_state(VideoConfig::default());
        s.current_frame = Some(img);
        s.started = true;
        let mut dataset = RefAny::new(s);
        for _ in 0..8 {
            let ret = with_virtual_view_info(320.0, 240.0, |info| {
                video_widget_render(dataset.clone(), info)
            });
            assert_eq!(rendered_image_id(&ret), Some(expected_id));
        }
        let summary = read_state(&mut dataset);
        assert!(summary.started, "render must not touch the live state");
        assert_eq!(summary.current_frame_id, Some(expected_id));
    }
    #[test]
    fn render_with_the_smallest_positive_bounds_still_emits_the_image() {
        let img = placeholder_image(b"tiny");
        let expected_id = img.id;
        let mut s = base_state(VideoConfig::default());
        s.current_frame = Some(img);
        let dataset = RefAny::new(s);
        for (w, h) in [(f32::MIN_POSITIVE, f32::MIN_POSITIVE), (1.0, 1.0), (f32::MAX, f32::MAX)] {
            let ret = with_virtual_view_info(w, h, |info| video_widget_render(dataset.clone(), info));
            assert_eq!(
                rendered_image_id(&ret),
                Some(expected_id),
                "{w}x{h} is finite and positive, so the frame must render"
            );
        }
    }
    // ==================================================================
    // video_on_after_mount
    //
    // NOTE: the default (test-pattern) mount path is deliberately NOT driven
    // here. `video_test_worker` never reads its receiver, so it ignores
    // `ThreadSendMsg::TerminateThread`; the framework's thread destructor
    // *joins* that worker and would hang the test binary forever (see the
    // report). Only workers that return on their own are mounted below.
    // ==================================================================
    #[test]
    fn after_mount_ignores_a_dataset_that_is_not_a_video_state() {
        let (update, changes) =
            with_callback_info(|info| video_on_after_mount(RefAny::new(0_u32), info));
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "a foreign dataset must not start a decode thread"
        );
    }
    #[test]
    fn after_mount_is_a_no_op_once_the_decode_thread_has_started() {
        let mut s = base_state(VideoConfig::default());
        s.started = true;
        s.thread_id = Some(ThreadId::unique());
        s.current_frame = Some(placeholder_image(b"kept"));
        let mut data = RefAny::new(s);
        let before = read_state(&mut data);
        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "AfterMount must start the decode thread at most once"
        );
        assert_eq!(
            read_state(&mut data),
            before,
            "a re-mount must not disturb the running state"
        );
    }
    #[test]
    fn after_mount_spawns_the_streaming_decoder_and_remembers_its_id_and_sender() {
        let mut s = base_state(config(file_source("/tmp/clip.mp4"), 12.5));
        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
        let mut data = RefAny::new(s);
        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(update, Update::DoNothing, "mounting never triggers relayout");
        assert_eq!(changes.len(), 1, "exactly one thread is spawned");
        let tid = added_thread_id(&changes).expect("the decode worker must be added as a Thread");
        let summary = read_state(&mut data);
        assert!(summary.started);
        assert_eq!(
            summary.thread_id,
            Some(tid),
            "the state must remember the very thread id it registered (resize messaging)"
        );
        assert!(
            summary.has_seek_sender,
            "the merge callback needs the worker's sender to push seeks"
        );
    }
    #[test]
    fn after_mount_only_ever_spawns_one_decode_thread() {
        let mut s = base_state(VideoConfig::default());
        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
        let mut data = RefAny::new(s);
        let (_, first) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        let first_id = read_state(&mut data).thread_id;
        let (_, second) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(first.len(), 1);
        assert!(second.is_empty(), "the second AfterMount must be a no-op");
        assert_eq!(
            read_state(&mut data).thread_id,
            first_id,
            "the recorded thread id must not be re-rolled"
        );
    }
    #[test]
    fn after_mount_replay_path_spawns_a_worker_but_records_no_id_or_sender() {
        // ADVERSARIAL: the replay path spawns a `Thread` like the streaming path
        // does, but stores neither its `ThreadId` nor its sender - so resize
        // re-targeting and scrub/seek messaging are silently dead for replayed
        // clips (see the report). An EMPTY frame list is used so the worker
        // returns immediately and can be joined.
        let mut s = base_state(VideoConfig::default());
        s.frames = OptionRefAny::Some(RefAny::new(Vec::<VideoFrame>::new()));
        let mut data = RefAny::new(s);
        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(changes.len(), 1, "the replay worker is still spawned");
        let summary = read_state(&mut data);
        assert!(summary.started);
        assert_eq!(summary.thread_id, None);
        assert!(!summary.has_seek_sender);
    }
    #[test]
    fn after_mount_replay_path_accepts_a_wrong_typed_frame_list() {
        // A `RefAny` that is not a `Vec<VideoFrame>` must not panic the mount -
        // the worker just returns.
        let mut s = base_state(VideoConfig::default());
        s.frames = OptionRefAny::Some(RefAny::new("not a frame list"));
        let mut data = RefAny::new(s);
        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(changes.len(), 1);
        assert!(read_state(&mut data).started);
    }
    #[test]
    fn after_mount_prefers_the_streaming_decoder_over_a_replay_list() {
        // Documented priority: decode worker > replay frames > test pattern.
        // A NON-empty replay list is safe here precisely because it must NOT be
        // used (the replay worker would otherwise loop forever).
        let mut s = base_state(VideoConfig::default());
        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
        s.frames = OptionRefAny::Some(RefAny::new(vec![frame(2, 2), frame(2, 2)]));
        let mut data = RefAny::new(s);
        let (_, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
        assert_eq!(changes.len(), 1);
        let summary = read_state(&mut data);
        assert!(
            summary.thread_id.is_some() && summary.has_seek_sender,
            "only the streaming path records an id + sender, so it is the one that ran"
        );
        assert!(summary.has_frames, "the replay list is kept, just unused");
    }
    // ==================================================================
    // video_on_resize
    // ==================================================================
    #[test]
    fn resize_ignores_a_dataset_that_is_not_a_video_state() {
        let (update, changes) = with_callback_info(|info| video_on_resize(RefAny::new(0_u32), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn resize_before_the_worker_started_is_a_no_op() {
        let mut data = state(VideoConfig::default());
        let before = read_state(&mut data);
        let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
        assert_eq!(
            update,
            Update::DoNothing,
            "resize is a message, never a relayout"
        );
        assert!(changes.is_empty(), "no worker -> nothing to tell");
        assert_eq!(read_state(&mut data), before);
    }
    #[test]
    fn resize_with_an_unknown_node_is_a_no_op() {
        // The state knows a thread id, but the hit node has no laid-out size in
        // this (empty) window: the callback must bail instead of messaging a
        // bogus target size.
        let mut s = base_state(VideoConfig::default());
        s.started = true;
        s.thread_id = Some(ThreadId::unique());
        let mut data = RefAny::new(s);
        let before = read_state(&mut data);
        let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert_eq!(read_state(&mut data), before);
    }
    #[test]
    fn resize_with_a_thread_id_that_no_longer_exists_is_a_no_op() {
        // A worker that already exited: `get_thread` returns None and the
        // best-effort send is simply skipped.
        let mut s = base_state(VideoConfig::default());
        s.thread_id = Some(ThreadId::unique());
        s.started = true;
        let data = RefAny::new(s);
        for _ in 0..4 {
            let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
            assert_eq!(update, Update::DoNothing);
            assert!(changes.is_empty());
        }
    }
    // ==================================================================
    // video_test_worker
    // ==================================================================
    #[test]
    fn test_worker_stops_as_soon_as_the_main_thread_stops_receiving() {
        let sent = run_worker(video_test_worker, RefAny::new(()), 0, false);
        assert_eq!(
            sent.len(),
            1,
            "the worker must stop after the first rejected send, not spin"
        );
        assert_eq!((sent[0].width, sent[0].height), (1280, 720));
        assert_eq!(
            sent[0].len,
            1280 * 720 * 4,
            "a frame is exactly width * height * 4 tightly-packed RGBA bytes"
        );
    }
    #[test]
    fn test_worker_emits_seven_opaque_smpte_bars_in_order() {
        let sent = run_worker(video_test_worker, RefAny::new(()), 0, false);
        let f = &sent[0];
        assert!(
            f.rows_identical,
            "the bars scroll horizontally only - every scanline must be identical"
        );
        assert_eq!(
            f.row0_palette,
            EXPECTED_BARS.to_vec(),
            "tick 0 must emit the seven SMPTE bars left-to-right, all fully opaque"
        );
    }
    #[test]
    fn test_worker_ignores_terminate_and_scrolls_the_pattern() {
        // ADVERSARIAL: the worker never polls its receiver, so `TerminateThread`
        // - the message the framework's thread destructor sends before joining -
        // is ignored outright. The only thing that stops it is a failed send.
        let sent = run_worker(video_test_worker, RefAny::new(()), 3, true);
        assert_eq!(
            sent.len(),
            4,
            "3 accepted + 1 rejected: TerminateThread did not stop the worker"
        );
        for f in &sent {
            assert_eq!(f.len, 1280 * 720 * 4);
            assert!(f.rows_identical);
        }
        // tick advances by 2 per frame and the shift is `tick / 4`, so frames
        // 0+1 share a phase and frame 2 is rotated by exactly one bar.
        assert_eq!(sent[0].row0_palette, sent[1].row0_palette);
        assert_eq!(sent[2].row0_palette, sent[3].row0_palette);
        assert_ne!(
            sent[1].row0_palette, sent[2].row0_palette,
            "the pattern must actually scroll"
        );
        assert_eq!(
            sent[2].row0_palette[0], EXPECTED_BARS[1],
            "one tick of scroll rotates the palette by one bar"
        );
    }
    #[test]
    fn test_worker_ignores_its_init_payload_entirely() {
        // The test pattern is fixed-size: no init data can change it (or crash it).
        for init in [
            RefAny::new(()),
            RefAny::new(0_u32),
            RefAny::new(VideoConfig::default()),
            RefAny::new(vec![frame(1, 1)]),
        ] {
            let sent = run_worker(video_test_worker, init, 0, false);
            assert_eq!(sent.len(), 1);
            assert_eq!((sent[0].width, sent[0].height), (1280, 720));
        }
    }
    // ==================================================================
    // video_replay_worker
    // ==================================================================
    #[test]
    fn replay_worker_returns_immediately_for_a_wrong_typed_init() {
        for init in [
            RefAny::new(0_u32),
            RefAny::new("not a frame list"),
            RefAny::new(VideoConfig::default()),
            RefAny::new(frame(1, 1)),
        ] {
            let sent = run_worker(video_replay_worker, init, 8, false);
            assert!(
                sent.is_empty(),
                "a payload that is not a Vec<VideoFrame> must be skipped, not guessed at"
            );
        }
    }
    #[test]
    fn replay_worker_returns_immediately_for_an_empty_frame_list() {
        // Boundary: an empty list would make `idx % frames.len()` divide by
        // zero - the worker must bail first.
        let sent = run_worker(
            video_replay_worker,
            RefAny::new(Vec::<VideoFrame>::new()),
            8,
            false,
        );
        assert!(sent.is_empty());
    }
    #[test]
    fn replay_worker_sends_the_caller_frames_byte_for_byte() {
        let frames = vec![
            frame_raw(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]),
            frame_raw(1, 2, vec![9, 10, 11, 12, 13, 14, 15, 16]),
        ];
        let sent = run_worker(video_replay_worker, RefAny::new(frames.clone()), 2, false);
        assert_eq!(sent.len(), 3, "2 accepted + 1 rejected");
        for (i, s) in sent.iter().enumerate() {
            let expected = &frames[i % frames.len()];
            assert_eq!((s.width, s.height), (expected.width, expected.height));
            assert_eq!(
                s.small_bytes.as_deref(),
                Some(expected.bytes.as_ref()),
                "frame {i} must be replayed verbatim - no re-encoding"
            );
        }
    }
    #[test]
    fn replay_worker_cycles_the_list_and_never_indexes_out_of_bounds() {
        let frames = vec![frame_raw(1, 1, vec![0; 4]), frame_raw(2, 2, vec![1; 16])];
        let sent = run_worker(video_replay_worker, RefAny::new(frames), 5, false);
        assert_eq!(sent.len(), 6);
        let widths: Vec<u32> = sent.iter().map(|s| s.width).collect();
        assert_eq!(widths, vec![1, 2, 1, 2, 1, 2], "the list must wrap around");
    }
    #[test]
    fn replay_worker_forwards_degenerate_frames_unchanged() {
        // A decoder can hand back a 0x0 frame or one whose byte count does not
        // match its dimensions; the replay worker is a pipe, not a validator -
        // it must not panic, truncate, or drop them.
        let frames = vec![
            frame_raw(0, 0, Vec::new()),
            frame_raw(u32::MAX, u32::MAX, Vec::new()),
            frame_raw(1, 1, vec![0xAB; 3]),
        ];
        let sent = run_worker(video_replay_worker, RefAny::new(frames), 2, false);
        assert_eq!(sent.len(), 3);
        assert_eq!((sent[0].width, sent[0].height, sent[0].len), (0, 0, 0));
        assert_eq!(
            (sent[1].width, sent[1].height, sent[1].len),
            (u32::MAX, u32::MAX, 0)
        );
        assert_eq!(sent[2].small_bytes.as_deref(), Some(&[0xAB, 0xAB, 0xAB][..]));
    }
    // ==================================================================
    // video_writeback
    // ==================================================================
    #[test]
    fn writeback_stores_the_frame_and_rerenders_the_virtual_view() {
        let mut data = state(VideoConfig::default());
        let frame_data = RefAny::new(frame(4, 3));
        let (update, changes) =
            with_callback_info(|info| video_writeback(data.clone(), frame_data.clone(), info));
        assert_eq!(update, Update::DoNothing, "no hook -> no user update");
        assert_eq!(
            count_virtual_view_rerenders(&changes),
            1,
            "the VirtualView must be re-rendered in place (never RefreshDom)"
        );
        assert_eq!(
            current_frame_dims(&mut data),
            Some((4, 3)),
            "the decoded frame becomes the widget's current CPU image"
        );
    }
    #[test]
    fn writeback_invokes_the_hook_with_the_exact_frame_and_returns_its_update() {
        let mut log = frame_log(Update::RefreshDom);
        let mut s = base_state(VideoConfig::default());
        s.on_frame = hook_into(&log);
        let mut data = RefAny::new(s);
        let frame_data = RefAny::new(frame(2, 2));
        let (update, changes) =
            with_callback_info(|info| video_writeback(data.clone(), frame_data.clone(), info));
        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
        assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
        assert_eq!(count_virtual_view_rerenders(&changes), 1);
        assert_eq!(current_frame_dims(&mut data), Some((2, 2)));
    }
    #[test]
    fn writeback_ignores_frame_data_of_the_wrong_type() {
        let mut log = frame_log(Update::RefreshDom);
        let mut s = base_state(VideoConfig::default());
        s.on_frame = hook_into(&log);
        let mut data = RefAny::new(s);
        let (update, changes) =
            with_callback_info(|info| video_writeback(data.clone(), RefAny::new(0_u32), info));
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "no frame -> no re-render is scheduled at all"
        );
        assert!(
            logged_frames(&mut log).is_empty(),
            "the user hook must not fire without a frame"
        );
        assert_eq!(read_state(&mut data).current_frame_id, None);
    }
    #[test]
    fn writeback_survives_a_writeback_dataset_that_is_not_a_video_state() {
        let (update, changes) = with_callback_info(|info| {
            video_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
        });
        assert_eq!(
            update,
            Update::DoNothing,
            "a foreign dataset means no hook and nowhere to store - but no panic"
        );
        assert_eq!(
            count_virtual_view_rerenders(&changes),
            1,
            "the re-render is still scheduled (documented cost of a stale dataset)"
        );
    }
    #[test]
    fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
        // A malformed/hostile frame: the image build must fail cleanly instead
        // of indexing out of bounds or allocating.
        let mut data = state(VideoConfig::default());
        for bogus in [
            frame_raw(u32::MAX, 1, Vec::new()),
            frame_raw(4, 4, vec![0; 4 * 4 * 4 - 1]),
            frame_raw(4, 4, vec![0; 4 * 4 * 4 + 1]),
            frame_raw(1, 1, Vec::new()),
            frame_raw(0, 0, vec![0; 4]),
        ] {
            let payload = RefAny::new(bogus);
            let (update, changes) =
                with_callback_info(|info| video_writeback(data.clone(), payload.clone(), info));
            assert_eq!(update, Update::DoNothing);
            assert_eq!(
                count_virtual_view_rerenders(&changes),
                1,
                "a rejected frame still costs a re-render"
            );
            assert_eq!(
                read_state(&mut data).current_frame_id,
                None,
                "a rejected frame must never become the displayed image"
            );
        }
    }
    #[test]
    fn writeback_accepts_an_empty_zero_by_zero_frame() {
        // Boundary: 0x0 with 0 bytes is internally consistent, so it is accepted
        // as a (degenerate) image rather than rejected.
        let mut data = state(VideoConfig::default());
        let empty = RefAny::new(frame_raw(0, 0, Vec::new()));
        let (update, _) =
            with_callback_info(|info| video_writeback(data.clone(), empty.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(current_frame_dims(&mut data), Some((0, 0)));
    }
    #[test]
    fn writeback_replaces_the_previous_frame_every_time() {
        let mut s = base_state(VideoConfig::default());
        s.current_frame = Some(placeholder_image(b"old"));
        let mut data = RefAny::new(s);
        let old_id = read_state(&mut data).current_frame_id.expect("seeded");
        let f1 = RefAny::new(frame(2, 2));
        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), f1.clone(), info));
        let id1 = read_state(&mut data).current_frame_id.expect("stored");
        assert_ne!(id1, old_id, "the stale placeholder must be replaced");
        let f2 = RefAny::new(frame(3, 3));
        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), f2.clone(), info));
        let id2 = read_state(&mut data).current_frame_id.expect("stored");
        assert_ne!(id2, id1, "every frame installs a fresh image");
        assert_eq!(current_frame_dims(&mut data), Some((3, 3)));
    }
    #[test]
    fn writeback_keeps_the_last_good_frame_when_a_later_one_is_malformed() {
        let mut data = state(VideoConfig::default());
        let good = RefAny::new(frame(2, 2));
        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), good.clone(), info));
        let good_id = read_state(&mut data).current_frame_id.expect("stored");
        let bad = RefAny::new(frame_raw(1024, 1024, vec![0; 16]));
        let (update, _) =
            with_callback_info(|info| video_writeback(data.clone(), bad.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            read_state(&mut data).current_frame_id,
            Some(good_id),
            "a corrupt frame must not blank the picture"
        );
    }
    #[test]
    fn writeback_still_notifies_the_hook_for_a_frame_it_cannot_display() {
        // The hook is the user's data path (save / send), so it fires even when
        // the frame is unusable as an image - documented here so a change is
        // deliberate.
        let mut log = frame_log(Update::RefreshDom);
        let mut s = base_state(VideoConfig::default());
        s.on_frame = hook_into(&log);
        let mut data = RefAny::new(s);
        let bogus = RefAny::new(frame_raw(64, 64, vec![0; 3]));
        let (update, _) =
            with_callback_info(|info| video_writeback(data.clone(), bogus.clone(), info));
        assert_eq!(update, Update::RefreshDom);
        assert_eq!(logged_frames(&mut log), vec![(64, 64, 3)]);
        assert_eq!(read_state(&mut data).current_frame_id, None);
    }
    #[test]
    fn writeback_survives_dimensions_whose_byte_count_overflows_usize() {
        // ADVERSARIAL: a decoder reporting 2^31 x 2^31 makes the raw-image path
        // compute `width * height * 4` in usize -> 2^64, which overflows. In a
        // debug build that is an arithmetic-overflow panic; in release it wraps
        // and the empty buffer may be *accepted*. Neither is a graceful
        // rejection (see the report) - what must hold in both modes is that the
        // widget never ends up displaying a bogus image.
        let mut data = state(VideoConfig::default());
        let huge = RefAny::new(frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()));
        let (result, _) = with_callback_info(|info| {
            catch_unwind(AssertUnwindSafe(|| {
                video_writeback(data.clone(), huge.clone(), info)
            }))
        });
        match result {
            Ok(update) => {
                assert_eq!(update, Update::DoNothing);
                assert_eq!(
                    read_state(&mut data).current_frame_id,
                    None,
                    "an overflowing frame must not become the displayed image"
                );
            }
            Err(_) => eprintln!(
                "NOTE: video_writeback panicked (usize overflow of width*height*4) for a \
                 2^31 x 2^31 frame - see the autotest report"
            ),
        }
    }
    // ==================================================================
    // merge_video_state
    // ==================================================================
    /// An `(old, new)` pair plus the seek channel `old` hands forward.
    fn merge_pair(
        old_cfg: VideoConfig,
        new_cfg: VideoConfig,
    ) -> (RefAny, RefAny, Receiver<ThreadSendMsg>) {
        let (tx, rx) = channel::<ThreadSendMsg>();
        let mut old = base_state(old_cfg);
        old.started = true;
        old.thread_id = Some(ThreadId::unique());
        old.seek_sender = Some(tx);
        (RefAny::new(base_state(new_cfg)), RefAny::new(old), rx)
    }
    #[test]
    fn merge_takes_the_live_state_from_old_and_the_config_from_new() {
        let log = frame_log(Update::DoNothing);
        let tid = ThreadId::unique();
        let (tx, _rx) = channel::<ThreadSendMsg>();
        let mut new = base_state(config(file_source("/new.mp4"), 3.0));
        new.on_frame = hook_into(&log);
        new.frames = OptionRefAny::Some(RefAny::new(vec![frame(1, 1)]));
        let mut old = base_state(config(file_source("/old.mp4"), 3.0));
        old.started = true;
        old.gl_texture_id = Some(9);
        old.frames = OptionRefAny::Some(RefAny::new(vec![frame(7, 7), frame(8, 8)]));
        old.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
        old.current_frame = Some(placeholder_image(b"live"));
        old.thread_id = Some(tid);
        old.seek_sender = Some(tx);
        let old_frame_id = old.current_frame.as_ref().map(|i| i.id);
        let mut merged = merge_video_state(RefAny::new(new), RefAny::new(old));
        assert_same_config(
            &read_config(&mut merged),
            &config(file_source("/new.mp4"), 3.0),
        );
        let summary = read_state(&mut merged);
        assert!(summary.has_hook, "the fresh build's hook wins");
        assert!(summary.started, "'already running' must carry forward");
        assert_eq!(summary.gl_texture_id, Some(9));
        assert_eq!(summary.decode_cb, Some(noop_decode_worker as ThreadCallbackType as usize));
        assert_eq!(summary.current_frame_id, old_frame_id, "no visible flicker");
        assert_eq!(summary.thread_id, Some(tid));
        assert!(summary.has_seek_sender);
        assert_eq!(
            state_frames(&mut merged),
            Some(vec![(7, 7), (8, 8)]),
            "the OLD replay list wins - a fresh build cannot swap the clip"
        );
    }
    #[test]
    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
        let mut new = base_state(config(file_source("/new.mp4"), 1.0));
        new.frames = OptionRefAny::Some(RefAny::new(vec![frame(5, 5)]));
        let mut merged = merge_video_state(RefAny::new(new), RefAny::new(0_u32));
        let summary = read_state(&mut merged);
        assert!(!summary.started, "nothing to carry forward");
        assert_eq!(summary.thread_id, None);
        assert_eq!(
            state_frames(&mut merged),
            Some(vec![(5, 5)]),
            "with no old state the new build's own list survives"
        );
    }
    #[test]
    fn merge_returns_a_foreign_new_dataset_untouched() {
        let old = state(VideoConfig::default());
        let mut merged = merge_video_state(RefAny::new(77_u32), old);
        assert_eq!(
            merged.downcast_ref::<u32>().map(|v| *v),
            Some(77),
            "merge must hand back exactly the payload it was given"
        );
    }
    #[test]
    fn merge_of_a_dataset_with_itself_does_not_panic() {
        // The same RefAny on both sides: the mutable + shared borrows overlap,
        // so the merge is skipped rather than aliasing. Either way the state
        // must survive intact.
        let mut s = base_state(config(file_source("/self.mp4"), 4.0));
        s.started = true;
        s.gl_texture_id = Some(5);
        let mut data = RefAny::new(s);
        let before = read_state(&mut data);
        let mut merged = merge_video_state(data.clone(), data.clone());
        assert_eq!(read_state(&mut merged), before);
        assert_eq!(read_state(&mut data), before);
    }
    #[test]
    fn merge_pushes_a_seek_when_the_scrub_position_changed() {
        let (new, old, rx) = merge_pair(
            config(file_source("/clip.mp4"), 0.0),
            config(file_source("/clip.mp4"), 42.25),
        );
        let _merged = merge_video_state(new, old);
        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
        assert_eq!(msgs.len(), 1, "one seek, no source re-init");
        assert_eq!(
            custom_f32(&msgs[0]),
            Some(42.25),
            "the worker must be told the NEW timestamp"
        );
    }
    #[test]
    fn merge_stays_quiet_when_nothing_changed() {
        let (new, old, rx) = merge_pair(
            config(file_source("/clip.mp4"), 7.5),
            config(file_source("/clip.mp4"), 7.5),
        );
        let _merged = merge_video_state(new, old);
        assert!(
            rx.try_iter().next().is_none(),
            "an unchanged config must not wake the decode worker"
        );
    }
    #[test]
    fn merge_treats_negative_zero_and_zero_as_the_same_position() {
        let (new, old, rx) = merge_pair(
            config(file_source("/clip.mp4"), -0.0),
            config(file_source("/clip.mp4"), 0.0),
        );
        let _merged = merge_video_state(new, old);
        assert!(
            rx.try_iter().next().is_none(),
            "-0.0 == 0.0 is the same scrub position"
        );
    }
    #[test]
    fn merge_seeks_on_every_relayout_while_the_timestamp_is_nan() {
        // ADVERSARIAL: `NaN != NaN`, so an unchanged NaN scrub position looks
        // like a change on every single relayout and floods the worker with
        // seeks (see the report). Pinned here as the current behaviour.
        let (new, old, rx) = merge_pair(
            config(file_source("/clip.mp4"), f32::NAN),
            config(file_source("/clip.mp4"), f32::NAN),
        );
        let _merged = merge_video_state(new, old);
        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
        assert_eq!(msgs.len(), 1);
        assert!(
            custom_f32(&msgs[0]).is_some_and(f32::is_nan),
            "the spurious seek carries the NaN straight through to the worker"
        );
    }
    #[test]
    fn merge_pushes_the_new_source_when_the_input_changed() {
        let (new, old, rx) = merge_pair(
            config(file_source("/old.mp4"), 1.0),
            config(url_source("cdn.example", "/new.mp4"), 1.0),
        );
        let _merged = merge_video_state(new, old);
        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
        assert_eq!(msgs.len(), 1, "one re-init, no seek");
        assert_eq!(
            custom_source(&msgs[0]),
            Some(url_source("cdn.example", "/new.mp4"))
        );
    }
    #[test]
    fn merge_sends_the_seek_before_the_source_when_both_changed() {
        let (new, old, rx) = merge_pair(
            config(file_source("/old.mp4"), 0.0),
            config(bytes_source(vec![1, 2, 3]), 9.0),
        );
        let _merged = merge_video_state(new, old);
        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
        assert_eq!(msgs.len(), 2);
        assert_eq!(custom_f32(&msgs[0]), Some(9.0));
        assert_eq!(custom_source(&msgs[1]), Some(bytes_source(vec![1, 2, 3])));
    }
    #[test]
    fn merge_notices_a_source_change_that_only_differs_in_unicode() {
        let (new, old, rx) = merge_pair(
            config(file_source("/tmp/\u{1F3AC}.mp4"), 0.0),
            config(file_source("/tmp/\u{1F3AB}.mp4"), 0.0),
        );
        let _merged = merge_video_state(new, old);
        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
        assert_eq!(msgs.len(), 1, "distinct emoji are distinct sources");
        assert_eq!(
            custom_source(&msgs[0]),
            Some(file_source("/tmp/\u{1F3AB}.mp4"))
        );
    }
    #[test]
    fn merge_without_a_seek_sender_drops_the_seek_silently() {
        // Nothing to send to (replay / test-pattern mounts never record a
        // sender): the merge must still carry the state, not panic.
        let new = RefAny::new(base_state(config(file_source("/clip.mp4"), 5.0)));
        let mut old_state = base_state(config(file_source("/clip.mp4"), 0.0));
        old_state.started = true;
        let mut merged = merge_video_state(new, RefAny::new(old_state));
        let summary = read_state(&mut merged);
        assert!(summary.started);
        assert!(!summary.has_seek_sender);
        assert_eq!(read_config(&mut merged).timestamp, 5.0);
    }
    #[test]
    fn merge_survives_a_worker_whose_channel_is_already_closed() {
        let (new, old, rx) = merge_pair(
            config(file_source("/a.mp4"), 0.0),
            config(file_source("/b.mp4"), 1.0),
        );
        drop(rx); // the worker exited and its receiver is gone
        let mut merged = merge_video_state(new, old);
        let summary = read_state(&mut merged);
        assert!(
            summary.has_seek_sender,
            "a dead sender is still carried forward - the send just fails"
        );
        assert!(summary.started);
    }
    #[test]
    fn merge_is_idempotent_across_repeated_relayouts() {
        let (tx, rx) = channel::<ThreadSendMsg>();
        let tid = ThreadId::unique();
        let mut live = base_state(config(file_source("/clip.mp4"), 2.0));
        live.started = true;
        live.gl_texture_id = Some(3);
        live.thread_id = Some(tid);
        live.seek_sender = Some(tx);
        live.current_frame = Some(placeholder_image(b"live"));
        let mut carried = RefAny::new(live);
        for _ in 0..5 {
            let fresh = RefAny::new(base_state(config(file_source("/clip.mp4"), 2.0)));
            carried = merge_video_state(fresh, carried);
        }
        let summary = read_state(&mut carried);
        assert!(summary.started);
        assert_eq!(summary.gl_texture_id, Some(3));
        assert_eq!(summary.thread_id, Some(tid));
        assert!(summary.current_frame_id.is_some(), "the picture never blanks");
        assert!(
            rx.try_iter().next().is_none(),
            "a stable config must never seek, however many relayouts happen"
        );
    }
}