1
//! Shared core for the "video-ish" widgets (camera / screencap / video).
2
//!
3
//! All three are identical in architecture (RefAny dataset + AfterMount
4
//! background capture/decode thread + writeback that uploads each frame into a
5
//! stable external GL texture + recomposites). Only the *config* and the
6
//! *worker* differ. This module holds the duplicated pieces - the [`VideoFrame`]
7
//! the worker produces and [`present_frame`], the GL writeback core - so each
8
//! widget is a thin config+worker wrapper and there's a single place for GL
9
//! fixes + the real platform workers (AVFoundation / ScreenCaptureKit /
10
//! vk-video) to plug in.
11
//!
12
//! NOTE: GL code - compile-verified here; the actual texture rendering must be
13
//! verified on a machine with a window + GPU.
14

            
15
use azul_core::resources::UpdateImageType;
16
use azul_core::callbacks::Update;
17
use azul_core::gl::gl::{RGBA, TEXTURE_2D, UNSIGNED_BYTE};
18
use azul_core::gl::{GlContextPtr, OptionU8VecRef, U8VecRef};
19
use azul_core::geom::PhysicalSizeU32;
20
use azul_core::refany::RefAny;
21
use azul_core::resources::ImageRef;
22
use azul_core::video::VideoFrame;
23
use azul_css::impl_option_inner; // brought into scope for impl_widget_callback!'s impl_option!
24
use azul_css::props::basic::ColorU;
25

            
26
use crate::callbacks::CallbackInfo;
27

            
28
/// User hook fired once per captured/decoded frame - the backreference
29
/// dependency-injection pattern (see `architecture.md`).
30
///
31
/// A capture widget's
32
/// private writeback invokes it with each [`VideoFrame`], so application code
33
/// can apply effects, save the frame into its own data model, or send it over
34
/// the network (azul-meet). Returns `Update` like any callback. Wired via
35
/// `CameraWidget::with_on_frame` / `ScreenCaptureWidget::with_on_frame` /
36
/// `VideoWidget::with_on_frame`.
37
pub type OnVideoFrameCallbackType = extern "C" fn(RefAny, CallbackInfo, VideoFrame) -> Update;
38
impl_widget_callback!(
39
    OnVideoFrame,
40
    OptionOnVideoFrame,
41
    OnVideoFrameCallback,
42
    OnVideoFrameCallbackType
43
);
44

            
45
// Host-invoker plumbing for managed-FFI bindings - see core/src/host_invoker.rs.
46
azul_core::impl_managed_callback! {
47
    wrapper:        OnVideoFrameCallback,
48
    info_ty:        CallbackInfo,
49
    return_ty:      Update,
50
    default_ret:    Update::DoNothing,
51
    invoker_static: ON_VIDEO_FRAME_INVOKER,
52
    invoker_ty:     AzOnVideoFrameCallbackInvoker,
53
    thunk_fn:       az_on_video_frame_callback_thunk,
54
    setter_fn:      AzApp_setOnVideoFrameCallbackInvoker,
55
    from_handle_fn: AzOnVideoFrameCallback_createFromHostHandle,
56
    extra_args:     [ frame: VideoFrame ],
57
}
58

            
59
/// Invoke a capture widget's optional `on_frame` hook with `frame`, returning
60
/// the user's `Update` (`DoNothing` when no hook is set). Shared by all three
61
/// capture widgets' writebacks.
62
45
pub fn invoke_on_frame(
63
45
    hook: &OptionOnVideoFrame,
64
45
    info: &mut CallbackInfo,
65
45
    frame: &VideoFrame,
66
45
) -> Update {
67
45
    match hook {
68
18
        OptionOnVideoFrame::Some(h) => {
69
18
            (h.callback.cb)(h.refany.clone(), *info, frame.clone())
70
        }
71
27
        OptionOnVideoFrame::None => Update::DoNothing,
72
    }
73
45
}
74

            
75
/// Present `frame` for a video-ish widget.
76
///
77
/// ONE path on every backend: install the frame as a raw RGBA `ImageRef` on
78
/// the widget's node via `change_node_image` → the content chokepoint
79
/// (`LayoutWindow::apply_content_change`), which patches the display list in
80
/// place and lets damage fall out of `ImageRef` identity. The widget never
81
/// branches on the renderer — that branch was the shipped bug: a CPU-rendered
82
/// window can still EXPOSE a GL context, so the widget took the GL path and
83
/// sent texture-only updates (`update_all_image_callbacks` → `ReRender`) that
84
/// the CPU rasterizer never saw; camera/screenshare tiles froze on their
85
/// placeholder. On GPU backends the `WebRender` translator re-uploads the
86
/// changed raster `ImageRef` — the backend decides texture vs raster, the
87
/// widget cannot know or care.
88
///
89
/// `current_id` is passed through unchanged (widgets store it; the GL texture
90
/// pool it used to name is gone).
91
38
pub fn present_frame(
92
38
    info: &mut CallbackInfo,
93
38
    dataset: RefAny,
94
38
    current_id: Option<u32>,
95
38
    frame: &VideoFrame,
96
38
) -> Option<u32> {
97
    use azul_core::resources::{RawImage, RawImageData, RawImageFormat};
98

            
99
38
    if let Some(img) = ImageRef::new_rawimage(RawImage {
100
38
        pixels: RawImageData::U8(frame.bytes.clone()),
101
38
        width: frame.width as usize,
102
38
        height: frame.height as usize,
103
38
        premultiplied_alpha: false,
104
38
        data_format: RawImageFormat::RGBA8,
105
38
        tag: b"azul-capture-frame".to_vec().into(),
106
38
    }) {
107
26
        if let Some(node) = info.get_node_id_of_root_dataset(dataset) {
108
12
            if let Some(nid) = node.node.into_crate_internal() {
109
12
                info.change_node_image(node.dom, nid, img, UpdateImageType::Content);
110
12
            }
111
14
        }
112
12
    }
113
38
    current_id
114
38
}
115

            
116
/// Upload tightly-packed RGBA8 pixels into the GL texture `texture_id`.
117
#[allow(clippy::cast_possible_wrap)] // bounded graphics/coord/counter/fixed-point cast
118
12
pub fn upload_rgba(gl: &GlContextPtr, texture_id: u32, frame: &VideoFrame) {
119
12
    gl.bind_texture(TEXTURE_2D, texture_id);
120
12
    gl.tex_image_2d(
121
        TEXTURE_2D,
122
        0,
123
12
        RGBA as i32,
124
12
        frame.width as i32,
125
12
        frame.height as i32,
126
        0,
127
        RGBA,
128
        UNSIGNED_BYTE,
129
12
        OptionU8VecRef::Some(U8VecRef::from(frame.bytes.as_ref())),
130
    );
131
12
}
132

            
133
/// A platform frame-capture backend (camera / screen), registered by the dll at
134
/// startup so the cross-platform capture widgets can pull **real** frames
135
/// instead of their built-in test pattern.
136
///
137
/// The dll provides one per OS (v4l2 on
138
/// Linux, `AVFoundation` on macOS, Media Foundation on Windows, `ScreenCaptureKit` /
139
/// `PipeWire` / DXGI for screens, ...). These are plain Rust fn pointers - the dll
140
/// links azul-layout statically, so registering + calling is a Rust-to-Rust
141
/// call, no `extern "C"`/trait-object dance.
142
#[derive(Debug, Clone, Copy)]
143
pub struct CaptureVTable {
144
    /// Open source `index` (camera device / display index) at the requested
145
    /// `width` x `height`. Returns an opaque handle, or `0` on failure (the
146
    /// worker then falls back to the test pattern).
147
    pub open: fn(index: u32, width: u32, height: u32) -> u64,
148
    /// Block for the next frame, writing tightly-packed RGBA8 into `out`
149
    /// (resized as needed). Returns the actual frame `(width, height)`, or
150
    /// `(0, 0)` on end-of-stream / error (the worker then stops + closes).
151
    pub read: fn(handle: u64, out: &mut Vec<u8>) -> (u32, u32),
152
    /// Close + free the source.
153
    pub close: fn(handle: u64),
154
}
155

            
156
static CAMERA_BACKEND: std::sync::OnceLock<CaptureVTable> = std::sync::OnceLock::new();
157
static SCREEN_BACKEND: std::sync::OnceLock<CaptureVTable> = std::sync::OnceLock::new();
158

            
159
/// Register the platform **camera** capture backend (called once by the dll at
160
/// startup; the first registration wins). Without it, `CameraWidget` shows its
161
/// test pattern.
162
3
pub fn register_camera_backend(vtable: CaptureVTable) {
163
3
    let _ = CAMERA_BACKEND.set(vtable);
164
3
}
165

            
166
/// Register the platform **screen** capture backend (for `ScreenCaptureWidget`).
167
1
pub fn register_screen_backend(vtable: CaptureVTable) {
168
1
    let _ = SCREEN_BACKEND.set(vtable);
169
1
}
170

            
171
/// The registered camera backend, if the dll provided one for this platform.
172
15
pub fn camera_backend() -> Option<CaptureVTable> {
173
15
    CAMERA_BACKEND.get().copied()
174
15
}
175

            
176
/// The registered screen-capture backend, if any.
177
6
pub fn screen_backend() -> Option<CaptureVTable> {
178
6
    SCREEN_BACKEND.get().copied()
179
6
}
180

            
181
/// A platform **audio**-capture backend (microphone), registered by the dll so
182
/// `MicrophoneWidget` can pull real samples instead of the test tone.
183
///
184
/// Like
185
/// [`CaptureVTable`] but yields interleaved `f32` audio rather than RGBA video.
186
#[derive(Debug, Clone, Copy)]
187
pub struct AudioCaptureVTable {
188
    /// Open the default mic at `sample_rate` x `channels`. Opaque handle, or
189
    /// `0` on failure.
190
    pub open: fn(sample_rate: u32, channels: u16) -> u64,
191
    /// Block for the next chunk, writing interleaved `f32` into `out` (resized).
192
    /// Returns the frame count (`out.len() / channels`), or `0` on error / EOF
193
    /// (the worker then stops + closes).
194
    pub read: fn(handle: u64, out: &mut Vec<f32>) -> u32,
195
    /// Close + free the source.
196
    pub close: fn(handle: u64),
197
}
198

            
199
static MIC_BACKEND: std::sync::OnceLock<AudioCaptureVTable> = std::sync::OnceLock::new();
200

            
201
/// Register the platform microphone-capture backend (called once by the dll).
202
2
pub fn register_mic_backend(vtable: AudioCaptureVTable) {
203
2
    let _ = MIC_BACKEND.set(vtable);
204
2
}
205

            
206
/// The registered mic-capture backend, if the dll provided one for this platform.
207
13
pub fn mic_backend() -> Option<AudioCaptureVTable> {
208
13
    MIC_BACKEND.get().copied()
209
13
}
210

            
211
/// Poll the main->worker channel and report whether the worker was asked to
212
/// stop.
213
///
214
/// Every capture worker (camera / screencap / microphone) sits in a
215
/// `loop { read_device(); send_frame(); }`. Before this existed, none of them
216
/// ever looked at their `ThreadReceiver`, so `ThreadSendMsg::TerminateThread`
217
/// was never observed and the only way out was `sender.send()` failing — which
218
/// does NOT happen at shutdown, because the main thread still owns the
219
/// receiving end while it waits. The result was the 2 s grace period in
220
/// `crate::thread::default_thread_destructor_fn` expiring and the worker being
221
/// DETACHED:
222
///
223
/// ```text
224
/// [azul][thread] a background thread did not acknowledge TerminateThread
225
/// within 2000ms and was DETACHED rather than joined.
226
/// ```
227
///
228
/// (Reported twice from azul-meet on macOS after using camera + screenshare —
229
/// one line per capture worker.)
230
///
231
/// `ThreadReceiver::recv` is a `try_recv` under the hood, so this never blocks.
232
/// Non-terminate messages are drained and ignored: these workers have no other
233
/// commands, and leaving them queued would hide a `TerminateThread` sent behind
234
/// them.
235
#[must_use]
236
11
pub fn terminate_requested(recv: &mut azul_core::task::ThreadReceiver) -> bool {
237
    use azul_core::task::{OptionThreadSendMsg, ThreadSendMsg};
238
    loop {
239
11
        match recv.recv() {
240
3
            OptionThreadSendMsg::Some(ThreadSendMsg::TerminateThread) => return true,
241
            OptionThreadSendMsg::Some(_) => {}
242
8
            OptionThreadSendMsg::None => return false,
243
        }
244
    }
245
11
}
246

            
247
#[cfg(test)]
248
#[allow(clippy::too_many_lines)] // table-driven cases; splitting them hides the case list
249
mod autotest_generated {
250
    use std::{
251
        collections::{BTreeMap, HashMap},
252
        panic::{catch_unwind, AssertUnwindSafe},
253
        rc::Rc,
254
        sync::{Arc, Mutex, PoisonError},
255
    };
256

            
257
    use azul_core::{
258
        dom::{Dom, DomId, DomNodeId, NodeId, NodeType},
259
        geom::{LogicalRect, OptionLogicalPosition},
260
        gl::{GenericGlContext, OptionGlContextPtr, GLvoid},
261
        hit_test::ScrollPosition,
262
        refany::OptionRefAny,
263
        resources::{DecodedImage, RendererResources},
264
        styled_dom::{NodeHierarchyItemId, StyledDom},
265
        window::{MonitorVec, RawWindowHandle, RendererType},
266
    };
267
    use azul_css::system::SystemStyle;
268
    use rust_fontconfig::FcFontCache;
269

            
270
    use super::*;
271
    #[cfg(feature = "icu")]
272
    use crate::icu::IcuLocalizerHandle;
273
    use crate::{
274
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
275
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
276
        window::{DomLayoutResult, LayoutWindow},
277
        window_state::FullWindowState,
278
    };
279

            
280
    // ------------------------------------------------------------------
281
    // Fake GL drivers
282
    //
283
    // Every field of `GenericGlContext` is a `*mut c_void` entry point, and
284
    // gl-context-loader null-checks each one before transmuting + calling it
285
    // (returning a default instead). So an all-zero context is a SAFE no-op
286
    // "driver never loaded" GL, and a context with only the three entry points
287
    // this module actually uses filled in is a safe *recording* driver: we can
288
    // observe exactly which GL calls `upload_rgba` / `present_frame` emit, with
289
    // which arguments, entirely off-GPU.
290
    // ------------------------------------------------------------------
291

            
292
    /// The texture name the recording driver hands out from `glGenTextures`.
293
    const RECORDED_TEXTURE_ID: u32 = 42;
294

            
295
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
296
    enum GlCall {
297
        GenTextures {
298
            n: i32,
299
        },
300
        BindTexture {
301
            target: u32,
302
            texture: u32,
303
        },
304
        TexImage2d {
305
            target: u32,
306
            level: i32,
307
            internal_format: i32,
308
            width: i32,
309
            height: i32,
310
            border: i32,
311
            format: u32,
312
            ty: u32,
313
            /// `false` = the `NULL` pixel pointer `Texture::allocate_rgba8` uses,
314
            /// `true` = a real pixel upload (what `upload_rgba` does).
315
            has_pixels: bool,
316
        },
317
    }
318

            
319
    static GL_LOG: Mutex<Vec<GlCall>> = Mutex::new(Vec::new());
320
    /// Serializes the tests that use the (process-global) recording driver.
321
    static GL_SERIAL: Mutex<()> = Mutex::new(());
322

            
323
    fn gl_log_push(call: GlCall) {
324
        GL_LOG
325
            .lock()
326
            .unwrap_or_else(PoisonError::into_inner)
327
            .push(call);
328
    }
329

            
330
    extern "system" fn rec_gen_textures(n: i32, out: *mut u32) {
331
        gl_log_push(GlCall::GenTextures { n });
332
        // The caller (gl-context-loader) always passes a `Vec<GLuint>` of len `n`.
333
        for i in 0..n.max(0) {
334
            // SAFETY: `out` addresses `n` writable `GLuint`s (a `vec![0; n]`).
335
            unsafe { out.add(i as usize).write(RECORDED_TEXTURE_ID + i as u32) };
336
        }
337
    }
338

            
339
    extern "system" fn rec_bind_texture(target: u32, texture: u32) {
340
        gl_log_push(GlCall::BindTexture { target, texture });
341
    }
342

            
343
    #[allow(clippy::too_many_arguments)] // must mirror glTexImage2D exactly
344
    extern "system" fn rec_tex_image_2d(
345
        target: u32,
346
        level: i32,
347
        internal_format: i32,
348
        width: i32,
349
        height: i32,
350
        border: i32,
351
        format: u32,
352
        ty: u32,
353
        pixels: *const GLvoid,
354
    ) {
355
        gl_log_push(GlCall::TexImage2d {
356
            target,
357
            level,
358
            internal_format,
359
            width,
360
            height,
361
            border,
362
            format,
363
            ty,
364
            has_pixels: !pixels.is_null(),
365
        });
366
    }
367

            
368
    /// A GL context whose entry points are all `NULL` (driver never loaded).
369
    fn null_gl_context() -> GlContextPtr {
370
        // SAFETY: every field of `GenericGlContext` is a raw pointer, for which
371
        // the all-zero (NULL) bit pattern is valid.
372
        let ctx: GenericGlContext = unsafe { core::mem::zeroed() };
373
        GlContextPtr::new(RendererType::Software, Rc::new(ctx))
374
    }
375

            
376
    /// A GL context that records the calls this module makes (and nothing else:
377
    /// `glTexParameteri` / `glGetIntegerv` / `glDeleteTextures` stay NULL, i.e.
378
    /// safe no-ops).
379
    fn recording_gl_context() -> GlContextPtr {
380
        // SAFETY: as above — NULL is a valid value for every field; the three we
381
        // overwrite get fn pointers with exactly the signatures gl-context-loader
382
        // transmutes them back to.
383
        let mut ctx: GenericGlContext = unsafe { core::mem::zeroed() };
384
        ctx.glGenTextures = rec_gen_textures as *const () as *mut azul_core::gl::c_void;
385
        ctx.glBindTexture = rec_bind_texture as *const () as *mut azul_core::gl::c_void;
386
        ctx.glTexImage2D = rec_tex_image_2d as *const () as *mut azul_core::gl::c_void;
387
        GlContextPtr::new(RendererType::Software, Rc::new(ctx))
388
    }
389

            
390
    /// Runs `f` against the recording driver and returns the GL calls it made.
391
    fn with_recorded_gl<R>(f: impl FnOnce(GlContextPtr) -> R) -> (R, Vec<GlCall>) {
392
        let _serial = GL_SERIAL.lock().unwrap_or_else(PoisonError::into_inner);
393
        GL_LOG
394
            .lock()
395
            .unwrap_or_else(PoisonError::into_inner)
396
            .clear();
397
        let out = f(recording_gl_context());
398
        let log = GL_LOG
399
            .lock()
400
            .unwrap_or_else(PoisonError::into_inner)
401
            .clone();
402
        (out, log)
403
    }
404

            
405
    // ------------------------------------------------------------------
406
    // CallbackInfo harness (mirrors the other widget test modules)
407
    // ------------------------------------------------------------------
408

            
409
    /// A `DomLayoutResult` with an *empty* layout tree: the code under test only
410
    /// walks `styled_dom.node_data`, so no real layout (and no font) is needed.
411
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
412
        DomLayoutResult {
413
            styled_dom,
414
            layout_tree: LayoutTree {
415
                nodes: Vec::new(),
416
                warm: Vec::new(),
417
                cold: Vec::new(),
418
                root: 0,
419
                dom_to_layout: BTreeMap::new(),
420
                children_arena: Vec::new(),
421
                children_offsets: Vec::new(),
422
                subtree_needs_intrinsic: Vec::new(),
423
            },
424
            calculated_positions: Vec::new(),
425
            viewport: LogicalRect::zero(),
426
            display_list: Arc::new(DisplayList::default()),
427
            scroll_ids: HashMap::new(),
428
            scroll_id_to_node_id: HashMap::new(),
429
        }
430
    }
431

            
432
    /// Invokes `f` with a `CallbackInfo` over a window holding `styled` (or no
433
    /// layout results at all, when `styled` is `None`) and the given GL context.
434
    /// Returns `f`'s value plus every `CallbackChange` the callback recorded.
435
    fn with_callback_info<R>(
436
        styled: Option<StyledDom>,
437
        gl_context: OptionGlContextPtr,
438
        f: impl FnOnce(&mut CallbackInfo) -> R,
439
    ) -> (R, Vec<CallbackChange>) {
440
        let mut layout_window =
441
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
442
        if let Some(sd) = styled {
443
            layout_window
444
                .layout_results
445
                .insert(DomId::ROOT_ID, layout_result(sd));
446
        }
447

            
448
        let renderer_resources = RendererResources::default();
449
        let previous_window_state: Option<FullWindowState> = None;
450
        let current_window_state = FullWindowState::default();
451
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
452
            BTreeMap::new();
453
        let window_handle = RawWindowHandle::Unsupported;
454
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
455

            
456
        let ref_data = CallbackInfoRefData {
457
            layout_window: &layout_window,
458
            renderer_resources: &renderer_resources,
459
            previous_window_state: &previous_window_state,
460
            current_window_state: &current_window_state,
461
            gl_context: &gl_context,
462
            current_scroll_manager: &scroll_states,
463
            current_window_handle: &window_handle,
464
            system_callbacks: &system_callbacks,
465
            system_style: Arc::new(SystemStyle::default()),
466
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
467
            #[cfg(feature = "icu")]
468
            icu_localizer: IcuLocalizerHandle::default(),
469
            ctx: OptionRefAny::None,
470
        };
471

            
472
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
473

            
474
        let mut info = CallbackInfo::new(
475
            &ref_data,
476
            &changes,
477
            DomNodeId {
478
                dom: DomId::ROOT_ID,
479
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
480
            },
481
            OptionLogicalPosition::None,
482
            OptionLogicalPosition::None,
483
        );
484

            
485
        let out = f(&mut info);
486
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
487
        (out, recorded)
488
    }
489

            
490
    // ------------------------------------------------------------------
491
    // Fixtures
492
    // ------------------------------------------------------------------
493

            
494
    /// The dataset type a capture widget stores on its node.
495
    #[derive(Debug, Default)]
496
    struct CamState {
497
        _texture_id: Option<u32>,
498
    }
499

            
500
    /// A *different* dataset type, to prove the node lookup is type-scoped.
501
    #[derive(Debug, Default)]
502
    struct OtherState {
503
        _unused: u8,
504
    }
505

            
506
    /// A `div`, carrying `ds` as its dataset when there is one.
507
    fn div_with(ds: Option<RefAny>) -> Dom {
508
        let d = Dom::create_node(NodeType::Div);
509
        match ds {
510
            Some(r) => d.with_dataset(OptionRefAny::Some(r)),
511
            None => d,
512
        }
513
    }
514

            
515
    /// `body(0) -> div(1) -> div(2)`, where a `Some(ds)` gives that div a dataset.
516
    fn dom_with_datasets(first: Option<RefAny>, second: Option<RefAny>) -> StyledDom {
517
        let dom = Dom::create_node(NodeType::Body)
518
            .with_child(div_with(first))
519
            .with_child(div_with(second));
520
        let styled = StyledDom::create_from_dom(dom);
521
        assert_eq!(
522
            styled.node_hierarchy.as_ref().len(),
523
            3,
524
            "fixture must flatten to exactly body + 2 divs"
525
        );
526
        styled
527
    }
528

            
529
    /// A `width` x `height` RGBA8 frame with a deterministic (tightly-packed) ramp.
530
    /// Only ever called with tiny dimensions — `width * height * 4` is allocated.
531
    fn frame(width: u32, height: u32) -> VideoFrame {
532
        let len = (width as usize) * (height as usize) * 4;
533
        let bytes: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
534
        VideoFrame::new(width, height, bytes.into())
535
    }
536

            
537
    /// A frame whose *declared* dimensions need not match its byte count.
538
    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
539
        VideoFrame::new(width, height, bytes.into())
540
    }
541

            
542
    /// Every image installed on a node, as `(dom, node index, image, update type)`.
543
    fn image_installs(
544
        changes: &[CallbackChange],
545
    ) -> Vec<(DomId, usize, &ImageRef, UpdateImageType)> {
546
        changes
547
            .iter()
548
            .filter_map(|c| match c {
549
                CallbackChange::ChangeNodeImage {
550
                    dom_id,
551
                    node_id,
552
                    image,
553
                    update_type,
554
                } => Some((*dom_id, node_id.index(), image, *update_type)),
555
                _ => None,
556
            })
557
            .collect()
558
    }
559

            
560
    /// How many "recomposite, don't relayout" requests the callback made.
561
    fn recomposites(changes: &[CallbackChange]) -> usize {
562
        changes
563
            .iter()
564
            .filter(|c| matches!(c, CallbackChange::UpdateAllImageCallbacks))
565
            .count()
566
    }
567

            
568
    // ==================================================================
569
    // invoke_on_frame
570
    // ==================================================================
571

            
572
    /// Payload of the `on_frame` hook: records every frame it is handed.
573
    #[derive(Debug)]
574
    struct HookLog {
575
        seen: Vec<(u32, u32, usize, Option<u8>)>,
576
        reply: Update,
577
    }
578

            
579
    extern "C" fn hook_record(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
580
        let mut reply = Update::DoNothing;
581
        if let Some(mut log) = data.downcast_mut::<HookLog>() {
582
            let bytes = frame.bytes.as_ref();
583
            log.seen
584
                .push((frame.width, frame.height, bytes.len(), bytes.first().copied()));
585
            reply = log.reply;
586
        }
587
        reply
588
    }
589

            
590
    /// A hook that writes through the `CallbackInfo` it was handed (by value).
591
    extern "C" fn hook_recomposite(_: RefAny, mut info: CallbackInfo, _: VideoFrame) -> Update {
592
        info.update_all_image_callbacks();
593
        Update::RefreshDomAllWindows
594
    }
595

            
596
    fn hook(cb: OnVideoFrameCallbackType, data: RefAny) -> OptionOnVideoFrame {
597
        OptionOnVideoFrame::Some(OnVideoFrame {
598
            refany: data,
599
            callback: cb.into(),
600
        })
601
    }
602

            
603
    fn hook_seen(data: &mut RefAny) -> Vec<(u32, u32, usize, Option<u8>)> {
604
        data.downcast_ref::<HookLog>()
605
            .expect("payload must still be a HookLog")
606
            .seen
607
            .clone()
608
    }
609

            
610
    #[test]
611
    fn invoke_on_frame_without_a_hook_is_do_nothing_and_touches_nothing() {
612
        let (update, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
613
            invoke_on_frame(&OptionOnVideoFrame::None, info, &frame(2, 2))
614
        });
615
        assert_eq!(
616
            update,
617
            Update::DoNothing,
618
            "an unset on_frame hook must be a no-op"
619
        );
620
        assert!(
621
            changes.is_empty(),
622
            "an unset hook must not record any change, got {changes:?}"
623
        );
624
    }
625

            
626
    #[test]
627
    fn invoke_on_frame_returns_the_hooks_update_verbatim() {
628
        for reply in [
629
            Update::DoNothing,
630
            Update::RefreshDom,
631
            Update::RefreshDomAllWindows,
632
        ] {
633
            let data = RefAny::new(HookLog {
634
                seen: Vec::new(),
635
                reply,
636
            });
637
            let h = hook(hook_record, data);
638
            let (update, _) = with_callback_info(None, OptionGlContextPtr::None, |info| {
639
                invoke_on_frame(&h, info, &frame(1, 1))
640
            });
641
            assert_eq!(
642
                update, reply,
643
                "invoke_on_frame must return the user's Update unchanged"
644
            );
645
        }
646
    }
647

            
648
    #[test]
649
    fn invoke_on_frame_forwards_every_frame_into_the_hooks_shared_refany() {
650
        let mut data = RefAny::new(HookLog {
651
            seen: Vec::new(),
652
            reply: Update::RefreshDom,
653
        });
654
        let h = hook(hook_record, data.clone());
655

            
656
        // The hook is handed a *clone* of its RefAny on every invocation — the
657
        // backreference DI pattern only works if that clone shares the payload.
658
        with_callback_info(None, OptionGlContextPtr::None, |info| {
659
            for (w, hgt) in [(1_u32, 1_u32), (2, 3), (4, 4)] {
660
                invoke_on_frame(&h, info, &frame(w, hgt));
661
            }
662
        });
663

            
664
        assert_eq!(
665
            hook_seen(&mut data),
666
            vec![
667
                (1, 1, 4, Some(0)),
668
                (2, 3, 24, Some(0)),
669
                (4, 4, 64, Some(0)),
670
            ],
671
            "every frame must reach the hook, in order, with its bytes intact"
672
        );
673
    }
674

            
675
    #[test]
676
    fn invoke_on_frame_forwards_degenerate_frames_unvalidated_and_without_panicking() {
677
        let mut data = RefAny::new(HookLog {
678
            seen: Vec::new(),
679
            reply: Update::DoNothing,
680
        });
681
        let h = hook(hook_record, data.clone());
682

            
683
        with_callback_info(None, OptionGlContextPtr::None, |info| {
684
            // 0x0, dimensions that disagree with the byte count, and dimensions
685
            // whose tight-packing size (w*h*4) overflows usize. `invoke_on_frame`
686
            // must hand all of them to the hook as-is: it is a pure forwarder and
687
            // must never multiply the dimensions out.
688
            invoke_on_frame(&h, info, &frame_raw(0, 0, Vec::new()));
689
            invoke_on_frame(&h, info, &frame_raw(9, 9, vec![7, 8, 9]));
690
            invoke_on_frame(&h, info, &frame_raw(u32::MAX, u32::MAX, Vec::new()));
691
            invoke_on_frame(&h, info, &frame_raw(u32::MAX, 1, vec![255]));
692
        });
693

            
694
        assert_eq!(
695
            hook_seen(&mut data),
696
            vec![
697
                (0, 0, 0, None),
698
                (9, 9, 3, Some(7)),
699
                (u32::MAX, u32::MAX, 0, None),
700
                (u32::MAX, 1, 1, Some(255)),
701
            ],
702
            "invoke_on_frame must forward frames verbatim, without validating them"
703
        );
704
    }
705

            
706
    #[test]
707
    fn invoke_on_frame_hook_writes_through_the_shared_callback_info() {
708
        // `invoke_on_frame` passes `*info` (CallbackInfo is Copy) — the copy must
709
        // still write into the *caller's* transaction container.
710
        let h = hook(hook_recomposite, RefAny::new(OtherState::default()));
711
        let (update, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
712
            invoke_on_frame(&h, info, &frame(1, 1))
713
        });
714

            
715
        assert_eq!(update, Update::RefreshDomAllWindows);
716
        assert_eq!(
717
            recomposites(&changes),
718
            1,
719
            "a change made by the hook must be visible to the widget's writeback"
720
        );
721
    }
722

            
723
    // ==================================================================
724
    // upload_rgba
725
    // ==================================================================
726

            
727
    #[test]
728
    fn upload_rgba_forwards_the_texture_id_and_the_rgba8_constants() {
729
        for id in [0_u32, 1, 7, u32::MAX] {
730
            let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, id, &frame(2, 2)));
731
            assert_eq!(
732
                log,
733
                vec![
734
                    GlCall::BindTexture {
735
                        target: TEXTURE_2D,
736
                        texture: id,
737
                    },
738
                    GlCall::TexImage2d {
739
                        target: TEXTURE_2D,
740
                        level: 0,
741
                        internal_format: RGBA as i32,
742
                        width: 2,
743
                        height: 2,
744
                        border: 0,
745
                        format: RGBA,
746
                        ty: UNSIGNED_BYTE,
747
                        has_pixels: true,
748
                    },
749
                ],
750
                "upload_rgba must bind exactly texture {id} and upload tightly-packed RGBA8"
751
            );
752
        }
753
    }
754

            
755
    #[test]
756
    fn upload_rgba_zero_sized_frame_is_forwarded_as_a_0x0_upload() {
757
        let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, 3, &frame_raw(0, 0, Vec::new())));
758
        assert_eq!(
759
            log,
760
            vec![
761
                GlCall::BindTexture {
762
                    target: TEXTURE_2D,
763
                    texture: 3,
764
                },
765
                GlCall::TexImage2d {
766
                    target: TEXTURE_2D,
767
                    level: 0,
768
                    internal_format: RGBA as i32,
769
                    width: 0,
770
                    height: 0,
771
                    border: 0,
772
                    format: RGBA,
773
                    ty: UNSIGNED_BYTE,
774
                    has_pixels: true,
775
                },
776
            ],
777
            "a 0x0 frame must still be a well-formed (if empty) glTexImage2D, not a panic"
778
        );
779
    }
780

            
781
    #[test]
782
    fn upload_rgba_dimensions_above_i32_max_wrap_to_negative_glsizei() {
783
        // glTexImage2D takes GLsizei (= i32), so a u32 dimension > i32::MAX is a
784
        // lossy cast. Assert the *exact* wrapped value: GL then rejects the call
785
        // with GL_INVALID_VALUE (the frame is dropped) — the cast must never be a
786
        // debug-mode overflow panic or UB.
787
        let cases: [(u32, u32, i32, i32); 4] = [
788
            (i32::MAX as u32, 1, i32::MAX, 1),
789
            (i32::MAX as u32 + 1, 1, i32::MIN, 1),
790
            (u32::MAX, u32::MAX, -1, -1),
791
            (u32::MAX - 1, 2, -2, 2),
792
        ];
793

            
794
        for (w, h, want_w, want_h) in cases {
795
            // Empty byte buffer: the huge dimensions must never be multiplied out
796
            // (that would be a several-exabyte allocation), only cast.
797
            let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, 1, &frame_raw(w, h, Vec::new())));
798
            let tex = log
799
                .iter()
800
                .find_map(|c| match c {
801
                    GlCall::TexImage2d { width, height, .. } => Some((*width, *height)),
802
                    _ => None,
803
                })
804
                .expect("upload_rgba must always call glTexImage2D");
805
            assert_eq!(
806
                tex,
807
                (want_w, want_h),
808
                "{w}x{h} must cast to GLsizei {want_w}x{want_h}"
809
            );
810
        }
811
    }
812

            
813
    #[test]
814
    fn upload_rgba_against_an_unloaded_driver_is_a_silent_no_op() {
815
        // is_gl_usable() == false (all entry points NULL): the loader must swallow
816
        // every call rather than jumping through a NULL function pointer.
817
        let gl = null_gl_context();
818
        upload_rgba(&gl, 0, &frame(2, 2));
819
        upload_rgba(&gl, u32::MAX, &frame_raw(u32::MAX, u32::MAX, Vec::new()));
820
        upload_rgba(&gl, 1, &frame_raw(0, 0, Vec::new()));
821
    }
822

            
823
    // ==================================================================
824
    // present_frame — CPU (no GL context)
825
    // ==================================================================
826

            
827
    #[test]
828
    fn present_frame_without_gl_installs_a_raw_image_on_the_dataset_node() {
829
        let ds = RefAny::new(CamState::default());
830
        let styled = dom_with_datasets(Some(ds.clone()), None);
831

            
832
        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
833
            present_frame(info, ds.clone(), None, &frame(4, 4))
834
        });
835

            
836
        // The CPU path never allocates a GL texture, so it must hand back the id it
837
        // was given (None) rather than inventing one.
838
        assert_eq!(id, None, "the cpurender path must not invent a texture id");
839

            
840
        let installs = image_installs(&changes);
841
        assert_eq!(installs.len(), 1, "exactly one image install per frame");
842
        let (dom_id, node_idx, image, update_type) = installs[0];
843
        assert_eq!(dom_id, DomId::ROOT_ID);
844
        assert_eq!(node_idx, 1, "the image must land on the dataset's node");
845
        assert_eq!(update_type, UpdateImageType::Content);
846
        match image.get_data() {
847
            DecodedImage::Raw((descriptor, _)) => {
848
                assert_eq!(
849
                    (descriptor.width, descriptor.height),
850
                    (4, 4),
851
                    "the installed image must keep the frame's dimensions"
852
                );
853
            }
854
            other => panic!("cpurender must install a raw image, got {other:?}"),
855
        }
856
        assert_eq!(
857
            recomposites(&changes),
858
            0,
859
            "the CPU path swaps the node's image instead of recompositing a texture"
860
        );
861
    }
862

            
863
    #[test]
864
    fn present_frame_without_gl_returns_the_current_id_verbatim() {
865
        for current in [None, Some(0_u32), Some(1), Some(u32::MAX)] {
866
            let ds = RefAny::new(CamState::default());
867
            let styled = dom_with_datasets(Some(ds.clone()), None);
868
            let (id, changes) =
869
                with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
870
                    present_frame(info, ds.clone(), current, &frame(2, 2))
871
                });
872
            assert_eq!(
873
                id, current,
874
                "the cpurender path must round-trip current_id ({current:?}) untouched"
875
            );
876
            assert_eq!(
877
                image_installs(&changes).len(),
878
                1,
879
                "the CPU path re-installs the image on *every* frame"
880
            );
881
        }
882
    }
883

            
884
    #[test]
885
    fn present_frame_without_gl_and_without_a_matching_dataset_installs_nothing() {
886
        // Node carries `OtherState`, the widget looks for `CamState`.
887
        let node_ds = RefAny::new(OtherState::default());
888
        let styled = dom_with_datasets(Some(node_ds), None);
889
        let search = RefAny::new(CamState::default());
890

            
891
        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
892
            present_frame(info, search.clone(), Some(9), &frame(2, 2))
893
        });
894

            
895
        assert_eq!(id, Some(9), "a failed node lookup must not lose the id");
896
        assert!(
897
            changes.is_empty(),
898
            "no node owns the dataset, so nothing may be installed: {changes:?}"
899
        );
900
    }
901

            
902
    #[test]
903
    fn present_frame_without_gl_and_without_any_layout_result_installs_nothing() {
904
        let ds = RefAny::new(CamState::default());
905
        let (id, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
906
            present_frame(info, ds.clone(), Some(3), &frame(2, 2))
907
        });
908
        assert_eq!(id, Some(3));
909
        assert!(
910
            changes.is_empty(),
911
            "an empty window must not be written to: {changes:?}"
912
        );
913
    }
914

            
915
    #[test]
916
    fn present_frame_without_gl_rejects_a_frame_whose_byte_count_disagrees_with_its_size() {
917
        // A backend that lies about the frame size (or a short read) must not be
918
        // able to install a bogus image — RawImage validates len == w*h*4.
919
        for (w, h, bytes) in [
920
            (4_u32, 4_u32, vec![0_u8; 3]),        // far too short
921
            (4, 4, vec![0_u8; 63]),               // one byte short
922
            (4, 4, vec![0_u8; 65]),               // one byte long
923
            (2, 2, Vec::new()),                   // no pixels at all
924
        ] {
925
            let ds = RefAny::new(CamState::default());
926
            let styled = dom_with_datasets(Some(ds.clone()), None);
927
            let (id, changes) =
928
                with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
929
                    present_frame(info, ds.clone(), Some(5), &frame_raw(w, h, bytes.clone()))
930
                });
931

            
932
            assert_eq!(id, Some(5), "a rejected frame must not disturb the id");
933
            assert!(
934
                changes.is_empty(),
935
                "a {w}x{h} frame with {} bytes must be rejected, not installed: {changes:?}",
936
                bytes.len()
937
            );
938
        }
939
    }
940

            
941
    #[test]
942
    fn present_frame_without_gl_installs_a_degenerate_image_for_a_0x0_frame() {
943
        // 0*0*4 == 0 == len(bytes), so a 0x0 frame passes RawImage's length check
944
        // and IS installed (as a 0x0 image). Pin the behaviour: it must at least
945
        // not panic and must not corrupt the returned id.
946
        let ds = RefAny::new(CamState::default());
947
        let styled = dom_with_datasets(Some(ds.clone()), None);
948
        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
949
            present_frame(info, ds.clone(), Some(2), &frame_raw(0, 0, Vec::new()))
950
        });
951

            
952
        assert_eq!(id, Some(2));
953
        let installs = image_installs(&changes);
954
        assert_eq!(installs.len(), 1);
955
        match installs[0].2.get_data() {
956
            DecodedImage::Raw((descriptor, _)) => {
957
                assert_eq!((descriptor.width, descriptor.height), (0, 0));
958
            }
959
            other => panic!("expected a raw image, got {other:?}"),
960
        }
961
    }
962

            
963
    #[test]
964
    fn present_frame_without_gl_survives_dimensions_whose_byte_count_overflows_usize() {
965
        // ADVERSARIAL: a backend reporting 2^31 x 2^31 makes the CPU path compute
966
        // `width * height * 4` in usize inside `RawImage::into_loaded_image_source`
967
        // -> 2^64, which overflows.
968
        //
969
        // Today that is an arithmetic-overflow PANIC in a debug build (and a
970
        // silent wrap to 0 in release, which then *accepts* the empty byte buffer
971
        // as a valid 2^31 x 2^31 image). Neither is a graceful rejection — see the
972
        // autotest report. What must hold in *both* modes is the one invariant we
973
        // can still assert: the caller's texture id is never corrupted, and no GL
974
        // work is attempted.
975
        let ds = RefAny::new(CamState::default());
976
        let styled = dom_with_datasets(Some(ds.clone()), None);
977

            
978
        let (result, _changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
979
            catch_unwind(AssertUnwindSafe(|| {
980
                present_frame(
981
                    info,
982
                    ds.clone(),
983
                    Some(11),
984
                    &frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()),
985
                )
986
            }))
987
        });
988

            
989
        match result {
990
            Ok(id) => assert_eq!(
991
                id,
992
                Some(11),
993
                "the cpurender path must always hand back current_id"
994
            ),
995
            Err(_) => eprintln!(
996
                "NOTE: present_frame panicked (usize overflow of width*height*4) for a \
997
                 2^31 x 2^31 frame — a malformed capture backend can take the process down"
998
            ),
999
        }
    }
    #[test]
    fn present_frame_installs_exactly_one_image_when_two_nodes_share_a_dataset_type() {
        // Two capture widgets of the same state type in one DOM: the lookup scores
        // candidates by RefAny instance id, so *which* node wins is an internal
        // detail — but it must pick exactly ONE, and it must be a node that
        // actually owns a dataset (never the body at index 0, never both).
        let styled = dom_with_datasets(
            Some(RefAny::new(CamState::default())),
            Some(RefAny::new(CamState::default())),
        );
        let search = RefAny::new(CamState::default());
        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
            present_frame(info, search.clone(), Some(4), &frame(2, 2))
        });
        assert_eq!(id, Some(4));
        let installs = image_installs(&changes);
        assert_eq!(
            installs.len(),
            1,
            "a frame must never be installed on two nodes at once: {changes:?}"
        );
        assert!(
            installs[0].1 == 1 || installs[0].1 == 2,
            "the image must land on a node that owns a dataset, not on node {}",
            installs[0].1
        );
    }
    #[test]
    fn present_frame_matches_datasets_by_type_id_not_by_identity() {
        // FOOTGUN: the lookup compares *type ids*, so a completely unrelated
        // RefAny of the same type finds the node. Two capture widgets sharing a
        // state type would therefore fight over one node.
        let node_ds = RefAny::new(CamState::default());
        let styled = dom_with_datasets(Some(node_ds), None);
        let unrelated = RefAny::new(CamState::default()); // a different allocation
        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
            present_frame(info, unrelated.clone(), None, &frame(2, 2))
        });
        assert_eq!(id, None);
        assert_eq!(
            image_installs(&changes).len(),
            1,
            "an unrelated RefAny of the same type still resolves to the node"
        );
    }
    // ==================================================================
    // present_frame — with a GL context PRESENT (the trap case)
    //
    // A CPU-rendered window can still EXPOSE a GL context. The old code
    // branched on it inside the WIDGET and sent texture-only updates the CPU
    // rasterizer never saw (frozen camera tiles). The contract now: ONE path,
    // no GL calls, always a raw-image ChangeNodeImage through the chokepoint.
    // ==================================================================
    #[test]
    fn present_frame_with_gl_still_installs_a_raw_image_and_touches_no_gl() {
        let ds = RefAny::new(CamState::default());
        let styled = dom_with_datasets(Some(ds.clone()), None);
        let ((id, changes), log) = with_recorded_gl(|gl| {
            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
                present_frame(info, ds.clone(), None, &frame(4, 4))
            })
        });
        assert_eq!(id, None, "current_id passes through unchanged (no texture pool)");
        assert!(
            log.is_empty(),
            "the widget must not branch on the GL context — no GL call is ever made: {log:?}"
        );
        let installs = image_installs(&changes);
        assert_eq!(installs.len(), 1, "exactly one ChangeNodeImage per frame");
        assert_eq!(installs[0].1, 1);
        assert_eq!(installs[0].3, UpdateImageType::Content);
        match installs[0].2.get_data() {
            DecodedImage::Raw((descriptor, _)) => {
                assert_eq!(
                    (descriptor.width, descriptor.height),
                    (4, 4),
                    "the installed raw image must be sized like the frame"
                );
            }
            other => panic!("a RAW image must be installed on every backend, got {other:?}"),
        }
        assert_eq!(
            recomposites(&changes),
            0,
            "no texture-only recomposite: the chokepoint's paint tier drives the repaint"
        );
    }
    #[test]
    fn present_frame_with_gl_steady_state_reinstalls_the_frame_not_a_texture() {
        // Re-installing per frame is CORRECT now: the chokepoint patches the
        // display list in place (no rebuild), and the ImageRef identity change
        // is exactly what makes the CPU diff damage the tile.
        let ds = RefAny::new(CamState::default());
        let styled = dom_with_datasets(Some(ds.clone()), None);
        let ((id, changes), log) = with_recorded_gl(|gl| {
            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
                present_frame(info, ds.clone(), Some(RECORDED_TEXTURE_ID), &frame(4, 4))
            })
        });
        assert_eq!(
            id,
            Some(RECORDED_TEXTURE_ID),
            "a stored id must survive the writeback unchanged"
        );
        assert!(log.is_empty(), "steady state makes no GL calls either: {log:?}");
        assert_eq!(image_installs(&changes).len(), 1);
        assert_eq!(recomposites(&changes), 0);
    }
    #[test]
    fn present_frame_with_gl_round_trips_extreme_texture_ids() {
        for current in [Some(0_u32), Some(u32::MAX)] {
            let ds = RefAny::new(CamState::default());
            let styled = dom_with_datasets(Some(ds.clone()), None);
            let ((id, changes), log) = with_recorded_gl(|gl| {
                with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
                    present_frame(info, ds.clone(), current, &frame(1, 1))
                })
            });
            assert_eq!(
                id, current,
                "a stored texture id must survive the writeback unchanged"
            );
            assert!(log.is_empty(), "no GL call for id {current:?}: {log:?}");
            assert_eq!(image_installs(&changes).len(), 1);
        }
    }
    #[test]
    fn present_frame_with_gl_without_a_matching_node_installs_nothing() {
        // The node lookup fails (no dataset of that type): nothing is
        // installed, nothing is allocated, and the id still passes through.
        let styled = dom_with_datasets(Some(RefAny::new(OtherState::default())), None);
        let search = RefAny::new(CamState::default());
        let ((id, changes), log) = with_recorded_gl(|gl| {
            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
                present_frame(info, search.clone(), None, &frame(2, 2))
            })
        });
        assert_eq!(id, None);
        assert!(
            changes.is_empty(),
            "nothing may be installed when no node owns the dataset: {changes:?}"
        );
        assert!(log.is_empty(), "and no GL resource may leak: {log:?}");
    }
    // ==================================================================
    // Backend registries (CaptureVTable / AudioCaptureVTable)
    //
    // The three registries are process-global `OnceLock`s, so each is exercised
    // by exactly ONE test (registering from two tests would race). Each backend
    // fn body is deliberately distinct so the linker cannot fold them onto one
    // address and make the identity assertions vacuous.
    // ==================================================================
    fn open_a(index: u32, width: u32, height: u32) -> u64 {
        u64::from(index) + u64::from(width) * 3 + u64::from(height)
    }
    fn read_a(handle: u64, out: &mut Vec<u8>) -> (u32, u32) {
        out.clear();
        out.extend_from_slice(&[1, 2, 3, 4]);
        (handle as u32, 1)
    }
    fn close_a(_handle: u64) {}
    fn open_b(index: u32, width: u32, height: u32) -> u64 {
        u64::from(index) * 7 + u64::from(width) + u64::from(height) * 11
    }
    fn read_b(_handle: u64, out: &mut Vec<u8>) -> (u32, u32) {
        out.push(9);
        (0, 0)
    }
    fn close_b(_handle: u64) {
        // distinct body: the linker must not fold this onto close_a
        let _ = core::hint::black_box(1_u8);
    }
    fn vtable_a() -> CaptureVTable {
        CaptureVTable {
            open: open_a,
            read: read_a,
            close: close_a,
        }
    }
    fn vtable_b() -> CaptureVTable {
        CaptureVTable {
            open: open_b,
            read: read_b,
            close: close_b,
        }
    }
    fn same_vtable(a: CaptureVTable, b: CaptureVTable) -> bool {
        a.open as usize == b.open as usize
            && a.read as usize == b.read as usize
            && a.close as usize == b.close as usize
    }
    #[test]
    fn register_camera_backend_is_first_wins_and_never_overwritten() {
        let before = camera_backend();
        register_camera_backend(vtable_a());
        let first = camera_backend().expect("a backend is registered after the first call");
        // A second registration must be silently ignored, not panic and not swap
        // the vtable out from under a running capture worker.
        register_camera_backend(vtable_b());
        register_camera_backend(vtable_b());
        let after = camera_backend().expect("the backend must still be there");
        assert!(
            same_vtable(first, after),
            "the first registration must win; a later one must not replace it"
        );
        if let Some(pre) = before {
            assert!(
                same_vtable(pre, after),
                "a backend registered before this test must not have been replaced"
            );
        } else {
            assert!(
                same_vtable(vtable_a(), after),
                "camera_backend() must hand back exactly the vtable that was registered"
            );
            // The registered fn pointers must actually be callable through the vtable.
            assert_eq!((after.open)(1, 2, 3), open_a(1, 2, 3));
            assert_eq!((after.open)(u32::MAX, u32::MAX, u32::MAX), open_a(u32::MAX, u32::MAX, u32::MAX));
            let mut buf = vec![0_u8; 8];
            assert_eq!((after.read)(u64::from(u32::MAX), &mut buf), (u32::MAX, 1));
            assert_eq!(buf, vec![1, 2, 3, 4], "read must be able to resize `out`");
            (after.close)(0);
            (after.close)(u64::MAX);
        }
    }
    #[test]
    fn register_screen_backend_is_independent_of_the_camera_backend() {
        let before = screen_backend();
        register_screen_backend(vtable_b());
        let after = screen_backend().expect("a screen backend is registered");
        if let Some(pre) = before {
            assert!(same_vtable(pre, after), "first registration wins");
        } else {
            assert!(
                same_vtable(vtable_b(), after),
                "the screen registry must hand back the screen vtable"
            );
            // Registering into the screen slot must not have leaked into the
            // camera slot (they are separate OnceLocks).
            if let Some(cam) = camera_backend() {
                assert!(
                    !same_vtable(cam, vtable_b()),
                    "the camera registry must not pick up the screen vtable"
                );
            }
            // `(0, 0)` is the documented end-of-stream signal.
            let mut buf = Vec::new();
            assert_eq!((after.read)(0, &mut buf), (0, 0));
        }
    }
    fn mic_open(sample_rate: u32, channels: u16) -> u64 {
        u64::from(sample_rate) * 2 + u64::from(channels)
    }
    fn mic_read(handle: u64, out: &mut Vec<f32>) -> u32 {
        out.clear();
        // NaN / inf / subnormal samples must survive the vtable boundary untouched.
        out.extend_from_slice(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0]);
        (handle % 3) as u32
    }
    fn mic_close(_handle: u64) {}
    fn mic_open_other(sample_rate: u32, channels: u16) -> u64 {
        u64::from(sample_rate) ^ u64::from(channels)
    }
    fn mic_read_other(_handle: u64, out: &mut Vec<f32>) -> u32 {
        out.push(1.0);
        0
    }
    fn mic_close_other(_handle: u64) {
        let _ = core::hint::black_box(2_u8);
    }
    #[test]
    fn register_mic_backend_is_first_wins_and_passes_f32_samples_through() {
        let before = mic_backend();
        register_mic_backend(AudioCaptureVTable {
            open: mic_open,
            read: mic_read,
            close: mic_close,
        });
        register_mic_backend(AudioCaptureVTable {
            open: mic_open_other,
            read: mic_read_other,
            close: mic_close_other,
        });
        let vt = mic_backend().expect("a mic backend is registered");
        if before.is_none() {
            assert_eq!(
                vt.open as usize, mic_open as usize,
                "the first mic registration must win"
            );
            // Boundary sample rates / channel counts must go through untouched.
            assert_eq!((vt.open)(0, 0), 0);
            assert_eq!((vt.open)(u32::MAX, u16::MAX), mic_open(u32::MAX, u16::MAX));
            let mut samples = Vec::new();
            let frames = (vt.read)(4, &mut samples);
            assert_eq!(frames, 1, "the frame count must be the vtable's, verbatim");
            assert_eq!(samples.len(), 4);
            assert!(samples[0].is_nan(), "a NaN sample must not be normalised");
            assert_eq!(samples[1], f32::INFINITY);
            assert_eq!(samples[2], f32::NEG_INFINITY);
            assert!(
                samples[3] == 0.0 && samples[3].is_sign_negative(),
                "-0.0 must keep its sign bit"
            );
            // `0` is the documented EOF/error return.
            assert_eq!((vt.read)(3, &mut samples), 0);
            (vt.close)(u64::MAX);
        }
    }
}