1use alloc::vec::Vec;
19
20use azul_core::callbacks::Update;
21use azul_core::camera::CameraConfig;
22use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
23use azul_core::refany::{OptionRefAny, RefAny};
24use azul_core::resources::{ImageRef, RawImageFormat};
25use azul_core::task::{ThreadId, ThreadReceiver};
26
27use azul_core::video::VideoFrame;
28
29use super::capture_common::{
30 camera_backend, invoke_on_frame, present_frame, OnVideoFrame, OnVideoFrameCallback,
31 OptionOnVideoFrame,
32};
33use crate::callbacks::{Callback, CallbackInfo, CallbackType};
34use crate::thread::{
35 Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
36};
37
38struct CameraThreadInit {
40 width: u32,
41 height: u32,
42}
43
44#[derive(Debug)]
47pub struct CameraWidgetState {
48 pub config: CameraConfig,
50 pub started: bool,
52 pub gl_texture_id: Option<u32>,
54 pub on_frame: OptionOnVideoFrame,
57}
58
59#[repr(C)]
62#[derive(Debug)]
63pub struct CameraWidget {
64 pub config: CameraConfig,
66 pub on_frame: OptionOnVideoFrame,
68}
69
70impl CameraWidget {
71 #[must_use] pub const fn create(config: CameraConfig) -> Self {
73 Self {
74 config,
75 on_frame: OptionOnVideoFrame::None,
76 }
77 }
78
79 pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
83 self.on_frame = Some(OnVideoFrame {
84 refany: data,
85 callback: on_frame.into(),
86 })
87 .into();
88 }
89
90 #[must_use]
92 pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
93 mut self,
94 data: RefAny,
95 on_frame: C,
96 ) -> Self {
97 self.set_on_frame(data, on_frame);
98 self
99 }
100
101 #[must_use] pub fn dom(self) -> Dom {
104 let state = CameraWidgetState {
105 config: self.config,
106 started: false,
107 gl_texture_id: None,
108 on_frame: self.on_frame,
109 };
110 let dataset = RefAny::new(state);
111
112 let (w, h) = frame_dims(&self.config);
113 let placeholder = ImageRef::null_image(
114 w as usize,
115 h as usize,
116 RawImageFormat::BGRA8,
117 b"azul-camera-placeholder".to_vec(),
118 );
119
120 Dom::create_image(placeholder)
121 .with_dataset(OptionRefAny::Some(dataset.clone()))
122 .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_camera_state))
123 .with_callback(
124 EventFilter::Component(ComponentEventFilter::AfterMount),
125 dataset,
126 Callback::from_ptr(camera_on_after_mount),
127 )
128 }
129}
130
131const fn frame_dims(config: &CameraConfig) -> (u32, u32) {
133 let w = if config.width > 0 { config.width } else { 640 };
134 let h = if config.height > 0 { config.height } else { 480 };
135 (w, h)
136}
137
138extern "C" fn camera_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
140 let dims = {
141 let Some(mut s) = data.downcast_mut::<CameraWidgetState>() else {
142 return Update::DoNothing;
143 };
144 if s.started {
145 return Update::DoNothing;
146 }
147 s.started = true;
148 frame_dims(&s.config)
149 };
150
151 info.add_thread(
152 ThreadId::unique(),
153 Thread::create(
154 RefAny::new(CameraThreadInit {
155 width: dims.0,
156 height: dims.1,
157 }),
158 data.clone(),
159 ThreadCallback::new(camera_worker),
160 ),
161 );
162 Update::DoNothing
163}
164
165#[allow(clippy::cast_possible_truncation)] extern "C" fn camera_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
169 let (w, h) = init
170 .downcast_ref::<CameraThreadInit>()
171 .map_or((640, 480), |i| (i.width, i.height));
172
173 if let Some(backend) = camera_backend() {
176 let handle = (backend.open)(0, w, h);
177 if handle != 0 {
178 let mut buf: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
179 loop {
180 let (fw, fh) = (backend.read)(handle, &mut buf);
181 if fw == 0 || fh == 0 {
182 break;
183 }
184 let frame = VideoFrame {
185 width: fw,
186 height: fh,
187 bytes: buf.clone().into(),
188 };
189 if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
190 WriteBackCallback::new(camera_writeback),
191 RefAny::new(frame),
192 ))) {
193 break;
194 }
195 }
196 (backend.close)(handle);
197 return;
198 }
199 }
200
201 let px = (w as usize) * (h as usize);
202 let mut tick: u32 = 0;
203 loop {
204 let color = [
205 (tick % 256) as u8,
206 (tick.wrapping_mul(2) % 256) as u8,
207 (tick.wrapping_mul(3) % 256) as u8,
208 255u8,
209 ];
210 let mut bytes = Vec::with_capacity(px * 4);
211 for _ in 0..px {
212 bytes.extend_from_slice(&color);
213 }
214 let frame = VideoFrame {
215 width: w,
216 height: h,
217 bytes: bytes.into(),
218 };
219 let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
220 WriteBackCallback::new(camera_writeback),
221 RefAny::new(frame),
222 )));
223 if !sent {
224 break;
225 }
226 std::thread::sleep(std::time::Duration::from_millis(33));
227 tick = tick.wrapping_add(8);
228 }
229}
230
231extern "C" fn camera_writeback(
234 mut writeback_data: RefAny,
235 mut frame_data: RefAny,
236 mut info: CallbackInfo,
237) -> Update {
238 let (current, hook) = writeback_data.downcast_ref::<CameraWidgetState>().map_or_else(|| (None, OptionOnVideoFrame::None), |s| (s.gl_texture_id, s.on_frame.clone()));
239 let mut user_update = Update::DoNothing;
240 let new_id = match frame_data.downcast_ref::<VideoFrame>() {
241 Some(frame) => {
242 let id = present_frame(&mut info, writeback_data.clone(), current, &frame);
243 user_update = invoke_on_frame(&hook, &mut info, &frame);
244 id
245 }
246 None => return Update::DoNothing,
247 };
248 if let Some(mut s) = writeback_data.downcast_mut::<CameraWidgetState>() {
249 s.gl_texture_id = new_id;
250 }
251 user_update
252}
253
254extern "C" fn merge_camera_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
257 {
258 let new_guard = new_data.downcast_mut::<CameraWidgetState>();
259 let old_guard = old_data.downcast_ref::<CameraWidgetState>();
260 if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
261 new_g.started = old_g.started;
262 new_g.gl_texture_id = old_g.gl_texture_id;
263 }
264 }
265 new_data
266}
267
268#[cfg(test)]
273#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
274mod autotest_generated {
275 use std::{
276 collections::BTreeMap,
277 sync::{
278 mpsc::{channel, Receiver, Sender},
279 Arc, Mutex,
280 },
281 };
282
283 use azul_core::{
284 camera::CameraFacing,
285 dom::{DomId, DomNodeId, NodeType},
286 geom::OptionLogicalPosition,
287 gl::OptionGlContextPtr,
288 hit_test::ScrollPosition,
289 resources::{DecodedImage, RendererResources},
290 styled_dom::NodeHierarchyItemId,
291 task::{
292 OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
293 ThreadRecvCallback, ThreadSendMsg,
294 },
295 window::{MonitorVec, RawWindowHandle},
296 };
297 use azul_css::system::SystemStyle;
298 use rust_fontconfig::FcFontCache;
299
300 use super::*;
301 #[cfg(feature = "icu")]
302 use crate::icu::IcuLocalizerHandle;
303 use crate::{
304 callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
305 thread::{ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner},
306 widgets::capture_common::OnVideoFrameCallbackType,
307 window::LayoutWindow,
308 window_state::FullWindowState,
309 };
310
311 const ALL_FACINGS: [CameraFacing; 3] = [
316 CameraFacing::Front,
317 CameraFacing::Back,
318 CameraFacing::External,
319 ];
320
321 fn cfg(width: u32, height: u32) -> CameraConfig {
323 CameraConfig {
324 facing: CameraFacing::Front,
325 width,
326 height,
327 fps: 30,
328 output_format: RawImageFormat::BGRA8,
329 }
330 }
331
332 fn state(config: CameraConfig, started: bool, gl_texture_id: Option<u32>) -> RefAny {
334 RefAny::new(CameraWidgetState {
335 config,
336 started,
337 gl_texture_id,
338 on_frame: OptionOnVideoFrame::None,
339 })
340 }
341
342 fn read_state(data: &mut RefAny) -> (CameraConfig, bool, Option<u32>, bool) {
344 let s = data
345 .downcast_ref::<CameraWidgetState>()
346 .expect("payload must still be a CameraWidgetState");
347 (
348 s.config,
349 s.started,
350 s.gl_texture_id,
351 matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
352 )
353 }
354
355 fn placeholder_of(dom: &Dom) -> (usize, usize, RawImageFormat, Vec<u8>) {
357 let NodeType::Image(image) = dom.root.get_node_type() else {
358 panic!("CameraWidget::dom must build an image node");
359 };
360 match image.get_data() {
361 DecodedImage::NullImage {
362 width,
363 height,
364 format,
365 tag,
366 } => (*width, *height, *format, tag.clone()),
367 _ => panic!("the placeholder must be a NullImage (no decode, no allocation)"),
368 }
369 }
370
371 struct FrameLog {
375 seen: Vec<(u32, u32, usize)>,
376 }
377
378 extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
379 if let Some(mut log) = data.downcast_mut::<FrameLog>() {
380 log.seen.push((frame.width, frame.height, frame.bytes.as_ref().len()));
381 }
382 Update::RefreshDom
383 }
384
385 extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
386 Update::DoNothing
387 }
388
389 fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
391 data.downcast_ref::<FrameLog>()
392 .expect("payload must still be a FrameLog")
393 .seen
394 .clone()
395 }
396
397 fn state_with_hook(config: CameraConfig, log: &RefAny) -> RefAny {
399 RefAny::new(CameraWidgetState {
400 config,
401 started: true,
402 gl_texture_id: None,
403 on_frame: Some(OnVideoFrame {
404 refany: log.clone(),
405 callback: (record_frame as OnVideoFrameCallbackType).into(),
406 })
407 .into(),
408 })
409 }
410
411 fn frame(width: u32, height: u32) -> VideoFrame {
413 let px = (width as usize) * (height as usize);
414 VideoFrame {
415 width,
416 height,
417 bytes: vec![7u8; px * 4].into(),
418 }
419 }
420
421 fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
427 let layout_window =
428 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
429 let renderer_resources = RendererResources::default();
430 let previous_window_state: Option<FullWindowState> = None;
431 let current_window_state = FullWindowState::default();
432 let gl_context = OptionGlContextPtr::None;
433 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
434 BTreeMap::new();
435 let window_handle = RawWindowHandle::Unsupported;
436 let system_callbacks = ExternalSystemCallbacks::rust_internal();
437
438 let ref_data = CallbackInfoRefData {
439 layout_window: &layout_window,
440 renderer_resources: &renderer_resources,
441 previous_window_state: &previous_window_state,
442 current_window_state: ¤t_window_state,
443 gl_context: &gl_context,
444 current_scroll_manager: &scroll_states,
445 current_window_handle: &window_handle,
446 system_callbacks: &system_callbacks,
447 system_style: Arc::new(SystemStyle::default()),
448 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
449 #[cfg(feature = "icu")]
450 icu_localizer: IcuLocalizerHandle::default(),
451 ctx: OptionRefAny::None,
452 };
453
454 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
455
456 let info = CallbackInfo::new(
457 &ref_data,
458 &changes,
459 DomNodeId {
460 dom: DomId::ROOT_ID,
461 node: NodeHierarchyItemId::NONE,
462 },
463 OptionLogicalPosition::None,
464 OptionLogicalPosition::None,
465 );
466
467 let out = f(info);
468 let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
469 (out, recorded)
470 }
471
472 static WORKER_LOG: Mutex<Vec<(u32, u32, usize, bool)>> = Mutex::new(Vec::new());
478 static WORKER_GATE: Mutex<()> = Mutex::new(());
479
480 extern "C" fn record_and_stop(_sender: *const core::ffi::c_void, msg: ThreadReceiveMsg) -> bool {
484 if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
485 if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
486 let bytes = f.bytes.as_ref();
487 let tick0 = bytes
488 .chunks_exact(4)
489 .all(|px| px == &[0u8, 0, 0, 255][..]);
490 WORKER_LOG
491 .lock()
492 .unwrap_or_else(std::sync::PoisonError::into_inner)
493 .push((f.width, f.height, bytes.len(), tick0));
494 }
495 }
496 false
497 }
498
499 extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
500 extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
501 extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
502 OptionThreadSendMsg::None
503 }
504
505 fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
507 let (tx, rx) = channel::<ThreadReceiveMsg>();
508 let sender = ThreadSender::new(ThreadSenderInner {
509 ptr: Box::new(tx),
510 send_fn: ThreadSendCallback { cb: record_and_stop },
511 destructor: ThreadSenderDestructorCallback {
512 cb: sender_drop_noop,
513 },
514 });
515 (rx, sender)
516 }
517
518 fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
520 let (tx, rx) = channel::<ThreadSendMsg>();
521 let receiver = ThreadReceiver::new(ThreadReceiverInner {
522 ptr: Box::new(rx),
523 recv_fn: ThreadRecvCallback { cb: recv_nothing },
524 destructor: ThreadReceiverDestructorCallback {
525 cb: receiver_drop_noop,
526 },
527 });
528 (tx, receiver)
529 }
530
531 fn run_worker(init: RefAny) -> Option<Vec<(u32, u32, usize, bool)>> {
536 let _gate = WORKER_GATE
537 .lock()
538 .unwrap_or_else(std::sync::PoisonError::into_inner);
539 if camera_backend().is_some() {
540 return None;
541 }
542 WORKER_LOG
543 .lock()
544 .unwrap_or_else(std::sync::PoisonError::into_inner)
545 .clear();
546
547 let (_rx, sender) = stopped_sender();
548 let (_tx, receiver) = silent_receiver();
549 camera_worker(init, sender, receiver);
550
551 let sent = WORKER_LOG
552 .lock()
553 .unwrap_or_else(std::sync::PoisonError::into_inner)
554 .clone();
555 Some(sent)
556 }
557
558 #[test]
563 fn frame_dims_substitutes_the_default_for_a_zero_dimension() {
564 assert_eq!(frame_dims(&cfg(0, 0)), (640, 480));
565 assert_eq!(frame_dims(&cfg(0, 720)), (640, 720), "only width defaults");
566 assert_eq!(frame_dims(&cfg(1280, 0)), (1280, 480), "only height defaults");
567 assert_eq!(frame_dims(&CameraConfig::default()), (640, 480));
568 }
569
570 #[test]
571 fn frame_dims_passes_nonzero_dimensions_through_unclamped() {
572 assert_eq!(frame_dims(&cfg(1, 1)), (1, 1), "1px is not 'unset'");
573 assert_eq!(frame_dims(&cfg(u32::MAX, u32::MAX)), (u32::MAX, u32::MAX));
574 assert_eq!(frame_dims(&cfg(u32::MAX, 0)), (u32::MAX, 480));
575 }
576
577 #[test]
578 fn frame_dims_ignores_facing_fps_and_format() {
579 for facing in ALL_FACINGS {
580 for fps in [0, 1, u32::MAX] {
581 let config = CameraConfig {
582 facing,
583 width: 0,
584 height: 0,
585 fps,
586 output_format: RawImageFormat::R8,
587 };
588 assert_eq!(frame_dims(&config), (640, 480));
589 }
590 }
591 }
592
593 #[test]
594 fn frame_dims_is_usable_in_const_context() {
595 const CONFIG: CameraConfig = CameraConfig {
596 facing: CameraFacing::Back,
597 width: 0,
598 height: 4096,
599 fps: 0,
600 output_format: RawImageFormat::BGRA8,
601 };
602 const DIMS: (u32, u32) = frame_dims(&CONFIG);
603 assert_eq!(DIMS, (640, 4096));
604 }
605
606 #[test]
611 fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
612 for facing in ALL_FACINGS {
613 for (w, h, fps) in [(0, 0, 0), (1, 1, 1), (u32::MAX, u32::MAX, u32::MAX)] {
614 let config = CameraConfig {
615 facing,
616 width: w,
617 height: h,
618 fps,
619 output_format: RawImageFormat::RGBA8,
620 };
621 let widget = CameraWidget::create(config);
622 assert_eq!(widget.config, config, "create must not normalise the config");
623 assert!(
624 matches!(widget.on_frame, OptionOnVideoFrame::None),
625 "a fresh widget has no frame hook"
626 );
627 }
628 }
629 }
630
631 #[test]
632 fn with_on_frame_installs_the_hook_and_keeps_the_config() {
633 let config = cfg(320, 240);
634 let widget = CameraWidget::create(config).with_on_frame(
635 RefAny::new(FrameLog { seen: Vec::new() }),
636 record_frame as OnVideoFrameCallbackType,
637 );
638
639 assert_eq!(widget.config, config, "the builder must not touch the config");
640 let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
641 panic!("with_on_frame must install a hook");
642 };
643 assert_eq!(
644 hook.callback.cb as usize,
645 record_frame as OnVideoFrameCallbackType as usize
646 );
647 }
648
649 #[test]
650 fn set_on_frame_twice_keeps_only_the_last_hook() {
651 let mut widget = CameraWidget::create(cfg(2, 2));
652 widget.set_on_frame(
653 RefAny::new(0_usize),
654 record_frame as OnVideoFrameCallbackType,
655 );
656 widget.set_on_frame(
657 RefAny::new(1_usize),
658 frame_do_nothing as OnVideoFrameCallbackType,
659 );
660
661 let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
662 panic!("hook must still be set");
663 };
664 assert_eq!(
665 hook.callback.cb as usize,
666 frame_do_nothing as OnVideoFrameCallbackType as usize,
667 "the second set_on_frame must replace the first"
668 );
669 }
670
671 #[test]
676 fn dom_placeholder_uses_the_defaulted_dims_and_is_always_bgra8() {
677 let (w, h, format, tag) = placeholder_of(&CameraWidget::create(cfg(0, 0)).dom());
678 assert_eq!((w, h), (640, 480), "a 0-sized config falls back to 640x480");
679 assert_eq!(format, RawImageFormat::BGRA8);
680 assert_eq!(tag, b"azul-camera-placeholder".to_vec());
681
682 let config = CameraConfig {
685 output_format: RawImageFormat::R8,
686 ..cfg(320, 240)
687 };
688 let (w, h, format, _) = placeholder_of(&CameraWidget::create(config).dom());
689 assert_eq!((w, h), (320, 240));
690 assert_eq!(format, RawImageFormat::BGRA8);
691 }
692
693 #[test]
694 fn dom_with_extreme_dims_builds_a_null_image_without_allocating() {
695 let (w, h, format, _) = placeholder_of(&CameraWidget::create(cfg(u32::MAX, u32::MAX)).dom());
698 assert_eq!((w, h), (u32::MAX as usize, u32::MAX as usize));
699 assert_eq!(format, RawImageFormat::BGRA8);
700 }
701
702 #[test]
703 fn dom_wires_exactly_one_after_mount_callback_a_dataset_and_a_merge_callback() {
704 let dom = CameraWidget::create(cfg(64, 48)).dom();
705
706 assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
707
708 let callbacks = dom.root.get_callbacks();
709 assert_eq!(
710 callbacks.as_ref().len(),
711 1,
712 "exactly one callback: the AfterMount capture-thread starter"
713 );
714 assert_eq!(
715 callbacks.as_ref()[0].event,
716 EventFilter::Component(ComponentEventFilter::AfterMount)
717 );
718 assert!(
719 dom.root.get_merge_callback().is_some(),
720 "state must survive relayout"
721 );
722
723 let mut dataset = dom
724 .root
725 .get_dataset()
726 .cloned()
727 .expect("the node must carry its CameraWidgetState");
728 let (config, started, texture, has_hook) = read_state(&mut dataset);
729 assert_eq!(config, cfg(64, 48));
730 assert!(!started, "the thread only starts on AfterMount");
731 assert_eq!(texture, None);
732 assert!(!has_hook);
733 }
734
735 #[test]
736 fn dom_moves_the_on_frame_hook_into_the_dataset() {
737 let dom = CameraWidget::create(cfg(8, 8))
738 .with_on_frame(
739 RefAny::new(FrameLog { seen: Vec::new() }),
740 record_frame as OnVideoFrameCallbackType,
741 )
742 .dom();
743
744 let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
745 let (_, _, _, has_hook) = read_state(&mut dataset);
746 assert!(has_hook, "dom() must carry the user hook into the state");
747 }
748
749 #[test]
759 fn after_mount_ignores_a_dataset_that_is_not_a_camera_state() {
760 let (update, changes) =
761 with_callback_info(|info| camera_on_after_mount(RefAny::new(0_u32), info));
762
763 assert_eq!(update, Update::DoNothing);
764 assert!(
765 changes.is_empty(),
766 "a foreign dataset must not start a capture thread"
767 );
768 }
769
770 #[test]
771 fn after_mount_is_a_no_op_once_the_thread_has_started() {
772 let mut data = state(cfg(0, 0), true, Some(3));
773 let (update, changes) = with_callback_info(|info| camera_on_after_mount(data.clone(), info));
774
775 assert_eq!(update, Update::DoNothing);
776 assert!(
777 changes.is_empty(),
778 "AfterMount must start the capture thread at most once"
779 );
780 let (_, started, texture, _) = read_state(&mut data);
781 assert!(started);
782 assert_eq!(texture, Some(3), "a re-mount must not drop the texture");
783 }
784
785 #[test]
790 fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
791 let Some(sent) = run_worker(RefAny::new(CameraThreadInit {
792 width: 2,
793 height: 3,
794 })) else {
795 return; };
797
798 assert_eq!(
799 sent.len(),
800 1,
801 "the worker must stop after the first rejected send, not spin"
802 );
803 assert_eq!(
804 sent[0],
805 (2, 3, 2 * 3 * 4, true),
806 "the first test-pattern frame is w*h*4 opaque-black RGBA bytes"
807 );
808 }
809
810 #[test]
811 fn worker_with_a_foreign_init_falls_back_to_640x480() {
812 let Some(sent) = run_worker(RefAny::new("not a CameraThreadInit")) else {
813 return;
814 };
815
816 assert_eq!(sent.len(), 1);
817 let (w, h, bytes, _) = sent[0];
818 assert_eq!((w, h), (640, 480), "a bad init must not panic - it defaults");
819 assert_eq!(bytes, 640 * 480 * 4);
820 }
821
822 #[test]
823 fn worker_with_zero_dims_sends_an_empty_frame_instead_of_hanging() {
824 let Some(sent) = run_worker(RefAny::new(CameraThreadInit {
827 width: 0,
828 height: 0,
829 })) else {
830 return;
831 };
832
833 assert_eq!(sent.len(), 1);
834 assert_eq!(sent[0], (0, 0, 0, true));
835 }
836
837 #[test]
842 fn writeback_invokes_the_hook_with_the_frame_and_returns_its_update() {
843 let mut log = RefAny::new(FrameLog { seen: Vec::new() });
844 let mut data = state_with_hook(cfg(2, 2), &log);
845 let frame_data = RefAny::new(frame(2, 2));
846
847 let (update, _) =
848 with_callback_info(|info| camera_writeback(data.clone(), frame_data.clone(), info));
849
850 assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
851 assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
852 let (_, _, texture, _) = read_state(&mut data);
853 assert_eq!(
854 texture, None,
855 "without a GL context no texture id is ever installed"
856 );
857 }
858
859 #[test]
860 fn writeback_ignores_frame_data_of_the_wrong_type() {
861 let mut log = RefAny::new(FrameLog { seen: Vec::new() });
862 let mut data = state_with_hook(cfg(2, 2), &log);
863
864 let (update, changes) = with_callback_info(|info| {
865 camera_writeback(data.clone(), RefAny::new(0_u32), info)
866 });
867
868 assert_eq!(update, Update::DoNothing);
869 assert!(changes.is_empty(), "no frame -> no image change");
870 assert!(
871 logged_frames(&mut log).is_empty(),
872 "the user hook must not fire without a frame"
873 );
874 }
875
876 #[test]
877 fn writeback_survives_a_writeback_dataset_that_is_not_a_camera_state() {
878 let (update, _) = with_callback_info(|info| {
879 camera_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
880 });
881
882 assert_eq!(
883 update,
884 Update::DoNothing,
885 "a foreign dataset means no hook and no texture - but no panic either"
886 );
887 }
888
889 #[test]
890 fn writeback_keeps_a_preexisting_texture_id_on_the_cpu_path() {
891 let mut data = state(cfg(2, 2), true, Some(42));
892 let frame_data = RefAny::new(frame(2, 2));
893
894 let (update, _) =
895 with_callback_info(|info| camera_writeback(data.clone(), frame_data.clone(), info));
896
897 assert_eq!(update, Update::DoNothing, "no hook -> no user update");
898 let (_, _, texture, _) = read_state(&mut data);
899 assert_eq!(texture, Some(42), "the texture id must stay stable");
900 }
901
902 #[test]
903 fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
904 let mut data = state(cfg(2, 2), true, None);
907 let bogus = RefAny::new(VideoFrame {
908 width: u32::MAX,
909 height: 1,
910 bytes: Vec::<u8>::new().into(),
911 });
912
913 let (update, changes) =
914 with_callback_info(|info| camera_writeback(data.clone(), bogus.clone(), info));
915
916 assert_eq!(update, Update::DoNothing);
917 assert!(changes.is_empty(), "a rejected frame must not touch the DOM");
918 let (_, _, texture, _) = read_state(&mut data);
919 assert_eq!(texture, None);
920 }
921
922 #[test]
927 fn merge_takes_the_thread_state_from_old_and_everything_else_from_new() {
928 let log = RefAny::new(FrameLog { seen: Vec::new() });
929 let new_data = state_with_hook(cfg(1920, 1080), &log);
930 let old_data = state(cfg(320, 240), true, Some(9));
931
932 let mut merged = merge_camera_state(new_data, old_data);
933 let (config, started, texture, has_hook) = read_state(&mut merged);
934
935 assert_eq!(config, cfg(1920, 1080), "the fresh build's config wins");
936 assert!(has_hook, "the fresh build's hook wins");
937 assert!(started, "'thread already running' must carry forward");
938 assert_eq!(texture, Some(9), "the stable texture id must carry forward");
939 }
940
941 #[test]
942 fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
943 let new_data = state(cfg(640, 480), false, None);
944 let mut merged = merge_camera_state(new_data, RefAny::new(0_u32));
945
946 let (config, started, texture, _) = read_state(&mut merged);
947 assert_eq!(config, cfg(640, 480));
948 assert!(!started, "nothing to carry forward from a foreign payload");
949 assert_eq!(texture, None);
950 }
951
952 #[test]
953 fn merge_returns_a_foreign_new_dataset_untouched() {
954 let old_data = state(cfg(640, 480), true, Some(1));
955 let mut merged = merge_camera_state(RefAny::new(77_u32), old_data);
956
957 assert_eq!(
958 merged.downcast_ref::<u32>().map(|v| *v),
959 Some(77),
960 "merge must hand back exactly the payload it was given"
961 );
962 }
963
964 #[test]
965 fn merge_of_a_dataset_with_itself_does_not_panic() {
966 let mut data = state(cfg(800, 600), true, Some(5));
969 let mut merged = merge_camera_state(data.clone(), data.clone());
970
971 let (config, started, texture, _) = read_state(&mut merged);
972 assert_eq!(config, cfg(800, 600));
973 assert!(started);
974 assert_eq!(texture, Some(5));
975 assert_eq!(read_state(&mut data), (cfg(800, 600), true, Some(5), false));
976 }
977}