Skip to main content

azul_layout/widgets/
capture_common.rs

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
15use azul_core::resources::UpdateImageType;
16use azul_core::callbacks::Update;
17use azul_core::gl::gl::{RGBA, TEXTURE_2D, UNSIGNED_BYTE};
18use azul_core::gl::{GlContextPtr, OptionU8VecRef, Texture, U8VecRef};
19use azul_core::geom::PhysicalSizeU32;
20use azul_core::refany::RefAny;
21use azul_core::resources::ImageRef;
22use azul_core::video::VideoFrame;
23use azul_css::impl_option_inner; // brought into scope for impl_widget_callback!'s impl_option!
24use azul_css::props::basic::ColorU;
25
26use 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`.
37pub type OnVideoFrameCallbackType = extern "C" fn(RefAny, CallbackInfo, VideoFrame) -> Update;
38impl_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.
46azul_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.
62pub fn invoke_on_frame(
63    hook: &OptionOnVideoFrame,
64    info: &mut CallbackInfo,
65    frame: &VideoFrame,
66) -> Update {
67    match hook {
68        OptionOnVideoFrame::Some(h) => {
69            (h.callback.cb)(h.refany.clone(), *info, frame.clone())
70        }
71        OptionOnVideoFrame::None => Update::DoNothing,
72    }
73}
74
75/// Present `frame` for a video-ish widget and return the (stable) GL texture
76/// id to store back in the widget's state.
77///
78/// - First frame (`current_id` is `None`): allocate a GL texture, upload, wrap
79///   in an external-texture `ImageRef`, and install it on the widget's node
80///   **once** via `change_node_image` (the node is found via
81///   `get_node_id_of_root_dataset(dataset)`). Returns `Some(new_id)`.
82/// - Every frame after: re-upload into the same texture id + recomposite
83///   (`update_all_image_callbacks` -> `ShouldReRenderCurrentWindow`) - no
84///   relayout, no display-list rebuild, since the external texture's wr key
85///   (= the `ImageRef` data pointer) stays stable. Returns `current_id`.
86/// - No GL context (cpurender): installs the frame as a **raw RGBA
87///   `ImageRef`** on the node every frame (same per-frame `new_rawimage` the
88///   `VideoWidget` CPU path uses) - heavier than the GL re-upload (new image
89///   resource per frame) but the tile shows live frames on both renderers.
90pub fn present_frame(
91    info: &mut CallbackInfo,
92    dataset: RefAny,
93    current_id: Option<u32>,
94    frame: &VideoFrame,
95) -> Option<u32> {
96    use azul_core::resources::{RawImage, RawImageData, RawImageFormat};
97
98    let Some(gl) = info.get_gl_context().into_option() else {
99        // CPU renderer: swap the node's image content for this frame.
100        if let Some(img) = ImageRef::new_rawimage(RawImage {
101            pixels: RawImageData::U8(frame.bytes.clone()),
102            width: frame.width as usize,
103            height: frame.height as usize,
104            premultiplied_alpha: false,
105            data_format: RawImageFormat::RGBA8,
106            tag: b"azul-capture-frame".to_vec().into(),
107        }) {
108            if let Some(node) = info.get_node_id_of_root_dataset(dataset) {
109                if let Some(nid) = node.node.into_crate_internal() {
110                    info.change_node_image(node.dom, nid, img, UpdateImageType::Content);
111                }
112            }
113        }
114        return current_id;
115    };
116
117    if let Some(id) = current_id {
118        upload_rgba(&gl, id, frame);
119        info.update_all_image_callbacks();
120        Some(id)
121    } else {
122        let tex = Texture::allocate_rgba8(
123            gl.clone(),
124            PhysicalSizeU32 {
125                width: frame.width,
126                height: frame.height,
127            },
128            ColorU {
129                r: 0,
130                g: 0,
131                b: 0,
132                a: 0,
133            },
134        );
135        let id = tex.texture_id;
136        upload_rgba(&gl, id, frame);
137        let image = ImageRef::new_gltexture(tex);
138        if let Some(node) = info.get_node_id_of_root_dataset(dataset) {
139            if let Some(nid) = node.node.into_crate_internal() {
140                info.change_node_image(node.dom, nid, image, UpdateImageType::Content);
141            }
142        }
143        Some(id)
144    }
145}
146
147/// Upload tightly-packed RGBA8 pixels into the GL texture `texture_id`.
148#[allow(clippy::cast_possible_wrap)] // bounded graphics/coord/counter/fixed-point cast
149pub fn upload_rgba(gl: &GlContextPtr, texture_id: u32, frame: &VideoFrame) {
150    gl.bind_texture(TEXTURE_2D, texture_id);
151    gl.tex_image_2d(
152        TEXTURE_2D,
153        0,
154        RGBA as i32,
155        frame.width as i32,
156        frame.height as i32,
157        0,
158        RGBA,
159        UNSIGNED_BYTE,
160        OptionU8VecRef::Some(U8VecRef::from(frame.bytes.as_ref())),
161    );
162}
163
164/// A platform frame-capture backend (camera / screen), registered by the dll at
165/// startup so the cross-platform capture widgets can pull **real** frames
166/// instead of their built-in test pattern.
167///
168/// The dll provides one per OS (v4l2 on
169/// Linux, `AVFoundation` on macOS, Media Foundation on Windows, `ScreenCaptureKit` /
170/// `PipeWire` / DXGI for screens, ...). These are plain Rust fn pointers - the dll
171/// links azul-layout statically, so registering + calling is a Rust-to-Rust
172/// call, no `extern "C"`/trait-object dance.
173#[derive(Debug, Clone, Copy)]
174pub struct CaptureVTable {
175    /// Open source `index` (camera device / display index) at the requested
176    /// `width` x `height`. Returns an opaque handle, or `0` on failure (the
177    /// worker then falls back to the test pattern).
178    pub open: fn(index: u32, width: u32, height: u32) -> u64,
179    /// Block for the next frame, writing tightly-packed RGBA8 into `out`
180    /// (resized as needed). Returns the actual frame `(width, height)`, or
181    /// `(0, 0)` on end-of-stream / error (the worker then stops + closes).
182    pub read: fn(handle: u64, out: &mut Vec<u8>) -> (u32, u32),
183    /// Close + free the source.
184    pub close: fn(handle: u64),
185}
186
187static CAMERA_BACKEND: std::sync::OnceLock<CaptureVTable> = std::sync::OnceLock::new();
188static SCREEN_BACKEND: std::sync::OnceLock<CaptureVTable> = std::sync::OnceLock::new();
189
190/// Register the platform **camera** capture backend (called once by the dll at
191/// startup; the first registration wins). Without it, `CameraWidget` shows its
192/// test pattern.
193pub fn register_camera_backend(vtable: CaptureVTable) {
194    let _ = CAMERA_BACKEND.set(vtable);
195}
196
197/// Register the platform **screen** capture backend (for `ScreenCaptureWidget`).
198pub fn register_screen_backend(vtable: CaptureVTable) {
199    let _ = SCREEN_BACKEND.set(vtable);
200}
201
202/// The registered camera backend, if the dll provided one for this platform.
203pub fn camera_backend() -> Option<CaptureVTable> {
204    CAMERA_BACKEND.get().copied()
205}
206
207/// The registered screen-capture backend, if any.
208pub fn screen_backend() -> Option<CaptureVTable> {
209    SCREEN_BACKEND.get().copied()
210}
211
212/// A platform **audio**-capture backend (microphone), registered by the dll so
213/// `MicrophoneWidget` can pull real samples instead of the test tone.
214///
215/// Like
216/// [`CaptureVTable`] but yields interleaved `f32` audio rather than RGBA video.
217#[derive(Debug, Clone, Copy)]
218pub struct AudioCaptureVTable {
219    /// Open the default mic at `sample_rate` x `channels`. Opaque handle, or
220    /// `0` on failure.
221    pub open: fn(sample_rate: u32, channels: u16) -> u64,
222    /// Block for the next chunk, writing interleaved `f32` into `out` (resized).
223    /// Returns the frame count (`out.len() / channels`), or `0` on error / EOF
224    /// (the worker then stops + closes).
225    pub read: fn(handle: u64, out: &mut Vec<f32>) -> u32,
226    /// Close + free the source.
227    pub close: fn(handle: u64),
228}
229
230static MIC_BACKEND: std::sync::OnceLock<AudioCaptureVTable> = std::sync::OnceLock::new();
231
232/// Register the platform microphone-capture backend (called once by the dll).
233pub fn register_mic_backend(vtable: AudioCaptureVTable) {
234    let _ = MIC_BACKEND.set(vtable);
235}
236
237/// The registered mic-capture backend, if the dll provided one for this platform.
238pub fn mic_backend() -> Option<AudioCaptureVTable> {
239    MIC_BACKEND.get().copied()
240}
241
242#[cfg(test)]
243#[allow(clippy::too_many_lines)] // table-driven cases; splitting them hides the case list
244mod autotest_generated {
245    use std::{
246        collections::{BTreeMap, HashMap},
247        panic::{catch_unwind, AssertUnwindSafe},
248        rc::Rc,
249        sync::{Arc, Mutex, PoisonError},
250    };
251
252    use azul_core::{
253        dom::{Dom, DomId, DomNodeId, NodeId, NodeType},
254        geom::{LogicalRect, OptionLogicalPosition},
255        gl::{GenericGlContext, OptionGlContextPtr, GLvoid},
256        hit_test::ScrollPosition,
257        refany::OptionRefAny,
258        resources::{DecodedImage, RendererResources},
259        styled_dom::{NodeHierarchyItemId, StyledDom},
260        window::{MonitorVec, RawWindowHandle, RendererType},
261    };
262    use azul_css::system::SystemStyle;
263    use rust_fontconfig::FcFontCache;
264
265    use super::*;
266    #[cfg(feature = "icu")]
267    use crate::icu::IcuLocalizerHandle;
268    use crate::{
269        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
270        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
271        window::{DomLayoutResult, LayoutWindow},
272        window_state::FullWindowState,
273    };
274
275    // ------------------------------------------------------------------
276    // Fake GL drivers
277    //
278    // Every field of `GenericGlContext` is a `*mut c_void` entry point, and
279    // gl-context-loader null-checks each one before transmuting + calling it
280    // (returning a default instead). So an all-zero context is a SAFE no-op
281    // "driver never loaded" GL, and a context with only the three entry points
282    // this module actually uses filled in is a safe *recording* driver: we can
283    // observe exactly which GL calls `upload_rgba` / `present_frame` emit, with
284    // which arguments, entirely off-GPU.
285    // ------------------------------------------------------------------
286
287    /// The texture name the recording driver hands out from `glGenTextures`.
288    const RECORDED_TEXTURE_ID: u32 = 42;
289
290    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
291    enum GlCall {
292        GenTextures {
293            n: i32,
294        },
295        BindTexture {
296            target: u32,
297            texture: u32,
298        },
299        TexImage2d {
300            target: u32,
301            level: i32,
302            internal_format: i32,
303            width: i32,
304            height: i32,
305            border: i32,
306            format: u32,
307            ty: u32,
308            /// `false` = the `NULL` pixel pointer `Texture::allocate_rgba8` uses,
309            /// `true` = a real pixel upload (what `upload_rgba` does).
310            has_pixels: bool,
311        },
312    }
313
314    static GL_LOG: Mutex<Vec<GlCall>> = Mutex::new(Vec::new());
315    /// Serializes the tests that use the (process-global) recording driver.
316    static GL_SERIAL: Mutex<()> = Mutex::new(());
317
318    fn gl_log_push(call: GlCall) {
319        GL_LOG
320            .lock()
321            .unwrap_or_else(PoisonError::into_inner)
322            .push(call);
323    }
324
325    extern "system" fn rec_gen_textures(n: i32, out: *mut u32) {
326        gl_log_push(GlCall::GenTextures { n });
327        // The caller (gl-context-loader) always passes a `Vec<GLuint>` of len `n`.
328        for i in 0..n.max(0) {
329            // SAFETY: `out` addresses `n` writable `GLuint`s (a `vec![0; n]`).
330            unsafe { out.add(i as usize).write(RECORDED_TEXTURE_ID + i as u32) };
331        }
332    }
333
334    extern "system" fn rec_bind_texture(target: u32, texture: u32) {
335        gl_log_push(GlCall::BindTexture { target, texture });
336    }
337
338    #[allow(clippy::too_many_arguments)] // must mirror glTexImage2D exactly
339    extern "system" fn rec_tex_image_2d(
340        target: u32,
341        level: i32,
342        internal_format: i32,
343        width: i32,
344        height: i32,
345        border: i32,
346        format: u32,
347        ty: u32,
348        pixels: *const GLvoid,
349    ) {
350        gl_log_push(GlCall::TexImage2d {
351            target,
352            level,
353            internal_format,
354            width,
355            height,
356            border,
357            format,
358            ty,
359            has_pixels: !pixels.is_null(),
360        });
361    }
362
363    /// A GL context whose entry points are all `NULL` (driver never loaded).
364    fn null_gl_context() -> GlContextPtr {
365        // SAFETY: every field of `GenericGlContext` is a raw pointer, for which
366        // the all-zero (NULL) bit pattern is valid.
367        let ctx: GenericGlContext = unsafe { core::mem::zeroed() };
368        GlContextPtr::new(RendererType::Software, Rc::new(ctx))
369    }
370
371    /// A GL context that records the calls this module makes (and nothing else:
372    /// `glTexParameteri` / `glGetIntegerv` / `glDeleteTextures` stay NULL, i.e.
373    /// safe no-ops).
374    fn recording_gl_context() -> GlContextPtr {
375        // SAFETY: as above — NULL is a valid value for every field; the three we
376        // overwrite get fn pointers with exactly the signatures gl-context-loader
377        // transmutes them back to.
378        let mut ctx: GenericGlContext = unsafe { core::mem::zeroed() };
379        ctx.glGenTextures = rec_gen_textures as *const () as *mut azul_core::gl::c_void;
380        ctx.glBindTexture = rec_bind_texture as *const () as *mut azul_core::gl::c_void;
381        ctx.glTexImage2D = rec_tex_image_2d as *const () as *mut azul_core::gl::c_void;
382        GlContextPtr::new(RendererType::Software, Rc::new(ctx))
383    }
384
385    /// Runs `f` against the recording driver and returns the GL calls it made.
386    fn with_recorded_gl<R>(f: impl FnOnce(GlContextPtr) -> R) -> (R, Vec<GlCall>) {
387        let _serial = GL_SERIAL.lock().unwrap_or_else(PoisonError::into_inner);
388        GL_LOG
389            .lock()
390            .unwrap_or_else(PoisonError::into_inner)
391            .clear();
392        let out = f(recording_gl_context());
393        let log = GL_LOG
394            .lock()
395            .unwrap_or_else(PoisonError::into_inner)
396            .clone();
397        (out, log)
398    }
399
400    // ------------------------------------------------------------------
401    // CallbackInfo harness (mirrors the other widget test modules)
402    // ------------------------------------------------------------------
403
404    /// A `DomLayoutResult` with an *empty* layout tree: the code under test only
405    /// walks `styled_dom.node_data`, so no real layout (and no font) is needed.
406    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
407        DomLayoutResult {
408            styled_dom,
409            layout_tree: LayoutTree {
410                nodes: Vec::new(),
411                warm: Vec::new(),
412                cold: Vec::new(),
413                root: 0,
414                dom_to_layout: BTreeMap::new(),
415                children_arena: Vec::new(),
416                children_offsets: Vec::new(),
417                subtree_needs_intrinsic: Vec::new(),
418            },
419            calculated_positions: Vec::new(),
420            viewport: LogicalRect::zero(),
421            display_list: DisplayList::default(),
422            scroll_ids: HashMap::new(),
423            scroll_id_to_node_id: HashMap::new(),
424        }
425    }
426
427    /// Invokes `f` with a `CallbackInfo` over a window holding `styled` (or no
428    /// layout results at all, when `styled` is `None`) and the given GL context.
429    /// Returns `f`'s value plus every `CallbackChange` the callback recorded.
430    fn with_callback_info<R>(
431        styled: Option<StyledDom>,
432        gl_context: OptionGlContextPtr,
433        f: impl FnOnce(&mut CallbackInfo) -> R,
434    ) -> (R, Vec<CallbackChange>) {
435        let mut layout_window =
436            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
437        if let Some(sd) = styled {
438            layout_window
439                .layout_results
440                .insert(DomId::ROOT_ID, layout_result(sd));
441        }
442
443        let renderer_resources = RendererResources::default();
444        let previous_window_state: Option<FullWindowState> = None;
445        let current_window_state = FullWindowState::default();
446        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
447            BTreeMap::new();
448        let window_handle = RawWindowHandle::Unsupported;
449        let system_callbacks = ExternalSystemCallbacks::rust_internal();
450
451        let ref_data = CallbackInfoRefData {
452            layout_window: &layout_window,
453            renderer_resources: &renderer_resources,
454            previous_window_state: &previous_window_state,
455            current_window_state: &current_window_state,
456            gl_context: &gl_context,
457            current_scroll_manager: &scroll_states,
458            current_window_handle: &window_handle,
459            system_callbacks: &system_callbacks,
460            system_style: Arc::new(SystemStyle::default()),
461            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
462            #[cfg(feature = "icu")]
463            icu_localizer: IcuLocalizerHandle::default(),
464            ctx: OptionRefAny::None,
465        };
466
467        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
468
469        let mut info = CallbackInfo::new(
470            &ref_data,
471            &changes,
472            DomNodeId {
473                dom: DomId::ROOT_ID,
474                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
475            },
476            OptionLogicalPosition::None,
477            OptionLogicalPosition::None,
478        );
479
480        let out = f(&mut info);
481        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
482        (out, recorded)
483    }
484
485    // ------------------------------------------------------------------
486    // Fixtures
487    // ------------------------------------------------------------------
488
489    /// The dataset type a capture widget stores on its node.
490    #[derive(Debug, Default)]
491    struct CamState {
492        _texture_id: Option<u32>,
493    }
494
495    /// A *different* dataset type, to prove the node lookup is type-scoped.
496    #[derive(Debug, Default)]
497    struct OtherState {
498        _unused: u8,
499    }
500
501    /// A `div`, carrying `ds` as its dataset when there is one.
502    fn div_with(ds: Option<RefAny>) -> Dom {
503        let d = Dom::create_node(NodeType::Div);
504        match ds {
505            Some(r) => d.with_dataset(OptionRefAny::Some(r)),
506            None => d,
507        }
508    }
509
510    /// `body(0) -> div(1) -> div(2)`, where a `Some(ds)` gives that div a dataset.
511    fn dom_with_datasets(first: Option<RefAny>, second: Option<RefAny>) -> StyledDom {
512        let dom = Dom::create_node(NodeType::Body)
513            .with_child(div_with(first))
514            .with_child(div_with(second));
515        let styled = StyledDom::create_from_dom(dom);
516        assert_eq!(
517            styled.node_hierarchy.as_ref().len(),
518            3,
519            "fixture must flatten to exactly body + 2 divs"
520        );
521        styled
522    }
523
524    /// A `width` x `height` RGBA8 frame with a deterministic (tightly-packed) ramp.
525    /// Only ever called with tiny dimensions — `width * height * 4` is allocated.
526    fn frame(width: u32, height: u32) -> VideoFrame {
527        let len = (width as usize) * (height as usize) * 4;
528        let bytes: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
529        VideoFrame::new(width, height, bytes.into())
530    }
531
532    /// A frame whose *declared* dimensions need not match its byte count.
533    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
534        VideoFrame::new(width, height, bytes.into())
535    }
536
537    /// Every image installed on a node, as `(dom, node index, image, update type)`.
538    fn image_installs(
539        changes: &[CallbackChange],
540    ) -> Vec<(DomId, usize, &ImageRef, UpdateImageType)> {
541        changes
542            .iter()
543            .filter_map(|c| match c {
544                CallbackChange::ChangeNodeImage {
545                    dom_id,
546                    node_id,
547                    image,
548                    update_type,
549                } => Some((*dom_id, node_id.index(), image, *update_type)),
550                _ => None,
551            })
552            .collect()
553    }
554
555    /// How many "recomposite, don't relayout" requests the callback made.
556    fn recomposites(changes: &[CallbackChange]) -> usize {
557        changes
558            .iter()
559            .filter(|c| matches!(c, CallbackChange::UpdateAllImageCallbacks))
560            .count()
561    }
562
563    // ==================================================================
564    // invoke_on_frame
565    // ==================================================================
566
567    /// Payload of the `on_frame` hook: records every frame it is handed.
568    #[derive(Debug)]
569    struct HookLog {
570        seen: Vec<(u32, u32, usize, Option<u8>)>,
571        reply: Update,
572    }
573
574    extern "C" fn hook_record(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
575        let mut reply = Update::DoNothing;
576        if let Some(mut log) = data.downcast_mut::<HookLog>() {
577            let bytes = frame.bytes.as_ref();
578            log.seen
579                .push((frame.width, frame.height, bytes.len(), bytes.first().copied()));
580            reply = log.reply;
581        }
582        reply
583    }
584
585    /// A hook that writes through the `CallbackInfo` it was handed (by value).
586    extern "C" fn hook_recomposite(_: RefAny, mut info: CallbackInfo, _: VideoFrame) -> Update {
587        info.update_all_image_callbacks();
588        Update::RefreshDomAllWindows
589    }
590
591    fn hook(cb: OnVideoFrameCallbackType, data: RefAny) -> OptionOnVideoFrame {
592        OptionOnVideoFrame::Some(OnVideoFrame {
593            refany: data,
594            callback: cb.into(),
595        })
596    }
597
598    fn hook_seen(data: &mut RefAny) -> Vec<(u32, u32, usize, Option<u8>)> {
599        data.downcast_ref::<HookLog>()
600            .expect("payload must still be a HookLog")
601            .seen
602            .clone()
603    }
604
605    #[test]
606    fn invoke_on_frame_without_a_hook_is_do_nothing_and_touches_nothing() {
607        let (update, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
608            invoke_on_frame(&OptionOnVideoFrame::None, info, &frame(2, 2))
609        });
610        assert_eq!(
611            update,
612            Update::DoNothing,
613            "an unset on_frame hook must be a no-op"
614        );
615        assert!(
616            changes.is_empty(),
617            "an unset hook must not record any change, got {changes:?}"
618        );
619    }
620
621    #[test]
622    fn invoke_on_frame_returns_the_hooks_update_verbatim() {
623        for reply in [
624            Update::DoNothing,
625            Update::RefreshDom,
626            Update::RefreshDomAllWindows,
627        ] {
628            let data = RefAny::new(HookLog {
629                seen: Vec::new(),
630                reply,
631            });
632            let h = hook(hook_record, data);
633            let (update, _) = with_callback_info(None, OptionGlContextPtr::None, |info| {
634                invoke_on_frame(&h, info, &frame(1, 1))
635            });
636            assert_eq!(
637                update, reply,
638                "invoke_on_frame must return the user's Update unchanged"
639            );
640        }
641    }
642
643    #[test]
644    fn invoke_on_frame_forwards_every_frame_into_the_hooks_shared_refany() {
645        let mut data = RefAny::new(HookLog {
646            seen: Vec::new(),
647            reply: Update::RefreshDom,
648        });
649        let h = hook(hook_record, data.clone());
650
651        // The hook is handed a *clone* of its RefAny on every invocation — the
652        // backreference DI pattern only works if that clone shares the payload.
653        with_callback_info(None, OptionGlContextPtr::None, |info| {
654            for (w, hgt) in [(1_u32, 1_u32), (2, 3), (4, 4)] {
655                invoke_on_frame(&h, info, &frame(w, hgt));
656            }
657        });
658
659        assert_eq!(
660            hook_seen(&mut data),
661            vec![
662                (1, 1, 4, Some(0)),
663                (2, 3, 24, Some(0)),
664                (4, 4, 64, Some(0)),
665            ],
666            "every frame must reach the hook, in order, with its bytes intact"
667        );
668    }
669
670    #[test]
671    fn invoke_on_frame_forwards_degenerate_frames_unvalidated_and_without_panicking() {
672        let mut data = RefAny::new(HookLog {
673            seen: Vec::new(),
674            reply: Update::DoNothing,
675        });
676        let h = hook(hook_record, data.clone());
677
678        with_callback_info(None, OptionGlContextPtr::None, |info| {
679            // 0x0, dimensions that disagree with the byte count, and dimensions
680            // whose tight-packing size (w*h*4) overflows usize. `invoke_on_frame`
681            // must hand all of them to the hook as-is: it is a pure forwarder and
682            // must never multiply the dimensions out.
683            invoke_on_frame(&h, info, &frame_raw(0, 0, Vec::new()));
684            invoke_on_frame(&h, info, &frame_raw(9, 9, vec![7, 8, 9]));
685            invoke_on_frame(&h, info, &frame_raw(u32::MAX, u32::MAX, Vec::new()));
686            invoke_on_frame(&h, info, &frame_raw(u32::MAX, 1, vec![255]));
687        });
688
689        assert_eq!(
690            hook_seen(&mut data),
691            vec![
692                (0, 0, 0, None),
693                (9, 9, 3, Some(7)),
694                (u32::MAX, u32::MAX, 0, None),
695                (u32::MAX, 1, 1, Some(255)),
696            ],
697            "invoke_on_frame must forward frames verbatim, without validating them"
698        );
699    }
700
701    #[test]
702    fn invoke_on_frame_hook_writes_through_the_shared_callback_info() {
703        // `invoke_on_frame` passes `*info` (CallbackInfo is Copy) — the copy must
704        // still write into the *caller's* transaction container.
705        let h = hook(hook_recomposite, RefAny::new(OtherState::default()));
706        let (update, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
707            invoke_on_frame(&h, info, &frame(1, 1))
708        });
709
710        assert_eq!(update, Update::RefreshDomAllWindows);
711        assert_eq!(
712            recomposites(&changes),
713            1,
714            "a change made by the hook must be visible to the widget's writeback"
715        );
716    }
717
718    // ==================================================================
719    // upload_rgba
720    // ==================================================================
721
722    #[test]
723    fn upload_rgba_forwards_the_texture_id_and_the_rgba8_constants() {
724        for id in [0_u32, 1, 7, u32::MAX] {
725            let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, id, &frame(2, 2)));
726            assert_eq!(
727                log,
728                vec![
729                    GlCall::BindTexture {
730                        target: TEXTURE_2D,
731                        texture: id,
732                    },
733                    GlCall::TexImage2d {
734                        target: TEXTURE_2D,
735                        level: 0,
736                        internal_format: RGBA as i32,
737                        width: 2,
738                        height: 2,
739                        border: 0,
740                        format: RGBA,
741                        ty: UNSIGNED_BYTE,
742                        has_pixels: true,
743                    },
744                ],
745                "upload_rgba must bind exactly texture {id} and upload tightly-packed RGBA8"
746            );
747        }
748    }
749
750    #[test]
751    fn upload_rgba_zero_sized_frame_is_forwarded_as_a_0x0_upload() {
752        let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, 3, &frame_raw(0, 0, Vec::new())));
753        assert_eq!(
754            log,
755            vec![
756                GlCall::BindTexture {
757                    target: TEXTURE_2D,
758                    texture: 3,
759                },
760                GlCall::TexImage2d {
761                    target: TEXTURE_2D,
762                    level: 0,
763                    internal_format: RGBA as i32,
764                    width: 0,
765                    height: 0,
766                    border: 0,
767                    format: RGBA,
768                    ty: UNSIGNED_BYTE,
769                    has_pixels: true,
770                },
771            ],
772            "a 0x0 frame must still be a well-formed (if empty) glTexImage2D, not a panic"
773        );
774    }
775
776    #[test]
777    fn upload_rgba_dimensions_above_i32_max_wrap_to_negative_glsizei() {
778        // glTexImage2D takes GLsizei (= i32), so a u32 dimension > i32::MAX is a
779        // lossy cast. Assert the *exact* wrapped value: GL then rejects the call
780        // with GL_INVALID_VALUE (the frame is dropped) — the cast must never be a
781        // debug-mode overflow panic or UB.
782        let cases: [(u32, u32, i32, i32); 4] = [
783            (i32::MAX as u32, 1, i32::MAX, 1),
784            (i32::MAX as u32 + 1, 1, i32::MIN, 1),
785            (u32::MAX, u32::MAX, -1, -1),
786            (u32::MAX - 1, 2, -2, 2),
787        ];
788
789        for (w, h, want_w, want_h) in cases {
790            // Empty byte buffer: the huge dimensions must never be multiplied out
791            // (that would be a several-exabyte allocation), only cast.
792            let ((), log) = with_recorded_gl(|gl| upload_rgba(&gl, 1, &frame_raw(w, h, Vec::new())));
793            let tex = log
794                .iter()
795                .find_map(|c| match c {
796                    GlCall::TexImage2d { width, height, .. } => Some((*width, *height)),
797                    _ => None,
798                })
799                .expect("upload_rgba must always call glTexImage2D");
800            assert_eq!(
801                tex,
802                (want_w, want_h),
803                "{w}x{h} must cast to GLsizei {want_w}x{want_h}"
804            );
805        }
806    }
807
808    #[test]
809    fn upload_rgba_against_an_unloaded_driver_is_a_silent_no_op() {
810        // is_gl_usable() == false (all entry points NULL): the loader must swallow
811        // every call rather than jumping through a NULL function pointer.
812        let gl = null_gl_context();
813        upload_rgba(&gl, 0, &frame(2, 2));
814        upload_rgba(&gl, u32::MAX, &frame_raw(u32::MAX, u32::MAX, Vec::new()));
815        upload_rgba(&gl, 1, &frame_raw(0, 0, Vec::new()));
816    }
817
818    // ==================================================================
819    // present_frame — CPU (no GL context)
820    // ==================================================================
821
822    #[test]
823    fn present_frame_without_gl_installs_a_raw_image_on_the_dataset_node() {
824        let ds = RefAny::new(CamState::default());
825        let styled = dom_with_datasets(Some(ds.clone()), None);
826
827        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
828            present_frame(info, ds.clone(), None, &frame(4, 4))
829        });
830
831        // The CPU path never allocates a GL texture, so it must hand back the id it
832        // was given (None) rather than inventing one.
833        assert_eq!(id, None, "the cpurender path must not invent a texture id");
834
835        let installs = image_installs(&changes);
836        assert_eq!(installs.len(), 1, "exactly one image install per frame");
837        let (dom_id, node_idx, image, update_type) = installs[0];
838        assert_eq!(dom_id, DomId::ROOT_ID);
839        assert_eq!(node_idx, 1, "the image must land on the dataset's node");
840        assert_eq!(update_type, UpdateImageType::Content);
841        match image.get_data() {
842            DecodedImage::Raw((descriptor, _)) => {
843                assert_eq!(
844                    (descriptor.width, descriptor.height),
845                    (4, 4),
846                    "the installed image must keep the frame's dimensions"
847                );
848            }
849            other => panic!("cpurender must install a raw image, got {other:?}"),
850        }
851        assert_eq!(
852            recomposites(&changes),
853            0,
854            "the CPU path swaps the node's image instead of recompositing a texture"
855        );
856    }
857
858    #[test]
859    fn present_frame_without_gl_returns_the_current_id_verbatim() {
860        for current in [None, Some(0_u32), Some(1), Some(u32::MAX)] {
861            let ds = RefAny::new(CamState::default());
862            let styled = dom_with_datasets(Some(ds.clone()), None);
863            let (id, changes) =
864                with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
865                    present_frame(info, ds.clone(), current, &frame(2, 2))
866                });
867            assert_eq!(
868                id, current,
869                "the cpurender path must round-trip current_id ({current:?}) untouched"
870            );
871            assert_eq!(
872                image_installs(&changes).len(),
873                1,
874                "the CPU path re-installs the image on *every* frame"
875            );
876        }
877    }
878
879    #[test]
880    fn present_frame_without_gl_and_without_a_matching_dataset_installs_nothing() {
881        // Node carries `OtherState`, the widget looks for `CamState`.
882        let node_ds = RefAny::new(OtherState::default());
883        let styled = dom_with_datasets(Some(node_ds), None);
884        let search = RefAny::new(CamState::default());
885
886        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
887            present_frame(info, search.clone(), Some(9), &frame(2, 2))
888        });
889
890        assert_eq!(id, Some(9), "a failed node lookup must not lose the id");
891        assert!(
892            changes.is_empty(),
893            "no node owns the dataset, so nothing may be installed: {changes:?}"
894        );
895    }
896
897    #[test]
898    fn present_frame_without_gl_and_without_any_layout_result_installs_nothing() {
899        let ds = RefAny::new(CamState::default());
900        let (id, changes) = with_callback_info(None, OptionGlContextPtr::None, |info| {
901            present_frame(info, ds.clone(), Some(3), &frame(2, 2))
902        });
903        assert_eq!(id, Some(3));
904        assert!(
905            changes.is_empty(),
906            "an empty window must not be written to: {changes:?}"
907        );
908    }
909
910    #[test]
911    fn present_frame_without_gl_rejects_a_frame_whose_byte_count_disagrees_with_its_size() {
912        // A backend that lies about the frame size (or a short read) must not be
913        // able to install a bogus image — RawImage validates len == w*h*4.
914        for (w, h, bytes) in [
915            (4_u32, 4_u32, vec![0_u8; 3]),        // far too short
916            (4, 4, vec![0_u8; 63]),               // one byte short
917            (4, 4, vec![0_u8; 65]),               // one byte long
918            (2, 2, Vec::new()),                   // no pixels at all
919        ] {
920            let ds = RefAny::new(CamState::default());
921            let styled = dom_with_datasets(Some(ds.clone()), None);
922            let (id, changes) =
923                with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
924                    present_frame(info, ds.clone(), Some(5), &frame_raw(w, h, bytes.clone()))
925                });
926
927            assert_eq!(id, Some(5), "a rejected frame must not disturb the id");
928            assert!(
929                changes.is_empty(),
930                "a {w}x{h} frame with {} bytes must be rejected, not installed: {changes:?}",
931                bytes.len()
932            );
933        }
934    }
935
936    #[test]
937    fn present_frame_without_gl_installs_a_degenerate_image_for_a_0x0_frame() {
938        // 0*0*4 == 0 == len(bytes), so a 0x0 frame passes RawImage's length check
939        // and IS installed (as a 0x0 image). Pin the behaviour: it must at least
940        // not panic and must not corrupt the returned id.
941        let ds = RefAny::new(CamState::default());
942        let styled = dom_with_datasets(Some(ds.clone()), None);
943        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
944            present_frame(info, ds.clone(), Some(2), &frame_raw(0, 0, Vec::new()))
945        });
946
947        assert_eq!(id, Some(2));
948        let installs = image_installs(&changes);
949        assert_eq!(installs.len(), 1);
950        match installs[0].2.get_data() {
951            DecodedImage::Raw((descriptor, _)) => {
952                assert_eq!((descriptor.width, descriptor.height), (0, 0));
953            }
954            other => panic!("expected a raw image, got {other:?}"),
955        }
956    }
957
958    #[test]
959    fn present_frame_without_gl_survives_dimensions_whose_byte_count_overflows_usize() {
960        // ADVERSARIAL: a backend reporting 2^31 x 2^31 makes the CPU path compute
961        // `width * height * 4` in usize inside `RawImage::into_loaded_image_source`
962        // -> 2^64, which overflows.
963        //
964        // Today that is an arithmetic-overflow PANIC in a debug build (and a
965        // silent wrap to 0 in release, which then *accepts* the empty byte buffer
966        // as a valid 2^31 x 2^31 image). Neither is a graceful rejection — see the
967        // autotest report. What must hold in *both* modes is the one invariant we
968        // can still assert: the caller's texture id is never corrupted, and no GL
969        // work is attempted.
970        let ds = RefAny::new(CamState::default());
971        let styled = dom_with_datasets(Some(ds.clone()), None);
972
973        let (result, _changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
974            catch_unwind(AssertUnwindSafe(|| {
975                present_frame(
976                    info,
977                    ds.clone(),
978                    Some(11),
979                    &frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()),
980                )
981            }))
982        });
983
984        match result {
985            Ok(id) => assert_eq!(
986                id,
987                Some(11),
988                "the cpurender path must always hand back current_id"
989            ),
990            Err(_) => eprintln!(
991                "NOTE: present_frame panicked (usize overflow of width*height*4) for a \
992                 2^31 x 2^31 frame — a malformed capture backend can take the process down"
993            ),
994        }
995    }
996
997    #[test]
998    fn present_frame_installs_exactly_one_image_when_two_nodes_share_a_dataset_type() {
999        // Two capture widgets of the same state type in one DOM: the lookup scores
1000        // candidates by RefAny instance id, so *which* node wins is an internal
1001        // detail — but it must pick exactly ONE, and it must be a node that
1002        // actually owns a dataset (never the body at index 0, never both).
1003        let styled = dom_with_datasets(
1004            Some(RefAny::new(CamState::default())),
1005            Some(RefAny::new(CamState::default())),
1006        );
1007        let search = RefAny::new(CamState::default());
1008
1009        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
1010            present_frame(info, search.clone(), Some(4), &frame(2, 2))
1011        });
1012
1013        assert_eq!(id, Some(4));
1014        let installs = image_installs(&changes);
1015        assert_eq!(
1016            installs.len(),
1017            1,
1018            "a frame must never be installed on two nodes at once: {changes:?}"
1019        );
1020        assert!(
1021            installs[0].1 == 1 || installs[0].1 == 2,
1022            "the image must land on a node that owns a dataset, not on node {}",
1023            installs[0].1
1024        );
1025    }
1026
1027    #[test]
1028    fn present_frame_matches_datasets_by_type_id_not_by_identity() {
1029        // FOOTGUN: the lookup compares *type ids*, so a completely unrelated
1030        // RefAny of the same type finds the node. Two capture widgets sharing a
1031        // state type would therefore fight over one node.
1032        let node_ds = RefAny::new(CamState::default());
1033        let styled = dom_with_datasets(Some(node_ds), None);
1034
1035        let unrelated = RefAny::new(CamState::default()); // a different allocation
1036        let (id, changes) = with_callback_info(Some(styled), OptionGlContextPtr::None, |info| {
1037            present_frame(info, unrelated.clone(), None, &frame(2, 2))
1038        });
1039
1040        assert_eq!(id, None);
1041        assert_eq!(
1042            image_installs(&changes).len(),
1043            1,
1044            "an unrelated RefAny of the same type still resolves to the node"
1045        );
1046    }
1047
1048    // ==================================================================
1049    // present_frame — GL
1050    // ==================================================================
1051
1052    #[test]
1053    fn present_frame_with_gl_first_frame_allocates_uploads_and_installs_once() {
1054        let ds = RefAny::new(CamState::default());
1055        let styled = dom_with_datasets(Some(ds.clone()), None);
1056
1057        let ((id, changes), log) = with_recorded_gl(|gl| {
1058            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
1059                present_frame(info, ds.clone(), None, &frame(4, 4))
1060            })
1061        });
1062
1063        assert_eq!(
1064            id,
1065            Some(RECORDED_TEXTURE_ID),
1066            "the first frame must hand back the texture name the driver allocated"
1067        );
1068
1069        // Installed exactly once, as an external GL texture, on the dataset's node.
1070        let installs = image_installs(&changes);
1071        assert_eq!(installs.len(), 1, "the node's image is installed ONCE");
1072        assert_eq!(installs[0].1, 1);
1073        assert_eq!(installs[0].3, UpdateImageType::Content);
1074        match installs[0].2.get_data() {
1075            DecodedImage::Gl(texture) => {
1076                assert_eq!(texture.texture_id, RECORDED_TEXTURE_ID);
1077                assert_eq!(
1078                    (texture.size.width, texture.size.height),
1079                    (4, 4),
1080                    "the external texture must be sized like the frame"
1081                );
1082            }
1083            other => panic!("the GL path must install an external texture, got {other:?}"),
1084        }
1085        assert_eq!(
1086            recomposites(&changes),
1087            0,
1088            "the install itself rebuilds the display list; no extra recomposite needed"
1089        );
1090
1091        // One texture allocated, and the pixels really were uploaded into it.
1092        assert_eq!(
1093            log.iter()
1094                .filter(|c| matches!(c, GlCall::GenTextures { .. }))
1095                .count(),
1096            1,
1097            "exactly one texture may be allocated for the first frame"
1098        );
1099        assert_eq!(
1100            log.last(),
1101            Some(&GlCall::TexImage2d {
1102                target: TEXTURE_2D,
1103                level: 0,
1104                internal_format: RGBA as i32,
1105                width: 4,
1106                height: 4,
1107                border: 0,
1108                format: RGBA,
1109                ty: UNSIGNED_BYTE,
1110                has_pixels: true,
1111            }),
1112            "the last GL call must be the pixel upload, not the empty allocation"
1113        );
1114        assert!(
1115            log.contains(&GlCall::BindTexture {
1116                target: TEXTURE_2D,
1117                texture: RECORDED_TEXTURE_ID,
1118            }),
1119            "the upload must target the freshly allocated texture: {log:?}"
1120        );
1121    }
1122
1123    #[test]
1124    fn present_frame_with_gl_later_frames_reupload_into_the_same_texture() {
1125        let ds = RefAny::new(CamState::default());
1126        let styled = dom_with_datasets(Some(ds.clone()), None);
1127
1128        let ((id, changes), log) = with_recorded_gl(|gl| {
1129            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
1130                present_frame(info, ds.clone(), Some(RECORDED_TEXTURE_ID), &frame(4, 4))
1131            })
1132        });
1133
1134        assert_eq!(id, Some(RECORDED_TEXTURE_ID), "the texture id must stay stable");
1135        assert!(
1136            image_installs(&changes).is_empty(),
1137            "steady-state frames must NOT re-install the node's image (that would \
1138             rebuild the display list every frame): {changes:?}"
1139        );
1140        assert_eq!(
1141            recomposites(&changes),
1142            1,
1143            "a steady-state frame recomposites exactly once"
1144        );
1145        assert_eq!(
1146            log,
1147            vec![
1148                GlCall::BindTexture {
1149                    target: TEXTURE_2D,
1150                    texture: RECORDED_TEXTURE_ID,
1151                },
1152                GlCall::TexImage2d {
1153                    target: TEXTURE_2D,
1154                    level: 0,
1155                    internal_format: RGBA as i32,
1156                    width: 4,
1157                    height: 4,
1158                    border: 0,
1159                    format: RGBA,
1160                    ty: UNSIGNED_BYTE,
1161                    has_pixels: true,
1162                },
1163            ],
1164            "a steady-state frame must be exactly one re-upload — no new texture"
1165        );
1166    }
1167
1168    #[test]
1169    fn present_frame_with_gl_round_trips_extreme_texture_ids() {
1170        for current in [Some(0_u32), Some(u32::MAX)] {
1171            let ds = RefAny::new(CamState::default());
1172            let styled = dom_with_datasets(Some(ds.clone()), None);
1173
1174            let ((id, changes), log) = with_recorded_gl(|gl| {
1175                with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
1176                    present_frame(info, ds.clone(), current, &frame(1, 1))
1177                })
1178            });
1179
1180            assert_eq!(
1181                id, current,
1182                "a stored texture id must survive the writeback unchanged"
1183            );
1184            assert_eq!(recomposites(&changes), 1);
1185            assert!(
1186                log.contains(&GlCall::BindTexture {
1187                    target: TEXTURE_2D,
1188                    texture: current.expect("current is Some"),
1189                }),
1190                "the id must be forwarded to glBindTexture verbatim: {log:?}"
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn present_frame_with_gl_first_frame_without_a_matching_node_still_returns_the_id() {
1197        // The node lookup fails (no dataset of that type), so the freshly created
1198        // ImageRef — and with it the GL texture — is dropped again, yet the id is
1199        // still handed back and will be re-uploaded into on every later frame.
1200        let styled = dom_with_datasets(Some(RefAny::new(OtherState::default())), None);
1201        let search = RefAny::new(CamState::default());
1202
1203        let ((id, changes), log) = with_recorded_gl(|gl| {
1204            with_callback_info(Some(styled), OptionGlContextPtr::Some(gl), |info| {
1205                present_frame(info, search.clone(), None, &frame(2, 2))
1206            })
1207        });
1208
1209        assert_eq!(id, Some(RECORDED_TEXTURE_ID));
1210        assert!(
1211            changes.is_empty(),
1212            "nothing may be installed when no node owns the dataset: {changes:?}"
1213        );
1214        assert_eq!(
1215            log.iter()
1216                .filter(|c| matches!(c, GlCall::GenTextures { .. }))
1217                .count(),
1218            1,
1219            "a texture is allocated even though it can never be shown"
1220        );
1221    }
1222
1223    // ==================================================================
1224    // Backend registries (CaptureVTable / AudioCaptureVTable)
1225    //
1226    // The three registries are process-global `OnceLock`s, so each is exercised
1227    // by exactly ONE test (registering from two tests would race). Each backend
1228    // fn body is deliberately distinct so the linker cannot fold them onto one
1229    // address and make the identity assertions vacuous.
1230    // ==================================================================
1231
1232    fn open_a(index: u32, width: u32, height: u32) -> u64 {
1233        u64::from(index) + u64::from(width) * 3 + u64::from(height)
1234    }
1235    fn read_a(handle: u64, out: &mut Vec<u8>) -> (u32, u32) {
1236        out.clear();
1237        out.extend_from_slice(&[1, 2, 3, 4]);
1238        (handle as u32, 1)
1239    }
1240    fn close_a(_handle: u64) {}
1241
1242    fn open_b(index: u32, width: u32, height: u32) -> u64 {
1243        u64::from(index) * 7 + u64::from(width) + u64::from(height) * 11
1244    }
1245    fn read_b(_handle: u64, out: &mut Vec<u8>) -> (u32, u32) {
1246        out.push(9);
1247        (0, 0)
1248    }
1249    fn close_b(_handle: u64) {
1250        // distinct body: the linker must not fold this onto close_a
1251        let _ = core::hint::black_box(1_u8);
1252    }
1253
1254    fn vtable_a() -> CaptureVTable {
1255        CaptureVTable {
1256            open: open_a,
1257            read: read_a,
1258            close: close_a,
1259        }
1260    }
1261    fn vtable_b() -> CaptureVTable {
1262        CaptureVTable {
1263            open: open_b,
1264            read: read_b,
1265            close: close_b,
1266        }
1267    }
1268
1269    fn same_vtable(a: CaptureVTable, b: CaptureVTable) -> bool {
1270        a.open as usize == b.open as usize
1271            && a.read as usize == b.read as usize
1272            && a.close as usize == b.close as usize
1273    }
1274
1275    #[test]
1276    fn register_camera_backend_is_first_wins_and_never_overwritten() {
1277        let before = camera_backend();
1278
1279        register_camera_backend(vtable_a());
1280        let first = camera_backend().expect("a backend is registered after the first call");
1281
1282        // A second registration must be silently ignored, not panic and not swap
1283        // the vtable out from under a running capture worker.
1284        register_camera_backend(vtable_b());
1285        register_camera_backend(vtable_b());
1286        let after = camera_backend().expect("the backend must still be there");
1287
1288        assert!(
1289            same_vtable(first, after),
1290            "the first registration must win; a later one must not replace it"
1291        );
1292        if let Some(pre) = before {
1293            assert!(
1294                same_vtable(pre, after),
1295                "a backend registered before this test must not have been replaced"
1296            );
1297        } else {
1298            assert!(
1299                same_vtable(vtable_a(), after),
1300                "camera_backend() must hand back exactly the vtable that was registered"
1301            );
1302            // The registered fn pointers must actually be callable through the vtable.
1303            assert_eq!((after.open)(1, 2, 3), open_a(1, 2, 3));
1304            assert_eq!((after.open)(u32::MAX, u32::MAX, u32::MAX), open_a(u32::MAX, u32::MAX, u32::MAX));
1305            let mut buf = vec![0_u8; 8];
1306            assert_eq!((after.read)(u64::from(u32::MAX), &mut buf), (u32::MAX, 1));
1307            assert_eq!(buf, vec![1, 2, 3, 4], "read must be able to resize `out`");
1308            (after.close)(0);
1309            (after.close)(u64::MAX);
1310        }
1311    }
1312
1313    #[test]
1314    fn register_screen_backend_is_independent_of_the_camera_backend() {
1315        let before = screen_backend();
1316        register_screen_backend(vtable_b());
1317        let after = screen_backend().expect("a screen backend is registered");
1318
1319        if let Some(pre) = before {
1320            assert!(same_vtable(pre, after), "first registration wins");
1321        } else {
1322            assert!(
1323                same_vtable(vtable_b(), after),
1324                "the screen registry must hand back the screen vtable"
1325            );
1326            // Registering into the screen slot must not have leaked into the
1327            // camera slot (they are separate OnceLocks).
1328            if let Some(cam) = camera_backend() {
1329                assert!(
1330                    !same_vtable(cam, vtable_b()),
1331                    "the camera registry must not pick up the screen vtable"
1332                );
1333            }
1334            // `(0, 0)` is the documented end-of-stream signal.
1335            let mut buf = Vec::new();
1336            assert_eq!((after.read)(0, &mut buf), (0, 0));
1337        }
1338    }
1339
1340    fn mic_open(sample_rate: u32, channels: u16) -> u64 {
1341        u64::from(sample_rate) * 2 + u64::from(channels)
1342    }
1343    fn mic_read(handle: u64, out: &mut Vec<f32>) -> u32 {
1344        out.clear();
1345        // NaN / inf / subnormal samples must survive the vtable boundary untouched.
1346        out.extend_from_slice(&[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0]);
1347        (handle % 3) as u32
1348    }
1349    fn mic_close(_handle: u64) {}
1350
1351    fn mic_open_other(sample_rate: u32, channels: u16) -> u64 {
1352        u64::from(sample_rate) ^ u64::from(channels)
1353    }
1354    fn mic_read_other(_handle: u64, out: &mut Vec<f32>) -> u32 {
1355        out.push(1.0);
1356        0
1357    }
1358    fn mic_close_other(_handle: u64) {
1359        let _ = core::hint::black_box(2_u8);
1360    }
1361
1362    #[test]
1363    fn register_mic_backend_is_first_wins_and_passes_f32_samples_through() {
1364        let before = mic_backend();
1365
1366        register_mic_backend(AudioCaptureVTable {
1367            open: mic_open,
1368            read: mic_read,
1369            close: mic_close,
1370        });
1371        register_mic_backend(AudioCaptureVTable {
1372            open: mic_open_other,
1373            read: mic_read_other,
1374            close: mic_close_other,
1375        });
1376
1377        let vt = mic_backend().expect("a mic backend is registered");
1378
1379        if before.is_none() {
1380            assert_eq!(
1381                vt.open as usize, mic_open as usize,
1382                "the first mic registration must win"
1383            );
1384
1385            // Boundary sample rates / channel counts must go through untouched.
1386            assert_eq!((vt.open)(0, 0), 0);
1387            assert_eq!((vt.open)(u32::MAX, u16::MAX), mic_open(u32::MAX, u16::MAX));
1388
1389            let mut samples = Vec::new();
1390            let frames = (vt.read)(4, &mut samples);
1391            assert_eq!(frames, 1, "the frame count must be the vtable's, verbatim");
1392            assert_eq!(samples.len(), 4);
1393            assert!(samples[0].is_nan(), "a NaN sample must not be normalised");
1394            assert_eq!(samples[1], f32::INFINITY);
1395            assert_eq!(samples[2], f32::NEG_INFINITY);
1396            assert!(
1397                samples[3] == 0.0 && samples[3].is_sign_negative(),
1398                "-0.0 must keep its sign bit"
1399            );
1400
1401            // `0` is the documented EOF/error return.
1402            assert_eq!((vt.read)(3, &mut samples), 0);
1403            (vt.close)(u64::MAX);
1404        }
1405    }
1406}