Skip to main content

azul_layout/widgets/
camera.rs

1//! Camera-preview widget - a "dumb widget" (like [`MapWidget`](super::map))
2//! that owns a background capture thread + a GL-texture `ImageRef`, with **no**
3//! camera-specific logic in the core framework (SUPER_PLAN_2 ยง4 P6, widget
4//! pivot - see the MASTER PLAN in `MOBILE_SESSION_LOG.md`).
5//!
6//! `CameraWidget::create(config).dom()` -> a static `<img>` whose pixels a
7//! background thread keeps fed. On `AfterMount` the capture thread starts
8//! (`CallbackInfo::add_thread`); each frame goes through
9//! [`super::capture_common::present_frame`], which uploads it into a stable
10//! external GL texture + recomposites - no relayout, no display-list rebuild.
11//! The shared thread/writeback/GL core lives in `capture_common`; this widget
12//! is just its config + worker.
13//!
14//! This tick uses a self-contained **test-pattern** worker (colour cycle, no
15//! platform deps); the real AVFoundation/Camera2 worker (dll-side) swaps in
16//! later.
17
18use 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
38/// Init data handed to the capture worker thread.
39struct CameraThreadInit {
40    width: u32,
41    height: u32,
42}
43
44/// Live state for one camera widget, carried across relayout by
45/// [`merge_camera_state`].
46#[derive(Debug)]
47pub struct CameraWidgetState {
48    /// The requested capture configuration (the control POD).
49    pub config: CameraConfig,
50    /// `true` once the capture thread has been started.
51    pub started: bool,
52    /// The stable external GL texture id once the first frame installed it.
53    pub gl_texture_id: Option<u32>,
54    /// Optional user hook invoked with each captured frame (effects / save /
55    /// send). Re-set on every fresh build (see [`merge_camera_state`]).
56    pub on_frame: OptionOnVideoFrame,
57}
58
59/// A camera-preview widget. `create(config).dom()` yields an `<img>` the
60/// capture thread keeps fed.
61#[repr(C)]
62#[derive(Debug)]
63pub struct CameraWidget {
64    /// Requested capture config (camera facing, resolution, fps, format).
65    pub config: CameraConfig,
66    /// Optional per-frame user hook (effects / save / send - azul-meet).
67    pub on_frame: OptionOnVideoFrame,
68}
69
70impl CameraWidget {
71    /// Create a camera widget for the given capture config.
72    #[must_use] pub const fn create(config: CameraConfig) -> Self {
73        Self {
74            config,
75            on_frame: OptionOnVideoFrame::None,
76        }
77    }
78
79    /// Set a hook invoked with every captured frame - for live effects, saving
80    /// frames into your data model, or sending them over the network
81    /// (azul-meet). The backreference DI pattern (see `architecture.md`).
82    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    /// Builder form of [`set_on_frame`](Self::set_on_frame).
91    #[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    /// Build the widget's DOM: a single `<img>` node, fed by a background
102    /// capture thread started on mount.
103    #[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
131/// Frame dimensions for a config (0 -> a sane default).
132const 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
138/// `AfterMount`: start the background capture thread exactly once.
139extern "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/// Background worker (test pattern): a colour-cycling solid frame ~30x/s until
166/// the widget unmounts. The real AVFoundation/Camera2 capture loop replaces it.
167#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
168extern "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    // Real platform capture if the dll registered a camera backend (v4l2 /
174    // AVFoundation / Media Foundation); otherwise the colour-cycle test pattern.
175    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
231/// Writeback (main thread): hand the frame to the shared GL presenter and
232/// store the (stable) texture id back in the widget's state.
233extern "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
254/// Carry live state forward across relayout (config from the fresh build,
255/// thread / texture from the previous frame).
256extern "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// ============================================================================
269// Generated adversarial tests
270// ============================================================================
271
272#[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    // ------------------------------------------------------------------
312    // Helpers
313    // ------------------------------------------------------------------
314
315    const ALL_FACINGS: [CameraFacing; 3] = [
316        CameraFacing::Front,
317        CameraFacing::Back,
318        CameraFacing::External,
319    ];
320
321    /// A config with explicit dimensions (everything else fixed).
322    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    /// A `CameraWidgetState` payload with no `on_frame` hook.
333    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    /// `(config, started, gl_texture_id, has_hook)` of a `CameraWidgetState` payload.
343    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    /// The placeholder image behind an `<img>` `Dom` root: `(width, height, format, tag)`.
356    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    // ---- frame hook -------------------------------------------------------
372
373    /// Records every frame a widget's `on_frame` hook is handed.
374    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    /// The frames recorded by a `FrameLog` payload.
390    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    /// A `CameraWidgetState` whose `on_frame` hook writes into `log`.
398    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    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
412    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    // ---- CallbackInfo harness --------------------------------------------
422
423    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
424    /// context -> the widgets' CPU present path). Returns `f`'s value plus every
425    /// `CallbackChange` the callback recorded.
426    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: &current_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    // ---- camera_worker harness -------------------------------------------
473
474    /// Every frame `camera_worker` pushed: `(width, height, bytes, all pixels are the
475    /// tick-0 colour)`. Guarded by `WORKER_GATE` - the worker's send callback is a
476    /// plain C fn pointer, so it has nowhere else to put its result.
477    static WORKER_LOG: Mutex<Vec<(u32, u32, usize, bool)>> = Mutex::new(Vec::new());
478    static WORKER_GATE: Mutex<()> = Mutex::new(());
479
480    /// Records the frame, then reports the send as *failed* - i.e. "the main thread is
481    /// gone", the only signal `camera_worker` has to stop. A worker that ignores it
482    /// would hang this test forever.
483    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    /// A `ThreadSender` whose every `send` is recorded and then rejected.
506    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    /// A `ThreadReceiver` that never delivers anything (`camera_worker` ignores it).
519    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    /// Runs `camera_worker` with `init` against a sender that rejects the first frame,
532    /// and returns everything the worker managed to send. `None` when a real platform
533    /// backend is registered in this process (then the worker is not the test pattern
534    /// these assertions describe).
535    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    // ------------------------------------------------------------------
559    // frame_dims  (numeric / boundary)
560    // ------------------------------------------------------------------
561
562    #[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    // ------------------------------------------------------------------
607    // CameraWidget::create / set_on_frame / with_on_frame
608    // ------------------------------------------------------------------
609
610    #[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    // ------------------------------------------------------------------
672    // CameraWidget::dom
673    // ------------------------------------------------------------------
674
675    #[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        // The requested output format is a *capture* request - the placeholder is
683        // BGRA8 regardless.
684        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        // u32::MAX x u32::MAX pixels is ~7e19 bytes - a NullImage reserves no memory,
696        // so this must stay a cheap, panic-free descriptor.
697        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    // ------------------------------------------------------------------
750    // camera_on_after_mount
751    //
752    // NOTE: the *first* mount is deliberately not exercised - it spawns a real
753    // capture thread whose `Thread` destructor joins a worker that never reads its
754    // receiver, which would hang the test binary (see the report). Only the guard
755    // paths below can be driven safely.
756    // ------------------------------------------------------------------
757
758    #[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    // ------------------------------------------------------------------
786    // camera_worker
787    // ------------------------------------------------------------------
788
789    #[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; // a platform backend is registered: not the test pattern
796        };
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        // camera_on_after_mount always routes through frame_dims, but the worker itself
825        // does not - a 0x0 init must still terminate and emit a well-formed empty frame.
826        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    // ------------------------------------------------------------------
838    // camera_writeback
839    // ------------------------------------------------------------------
840
841    #[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        // A malformed/hostile frame (huge dims, no pixels): the image upload must fail
905        // cleanly instead of indexing out of bounds or allocating.
906        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    // ------------------------------------------------------------------
923    // merge_camera_state
924    // ------------------------------------------------------------------
925
926    #[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        // The same RefAny on both sides: the mutable + shared borrow overlap, so the
967        // merge is skipped rather than aliasing. Either way the state must survive.
968        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}