Skip to main content

screen_app/
recording_compose.rs

1//! `RecordingCompose` — single-threaded wisp render + wgpu readback
2//! pump for M-PIX.5 of M-RECORD-EXPORT-REAL-PIXELS.
3//!
4//! Owns the wisp `Application` + `Renderer` + `RecordingScene` +
5//! the BGRA `RenderTexture` target. On each tick:
6//!
7//! 1. Pull the latest BGRA from the camera + screen
8//!    [`crate::recording::FrameSlot`]s (latest-frame-wins).
9//! 2. Upload to the scene's `VideoTexture`s.
10//! 3. `Renderer::render_stage` into the BGRA `RenderTexture`.
11//! 4. `RenderTexture::read_pixels` to pull the composed BGRA bytes
12//!    back to CPU (handles the staging buffer + row-stride math).
13//!
14//! Wgpu objects all live on this single thread — no Send / Sync
15//! gymnastics. The thread that owns `RecordingCompose` is also the
16//! thread that pushes frames into the encoder (M-PIX.6); the two
17//! steps are fused so there's no inter-thread channel between
18//! "BGRA produced" and "BGRA encoded."
19
20use wisp::application::{AppConfig, Application};
21use wisp::color::Color;
22use wisp::recording::{CamLayout, CursorRipple, RecordingScene, StreamDimensions};
23use wisp::render::Renderer;
24use wisp::texture::render_texture::RenderTexture;
25
26use crate::recording::FrameSlot;
27
28/// Result of a single compose tick. `bytes` is BGRA8 packed
29/// (no row padding) sized exactly `width * height * 4`.
30#[derive(Debug)]
31pub struct ComposedFrame {
32    /// Tightly-packed BGRA8 bytes.
33    pub bytes: Vec<u8>,
34    /// Frame dimensions in pixels (matches the encoder caps).
35    pub width: u32,
36    /// Frame dimensions in pixels.
37    pub height: u32,
38}
39
40/// Owner of the wisp + wgpu pipeline. Single-thread. Construct on
41/// the encoder feed thread + call [`Self::compose_frame`] once per
42/// frame.
43pub struct RecordingCompose {
44    app: Application,
45    renderer: Renderer,
46    scene: RecordingScene,
47    target: RenderTexture,
48    width: u32,
49    height: u32,
50    /// Tracks whether we've ever uploaded a camera frame. Used so
51    /// the scene doesn't render a stale-empty `VideoTexture` cam
52    /// rect when the user disabled the cam channel.
53    has_camera_frame: bool,
54    /// Same for screen.
55    has_screen_frame: bool,
56}
57
58impl RecordingCompose {
59    /// Boot a fresh wisp `Application` + allocate the BGRA
60    /// `RenderTexture` for the configured output resolution.
61    ///
62    /// # Errors
63    ///
64    /// Returns the underlying [`wisp::Error`] if the adapter / device
65    /// can't be created. On macOS dev machines this should always
66    /// succeed.
67    pub fn new(
68        width: u32,
69        height: u32,
70        screen_dims: StreamDimensions,
71        cam_dims: StreamDimensions,
72    ) -> Result<Self, wisp::Error> {
73        let app = pollster::block_on(Application::new(AppConfig {
74            width,
75            height,
76            ..AppConfig::default()
77        }))?;
78        let renderer = Renderer::new(&app, wisp::wgpu::TextureFormat::Bgra8UnormSrgb)?;
79        let scene = RecordingScene::new(&app, screen_dims, cam_dims, CamLayout::default());
80        let target = RenderTexture::with_format(
81            &app,
82            width,
83            height,
84            wisp::wgpu::TextureFormat::Bgra8UnormSrgb,
85        );
86
87        Ok(Self {
88            app,
89            renderer,
90            scene,
91            target,
92            width,
93            height,
94            has_camera_frame: false,
95            has_screen_frame: false,
96        })
97    }
98
99    /// Pull the latest BGRA from each slot, upload to the scene,
100    /// render to the target, read back via `RenderTexture::read_pixels`.
101    ///
102    /// Returns `None` when neither slot has ever been written
103    /// to — caller should skip pushing anything to the encoder
104    /// this tick.
105    pub fn compose_frame(
106        &mut self,
107        camera_slot: &FrameSlot,
108        screen_slot: &FrameSlot,
109    ) -> Option<ComposedFrame> {
110        // CLONE not take — M-PIX.8 added a second consumer (the
111        // <CameraPreview /> 15fps poll). Both consumers need to
112        // see the latest frame; latest-frame-wins still holds
113        // because capture-side writes unconditionally overwrite.
114        // Cost: one BGRA clone per consumer per tick — at 480×480
115        // ≈ 920 KB/clone, negligible.
116        let cam = camera_slot
117            .lock()
118            .unwrap_or_else(std::sync::PoisonError::into_inner)
119            .clone();
120        let screen = screen_slot
121            .lock()
122            .unwrap_or_else(std::sync::PoisonError::into_inner)
123            .clone();
124
125        if let Some(bytes) = cam {
126            let expected = self.scene.cam_dims().byte_len();
127            if bytes.len() == expected {
128                // `set_camera_frame` accepts top-down BGRA (the
129                // GStreamer / Canvas2D convention the cam slot
130                // stores) and handles the wisp-side Y convention
131                // internally.
132                self.scene.set_camera_frame(&self.app, &bytes);
133                self.has_camera_frame = true;
134            } else {
135                tracing::trace!(
136                    got = bytes.len(),
137                    expected,
138                    "compose: cam frame size mismatch, dropping"
139                );
140            }
141        }
142        if let Some(bytes) = screen {
143            let expected = self.scene.screen_dims().byte_len();
144            if bytes.len() == expected {
145                self.scene.set_screen_frame(&self.app, &bytes);
146                self.has_screen_frame = true;
147            } else {
148                tracing::trace!(
149                    got = bytes.len(),
150                    expected,
151                    "compose: screen frame size mismatch, dropping"
152                );
153            }
154        }
155
156        if !self.has_camera_frame && !self.has_screen_frame {
157            return None;
158        }
159
160        // Hide the cam / screen container when the channel has never
161        // uploaded a frame this session. Without this, a screen-only
162        // recording (camera toggle off) would render the cam sprite
163        // against an unwritten `VideoTexture` — wgpu's `create_texture`
164        // doesn't guarantee zero-initialised memory, and even when it
165        // does, a residual frame from a *previous* session that was
166        // still sitting in the long-lived `RecordingState` slot would
167        // leak through. Pair with the orchestrator handing a fresh
168        // empty `FrameSlot` for disabled channels (see `commands.rs`
169        // `start_recording`'s real-capture branch).
170        self.scene.set_camera_visible(self.has_camera_frame);
171        self.scene.set_screen_visible(self.has_screen_frame);
172
173        let _stats = self.renderer.render_stage(
174            &self.app,
175            self.target.view(),
176            Color::BLACK,
177            self.scene.stage(),
178        );
179
180        let bytes = self.target.read_pixels(&self.app);
181        Some(ComposedFrame {
182            bytes,
183            width: self.width,
184            height: self.height,
185        })
186    }
187
188    /// Set the screen sprite's local transform — the editor's crop-then-zoom
189    /// framing (ED.15/ED.16). The recorder never calls this, so its screen
190    /// stays at the base fill transform. Apply before [`Self::compose_frame`]
191    /// so it lands on that frame.
192    pub fn set_screen_transform(&mut self, transform: wisp::Transform) {
193        self.scene.set_screen_transform(transform);
194    }
195
196    /// Set (or clear) the screen sprite's clip — the editor's rounded-corner
197    /// frame window (ED.18). The recorder never calls this, so its screen
198    /// stays full-bleed and un-clipped.
199    pub fn set_screen_clip(&mut self, shape: Option<wisp::MaskShape>) {
200        self.scene.set_screen_clip(shape);
201    }
202
203    /// Paint the editor backdrop as a linear gradient (ED.18). The recorder
204    /// never calls this, so its scene has no backdrop node.
205    pub fn set_background_gradient(&mut self, from: Color, to: Color, angle_deg: f32) {
206        self.scene.set_background_gradient(from, to, angle_deg);
207    }
208
209    /// Paint the editor backdrop as a flat color (ED.18).
210    pub fn set_background_color(&mut self, color: Color) {
211        self.scene.set_background_color(color);
212    }
213
214    /// Toggle the gradient/color backdrop (ED.18) — the wallpaper↔gradient lever.
215    pub fn set_background_visible(&mut self, visible: bool) {
216        self.scene.set_background_visible(visible);
217    }
218
219    /// Draw the framed-screen drop shadow (ED.18). The recorder never calls
220    /// this, so its scene has no shadow.
221    pub fn set_frame_shadow(
222        &mut self,
223        window: wisp::Rect,
224        corner_radius: f32,
225        offset: wisp::Vec2,
226        color: Color,
227    ) {
228        self.scene
229            .set_frame_shadow(window, corner_radius, offset, color);
230    }
231
232    /// Toggle the drop shadow (ED.18).
233    pub fn set_frame_shadow_visible(&mut self, visible: bool) {
234        self.scene.set_frame_shadow_visible(visible);
235    }
236
237    /// Draw the inset border (ED.18).
238    pub fn set_frame_border(
239        &mut self,
240        window: wisp::Rect,
241        corner_radius: f32,
242        stroke: wisp::Stroke,
243    ) {
244        self.scene.set_frame_border(window, corner_radius, stroke);
245    }
246
247    /// Toggle the inset border (ED.18).
248    pub fn set_frame_border_visible(&mut self, visible: bool) {
249        self.scene.set_frame_border_visible(visible);
250    }
251
252    /// Set the wallpaper backdrop from decoded RGBA8 (ED.18). The recorder
253    /// never calls this.
254    pub fn set_background_wallpaper(&mut self, width: u32, height: u32, rgba: &[u8]) {
255        self.scene
256            .set_background_wallpaper(&self.app, width, height, rgba);
257    }
258
259    /// Toggle the wallpaper backdrop (ED.18) — the wallpaper↔gradient lever.
260    pub fn set_background_wallpaper_visible(&mut self, visible: bool) {
261        self.scene.set_background_wallpaper_visible(visible);
262    }
263
264    /// Draw the editor cursor overlay (ED.19) — pointer at `pointer_ndc`
265    /// sized by `half`, with click `ripples`. The recorder never calls this.
266    pub fn set_cursor(&mut self, pointer_ndc: wisp::Vec2, half: f32, ripples: &[CursorRipple]) {
267        self.scene.set_cursor(pointer_ndc, half, ripples);
268    }
269
270    /// Toggle the editor cursor overlay's visibility (ED.19).
271    pub fn set_cursor_visible(&mut self, visible: bool) {
272        self.scene.set_cursor_visible(visible);
273    }
274
275    /// Output dimensions in pixels.
276    #[must_use]
277    pub fn dimensions(&self) -> (u32, u32) {
278        (self.width, self.height)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::sync::{Arc, Mutex};
286
287    fn test_dims() -> (StreamDimensions, StreamDimensions) {
288        (StreamDimensions::new(64, 64), StreamDimensions::new(32, 32))
289    }
290
291    #[test]
292    fn new_allocates_compose_pipeline() {
293        let (screen, cam) = test_dims();
294        let compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
295        assert_eq!(compose.dimensions(), (64, 64));
296    }
297
298    #[test]
299    fn compose_frame_returns_none_when_slots_empty() {
300        let (screen, cam) = test_dims();
301        let mut compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
302        let cam_slot: FrameSlot = Arc::new(Mutex::new(None));
303        let screen_slot: FrameSlot = Arc::new(Mutex::new(None));
304        assert!(compose.compose_frame(&cam_slot, &screen_slot).is_none());
305    }
306
307    #[test]
308    fn compose_frame_returns_bgra_after_screen_upload() {
309        let (screen, cam) = test_dims();
310        let mut compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
311        let cam_slot: FrameSlot = Arc::new(Mutex::new(None));
312        let screen_slot: FrameSlot = Arc::new(Mutex::new(Some(vec![128u8; 64 * 64 * 4])));
313        let composed = compose
314            .compose_frame(&cam_slot, &screen_slot)
315            .expect("frame produced");
316        assert_eq!(composed.width, 64);
317        assert_eq!(composed.height, 64);
318        // BGRA packed = width * height * 4
319        assert_eq!(composed.bytes.len(), 64 * 64 * 4);
320    }
321
322    #[test]
323    fn compose_frame_drops_size_mismatched_uploads() {
324        let (screen, cam) = test_dims();
325        let mut compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
326        let cam_slot: FrameSlot = Arc::new(Mutex::new(Some(vec![0u8; 99])));
327        let screen_slot: FrameSlot = Arc::new(Mutex::new(None));
328        assert!(compose.compose_frame(&cam_slot, &screen_slot).is_none());
329    }
330
331    #[test]
332    fn compose_frame_hides_cam_when_only_screen_uploads() {
333        // Regression: with the camera channel disabled the orchestrator
334        // hands a fresh empty FrameSlot. compose_frame must keep the
335        // cam container hidden so the cam sprite doesn't render against
336        // an unwritten `VideoTexture` (or a stale frame leaking from
337        // the long-lived slot).
338        let (screen, cam) = test_dims();
339        let mut compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
340        let cam_slot: FrameSlot = Arc::new(Mutex::new(None));
341        let screen_slot: FrameSlot = Arc::new(Mutex::new(Some(vec![128u8; 64 * 64 * 4])));
342
343        let _ = compose
344            .compose_frame(&cam_slot, &screen_slot)
345            .expect("frame produced");
346
347        let cam_id = compose.scene.cam_container_id();
348        assert!(
349            !compose
350                .scene
351                .stage()
352                .get(cam_id)
353                .unwrap()
354                .container()
355                .visible,
356            "cam container must be hidden when no cam frame was uploaded"
357        );
358        let screen_id = compose.scene.screen_sprite_id();
359        assert!(
360            compose
361                .scene
362                .stage()
363                .get(screen_id)
364                .unwrap()
365                .container()
366                .visible,
367            "screen sprite must be visible after a screen-frame upload"
368        );
369    }
370
371    #[test]
372    fn compose_frame_leaves_slots_intact_for_other_consumers() {
373        // M-PIX.8: compose now CLONES (not takes) so the camera
374        // preview's 15fps poll sees the same latest-frame-wins
375        // contents. Verify the slot still holds the bytes after
376        // a compose tick.
377        let (screen, cam) = test_dims();
378        let mut compose = RecordingCompose::new(64, 64, screen, cam).expect("init");
379        let cam_slot: FrameSlot = Arc::new(Mutex::new(None));
380        let screen_bytes = vec![64u8; 64 * 64 * 4];
381        let screen_slot: FrameSlot = Arc::new(Mutex::new(Some(screen_bytes.clone())));
382        let _ = compose.compose_frame(&cam_slot, &screen_slot);
383        // Slot still holds the latest frame for other consumers.
384        assert_eq!(screen_slot.lock().unwrap().as_ref(), Some(&screen_bytes));
385        // Cam slot was always None — stays None.
386        assert!(cam_slot.lock().unwrap().is_none());
387    }
388}