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

            
18
use alloc::vec::Vec;
19

            
20
use azul_core::callbacks::Update;
21
use azul_core::camera::CameraConfig;
22
use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
23
use azul_core::refany::{OptionRefAny, RefAny};
24
use azul_core::resources::{ImageRef, RawImageFormat};
25
use azul_core::task::{ThreadId, ThreadReceiver};
26

            
27
use azul_core::video::VideoFrame;
28

            
29
use super::capture_common::{
30
    camera_backend, invoke_on_frame, present_frame, terminate_requested, OnVideoFrame,
31
    OnVideoFrameCallback, OptionOnVideoFrame,
32
};
33
use crate::callbacks::{Callback, CallbackInfo, CallbackType};
34
use crate::thread::{
35
    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
36
};
37

            
38
/// Init data handed to the capture worker thread.
39
struct 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)]
47
pub 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)]
63
pub 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

            
70
impl CameraWidget {
71
    /// Create a camera widget for the given capture config.
72
16
    #[must_use] pub const fn create(config: CameraConfig) -> Self {
73
16
        Self {
74
16
            config,
75
16
            on_frame: OptionOnVideoFrame::None,
76
16
        }
77
16
    }
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
4
    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
83
4
        self.on_frame = Some(OnVideoFrame {
84
4
            refany: data,
85
4
            callback: on_frame.into(),
86
4
        })
87
4
        .into();
88
4
    }
89

            
90
    /// Builder form of [`set_on_frame`](Self::set_on_frame).
91
    #[must_use]
92
2
    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
93
2
        mut self,
94
2
        data: RefAny,
95
2
        on_frame: C,
96
2
    ) -> Self {
97
2
        self.set_on_frame(data, on_frame);
98
2
        self
99
2
    }
100

            
101
    /// Build the widget's DOM: a single `<img>` node, fed by a background
102
    /// capture thread started on mount.
103
5
    #[must_use] pub fn dom(self) -> Dom {
104
5
        let state = CameraWidgetState {
105
5
            config: self.config,
106
5
            started: false,
107
5
            gl_texture_id: None,
108
5
            on_frame: self.on_frame,
109
5
        };
110
5
        let dataset = RefAny::new(state);
111

            
112
5
        let (w, h) = frame_dims(&self.config);
113
5
        let placeholder = ImageRef::null_image(
114
5
            w as usize,
115
5
            h as usize,
116
5
            RawImageFormat::BGRA8,
117
5
            b"azul-camera-placeholder".to_vec(),
118
        );
119

            
120
5
        Dom::create_image(placeholder)
121
5
            .with_dataset(OptionRefAny::Some(dataset.clone()))
122
5
            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_camera_state))
123
5
            .with_callback(
124
5
                EventFilter::Component(ComponentEventFilter::AfterMount),
125
5
                dataset,
126
5
                Callback::from_ptr(camera_on_after_mount),
127
            )
128
5
    }
129
}
130

            
131
/// Frame dimensions for a config (0 -> a sane default).
132
21
const fn frame_dims(config: &CameraConfig) -> (u32, u32) {
133
21
    let w = if config.width > 0 { config.width } else { 640 };
134
21
    let h = if config.height > 0 { config.height } else { 480 };
135
21
    (w, h)
136
21
}
137

            
138
/// `AfterMount`: start the background capture thread exactly once.
139
2
extern "C" fn camera_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
140
    let dims = {
141
2
        let Some(mut s) = data.downcast_mut::<CameraWidgetState>() else {
142
1
            return Update::DoNothing;
143
        };
144
1
        if s.started {
145
1
            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
2
}
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
168
4
extern "C" fn camera_worker(
169
4
    mut init: RefAny,
170
4
    mut sender: ThreadSender,
171
4
    mut recv: ThreadReceiver,
172
4
) {
173
4
    let (w, h) = init
174
4
        .downcast_ref::<CameraThreadInit>()
175
4
        .map_or((640, 480), |i| (i.width, i.height));
176

            
177
    // Real platform capture if the dll registered a camera backend (v4l2 /
178
    // AVFoundation / Media Foundation); otherwise the colour-cycle test pattern.
179
4
    if let Some(backend) = camera_backend() {
180
        let handle = (backend.open)(0, w, h);
181
        if handle != 0 {
182
            let mut buf: Vec<u8> = Vec::new();
183
            loop {
184
                // Ask before every device read, so `TerminateThread` costs at
185
                // most ONE read (bounded at ~960ms in the AVFoundation/v4l2
186
                // backends) rather than never being seen at all — see
187
                // `capture_common::terminate_requested`.
188
                if terminate_requested(&mut recv) {
189
                    break;
190
                }
191
                let (fw, fh) = (backend.read)(handle, &mut buf);
192
                if fw == 0 || fh == 0 {
193
                    break;
194
                }
195
                let frame = VideoFrame {
196
                    width: fw,
197
                    height: fh,
198
                    bytes: buf.clone().into(),
199
                };
200
                if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
201
                    WriteBackCallback::new(camera_writeback),
202
                    RefAny::new(frame),
203
                ))) {
204
                    break;
205
                }
206
            }
207
            (backend.close)(handle);
208
            return;
209
        }
210
4
    }
211

            
212
    // Reaching here means a CameraWidget is on screen and about to show the
213
    // colour-cycle TEST PATTERN instead of the camera — say why, once. The
214
    // dll-side [camera]/[dlopen] lines (if any) carry the detailed cause.
215
    {
216
        static TEST_PATTERN_ANNOUNCE: std::sync::Once = std::sync::Once::new();
217
4
        let have_backend = camera_backend().is_some();
218
4
        TEST_PATTERN_ANNOUNCE.call_once(|| {
219
1
            if have_backend {
220
                eprintln!(
221
                    "[azul][camera] the platform camera backend failed to open (device \
222
                     missing/busy, no permission, or libv4l2 unavailable — see lines \
223
                     above) — showing the colour-cycle TEST PATTERN instead of the \
224
                     camera"
225
                );
226
1
            } else {
227
1
                eprintln!(
228
1
                    "[azul][camera] no camera backend is registered in this build/OS — \
229
1
                     showing the colour-cycle TEST PATTERN instead of the camera"
230
1
                );
231
1
            }
232
1
        });
233
    }
234

            
235
4
    let px = (w as usize) * (h as usize);
236
4
    let mut tick: u32 = 0;
237
    loop {
238
4
        if terminate_requested(&mut recv) {
239
1
            break;
240
3
        }
241
3
        let color = [
242
3
            (tick % 256) as u8,
243
3
            (tick.wrapping_mul(2) % 256) as u8,
244
3
            (tick.wrapping_mul(3) % 256) as u8,
245
3
            255u8,
246
3
        ];
247
3
        let mut bytes = Vec::with_capacity(px * 4);
248
307206
        for _ in 0..px {
249
307206
            bytes.extend_from_slice(&color);
250
307206
        }
251
3
        let frame = VideoFrame {
252
3
            width: w,
253
3
            height: h,
254
3
            bytes: bytes.into(),
255
3
        };
256
3
        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
257
3
            WriteBackCallback::new(camera_writeback),
258
3
            RefAny::new(frame),
259
3
        )));
260
3
        if !sent {
261
3
            break;
262
        }
263
        std::thread::sleep(std::time::Duration::from_millis(33));
264
        tick = tick.wrapping_add(8);
265
    }
266
4
}
267

            
268
/// Writeback (main thread): hand the frame to the shared GL presenter and
269
/// store the (stable) texture id back in the widget's state.
270
5
extern "C" fn camera_writeback(
271
5
    mut writeback_data: RefAny,
272
5
    mut frame_data: RefAny,
273
5
    mut info: CallbackInfo,
274
5
) -> Update {
275
5
    let (current, hook) = writeback_data.downcast_ref::<CameraWidgetState>().map_or_else(|| (None, OptionOnVideoFrame::None), |s| (s.gl_texture_id, s.on_frame.clone()));
276
5
    let mut user_update = Update::DoNothing;
277
5
    let new_id = match frame_data.downcast_ref::<VideoFrame>() {
278
4
        Some(frame) => {
279
4
            let id = present_frame(&mut info, writeback_data.clone(), current, &frame);
280
4
            user_update = invoke_on_frame(&hook, &mut info, &frame);
281
4
            id
282
        }
283
1
        None => return Update::DoNothing,
284
    };
285
4
    if let Some(mut s) = writeback_data.downcast_mut::<CameraWidgetState>() {
286
3
        s.gl_texture_id = new_id;
287
3
    }
288
4
    user_update
289
5
}
290

            
291
/// Carry live state forward across relayout (config from the fresh build,
292
/// thread / texture from the previous frame).
293
4
extern "C" fn merge_camera_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
294
    // Return the OLD allocation (the one live capture backends may hold a
295
    // clone of), adopting config forward — the merge_map_tile_cache rule.
296
    // Returning new_data re-points the DOM at a fresh allocation; today the
297
    // frame writeback survives that only because present_frame finds its
298
    // node by RefAny TYPE id, which also means two widgets of the same type
299
    // collide. Keeping the persistent allocation makes dataset identity
300
    // stable so that search can become an identity lookup.
301
4
    let merged_into_old = {
302
4
        let new_guard = new_data.downcast_ref::<CameraWidgetState>();
303
4
        let old_guard = old_data.downcast_mut::<CameraWidgetState>();
304
4
        if let (Some(new_g), Some(mut old_g)) = (new_guard, old_guard) {
305
1
            old_g.config = new_g.config;
306
1
            old_g.on_frame = new_g.on_frame.clone();
307
1
            true
308
        } else {
309
            // Foreign / mismatched payloads (one side is not this widget's
310
            // state): hand back the NEW payload untouched — there is no
311
            // persistent allocation to preserve, and returning a
312
            // wrong-typed old dataset would poison the node.
313
3
            false
314
        }
315
    };
316
4
    if merged_into_old {
317
1
        old_data
318
    } else {
319
3
        new_data
320
    }
321
4
}
322

            
323
// ============================================================================
324
// Generated adversarial tests
325
// ============================================================================
326

            
327
#[cfg(test)]
328
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
329
mod autotest_generated {
330
    use std::{
331
        collections::BTreeMap,
332
        sync::{
333
            mpsc::{channel, Receiver, Sender},
334
            Arc, Mutex,
335
        },
336
    };
337

            
338
    use azul_core::{
339
        camera::CameraFacing,
340
        dom::{DomId, DomNodeId, NodeType},
341
        geom::OptionLogicalPosition,
342
        gl::OptionGlContextPtr,
343
        hit_test::ScrollPosition,
344
        resources::{DecodedImage, RendererResources},
345
        styled_dom::NodeHierarchyItemId,
346
        task::{
347
            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
348
            ThreadRecvCallback, ThreadSendMsg,
349
        },
350
        window::{MonitorVec, RawWindowHandle},
351
    };
352
    use azul_css::system::SystemStyle;
353
    use rust_fontconfig::FcFontCache;
354

            
355
    use super::*;
356
    #[cfg(feature = "icu")]
357
    use crate::icu::IcuLocalizerHandle;
358
    use crate::{
359
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
360
        thread::{ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner},
361
        widgets::capture_common::OnVideoFrameCallbackType,
362
        window::LayoutWindow,
363
        window_state::FullWindowState,
364
    };
365

            
366
    // ------------------------------------------------------------------
367
    // Helpers
368
    // ------------------------------------------------------------------
369

            
370
    const ALL_FACINGS: [CameraFacing; 3] = [
371
        CameraFacing::Front,
372
        CameraFacing::Back,
373
        CameraFacing::External,
374
    ];
375

            
376
    /// A config with explicit dimensions (everything else fixed).
377
    fn cfg(width: u32, height: u32) -> CameraConfig {
378
        CameraConfig {
379
            facing: CameraFacing::Front,
380
            width,
381
            height,
382
            fps: 30,
383
            output_format: RawImageFormat::BGRA8,
384
        }
385
    }
386

            
387
    /// A `CameraWidgetState` payload with no `on_frame` hook.
388
    fn state(config: CameraConfig, started: bool, gl_texture_id: Option<u32>) -> RefAny {
389
        RefAny::new(CameraWidgetState {
390
            config,
391
            started,
392
            gl_texture_id,
393
            on_frame: OptionOnVideoFrame::None,
394
        })
395
    }
396

            
397
    /// `(config, started, gl_texture_id, has_hook)` of a `CameraWidgetState` payload.
398
    fn read_state(data: &mut RefAny) -> (CameraConfig, bool, Option<u32>, bool) {
399
        let s = data
400
            .downcast_ref::<CameraWidgetState>()
401
            .expect("payload must still be a CameraWidgetState");
402
        (
403
            s.config,
404
            s.started,
405
            s.gl_texture_id,
406
            matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
407
        )
408
    }
409

            
410
    /// The placeholder image behind an `<img>` `Dom` root: `(width, height, format, tag)`.
411
    fn placeholder_of(dom: &Dom) -> (usize, usize, RawImageFormat, Vec<u8>) {
412
        let NodeType::Image(image) = dom.root.get_node_type() else {
413
            panic!("CameraWidget::dom must build an image node");
414
        };
415
        match image.get_data() {
416
            DecodedImage::NullImage {
417
                width,
418
                height,
419
                format,
420
                tag,
421
            } => (*width, *height, *format, tag.clone()),
422
            _ => panic!("the placeholder must be a NullImage (no decode, no allocation)"),
423
        }
424
    }
425

            
426
    // ---- frame hook -------------------------------------------------------
427

            
428
    /// Records every frame a widget's `on_frame` hook is handed.
429
    struct FrameLog {
430
        seen: Vec<(u32, u32, usize)>,
431
    }
432

            
433
    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
434
        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
435
            log.seen.push((frame.width, frame.height, frame.bytes.as_ref().len()));
436
        }
437
        Update::RefreshDom
438
    }
439

            
440
    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
441
        Update::DoNothing
442
    }
443

            
444
    /// The frames recorded by a `FrameLog` payload.
445
    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
446
        data.downcast_ref::<FrameLog>()
447
            .expect("payload must still be a FrameLog")
448
            .seen
449
            .clone()
450
    }
451

            
452
    /// A `CameraWidgetState` whose `on_frame` hook writes into `log`.
453
    fn state_with_hook(config: CameraConfig, log: &RefAny) -> RefAny {
454
        RefAny::new(CameraWidgetState {
455
            config,
456
            started: true,
457
            gl_texture_id: None,
458
            on_frame: Some(OnVideoFrame {
459
                refany: log.clone(),
460
                callback: (record_frame as OnVideoFrameCallbackType).into(),
461
            })
462
            .into(),
463
        })
464
    }
465

            
466
    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
467
    fn frame(width: u32, height: u32) -> VideoFrame {
468
        let px = (width as usize) * (height as usize);
469
        VideoFrame {
470
            width,
471
            height,
472
            bytes: vec![7u8; px * 4].into(),
473
        }
474
    }
475

            
476
    // ---- CallbackInfo harness --------------------------------------------
477

            
478
    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
479
    /// context -> the widgets' CPU present path). Returns `f`'s value plus every
480
    /// `CallbackChange` the callback recorded.
481
    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
482
        let layout_window =
483
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
484
        let renderer_resources = RendererResources::default();
485
        let previous_window_state: Option<FullWindowState> = None;
486
        let current_window_state = FullWindowState::default();
487
        let gl_context = OptionGlContextPtr::None;
488
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
489
            BTreeMap::new();
490
        let window_handle = RawWindowHandle::Unsupported;
491
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
492

            
493
        let ref_data = CallbackInfoRefData {
494
            layout_window: &layout_window,
495
            renderer_resources: &renderer_resources,
496
            previous_window_state: &previous_window_state,
497
            current_window_state: &current_window_state,
498
            gl_context: &gl_context,
499
            current_scroll_manager: &scroll_states,
500
            current_window_handle: &window_handle,
501
            system_callbacks: &system_callbacks,
502
            system_style: Arc::new(SystemStyle::default()),
503
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
504
            #[cfg(feature = "icu")]
505
            icu_localizer: IcuLocalizerHandle::default(),
506
            ctx: OptionRefAny::None,
507
        };
508

            
509
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
510

            
511
        let info = CallbackInfo::new(
512
            &ref_data,
513
            &changes,
514
            DomNodeId {
515
                dom: DomId::ROOT_ID,
516
                node: NodeHierarchyItemId::NONE,
517
            },
518
            OptionLogicalPosition::None,
519
            OptionLogicalPosition::None,
520
        );
521

            
522
        let out = f(info);
523
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
524
        (out, recorded)
525
    }
526

            
527
    // ---- camera_worker harness -------------------------------------------
528

            
529
    /// Every frame `camera_worker` pushed: `(width, height, bytes, all pixels are the
530
    /// tick-0 colour)`. Guarded by `WORKER_GATE` - the worker's send callback is a
531
    /// plain C fn pointer, so it has nowhere else to put its result.
532
    static WORKER_LOG: Mutex<Vec<(u32, u32, usize, bool)>> = Mutex::new(Vec::new());
533
    static WORKER_GATE: Mutex<()> = Mutex::new(());
534

            
535
    /// Records the frame, then reports the send as *failed* - i.e. "the main thread is
536
    /// gone", the only signal `camera_worker` has to stop. A worker that ignores it
537
    /// would hang this test forever.
538
    extern "C" fn record_and_stop(_sender: *const core::ffi::c_void, msg: ThreadReceiveMsg) -> bool {
539
        if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
540
            if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
541
                let bytes = f.bytes.as_ref();
542
                let tick0 = bytes
543
                    .chunks_exact(4)
544
                    .all(|px| px == &[0u8, 0, 0, 255][..]);
545
                WORKER_LOG
546
                    .lock()
547
                    .unwrap_or_else(std::sync::PoisonError::into_inner)
548
                    .push((f.width, f.height, bytes.len(), tick0));
549
            }
550
        }
551
        false
552
    }
553

            
554
    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
555
    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
556
    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
557
        OptionThreadSendMsg::None
558
    }
559

            
560
    /// A `ThreadSender` whose every `send` is recorded and then rejected.
561
    fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
562
        let (tx, rx) = channel::<ThreadReceiveMsg>();
563
        let sender = ThreadSender::new(ThreadSenderInner {
564
            ptr: Box::new(tx),
565
            send_fn: ThreadSendCallback { cb: record_and_stop },
566
            destructor: ThreadSenderDestructorCallback {
567
                cb: sender_drop_noop,
568
            },
569
        });
570
        (rx, sender)
571
    }
572

            
573
    /// A `ThreadReceiver` that never delivers anything (`camera_worker` ignores it).
574
    fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
575
        let (tx, rx) = channel::<ThreadSendMsg>();
576
        let receiver = ThreadReceiver::new(ThreadReceiverInner {
577
            ptr: Box::new(rx),
578
            recv_fn: ThreadRecvCallback { cb: recv_nothing },
579
            destructor: ThreadReceiverDestructorCallback {
580
                cb: receiver_drop_noop,
581
            },
582
        });
583
        (tx, receiver)
584
    }
585

            
586
    /// Runs `camera_worker` with `init` against a sender that rejects the first frame,
587
    /// and returns everything the worker managed to send. `None` when a real platform
588
    /// backend is registered in this process (then the worker is not the test pattern
589
    /// these assertions describe).
590
    fn run_worker(init: RefAny) -> Option<Vec<(u32, u32, usize, bool)>> {
591
        let _gate = WORKER_GATE
592
            .lock()
593
            .unwrap_or_else(std::sync::PoisonError::into_inner);
594
        if camera_backend().is_some() {
595
            return None;
596
        }
597
        WORKER_LOG
598
            .lock()
599
            .unwrap_or_else(std::sync::PoisonError::into_inner)
600
            .clear();
601

            
602
        let (_rx, sender) = stopped_sender();
603
        let (_tx, receiver) = silent_receiver();
604
        camera_worker(init, sender, receiver);
605

            
606
        let sent = WORKER_LOG
607
            .lock()
608
            .unwrap_or_else(std::sync::PoisonError::into_inner)
609
            .clone();
610
        Some(sent)
611
    }
612

            
613
    // ------------------------------------------------------------------
614
    // frame_dims  (numeric / boundary)
615
    // ------------------------------------------------------------------
616

            
617
    #[test]
618
    fn frame_dims_substitutes_the_default_for_a_zero_dimension() {
619
        assert_eq!(frame_dims(&cfg(0, 0)), (640, 480));
620
        assert_eq!(frame_dims(&cfg(0, 720)), (640, 720), "only width defaults");
621
        assert_eq!(frame_dims(&cfg(1280, 0)), (1280, 480), "only height defaults");
622
        assert_eq!(frame_dims(&CameraConfig::default()), (640, 480));
623
    }
624

            
625
    #[test]
626
    fn frame_dims_passes_nonzero_dimensions_through_unclamped() {
627
        assert_eq!(frame_dims(&cfg(1, 1)), (1, 1), "1px is not 'unset'");
628
        assert_eq!(frame_dims(&cfg(u32::MAX, u32::MAX)), (u32::MAX, u32::MAX));
629
        assert_eq!(frame_dims(&cfg(u32::MAX, 0)), (u32::MAX, 480));
630
    }
631

            
632
    #[test]
633
    fn frame_dims_ignores_facing_fps_and_format() {
634
        for facing in ALL_FACINGS {
635
            for fps in [0, 1, u32::MAX] {
636
                let config = CameraConfig {
637
                    facing,
638
                    width: 0,
639
                    height: 0,
640
                    fps,
641
                    output_format: RawImageFormat::R8,
642
                };
643
                assert_eq!(frame_dims(&config), (640, 480));
644
            }
645
        }
646
    }
647

            
648
    #[test]
649
    fn frame_dims_is_usable_in_const_context() {
650
        const CONFIG: CameraConfig = CameraConfig {
651
            facing: CameraFacing::Back,
652
            width: 0,
653
            height: 4096,
654
            fps: 0,
655
            output_format: RawImageFormat::BGRA8,
656
        };
657
        const DIMS: (u32, u32) = frame_dims(&CONFIG);
658
        assert_eq!(DIMS, (640, 4096));
659
    }
660

            
661
    // ------------------------------------------------------------------
662
    // CameraWidget::create / set_on_frame / with_on_frame
663
    // ------------------------------------------------------------------
664

            
665
    #[test]
666
    fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
667
        for facing in ALL_FACINGS {
668
            for (w, h, fps) in [(0, 0, 0), (1, 1, 1), (u32::MAX, u32::MAX, u32::MAX)] {
669
                let config = CameraConfig {
670
                    facing,
671
                    width: w,
672
                    height: h,
673
                    fps,
674
                    output_format: RawImageFormat::RGBA8,
675
                };
676
                let widget = CameraWidget::create(config);
677
                assert_eq!(widget.config, config, "create must not normalise the config");
678
                assert!(
679
                    matches!(widget.on_frame, OptionOnVideoFrame::None),
680
                    "a fresh widget has no frame hook"
681
                );
682
            }
683
        }
684
    }
685

            
686
    #[test]
687
    fn with_on_frame_installs_the_hook_and_keeps_the_config() {
688
        let config = cfg(320, 240);
689
        let widget = CameraWidget::create(config).with_on_frame(
690
            RefAny::new(FrameLog { seen: Vec::new() }),
691
            record_frame as OnVideoFrameCallbackType,
692
        );
693

            
694
        assert_eq!(widget.config, config, "the builder must not touch the config");
695
        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
696
            panic!("with_on_frame must install a hook");
697
        };
698
        assert_eq!(
699
            hook.callback.cb as usize,
700
            record_frame as OnVideoFrameCallbackType as usize
701
        );
702
    }
703

            
704
    #[test]
705
    fn set_on_frame_twice_keeps_only_the_last_hook() {
706
        let mut widget = CameraWidget::create(cfg(2, 2));
707
        widget.set_on_frame(
708
            RefAny::new(0_usize),
709
            record_frame as OnVideoFrameCallbackType,
710
        );
711
        widget.set_on_frame(
712
            RefAny::new(1_usize),
713
            frame_do_nothing as OnVideoFrameCallbackType,
714
        );
715

            
716
        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
717
            panic!("hook must still be set");
718
        };
719
        assert_eq!(
720
            hook.callback.cb as usize,
721
            frame_do_nothing as OnVideoFrameCallbackType as usize,
722
            "the second set_on_frame must replace the first"
723
        );
724
    }
725

            
726
    // ------------------------------------------------------------------
727
    // CameraWidget::dom
728
    // ------------------------------------------------------------------
729

            
730
    #[test]
731
    fn dom_placeholder_uses_the_defaulted_dims_and_is_always_bgra8() {
732
        let (w, h, format, tag) = placeholder_of(&CameraWidget::create(cfg(0, 0)).dom());
733
        assert_eq!((w, h), (640, 480), "a 0-sized config falls back to 640x480");
734
        assert_eq!(format, RawImageFormat::BGRA8);
735
        assert_eq!(tag, b"azul-camera-placeholder".to_vec());
736

            
737
        // The requested output format is a *capture* request - the placeholder is
738
        // BGRA8 regardless.
739
        let config = CameraConfig {
740
            output_format: RawImageFormat::R8,
741
            ..cfg(320, 240)
742
        };
743
        let (w, h, format, _) = placeholder_of(&CameraWidget::create(config).dom());
744
        assert_eq!((w, h), (320, 240));
745
        assert_eq!(format, RawImageFormat::BGRA8);
746
    }
747

            
748
    #[test]
749
    fn dom_with_extreme_dims_builds_a_null_image_without_allocating() {
750
        // u32::MAX x u32::MAX pixels is ~7e19 bytes - a NullImage reserves no memory,
751
        // so this must stay a cheap, panic-free descriptor.
752
        let (w, h, format, _) = placeholder_of(&CameraWidget::create(cfg(u32::MAX, u32::MAX)).dom());
753
        assert_eq!((w, h), (u32::MAX as usize, u32::MAX as usize));
754
        assert_eq!(format, RawImageFormat::BGRA8);
755
    }
756

            
757
    #[test]
758
    fn dom_wires_exactly_one_after_mount_callback_a_dataset_and_a_merge_callback() {
759
        let dom = CameraWidget::create(cfg(64, 48)).dom();
760

            
761
        assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
762

            
763
        let callbacks = dom.root.get_callbacks();
764
        assert_eq!(
765
            callbacks.as_ref().len(),
766
            1,
767
            "exactly one callback: the AfterMount capture-thread starter"
768
        );
769
        assert_eq!(
770
            callbacks.as_ref()[0].event,
771
            EventFilter::Component(ComponentEventFilter::AfterMount)
772
        );
773
        assert!(
774
            dom.root.get_merge_callback().is_some(),
775
            "state must survive relayout"
776
        );
777

            
778
        let mut dataset = dom
779
            .root
780
            .get_dataset()
781
            .cloned()
782
            .expect("the node must carry its CameraWidgetState");
783
        let (config, started, texture, has_hook) = read_state(&mut dataset);
784
        assert_eq!(config, cfg(64, 48));
785
        assert!(!started, "the thread only starts on AfterMount");
786
        assert_eq!(texture, None);
787
        assert!(!has_hook);
788
    }
789

            
790
    #[test]
791
    fn dom_moves_the_on_frame_hook_into_the_dataset() {
792
        let dom = CameraWidget::create(cfg(8, 8))
793
            .with_on_frame(
794
                RefAny::new(FrameLog { seen: Vec::new() }),
795
                record_frame as OnVideoFrameCallbackType,
796
            )
797
            .dom();
798

            
799
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
800
        let (_, _, _, has_hook) = read_state(&mut dataset);
801
        assert!(has_hook, "dom() must carry the user hook into the state");
802
    }
803

            
804
    // ------------------------------------------------------------------
805
    // camera_on_after_mount
806
    //
807
    // NOTE: the *first* mount is deliberately not exercised - it spawns a real
808
    // capture thread whose `Thread` destructor joins a worker that never reads its
809
    // receiver, which would hang the test binary (see the report). Only the guard
810
    // paths below can be driven safely.
811
    // ------------------------------------------------------------------
812

            
813
    #[test]
814
    fn after_mount_ignores_a_dataset_that_is_not_a_camera_state() {
815
        let (update, changes) =
816
            with_callback_info(|info| camera_on_after_mount(RefAny::new(0_u32), info));
817

            
818
        assert_eq!(update, Update::DoNothing);
819
        assert!(
820
            changes.is_empty(),
821
            "a foreign dataset must not start a capture thread"
822
        );
823
    }
824

            
825
    #[test]
826
    fn after_mount_is_a_no_op_once_the_thread_has_started() {
827
        let mut data = state(cfg(0, 0), true, Some(3));
828
        let (update, changes) = with_callback_info(|info| camera_on_after_mount(data.clone(), info));
829

            
830
        assert_eq!(update, Update::DoNothing);
831
        assert!(
832
            changes.is_empty(),
833
            "AfterMount must start the capture thread at most once"
834
        );
835
        let (_, started, texture, _) = read_state(&mut data);
836
        assert!(started);
837
        assert_eq!(texture, Some(3), "a re-mount must not drop the texture");
838
    }
839

            
840
    // ------------------------------------------------------------------
841
    // camera_worker
842
    // ------------------------------------------------------------------
843

            
844
    #[test]
845
    fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
846
        let Some(sent) = run_worker(RefAny::new(CameraThreadInit {
847
            width: 2,
848
            height: 3,
849
        })) else {
850
            return; // a platform backend is registered: not the test pattern
851
        };
852

            
853
        assert_eq!(
854
            sent.len(),
855
            1,
856
            "the worker must stop after the first rejected send, not spin"
857
        );
858
        assert_eq!(
859
            sent[0],
860
            (2, 3, 2 * 3 * 4, true),
861
            "the first test-pattern frame is w*h*4 opaque-black RGBA bytes"
862
        );
863
    }
864

            
865
    #[test]
866
    fn worker_with_a_foreign_init_falls_back_to_640x480() {
867
        let Some(sent) = run_worker(RefAny::new("not a CameraThreadInit")) else {
868
            return;
869
        };
870

            
871
        assert_eq!(sent.len(), 1);
872
        let (w, h, bytes, _) = sent[0];
873
        assert_eq!((w, h), (640, 480), "a bad init must not panic - it defaults");
874
        assert_eq!(bytes, 640 * 480 * 4);
875
    }
876

            
877
    #[test]
878
    fn worker_with_zero_dims_sends_an_empty_frame_instead_of_hanging() {
879
        // camera_on_after_mount always routes through frame_dims, but the worker itself
880
        // does not - a 0x0 init must still terminate and emit a well-formed empty frame.
881
        let Some(sent) = run_worker(RefAny::new(CameraThreadInit {
882
            width: 0,
883
            height: 0,
884
        })) else {
885
            return;
886
        };
887

            
888
        assert_eq!(sent.len(), 1);
889
        assert_eq!(sent[0], (0, 0, 0, true));
890
    }
891

            
892
    // ------------------------------------------------------------------
893
    // camera_writeback
894
    // ------------------------------------------------------------------
895

            
896
    #[test]
897
    fn writeback_invokes_the_hook_with_the_frame_and_returns_its_update() {
898
        let mut log = RefAny::new(FrameLog { seen: Vec::new() });
899
        let mut data = state_with_hook(cfg(2, 2), &log);
900
        let frame_data = RefAny::new(frame(2, 2));
901

            
902
        let (update, _) =
903
            with_callback_info(|info| camera_writeback(data.clone(), frame_data.clone(), info));
904

            
905
        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
906
        assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
907
        let (_, _, texture, _) = read_state(&mut data);
908
        assert_eq!(
909
            texture, None,
910
            "without a GL context no texture id is ever installed"
911
        );
912
    }
913

            
914
    #[test]
915
    fn writeback_ignores_frame_data_of_the_wrong_type() {
916
        let mut log = RefAny::new(FrameLog { seen: Vec::new() });
917
        let mut data = state_with_hook(cfg(2, 2), &log);
918

            
919
        let (update, changes) = with_callback_info(|info| {
920
            camera_writeback(data.clone(), RefAny::new(0_u32), info)
921
        });
922

            
923
        assert_eq!(update, Update::DoNothing);
924
        assert!(changes.is_empty(), "no frame -> no image change");
925
        assert!(
926
            logged_frames(&mut log).is_empty(),
927
            "the user hook must not fire without a frame"
928
        );
929
    }
930

            
931
    #[test]
932
    fn writeback_survives_a_writeback_dataset_that_is_not_a_camera_state() {
933
        let (update, _) = with_callback_info(|info| {
934
            camera_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
935
        });
936

            
937
        assert_eq!(
938
            update,
939
            Update::DoNothing,
940
            "a foreign dataset means no hook and no texture - but no panic either"
941
        );
942
    }
943

            
944
    #[test]
945
    fn writeback_keeps_a_preexisting_texture_id_on_the_cpu_path() {
946
        let mut data = state(cfg(2, 2), true, Some(42));
947
        let frame_data = RefAny::new(frame(2, 2));
948

            
949
        let (update, _) =
950
            with_callback_info(|info| camera_writeback(data.clone(), frame_data.clone(), info));
951

            
952
        assert_eq!(update, Update::DoNothing, "no hook -> no user update");
953
        let (_, _, texture, _) = read_state(&mut data);
954
        assert_eq!(texture, Some(42), "the texture id must stay stable");
955
    }
956

            
957
    #[test]
958
    fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
959
        // A malformed/hostile frame (huge dims, no pixels): the image upload must fail
960
        // cleanly instead of indexing out of bounds or allocating.
961
        let mut data = state(cfg(2, 2), true, None);
962
        let bogus = RefAny::new(VideoFrame {
963
            width: u32::MAX,
964
            height: 1,
965
            bytes: Vec::<u8>::new().into(),
966
        });
967

            
968
        let (update, changes) =
969
            with_callback_info(|info| camera_writeback(data.clone(), bogus.clone(), info));
970

            
971
        assert_eq!(update, Update::DoNothing);
972
        assert!(changes.is_empty(), "a rejected frame must not touch the DOM");
973
        let (_, _, texture, _) = read_state(&mut data);
974
        assert_eq!(texture, None);
975
    }
976

            
977
    // ------------------------------------------------------------------
978
    // merge_camera_state
979
    // ------------------------------------------------------------------
980

            
981
    #[test]
982
    fn merge_takes_the_thread_state_from_old_and_everything_else_from_new() {
983
        let log = RefAny::new(FrameLog { seen: Vec::new() });
984
        let new_data = state_with_hook(cfg(1920, 1080), &log);
985
        let old_data = state(cfg(320, 240), true, Some(9));
986

            
987
        let mut merged = merge_camera_state(new_data, old_data);
988
        let (config, started, texture, has_hook) = read_state(&mut merged);
989

            
990
        assert_eq!(config, cfg(1920, 1080), "the fresh build's config wins");
991
        assert!(has_hook, "the fresh build's hook wins");
992
        assert!(started, "'thread already running' must carry forward");
993
        assert_eq!(texture, Some(9), "the stable texture id must carry forward");
994
    }
995

            
996
    #[test]
997
    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
998
        let new_data = state(cfg(640, 480), false, None);
999
        let mut merged = merge_camera_state(new_data, RefAny::new(0_u32));
        let (config, started, texture, _) = read_state(&mut merged);
        assert_eq!(config, cfg(640, 480));
        assert!(!started, "nothing to carry forward from a foreign payload");
        assert_eq!(texture, None);
    }
    #[test]
    fn merge_returns_a_foreign_new_dataset_untouched() {
        let old_data = state(cfg(640, 480), true, Some(1));
        let mut merged = merge_camera_state(RefAny::new(77_u32), old_data);
        assert_eq!(
            merged.downcast_ref::<u32>().map(|v| *v),
            Some(77),
            "merge must hand back exactly the payload it was given"
        );
    }
    #[test]
    fn merge_of_a_dataset_with_itself_does_not_panic() {
        // The same RefAny on both sides: the mutable + shared borrow overlap, so the
        // merge is skipped rather than aliasing. Either way the state must survive.
        let mut data = state(cfg(800, 600), true, Some(5));
        let mut merged = merge_camera_state(data.clone(), data.clone());
        let (config, started, texture, _) = read_state(&mut merged);
        assert_eq!(config, cfg(800, 600));
        assert!(started);
        assert_eq!(texture, Some(5));
        assert_eq!(read_state(&mut data), (cfg(800, 600), true, Some(5), false));
    }
    /// REGRESSION (B3): a capture worker must ACKNOWLEDGE `TerminateThread`.
    ///
    /// Reported from azul-meet on macOS after using camera + screenshare — the
    /// framework printed, twice (once per capture worker):
    ///
    /// ```text
    /// [azul][thread] a background thread did not acknowledge TerminateThread
    /// within 2000ms and was DETACHED rather than joined.
    /// ```
    ///
    /// Root cause: the worker took its receiver as `_recv` and never polled it,
    /// so the terminate message was never observed. The only exit was
    /// `sender.send()` returning false, which does NOT happen at shutdown
    /// because the main thread still owns the receiving end while it waits out
    /// the grace period. Fixed by `capture_common::terminate_requested`.
    ///
    /// The budget here is the framework's own:
    /// `THREAD_TERMINATE_GRACE_STEPS (200) * 10ms = 2000ms`.
    #[test]
    fn camera_worker_acknowledges_terminate_within_the_grace_budget() {
        use crate::thread::{Thread, ThreadCallback};
        let t = Thread::create(
            RefAny::new(CameraThreadInit {
                width: 8,
                height: 8,
            }),
            RefAny::new(0_usize),
            ThreadCallback::new(camera_worker),
        );
        assert!(
            t.send_message(ThreadSendMsg::TerminateThread),
            "the worker holds its receiver alive, so the send must succeed"
        );
        let finished = || {
            t.ptr
                .lock()
                .expect("thread mutex must not be poisoned")
                .is_finished()
        };
        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2_000);
        while !finished() && std::time::Instant::now() < deadline {
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        assert!(
            finished(),
            "camera_worker did not acknowledge TerminateThread within 2000ms — at shutdown it \
             would be DETACHED rather than joined"
        );
    }
}