Skip to main content

screen_app/
editor_preview.rs

1//! `EditorPreview` — composes the frame at the playhead for the editor's
2//! preview surface (ED.6 / M-EDIT).
3//!
4//! Reuses [`RecordingCompose`](crate::recording_compose::RecordingCompose)
5//! — the proven recorder compositor — but sourced from a seekable
6//! [`EditorVideoStream`](decode::EditorVideoStream) at the editor's
7//! playhead ([`EditorPlayer::current_frame`](playback::EditorPlayer::current_frame))
8//! rather than from live capture slots. This is the **same compose path**
9//! the export pipeline (ED.20) will drive, so preview and export agree
10//! frame-for-frame by construction.
11//!
12//! The recorded clip is already a fully-composited screen frame (the cam
13//! bubble, if any, was baked in at record time), so the editor preview
14//! shows it full-frame; the cam channel of the underlying scene is unused.
15//! The cinematic framing controls (background, padding, rounded corners,
16//! shadow) layer on in ED.18. The live `winit` window that presents these
17//! composed frames follows the `preview` crate's pattern and is verified
18//! manually (it can't run in the headless gate).
19
20use std::sync::{Arc, Mutex};
21
22use decode::EditorVideoStream;
23use edit::style::{BackgroundConfig, BackgroundSource, CropRect, CursorConfig};
24use edit::zoom_anim::ZoomTransform;
25use playback::EditorPlayer;
26use wisp::recording::{CursorRipple, StreamDimensions};
27use wisp::{Color, MaskShape, Rect, Stroke, Transform, Vec2};
28
29use crate::recording::FrameSlot;
30use crate::recording_compose::{ComposedFrame, RecordingCompose};
31
32/// Base scale that fills the NDC `[-1, 1]` viewport: the screen sprite is
33/// anchored centre with a `[0, 1]²` local rect, so a scale of `2` maps the
34/// full source frame onto `[-1, 1]`.
35const FILL_SCALE: f64 = 2.0;
36
37/// Cursor pointer half-extent in NDC at 100 % size and 1× zoom (ED.19). The
38/// pointer grows with `size_pct` and with the zoom (it's part of the
39/// magnified content).
40const CURSOR_BASE_HALF: f32 = 0.05;
41
42/// Resolution the procedural wallpaper (ISS-15) is generated at — small, since
43/// a soft gradient stretches cleanly to fill the frame.
44const WALLPAPER_GEN_W: u32 = 320;
45/// See [`WALLPAPER_GEN_W`].
46const WALLPAPER_GEN_H: u32 = 180;
47
48/// Build the screen-sprite transform that applies `crop` then `zoom`.
49///
50/// The base screen sprite maps a normalized source point `(u, v)` to NDC
51/// `(2u − 1, −(2v − 1))` (centre-anchored, scale 2; the `−` on `v` is wisp's
52/// `+y`-up convention — the decoded top-down frame is flipped bottom-up at
53/// upload). This composes two affines into the single transform the sprite
54/// carries:
55///
56/// 1. **Crop** `[x, y, w, h]` → fill the sub-rect: scale `2/w, 2/h` and
57///    recentre so the crop centre lands at NDC `(0, 0)`.
58/// 2. **Zoom** by `z` about the focal NDC point `(2·fx − 1, −(2·fy − 1))`,
59///    keeping that point fixed: `pos += focal · (1 − z)`.
60///
61/// Composed `zoom ∘ crop`: `scale = z · crop_scale`,
62/// `pos = z · crop_pos + zoom_pos`. Pure — no GPU, exhaustively testable.
63#[must_use]
64#[allow(
65    clippy::cast_possible_truncation,
66    reason = "screen-space NDC transform components are small; the f64→f32 narrowing for the wisp Vec2 is intentional and well within f32 precision"
67)]
68fn framed_transform(zoom: ZoomTransform, crop: CropRect) -> Transform {
69    // Crop arm: fill sub-rect [x, x+w] × [y, y+h]. `CropRect` is sanitized
70    // to a non-zero in-frame rect, but clamp defensively against /0.
71    let w = f64::from(crop.width).max(1e-3);
72    let h = f64::from(crop.height).max(1e-3);
73    let crop_scale = (FILL_SCALE / w, FILL_SCALE / h);
74    let crop_pos = (
75        (1.0 - 2.0 * f64::from(crop.x)) / w - 1.0,
76        (2.0 * f64::from(crop.y) - 1.0) / h + 1.0,
77    );
78
79    // Zoom arm: magnify by `z` about the focal NDC point, keeping it fixed.
80    let z = zoom.scale.max(1.0);
81    let focal = (2.0 * zoom.center_x - 1.0, -(2.0 * zoom.center_y - 1.0));
82    let zoom_pos = (focal.0 * (1.0 - z), focal.1 * (1.0 - z));
83
84    Transform {
85        scale: Vec2::new((z * crop_scale.0) as f32, (z * crop_scale.1) as f32),
86        position: Vec2::new(
87            (z * crop_pos.0 + zoom_pos.0) as f32,
88            (z * crop_pos.1 + zoom_pos.1) as f32,
89        ),
90        ..Transform::IDENTITY
91    }
92}
93
94/// [`framed_transform`] with a uniform **padding** inset folded in (ED.18).
95///
96/// Background framing lifts the screen off its backdrop by `padding` pixels.
97/// In NDC that's a centred shrink by `(k_x, k_y)` about the origin, and since
98/// both the base fill and the zoom focal-pin are about the origin, the inset
99/// is *exactly* a post-multiply: `scale *= k`, `position *= k`. With
100/// `k = (1, 1)` it reduces to [`framed_transform`].
101#[must_use]
102#[allow(
103    clippy::cast_possible_truncation,
104    reason = "padding factors are in (0, 1] and the transform components are small NDC values, well within f32 precision"
105)]
106fn framed_transform_padded(zoom: ZoomTransform, crop: CropRect, k_x: f64, k_y: f64) -> Transform {
107    let t = framed_transform(zoom, crop);
108    let (kx, ky) = (k_x as f32, k_y as f32);
109    Transform {
110        scale: Vec2::new(t.scale.x * kx, t.scale.y * ky),
111        position: Vec2::new(t.position.x * kx, t.position.y * ky),
112        ..t
113    }
114}
115
116/// Derive the rounded-corner frame window + corner radius (both NDC) and the
117/// per-axis padding shrink factor from the canvas dims and the config's
118/// pixel padding / corner radius (ED.18). Pure — unit-tested without a GPU.
119///
120/// `padding` px → NDC margin `2·padding/axis` (NDC spans `[-1, 1]` = 2 units
121/// per axis), so the screen shrinks to `[-k, k]` with `k = 1 − 2·padding/axis`.
122/// The window rect is that `[-k, k]²` region; `corner_radius` px → NDC
123/// `2·corner_radius/width` (isotropic in NDC).
124#[must_use]
125#[allow(
126    clippy::cast_possible_truncation,
127    reason = "NDC window extents (|·| ≤ 2) and the corner radius fraction are small, well within f32 precision"
128)]
129fn background_geometry(
130    width: u32,
131    height: u32,
132    padding: u32,
133    corner_radius: u32,
134) -> (Rect, f32, (f64, f64)) {
135    let w = f64::from(width.max(1));
136    let h = f64::from(height.max(1));
137    let k_x = (1.0 - 2.0 * f64::from(padding) / w).clamp(0.05, 1.0);
138    let k_y = (1.0 - 2.0 * f64::from(padding) / h).clamp(0.05, 1.0);
139    let window = Rect::new(
140        -(k_x as f32),
141        -(k_y as f32),
142        (2.0 * k_x) as f32,
143        (2.0 * k_y) as f32,
144    );
145    let corner_ndc = (2.0 * f64::from(corner_radius) / w) as f32;
146    (window, corner_ndc, (k_x, k_y))
147}
148
149/// Three RGB anchor colors for a named wallpaper palette (ISS-15). Unknown
150/// names fall to the default "aurora".
151fn wallpaper_palette(name: &str) -> [[u8; 3]; 3] {
152    match name.to_ascii_lowercase().as_str() {
153        "sunset" => [[255, 94, 98], [255, 195, 113], [113, 70, 132]],
154        "ocean" | "aqua" => [[0, 79, 131], [0, 160, 176], [137, 218, 196]],
155        "forest" | "mint" => [[20, 60, 50], [44, 110, 73], [149, 200, 120]],
156        // "aurora" / default — warm→cool diagonal, matching the gradient default.
157        _ => [[40, 53, 147], [123, 67, 151], [255, 138, 128]],
158    }
159}
160
161/// Generate a procedural wallpaper backdrop as packed RGBA8 (`width*height*4`).
162/// A soft diagonal three-stop gradient with a faint aurora band, keyed on
163/// `name`'s palette. License-clean (no bundled asset), deterministic, and pure
164/// — exhaustively testable. (ISS-15.)
165#[must_use]
166#[allow(
167    clippy::cast_precision_loss,
168    clippy::cast_possible_truncation,
169    clippy::cast_sign_loss,
170    reason = "pixel coords + small dims are exact in f32; the channel result is clamped to [0,255] and rounded before the f32→u8 cast"
171)]
172fn wallpaper_rgba(name: &str, width: u32, height: u32) -> Vec<u8> {
173    let [c0, c1, c2] = wallpaper_palette(name);
174    let cols = width.max(1) as usize;
175    let rows = height.max(1) as usize;
176    let (wf, hf) = (cols as f32, rows as f32);
177    let lerp = |lo: [u8; 3], hi: [u8; 3], t: f32| -> [u8; 3] {
178        let t = t.clamp(0.0, 1.0);
179        let chan = |lc: u8, hc: u8| (f32::from(lc) + (f32::from(hc) - f32::from(lc)) * t).round();
180        [
181            chan(lo[0], hi[0]) as u8,
182            chan(lo[1], hi[1]) as u8,
183            chan(lo[2], hi[2]) as u8,
184        ]
185    };
186    let mut out = vec![0u8; cols * rows * 4];
187    for row in 0..rows {
188        for col in 0..cols {
189            let (xf, yf) = (col as f32, row as f32);
190            // Diagonal sweep across the two halves of the palette.
191            let diag = (xf / wf).midpoint(yf / hf);
192            // A faint aurora band on the cross-diagonal, lifting toward c2.
193            let band = (((xf / wf - yf / hf) * std::f32::consts::TAU).sin() * 0.5 + 0.5) * 0.22;
194            let base = if diag < 0.5 {
195                lerp(c0, c1, diag * 2.0)
196            } else {
197                lerp(c1, c2, (diag - 0.5) * 2.0)
198            };
199            let px = lerp(base, c2, band);
200            let idx = (row * cols + col) * 4;
201            out[idx] = px[0];
202            out[idx + 1] = px[1];
203            out[idx + 2] = px[2];
204            out[idx + 3] = 255;
205        }
206    }
207    out
208}
209
210/// Per-axis screen shrink that fits a `source_w × source_h` frame inside a
211/// `canvas_w × canvas_h` output canvas **without stretch** (AUT-513). The
212/// screen sprite's base transform fills NDC `[-1, 1]²` (the whole canvas), so
213/// when the canvas aspect differs from the source aspect the source would
214/// stretch; this factor shrinks the over-long axis so the source keeps its
215/// shape and the remainder becomes a matte (letterbox / pillarbox).
216///
217/// `r = source_aspect / canvas_aspect`: `r > 1` (source relatively wider) fits
218/// to width and shrinks height (`1/r`) → letterbox top/bottom; `r < 1` (source
219/// relatively taller) fits to height and shrinks width (`r`) → pillarbox sides;
220/// `r == 1` (same aspect) is `(1, 1)` (full fill, unchanged behaviour). Pure.
221#[must_use]
222fn aspect_fit_factor(source_w: u32, source_h: u32, canvas_w: u32, canvas_h: u32) -> (f64, f64) {
223    let source_aspect = f64::from(source_w.max(1)) / f64::from(source_h.max(1));
224    let canvas_aspect = f64::from(canvas_w.max(1)) / f64::from(canvas_h.max(1));
225    let r = source_aspect / canvas_aspect;
226    if r >= 1.0 { (1.0, 1.0 / r) } else { (r, 1.0) }
227}
228
229/// Composes the editor preview frame for a `source_w × source_h` clip into a
230/// `canvas_w × canvas_h` output (the project's aspect-ratio canvas).
231pub struct EditorPreview {
232    compose: RecordingCompose,
233    screen_slot: FrameSlot,
234    /// Always empty — the editor source is pre-composited, so the scene's
235    /// camera channel never renders.
236    cam_slot: FrameSlot,
237    width: u32,
238    height: u32,
239    /// Per-axis screen shrink applied to the transform: the aspect-fit matte
240    /// (AUT-513) optionally multiplied by the [`Self::set_background`] padding
241    /// inset (ED.18). Defaults to the aspect-fit so an un-styled render still
242    /// letterboxes the source into a differently-shaped canvas.
243    pad: (f64, f64),
244    /// The aspect-fit-only factor (no padding) — the matte that fits the source
245    /// into the canvas. `set_background` re-derives `pad` as `padding · this`.
246    aspect_fit: (f64, f64),
247}
248
249impl EditorPreview {
250    /// Allocate the compose pipeline for a clip rendered at its own
251    /// dimensions (source aspect == canvas aspect, no matte). Equivalent to
252    /// [`Self::with_canvas`]`(width, height, width, height)`.
253    ///
254    /// # Errors
255    ///
256    /// Returns the underlying [`wisp::Error`] if the wgpu device can't be
257    /// created.
258    pub fn new(width: u32, height: u32) -> Result<Self, wisp::Error> {
259        Self::with_canvas(width, height, width, height)
260    }
261
262    /// Allocate the compose pipeline for a `source_w × source_h` clip rendered
263    /// into a `canvas_w × canvas_h` output canvas (AUT-513). When the canvas
264    /// aspect differs from the source aspect the source is **aspect-fit** into
265    /// the canvas (letterbox / pillarbox) rather than stretched — the matte
266    /// area shows the background. Use [`EditProject::canvas_dims`] for the
267    /// canvas; the export path additionally clamps it to the HW-encoder edge cap.
268    ///
269    /// # Errors
270    ///
271    /// Returns the underlying [`wisp::Error`] if the wgpu device can't be
272    /// created.
273    pub fn with_canvas(
274        source_w: u32,
275        source_h: u32,
276        canvas_w: u32,
277        canvas_h: u32,
278    ) -> Result<Self, wisp::Error> {
279        // The screen *texture* is the source's dimensions; the render target
280        // (and `width`/`height`) is the canvas. `compose_frame` validates the
281        // uploaded bytes against the screen texture, so callers keep passing
282        // source-sized BGRA.
283        let screen = StreamDimensions::new(source_w, source_h);
284        // `RecordingScene::new` requires non-zero cam dims even though the
285        // cam never renders here; a 2×2 placeholder is the cheapest legal
286        // texture. No cam frame is ever uploaded, so it stays hidden.
287        let cam = StreamDimensions::new(2, 2);
288        let compose = RecordingCompose::new(canvas_w, canvas_h, screen, cam)?;
289        let aspect_fit = aspect_fit_factor(source_w, source_h, canvas_w, canvas_h);
290        Ok(Self {
291            compose,
292            screen_slot: Arc::new(Mutex::new(None)),
293            cam_slot: Arc::new(Mutex::new(None)),
294            width: canvas_w,
295            height: canvas_h,
296            // Default to the matte so an un-styled render letterboxes; a
297            // `set_background` call folds the padding inset in on top.
298            pad: aspect_fit,
299            aspect_fit,
300        })
301    }
302
303    /// Apply the project's **background framing** (ED.18): the backdrop fill,
304    /// the padding inset, and the rounded-corner frame window. Call once when
305    /// the config changes — the export sets it once at generator construction;
306    /// a live preview re-applies it on edit. After this, the per-frame
307    /// [`Self::render_framed`] folds the stored padding factor into the screen
308    /// transform, the screen sprite is clipped to the rounded window, and the
309    /// backdrop renders behind it.
310    ///
311    /// Drop-shadow (`shadow`) lifts the screen off the backdrop; the inset
312    /// border (`inset`) traces a colored edge just inside the frame — both
313    /// rendered here (ED.18). A `Wallpaper` source renders a procedural
314    /// backdrop ([`wallpaper_rgba`], ISS-15) — license-clean, no bundled asset.
315    #[allow(
316        clippy::cast_possible_truncation,
317        reason = "aspect-fit factors are in (0, 1] and the NDC window extents are small; the f64→f32 narrowing of the scaled window is well within f32 precision"
318    )]
319    pub fn set_background(&mut self, bg: &BackgroundConfig) {
320        let (window0, corner_ndc, (k_x, k_y)) =
321            background_geometry(self.width, self.height, bg.padding, bg.corner_radius);
322        // Fold the aspect-fit matte (AUT-513) into both the screen transform
323        // padding and the frame window, so the rounded-corner clip / shadow /
324        // border trace the *letterboxed* screen rect, not the full canvas.
325        let (fx, fy) = self.aspect_fit;
326        self.pad = (k_x * fx, k_y * fy);
327        let (fxf, fyf) = (fx as f32, fy as f32);
328        let window = Rect::new(
329            window0.min.x * fxf,
330            window0.min.y * fyf,
331            window0.size.x * fxf,
332            window0.size.y * fyf,
333        );
334        self.compose
335            .set_screen_clip(Some(MaskShape::rounded_rect(window, corner_ndc)));
336        self.apply_shadow_and_border(bg, window, corner_ndc);
337
338        // The wallpaper is a Sprite; the gradient/color is a Graphics. The
339        // renderer batches by pipeline, so the two backdrop layers can't
340        // coexist (the Graphics would paint over the Sprite) — they're
341        // mutually exclusive. Each arm shows its layer and hides the other.
342        match &bg.source {
343            BackgroundSource::Gradient {
344                from,
345                to,
346                angle_deg,
347            } => {
348                self.compose.set_background_gradient(
349                    Color::rgb_u8(from[0], from[1], from[2]),
350                    Color::rgb_u8(to[0], to[1], to[2]),
351                    *angle_deg,
352                );
353                self.compose.set_background_wallpaper_visible(false);
354            }
355            BackgroundSource::Color { rgb } => {
356                self.compose
357                    .set_background_color(Color::rgb_u8(rgb[0], rgb[1], rgb[2]));
358                self.compose.set_background_wallpaper_visible(false);
359            }
360            BackgroundSource::Wallpaper { name } => {
361                // Procedural wallpaper (ISS-15) — license-clean, no bundled
362                // asset, deterministic per `name`. Generated small + stretched
363                // by the Sprite (a soft gradient doesn't need resolution).
364                let rgba = wallpaper_rgba(name, WALLPAPER_GEN_W, WALLPAPER_GEN_H);
365                self.compose
366                    .set_background_wallpaper(WALLPAPER_GEN_W, WALLPAPER_GEN_H, &rgba);
367                self.compose.set_background_visible(false);
368            }
369        }
370    }
371
372    /// Apply the drop shadow + inset border for the frame window (ED.18).
373    /// `shadow` (0..=100) scales a dark, down-right-offset rounded-rect behind
374    /// the screen; `inset` (px) strokes a colored border tracing the window.
375    /// Each hides its node when its value is 0.
376    #[allow(
377        clippy::cast_possible_truncation,
378        reason = "shadow/border NDC magnitudes are small fractions, well within f32 precision"
379    )]
380    fn apply_shadow_and_border(&mut self, bg: &BackgroundConfig, window: Rect, corner_ndc: f32) {
381        if bg.shadow > 0 {
382            let s = f32::from(u16::try_from(bg.shadow.min(100)).unwrap_or(60)) / 100.0;
383            let mag = s * 0.035;
384            self.compose.set_frame_shadow(
385                window,
386                corner_ndc,
387                // Down (+y is up, so down = −y) and slightly right.
388                Vec2::new(mag * 0.5, -mag),
389                Color::rgba(0.0, 0.0, 0.0, (s * 0.55).min(0.55)),
390            );
391        } else {
392            self.compose.set_frame_shadow_visible(false);
393        }
394
395        if bg.inset > 0 {
396            let stroke_ndc = (2.0 * f64::from(bg.inset) / f64::from(self.width.max(1))) as f32;
397            self.compose.set_frame_border(
398                window,
399                corner_ndc,
400                Stroke::new(stroke_ndc, Color::rgba(1.0, 1.0, 1.0, 0.7)),
401            );
402        } else {
403            self.compose.set_frame_border_visible(false);
404        }
405    }
406
407    /// Compose a single source frame. `bgra` is top-down packed BGRA8 of
408    /// exactly `width * height * 4` bytes (the decoder's native output);
409    /// the scene flips it to wisp's convention internally. Returns `None`
410    /// if the byte count doesn't match the configured dimensions.
411    #[must_use]
412    pub fn render_frame(&mut self, bgra: Vec<u8>) -> Option<ComposedFrame> {
413        {
414            let mut guard = self
415                .screen_slot
416                .lock()
417                .unwrap_or_else(std::sync::PoisonError::into_inner);
418            *guard = Some(bgra);
419        }
420        self.compose
421            .compose_frame(&self.cam_slot, &self.screen_slot)
422    }
423
424    /// Compose a source frame with the editor's **framing** applied — the
425    /// `crop`/aspect reframe (ED.15), the `zoom` punch-in (ED.16), and the
426    /// background **padding** inset (ED.18) are written into the screen
427    /// sprite's transform, then the frame composes through the same path as
428    /// [`Self::render_frame`]. The backdrop + rounded-corner clip are set
429    /// once via [`Self::set_background`]; this only updates the per-frame
430    /// transform. The padding factor is `(1, 1)` until `set_background` runs,
431    /// so an un-styled call is the plain ED.16 transform. This is the export
432    /// generator's entry point (ED.20), so preview and export agree
433    /// frame-for-frame. `bgra` is top-down packed BGRA8 of `width*height*4`.
434    #[must_use]
435    pub fn render_framed(
436        &mut self,
437        bgra: Vec<u8>,
438        zoom: ZoomTransform,
439        crop: CropRect,
440    ) -> Option<ComposedFrame> {
441        let (k_x, k_y) = self.pad;
442        self.compose
443            .set_screen_transform(framed_transform_padded(zoom, crop, k_x, k_y));
444        self.render_frame(bgra)
445    }
446
447    /// Like [`Self::render_framed`], plus the cursor overlay (ED.19). `cursor`
448    /// is the smoothed normalized position at this frame
449    /// ([`edit::telemetry::cursor_at`]); `ripples` are the active click
450    /// ripples (`(x, y, age)` from [`edit::telemetry::ripples_at`]). Both the
451    /// pointer and each ripple are mapped through the **same** screen
452    /// transform as the frame, so the cursor stays glued to its pixel through
453    /// zoom / crop / padding. `cursor = None` hides the overlay (e.g. before
454    /// any sample, or a `hide_static` gap the caller decides).
455    #[must_use]
456    pub fn render_framed_with_cursor(
457        &mut self,
458        bgra: Vec<u8>,
459        zoom: ZoomTransform,
460        crop: CropRect,
461        cursor: Option<(f32, f32)>,
462        ripples: &[(f32, f32, f32)],
463        cfg: &CursorConfig,
464    ) -> Option<ComposedFrame> {
465        let (k_x, k_y) = self.pad;
466        let t = framed_transform_padded(zoom, crop, k_x, k_y);
467        self.compose.set_screen_transform(t);
468
469        // Map a normalized source point (top-left origin) through the same
470        // transform the screen sprite carries — `pos + scale · (local)` with
471        // the `+y`-up flip on the local `y` (matching `framed_transform`).
472        let map = |x: f32, y: f32| {
473            Vec2::new(
474                t.position.x + t.scale.x * (x - 0.5),
475                t.position.y + t.scale.y * -(y - 0.5),
476            )
477        };
478
479        if let Some((cx, cy)) = cursor {
480            // Pointer half-size: base × size_pct, grown by the transform's
481            // scale (base scale is 2, so `scale.y / 2` is the z·k_y zoom
482            // factor) — the cursor magnifies with the punch-in.
483            let size_factor = f32::from(u16::try_from(cfg.size_pct).unwrap_or(180)) / 100.0;
484            // Base sprite scale is 2 (`FILL_SCALE`), so `scale.y / 2` is the
485            // z·k_y zoom factor — the pointer magnifies with the punch-in.
486            let zoom_scale = (t.scale.y / 2.0).abs();
487            let half = CURSOR_BASE_HALF * size_factor * zoom_scale;
488            let rings: Vec<CursorRipple> = if cfg.click_ripples {
489                ripples
490                    .iter()
491                    .map(|&(rx, ry, age)| CursorRipple {
492                        center: map(rx, ry),
493                        radius: half * (1.0 + 3.0 * age),
494                        alpha: (1.0 - age) * 0.35,
495                    })
496                    .collect()
497            } else {
498                Vec::new()
499            };
500            self.compose.set_cursor(map(cx, cy), half, &rings);
501            self.compose.set_cursor_visible(true);
502        } else {
503            self.compose.set_cursor_visible(false);
504        }
505        self.render_frame(bgra)
506    }
507
508    /// Compose the frame at the player's current playhead, pulling it from
509    /// the seekable stream. Returns `None` past the end of the stream.
510    #[must_use]
511    pub fn render_at(
512        &mut self,
513        stream: &mut EditorVideoStream,
514        player: &EditorPlayer,
515    ) -> Option<ComposedFrame> {
516        let frame = stream.frame(player.current_frame())?;
517        self.render_frame(frame.bgra)
518    }
519
520    /// Output dimensions in pixels.
521    #[must_use]
522    pub fn dimensions(&self) -> (u32, u32) {
523        (self.width, self.height)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn renders_a_source_frame_to_composed_bgra() {
533        let mut preview = EditorPreview::new(64, 64).expect("init wgpu");
534        assert_eq!(preview.dimensions(), (64, 64));
535        // A mid-grey source frame composes to a non-empty BGRA buffer of
536        // the configured size (proves the wisp render path ran cleanly).
537        let composed = preview
538            .render_frame(vec![128u8; 64 * 64 * 4])
539            .expect("frame composed");
540        assert_eq!(composed.width, 64);
541        assert_eq!(composed.height, 64);
542        assert_eq!(composed.bytes.len(), 64 * 64 * 4);
543    }
544
545    #[test]
546    fn wrong_sized_frame_is_dropped() {
547        let mut preview = EditorPreview::new(64, 64).expect("init wgpu");
548        // A byte count that doesn't match 64×64×4 is dropped by the
549        // compositor → no composed frame.
550        assert!(preview.render_frame(vec![0u8; 100]).is_none());
551    }
552
553    #[test]
554    fn aspect_fit_factor_is_unit_for_equal_aspects() {
555        // Same aspect (any scale) → no matte, full fill — preserves the old
556        // source==canvas behaviour exactly.
557        let (fx, fy) = aspect_fit_factor(1920, 1080, 1920, 1080);
558        assert!((fx - 1.0).abs() < 1e-9 && (fy - 1.0).abs() < 1e-9);
559        let (fx, fy) = aspect_fit_factor(1280, 720, 640, 360);
560        assert!((fx - 1.0).abs() < 1e-9 && (fy - 1.0).abs() < 1e-9);
561    }
562
563    #[test]
564    fn aspect_fit_factor_letterboxes_a_wide_source_in_a_tall_canvas() {
565        // 16:9 source into a 9:16 canvas: fit width, shrink height by
566        // canvas_aspect/source_aspect = 0.5625/1.7778 = 0.31640625.
567        let (fx, fy) = aspect_fit_factor(1920, 1080, 1080, 1920);
568        assert!((fx - 1.0).abs() < 1e-9, "fills width");
569        assert!(
570            (fy - 0.316_406_25).abs() < 1e-6,
571            "shrinks height (letterbox)"
572        );
573        // 16:9 into 1:1: shrink height by 9/16 = 0.5625.
574        let (fx, fy) = aspect_fit_factor(1920, 1080, 1080, 1080);
575        assert!((fx - 1.0).abs() < 1e-9 && (fy - 0.5625).abs() < 1e-6);
576    }
577
578    #[test]
579    fn aspect_fit_factor_pillarboxes_a_tall_source_in_a_wide_canvas() {
580        // 9:16 source into a 16:9 canvas: fit height, shrink width.
581        let (fx, fy) = aspect_fit_factor(1080, 1920, 1920, 1080);
582        assert!((fy - 1.0).abs() < 1e-9, "fills height");
583        assert!(
584            (fx - 0.316_406_25).abs() < 1e-6,
585            "shrinks width (pillarbox)"
586        );
587    }
588
589    /// GPU: a 16:9 white source composed into a 9:16 canvas is a centred
590    /// horizontal band with dark matte bars top + bottom (letterbox), at the
591    /// canvas dimensions — proving the aspect reframe is honored without
592    /// stretching the source. Single-pass compose, no blur → every CI OS.
593    #[test]
594    fn with_canvas_letterboxes_a_wide_source_into_a_tall_canvas() {
595        // 160×90 (16:9) source → 90×160 (9:16) canvas.
596        let mut pv = EditorPreview::with_canvas(160, 90, 90, 160).expect("init wgpu");
597        assert_eq!(pv.dimensions(), (90, 160), "renders at the 9:16 canvas");
598        let white = vec![255u8; 160 * 90 * 4];
599        let f = pv
600            .render_framed(white, ZoomTransform::identity(), CropRect::full())
601            .expect("compose");
602        assert_eq!((f.width, f.height), (90, 160));
603        let px = |col: usize, row: usize| {
604            let i = (row * 90 + col) * 4;
605            [f.bytes[i], f.bytes[i + 1], f.bytes[i + 2]]
606        };
607        let is_white = |p: [u8; 3]| p[0] > 230 && p[1] > 230 && p[2] > 230;
608        let is_dark = |p: [u8; 3]| u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2]) < 60;
609        // Centre column, mid-row: the source band.
610        assert!(is_white(px(45, 80)), "centre is the source (not matte)");
611        // The source band is ~31.6% of the height (≈50 of 160 px) centred, so
612        // rows near the top + bottom edges are matte bars.
613        assert!(is_dark(px(45, 3)), "top letterbox bar is dark");
614        assert!(is_dark(px(45, 156)), "bottom letterbox bar is dark");
615    }
616
617    #[test]
618    fn framed_transform_is_base_fill_with_no_zoom_no_crop() {
619        let t = framed_transform(ZoomTransform::identity(), CropRect::full());
620        assert!((t.scale.x - 2.0).abs() < 1e-5 && (t.scale.y - 2.0).abs() < 1e-5);
621        assert!(t.position.x.abs() < 1e-5 && t.position.y.abs() < 1e-5);
622    }
623
624    #[test]
625    fn framed_transform_zoom_at_centre_magnifies_in_place() {
626        let z = ZoomTransform {
627            scale: 2.0,
628            center_x: 0.5,
629            center_y: 0.5,
630        };
631        let t = framed_transform(z, CropRect::full());
632        // 2× the base fill (→ 4) about the centred focal → no translation.
633        assert!((t.scale.x - 4.0).abs() < 1e-5 && (t.scale.y - 4.0).abs() < 1e-5);
634        assert!(t.position.x.abs() < 1e-5 && t.position.y.abs() < 1e-5);
635    }
636
637    #[test]
638    fn framed_transform_zoom_pins_the_focal_corner() {
639        // 2× at top-right (1, 0): focal NDC (1, 1) stays fixed →
640        // pos = (1, 1) · (1 − 2) = (−1, −1).
641        let z = ZoomTransform {
642            scale: 2.0,
643            center_x: 1.0,
644            center_y: 0.0,
645        };
646        let t = framed_transform(z, CropRect::full());
647        assert!((t.scale.x - 4.0).abs() < 1e-5);
648        assert!((t.position.x + 1.0).abs() < 1e-5, "right edge pinned");
649        assert!((t.position.y + 1.0).abs() < 1e-5, "top edge pinned");
650    }
651
652    #[test]
653    fn framed_transform_crop_fills_the_subrect() {
654        // Top-left quadrant [0, 0, 0.5, 0.5] fills the frame: scale
655        // 2 / 0.5 = 4 each; the quadrant maps [0, 0.5] → [−1, 1] (pos (1, −1)).
656        let crop = CropRect {
657            x: 0.0,
658            y: 0.0,
659            width: 0.5,
660            height: 0.5,
661        };
662        let t = framed_transform(ZoomTransform::identity(), crop);
663        assert!((t.scale.x - 4.0).abs() < 1e-5 && (t.scale.y - 4.0).abs() < 1e-5);
664        assert!((t.position.x - 1.0).abs() < 1e-5);
665        assert!((t.position.y + 1.0).abs() < 1e-5);
666    }
667
668    #[test]
669    fn framed_transform_padded_with_unit_factor_equals_unpadded() {
670        // k = (1, 1) ⇒ no inset ⇒ identical to framed_transform.
671        let z = ZoomTransform {
672            scale: 1.6,
673            center_x: 0.3,
674            center_y: 0.7,
675        };
676        let crop = CropRect {
677            x: 0.1,
678            y: 0.05,
679            width: 0.7,
680            height: 0.8,
681        };
682        let plain = framed_transform(z, crop);
683        let padded = framed_transform_padded(z, crop, 1.0, 1.0);
684        assert!((plain.scale.x - padded.scale.x).abs() < 1e-6);
685        assert!((plain.scale.y - padded.scale.y).abs() < 1e-6);
686        assert!((plain.position.x - padded.position.x).abs() < 1e-6);
687        assert!((plain.position.y - padded.position.y).abs() < 1e-6);
688    }
689
690    #[test]
691    fn framed_transform_padded_shrinks_about_the_centre() {
692        // No zoom / crop: base fill scale 2 → with k = 0.5 → scale 1, pos 0.
693        let t = framed_transform_padded(ZoomTransform::identity(), CropRect::full(), 0.5, 0.5);
694        assert!((t.scale.x - 1.0).abs() < 1e-6 && (t.scale.y - 1.0).abs() < 1e-6);
695        assert!(t.position.x.abs() < 1e-6 && t.position.y.abs() < 1e-6);
696    }
697
698    #[test]
699    fn framed_transform_padded_pins_the_focal_corner_inside_the_window() {
700        // 2× at top-right pins focal NDC (1, 1) → unpadded pos (−1, −1),
701        // scale 4. With k = 0.5 the pinned corner lands at the padded
702        // window corner: scale 2, pos (−0.5, −0.5).
703        let z = ZoomTransform {
704            scale: 2.0,
705            center_x: 1.0,
706            center_y: 0.0,
707        };
708        let t = framed_transform_padded(z, CropRect::full(), 0.5, 0.5);
709        assert!((t.scale.x - 2.0).abs() < 1e-6);
710        assert!(
711            (t.position.x + 0.5).abs() < 1e-6,
712            "right edge pinned to window"
713        );
714        assert!(
715            (t.position.y + 0.5).abs() < 1e-6,
716            "top edge pinned to window"
717        );
718    }
719
720    #[test]
721    fn background_geometry_matches_reference_defaults() {
722        // Reference design: padding 64, corner 14, on a 1920×1080 canvas.
723        let (window, corner, (k_x, k_y)) = background_geometry(1920, 1080, 64, 14);
724        // k_x = 1 − 128/1920 = 0.93333; k_y = 1 − 128/1080 = 0.88148.
725        assert!((k_x - 0.933_333).abs() < 1e-4);
726        assert!((k_y - 0.881_481).abs() < 1e-4);
727        assert!((window.min.x + 0.933_333).abs() < 1e-4);
728        assert!((window.min.y + 0.881_481).abs() < 1e-4);
729        assert!((window.size.x - 1.866_667).abs() < 1e-4);
730        assert!((window.size.y - 1.762_963).abs() < 1e-4);
731        // corner_ndc = 2·14/1920 = 0.014583.
732        assert!((corner - 0.014_583).abs() < 1e-5);
733    }
734
735    #[test]
736    fn background_geometry_clamps_pathological_padding() {
737        // Padding ≥ half the canvas would invert the screen — clamp to a
738        // small positive factor instead.
739        let (_w, _c, (k_x, k_y)) = background_geometry(128, 128, 200, 0);
740        assert!(
741            k_x >= 0.05 && k_y >= 0.05,
742            "padding clamped, screen survives"
743        );
744    }
745
746    /// GPU: a gradient backdrop + padding + rounded corners on a solid-white
747    /// "screen" frame. The centre stays white (screen visible); the corner
748    /// margin is the backdrop (rounded corners cut the white + padding reveals
749    /// the backdrop); and the backdrop is a real gradient (the margin colour
750    /// varies across the canvas, not a flat fill). Single-bind-group clip +
751    /// graphics — no blur — so it runs on every CI OS (no lavapipe guard).
752    #[test]
753    fn render_framed_with_gradient_background_frames_the_screen() {
754        const SIDE: usize = 128;
755        let dim = u32::try_from(SIDE).expect("SIDE fits u32");
756        let mut preview = EditorPreview::new(dim, dim).expect("init wgpu");
757        let bg = BackgroundConfig {
758            source: BackgroundSource::Gradient {
759                from: [255, 138, 128],
760                to: [40, 53, 147],
761                angle_deg: 135.0,
762            },
763            padding: 16,
764            corner_radius: 24,
765            shadow: 0,
766            inset: 0,
767        };
768        preview.set_background(&bg);
769        let white = vec![255u8; SIDE * SIDE * 4];
770        let frame = preview
771            .render_framed(white, ZoomTransform::identity(), CropRect::full())
772            .expect("compose");
773        let pixel = |col: usize, row: usize| {
774            let base = (row * SIDE + col) * 4;
775            [
776                frame.bytes[base],
777                frame.bytes[base + 1],
778                frame.bytes[base + 2],
779            ] // B, G, R
780        };
781        let is_white = |bgr: [u8; 3]| bgr[0] > 230 && bgr[1] > 230 && bgr[2] > 230;
782        // Centre: the white screen shows through the rounded window.
783        assert!(
784            is_white(pixel(SIDE / 2, SIDE / 2)),
785            "centre is the white screen"
786        );
787        // Corners: padding margin + rounded-corner cut → backdrop, not white,
788        // and not black (the gradient drew).
789        let corners = [
790            pixel(3, 3),
791            pixel(3, SIDE - 4),
792            pixel(SIDE - 4, 3),
793            pixel(SIDE - 4, SIDE - 4),
794        ];
795        for bgr in corners {
796            assert!(
797                !is_white(bgr),
798                "corner is backdrop, not the white screen ({bgr:?})"
799            );
800            let lum = u16::from(bgr[0]) + u16::from(bgr[1]) + u16::from(bgr[2]);
801            assert!(lum > 30, "backdrop drew (not black) at a corner ({bgr:?})");
802        }
803        // It's a gradient, not a flat fill: the corner colours span a range.
804        let reds: Vec<i32> = corners.iter().map(|bgr| i32::from(bgr[2])).collect();
805        let spread = reds.iter().max().unwrap() - reds.iter().min().unwrap();
806        assert!(
807            spread > 15,
808            "backdrop varies across the canvas (gradient), spread={spread}"
809        );
810    }
811
812    /// GPU: a flat-colour backdrop. The margin equals the requested colour's
813    /// channel ordering (R-dominant here) and is neither white nor black.
814    #[test]
815    fn render_framed_with_color_background_fills_the_margin() {
816        const SIDE: usize = 128;
817        let dim = u32::try_from(SIDE).expect("SIDE fits u32");
818        let mut preview = EditorPreview::new(dim, dim).expect("init wgpu");
819        preview.set_background(&BackgroundConfig {
820            source: BackgroundSource::Color { rgb: [210, 40, 70] },
821            padding: 16,
822            corner_radius: 8,
823            shadow: 0,
824            inset: 0,
825        });
826        let white = vec![255u8; SIDE * SIDE * 4];
827        let frame = preview
828            .render_framed(white, ZoomTransform::identity(), CropRect::full())
829            .expect("compose");
830        // A mid-edge margin pixel (outside the padded window, away from the
831        // rounded corners): the flat backdrop colour.
832        let base = ((SIDE / 2) * SIDE + 3) * 4; // row centre, col 3 → left margin
833        let (blue, green, red) = (
834            frame.bytes[base],
835            frame.bytes[base + 1],
836            frame.bytes[base + 2],
837        );
838        assert!(
839            red > green && red > blue,
840            "R-dominant fill (got B{blue} G{green} R{red})"
841        );
842        assert!(red > 60, "backdrop colour drew (not black)");
843        assert!(
844            !(red > 230 && green > 230 && blue > 230),
845            "not the white screen"
846        );
847    }
848
849    /// GPU: the drop shadow + inset border (ED.18) visibly change the composed
850    /// frame versus the same backdrop with `shadow = 0, inset = 0`. Pure
851    /// Graphics (no blur) → runs on every CI OS, no lavapipe guard.
852    #[test]
853    fn render_with_shadow_and_border_differs_from_without() {
854        const SIDE: usize = 128;
855        let dim = u32::try_from(SIDE).expect("SIDE fits u32");
856        let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
857        let cfg = |shadow: u32, inset: u32| BackgroundConfig {
858            source: BackgroundSource::Color {
859                rgb: [235, 235, 240],
860            },
861            padding: 18,
862            corner_radius: 10,
863            shadow,
864            inset,
865        };
866        let gray = || vec![128u8; SIDE * SIDE * 4];
867
868        pv.set_background(&cfg(0, 0));
869        let plain = pv
870            .render_framed(gray(), ZoomTransform::identity(), CropRect::full())
871            .expect("compose");
872        pv.set_background(&cfg(95, 8));
873        let decorated = pv
874            .render_framed(gray(), ZoomTransform::identity(), CropRect::full())
875            .expect("compose");
876
877        let diff = plain
878            .bytes
879            .iter()
880            .zip(decorated.bytes.iter())
881            .filter(|(a, b)| a.abs_diff(**b) > 16)
882            .count();
883        assert!(
884            diff > 200,
885            "shadow + inset border visibly change the frame ({diff} bytes differ)"
886        );
887    }
888
889    #[test]
890    fn wallpaper_rgba_is_well_formed_and_name_keyed() {
891        let aurora = wallpaper_rgba("aurora", 32, 18);
892        assert_eq!(aurora.len(), 32 * 18 * 4, "packed RGBA8 of the right size");
893        assert!(aurora.chunks_exact(4).all(|p| p[3] == 255), "opaque");
894        // Unknown name falls to the default (aurora).
895        assert_eq!(wallpaper_rgba("nonsense", 32, 18), aurora);
896        // Different palettes produce different pixels.
897        let ocean = wallpaper_rgba("ocean", 32, 18);
898        assert_ne!(ocean, aurora, "named palettes differ");
899        // Ocean is blue-dominant (its anchors are all blue/teal).
900        assert!(
901            ocean
902                .chunks_exact(4)
903                .all(|p| u16::from(p[2]) >= u16::from(p[0])),
904            "ocean wallpaper: blue ≥ red everywhere"
905        );
906    }
907
908    /// GPU: a `Wallpaper` backdrop fills the margin with the (procedural)
909    /// wallpaper — here the blue-dominant "ocean" palette — behind the white
910    /// screen. Sprite backdrop, single-bind-group → no lavapipe guard.
911    #[test]
912    fn render_with_wallpaper_backdrop_fills_the_margin() {
913        const SIDE: usize = 128;
914        let dim = u32::try_from(SIDE).expect("SIDE fits u32");
915        let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
916        pv.set_background(&BackgroundConfig {
917            source: BackgroundSource::Wallpaper {
918                name: "ocean".to_string(),
919            },
920            padding: 18,
921            corner_radius: 8,
922            shadow: 0,
923            inset: 0,
924        });
925        let white = vec![255u8; SIDE * SIDE * 4];
926        let f = pv
927            .render_framed(white, ZoomTransform::identity(), CropRect::full())
928            .expect("compose");
929        // Centre: the white screen.
930        let ci = ((SIDE / 2) * SIDE + SIDE / 2) * 4;
931        assert!(
932            f.bytes[ci] > 230 && f.bytes[ci + 1] > 230 && f.bytes[ci + 2] > 230,
933            "centre is the white screen"
934        );
935        // A margin pixel (outside the padded window): the ocean wallpaper —
936        // blue-dominant (B > R), not white.
937        let mi = (4 * SIDE + 4) * 4;
938        let (b, g, r) = (f.bytes[mi], f.bytes[mi + 1], f.bytes[mi + 2]);
939        assert!(
940            b > r,
941            "ocean wallpaper is blue-dominant in the margin (B{b} R{r})"
942        );
943        assert!(
944            !(b > 230 && g > 230 && r > 230),
945            "margin is the wallpaper, not the white screen"
946        );
947    }
948
949    /// GPU: the cursor overlay (ED.19) draws a pointer and — the load-bearing
950    /// decision — *rides the screen transform*: the same source point lands
951    /// further from centre under a zoom (glued to its pixel), not pinned in
952    /// output space. Detected via the pointer's white inner triangle on a
953    /// mid-grey screen. Single-bind-group graphics — no blur, no lavapipe
954    /// guard.
955    #[test]
956    #[allow(
957        clippy::cast_precision_loss,
958        reason = "SIDE = 128, so every pixel count / coordinate is a small integer exact in f64"
959    )]
960    fn render_framed_with_cursor_rides_the_zoom() {
961        const SIDE: usize = 128;
962        let dim = u32::try_from(SIDE).expect("SIDE fits u32");
963        let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
964        let cfg = CursorConfig::default();
965        let gray = || vec![128u8; SIDE * SIDE * 4];
966
967        // The pointer's white inner triangle — count + centroid of white px.
968        let white = |f: &ComposedFrame| -> (usize, f64, f64) {
969            let (mut n, mut sx, mut sy) = (0usize, 0.0, 0.0);
970            for row in 0..SIDE {
971                for col in 0..SIDE {
972                    let i = (row * SIDE + col) * 4;
973                    if f.bytes[i] > 220 && f.bytes[i + 1] > 220 && f.bytes[i + 2] > 220 {
974                        n += 1;
975                        sx += col as f64;
976                        sy += row as f64;
977                    }
978                }
979            }
980            if n == 0 {
981                (0, 0.0, 0.0)
982            } else {
983                (n, sx / n as f64, sy / n as f64)
984            }
985        };
986
987        // No cursor → a flat grey frame has no white pointer pixels.
988        let none = pv
989            .render_framed_with_cursor(
990                gray(),
991                ZoomTransform::identity(),
992                CropRect::full(),
993                None,
994                &[],
995                &cfg,
996            )
997            .expect("compose");
998        assert_eq!(white(&none).0, 0, "no pointer drawn when position is None");
999
1000        // Cursor at the top-left quadrant (0.25, 0.25) at 1×.
1001        let one = pv
1002            .render_framed_with_cursor(
1003                gray(),
1004                ZoomTransform::identity(),
1005                CropRect::full(),
1006                Some((0.25, 0.25)),
1007                &[],
1008                &cfg,
1009            )
1010            .expect("compose");
1011        let (n1, cx1, cy1) = white(&one);
1012        assert!(n1 > 4, "pointer visible at 1× ({n1} white px)");
1013
1014        // Same source point under a 2× centre zoom → it rides toward the
1015        // corner (further from centre), proving the cursor is glued to its
1016        // pixel rather than pinned in output space.
1017        let two = pv
1018            .render_framed_with_cursor(
1019                gray(),
1020                ZoomTransform {
1021                    scale: 2.0,
1022                    center_x: 0.5,
1023                    center_y: 0.5,
1024                },
1025                CropRect::full(),
1026                Some((0.25, 0.25)),
1027                &[],
1028                &cfg,
1029            )
1030            .expect("compose");
1031        let (n2, cx2, cy2) = white(&two);
1032        assert!(n2 > 4, "pointer visible at 2× ({n2} white px)");
1033
1034        let center = (SIDE as f64) / 2.0;
1035        let d1 = (cx1 - center).hypot(cy1 - center);
1036        let d2 = (cx2 - center).hypot(cy2 - center);
1037        assert!(
1038            d2 > d1,
1039            "cursor rides the zoom toward the corner (1×→{d1:.1}, 2×→{d2:.1} from centre)"
1040        );
1041    }
1042
1043    #[test]
1044    fn framed_transform_clamps_sub_one_zoom_to_no_zoom() {
1045        // A sub-1.0 amount can't shrink (clamped in zoom_at; defended here).
1046        let z = ZoomTransform {
1047            scale: 0.5,
1048            center_x: 0.5,
1049            center_y: 0.5,
1050        };
1051        let t = framed_transform(z, CropRect::full());
1052        assert!((t.scale.x - 2.0).abs() < 1e-5, "clamped to base fill");
1053    }
1054
1055    /// Golden render: a centred white marker on a dark field, composed with
1056    /// and without a 2× centre zoom. The zoom must *magnify* (not shrink or
1057    /// vanish), so the marker covers ~4× the pixels (2× linear → 4× area).
1058    /// A coarse pixel count is robust to driver AA / sub-pixel variation.
1059    #[test]
1060    fn render_framed_zoom_magnifies_the_focal_region() {
1061        const S: usize = 128;
1062        let pattern = {
1063            let mut b = vec![0u8; S * S * 4];
1064            for y in 0..S {
1065                for x in 0..S {
1066                    let i = (y * S + x) * 4;
1067                    if x.abs_diff(S / 2) < 8 && y.abs_diff(S / 2) < 8 {
1068                        b[i] = 255;
1069                        b[i + 1] = 255;
1070                        b[i + 2] = 255;
1071                    }
1072                    b[i + 3] = 255;
1073                }
1074            }
1075            b
1076        };
1077        let dim = u32::try_from(S).expect("S fits u32");
1078        let mut pv = EditorPreview::new(dim, dim).expect("init wgpu");
1079        let white = |f: &ComposedFrame| {
1080            f.bytes
1081                .chunks_exact(4)
1082                .filter(|p| p[0] > 240 && p[1] > 240 && p[2] > 240)
1083                .count()
1084        };
1085        let none = pv
1086            .render_framed(pattern.clone(), ZoomTransform::identity(), CropRect::full())
1087            .expect("compose");
1088        let zoomed = pv
1089            .render_framed(
1090                pattern,
1091                ZoomTransform {
1092                    scale: 2.0,
1093                    center_x: 0.5,
1094                    center_y: 0.5,
1095                },
1096                CropRect::full(),
1097            )
1098            .expect("compose");
1099        let (a, z) = (white(&none), white(&zoomed));
1100        assert!(a > 0 && z > 0, "marker visible in both ({a}, {z})");
1101        // 2× linear ⇒ ~4× area; allow [3×, 5×] for AA + clamping.
1102        assert!(
1103            z >= 3 * a && z <= 5 * a,
1104            "2× zoom should ~4× the marker area (got {a} → {z})"
1105        );
1106    }
1107}