1use 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
30const DEFAULT_W: u32 = 1280;
33const DEFAULT_H: u32 = 720;
34
35#[derive(Debug)]
38pub struct VideoWidgetState {
39 pub config: VideoConfig,
41 pub started: bool,
43 pub gl_texture_id: Option<u32>,
45 pub on_frame: OptionOnVideoFrame,
48 pub frames: OptionRefAny,
52 pub decode_callback: Option<ThreadCallback>,
57 pub current_frame: Option<ImageRef>,
64 pub thread_id: Option<ThreadId>,
69 pub seek_sender: Option<std::sync::mpsc::Sender<ThreadSendMsg>>,
73}
74
75#[repr(C)]
78#[derive(Debug)]
79pub struct VideoWidget {
80 pub config: VideoConfig,
82 pub on_frame: OptionOnVideoFrame,
84 pub frames: OptionRefAny,
88}
89
90impl VideoWidget {
91 #[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 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 #[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 #[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 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 .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 #[must_use] pub fn dom(self) -> Dom {
184 self.build_dom(None)
185 }
186
187 #[must_use] pub fn dom_with_decoder(self, cb: ThreadCallback) -> Dom {
196 self.build_dom(Some(cb))
197 }
198}
199
200extern "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 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
240extern "C" fn video_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
242 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 if let Some(cb) = decode_cb {
261 let init = RefAny::new(config);
264 let tid = ThreadId::unique();
265 let thread = Thread::create(init, data.clone(), cb);
266 let seek_sender = thread.clone_sender();
269 info.add_thread(tid, thread);
270 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#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] extern "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 let _ = thread.send_message(ThreadSendMsg::Custom(RefAny::new(target)));
315 }
316 Update::DoNothing
317}
318
319#[allow(clippy::cast_possible_truncation)] extern "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
360extern "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#[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 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 info.trigger_all_virtual_view_rerender();
437 user_update
438}
439
440#[allow(clippy::float_cmp)] extern "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 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 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#[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 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 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 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 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 const CONST_WIDGET: VideoWidget = VideoWidget::create(CONST_CONFIG);
610
611 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 #[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 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 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 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 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 fn placeholder_image(tag: &[u8]) -> ImageRef {
721 ImageRef::null_image(4, 4, RawImageFormat::BGRA8, tag.to_vec())
722 }
723
724 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 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 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 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 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: ¤t_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 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 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 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 #[derive(Debug, Clone, PartialEq, Eq)]
913 struct SentFrame {
914 width: u32,
915 height: u32,
916 len: usize,
917 row0_palette: Vec<[u8; 4]>,
920 rows_identical: bool,
922 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 static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
955 static WORKER_GATE: Mutex<()> = Mutex::new(());
956 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 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 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 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 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 #[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 #[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 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 #[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 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 #[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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 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); 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}