Skip to main content

azul_layout/widgets/
video.rs

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
12use alloc::vec::Vec;
13
14use azul_core::callbacks::{Update, VirtualViewCallbackInfo, VirtualViewReturn};
15use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter, OptionDom};
16use azul_core::geom::LogicalPosition;
17use azul_core::refany::{OptionRefAny, RefAny};
18use azul_core::resources::{ImageRef, RawImage, RawImageData, RawImageFormat};
19use azul_core::task::{ThreadId, ThreadReceiver, ThreadSendMsg};
20use azul_core::video::{VideoConfig, VideoFrame};
21
22use super::capture_common::{
23    invoke_on_frame, OnVideoFrame, OnVideoFrameCallback, OptionOnVideoFrame,
24};
25use crate::callbacks::{Callback, CallbackInfo, CallbackType};
26use 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).
32const DEFAULT_W: u32 = 1280;
33const DEFAULT_H: u32 = 720;
34
35/// Live state for one video widget, carried across relayout by
36/// [`merge_video_state`].
37#[derive(Debug)]
38pub 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)]
79pub 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
90impl VideoWidget {
91    /// Create a video widget for the given config.
92    #[must_use] pub const fn create(config: VideoConfig) -> Self {
93        Self {
94            config,
95            on_frame: OptionOnVideoFrame::None,
96            frames: OptionRefAny::None,
97        }
98    }
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    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
104        self.on_frame = Some(OnVideoFrame {
105            refany: data,
106            callback: on_frame.into(),
107        })
108        .into();
109    }
110
111    /// Builder form of [`set_on_frame`](Self::set_on_frame).
112    #[must_use]
113    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
114        mut self,
115        data: RefAny,
116        on_frame: C,
117    ) -> Self {
118        self.set_on_frame(data, on_frame);
119        self
120    }
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    #[must_use] pub fn with_frames(mut self, frames: RefAny) -> Self {
130        self.frames = Some(frames).into();
131        self
132    }
133
134    fn build_dom(self, decode_cb: Option<ThreadCallback>) -> Dom {
135        let state = VideoWidgetState {
136            config: self.config,
137            started: false,
138            gl_texture_id: None,
139            on_frame: self.on_frame,
140            frames: self.frames,
141            decode_callback: decode_cb,
142            current_frame: None,
143            thread_id: None,
144            seek_sender: None,
145        };
146        let dataset = RefAny::new(state);
147        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        Dom::create_div()
157            .with_dataset(OptionRefAny::Some(dataset.clone()))
158            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_video_state))
159            .with_callback(
160                EventFilter::Component(ComponentEventFilter::AfterMount),
161                dataset.clone(),
162                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            .with_callback(
167                EventFilter::Component(ComponentEventFilter::NodeResized),
168                dataset,
169                Callback::from_ptr(video_on_resize),
170            )
171            .with_child(
172                Dom::create_virtual_view(
173                    vv_data,
174                    azul_core::callbacks::VirtualViewCallback::create(video_widget_render),
175                )
176                .with_css("width: 100%; height: 100%; overflow: hidden;"),
177            )
178    }
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    #[must_use] pub fn dom(self) -> Dom {
184        self.build_dom(None)
185    }
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    #[must_use] pub fn dom_with_decoder(self, cb: ThreadCallback) -> Dom {
196        self.build_dom(Some(cb))
197    }
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.
205extern "C" fn video_widget_render(
206    mut data: RefAny,
207    info: VirtualViewCallbackInfo,
208) -> VirtualViewReturn {
209    let bounds = info.get_bounds().get_logical_size();
210    if std::env::var("AZ_VIDEO_FRAMELOG").is_ok() {
211        eprintln!("[vrender] bounds {}x{}", bounds.width, bounds.height);
212    }
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    let dom = if !bounds.width.is_finite()
217        || !bounds.height.is_finite()
218        || bounds.width <= 0.0
219        || bounds.height <= 0.0
220    {
221        OptionDom::None
222    } else {
223        data.downcast_ref::<VideoWidgetState>().map_or(OptionDom::None, |s| {
224            s.current_frame.as_ref().map_or(OptionDom::None, |img| {
225                OptionDom::Some(
226                    Dom::create_image(img.clone()).with_css("width: 100%; height: 100%;"),
227                )
228            })
229        })
230    };
231    VirtualViewReturn {
232        dom,
233        scroll_size: bounds,
234        scroll_offset: LogicalPosition::zero(),
235        virtual_scroll_size: bounds,
236        virtual_scroll_offset: LogicalPosition::zero(),
237    }
238}
239
240/// `AfterMount`: start the background decode thread exactly once.
241extern "C" fn video_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
242    // Mark started exactly once; pull out the streaming decode worker (if any),
243    // its source, and any pre-decoded replay frames.
244    let (decode_cb, config, frames) = {
245        let Some(mut s) = data.downcast_mut::<VideoWidgetState>() else {
246            return Update::DoNothing;
247        };
248        if s.started {
249            return Update::DoNothing;
250        }
251        s.started = true;
252        let frames = match &s.frames {
253            OptionRefAny::Some(f) => Some(f.clone()),
254            OptionRefAny::None => None,
255        };
256        (s.decode_callback.clone(), s.config.clone(), frames)
257    };
258    // Priority: off-main streaming decode worker > replay pre-decoded frames >
259    // built-in test pattern. All feed the same WriteBack -> video_writeback path.
260    if let Some(cb) = decode_cb {
261        // The worker's thread-init is the `VideoConfig` itself: it matches on
262        // `config.source` (typed — no RefAny downcast) and reads `config.timestamp`.
263        let init = RefAny::new(config);
264        let tid = ThreadId::unique();
265        let thread = Thread::create(init, data.clone(), cb);
266        // Grab the main→worker sender BEFORE add_thread moves the Thread, so the
267        // merge callback can push seeks to the worker (scrubbing).
268        let seek_sender = thread.clone_sender();
269        info.add_thread(tid, thread);
270        // Remember the worker's id (resize messaging) + sender (seek messaging).
271        if let Some(mut s) = data.downcast_mut::<VideoWidgetState>() {
272            s.thread_id = Some(tid);
273            s.seek_sender = seek_sender;
274        }
275    } else if let Some(frames) = frames {
276        info.add_thread(
277            ThreadId::unique(),
278            Thread::create(frames, data.clone(), ThreadCallback::new(video_replay_worker)),
279        );
280    } else {
281        info.add_thread(
282            ThreadId::unique(),
283            Thread::create(
284                RefAny::new(()),
285                data.clone(),
286                ThreadCallback::new(video_test_worker),
287            ),
288        );
289    }
290    Update::DoNothing
291}
292
293/// `NodeResized`: the video box changed physical size (window resize / relayout). Tell
294/// the running decode worker the new target size via its `ThreadSender` so it scales
295/// frames to fit OFF the main thread - the UI then does a cheap image swap with no
296/// interpolation. This is a message, NOT a relayout: returns `DoNothing`.
297#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
298extern "C" fn video_on_resize(mut data: RefAny, mut info: CallbackInfo) -> Update {
299    let tid = match data.downcast_ref::<VideoWidgetState>() {
300        Some(s) => s.thread_id,
301        None => return Update::DoNothing,
302    };
303    let Some(tid) = tid else {
304        return Update::DoNothing;
305    };
306    let node = info.get_hit_node();
307    let Some(size) = info.get_node_size(node) else {
308        return Update::DoNothing;
309    };
310    let target = (size.width.max(1.0) as u32, size.height.max(1.0) as u32);
311    if let Some(thread) = info.get_thread(&tid) {
312        // Best-effort resize notification: if the decode worker has already
313        // exited, the send fails and there is nothing to do here.
314        let _ = thread.send_message(ThreadSendMsg::Custom(RefAny::new(target)));
315    }
316    Update::DoNothing
317}
318
319/// Background worker (test pattern): SMPTE-style colour bars scrolling
320/// horizontally ~30x/s. Replaced by the real vk-video decode worker later.
321#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
322extern "C" fn video_test_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
323    const BARS: [[u8; 3]; 7] = [
324        [235, 235, 235],
325        [235, 235, 16],
326        [16, 235, 235],
327        [16, 235, 16],
328        [235, 16, 235],
329        [235, 16, 16],
330        [16, 16, 235],
331    ];
332    let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
333    let mut tick: u32 = 0;
334    loop {
335        let shift = (tick as usize / 4) % 7;
336        let mut bytes = Vec::with_capacity(w * h * 4);
337        for _y in 0..h {
338            for x in 0..w {
339                let c = BARS[((x * 7 / w) + shift) % 7];
340                bytes.extend_from_slice(&[c[0], c[1], c[2], 255]);
341            }
342        }
343        let frame = VideoFrame {
344            width: w as u32,
345            height: h as u32,
346            bytes: bytes.into(),
347        };
348        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
349            WriteBackCallback::new(video_writeback),
350            RefAny::new(frame),
351        )));
352        if !sent {
353            break;
354        }
355        std::thread::sleep(std::time::Duration::from_millis(33));
356        tick = tick.wrapping_add(2);
357    }
358}
359
360/// Background worker (replay): cycle a caller-supplied `Vec<VideoFrame>` (e.g. a
361/// clip decoded up front via `decode_mp4_h264_bytes`) ~30x/s through the same
362/// `WriteBack` -> [`video_writeback`] -> [`super::capture_common::present_frame`]
363/// path as the test pattern, so real decoded pixels land in the shared GL
364/// texture. `init` is the `RefAny` handed to
365/// [`VideoWidget::with_frames`](VideoWidget::with_frames); if it doesn't hold a
366/// non-empty `Vec<VideoFrame>` the worker just returns.
367extern "C" fn video_replay_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
368    let frames: Vec<VideoFrame> = match init.downcast_ref::<Vec<VideoFrame>>() {
369        Some(f) => f.clone(),
370        None => return,
371    };
372    if frames.is_empty() {
373        return;
374    }
375    let mut idx: usize = 0;
376    loop {
377        let frame = frames[idx % frames.len()].clone();
378        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
379            WriteBackCallback::new(video_writeback),
380            RefAny::new(frame),
381        )));
382        if !sent {
383            break;
384        }
385        std::thread::sleep(std::time::Duration::from_millis(33));
386        idx = idx.wrapping_add(1);
387    }
388}
389
390/// Writeback (main thread): store the decoded frame as the widget's
391/// `current_frame` (a CPU `ImageRef`) and re-render the `VirtualView` in place so it
392/// re-reads it - exactly like `map_tile_writeback`.
393///
394/// Renders on cpurender AND
395/// webrender (no GL `present_frame`, no DOM rebuild).
396#[must_use] pub extern "C" fn video_writeback(
397    mut writeback_data: RefAny,
398    mut frame_data: RefAny,
399    mut info: CallbackInfo,
400) -> Update {
401    let hook = writeback_data.downcast_ref::<VideoWidgetState>().map_or_else(|| OptionOnVideoFrame::None, |s| s.on_frame.clone());
402    let mut user_update = Update::DoNothing;
403    match frame_data.downcast_ref::<VideoFrame>() {
404        Some(frame) => {
405            // Guard against dimensions whose RGBA byte count overflows `usize`
406            // before `ImageRef::new_rawimage` validates it against the buffer:
407            // `width * height * 4` wraps in release (e.g. 2^31 x 2^31 -> 0) so an
408            // empty buffer would spuriously "match" and store a bogus frame. A
409            // `checked_mul` that overflows drops the frame — the hook is still
410            // notified below, exactly as for a byte-count mismatch.
411            let fits = (frame.width as usize)
412                .checked_mul(frame.height as usize)
413                .and_then(|px| px.checked_mul(4))
414                .is_some();
415            if fits {
416                if let Some(img) = ImageRef::new_rawimage(RawImage {
417                    pixels: RawImageData::U8(frame.bytes.clone()),
418                    width: frame.width as usize,
419                    height: frame.height as usize,
420                    premultiplied_alpha: false,
421                    data_format: RawImageFormat::RGBA8,
422                    tag: b"azul-video-frame".to_vec().into(),
423                }) {
424                    if let Some(mut s) = writeback_data.downcast_mut::<VideoWidgetState>() {
425                        s.current_frame = Some(img);
426                    }
427                }
428            }
429            user_update = invoke_on_frame(&hook, &mut info, &frame);
430        }
431        None => return Update::DoNothing,
432    }
433    // Re-render the VirtualView(s) in place so the content callback re-reads the
434    // freshly-stored `current_frame` (NOT RefreshDom — that would rebuild the DOM
435    // and orphan the worker's dataset clone). Same trick as `map_tile_writeback`.
436    info.trigger_all_virtual_view_rerender();
437    user_update
438}
439
440/// Carry live state forward across relayout.
441#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
442extern "C" fn merge_video_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
443    {
444        let new_guard = new_data.downcast_mut::<VideoWidgetState>();
445        let old_guard = old_data.downcast_ref::<VideoWidgetState>();
446        if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
447            new_g.started = old_g.started;
448            new_g.gl_texture_id = old_g.gl_texture_id;
449            new_g.frames = old_g.frames.clone();
450            new_g.decode_callback.clone_from(&old_g.decode_callback);
451            new_g.current_frame.clone_from(&old_g.current_frame);
452            new_g.thread_id = old_g.thread_id;
453            new_g.seek_sender.clone_from(&old_g.seek_sender);
454            // Scrubbing: a changed `config.timestamp` across this relayout → tell the
455            // worker to seek. Cheap wall-clock reposition (the worker already has the
456            // decoded frames), result comes back as an image swap — no re-decode here.
457            if old_g.config.timestamp != new_g.config.timestamp {
458                if let Some(snd) = new_g.seek_sender.as_ref() {
459                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.timestamp))));
460                }
461            }
462            // Input-source change → tell the worker to re-init the decode (it
463            // re-resolves/demuxes/decodes the new source); the frame swaps in when ready.
464            if old_g.config.source != new_g.config.source {
465                if let Some(snd) = new_g.seek_sender.as_ref() {
466                    drop(snd.send(ThreadSendMsg::Custom(RefAny::new(new_g.config.source.clone()))));
467                }
468            }
469        }
470    }
471    new_data
472}
473
474// ============================================================================
475// Generated adversarial tests
476// ============================================================================
477
478#[cfg(test)]
479#[allow(
480    clippy::too_many_lines,
481    clippy::cast_possible_truncation,
482    clippy::float_cmp,
483    clippy::items_after_statements,
484    clippy::let_and_return
485)]
486mod autotest_generated {
487    use std::{
488        collections::BTreeMap,
489        panic::{catch_unwind, AssertUnwindSafe},
490        sync::{
491            atomic::{AtomicUsize, Ordering},
492            mpsc::{channel, Receiver, Sender},
493            Arc, Mutex, PoisonError,
494        },
495    };
496
497    use azul_core::{
498        callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
499        dom::{DomId, DomNodeId, NodeType},
500        geom::{LogicalSize, OptionLogicalPosition},
501        gl::OptionGlContextPtr,
502        hit_test::ScrollPosition,
503        resources::{DecodedImage, DpiScaleFactor, ImageCache, RendererResources},
504        styled_dom::NodeHierarchyItemId,
505        task::{
506            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
507            ThreadRecvCallback,
508        },
509        video::VideoSource,
510        window::{MonitorVec, RawWindowHandle, WindowTheme},
511    };
512    use azul_css::{system::SystemStyle, AzString};
513    use rust_fontconfig::FcFontCache;
514
515    use super::*;
516    #[cfg(feature = "icu")]
517    use crate::icu::IcuLocalizerHandle;
518    use crate::{
519        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
520        thread::{
521            ThreadCallbackType, ThreadSendCallback, ThreadSenderDestructorCallback,
522            ThreadSenderInner,
523        },
524        widgets::capture_common::OnVideoFrameCallbackType,
525        window::LayoutWindow,
526        window_state::FullWindowState,
527    };
528
529    // ==================================================================
530    // Config fixtures
531    // ==================================================================
532
533    /// A config with an explicit source + scrub position (everything else
534    /// pinned so a test only varies what it names).
535    fn config(source: VideoSource, timestamp: f32) -> VideoConfig {
536        VideoConfig {
537            source,
538            timestamp,
539            autoplay: true,
540            looping: false,
541            output_format: RawImageFormat::BGRA8,
542        }
543    }
544
545    fn url_source(host: &str, path: &str) -> VideoSource {
546        VideoSource::Url(azul_core::url::Url::from_parts("https", host, 443, path))
547    }
548
549    fn file_source(path: &'static str) -> VideoSource {
550        VideoSource::File(AzString::from_const_str(path))
551    }
552
553    fn bytes_source(bytes: Vec<u8>) -> VideoSource {
554        VideoSource::Bytes(bytes.into())
555    }
556
557    /// Representative + hostile configs: every `VideoSource` variant (empty and
558    /// large payloads), a non-ASCII path, and every f32 boundary a scrub
559    /// position can take (NaN / ±inf / ±0 / MIN / MAX).
560    fn all_configs() -> Vec<VideoConfig> {
561        vec![
562            VideoConfig::default(),
563            config(url_source("example.com", "/clip.mp4"), 0.0),
564            config(url_source("", ""), f32::MAX),
565            config(file_source("/tmp/clip.mp4"), -1.0),
566            // unicode: emoji + CJK + RTL + a combining mark in the path.
567            config(
568                file_source("/tmp/\u{1F3AC}-\u{5F71}\u{7247}-\u{0631}\u{0645}\u{0632}-e\u{0301}.mp4"),
569                f32::NAN,
570            ),
571            config(bytes_source(Vec::new()), f32::INFINITY),
572            config(bytes_source(vec![0xFF; 8192]), f32::NEG_INFINITY),
573            config(bytes_source(vec![0x00]), f32::MIN),
574            VideoConfig {
575                source: file_source("x"),
576                timestamp: -0.0,
577                autoplay: false,
578                looping: true,
579                output_format: RawImageFormat::R8,
580            },
581        ]
582    }
583
584    /// `VideoConfig` is only `PartialEq`, so a NaN scrub position never compares
585    /// equal to itself - compare the timestamp bit-exactly instead.
586    fn assert_same_config(actual: &VideoConfig, expected: &VideoConfig) {
587        assert_eq!(actual.source, expected.source, "source must round-trip");
588        assert_eq!(
589            actual.timestamp.to_bits(),
590            expected.timestamp.to_bits(),
591            "timestamp must survive bit-exactly (NaN included)"
592        );
593        assert_eq!(actual.autoplay, expected.autoplay);
594        assert_eq!(actual.looping, expected.looping);
595        assert_eq!(actual.output_format, expected.output_format);
596    }
597
598    const CONST_CONFIG: VideoConfig = VideoConfig {
599        source: VideoSource::File(AzString::from_const_str("/tmp/const-clip.mp4")),
600        timestamp: 2.5,
601        autoplay: false,
602        looping: true,
603        output_format: RawImageFormat::RGBA8,
604    };
605
606    /// Compile-time proof that `create` really is a `const fn` - the `const`
607    /// qualifier is part of the public API, so a non-const `create` must break
608    /// this file.
609    const CONST_WIDGET: VideoWidget = VideoWidget::create(CONST_CONFIG);
610
611    // ==================================================================
612    // State fixtures
613    // ==================================================================
614
615    /// A freshly-built widget state (exactly what `build_dom` stores).
616    fn base_state(config: VideoConfig) -> VideoWidgetState {
617        VideoWidgetState {
618            config,
619            started: false,
620            gl_texture_id: None,
621            on_frame: OptionOnVideoFrame::None,
622            frames: OptionRefAny::None,
623            decode_callback: None,
624            current_frame: None,
625            thread_id: None,
626            seek_sender: None,
627        }
628    }
629
630    fn state(config: VideoConfig) -> RefAny {
631        RefAny::new(base_state(config))
632    }
633
634    /// Everything a test needs to know about a `VideoWidgetState`, read out in
635    /// one borrow (`downcast_ref` takes `&mut self`, so overlapping reads would
636    /// otherwise have to nest).
637    #[derive(Debug, Clone, PartialEq)]
638    struct StateSummary {
639        started: bool,
640        gl_texture_id: Option<u32>,
641        has_hook: bool,
642        has_frames: bool,
643        decode_cb: Option<usize>,
644        current_frame_id: Option<u64>,
645        thread_id: Option<ThreadId>,
646        has_seek_sender: bool,
647    }
648
649    fn read_state(data: &mut RefAny) -> StateSummary {
650        let s = data
651            .downcast_ref::<VideoWidgetState>()
652            .expect("payload must still be a VideoWidgetState");
653        StateSummary {
654            started: s.started,
655            gl_texture_id: s.gl_texture_id,
656            has_hook: matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
657            has_frames: matches!(s.frames, OptionRefAny::Some(_)),
658            decode_cb: s.decode_callback.as_ref().map(|c| c.cb as usize),
659            current_frame_id: s.current_frame.as_ref().map(|i| i.id),
660            thread_id: s.thread_id,
661            has_seek_sender: s.seek_sender.is_some(),
662        }
663    }
664
665    fn read_config(data: &mut RefAny) -> VideoConfig {
666        data.downcast_ref::<VideoWidgetState>()
667            .expect("payload must still be a VideoWidgetState")
668            .config
669            .clone()
670    }
671
672    /// The `(width, height)` of every frame in the state's replay list, or
673    /// `None` when there is no list / it does not hold a `Vec<VideoFrame>`.
674    fn state_frames(data: &mut RefAny) -> Option<Vec<(u32, u32)>> {
675        let inner = {
676            let s = data.downcast_ref::<VideoWidgetState>()?;
677            match &s.frames {
678                OptionRefAny::Some(f) => Some(f.clone()),
679                OptionRefAny::None => None,
680            }
681        };
682        let mut inner = inner?;
683        let v = inner.downcast_ref::<Vec<VideoFrame>>()?;
684        Some(v.iter().map(|f| (f.width, f.height)).collect())
685    }
686
687    /// The `(width, height)` of a widget's `frames` `RefAny` (same shape as
688    /// `state_frames`, but for the builder-side `VideoWidget`).
689    fn widget_frames(widget: &VideoWidget) -> Option<Vec<(u32, u32)>> {
690        let OptionRefAny::Some(f) = &widget.frames else {
691            return None;
692        };
693        let mut f = f.clone();
694        let v = f.downcast_ref::<Vec<VideoFrame>>()?;
695        Some(v.iter().map(|fr| (fr.width, fr.height)).collect())
696    }
697
698    // ---- frames / images --------------------------------------------------
699
700    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
701    fn frame(width: u32, height: u32) -> VideoFrame {
702        let px = (width as usize) * (height as usize);
703        VideoFrame {
704            width,
705            height,
706            bytes: vec![7u8; px * 4].into(),
707        }
708    }
709
710    /// A frame whose declared dimensions need NOT match its byte count.
711    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
712        VideoFrame {
713            width,
714            height,
715            bytes: bytes.into(),
716        }
717    }
718
719    /// A zero-allocation stand-in for an already-decoded frame.
720    fn placeholder_image(tag: &[u8]) -> ImageRef {
721        ImageRef::null_image(4, 4, RawImageFormat::BGRA8, tag.to_vec())
722    }
723
724    /// `(width, height)` of the raw CPU image a writeback stored, or `None` if
725    /// the stored image is not a raw one.
726    fn raw_dims(img: &ImageRef) -> Option<(usize, usize)> {
727        match img.get_data() {
728            DecodedImage::Raw((descriptor, _)) => Some((descriptor.width, descriptor.height)),
729            _ => None,
730        }
731    }
732
733    fn current_frame_dims(data: &mut RefAny) -> Option<(usize, usize)> {
734        let s = data.downcast_ref::<VideoWidgetState>()?;
735        raw_dims(s.current_frame.as_ref()?)
736    }
737
738    // ---- frame hook -------------------------------------------------------
739
740    /// Records every frame the widget's `on_frame` hook is handed, and answers
741    /// with a caller-chosen `Update`.
742    struct FrameLog {
743        seen: Vec<(u32, u32, usize)>,
744        reply: Update,
745    }
746
747    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
748        let mut reply = Update::DoNothing;
749        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
750            log.seen
751                .push((frame.width, frame.height, frame.bytes.as_ref().len()));
752            reply = log.reply;
753        }
754        reply
755    }
756
757    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
758        // A distinct body so the linker cannot fold this onto `record_frame`
759        // and make the fn-pointer identity assertions vacuous.
760        core::hint::black_box(Update::DoNothing)
761    }
762
763    fn frame_log(reply: Update) -> RefAny {
764        RefAny::new(FrameLog {
765            seen: Vec::new(),
766            reply,
767        })
768    }
769
770    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
771        data.downcast_ref::<FrameLog>()
772            .expect("payload must still be a FrameLog")
773            .seen
774            .clone()
775    }
776
777    fn hook_into(log: &RefAny) -> OptionOnVideoFrame {
778        Some(OnVideoFrame {
779            refany: log.clone(),
780            callback: (record_frame as OnVideoFrameCallbackType).into(),
781        })
782        .into()
783    }
784
785    // ---- thread workers ---------------------------------------------------
786
787    /// A decode worker that returns immediately. Used wherever a test must let
788    /// `AfterMount` really spawn a `Thread`: the framework's thread destructor
789    /// *joins*, so only a worker that returns on its own can be joined safely.
790    extern "C" fn noop_decode_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
791
792    extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {
793        core::hint::black_box(());
794    }
795
796    // ==================================================================
797    // CallbackInfo harness
798    // ==================================================================
799
800    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no
801    /// GL context, no laid-out nodes). Returns `f`'s value plus every
802    /// `CallbackChange` the callback recorded.
803    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
804        let layout_window =
805            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
806        let renderer_resources = RendererResources::default();
807        let previous_window_state: Option<FullWindowState> = None;
808        let current_window_state = FullWindowState::default();
809        let gl_context = OptionGlContextPtr::None;
810        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
811            BTreeMap::new();
812        let window_handle = RawWindowHandle::Unsupported;
813        let system_callbacks = ExternalSystemCallbacks::rust_internal();
814
815        let ref_data = CallbackInfoRefData {
816            layout_window: &layout_window,
817            renderer_resources: &renderer_resources,
818            previous_window_state: &previous_window_state,
819            current_window_state: &current_window_state,
820            gl_context: &gl_context,
821            current_scroll_manager: &scroll_states,
822            current_window_handle: &window_handle,
823            system_callbacks: &system_callbacks,
824            system_style: Arc::new(SystemStyle::default()),
825            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
826            #[cfg(feature = "icu")]
827            icu_localizer: IcuLocalizerHandle::default(),
828            ctx: OptionRefAny::None,
829        };
830
831        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
832
833        let info = CallbackInfo::new(
834            &ref_data,
835            &changes,
836            DomNodeId {
837                dom: DomId::ROOT_ID,
838                node: NodeHierarchyItemId::NONE,
839            },
840            OptionLogicalPosition::None,
841            OptionLogicalPosition::None,
842        );
843
844        let out = f(info);
845        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
846        (out, recorded)
847    }
848
849    fn count_virtual_view_rerenders(changes: &[CallbackChange]) -> usize {
850        changes
851            .iter()
852            .filter(|c| matches!(c, CallbackChange::UpdateAllVirtualViews))
853            .count()
854    }
855
856    /// The `ThreadId` of the single `AddThread` change, or `None`.
857    fn added_thread_id(changes: &[CallbackChange]) -> Option<ThreadId> {
858        changes.iter().find_map(|c| match c {
859            CallbackChange::AddThread { thread_id, .. } => Some(*thread_id),
860            _ => None,
861        })
862    }
863
864    // ==================================================================
865    // VirtualViewCallbackInfo harness
866    // ==================================================================
867
868    /// Runs `f` against a `VirtualViewCallbackInfo` reporting `w x h` bounds.
869    fn with_virtual_view_info<R>(w: f32, h: f32, f: impl FnOnce(VirtualViewCallbackInfo) -> R) -> R {
870        let fonts = FcFontCache::default();
871        let images = ImageCache::default();
872        let size = LogicalSize::new(w, h);
873        let info = VirtualViewCallbackInfo::new(
874            VirtualViewCallbackReason::InitialRender,
875            &fonts,
876            &images,
877            WindowTheme::LightMode,
878            HidpiAdjustedBounds {
879                logical_size: size,
880                hidpi_factor: DpiScaleFactor::new(1.0),
881            },
882            size,
883            LogicalPosition::zero(),
884            size,
885            LogicalPosition::zero(),
886        );
887        f(info)
888    }
889
890    /// The `ImageRef` id of the `<img>` a render pass emitted, or `None` when it
891    /// emitted no DOM at all.
892    fn rendered_image_id(ret: &VirtualViewReturn) -> Option<u64> {
893        let OptionDom::Some(dom) = &ret.dom else {
894            return None;
895        };
896        match dom.root.get_node_type() {
897            NodeType::Image(img) => Some(img.id),
898            other => panic!("the video VirtualView must render an <img>, got {other:?}"),
899        }
900    }
901
902    fn rendered_nothing(ret: &VirtualViewReturn) -> bool {
903        matches!(ret.dom, OptionDom::None)
904    }
905
906    // ==================================================================
907    // Worker harness
908    // ==================================================================
909
910    /// One frame a worker pushed, summarised so the (multi-megabyte) pixel
911    /// buffer never has to be cloned into the log.
912    #[derive(Debug, Clone, PartialEq, Eq)]
913    struct SentFrame {
914        width: u32,
915        height: u32,
916        len: usize,
917        /// Distinct RGBA pixels in the first scanline, in first-seen order
918        /// (capped, so a pathological frame cannot blow up the log).
919        row0_palette: Vec<[u8; 4]>,
920        /// Every scanline is byte-identical to the first.
921        rows_identical: bool,
922        /// The whole pixel buffer - captured only for frames small enough to
923        /// compare byte-for-byte (the replay fixtures).
924        small_bytes: Option<Vec<u8>>,
925    }
926
927    fn summarise(f: &VideoFrame) -> SentFrame {
928        let bytes = f.bytes.as_ref();
929        let row_len = (f.width as usize).saturating_mul(4);
930        let mut row0_palette: Vec<[u8; 4]> = Vec::new();
931        if row_len > 0 && bytes.len() >= row_len {
932            for px in bytes[..row_len].chunks_exact(4) {
933                let px = [px[0], px[1], px[2], px[3]];
934                if row0_palette.len() < 32 && !row0_palette.contains(&px) {
935                    row0_palette.push(px);
936                }
937            }
938        }
939        let rows_identical = row_len == 0
940            || bytes.len() < row_len
941            || bytes.chunks_exact(row_len).all(|row| row == &bytes[..row_len]);
942        SentFrame {
943            width: f.width,
944            height: f.height,
945            len: bytes.len(),
946            row0_palette,
947            rows_identical,
948            small_bytes: (bytes.len() <= 4096).then(|| bytes.to_vec()),
949        }
950    }
951
952    /// Guarded by `WORKER_GATE`: a worker's send callback is a plain C fn
953    /// pointer, so it has nowhere but a static to put its result.
954    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
955    static WORKER_GATE: Mutex<()> = Mutex::new(());
956    /// How many more sends are accepted before the harness reports "the main
957    /// thread is gone" - the only signal these workers ever stop on.
958    static ACCEPT_BUDGET: AtomicUsize = AtomicUsize::new(0);
959
960    extern "C" fn record_then_maybe_stop(
961        _sender: *const core::ffi::c_void,
962        msg: ThreadReceiveMsg,
963    ) -> bool {
964        let ThreadReceiveMsg::WriteBack(mut wb) = msg else {
965            return false;
966        };
967        if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
968            WORKER_LOG
969                .lock()
970                .unwrap_or_else(PoisonError::into_inner)
971                .push(summarise(&f));
972        }
973        let left = ACCEPT_BUDGET.load(Ordering::SeqCst);
974        if left == 0 {
975            return false;
976        }
977        ACCEPT_BUDGET.store(left - 1, Ordering::SeqCst);
978        true
979    }
980
981    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
982    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
983    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
984        OptionThreadSendMsg::None
985    }
986    /// A receiver that answers *every* poll with "terminate now".
987    extern "C" fn recv_terminate(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
988        OptionThreadSendMsg::Some(ThreadSendMsg::TerminateThread)
989    }
990
991    fn logging_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
992        let (tx, rx) = channel::<ThreadReceiveMsg>();
993        let sender = ThreadSender::new(ThreadSenderInner {
994            ptr: Box::new(tx),
995            send_fn: ThreadSendCallback {
996                cb: record_then_maybe_stop,
997            },
998            destructor: ThreadSenderDestructorCallback {
999                cb: sender_drop_noop,
1000            },
1001        });
1002        (rx, sender)
1003    }
1004
1005    fn receiver(terminate: bool) -> (Sender<ThreadSendMsg>, ThreadReceiver) {
1006        let (tx, rx) = channel::<ThreadSendMsg>();
1007        let cb: extern "C" fn(*const core::ffi::c_void) -> OptionThreadSendMsg =
1008            if terminate { recv_terminate } else { recv_nothing };
1009        let receiver = ThreadReceiver::new(ThreadReceiverInner {
1010            ptr: Box::new(rx),
1011            recv_fn: ThreadRecvCallback { cb },
1012            destructor: ThreadReceiverDestructorCallback {
1013                cb: receiver_drop_noop,
1014            },
1015        });
1016        (tx, receiver)
1017    }
1018
1019    /// Runs `worker` in-process against a sender that accepts `accept` frames
1020    /// and then reports failure, and returns everything it managed to send.
1021    /// `terminate` decides whether its receiver answers every poll with
1022    /// `TerminateThread`.
1023    fn run_worker(
1024        worker: ThreadCallbackType,
1025        init: RefAny,
1026        accept: usize,
1027        terminate: bool,
1028    ) -> Vec<SentFrame> {
1029        let _gate = WORKER_GATE
1030            .lock()
1031            .unwrap_or_else(PoisonError::into_inner);
1032        WORKER_LOG
1033            .lock()
1034            .unwrap_or_else(PoisonError::into_inner)
1035            .clear();
1036        ACCEPT_BUDGET.store(accept, Ordering::SeqCst);
1037
1038        let (_rx, sender) = logging_sender();
1039        let (_tx, recv) = receiver(terminate);
1040        worker(init, sender, recv);
1041
1042        WORKER_LOG
1043            .lock()
1044            .unwrap_or_else(PoisonError::into_inner)
1045            .clone()
1046    }
1047
1048    /// The SMPTE bars `video_test_worker` hard-codes (duplicated here on
1049    /// purpose: the palette is observable output, so a silent change to it must
1050    /// break a test).
1051    const EXPECTED_BARS: [[u8; 4]; 7] = [
1052        [235, 235, 235, 255],
1053        [235, 235, 16, 255],
1054        [16, 235, 235, 255],
1055        [16, 235, 16, 255],
1056        [235, 16, 235, 255],
1057        [235, 16, 16, 255],
1058        [16, 16, 235, 255],
1059    ];
1060
1061    // ==================================================================
1062    // Seek-channel helpers (merge_video_state)
1063    // ==================================================================
1064
1065    fn custom_f32(msg: &ThreadSendMsg) -> Option<f32> {
1066        let ThreadSendMsg::Custom(r) = msg else {
1067            return None;
1068        };
1069        let mut r = r.clone();
1070        let out = r.downcast_ref::<f32>().map(|v| *v);
1071        out
1072    }
1073
1074    fn custom_source(msg: &ThreadSendMsg) -> Option<VideoSource> {
1075        let ThreadSendMsg::Custom(r) = msg else {
1076            return None;
1077        };
1078        let mut r = r.clone();
1079        let out = r.downcast_ref::<VideoSource>().map(|v| (*v).clone());
1080        out
1081    }
1082
1083    // ==================================================================
1084    // VideoWidget::create  (constructor)
1085    // ==================================================================
1086
1087    #[test]
1088    fn create_stores_the_config_verbatim_and_leaves_every_hook_unset() {
1089        for cfg in all_configs() {
1090            let widget = VideoWidget::create(cfg.clone());
1091            assert_same_config(&widget.config, &cfg);
1092            assert!(
1093                matches!(widget.on_frame, OptionOnVideoFrame::None),
1094                "a fresh widget has no frame hook"
1095            );
1096            assert!(
1097                matches!(widget.frames, OptionRefAny::None),
1098                "a fresh widget has no replay list"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn create_is_usable_in_a_const_context() {
1105        let widget = CONST_WIDGET;
1106        assert_same_config(&widget.config, &CONST_CONFIG);
1107        assert!(matches!(widget.on_frame, OptionOnVideoFrame::None));
1108        assert!(matches!(widget.frames, OptionRefAny::None));
1109    }
1110
1111    // ==================================================================
1112    // VideoWidget::set_on_frame / with_on_frame  (constructor)
1113    // ==================================================================
1114
1115    #[test]
1116    fn with_on_frame_installs_the_hook_and_keeps_the_config() {
1117        for cfg in all_configs() {
1118            let widget = VideoWidget::create(cfg.clone())
1119                .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType);
1120
1121            assert_same_config(&widget.config, &cfg);
1122            let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
1123                panic!("with_on_frame must install a hook");
1124            };
1125            assert_eq!(
1126                hook.callback.cb as usize,
1127                record_frame as OnVideoFrameCallbackType as usize,
1128                "the installed hook must be exactly the one handed in"
1129            );
1130            assert!(
1131                matches!(widget.frames, OptionRefAny::None),
1132                "with_on_frame must not invent a replay list"
1133            );
1134        }
1135    }
1136
1137    #[test]
1138    fn set_on_frame_twice_keeps_only_the_last_hook() {
1139        let mut widget = VideoWidget::create(VideoConfig::default());
1140        widget.set_on_frame(
1141            RefAny::new(0_usize),
1142            record_frame as OnVideoFrameCallbackType,
1143        );
1144        widget.set_on_frame(
1145            RefAny::new(1_usize),
1146            frame_do_nothing as OnVideoFrameCallbackType,
1147        );
1148
1149        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
1150            panic!("hook must still be set");
1151        };
1152        assert_eq!(
1153            hook.callback.cb as usize,
1154            frame_do_nothing as OnVideoFrameCallbackType as usize,
1155            "the second set_on_frame must replace the first"
1156        );
1157        let mut data = hook.refany.clone();
1158        assert_eq!(
1159            data.downcast_ref::<usize>().map(|v| *v),
1160            Some(1),
1161            "the second hook's data must replace the first hook's, not merge with it"
1162        );
1163    }
1164
1165    #[test]
1166    fn set_on_frame_accepts_a_refany_that_is_also_the_widgets_replay_list() {
1167        // Aliasing the same RefAny into two slots must not panic or deadlock -
1168        // both are plain shared handles.
1169        let shared = RefAny::new(vec![frame(1, 1)]);
1170        let widget = VideoWidget::create(VideoConfig::default())
1171            .with_frames(shared.clone())
1172            .with_on_frame(shared, record_frame as OnVideoFrameCallbackType);
1173
1174        assert!(matches!(widget.on_frame, OptionOnVideoFrame::Some(_)));
1175        assert_eq!(widget_frames(&widget), Some(vec![(1, 1)]));
1176    }
1177
1178    // ==================================================================
1179    // VideoWidget::with_frames  (constructor)
1180    // ==================================================================
1181
1182    #[test]
1183    fn with_frames_stores_the_list_and_keeps_everything_else() {
1184        for cfg in all_configs() {
1185            let widget = VideoWidget::create(cfg.clone())
1186                .with_frames(RefAny::new(vec![frame(2, 3), frame(4, 5)]));
1187
1188            assert_same_config(&widget.config, &cfg);
1189            assert_eq!(widget_frames(&widget), Some(vec![(2, 3), (4, 5)]));
1190            assert!(
1191                matches!(widget.on_frame, OptionOnVideoFrame::None),
1192                "with_frames must not invent a hook"
1193            );
1194        }
1195    }
1196
1197    #[test]
1198    fn with_frames_twice_keeps_only_the_last_list() {
1199        let widget = VideoWidget::create(VideoConfig::default())
1200            .with_frames(RefAny::new(vec![frame(1, 1)]))
1201            .with_frames(RefAny::new(vec![frame(9, 9), frame(8, 8)]));
1202        assert_eq!(widget_frames(&widget), Some(vec![(9, 9), (8, 8)]));
1203    }
1204
1205    #[test]
1206    fn with_frames_accepts_an_empty_and_a_wrong_typed_payload_without_complaint() {
1207        // Documented: a `RefAny` that does not carry a `Vec<VideoFrame>` is
1208        // accepted here and only *skipped* later, by the replay worker.
1209        let empty = VideoWidget::create(VideoConfig::default())
1210            .with_frames(RefAny::new(Vec::<VideoFrame>::new()));
1211        assert_eq!(widget_frames(&empty), Some(Vec::new()));
1212
1213        let foreign =
1214            VideoWidget::create(VideoConfig::default()).with_frames(RefAny::new(0_u32));
1215        assert!(
1216            matches!(foreign.frames, OptionRefAny::Some(_)),
1217            "the builder stores whatever it is given"
1218        );
1219        assert_eq!(
1220            widget_frames(&foreign),
1221            None,
1222            "...but it is not a frame list"
1223        );
1224    }
1225
1226    #[test]
1227    fn builder_order_does_not_matter() {
1228        let a = VideoWidget::create(config(file_source("/a.mp4"), 1.0))
1229            .with_frames(RefAny::new(vec![frame(3, 3)]))
1230            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType);
1231        let b = VideoWidget::create(config(file_source("/a.mp4"), 1.0))
1232            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType)
1233            .with_frames(RefAny::new(vec![frame(3, 3)]));
1234
1235        assert_same_config(&a.config, &b.config);
1236        assert_eq!(widget_frames(&a), widget_frames(&b));
1237        assert!(matches!(a.on_frame, OptionOnVideoFrame::Some(_)));
1238        assert!(matches!(b.on_frame, OptionOnVideoFrame::Some(_)));
1239    }
1240
1241    // ==================================================================
1242    // VideoWidget::dom / dom_with_decoder / build_dom
1243    // ==================================================================
1244
1245    #[test]
1246    fn dom_builds_a_div_with_one_virtual_view_child() {
1247        let dom = VideoWidget::create(VideoConfig::default()).dom();
1248
1249        assert!(
1250            matches!(dom.root.get_node_type(), NodeType::Div),
1251            "the widget root is a plain div (the <img> lives in the VirtualView)"
1252        );
1253        assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
1254        assert!(
1255            matches!(
1256                dom.children.as_slice()[0].root.get_node_type(),
1257                NodeType::VirtualView
1258            ),
1259            "the child must be the VirtualView the decode worker re-renders"
1260        );
1261    }
1262
1263    #[test]
1264    fn dom_wires_after_mount_node_resized_a_dataset_and_a_merge_callback() {
1265        let dom = VideoWidget::create(VideoConfig::default()).dom();
1266
1267        let events: Vec<EventFilter> = dom
1268            .root
1269            .get_callbacks()
1270            .as_ref()
1271            .iter()
1272            .map(|c| c.event)
1273            .collect();
1274        assert_eq!(events.len(), 2, "exactly two component callbacks");
1275        assert!(events.contains(&EventFilter::Component(ComponentEventFilter::AfterMount)));
1276        assert!(events.contains(&EventFilter::Component(ComponentEventFilter::NodeResized)));
1277        assert!(
1278            dom.root.get_merge_callback().is_some(),
1279            "live state must survive relayout"
1280        );
1281        assert!(
1282            dom.root.get_dataset().is_some(),
1283            "the widget div must carry its VideoWidgetState"
1284        );
1285    }
1286
1287    #[test]
1288    fn dom_stores_a_pristine_state_for_every_config() {
1289        for cfg in all_configs() {
1290            let dom = VideoWidget::create(cfg.clone()).dom();
1291            let mut dataset = dom
1292                .root
1293                .get_dataset()
1294                .cloned()
1295                .expect("the node must carry its VideoWidgetState");
1296
1297            assert_same_config(&read_config(&mut dataset), &cfg);
1298            assert_eq!(
1299                read_state(&mut dataset),
1300                StateSummary {
1301                    started: false,
1302                    gl_texture_id: None,
1303                    has_hook: false,
1304                    has_frames: false,
1305                    decode_cb: None,
1306                    current_frame_id: None,
1307                    thread_id: None,
1308                    has_seek_sender: false,
1309                },
1310                "dom() must not start anything - AfterMount does that"
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn dom_moves_the_hook_and_the_replay_list_into_the_state() {
1317        let dom = VideoWidget::create(VideoConfig::default())
1318            .with_frames(RefAny::new(vec![frame(6, 7)]))
1319            .with_on_frame(frame_log(Update::DoNothing), record_frame as OnVideoFrameCallbackType)
1320            .dom();
1321
1322        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
1323        let summary = read_state(&mut dataset);
1324        assert!(summary.has_hook, "dom() must carry the user hook forward");
1325        assert!(summary.has_frames);
1326        assert_eq!(state_frames(&mut dataset), Some(vec![(6, 7)]));
1327    }
1328
1329    #[test]
1330    fn dom_with_decoder_records_exactly_the_worker_it_was_given() {
1331        let dom = VideoWidget::create(VideoConfig::default())
1332            .dom_with_decoder(ThreadCallback::new(noop_decode_worker));
1333
1334        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
1335        assert_eq!(
1336            read_state(&mut dataset).decode_cb,
1337            Some(noop_decode_worker as ThreadCallbackType as usize)
1338        );
1339
1340        // A different worker must be distinguishable (no fn-pointer folding).
1341        let other = VideoWidget::create(VideoConfig::default())
1342            .dom_with_decoder(ThreadCallback::new(other_noop_worker));
1343        let mut other_dataset = other.root.get_dataset().cloned().expect("dataset");
1344        assert_ne!(
1345            read_state(&mut other_dataset).decode_cb,
1346            read_state(&mut dataset).decode_cb
1347        );
1348    }
1349
1350    #[test]
1351    fn dom_and_dom_with_decoder_agree_on_everything_but_the_worker() {
1352        let plain = VideoWidget::create(config(bytes_source(vec![1, 2, 3]), -0.5)).dom();
1353        let with_cb = VideoWidget::create(config(bytes_source(vec![1, 2, 3]), -0.5))
1354            .dom_with_decoder(ThreadCallback::new(noop_decode_worker));
1355
1356        assert_eq!(
1357            plain.children.as_slice().len(),
1358            with_cb.children.as_slice().len()
1359        );
1360        let mut a = plain.root.get_dataset().cloned().expect("dataset");
1361        let mut b = with_cb.root.get_dataset().cloned().expect("dataset");
1362        assert_same_config(&read_config(&mut a), &read_config(&mut b));
1363
1364        let (sa, sb) = (read_state(&mut a), read_state(&mut b));
1365        assert_eq!(sa.decode_cb, None);
1366        assert!(sb.decode_cb.is_some());
1367        assert_eq!(
1368            StateSummary {
1369                decode_cb: None,
1370                ..sb
1371            },
1372            sa,
1373            "only the decode callback may differ"
1374        );
1375    }
1376
1377    #[test]
1378    fn dom_survives_a_huge_in_memory_source_without_copying_it_into_the_tree() {
1379        // 4 MiB of "MP4 bytes": the widget must move them into the state, not
1380        // choke on them.
1381        let widget = VideoWidget::create(config(bytes_source(vec![0xAB; 4 * 1024 * 1024]), 0.0));
1382        let dom = widget.dom();
1383        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
1384        match read_config(&mut dataset).source {
1385            VideoSource::Bytes(b) => assert_eq!(b.as_ref().len(), 4 * 1024 * 1024),
1386            other => panic!("the source must survive verbatim, got {other:?}"),
1387        }
1388    }
1389
1390    // ==================================================================
1391    // video_widget_render  (VirtualView callback)
1392    // ==================================================================
1393
1394    #[test]
1395    fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
1396        let mut s = base_state(VideoConfig::default());
1397        s.current_frame = Some(placeholder_image(b"ready"));
1398        let dataset = RefAny::new(s);
1399
1400        for (w, h) in [
1401            (0.0_f32, 0.0_f32),
1402            (0.0, 600.0),
1403            (800.0, 0.0),
1404            (-800.0, -600.0),
1405            (-1.0, 600.0),
1406            (f32::NAN, 600.0),
1407            (800.0, f32::NAN),
1408            (f32::INFINITY, 600.0),
1409            (800.0, f32::NEG_INFINITY),
1410        ] {
1411            let ret = with_virtual_view_info(w, h, |info| video_widget_render(dataset.clone(), info));
1412            assert!(
1413                rendered_nothing(&ret),
1414                "bounds {w}x{h} must render nothing until layout settles - even with a frame ready"
1415            );
1416        }
1417    }
1418
1419    #[test]
1420    fn render_with_a_wrong_typed_dataset_emits_no_dom() {
1421        let dataset = RefAny::new(0_u32);
1422        let ret =
1423            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
1424        assert!(rendered_nothing(&ret));
1425    }
1426
1427    #[test]
1428    fn render_before_the_first_frame_emits_no_dom() {
1429        let dataset = state(VideoConfig::default());
1430        let ret =
1431            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
1432        assert!(
1433            rendered_nothing(&ret),
1434            "no decoded frame yet -> nothing to show"
1435        );
1436    }
1437
1438    #[test]
1439    fn render_emits_the_stored_frame_as_an_image() {
1440        let img = placeholder_image(b"azul-video-frame");
1441        let expected_id = img.id;
1442        let mut s = base_state(VideoConfig::default());
1443        s.current_frame = Some(img);
1444        let dataset = RefAny::new(s);
1445
1446        let ret =
1447            with_virtual_view_info(800.0, 600.0, |info| video_widget_render(dataset.clone(), info));
1448        assert_eq!(
1449            rendered_image_id(&ret),
1450            Some(expected_id),
1451            "the <img> must show exactly the frame the writeback stored"
1452        );
1453    }
1454
1455    #[test]
1456    fn render_reports_the_bounds_back_as_the_scroll_size() {
1457        let dataset = state(VideoConfig::default());
1458        let ret =
1459            with_virtual_view_info(640.0, 480.0, |info| video_widget_render(dataset.clone(), info));
1460
1461        assert_eq!(ret.scroll_size.width, 640.0);
1462        assert_eq!(ret.scroll_size.height, 480.0);
1463        assert_eq!(ret.virtual_scroll_size.width, 640.0);
1464        assert_eq!(ret.virtual_scroll_size.height, 480.0);
1465        assert_eq!((ret.scroll_offset.x, ret.scroll_offset.y), (0.0, 0.0));
1466        assert_eq!(
1467            (ret.virtual_scroll_offset.x, ret.virtual_scroll_offset.y),
1468            (0.0, 0.0)
1469        );
1470    }
1471
1472    #[test]
1473    fn render_echoes_even_a_nan_bound_into_the_scroll_size() {
1474        // The early-out only suppresses the DOM: the reported scroll size is
1475        // still whatever layout handed in, NaN included.
1476        let dataset = state(VideoConfig::default());
1477        let ret = with_virtual_view_info(f32::NAN, 480.0, |info| {
1478            video_widget_render(dataset.clone(), info)
1479        });
1480        assert!(rendered_nothing(&ret));
1481        assert!(ret.scroll_size.width.is_nan());
1482        assert_eq!(ret.scroll_size.height, 480.0);
1483    }
1484
1485    #[test]
1486    fn render_is_pure_and_repeatable() {
1487        let img = placeholder_image(b"stable");
1488        let expected_id = img.id;
1489        let mut s = base_state(VideoConfig::default());
1490        s.current_frame = Some(img);
1491        s.started = true;
1492        let mut dataset = RefAny::new(s);
1493
1494        for _ in 0..8 {
1495            let ret = with_virtual_view_info(320.0, 240.0, |info| {
1496                video_widget_render(dataset.clone(), info)
1497            });
1498            assert_eq!(rendered_image_id(&ret), Some(expected_id));
1499        }
1500        let summary = read_state(&mut dataset);
1501        assert!(summary.started, "render must not touch the live state");
1502        assert_eq!(summary.current_frame_id, Some(expected_id));
1503    }
1504
1505    #[test]
1506    fn render_with_the_smallest_positive_bounds_still_emits_the_image() {
1507        let img = placeholder_image(b"tiny");
1508        let expected_id = img.id;
1509        let mut s = base_state(VideoConfig::default());
1510        s.current_frame = Some(img);
1511        let dataset = RefAny::new(s);
1512
1513        for (w, h) in [(f32::MIN_POSITIVE, f32::MIN_POSITIVE), (1.0, 1.0), (f32::MAX, f32::MAX)] {
1514            let ret = with_virtual_view_info(w, h, |info| video_widget_render(dataset.clone(), info));
1515            assert_eq!(
1516                rendered_image_id(&ret),
1517                Some(expected_id),
1518                "{w}x{h} is finite and positive, so the frame must render"
1519            );
1520        }
1521    }
1522
1523    // ==================================================================
1524    // video_on_after_mount
1525    //
1526    // NOTE: the default (test-pattern) mount path is deliberately NOT driven
1527    // here. `video_test_worker` never reads its receiver, so it ignores
1528    // `ThreadSendMsg::TerminateThread`; the framework's thread destructor
1529    // *joins* that worker and would hang the test binary forever (see the
1530    // report). Only workers that return on their own are mounted below.
1531    // ==================================================================
1532
1533    #[test]
1534    fn after_mount_ignores_a_dataset_that_is_not_a_video_state() {
1535        let (update, changes) =
1536            with_callback_info(|info| video_on_after_mount(RefAny::new(0_u32), info));
1537
1538        assert_eq!(update, Update::DoNothing);
1539        assert!(
1540            changes.is_empty(),
1541            "a foreign dataset must not start a decode thread"
1542        );
1543    }
1544
1545    #[test]
1546    fn after_mount_is_a_no_op_once_the_decode_thread_has_started() {
1547        let mut s = base_state(VideoConfig::default());
1548        s.started = true;
1549        s.thread_id = Some(ThreadId::unique());
1550        s.current_frame = Some(placeholder_image(b"kept"));
1551        let mut data = RefAny::new(s);
1552        let before = read_state(&mut data);
1553
1554        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1555
1556        assert_eq!(update, Update::DoNothing);
1557        assert!(
1558            changes.is_empty(),
1559            "AfterMount must start the decode thread at most once"
1560        );
1561        assert_eq!(
1562            read_state(&mut data),
1563            before,
1564            "a re-mount must not disturb the running state"
1565        );
1566    }
1567
1568    #[test]
1569    fn after_mount_spawns_the_streaming_decoder_and_remembers_its_id_and_sender() {
1570        let mut s = base_state(config(file_source("/tmp/clip.mp4"), 12.5));
1571        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
1572        let mut data = RefAny::new(s);
1573
1574        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1575
1576        assert_eq!(update, Update::DoNothing, "mounting never triggers relayout");
1577        assert_eq!(changes.len(), 1, "exactly one thread is spawned");
1578        let tid = added_thread_id(&changes).expect("the decode worker must be added as a Thread");
1579
1580        let summary = read_state(&mut data);
1581        assert!(summary.started);
1582        assert_eq!(
1583            summary.thread_id,
1584            Some(tid),
1585            "the state must remember the very thread id it registered (resize messaging)"
1586        );
1587        assert!(
1588            summary.has_seek_sender,
1589            "the merge callback needs the worker's sender to push seeks"
1590        );
1591    }
1592
1593    #[test]
1594    fn after_mount_only_ever_spawns_one_decode_thread() {
1595        let mut s = base_state(VideoConfig::default());
1596        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
1597        let mut data = RefAny::new(s);
1598
1599        let (_, first) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1600        let first_id = read_state(&mut data).thread_id;
1601        let (_, second) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1602
1603        assert_eq!(first.len(), 1);
1604        assert!(second.is_empty(), "the second AfterMount must be a no-op");
1605        assert_eq!(
1606            read_state(&mut data).thread_id,
1607            first_id,
1608            "the recorded thread id must not be re-rolled"
1609        );
1610    }
1611
1612    #[test]
1613    fn after_mount_replay_path_spawns_a_worker_but_records_no_id_or_sender() {
1614        // ADVERSARIAL: the replay path spawns a `Thread` like the streaming path
1615        // does, but stores neither its `ThreadId` nor its sender - so resize
1616        // re-targeting and scrub/seek messaging are silently dead for replayed
1617        // clips (see the report). An EMPTY frame list is used so the worker
1618        // returns immediately and can be joined.
1619        let mut s = base_state(VideoConfig::default());
1620        s.frames = OptionRefAny::Some(RefAny::new(Vec::<VideoFrame>::new()));
1621        let mut data = RefAny::new(s);
1622
1623        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1624
1625        assert_eq!(update, Update::DoNothing);
1626        assert_eq!(changes.len(), 1, "the replay worker is still spawned");
1627        let summary = read_state(&mut data);
1628        assert!(summary.started);
1629        assert_eq!(summary.thread_id, None);
1630        assert!(!summary.has_seek_sender);
1631    }
1632
1633    #[test]
1634    fn after_mount_replay_path_accepts_a_wrong_typed_frame_list() {
1635        // A `RefAny` that is not a `Vec<VideoFrame>` must not panic the mount -
1636        // the worker just returns.
1637        let mut s = base_state(VideoConfig::default());
1638        s.frames = OptionRefAny::Some(RefAny::new("not a frame list"));
1639        let mut data = RefAny::new(s);
1640
1641        let (update, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1642
1643        assert_eq!(update, Update::DoNothing);
1644        assert_eq!(changes.len(), 1);
1645        assert!(read_state(&mut data).started);
1646    }
1647
1648    #[test]
1649    fn after_mount_prefers_the_streaming_decoder_over_a_replay_list() {
1650        // Documented priority: decode worker > replay frames > test pattern.
1651        // A NON-empty replay list is safe here precisely because it must NOT be
1652        // used (the replay worker would otherwise loop forever).
1653        let mut s = base_state(VideoConfig::default());
1654        s.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
1655        s.frames = OptionRefAny::Some(RefAny::new(vec![frame(2, 2), frame(2, 2)]));
1656        let mut data = RefAny::new(s);
1657
1658        let (_, changes) = with_callback_info(|info| video_on_after_mount(data.clone(), info));
1659
1660        assert_eq!(changes.len(), 1);
1661        let summary = read_state(&mut data);
1662        assert!(
1663            summary.thread_id.is_some() && summary.has_seek_sender,
1664            "only the streaming path records an id + sender, so it is the one that ran"
1665        );
1666        assert!(summary.has_frames, "the replay list is kept, just unused");
1667    }
1668
1669    // ==================================================================
1670    // video_on_resize
1671    // ==================================================================
1672
1673    #[test]
1674    fn resize_ignores_a_dataset_that_is_not_a_video_state() {
1675        let (update, changes) = with_callback_info(|info| video_on_resize(RefAny::new(0_u32), info));
1676        assert_eq!(update, Update::DoNothing);
1677        assert!(changes.is_empty());
1678    }
1679
1680    #[test]
1681    fn resize_before_the_worker_started_is_a_no_op() {
1682        let mut data = state(VideoConfig::default());
1683        let before = read_state(&mut data);
1684
1685        let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
1686
1687        assert_eq!(
1688            update,
1689            Update::DoNothing,
1690            "resize is a message, never a relayout"
1691        );
1692        assert!(changes.is_empty(), "no worker -> nothing to tell");
1693        assert_eq!(read_state(&mut data), before);
1694    }
1695
1696    #[test]
1697    fn resize_with_an_unknown_node_is_a_no_op() {
1698        // The state knows a thread id, but the hit node has no laid-out size in
1699        // this (empty) window: the callback must bail instead of messaging a
1700        // bogus target size.
1701        let mut s = base_state(VideoConfig::default());
1702        s.started = true;
1703        s.thread_id = Some(ThreadId::unique());
1704        let mut data = RefAny::new(s);
1705        let before = read_state(&mut data);
1706
1707        let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
1708
1709        assert_eq!(update, Update::DoNothing);
1710        assert!(changes.is_empty());
1711        assert_eq!(read_state(&mut data), before);
1712    }
1713
1714    #[test]
1715    fn resize_with_a_thread_id_that_no_longer_exists_is_a_no_op() {
1716        // A worker that already exited: `get_thread` returns None and the
1717        // best-effort send is simply skipped.
1718        let mut s = base_state(VideoConfig::default());
1719        s.thread_id = Some(ThreadId::unique());
1720        s.started = true;
1721        let data = RefAny::new(s);
1722
1723        for _ in 0..4 {
1724            let (update, changes) = with_callback_info(|info| video_on_resize(data.clone(), info));
1725            assert_eq!(update, Update::DoNothing);
1726            assert!(changes.is_empty());
1727        }
1728    }
1729
1730    // ==================================================================
1731    // video_test_worker
1732    // ==================================================================
1733
1734    #[test]
1735    fn test_worker_stops_as_soon_as_the_main_thread_stops_receiving() {
1736        let sent = run_worker(video_test_worker, RefAny::new(()), 0, false);
1737
1738        assert_eq!(
1739            sent.len(),
1740            1,
1741            "the worker must stop after the first rejected send, not spin"
1742        );
1743        assert_eq!((sent[0].width, sent[0].height), (1280, 720));
1744        assert_eq!(
1745            sent[0].len,
1746            1280 * 720 * 4,
1747            "a frame is exactly width * height * 4 tightly-packed RGBA bytes"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_worker_emits_seven_opaque_smpte_bars_in_order() {
1753        let sent = run_worker(video_test_worker, RefAny::new(()), 0, false);
1754        let f = &sent[0];
1755
1756        assert!(
1757            f.rows_identical,
1758            "the bars scroll horizontally only - every scanline must be identical"
1759        );
1760        assert_eq!(
1761            f.row0_palette,
1762            EXPECTED_BARS.to_vec(),
1763            "tick 0 must emit the seven SMPTE bars left-to-right, all fully opaque"
1764        );
1765    }
1766
1767    #[test]
1768    fn test_worker_ignores_terminate_and_scrolls_the_pattern() {
1769        // ADVERSARIAL: the worker never polls its receiver, so `TerminateThread`
1770        // - the message the framework's thread destructor sends before joining -
1771        // is ignored outright. The only thing that stops it is a failed send.
1772        let sent = run_worker(video_test_worker, RefAny::new(()), 3, true);
1773
1774        assert_eq!(
1775            sent.len(),
1776            4,
1777            "3 accepted + 1 rejected: TerminateThread did not stop the worker"
1778        );
1779        for f in &sent {
1780            assert_eq!(f.len, 1280 * 720 * 4);
1781            assert!(f.rows_identical);
1782        }
1783        // tick advances by 2 per frame and the shift is `tick / 4`, so frames
1784        // 0+1 share a phase and frame 2 is rotated by exactly one bar.
1785        assert_eq!(sent[0].row0_palette, sent[1].row0_palette);
1786        assert_eq!(sent[2].row0_palette, sent[3].row0_palette);
1787        assert_ne!(
1788            sent[1].row0_palette, sent[2].row0_palette,
1789            "the pattern must actually scroll"
1790        );
1791        assert_eq!(
1792            sent[2].row0_palette[0], EXPECTED_BARS[1],
1793            "one tick of scroll rotates the palette by one bar"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_worker_ignores_its_init_payload_entirely() {
1799        // The test pattern is fixed-size: no init data can change it (or crash it).
1800        for init in [
1801            RefAny::new(()),
1802            RefAny::new(0_u32),
1803            RefAny::new(VideoConfig::default()),
1804            RefAny::new(vec![frame(1, 1)]),
1805        ] {
1806            let sent = run_worker(video_test_worker, init, 0, false);
1807            assert_eq!(sent.len(), 1);
1808            assert_eq!((sent[0].width, sent[0].height), (1280, 720));
1809        }
1810    }
1811
1812    // ==================================================================
1813    // video_replay_worker
1814    // ==================================================================
1815
1816    #[test]
1817    fn replay_worker_returns_immediately_for_a_wrong_typed_init() {
1818        for init in [
1819            RefAny::new(0_u32),
1820            RefAny::new("not a frame list"),
1821            RefAny::new(VideoConfig::default()),
1822            RefAny::new(frame(1, 1)),
1823        ] {
1824            let sent = run_worker(video_replay_worker, init, 8, false);
1825            assert!(
1826                sent.is_empty(),
1827                "a payload that is not a Vec<VideoFrame> must be skipped, not guessed at"
1828            );
1829        }
1830    }
1831
1832    #[test]
1833    fn replay_worker_returns_immediately_for_an_empty_frame_list() {
1834        // Boundary: an empty list would make `idx % frames.len()` divide by
1835        // zero - the worker must bail first.
1836        let sent = run_worker(
1837            video_replay_worker,
1838            RefAny::new(Vec::<VideoFrame>::new()),
1839            8,
1840            false,
1841        );
1842        assert!(sent.is_empty());
1843    }
1844
1845    #[test]
1846    fn replay_worker_sends_the_caller_frames_byte_for_byte() {
1847        let frames = vec![
1848            frame_raw(2, 1, vec![1, 2, 3, 4, 5, 6, 7, 8]),
1849            frame_raw(1, 2, vec![9, 10, 11, 12, 13, 14, 15, 16]),
1850        ];
1851        let sent = run_worker(video_replay_worker, RefAny::new(frames.clone()), 2, false);
1852
1853        assert_eq!(sent.len(), 3, "2 accepted + 1 rejected");
1854        for (i, s) in sent.iter().enumerate() {
1855            let expected = &frames[i % frames.len()];
1856            assert_eq!((s.width, s.height), (expected.width, expected.height));
1857            assert_eq!(
1858                s.small_bytes.as_deref(),
1859                Some(expected.bytes.as_ref()),
1860                "frame {i} must be replayed verbatim - no re-encoding"
1861            );
1862        }
1863    }
1864
1865    #[test]
1866    fn replay_worker_cycles_the_list_and_never_indexes_out_of_bounds() {
1867        let frames = vec![frame_raw(1, 1, vec![0; 4]), frame_raw(2, 2, vec![1; 16])];
1868        let sent = run_worker(video_replay_worker, RefAny::new(frames), 5, false);
1869
1870        assert_eq!(sent.len(), 6);
1871        let widths: Vec<u32> = sent.iter().map(|s| s.width).collect();
1872        assert_eq!(widths, vec![1, 2, 1, 2, 1, 2], "the list must wrap around");
1873    }
1874
1875    #[test]
1876    fn replay_worker_forwards_degenerate_frames_unchanged() {
1877        // A decoder can hand back a 0x0 frame or one whose byte count does not
1878        // match its dimensions; the replay worker is a pipe, not a validator -
1879        // it must not panic, truncate, or drop them.
1880        let frames = vec![
1881            frame_raw(0, 0, Vec::new()),
1882            frame_raw(u32::MAX, u32::MAX, Vec::new()),
1883            frame_raw(1, 1, vec![0xAB; 3]),
1884        ];
1885        let sent = run_worker(video_replay_worker, RefAny::new(frames), 2, false);
1886
1887        assert_eq!(sent.len(), 3);
1888        assert_eq!((sent[0].width, sent[0].height, sent[0].len), (0, 0, 0));
1889        assert_eq!(
1890            (sent[1].width, sent[1].height, sent[1].len),
1891            (u32::MAX, u32::MAX, 0)
1892        );
1893        assert_eq!(sent[2].small_bytes.as_deref(), Some(&[0xAB, 0xAB, 0xAB][..]));
1894    }
1895
1896    // ==================================================================
1897    // video_writeback
1898    // ==================================================================
1899
1900    #[test]
1901    fn writeback_stores_the_frame_and_rerenders_the_virtual_view() {
1902        let mut data = state(VideoConfig::default());
1903        let frame_data = RefAny::new(frame(4, 3));
1904
1905        let (update, changes) =
1906            with_callback_info(|info| video_writeback(data.clone(), frame_data.clone(), info));
1907
1908        assert_eq!(update, Update::DoNothing, "no hook -> no user update");
1909        assert_eq!(
1910            count_virtual_view_rerenders(&changes),
1911            1,
1912            "the VirtualView must be re-rendered in place (never RefreshDom)"
1913        );
1914        assert_eq!(
1915            current_frame_dims(&mut data),
1916            Some((4, 3)),
1917            "the decoded frame becomes the widget's current CPU image"
1918        );
1919    }
1920
1921    #[test]
1922    fn writeback_invokes_the_hook_with_the_exact_frame_and_returns_its_update() {
1923        let mut log = frame_log(Update::RefreshDom);
1924        let mut s = base_state(VideoConfig::default());
1925        s.on_frame = hook_into(&log);
1926        let mut data = RefAny::new(s);
1927        let frame_data = RefAny::new(frame(2, 2));
1928
1929        let (update, changes) =
1930            with_callback_info(|info| video_writeback(data.clone(), frame_data.clone(), info));
1931
1932        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
1933        assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
1934        assert_eq!(count_virtual_view_rerenders(&changes), 1);
1935        assert_eq!(current_frame_dims(&mut data), Some((2, 2)));
1936    }
1937
1938    #[test]
1939    fn writeback_ignores_frame_data_of_the_wrong_type() {
1940        let mut log = frame_log(Update::RefreshDom);
1941        let mut s = base_state(VideoConfig::default());
1942        s.on_frame = hook_into(&log);
1943        let mut data = RefAny::new(s);
1944
1945        let (update, changes) =
1946            with_callback_info(|info| video_writeback(data.clone(), RefAny::new(0_u32), info));
1947
1948        assert_eq!(update, Update::DoNothing);
1949        assert!(
1950            changes.is_empty(),
1951            "no frame -> no re-render is scheduled at all"
1952        );
1953        assert!(
1954            logged_frames(&mut log).is_empty(),
1955            "the user hook must not fire without a frame"
1956        );
1957        assert_eq!(read_state(&mut data).current_frame_id, None);
1958    }
1959
1960    #[test]
1961    fn writeback_survives_a_writeback_dataset_that_is_not_a_video_state() {
1962        let (update, changes) = with_callback_info(|info| {
1963            video_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
1964        });
1965
1966        assert_eq!(
1967            update,
1968            Update::DoNothing,
1969            "a foreign dataset means no hook and nowhere to store - but no panic"
1970        );
1971        assert_eq!(
1972            count_virtual_view_rerenders(&changes),
1973            1,
1974            "the re-render is still scheduled (documented cost of a stale dataset)"
1975        );
1976    }
1977
1978    #[test]
1979    fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
1980        // A malformed/hostile frame: the image build must fail cleanly instead
1981        // of indexing out of bounds or allocating.
1982        let mut data = state(VideoConfig::default());
1983
1984        for bogus in [
1985            frame_raw(u32::MAX, 1, Vec::new()),
1986            frame_raw(4, 4, vec![0; 4 * 4 * 4 - 1]),
1987            frame_raw(4, 4, vec![0; 4 * 4 * 4 + 1]),
1988            frame_raw(1, 1, Vec::new()),
1989            frame_raw(0, 0, vec![0; 4]),
1990        ] {
1991            let payload = RefAny::new(bogus);
1992            let (update, changes) =
1993                with_callback_info(|info| video_writeback(data.clone(), payload.clone(), info));
1994
1995            assert_eq!(update, Update::DoNothing);
1996            assert_eq!(
1997                count_virtual_view_rerenders(&changes),
1998                1,
1999                "a rejected frame still costs a re-render"
2000            );
2001            assert_eq!(
2002                read_state(&mut data).current_frame_id,
2003                None,
2004                "a rejected frame must never become the displayed image"
2005            );
2006        }
2007    }
2008
2009    #[test]
2010    fn writeback_accepts_an_empty_zero_by_zero_frame() {
2011        // Boundary: 0x0 with 0 bytes is internally consistent, so it is accepted
2012        // as a (degenerate) image rather than rejected.
2013        let mut data = state(VideoConfig::default());
2014        let empty = RefAny::new(frame_raw(0, 0, Vec::new()));
2015
2016        let (update, _) =
2017            with_callback_info(|info| video_writeback(data.clone(), empty.clone(), info));
2018
2019        assert_eq!(update, Update::DoNothing);
2020        assert_eq!(current_frame_dims(&mut data), Some((0, 0)));
2021    }
2022
2023    #[test]
2024    fn writeback_replaces_the_previous_frame_every_time() {
2025        let mut s = base_state(VideoConfig::default());
2026        s.current_frame = Some(placeholder_image(b"old"));
2027        let mut data = RefAny::new(s);
2028        let old_id = read_state(&mut data).current_frame_id.expect("seeded");
2029
2030        let f1 = RefAny::new(frame(2, 2));
2031        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), f1.clone(), info));
2032        let id1 = read_state(&mut data).current_frame_id.expect("stored");
2033        assert_ne!(id1, old_id, "the stale placeholder must be replaced");
2034
2035        let f2 = RefAny::new(frame(3, 3));
2036        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), f2.clone(), info));
2037        let id2 = read_state(&mut data).current_frame_id.expect("stored");
2038        assert_ne!(id2, id1, "every frame installs a fresh image");
2039        assert_eq!(current_frame_dims(&mut data), Some((3, 3)));
2040    }
2041
2042    #[test]
2043    fn writeback_keeps_the_last_good_frame_when_a_later_one_is_malformed() {
2044        let mut data = state(VideoConfig::default());
2045        let good = RefAny::new(frame(2, 2));
2046        let (_, _) = with_callback_info(|info| video_writeback(data.clone(), good.clone(), info));
2047        let good_id = read_state(&mut data).current_frame_id.expect("stored");
2048
2049        let bad = RefAny::new(frame_raw(1024, 1024, vec![0; 16]));
2050        let (update, _) =
2051            with_callback_info(|info| video_writeback(data.clone(), bad.clone(), info));
2052
2053        assert_eq!(update, Update::DoNothing);
2054        assert_eq!(
2055            read_state(&mut data).current_frame_id,
2056            Some(good_id),
2057            "a corrupt frame must not blank the picture"
2058        );
2059    }
2060
2061    #[test]
2062    fn writeback_still_notifies_the_hook_for_a_frame_it_cannot_display() {
2063        // The hook is the user's data path (save / send), so it fires even when
2064        // the frame is unusable as an image - documented here so a change is
2065        // deliberate.
2066        let mut log = frame_log(Update::RefreshDom);
2067        let mut s = base_state(VideoConfig::default());
2068        s.on_frame = hook_into(&log);
2069        let mut data = RefAny::new(s);
2070        let bogus = RefAny::new(frame_raw(64, 64, vec![0; 3]));
2071
2072        let (update, _) =
2073            with_callback_info(|info| video_writeback(data.clone(), bogus.clone(), info));
2074
2075        assert_eq!(update, Update::RefreshDom);
2076        assert_eq!(logged_frames(&mut log), vec![(64, 64, 3)]);
2077        assert_eq!(read_state(&mut data).current_frame_id, None);
2078    }
2079
2080    #[test]
2081    fn writeback_survives_dimensions_whose_byte_count_overflows_usize() {
2082        // ADVERSARIAL: a decoder reporting 2^31 x 2^31 makes the raw-image path
2083        // compute `width * height * 4` in usize -> 2^64, which overflows. In a
2084        // debug build that is an arithmetic-overflow panic; in release it wraps
2085        // and the empty buffer may be *accepted*. Neither is a graceful
2086        // rejection (see the report) - what must hold in both modes is that the
2087        // widget never ends up displaying a bogus image.
2088        let mut data = state(VideoConfig::default());
2089        let huge = RefAny::new(frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()));
2090
2091        let (result, _) = with_callback_info(|info| {
2092            catch_unwind(AssertUnwindSafe(|| {
2093                video_writeback(data.clone(), huge.clone(), info)
2094            }))
2095        });
2096
2097        match result {
2098            Ok(update) => {
2099                assert_eq!(update, Update::DoNothing);
2100                assert_eq!(
2101                    read_state(&mut data).current_frame_id,
2102                    None,
2103                    "an overflowing frame must not become the displayed image"
2104                );
2105            }
2106            Err(_) => eprintln!(
2107                "NOTE: video_writeback panicked (usize overflow of width*height*4) for a \
2108                 2^31 x 2^31 frame - see the autotest report"
2109            ),
2110        }
2111    }
2112
2113    // ==================================================================
2114    // merge_video_state
2115    // ==================================================================
2116
2117    /// An `(old, new)` pair plus the seek channel `old` hands forward.
2118    fn merge_pair(
2119        old_cfg: VideoConfig,
2120        new_cfg: VideoConfig,
2121    ) -> (RefAny, RefAny, Receiver<ThreadSendMsg>) {
2122        let (tx, rx) = channel::<ThreadSendMsg>();
2123        let mut old = base_state(old_cfg);
2124        old.started = true;
2125        old.thread_id = Some(ThreadId::unique());
2126        old.seek_sender = Some(tx);
2127        (RefAny::new(base_state(new_cfg)), RefAny::new(old), rx)
2128    }
2129
2130    #[test]
2131    fn merge_takes_the_live_state_from_old_and_the_config_from_new() {
2132        let log = frame_log(Update::DoNothing);
2133        let tid = ThreadId::unique();
2134        let (tx, _rx) = channel::<ThreadSendMsg>();
2135
2136        let mut new = base_state(config(file_source("/new.mp4"), 3.0));
2137        new.on_frame = hook_into(&log);
2138        new.frames = OptionRefAny::Some(RefAny::new(vec![frame(1, 1)]));
2139
2140        let mut old = base_state(config(file_source("/old.mp4"), 3.0));
2141        old.started = true;
2142        old.gl_texture_id = Some(9);
2143        old.frames = OptionRefAny::Some(RefAny::new(vec![frame(7, 7), frame(8, 8)]));
2144        old.decode_callback = Some(ThreadCallback::new(noop_decode_worker));
2145        old.current_frame = Some(placeholder_image(b"live"));
2146        old.thread_id = Some(tid);
2147        old.seek_sender = Some(tx);
2148        let old_frame_id = old.current_frame.as_ref().map(|i| i.id);
2149
2150        let mut merged = merge_video_state(RefAny::new(new), RefAny::new(old));
2151
2152        assert_same_config(
2153            &read_config(&mut merged),
2154            &config(file_source("/new.mp4"), 3.0),
2155        );
2156        let summary = read_state(&mut merged);
2157        assert!(summary.has_hook, "the fresh build's hook wins");
2158        assert!(summary.started, "'already running' must carry forward");
2159        assert_eq!(summary.gl_texture_id, Some(9));
2160        assert_eq!(summary.decode_cb, Some(noop_decode_worker as ThreadCallbackType as usize));
2161        assert_eq!(summary.current_frame_id, old_frame_id, "no visible flicker");
2162        assert_eq!(summary.thread_id, Some(tid));
2163        assert!(summary.has_seek_sender);
2164        assert_eq!(
2165            state_frames(&mut merged),
2166            Some(vec![(7, 7), (8, 8)]),
2167            "the OLD replay list wins - a fresh build cannot swap the clip"
2168        );
2169    }
2170
2171    #[test]
2172    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
2173        let mut new = base_state(config(file_source("/new.mp4"), 1.0));
2174        new.frames = OptionRefAny::Some(RefAny::new(vec![frame(5, 5)]));
2175        let mut merged = merge_video_state(RefAny::new(new), RefAny::new(0_u32));
2176
2177        let summary = read_state(&mut merged);
2178        assert!(!summary.started, "nothing to carry forward");
2179        assert_eq!(summary.thread_id, None);
2180        assert_eq!(
2181            state_frames(&mut merged),
2182            Some(vec![(5, 5)]),
2183            "with no old state the new build's own list survives"
2184        );
2185    }
2186
2187    #[test]
2188    fn merge_returns_a_foreign_new_dataset_untouched() {
2189        let old = state(VideoConfig::default());
2190        let mut merged = merge_video_state(RefAny::new(77_u32), old);
2191        assert_eq!(
2192            merged.downcast_ref::<u32>().map(|v| *v),
2193            Some(77),
2194            "merge must hand back exactly the payload it was given"
2195        );
2196    }
2197
2198    #[test]
2199    fn merge_of_a_dataset_with_itself_does_not_panic() {
2200        // The same RefAny on both sides: the mutable + shared borrows overlap,
2201        // so the merge is skipped rather than aliasing. Either way the state
2202        // must survive intact.
2203        let mut s = base_state(config(file_source("/self.mp4"), 4.0));
2204        s.started = true;
2205        s.gl_texture_id = Some(5);
2206        let mut data = RefAny::new(s);
2207        let before = read_state(&mut data);
2208
2209        let mut merged = merge_video_state(data.clone(), data.clone());
2210
2211        assert_eq!(read_state(&mut merged), before);
2212        assert_eq!(read_state(&mut data), before);
2213    }
2214
2215    #[test]
2216    fn merge_pushes_a_seek_when_the_scrub_position_changed() {
2217        let (new, old, rx) = merge_pair(
2218            config(file_source("/clip.mp4"), 0.0),
2219            config(file_source("/clip.mp4"), 42.25),
2220        );
2221        let _merged = merge_video_state(new, old);
2222
2223        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
2224        assert_eq!(msgs.len(), 1, "one seek, no source re-init");
2225        assert_eq!(
2226            custom_f32(&msgs[0]),
2227            Some(42.25),
2228            "the worker must be told the NEW timestamp"
2229        );
2230    }
2231
2232    #[test]
2233    fn merge_stays_quiet_when_nothing_changed() {
2234        let (new, old, rx) = merge_pair(
2235            config(file_source("/clip.mp4"), 7.5),
2236            config(file_source("/clip.mp4"), 7.5),
2237        );
2238        let _merged = merge_video_state(new, old);
2239        assert!(
2240            rx.try_iter().next().is_none(),
2241            "an unchanged config must not wake the decode worker"
2242        );
2243    }
2244
2245    #[test]
2246    fn merge_treats_negative_zero_and_zero_as_the_same_position() {
2247        let (new, old, rx) = merge_pair(
2248            config(file_source("/clip.mp4"), -0.0),
2249            config(file_source("/clip.mp4"), 0.0),
2250        );
2251        let _merged = merge_video_state(new, old);
2252        assert!(
2253            rx.try_iter().next().is_none(),
2254            "-0.0 == 0.0 is the same scrub position"
2255        );
2256    }
2257
2258    #[test]
2259    fn merge_seeks_on_every_relayout_while_the_timestamp_is_nan() {
2260        // ADVERSARIAL: `NaN != NaN`, so an unchanged NaN scrub position looks
2261        // like a change on every single relayout and floods the worker with
2262        // seeks (see the report). Pinned here as the current behaviour.
2263        let (new, old, rx) = merge_pair(
2264            config(file_source("/clip.mp4"), f32::NAN),
2265            config(file_source("/clip.mp4"), f32::NAN),
2266        );
2267        let _merged = merge_video_state(new, old);
2268
2269        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
2270        assert_eq!(msgs.len(), 1);
2271        assert!(
2272            custom_f32(&msgs[0]).is_some_and(f32::is_nan),
2273            "the spurious seek carries the NaN straight through to the worker"
2274        );
2275    }
2276
2277    #[test]
2278    fn merge_pushes_the_new_source_when_the_input_changed() {
2279        let (new, old, rx) = merge_pair(
2280            config(file_source("/old.mp4"), 1.0),
2281            config(url_source("cdn.example", "/new.mp4"), 1.0),
2282        );
2283        let _merged = merge_video_state(new, old);
2284
2285        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
2286        assert_eq!(msgs.len(), 1, "one re-init, no seek");
2287        assert_eq!(
2288            custom_source(&msgs[0]),
2289            Some(url_source("cdn.example", "/new.mp4"))
2290        );
2291    }
2292
2293    #[test]
2294    fn merge_sends_the_seek_before_the_source_when_both_changed() {
2295        let (new, old, rx) = merge_pair(
2296            config(file_source("/old.mp4"), 0.0),
2297            config(bytes_source(vec![1, 2, 3]), 9.0),
2298        );
2299        let _merged = merge_video_state(new, old);
2300
2301        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
2302        assert_eq!(msgs.len(), 2);
2303        assert_eq!(custom_f32(&msgs[0]), Some(9.0));
2304        assert_eq!(custom_source(&msgs[1]), Some(bytes_source(vec![1, 2, 3])));
2305    }
2306
2307    #[test]
2308    fn merge_notices_a_source_change_that_only_differs_in_unicode() {
2309        let (new, old, rx) = merge_pair(
2310            config(file_source("/tmp/\u{1F3AC}.mp4"), 0.0),
2311            config(file_source("/tmp/\u{1F3AB}.mp4"), 0.0),
2312        );
2313        let _merged = merge_video_state(new, old);
2314
2315        let msgs: Vec<ThreadSendMsg> = rx.try_iter().collect();
2316        assert_eq!(msgs.len(), 1, "distinct emoji are distinct sources");
2317        assert_eq!(
2318            custom_source(&msgs[0]),
2319            Some(file_source("/tmp/\u{1F3AB}.mp4"))
2320        );
2321    }
2322
2323    #[test]
2324    fn merge_without_a_seek_sender_drops_the_seek_silently() {
2325        // Nothing to send to (replay / test-pattern mounts never record a
2326        // sender): the merge must still carry the state, not panic.
2327        let new = RefAny::new(base_state(config(file_source("/clip.mp4"), 5.0)));
2328        let mut old_state = base_state(config(file_source("/clip.mp4"), 0.0));
2329        old_state.started = true;
2330        let mut merged = merge_video_state(new, RefAny::new(old_state));
2331
2332        let summary = read_state(&mut merged);
2333        assert!(summary.started);
2334        assert!(!summary.has_seek_sender);
2335        assert_eq!(read_config(&mut merged).timestamp, 5.0);
2336    }
2337
2338    #[test]
2339    fn merge_survives_a_worker_whose_channel_is_already_closed() {
2340        let (new, old, rx) = merge_pair(
2341            config(file_source("/a.mp4"), 0.0),
2342            config(file_source("/b.mp4"), 1.0),
2343        );
2344        drop(rx); // the worker exited and its receiver is gone
2345
2346        let mut merged = merge_video_state(new, old);
2347
2348        let summary = read_state(&mut merged);
2349        assert!(
2350            summary.has_seek_sender,
2351            "a dead sender is still carried forward - the send just fails"
2352        );
2353        assert!(summary.started);
2354    }
2355
2356    #[test]
2357    fn merge_is_idempotent_across_repeated_relayouts() {
2358        let (tx, rx) = channel::<ThreadSendMsg>();
2359        let tid = ThreadId::unique();
2360        let mut live = base_state(config(file_source("/clip.mp4"), 2.0));
2361        live.started = true;
2362        live.gl_texture_id = Some(3);
2363        live.thread_id = Some(tid);
2364        live.seek_sender = Some(tx);
2365        live.current_frame = Some(placeholder_image(b"live"));
2366        let mut carried = RefAny::new(live);
2367
2368        for _ in 0..5 {
2369            let fresh = RefAny::new(base_state(config(file_source("/clip.mp4"), 2.0)));
2370            carried = merge_video_state(fresh, carried);
2371        }
2372
2373        let summary = read_state(&mut carried);
2374        assert!(summary.started);
2375        assert_eq!(summary.gl_texture_id, Some(3));
2376        assert_eq!(summary.thread_id, Some(tid));
2377        assert!(summary.current_frame_id.is_some(), "the picture never blanks");
2378        assert!(
2379            rx.try_iter().next().is_none(),
2380            "a stable config must never seek, however many relayouts happen"
2381        );
2382    }
2383}