Skip to main content

wisp/
recording.rs

1//! `RecordingScene` — composes the recorder's two visible streams
2//! (full-screen capture + circular webcam bubble) into a single
3//! `Stage` ready to render to a `wgpu::TextureView` (M-EXPORT.0 of
4//! M-RECORD-EXPORT).
5//!
6//! Reusable composition primitive: the recorder feeds it new BGRA
7//! frames from the per-channel capture pipelines; the future editor
8//! preview lane will instantiate the same scene from decoded
9//! recording frames. Keeping composition inside `wisp` (rather than
10//! in `screen-app`) keeps the recorder + editor visually consistent
11//! and lets the storybook drive snapshot tests without booting Tauri.
12//!
13//! ## Layout
14//!
15//! - **Screen frame** is the fullscreen background — a `Sprite`
16//!   anchored centre, scaled to fill NDC `[-1, 1]²`.
17//! - **Camera frame** is a smaller `Sprite` inside a `Container` whose
18//!   `clip` is a [`MaskShape::Ellipse`] with half-extents
19//!   aspect-compensated for the output canvas, so the bubble is a true
20//!   circle in *pixels* (an NDC `Circle` would stretch into an ellipse
21//!   on a non-square frame). Default position is the bottom-left
22//!   corner with a small margin; the [`CamLayout`] struct exposes this
23//!   so the future editor can re-anchor.
24//!
25//! ```admonish important title="Latest-frame-wins"
26//! `set_screen_frame` / `set_camera_frame` overwrite the GPU
27//! texture in place — no queueing. The encoder reads at its own
28//! frame rate (30 fps in M-EXPORT.1's default) regardless of source
29//! FPS; uploads from faster sources just write the most recent
30//! frame, slower sources show the last upload until a new one
31//! lands.
32//! ```
33
34use glam::Vec2;
35
36use crate::application::Application;
37use crate::color::Color;
38use crate::math::Rect;
39use crate::render::{RenderStats, Renderer};
40use crate::scene::Stage;
41use crate::scene::clip::MaskShape;
42use crate::scene::container::Container;
43use crate::scene::graphics::{Fill, Graphics, Stroke};
44use crate::scene::node::{Node, NodeId};
45use crate::scene::sprite::Sprite;
46use crate::scene::transform::Transform;
47use crate::texture::Texture;
48use crate::texture::video_texture::VideoTexture;
49
50/// Position + size of the circular webcam bubble within the composed
51/// frame. All coordinates are NDC — `(-1, -1)` bottom-left,
52/// `(1, 1)` top-right.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct CamLayout {
55    /// Centre of the circle in NDC.
56    pub center: Vec2,
57    /// Radius in NDC units. The default `0.18` makes the bubble
58    /// roughly 18% of the viewport's shorter side, matching the
59    /// Screen Studio convention.
60    pub radius: f32,
61}
62
63impl CamLayout {
64    /// Bottom-right corner with a 24 px-equivalent NDC margin
65    /// (~`0.05` in `[-1, 1]` space) and a radius of `0.18` (~18% of
66    /// viewport).
67    pub const BOTTOM_RIGHT: Self = Self {
68        center: Vec2::new(0.74, -0.74),
69        radius: 0.20,
70    };
71
72    /// Bottom-left variant — the current default. Mirrors the
73    /// Screen Studio bottom-left cam placement convention; flip to
74    /// [`Self::BOTTOM_RIGHT`] for right-handed presenters.
75    pub const BOTTOM_LEFT: Self = Self {
76        center: Vec2::new(-0.74, -0.74),
77        radius: 0.20,
78    };
79
80    /// Top-left variant.
81    pub const TOP_LEFT: Self = Self {
82        center: Vec2::new(-0.74, 0.74),
83        radius: 0.20,
84    };
85}
86
87impl Default for CamLayout {
88    fn default() -> Self {
89        Self::BOTTOM_LEFT
90    }
91}
92
93/// One click ripple to draw under the cursor (ED.19): an expanding,
94/// fading ring centered at a click, in output NDC. The app derives these
95/// from the click log (`edit::telemetry::ripples_at`) — `center` is already
96/// mapped through the screen transform, `radius` grows with age, `alpha`
97/// fades `1 → 0`.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct CursorRipple {
100    /// Ring center in NDC.
101    pub center: Vec2,
102    /// Ring radius in NDC units.
103    pub radius: f32,
104    /// Ring opacity, `0.0..=1.0`.
105    pub alpha: f32,
106}
107
108/// Append the arrow-cursor silhouette to `g`: a classic pointer filled with
109/// `color`, sized by NDC half-extent `half`, anchored at its hot-spot `tip`.
110///
111/// A single *convex* quad — tip, vertical left edge, tail point, right barb —
112/// because [`Graphics::draw_polygon`] fan-triangulates from the first vertex
113/// (a notched arrow would self-overlap) and because the dark-outline-behind
114/// trick (`set_cursor` draws a larger dark copy then a white one) only stays
115/// registered for a compact shape near the `tip`: scaling a far-from-tip tail
116/// about the tip would bloat its border out of proportion. The four points
117/// read unmistakably as a pointer while keeping both invariants.
118fn draw_pointer(g: &mut Graphics, tip: Vec2, half: f32, color: Color) {
119    let p = |x: f32, y: f32| tip + Vec2::new(x, y) * half;
120    g.fill(Fill::Solid(color));
121    g.draw_polygon(&[p(0.0, 0.0), p(0.0, -1.5), p(0.45, -1.75), p(1.05, -0.95)]);
122}
123
124/// Dimensions for one of the composed input streams. Used at
125/// [`RecordingScene::new`] time to allocate the backing
126/// [`VideoTexture`]s; subsequent `set_*_frame` calls must pass a
127/// BGRA buffer matching `width * height * 4`.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub struct StreamDimensions {
130    /// Pixel width.
131    pub width: u32,
132    /// Pixel height.
133    pub height: u32,
134}
135
136impl StreamDimensions {
137    /// Construct from width + height.
138    #[must_use]
139    pub fn new(width: u32, height: u32) -> Self {
140        Self { width, height }
141    }
142
143    /// Expected `bgra` byte length for `set_*_frame`.
144    #[must_use]
145    pub fn byte_len(self) -> usize {
146        (self.width as usize) * (self.height as usize) * 4
147    }
148}
149
150/// Copy a packed BGRA buffer (`width * height * 4` bytes, no row
151/// padding) into a freshly-allocated `Vec<u8>` with rows reversed so
152/// the output is bottom-up.
153///
154/// wisp's `Sprite` vertex shader maps texture UV `(0, 0)` to the
155/// bottom-left of the rendered NDC quad (the same "+y flip" the
156/// sprite vs glyphon convention bullet in CLAUDE.md flags), so
157/// `VideoTexture::upload_bgra` of a standard top-down BGRA image
158/// renders upside-down. The `set_*_frame` methods on [`RecordingScene`]
159/// run this helper so callers can pass top-down BGRA (the universal
160/// `CoreVideo` / `GStreamer` / `Canvas2D` convention).
161fn flip_bgra_rows_top_down_to_bottom_up(src: &[u8], width: u32, height: u32) -> Vec<u8> {
162    // Caller (RecordingScene::set_*_frame) asserts src.len() == width * height * 4
163    // before calling, so the slice indexing below stays in bounds.
164    let row_bytes = (width as usize) * 4;
165    let h = height as usize;
166    let mut out = Vec::with_capacity(row_bytes * h);
167    for row in (0..h).rev() {
168        let start = row * row_bytes;
169        out.extend_from_slice(&src[start..start + row_bytes]);
170    }
171    out
172}
173
174/// Two-stream composition: fullscreen screen-capture + circular
175/// webcam bubble. Owns the `Stage` + the per-stream `VideoTexture`s.
176///
177/// The struct itself doesn't render — pass [`Self::stage`] to
178/// [`Renderer::render_stage`] or call the [`Self::render`] convenience
179/// wrapper.
180pub struct RecordingScene {
181    stage: Stage,
182    screen_video: VideoTexture,
183    cam_video: VideoTexture,
184    screen_dims: StreamDimensions,
185    cam_dims: StreamDimensions,
186    cam_layout: CamLayout,
187    screen_sprite: NodeId,
188    cam_container: NodeId,
189    cam_sprite: NodeId,
190    /// Editor-only backdrop node (a full-NDC `Graphics` rect drawn behind
191    /// the screen). Lazily created on the first `set_background_*` call;
192    /// the recorder never touches it, so its scene stays a plain
193    /// three-node screen + cam stage.
194    backdrop: Option<NodeId>,
195    /// Editor-only cursor-overlay node (a `Graphics` pointer + click ripples,
196    /// drawn over the framed screen). Lazily created on the first
197    /// [`Self::set_cursor`] call; the recorder never touches it (its live
198    /// capture already has a cursor baked into the screen frame).
199    cursor: Option<NodeId>,
200    /// Editor-only drop-shadow node (ED.18): a dark, offset rounded-rect drawn
201    /// *behind* the framed screen (Phase 1, like the backdrop), so the offset
202    /// sliver reads as a shadow. Lazily created by [`Self::set_frame_shadow`].
203    shadow: Option<NodeId>,
204    /// Editor-only inset-border node (ED.18): a rounded-rect *stroke* tracing
205    /// the frame window, drawn *over* the screen (Phase 2, like the cursor).
206    /// Lazily created by [`Self::set_frame_border`].
207    border: Option<NodeId>,
208    /// Editor-only wallpaper backdrop (ED.18): a full-NDC `Sprite` of a decoded
209    /// image, drawn behind everything. A `Sprite` (not `Graphics`) so the
210    /// renderer's batch order paints it before the gradient/shadow graphics —
211    /// the backmost layer. Mutually exclusive with the gradient/color backdrop
212    /// (the app hides one when the other is set). Created by
213    /// [`Self::set_background_wallpaper`].
214    wallpaper: Option<NodeId>,
215}
216
217impl std::fmt::Debug for RecordingScene {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        // `Stage` doesn't derive `Debug` (would require Debug on
220        // every Node variant); skip it here. The other fields are
221        // enough for debugging the layout / dimensions.
222        f.debug_struct("RecordingScene")
223            .field("screen_dims", &self.screen_dims)
224            .field("cam_dims", &self.cam_dims)
225            .field("cam_layout", &self.cam_layout)
226            .field("nodes_in_stage", &self.stage.len())
227            .finish_non_exhaustive()
228    }
229}
230
231impl RecordingScene {
232    /// Build a fresh scene. Allocates two `VideoTexture`s at the
233    /// requested dimensions (the host re-uploads BGRA bytes via
234    /// [`Self::set_screen_frame`] / [`Self::set_camera_frame`] on
235    /// every captured frame); inserts the corresponding sprites
236    /// into the stage with the cam wrapped in a circular-clip
237    /// container.
238    ///
239    /// # Panics
240    ///
241    /// Panics if either dimension pair is zero — the wgpu texture
242    /// allocator rejects zero-size textures and we'd prefer the
243    /// trap fire at construction.
244    #[must_use]
245    pub fn new(
246        app: &Application,
247        screen_dims: StreamDimensions,
248        cam_dims: StreamDimensions,
249        cam_layout: CamLayout,
250    ) -> Self {
251        assert!(
252            screen_dims.width > 0 && screen_dims.height > 0,
253            "RecordingScene: screen dims must be non-zero (got {}×{})",
254            screen_dims.width,
255            screen_dims.height,
256        );
257        assert!(
258            cam_dims.width > 0 && cam_dims.height > 0,
259            "RecordingScene: cam dims must be non-zero (got {}×{})",
260            cam_dims.width,
261            cam_dims.height,
262        );
263
264        let mut stage = Stage::new();
265        let screen_video = VideoTexture::new(app, screen_dims.width, screen_dims.height);
266        let cam_video = VideoTexture::new(app, cam_dims.width, cam_dims.height);
267
268        // Fullscreen screen sprite: anchored centre, scaled to fill
269        // the full NDC `[-1, 1]` viewport. Sprite local rect is
270        // `[0, 1]²` so unit scale is 1×1 NDC; we want 2×2.
271        let mut screen_sprite =
272            Sprite::from_texture(screen_video.texture().clone()).with_anchor(Vec2::splat(0.5));
273        screen_sprite.container.transform = Transform {
274            scale: Vec2::splat(2.0),
275            ..Transform::IDENTITY
276        };
277        let screen_sprite_id = stage
278            .add_child(stage.root(), screen_sprite)
279            .expect("Stage root is alive at scene construction");
280
281        // Aspect-compensated bubble geometry. `cam_layout.radius` is in
282        // NDC, where `[-1, 1]` spans the FULL output canvas on each
283        // axis — so a circle with equal NDC x/y radius renders as an
284        // ELLIPSE on a non-square canvas (the recorded-output stretch
285        // bug). Convert the radius into per-axis NDC half-extents that
286        // are equal in PIXELS: a target pixel radius of
287        // `radius * min(w, h) / 2` (≈ `radius` of the shorter side, per
288        // the `CamLayout` doc) maps back to `radius * min(w,h)/w` in x
289        // and `radius * min(w,h)/h` in y. The output canvas size is
290        // `screen_dims` (the screen fills the frame 1:1).
291        // Dimensions fit u16 for any real display; `f32::from(u16)` is
292        // lossless and sidesteps the `u32 as f32` precision-loss lint
293        // (same conversion pattern as `render::mask_texture`).
294        let dim_to_f32 = |d: u32| f32::from(u16::try_from(d.min(u32::from(u16::MAX))).unwrap_or(1));
295        let canvas_width = dim_to_f32(screen_dims.width);
296        let canvas_height = dim_to_f32(screen_dims.height);
297        let min_side = canvas_width.min(canvas_height);
298        let cam_half_extents = Vec2::new(
299            cam_layout.radius * min_side / canvas_width,
300            cam_layout.radius * min_side / canvas_height,
301        );
302
303        // Cam container — the clip is a true pixel circle (an NDC
304        // ellipse with the compensated half-extents); its child sprite
305        // fills the same square pixel region so the feed is undistorted.
306        let cam_container = Container {
307            clip: Some(MaskShape::ellipse(cam_layout.center, cam_half_extents)),
308            ..Container::default()
309        };
310        let cam_container_id = stage
311            .add_child(stage.root(), cam_container)
312            .expect("Stage root is alive at scene construction");
313
314        // Cam sprite — anchored centre at the bubble position, scaled
315        // so its square texture covers a SQUARE PIXEL region (diameter
316        // = the bubble's), i.e. NDC scale = `2 * half_extents`. Using
317        // `splat(radius*2)` here would re-introduce the stretch. The
318        // parent container's elliptical clip masks it to a circle.
319        let mut cam_sprite =
320            Sprite::from_texture(cam_video.texture().clone()).with_anchor(Vec2::splat(0.5));
321        cam_sprite.container.transform = Transform {
322            position: cam_layout.center,
323            scale: cam_half_extents * 2.0,
324            ..Transform::IDENTITY
325        };
326        let cam_sprite_id = stage
327            .add_child(cam_container_id, cam_sprite)
328            .expect("cam container is alive immediately after add_child");
329
330        Self {
331            stage,
332            screen_video,
333            cam_video,
334            screen_dims,
335            cam_dims,
336            cam_layout,
337            screen_sprite: screen_sprite_id,
338            cam_container: cam_container_id,
339            cam_sprite: cam_sprite_id,
340            backdrop: None,
341            cursor: None,
342            shadow: None,
343            border: None,
344            wallpaper: None,
345        }
346    }
347
348    /// Upload the latest screen-capture frame. `bgra` is **top-down**
349    /// packed BGRA8 (the standard `CoreVideo` / `GStreamer`
350    /// convention) and `bgra.len()` must equal
351    /// `screen_dims().byte_len()`. The method flips rows internally
352    /// to match wisp's sprite convention before uploading — see
353    /// [`flip_bgra_rows_top_down_to_bottom_up`].
354    ///
355    /// # Panics
356    ///
357    /// Panics with `"VideoTexture::upload_bgra: byte length mismatch"`
358    /// if `bgra.len()` doesn't match the configured screen dimensions.
359    pub fn set_screen_frame(&mut self, app: &Application, bgra: &[u8]) {
360        assert_eq!(
361            bgra.len(),
362            self.screen_dims.byte_len(),
363            "VideoTexture::upload_bgra: byte length mismatch"
364        );
365        let flipped = flip_bgra_rows_top_down_to_bottom_up(
366            bgra,
367            self.screen_dims.width,
368            self.screen_dims.height,
369        );
370        self.screen_video.upload_bgra(app, &flipped);
371    }
372
373    /// Upload the latest camera frame. `bgra` is **top-down** packed
374    /// BGRA8 (same convention as [`Self::set_screen_frame`]) and
375    /// `bgra.len()` must equal `cam_dims().byte_len()`.
376    ///
377    /// # Panics
378    ///
379    /// Panics with `"VideoTexture::upload_bgra: byte length mismatch"`
380    /// if `bgra.len()` doesn't match the configured cam dimensions.
381    pub fn set_camera_frame(&mut self, app: &Application, bgra: &[u8]) {
382        assert_eq!(
383            bgra.len(),
384            self.cam_dims.byte_len(),
385            "VideoTexture::upload_bgra: byte length mismatch"
386        );
387        let flipped =
388            flip_bgra_rows_top_down_to_bottom_up(bgra, self.cam_dims.width, self.cam_dims.height);
389        self.cam_video.upload_bgra(app, &flipped);
390    }
391
392    /// Render the composed scene into `view`. Thin wrapper around
393    /// [`Renderer::render_stage`] — callers that need the
394    /// [`RenderStats`] can use that directly via [`Self::stage`].
395    pub fn render(
396        &self,
397        renderer: &Renderer,
398        app: &Application,
399        view: &wgpu::TextureView,
400        clear: Color,
401    ) -> RenderStats {
402        renderer.render_stage(app, view, clear, &self.stage)
403    }
404
405    /// Borrow the scene graph (e.g. to call
406    /// [`Renderer::render_stage`] directly).
407    #[must_use]
408    pub fn stage(&self) -> &Stage {
409        &self.stage
410    }
411
412    /// Mutable borrow of the underlying stage — exposed so future
413    /// extensions (annotations, cursor overlays, watermarks) can
414    /// attach extra nodes without monkey-patching this type.
415    #[must_use]
416    pub fn stage_mut(&mut self) -> &mut Stage {
417        &mut self.stage
418    }
419
420    /// Configured screen dimensions.
421    #[must_use]
422    pub fn screen_dims(&self) -> StreamDimensions {
423        self.screen_dims
424    }
425
426    /// Configured camera dimensions.
427    #[must_use]
428    pub fn cam_dims(&self) -> StreamDimensions {
429        self.cam_dims
430    }
431
432    /// Active camera layout (centre + radius).
433    #[must_use]
434    pub fn cam_layout(&self) -> CamLayout {
435        self.cam_layout
436    }
437
438    /// `NodeId` of the fullscreen screen sprite. Exposed so callers
439    /// (storybook stories, future editor) can tweak the sprite's
440    /// container without rebuilding the scene.
441    #[must_use]
442    pub fn screen_sprite_id(&self) -> NodeId {
443        self.screen_sprite
444    }
445
446    /// `NodeId` of the cam container (the one holding the clip).
447    /// Modifying this container's transform moves the bubble.
448    #[must_use]
449    pub fn cam_container_id(&self) -> NodeId {
450        self.cam_container
451    }
452
453    /// `NodeId` of the cam sprite (inside the cam container).
454    #[must_use]
455    pub fn cam_sprite_id(&self) -> NodeId {
456        self.cam_sprite
457    }
458
459    /// Toggle the camera bubble's visibility. Hiding the cam container
460    /// prevents the cam sprite from rendering at all — useful when the
461    /// recording session has the camera channel disabled (no upload
462    /// happens, so `VideoTexture` would sample whatever wgpu's
463    /// `create_texture` left in memory; not guaranteed to be zero).
464    pub fn set_camera_visible(&mut self, visible: bool) {
465        if let Some(node) = self.stage.get_mut(self.cam_container) {
466            node.container_mut().visible = visible;
467        }
468    }
469
470    /// Toggle the screen sprite's visibility. Same rationale as
471    /// [`Self::set_camera_visible`] for the screen channel.
472    pub fn set_screen_visible(&mut self, visible: bool) {
473        if let Some(node) = self.stage.get_mut(self.screen_sprite) {
474            node.container_mut().visible = visible;
475        }
476    }
477
478    /// Set the screen sprite's local transform. The recorder leaves this at
479    /// the base fill-NDC transform (`position 0`, `scale 2`); the editor
480    /// (ED.16/ED.15) writes a per-frame crop-then-zoom transform here so the
481    /// composed frame punches in / reframes. The screen sprite is anchored
482    /// centre, so a `scale` of `2 * z` magnifies the source by `z` about the
483    /// frame centre, and `position` shifts the focal point in NDC.
484    pub fn set_screen_transform(&mut self, transform: Transform) {
485        if let Some(node) = self.stage.get_mut(self.screen_sprite) {
486            node.container_mut().transform = transform;
487        }
488    }
489
490    /// Set (or clear) the screen sprite's clip — the editor's
491    /// rounded-corner *frame window* (ED.18). The shape is in fixed output
492    /// NDC (the clip is screen-space, not transform-aware), so the window
493    /// stays put while the screen transform punches in inside it. Setting a
494    /// clip makes the screen a dispatched node that composites *over* the
495    /// backdrop. The recorder leaves this `None` (full-bleed, no clip).
496    pub fn set_screen_clip(&mut self, shape: Option<MaskShape>) {
497        if let Some(node) = self.stage.get_mut(self.screen_sprite) {
498            node.container_mut().clip = shape;
499        }
500    }
501
502    /// Lazily insert (or fetch) the backdrop `Graphics` node. It is added as
503    /// a child of root with no clip, so it renders in the advanced-dispatch
504    /// Phase 1 (behind the clipped, dispatched screen sprite). The recorder
505    /// never calls a `set_background_*` method, so this node is never created
506    /// for a recording scene.
507    fn backdrop_graphics_mut(&mut self) -> &mut Graphics {
508        if self.backdrop.is_none() {
509            let root = self.stage.root();
510            let id = self
511                .stage
512                .add_child(root, Graphics::new())
513                .expect("Stage root is alive");
514            self.backdrop = Some(id);
515        }
516        let id = self.backdrop.expect("backdrop node just ensured");
517        match self.stage.get_mut(id) {
518            Some(Node::Graphics(g)) => g,
519            _ => unreachable!("backdrop node is always a Graphics"),
520        }
521    }
522
523    /// Paint the backdrop as a linear gradient between two colors at
524    /// `angle_deg` (0 = left→right, 90 = bottom→top), and make it visible.
525    /// Editor-only (the framed-screen look behind the rounded canvas).
526    pub fn set_background_gradient(&mut self, from: Color, to: Color, angle_deg: f32) {
527        let theta = angle_deg.to_radians();
528        // Endpoints in primitive-local coords ([-1, 1] for a full-NDC rect).
529        let dir = Vec2::new(theta.cos(), theta.sin());
530        let g = self.backdrop_graphics_mut();
531        g.primitives.clear();
532        g.fill(Fill::LinearGradient {
533            start: -dir,
534            end: dir,
535            color_a: from,
536            color_b: to,
537        });
538        g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
539        g.container.visible = true;
540    }
541
542    /// Paint the backdrop as a flat color fill, and make it visible.
543    pub fn set_background_color(&mut self, color: Color) {
544        let g = self.backdrop_graphics_mut();
545        g.primitives.clear();
546        g.fill(Fill::Solid(color));
547        g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
548        g.container.visible = true;
549    }
550
551    /// Toggle the backdrop's visibility. No-op if no backdrop has been set
552    /// (the recorder path).
553    pub fn set_background_visible(&mut self, visible: bool) {
554        if let Some(id) = self.backdrop
555            && let Some(node) = self.stage.get_mut(id)
556        {
557            node.container_mut().visible = visible;
558        }
559    }
560
561    /// Lazily insert (or fetch) the cursor-overlay `Graphics` node. It carries
562    /// a full-NDC clip so it renders in the advanced-dispatch Phase 2 (over
563    /// the framed, also-dispatched screen sprite), and is added after the
564    /// screen so it composites on top. Editor-only — never created for a
565    /// recording scene.
566    fn cursor_graphics_mut(&mut self) -> &mut Graphics {
567        if self.cursor.is_none() {
568            let root = self.stage.root();
569            let mut g = Graphics::new();
570            g.container.clip = Some(MaskShape::rect(Rect::new(-1.0, -1.0, 2.0, 2.0)));
571            let id = self.stage.add_child(root, g).expect("Stage root is alive");
572            self.cursor = Some(id);
573        }
574        let id = self.cursor.expect("cursor node just ensured");
575        match self.stage.get_mut(id) {
576            Some(Node::Graphics(g)) => g,
577            _ => unreachable!("cursor node is always a Graphics"),
578        }
579    }
580
581    /// Draw the cursor overlay (ED.19): expanding click ripples under a
582    /// dark-outlined white pointer at `pointer_ndc`, with the pointer sized by
583    /// `half` (NDC half-extent). All coordinates are output NDC — the app maps
584    /// the captured normalized position through the screen transform so the
585    /// pointer stays glued to its pixel through zoom/crop/padding. Makes the
586    /// overlay visible. Editor-only.
587    pub fn set_cursor(&mut self, pointer_ndc: Vec2, half: f32, ripples: &[CursorRipple]) {
588        let g = self.cursor_graphics_mut();
589        g.primitives.clear();
590        g.stroke(None);
591        // Ripples first (under the pointer): expanding, fading discs.
592        for r in ripples {
593            g.fill(Fill::Solid(Color::rgba(1.0, 1.0, 1.0, r.alpha)));
594            g.draw_ellipse(r.center, Vec2::splat(r.radius));
595        }
596        // Arrow pointer: an arrowhead + tail, scaled by `half` about its
597        // hot-spot tip. A dark, slightly-larger copy behind a white one reads
598        // on any backdrop.
599        draw_pointer(g, pointer_ndc, half * 1.25, Color::rgb_u8(20, 20, 20));
600        draw_pointer(g, pointer_ndc, half, Color::WHITE);
601        g.container.visible = true;
602    }
603
604    /// Toggle the cursor overlay's visibility. No-op if no cursor has been set
605    /// (the recorder path).
606    pub fn set_cursor_visible(&mut self, visible: bool) {
607        if let Some(id) = self.cursor
608            && let Some(node) = self.stage.get_mut(id)
609        {
610            node.container_mut().visible = visible;
611        }
612    }
613
614    /// Lazily insert (or fetch) the drop-shadow `Graphics` node — an
615    /// *unclipped* root child, so it renders in advanced-dispatch Phase 1
616    /// (behind the dispatched, clipped screen sprite). Added after the backdrop
617    /// so the shadow composites over the backdrop but under the screen.
618    fn shadow_graphics_mut(&mut self) -> &mut Graphics {
619        if self.shadow.is_none() {
620            let root = self.stage.root();
621            let id = self
622                .stage
623                .add_child(root, Graphics::new())
624                .expect("Stage root is alive");
625            self.shadow = Some(id);
626        }
627        let id = self.shadow.expect("shadow node just ensured");
628        match self.stage.get_mut(id) {
629            Some(Node::Graphics(g)) => g,
630            _ => unreachable!("shadow node is always a Graphics"),
631        }
632    }
633
634    /// Draw the framed-screen drop shadow (ED.18): a `color` rounded-rect the
635    /// shape of the frame `window`, offset by `offset` (NDC), drawn behind the
636    /// screen so only the offset sliver shows. Editor-only — the recorder never
637    /// calls this, so its scene has no shadow node.
638    pub fn set_frame_shadow(
639        &mut self,
640        window: Rect,
641        corner_radius: f32,
642        offset: Vec2,
643        color: Color,
644    ) {
645        let g = self.shadow_graphics_mut();
646        g.primitives.clear();
647        g.stroke(None);
648        g.fill(Fill::Solid(color));
649        let shifted = Rect::new(
650            window.min.x + offset.x,
651            window.min.y + offset.y,
652            window.size.x,
653            window.size.y,
654        );
655        g.draw_rounded_rect(shifted, corner_radius);
656        g.container.visible = true;
657    }
658
659    /// Toggle the drop-shadow's visibility. No-op if unset (recorder path).
660    pub fn set_frame_shadow_visible(&mut self, visible: bool) {
661        if let Some(id) = self.shadow
662            && let Some(node) = self.stage.get_mut(id)
663        {
664            node.container_mut().visible = visible;
665        }
666    }
667
668    /// Lazily insert (or fetch) the inset-border `Graphics` node — carries a
669    /// full-NDC clip so it dispatches in Phase 2 (over the framed screen),
670    /// mirroring the cursor overlay.
671    fn border_graphics_mut(&mut self) -> &mut Graphics {
672        if self.border.is_none() {
673            let root = self.stage.root();
674            let mut g = Graphics::new();
675            g.container.clip = Some(MaskShape::rect(Rect::new(-1.0, -1.0, 2.0, 2.0)));
676            let id = self.stage.add_child(root, g).expect("Stage root is alive");
677            self.border = Some(id);
678        }
679        let id = self.border.expect("border node just ensured");
680        match self.stage.get_mut(id) {
681            Some(Node::Graphics(g)) => g,
682            _ => unreachable!("border node is always a Graphics"),
683        }
684    }
685
686    /// Draw the inset border (ED.18): a `stroke`d rounded-rect tracing the
687    /// frame `window`, over the screen. Transparent fill so only the stroke
688    /// shows. Editor-only.
689    pub fn set_frame_border(&mut self, window: Rect, corner_radius: f32, stroke: Stroke) {
690        let g = self.border_graphics_mut();
691        g.primitives.clear();
692        g.fill(Fill::Solid(Color::rgba(0.0, 0.0, 0.0, 0.0)));
693        g.stroke(Some(stroke));
694        g.draw_rounded_rect(window, corner_radius);
695        g.container.visible = true;
696    }
697
698    /// Toggle the inset border's visibility. No-op if unset (recorder path).
699    pub fn set_frame_border_visible(&mut self, visible: bool) {
700        if let Some(id) = self.border
701            && let Some(node) = self.stage.get_mut(id)
702        {
703            node.container_mut().visible = visible;
704        }
705    }
706
707    /// Set the wallpaper backdrop (ED.18) from decoded RGBA8 (`width*height*4`
708    /// bytes — the app decodes the image; wisp must not gain an asset path).
709    /// A full-NDC `Sprite`, the backmost layer. The caller is responsible for
710    /// hiding the gradient/color backdrop (they're mutually exclusive — a
711    /// `Sprite` and a `Graphics` backdrop fight the batch order). Rebuilds the
712    /// node on each call (wallpaper changes are user-driven + rare).
713    ///
714    /// # Panics
715    ///
716    /// Panics if `rgba.len() != width * height * 4` (via `Texture::from_rgba`).
717    pub fn set_background_wallpaper(
718        &mut self,
719        app: &Application,
720        width: u32,
721        height: u32,
722        rgba: &[u8],
723    ) {
724        let tex = Texture::from_rgba(app, width, height, rgba);
725        let mut sprite = Sprite::from_texture(tex).with_anchor(Vec2::splat(0.5));
726        sprite.container.transform = Transform {
727            scale: Vec2::splat(2.0),
728            ..Transform::IDENTITY
729        };
730        // A Sprite can't swap its texture in place; rebuild the node.
731        if let Some(old) = self.wallpaper.take() {
732            self.stage.destroy(old);
733        }
734        let root = self.stage.root();
735        let id = self
736            .stage
737            .add_child(root, sprite)
738            .expect("Stage root is alive");
739        self.wallpaper = Some(id);
740    }
741
742    /// Toggle the wallpaper backdrop's visibility. No-op if unset (recorder
743    /// path); also the app's lever for the wallpaper↔gradient mutual exclusion.
744    pub fn set_background_wallpaper_visible(&mut self, visible: bool) {
745        if let Some(id) = self.wallpaper
746            && let Some(node) = self.stage.get_mut(id)
747        {
748            node.container_mut().visible = visible;
749        }
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::application::AppConfig;
757
758    fn boot() -> Application {
759        pollster::block_on(Application::new(AppConfig::default())).expect("init")
760    }
761
762    #[test]
763    fn cam_layout_default_is_bottom_left() {
764        assert_eq!(CamLayout::default(), CamLayout::BOTTOM_LEFT);
765    }
766
767    #[test]
768    fn cam_layout_constants_are_in_ndc_range() {
769        for layout in [
770            CamLayout::BOTTOM_RIGHT,
771            CamLayout::BOTTOM_LEFT,
772            CamLayout::TOP_LEFT,
773        ] {
774            assert!(layout.center.x.abs() <= 1.0);
775            assert!(layout.center.y.abs() <= 1.0);
776            assert!(layout.radius > 0.0 && layout.radius <= 1.0);
777        }
778    }
779
780    #[test]
781    fn stream_dims_byte_len_matches_w_h_4() {
782        assert_eq!(StreamDimensions::new(64, 32).byte_len(), 64 * 32 * 4);
783        assert_eq!(
784            StreamDimensions::new(1920, 1080).byte_len(),
785            1920 * 1080 * 4
786        );
787    }
788
789    #[test]
790    fn new_inserts_three_nodes_under_root() {
791        let app = boot();
792        let scene = RecordingScene::new(
793            &app,
794            StreamDimensions::new(128, 72),
795            StreamDimensions::new(64, 64),
796            CamLayout::default(),
797        );
798        // Root + screen sprite + cam container + cam sprite = 4
799        // total. (Stage::len includes the root.)
800        assert_eq!(scene.stage().len(), 4);
801        assert_eq!(scene.screen_dims().width, 128);
802        assert_eq!(scene.cam_dims().height, 64);
803        // Recorder isolation (ED.18): a fresh scene has no backdrop node
804        // and the screen sprite is un-clipped (full-bleed). The editor's
805        // `set_background_*` / `set_screen_clip` are the only mutators, and
806        // the recorder never calls them — so a recording scene is
807        // bit-identical to pre-ED.18.
808        assert!(scene.backdrop.is_none(), "no backdrop until set_background");
809        assert!(scene.cursor.is_none(), "no cursor overlay until set_cursor");
810        assert!(
811            scene.shadow.is_none(),
812            "no drop shadow until set_frame_shadow"
813        );
814        assert!(
815            scene.border.is_none(),
816            "no inset border until set_frame_border"
817        );
818        assert!(
819            scene.wallpaper.is_none(),
820            "no wallpaper until set_background_wallpaper"
821        );
822        assert!(
823            scene
824                .stage()
825                .get(scene.screen_sprite_id())
826                .unwrap()
827                .container()
828                .clip
829                .is_none(),
830            "screen sprite is un-clipped in the recorder path"
831        );
832    }
833
834    #[test]
835    fn set_cursor_adds_one_overlay_node_and_toggles() {
836        let app = boot();
837        let mut scene = RecordingScene::new(
838            &app,
839            StreamDimensions::new(64, 64),
840            StreamDimensions::new(32, 32),
841            CamLayout::default(),
842        );
843        let before = scene.stage().len();
844        let ripples = [CursorRipple {
845            center: Vec2::new(0.2, -0.3),
846            radius: 0.1,
847            alpha: 0.5,
848        }];
849        scene.set_cursor(Vec2::new(0.1, 0.2), 0.08, &ripples);
850        // One new node (the cursor Graphics).
851        assert_eq!(scene.stage().len(), before + 1);
852        let id = scene.cursor.expect("cursor created");
853        assert!(scene.stage().get(id).unwrap().container().visible);
854        // The node carries a full-NDC clip so it dispatches over the screen.
855        assert!(
856            scene.stage().get(id).unwrap().container().clip.is_some(),
857            "cursor node dispatches (clip set) so it composites on top"
858        );
859        // A second call reuses the node — no leak.
860        scene.set_cursor(Vec2::new(-0.4, 0.5), 0.06, &[]);
861        assert_eq!(scene.stage().len(), before + 1, "cursor node reused");
862        // Toggle off.
863        scene.set_cursor_visible(false);
864        assert!(!scene.stage().get(id).unwrap().container().visible);
865    }
866
867    #[test]
868    fn set_frame_shadow_and_border_add_one_node_each_with_right_dispatch() {
869        let app = boot();
870        let mut scene = RecordingScene::new(
871            &app,
872            StreamDimensions::new(64, 64),
873            StreamDimensions::new(32, 32),
874            CamLayout::default(),
875        );
876        let before = scene.stage().len();
877        let window = Rect::new(-0.8, -0.8, 1.6, 1.6);
878
879        // Shadow: an UNCLIPPED node (Phase 1, behind the screen).
880        scene.set_frame_shadow(
881            window,
882            0.05,
883            Vec2::new(0.02, -0.02),
884            Color::rgba(0.0, 0.0, 0.0, 0.4),
885        );
886        assert_eq!(scene.stage().len(), before + 1);
887        let sid = scene.shadow.expect("shadow created");
888        assert!(scene.stage().get(sid).unwrap().container().visible);
889        assert!(
890            scene.stage().get(sid).unwrap().container().clip.is_none(),
891            "shadow is unclipped (Phase 1, behind the screen)"
892        );
893
894        // Border: a full-NDC-clipped node (Phase 2, over the screen).
895        scene.set_frame_border(window, 0.05, Stroke::new(0.01, Color::WHITE));
896        assert_eq!(scene.stage().len(), before + 2);
897        let bid = scene.border.expect("border created");
898        assert!(
899            scene.stage().get(bid).unwrap().container().clip.is_some(),
900            "border dispatches (clip set) so it composites over the screen"
901        );
902
903        // Reuse + toggle.
904        scene.set_frame_shadow(window, 0.05, Vec2::ZERO, Color::BLACK);
905        scene.set_frame_border(window, 0.05, Stroke::new(0.02, Color::BLACK));
906        assert_eq!(scene.stage().len(), before + 2, "nodes reused, no leak");
907        scene.set_frame_shadow_visible(false);
908        scene.set_frame_border_visible(false);
909        assert!(!scene.stage().get(sid).unwrap().container().visible);
910        assert!(!scene.stage().get(bid).unwrap().container().visible);
911    }
912
913    #[test]
914    fn set_background_wallpaper_adds_one_sprite_and_rebuilds() {
915        let app = boot();
916        let mut scene = RecordingScene::new(
917            &app,
918            StreamDimensions::new(64, 64),
919            StreamDimensions::new(32, 32),
920            CamLayout::default(),
921        );
922        let before = scene.stage().len();
923        let rgba = vec![120u8; 16 * 16 * 4];
924        scene.set_background_wallpaper(&app, 16, 16, &rgba);
925        assert_eq!(scene.stage().len(), before + 1, "one wallpaper sprite");
926        let first = scene.wallpaper.expect("wallpaper created");
927        assert!(scene.stage().get(first).unwrap().container().visible);
928        // A second call rebuilds the node (texture can't swap in place) — still
929        // exactly one wallpaper node, no leak.
930        scene.set_background_wallpaper(&app, 8, 8, &vec![200u8; 8 * 8 * 4]);
931        assert_eq!(scene.stage().len(), before + 1, "rebuilt, no leak");
932        scene.set_background_wallpaper_visible(false);
933        let id = scene.wallpaper.expect("wallpaper present");
934        assert!(!scene.stage().get(id).unwrap().container().visible);
935    }
936
937    #[test]
938    fn set_background_gradient_adds_one_visible_backdrop_node() {
939        let app = boot();
940        let mut scene = RecordingScene::new(
941            &app,
942            StreamDimensions::new(64, 64),
943            StreamDimensions::new(32, 32),
944            CamLayout::default(),
945        );
946        let before = scene.stage().len();
947        scene.set_background_gradient(
948            Color::rgb_u8(255, 138, 128),
949            Color::rgb_u8(40, 53, 147),
950            135.0,
951        );
952        // Exactly one new node (the backdrop Graphics).
953        assert_eq!(scene.stage().len(), before + 1);
954        let id = scene.backdrop.expect("backdrop created");
955        assert!(scene.stage().get(id).unwrap().container().visible);
956        // A second call reuses the node — no leak.
957        scene.set_background_color(Color::rgb_u8(10, 20, 30));
958        assert_eq!(scene.stage().len(), before + 1, "backdrop node is reused");
959        // Toggle visibility off.
960        scene.set_background_visible(false);
961        assert!(!scene.stage().get(id).unwrap().container().visible);
962    }
963
964    #[test]
965    fn set_screen_clip_sets_and_clears_the_screen_clip() {
966        let app = boot();
967        let mut scene = RecordingScene::new(
968            &app,
969            StreamDimensions::new(64, 64),
970            StreamDimensions::new(32, 32),
971            CamLayout::default(),
972        );
973        let window = Rect::new(-0.9, -0.9, 1.8, 1.8);
974        scene.set_screen_clip(Some(MaskShape::rounded_rect(window, 0.05)));
975        let clip = scene
976            .stage()
977            .get(scene.screen_sprite_id())
978            .unwrap()
979            .container()
980            .clip
981            .expect("clip set");
982        assert!(matches!(clip, MaskShape::RoundedRect { .. }));
983        scene.set_screen_clip(None);
984        assert!(
985            scene
986                .stage()
987                .get(scene.screen_sprite_id())
988                .unwrap()
989                .container()
990                .clip
991                .is_none(),
992            "clip cleared"
993        );
994    }
995
996    #[test]
997    fn set_screen_transform_updates_the_screen_sprite() {
998        let app = boot();
999        let mut scene = RecordingScene::new(
1000            &app,
1001            StreamDimensions::new(64, 64),
1002            StreamDimensions::new(32, 32),
1003            CamLayout::default(),
1004        );
1005        // A zoom-style transform (4× = 2× the base fill scale, shifted).
1006        let t = Transform {
1007            scale: Vec2::splat(4.0),
1008            position: Vec2::new(0.5, -0.25),
1009            ..Transform::IDENTITY
1010        };
1011        scene.set_screen_transform(t);
1012        let node = scene
1013            .stage()
1014            .get(scene.screen_sprite_id())
1015            .expect("screen sprite node");
1016        assert_eq!(node.container().transform, t);
1017    }
1018
1019    #[test]
1020    fn cam_container_clip_is_circle_on_square_canvas() {
1021        let app = boot();
1022        // Square canvas → no aspect compensation → equal half-extents
1023        // (a true circle, expressed as an Ellipse with hx == hy).
1024        let scene = RecordingScene::new(
1025            &app,
1026            StreamDimensions::new(64, 64),
1027            StreamDimensions::new(32, 32),
1028            CamLayout::BOTTOM_RIGHT,
1029        );
1030        let cam_node = scene
1031            .stage()
1032            .get(scene.cam_container_id())
1033            .expect("cam container alive");
1034        let container: &Container = cam_node.container();
1035        let clip = container.clip.expect("cam container has clip");
1036        match clip {
1037            MaskShape::Ellipse {
1038                center,
1039                half_extents,
1040            } => {
1041                assert_eq!(center, CamLayout::BOTTOM_RIGHT.center);
1042                assert!(
1043                    (half_extents.x - half_extents.y).abs() < 1e-6,
1044                    "square canvas → equal half-extents (circle)"
1045                );
1046                assert!((half_extents.x - CamLayout::BOTTOM_RIGHT.radius).abs() < 1e-6);
1047            }
1048            other => panic!("expected Ellipse, got {other:?}"),
1049        }
1050    }
1051
1052    #[test]
1053    fn cam_bubble_aspect_compensated_on_landscape_canvas() {
1054        let app = boot();
1055        // 16:9 landscape: the x half-extent must shrink so the bubble
1056        // is a true circle in PIXELS, not a horizontally-stretched
1057        // ellipse (the recorded-output bug). Equal pixel radii means
1058        // `hx * width == hy * height`.
1059        let scene = RecordingScene::new(
1060            &app,
1061            StreamDimensions::new(1920, 1080),
1062            StreamDimensions::new(64, 64),
1063            CamLayout::BOTTOM_RIGHT,
1064        );
1065        let clip = scene
1066            .stage()
1067            .get(scene.cam_container_id())
1068            .expect("cam container alive")
1069            .container()
1070            .clip
1071            .expect("cam container has clip");
1072        let MaskShape::Ellipse {
1073            half_extents: he, ..
1074        } = clip
1075        else {
1076            panic!("expected Ellipse, got {clip:?}");
1077        };
1078        let r = CamLayout::BOTTOM_RIGHT.radius;
1079        // min_side = 1080 → hx = r * 1080/1920, hy = r.
1080        assert!((he.x - r * 1080.0 / 1920.0).abs() < 1e-6, "hx compensated");
1081        assert!((he.y - r).abs() < 1e-6, "hy unchanged on landscape");
1082        assert!(
1083            (he.x * 1920.0 - he.y * 1080.0).abs() < 1e-3,
1084            "equal pixel radii → true circle"
1085        );
1086    }
1087
1088    #[test]
1089    fn set_screen_frame_round_trips_correct_byte_count() {
1090        let app = boot();
1091        let mut scene = RecordingScene::new(
1092            &app,
1093            StreamDimensions::new(32, 32),
1094            StreamDimensions::new(16, 16),
1095            CamLayout::default(),
1096        );
1097        let buf = vec![128u8; 32 * 32 * 4];
1098        scene.set_screen_frame(&app, &buf);
1099        // No panic = success; VideoTexture asserts the count.
1100    }
1101
1102    #[test]
1103    fn set_camera_frame_round_trips_correct_byte_count() {
1104        let app = boot();
1105        let mut scene = RecordingScene::new(
1106            &app,
1107            StreamDimensions::new(32, 32),
1108            StreamDimensions::new(16, 16),
1109            CamLayout::default(),
1110        );
1111        let buf = vec![64u8; 16 * 16 * 4];
1112        scene.set_camera_frame(&app, &buf);
1113    }
1114
1115    #[test]
1116    #[should_panic(expected = "byte length mismatch")]
1117    fn set_screen_frame_panics_on_wrong_byte_count() {
1118        let app = boot();
1119        let mut scene = RecordingScene::new(
1120            &app,
1121            StreamDimensions::new(32, 32),
1122            StreamDimensions::new(16, 16),
1123            CamLayout::default(),
1124        );
1125        scene.set_screen_frame(&app, &[0u8; 100]);
1126    }
1127
1128    #[test]
1129    #[should_panic(expected = "screen dims must be non-zero")]
1130    fn new_panics_on_zero_screen_dim() {
1131        let app = boot();
1132        let _ = RecordingScene::new(
1133            &app,
1134            StreamDimensions::new(0, 32),
1135            StreamDimensions::new(16, 16),
1136            CamLayout::default(),
1137        );
1138    }
1139
1140    #[test]
1141    #[should_panic(expected = "cam dims must be non-zero")]
1142    fn new_panics_on_zero_cam_dim() {
1143        let app = boot();
1144        let _ = RecordingScene::new(
1145            &app,
1146            StreamDimensions::new(64, 64),
1147            StreamDimensions::new(0, 16),
1148            CamLayout::default(),
1149        );
1150    }
1151
1152    #[test]
1153    fn flip_helper_reverses_row_order() {
1154        // 4 pixels × 2 rows = 8 bytes/row × 2 = 16 bytes.
1155        // Wait — BGRA = 4 bytes/pixel, so 4 × 4 = 16 bytes/row × 2 = 32 total.
1156        let row_0 = [0xAAu8; 16];
1157        let row_1 = [0xCCu8; 16];
1158        let mut src = Vec::with_capacity(32);
1159        src.extend_from_slice(&row_0);
1160        src.extend_from_slice(&row_1);
1161        let out = flip_bgra_rows_top_down_to_bottom_up(&src, 4, 2);
1162        assert_eq!(out.len(), 32);
1163        assert_eq!(
1164            &out[0..16],
1165            &row_1,
1166            "row 1 (originally bottom) lands first in bottom-up output"
1167        );
1168        assert_eq!(&out[16..32], &row_0, "row 0 (originally top) lands last");
1169    }
1170
1171    #[test]
1172    fn flip_helper_is_identity_for_single_row() {
1173        let row = [0x42u8; 16];
1174        let out = flip_bgra_rows_top_down_to_bottom_up(&row, 4, 1);
1175        assert_eq!(out, row);
1176    }
1177
1178    #[test]
1179    fn set_camera_visible_toggles_cam_container_visibility() {
1180        let app = boot();
1181        let mut scene = RecordingScene::new(
1182            &app,
1183            StreamDimensions::new(32, 32),
1184            StreamDimensions::new(16, 16),
1185            CamLayout::default(),
1186        );
1187        // Default: visible.
1188        let cam_id = scene.cam_container_id();
1189        assert!(scene.stage().get(cam_id).unwrap().container().visible);
1190
1191        scene.set_camera_visible(false);
1192        assert!(!scene.stage().get(cam_id).unwrap().container().visible);
1193
1194        scene.set_camera_visible(true);
1195        assert!(scene.stage().get(cam_id).unwrap().container().visible);
1196    }
1197
1198    #[test]
1199    fn set_screen_visible_toggles_screen_sprite_visibility() {
1200        let app = boot();
1201        let mut scene = RecordingScene::new(
1202            &app,
1203            StreamDimensions::new(32, 32),
1204            StreamDimensions::new(16, 16),
1205            CamLayout::default(),
1206        );
1207        let screen_id = scene.screen_sprite_id();
1208        assert!(scene.stage().get(screen_id).unwrap().container().visible);
1209        scene.set_screen_visible(false);
1210        assert!(!scene.stage().get(screen_id).unwrap().container().visible);
1211    }
1212
1213    #[test]
1214    fn stage_mut_allows_extension() {
1215        let app = boot();
1216        let mut scene = RecordingScene::new(
1217            &app,
1218            StreamDimensions::new(32, 32),
1219            StreamDimensions::new(16, 16),
1220            CamLayout::default(),
1221        );
1222        let before = scene.stage().len();
1223        let root = scene.stage().root();
1224        let extra = scene
1225            .stage_mut()
1226            .add_child(root, Container::default())
1227            .expect("add_child");
1228        assert_eq!(scene.stage().len(), before + 1);
1229        // Use the returned NodeId so it's not dead-code.
1230        let _ = extra;
1231    }
1232}