1use 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; use azul_css::props::basic::ColorU;
25
26use crate::callbacks::CallbackInfo;
27
28pub type OnVideoFrameCallbackType = extern "C" fn(RefAny, CallbackInfo, VideoFrame) -> Update;
38impl_widget_callback!(
39 OnVideoFrame,
40 OptionOnVideoFrame,
41 OnVideoFrameCallback,
42 OnVideoFrameCallbackType
43);
44
45azul_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
59pub 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
75pub 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 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#[allow(clippy::cast_possible_wrap)] pub 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#[derive(Debug, Clone, Copy)]
174pub struct CaptureVTable {
175 pub open: fn(index: u32, width: u32, height: u32) -> u64,
179 pub read: fn(handle: u64, out: &mut Vec<u8>) -> (u32, u32),
183 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
190pub fn register_camera_backend(vtable: CaptureVTable) {
194 let _ = CAMERA_BACKEND.set(vtable);
195}
196
197pub fn register_screen_backend(vtable: CaptureVTable) {
199 let _ = SCREEN_BACKEND.set(vtable);
200}
201
202pub fn camera_backend() -> Option<CaptureVTable> {
204 CAMERA_BACKEND.get().copied()
205}
206
207pub fn screen_backend() -> Option<CaptureVTable> {
209 SCREEN_BACKEND.get().copied()
210}
211
212#[derive(Debug, Clone, Copy)]
218pub struct AudioCaptureVTable {
219 pub open: fn(sample_rate: u32, channels: u16) -> u64,
222 pub read: fn(handle: u64, out: &mut Vec<f32>) -> u32,
226 pub close: fn(handle: u64),
228}
229
230static MIC_BACKEND: std::sync::OnceLock<AudioCaptureVTable> = std::sync::OnceLock::new();
231
232pub fn register_mic_backend(vtable: AudioCaptureVTable) {
234 let _ = MIC_BACKEND.set(vtable);
235}
236
237pub fn mic_backend() -> Option<AudioCaptureVTable> {
239 MIC_BACKEND.get().copied()
240}
241
242#[cfg(test)]
243#[allow(clippy::too_many_lines)] mod 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 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 has_pixels: bool,
311 },
312 }
313
314 static GL_LOG: Mutex<Vec<GlCall>> = Mutex::new(Vec::new());
315 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 for i in 0..n.max(0) {
329 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)] 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 fn null_gl_context() -> GlContextPtr {
365 let ctx: GenericGlContext = unsafe { core::mem::zeroed() };
368 GlContextPtr::new(RendererType::Software, Rc::new(ctx))
369 }
370
371 fn recording_gl_context() -> GlContextPtr {
375 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 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 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 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: ¤t_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 #[derive(Debug, Default)]
491 struct CamState {
492 _texture_id: Option<u32>,
493 }
494
495 #[derive(Debug, Default)]
497 struct OtherState {
498 _unused: u8,
499 }
500
501 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 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 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 fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
534 VideoFrame::new(width, height, bytes.into())
535 }
536
537 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 fn recomposites(changes: &[CallbackChange]) -> usize {
557 changes
558 .iter()
559 .filter(|c| matches!(c, CallbackChange::UpdateAllImageCallbacks))
560 .count()
561 }
562
563 #[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 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 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 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 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 #[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 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 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 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 #[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 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 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 for (w, h, bytes) in [
915 (4_u32, 4_u32, vec![0_u8; 3]), (4, 4, vec![0_u8; 63]), (4, 4, vec![0_u8; 65]), (2, 2, Vec::new()), ] {
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 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 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 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 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()); 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 #[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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!((vt.read)(3, &mut samples), 0);
1403 (vt.close)(u64::MAX);
1404 }
1405 }
1406}