Skip to main content

rustmotion_core/engine/
transition.rs

1use crate::engine::animator::ease;
2use crate::schema::{
3    EasingType, PanBackground, PixelDissolveOrder, Transition, TransitionCorner,
4    TransitionDirection, TransitionType,
5};
6use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, PathBuilder, Rect};
7
8/// The per-type knobs a transition may read, bundled.
9///
10/// Each of these is inert for every transition but the one or two that read
11/// it, so they travel together rather than as a growing tail of positional
12/// arguments threaded through the render task queue — a shape that made
13/// adding a transition a five-file edit.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct TransitionOptions {
16    /// `corner_reveal`: which corner the reveal grows from.
17    pub corner: TransitionCorner,
18    /// `pixel_dissolve`: cell edge in px.
19    pub cell: f32,
20    /// `pixel_dissolve`: scatter seed.
21    pub seed: u32,
22    /// `pixel_dissolve`: which cells turn first.
23    pub order: PixelDissolveOrder,
24    /// `chromatic_wipe`: which way it travels.
25    pub direction: TransitionDirection,
26    /// `chromatic_wipe`: channel-split multiplier.
27    pub aberration: f32,
28}
29
30impl Default for TransitionOptions {
31    fn default() -> Self {
32        Self {
33            corner: TransitionCorner::default(),
34            cell: 48.0,
35            seed: 11,
36            order: PixelDissolveOrder::default(),
37            direction: TransitionDirection::default(),
38            aberration: 1.0,
39        }
40    }
41}
42
43impl From<&Transition> for TransitionOptions {
44    fn from(t: &Transition) -> Self {
45        Self {
46            corner: t.corner,
47            cell: t.cell,
48            seed: t.seed,
49            order: t.order,
50            direction: t.direction,
51            aberration: t.aberration,
52        }
53    }
54}
55
56/// Composite two RGBA frames during a transition.
57/// `progress` goes from 0.0 (fully frame_a) to 1.0 (fully frame_b).
58pub fn apply_transition(
59    frame_a: &[u8],
60    frame_b: &[u8],
61    width: u32,
62    height: u32,
63    progress: f64,
64    transition_type: &TransitionType,
65    opts: &TransitionOptions,
66) -> Vec<u8> {
67    let progress = progress.clamp(0.0, 1.0) as f32;
68    let TransitionOptions {
69        corner,
70        cell,
71        seed,
72        order,
73        direction,
74        aberration,
75    } = *opts;
76
77    match transition_type {
78        TransitionType::Fade => blend_fade(frame_a, frame_b, progress),
79        TransitionType::WipeLeft => {
80            wipe(frame_a, frame_b, width, height, progress, Direction::Left)
81        }
82        TransitionType::WipeRight => {
83            wipe(frame_a, frame_b, width, height, progress, Direction::Right)
84        }
85        TransitionType::WipeUp => wipe(frame_a, frame_b, width, height, progress, Direction::Up),
86        TransitionType::WipeDown => {
87            wipe(frame_a, frame_b, width, height, progress, Direction::Down)
88        }
89        TransitionType::ZoomIn => zoom_transition(frame_a, frame_b, width, height, progress, true),
90        TransitionType::ZoomOut => {
91            zoom_transition(frame_a, frame_b, width, height, progress, false)
92        }
93        TransitionType::Flip => flip_transition(frame_a, frame_b, width, height, progress),
94        TransitionType::ClockWipe => clock_wipe(frame_a, frame_b, width, height, progress),
95        TransitionType::Iris => iris_transition(frame_a, frame_b, width, height, progress),
96        TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress),
97        TransitionType::Dissolve => dissolve_transition(frame_a, frame_b, width, height, progress),
98        TransitionType::CornerReveal => {
99            corner_reveal(frame_a, frame_b, width, height, progress, corner)
100        }
101        TransitionType::PixelDissolve => {
102            pixel_dissolve(frame_a, frame_b, width, height, progress, cell, seed, order)
103        }
104        TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress),
105        TransitionType::ChromaticWipe => chromatic_wipe(
106            frame_a, frame_b, width, height, progress, direction, aberration,
107        ),
108        TransitionType::None => {
109            if progress < 0.5 {
110                frame_a.to_vec()
111            } else {
112                frame_b.to_vec()
113            }
114        }
115    }
116}
117
118/// Reveal the incoming frame through a rectangle anchored at one corner.
119///
120/// Measured on a reference piece, over 15 frames (0.5 s): the right and top
121/// edges stay pinned to the frame while the left edge travels 2160 -> 0 and the
122/// bottom edge 1480 -> 2152. So it is not a wipe — `wipe_*` moves one
123/// full-width band — and not an `iris`, which is a circle. Both edges move at
124/// once, and the incoming scene sits still behind the growing window rather
125/// than sliding in: what arrives is *uncovered*, not pushed.
126fn corner_reveal(
127    frame_a: &[u8],
128    frame_b: &[u8],
129    width: u32,
130    height: u32,
131    progress: f32,
132    corner: TransitionCorner,
133) -> Vec<u8> {
134    let mut surface = match create_skia_surface(width, height) {
135        Some(s) => s,
136        None => return blend_fade(frame_a, frame_b, progress),
137    };
138    let img_a = match frame_to_image(frame_a, width, height) {
139        Some(i) => i,
140        None => return blend_fade(frame_a, frame_b, progress),
141    };
142    let img_b = match frame_to_image(frame_b, width, height) {
143        Some(i) => i,
144        None => return blend_fade(frame_a, frame_b, progress),
145    };
146
147    let (w, h) = (width as f32, height as f32);
148    let rect = corner_rect(corner, w, h, progress);
149
150    let canvas = surface.canvas();
151    canvas.draw_image(&img_a, (0.0, 0.0), None);
152    canvas.save();
153    canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false);
154    canvas.draw_image(&img_b, (0.0, 0.0), None);
155    canvas.restore();
156
157    surface_to_pixels(surface, width, height)
158}
159
160/// The revealed rectangle at `progress`, anchored so that two edges stay on the
161/// frame and two travel.
162fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_safe::Rect {
163    let p = progress.clamp(0.0, 1.0);
164    let (rw, rh) = (w * p, h * p);
165    match corner {
166        TransitionCorner::TopRight => skia_safe::Rect::from_xywh(w - rw, 0.0, rw, rh),
167        TransitionCorner::TopLeft => skia_safe::Rect::from_xywh(0.0, 0.0, rw, rh),
168        TransitionCorner::BottomRight => skia_safe::Rect::from_xywh(w - rw, h - rh, rw, rh),
169        TransitionCorner::BottomLeft => skia_safe::Rect::from_xywh(0.0, h - rh, rw, rh),
170    }
171}
172
173/// How much a cell's own position pulls its threshold, against the hash. Enough
174/// to read as a front travelling inward, little enough that the front stays
175/// ragged instead of collapsing to a clean rectangle closing in.
176const SPATIAL_WEIGHT: f32 = 0.72;
177
178/// Deterministic 0..1 threshold for a cell — the moment it starts to turn.
179///
180/// A hash of the cell's coordinates, not a random draw: the transition must
181/// dissolve the same way on every render, and re-rolling per frame would make
182/// the mosaic boil instead of resolve.
183fn cell_hash01(col: i32, row: i32, seed: u32) -> f32 {
184    let mut h = seed
185        .wrapping_mul(0x9E37_79B9)
186        .wrapping_add((col as u32).wrapping_mul(0x85EB_CA6B))
187        .wrapping_add((row as u32).wrapping_mul(0xC2B2_AE35));
188    h ^= h >> 16;
189    h = h.wrapping_mul(0x7FEB_352D);
190    h ^= h >> 15;
191    (h & 0x00FF_FFFF) as f32 / 0x0100_0000 as f32
192}
193
194/// The threshold once the spatial order is folded in.
195///
196/// `EdgesIn` gives border cells an early threshold and the centre a late one,
197/// so the subject in the middle is the last thing to go. The hash still
198/// contributes: without it the front is a rectangle closing in, which reads as
199/// a wipe rather than a dissolve.
200fn cell_threshold(
201    col: i32,
202    row: i32,
203    cols: i32,
204    rows: i32,
205    seed: u32,
206    order: PixelDissolveOrder,
207) -> f32 {
208    let noise = cell_hash01(col, row, seed);
209    if order == PixelDissolveOrder::Random {
210        return noise;
211    }
212    // Chebyshev distance from the centre, 0 at the middle and 1 at the border:
213    // it follows the frame's own rectangle, where a Euclidean radius would
214    // leave the corners lagging behind the edges.
215    let (cx, cy) = ((cols - 1) as f32 / 2.0, (rows - 1) as f32 / 2.0);
216    let dx = if cx > 0.0 {
217        (col as f32 - cx).abs() / cx
218    } else {
219        0.0
220    };
221    let dy = if cy > 0.0 {
222        (row as f32 - cy).abs() / cy
223    } else {
224        0.0
225    };
226    let edge = dx.max(dy).clamp(0.0, 1.0);
227    let spatial = match order {
228        PixelDissolveOrder::EdgesIn => 1.0 - edge,
229        PixelDissolveOrder::CenterOut => edge,
230        PixelDissolveOrder::Random => unreachable!("handled above"),
231    };
232    (spatial * SPATIAL_WEIGHT + noise * (1.0 - SPATIAL_WEIGHT)).clamp(0.0, 1.0)
233}
234
235/// Cross-fade the two frames cell by cell on a square lattice.
236///
237/// Each cell has its own start time, so at any instant the frame is a mosaic of
238/// both scenes with a band of half-faded cells between them — which is what
239/// separates this from `dissolve` (one global opacity, no structure) and from
240/// the wipes (a single hard boundary). `feather` is what makes a cell *fade*
241/// rather than flip: with it at 0 the effect degrades to a hard checkerboard.
242fn pixel_dissolve(
243    frame_a: &[u8],
244    frame_b: &[u8],
245    width: u32,
246    height: u32,
247    progress: f32,
248    cell: f32,
249    seed: u32,
250    order: PixelDissolveOrder,
251) -> Vec<u8> {
252    let mut surface = match create_skia_surface(width, height) {
253        Some(s) => s,
254        None => return blend_fade(frame_a, frame_b, progress),
255    };
256    let img_a = match frame_to_image(frame_a, width, height) {
257        Some(i) => i,
258        None => return blend_fade(frame_a, frame_b, progress),
259    };
260    let img_b = match frame_to_image(frame_b, width, height) {
261        Some(i) => i,
262        None => return blend_fade(frame_a, frame_b, progress),
263    };
264
265    let cell = cell.max(1.0);
266    let cols = (width as f32 / cell).ceil() as i32;
267    let rows = (height as f32 / cell).ceil() as i32;
268
269    let canvas = surface.canvas();
270    canvas.draw_image(&img_a, (0.0, 0.0), None);
271
272    // The whole run has to finish by progress 1, so the schedule is compressed
273    // to leave room for the last cell's own fade.
274    const FEATHER: f32 = 0.35;
275    let p = progress.clamp(0.0, 1.0) * (1.0 + FEATHER);
276
277    for row in 0..rows {
278        for col in 0..cols {
279            let t = cell_threshold(col, row, cols, rows, seed, order);
280            let alpha = ((p - t) / FEATHER).clamp(0.0, 1.0);
281            if alpha <= 0.001 {
282                continue;
283            }
284            let rect = Rect::from_xywh(col as f32 * cell, row as f32 * cell, cell, cell);
285            canvas.save();
286            canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false);
287            let mut paint = Paint::default();
288            paint.set_alpha_f(alpha);
289            canvas.draw_image(&img_b, (0.0, 0.0), Some(&paint));
290            canvas.restore();
291        }
292    }
293
294    surface_to_pixels(surface, width, height)
295}
296
297fn blend_fade(frame_a: &[u8], frame_b: &[u8], progress: f32) -> Vec<u8> {
298    let inv = 1.0 - progress;
299    frame_a
300        .iter()
301        .zip(frame_b.iter())
302        .map(|(&a, &b)| {
303            let va = a as f32 * inv;
304            let vb = b as f32 * progress;
305            (va + vb + 0.5) as u8
306        })
307        .collect()
308}
309
310enum Direction {
311    Left,
312    Right,
313    Up,
314    Down,
315}
316
317fn wipe(
318    frame_a: &[u8],
319    frame_b: &[u8],
320    width: u32,
321    height: u32,
322    progress: f32,
323    direction: Direction,
324) -> Vec<u8> {
325    let mut surface = match create_skia_surface(width, height) {
326        Some(s) => s,
327        None => return blend_fade(frame_a, frame_b, progress),
328    };
329    let img_a = match frame_to_image(frame_a, width, height) {
330        Some(i) => i,
331        None => return blend_fade(frame_a, frame_b, progress),
332    };
333    let img_b = match frame_to_image(frame_b, width, height) {
334        Some(i) => i,
335        None => return blend_fade(frame_a, frame_b, progress),
336    };
337
338    let canvas = surface.canvas();
339    let w = width as f32;
340    let h = height as f32;
341
342    // Draw frame A as background
343    canvas.draw_image(&img_a, (0.0, 0.0), None);
344
345    // Clip frame B to the wipe region
346    let clip_rect = match direction {
347        Direction::Left => Rect::from_xywh(0.0, 0.0, w * progress, h),
348        Direction::Right => Rect::from_xywh(w * (1.0 - progress), 0.0, w * progress, h),
349        Direction::Up => Rect::from_xywh(0.0, 0.0, w, h * progress),
350        Direction::Down => Rect::from_xywh(0.0, h * (1.0 - progress), w, h * progress),
351    };
352
353    canvas.save();
354    canvas.clip_rect(clip_rect, skia_safe::ClipOp::Intersect, true);
355    canvas.draw_image(&img_b, (0.0, 0.0), None);
356    canvas.restore();
357
358    surface_to_pixels(surface, width, height)
359}
360
361fn create_skia_surface(width: u32, height: u32) -> Option<skia_safe::Surface> {
362    let info = ImageInfo::new(
363        (width as i32, height as i32),
364        ColorType::RGBA8888,
365        skia_safe::AlphaType::Premul,
366        None,
367    );
368    surfaces::raster(&info, None, None)
369}
370
371fn frame_to_image(frame: &[u8], width: u32, height: u32) -> Option<skia_safe::Image> {
372    let info = ImageInfo::new(
373        (width as i32, height as i32),
374        ColorType::RGBA8888,
375        skia_safe::AlphaType::Premul,
376        None,
377    );
378    let data = skia_safe::Data::new_copy(frame);
379    skia_safe::images::raster_from_data(&info, data, width as usize * 4)
380}
381
382fn surface_to_pixels(mut surface: skia_safe::Surface, width: u32, height: u32) -> Vec<u8> {
383    let row_bytes = width as usize * 4;
384    let mut pixels = vec![0u8; row_bytes * height as usize];
385    let info = ImageInfo::new(
386        (width as i32, height as i32),
387        ColorType::RGBA8888,
388        skia_safe::AlphaType::Premul,
389        None,
390    );
391    surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
392    pixels
393}
394
395fn zoom_transition(
396    frame_a: &[u8],
397    frame_b: &[u8],
398    width: u32,
399    height: u32,
400    progress: f32,
401    zoom_in: bool,
402) -> Vec<u8> {
403    let mut surface = match create_skia_surface(width, height) {
404        Some(s) => s,
405        None => return blend_fade(frame_a, frame_b, progress),
406    };
407    let img_a = match frame_to_image(frame_a, width, height) {
408        Some(i) => i,
409        None => return blend_fade(frame_a, frame_b, progress),
410    };
411    let img_b = match frame_to_image(frame_b, width, height) {
412        Some(i) => i,
413        None => return blend_fade(frame_a, frame_b, progress),
414    };
415
416    let canvas = surface.canvas();
417    let w = width as f32;
418    let h = height as f32;
419
420    if zoom_in {
421        // Frame A zooms in and fades out, revealing frame B
422        let scale = 1.0 + progress * 0.3;
423        canvas.draw_image(&img_b, (0.0, 0.0), None);
424        canvas.save();
425        canvas.translate((w / 2.0, h / 2.0));
426        canvas.scale((scale, scale));
427        canvas.translate((-w / 2.0, -h / 2.0));
428        let mut paint = Paint::default();
429        paint.set_alpha_f(1.0 - progress);
430        canvas.draw_image(&img_a, (0.0, 0.0), Some(&paint));
431        canvas.restore();
432    } else {
433        // Frame B zooms out from larger to normal
434        canvas.draw_image(&img_a, (0.0, 0.0), None);
435        let scale = 1.3 - progress * 0.3;
436        canvas.save();
437        canvas.translate((w / 2.0, h / 2.0));
438        canvas.scale((scale, scale));
439        canvas.translate((-w / 2.0, -h / 2.0));
440        let mut paint = Paint::default();
441        paint.set_alpha_f(progress);
442        canvas.draw_image(&img_b, (0.0, 0.0), Some(&paint));
443        canvas.restore();
444    }
445
446    surface_to_pixels(surface, width, height)
447}
448
449fn flip_transition(
450    frame_a: &[u8],
451    frame_b: &[u8],
452    width: u32,
453    height: u32,
454    progress: f32,
455) -> Vec<u8> {
456    let mut surface = match create_skia_surface(width, height) {
457        Some(s) => s,
458        None => return blend_fade(frame_a, frame_b, progress),
459    };
460    let img_a = match frame_to_image(frame_a, width, height) {
461        Some(i) => i,
462        None => return blend_fade(frame_a, frame_b, progress),
463    };
464    let img_b = match frame_to_image(frame_b, width, height) {
465        Some(i) => i,
466        None => return blend_fade(frame_a, frame_b, progress),
467    };
468
469    let canvas = surface.canvas();
470    let w = width as f32;
471
472    // Simulate 3D flip by scaling X axis
473    // First half: frame_a shrinks on X. Second half: frame_b grows on X.
474    if progress < 0.5 {
475        let scale_x = 1.0 - progress * 2.0; // 1.0 -> 0.0
476        canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
477        canvas.save();
478        canvas.translate((w / 2.0, 0.0));
479        canvas.scale((scale_x.max(0.01), 1.0));
480        canvas.translate((-w / 2.0, 0.0));
481        canvas.draw_image(&img_a, (0.0, 0.0), None);
482        canvas.restore();
483    } else {
484        let scale_x = (progress - 0.5) * 2.0; // 0.0 -> 1.0
485        canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
486        canvas.save();
487        canvas.translate((w / 2.0, 0.0));
488        canvas.scale((scale_x.max(0.01), 1.0));
489        canvas.translate((-w / 2.0, 0.0));
490        canvas.draw_image(&img_b, (0.0, 0.0), None);
491        canvas.restore();
492    }
493
494    surface_to_pixels(surface, width, height)
495}
496
497fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: f32) -> Vec<u8> {
498    let mut surface = match create_skia_surface(width, height) {
499        Some(s) => s,
500        None => return blend_fade(frame_a, frame_b, progress),
501    };
502    let img_a = match frame_to_image(frame_a, width, height) {
503        Some(i) => i,
504        None => return blend_fade(frame_a, frame_b, progress),
505    };
506    let img_b = match frame_to_image(frame_b, width, height) {
507        Some(i) => i,
508        None => return blend_fade(frame_a, frame_b, progress),
509    };
510
511    let canvas = surface.canvas();
512    let w = width as f32;
513    let h = height as f32;
514    let cx = w / 2.0;
515    let cy = h / 2.0;
516    let radius = (w * w + h * h).sqrt();
517
518    // Draw frame A as background
519    canvas.draw_image(&img_a, (0.0, 0.0), None);
520
521    // Draw frame B clipped to a clock-wipe arc
522    let sweep_angle = progress * 360.0;
523    let start_angle = -90.0; // Start from top
524
525    let mut path = PathBuilder::new();
526    path.move_to((cx, cy));
527    path.arc_to(
528        Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0),
529        start_angle,
530        sweep_angle,
531        false,
532    );
533    path.close();
534
535    canvas.save();
536    canvas.clip_path(&path.detach(), skia_safe::ClipOp::Intersect, true);
537    canvas.draw_image(&img_b, (0.0, 0.0), None);
538    canvas.restore();
539
540    surface_to_pixels(surface, width, height)
541}
542
543fn iris_transition(
544    frame_a: &[u8],
545    frame_b: &[u8],
546    width: u32,
547    height: u32,
548    progress: f32,
549) -> Vec<u8> {
550    let mut surface = match create_skia_surface(width, height) {
551        Some(s) => s,
552        None => return blend_fade(frame_a, frame_b, progress),
553    };
554    let img_a = match frame_to_image(frame_a, width, height) {
555        Some(i) => i,
556        None => return blend_fade(frame_a, frame_b, progress),
557    };
558    let img_b = match frame_to_image(frame_b, width, height) {
559        Some(i) => i,
560        None => return blend_fade(frame_a, frame_b, progress),
561    };
562
563    let canvas = surface.canvas();
564    let w = width as f32;
565    let h = height as f32;
566    let cx = w / 2.0;
567    let cy = h / 2.0;
568    let max_radius = (w * w + h * h).sqrt() / 2.0;
569    let radius = max_radius * progress;
570
571    // Draw frame A as background
572    canvas.draw_image(&img_a, (0.0, 0.0), None);
573
574    // Clip frame B to an expanding circle
575    let mut path = PathBuilder::new();
576    path.add_circle((cx, cy), radius, None);
577
578    canvas.save();
579    canvas.clip_path(&path.detach(), skia_safe::ClipOp::Intersect, true);
580    canvas.draw_image(&img_b, (0.0, 0.0), None);
581    canvas.restore();
582
583    surface_to_pixels(surface, width, height)
584}
585
586fn slide_transition(
587    frame_a: &[u8],
588    frame_b: &[u8],
589    width: u32,
590    height: u32,
591    progress: f32,
592) -> Vec<u8> {
593    let mut surface = match create_skia_surface(width, height) {
594        Some(s) => s,
595        None => return blend_fade(frame_a, frame_b, progress),
596    };
597    let img_a = match frame_to_image(frame_a, width, height) {
598        Some(i) => i,
599        None => return blend_fade(frame_a, frame_b, progress),
600    };
601    let img_b = match frame_to_image(frame_b, width, height) {
602        Some(i) => i,
603        None => return blend_fade(frame_a, frame_b, progress),
604    };
605
606    let canvas = surface.canvas();
607    let w = width as f32;
608
609    // Frame A slides left, frame B slides in from right
610    let offset = -progress * w;
611    canvas.draw_image(&img_a, (offset, 0.0), None);
612    canvas.draw_image(&img_b, (offset + w, 0.0), None);
613
614    surface_to_pixels(surface, width, height)
615}
616
617/// A fast slide whose reveal edge splits into red and cyan at the peak.
618///
619/// Both frames travel the same way — the incoming one is simply one screen
620/// behind — so the edge between them is a hard seam rather than a dissolve.
621/// The channel split is applied to that composite, peaking mid-transition and
622/// gone by the time it lands, so the flash reads as an artefact of the *speed*
623/// of the cut rather than as a colour treatment on either scene.
624fn chromatic_wipe(
625    frame_a: &[u8],
626    frame_b: &[u8],
627    width: u32,
628    height: u32,
629    progress: f32,
630    direction: TransitionDirection,
631    aberration: f32,
632) -> Vec<u8> {
633    let (w, h) = (width as f32, height as f32);
634    // Slide axis, as a unit vector. Both frames move along it; B starts one
635    // full screen back.
636    let (ux, uy) = match direction {
637        TransitionDirection::Left => (-1.0, 0.0),
638        TransitionDirection::Right => (1.0, 0.0),
639        TransitionDirection::Up => (0.0, -1.0),
640        TransitionDirection::Down => (0.0, 1.0),
641    };
642
643    let slid = {
644        let mut surface = match create_skia_surface(width, height) {
645            Some(s) => s,
646            None => return blend_fade(frame_a, frame_b, progress),
647        };
648        let (Some(img_a), Some(img_b)) = (
649            frame_to_image(frame_a, width, height),
650            frame_to_image(frame_b, width, height),
651        ) else {
652            return blend_fade(frame_a, frame_b, progress);
653        };
654        let canvas = surface.canvas();
655        let (dx, dy) = (ux * progress * w, uy * progress * h);
656        canvas.draw_image(&img_a, (dx, dy), None);
657        canvas.draw_image(&img_b, (dx - ux * w, dy - uy * h), None);
658        surface_to_pixels(surface, width, height)
659    };
660
661    // Peak at the midpoint, nothing at either end: a split still present on
662    // the last frame would bleed into the scene that follows.
663    let peak = 1.0 - (progress * 2.0 - 1.0).abs();
664    let shift = (aberration.max(0.0) * peak * w * 0.012).round() as i32;
665    if shift == 0 {
666        return slid;
667    }
668
669    // Red leads the travel, blue trails it — the two channels sampled from
670    // either side of where green is, which is what a lens does under speed.
671    let mut out = slid.clone();
672    let (sx, sy) = (
673        (ux * shift as f32).round() as i32,
674        (uy * shift as f32).round() as i32,
675    );
676    let sample = |buf: &[u8], x: i32, y: i32, channel: usize| -> u8 {
677        let cx = x.clamp(0, width as i32 - 1);
678        let cy = y.clamp(0, height as i32 - 1);
679        buf[((cy as u32 * width + cx as u32) * 4) as usize + channel]
680    };
681    for y in 0..height as i32 {
682        for x in 0..width as i32 {
683            let base = ((y as u32 * width + x as u32) * 4) as usize;
684            out[base] = sample(&slid, x - sx, y - sy, 0);
685            out[base + 2] = sample(&slid, x + sx, y + sy, 2);
686        }
687    }
688    out
689}
690
691fn dissolve_transition(
692    frame_a: &[u8],
693    frame_b: &[u8],
694    _width: u32,
695    _height: u32,
696    progress: f32,
697) -> Vec<u8> {
698    // Dissolve is a smooth cross-dissolve (same as fade in standard video editing)
699    blend_fade(frame_a, frame_b, progress)
700}
701
702/// Camera pan transition: composited background + sliding foreground children.
703/// `bg_a`/`bg_b` are the outgoing/incoming backgrounds, `fg_a`/`fg_b` are
704/// children-only (transparent). fg_a slides out by (-dx*t, -dy*t), fg_b
705/// slides in from (dx*(1-t), dy*(1-t)).
706///
707/// `pan_background` controls how the two backgrounds combine:
708/// - `Static`: neither travels nor scales — the backdrop holds its position,
709///   which is what keeps a shared ambience continuous across a beat when both
710///   scenes actually share the same background (the crossfade below is then
711///   a no-op, since blending a frame with itself returns that frame).
712/// - `Travel`: each background moves locked to its own foreground, so the
713///   two beats read as different places rather than one space.
714///
715/// Both modes crossfade the two background layers in f32 rather than through
716/// Skia's `Paint` alpha, which quantizes to an 8-bit byte: while the byte
717/// climbs, the premultiplied blend truncates ~1 LSB per channel across the
718/// whole frame, and the instant it reaches 255 Skia takes the opaque fast
719/// path and every pixel regains that level in a single frame — a visible
720/// step at 40-80x the local per-frame rate. `blend_fade` already does this
721/// crossfade correctly (see its doc); we render each background into its own
722/// full-frame layer first (needed for `Travel`'s scale + translate), then
723/// hand both raw buffers to it.
724#[allow(clippy::too_many_arguments)]
725pub fn camera_pan_transition(
726    bg_a: &[u8],
727    bg_b: &[u8],
728    fg_a: &[u8],
729    fg_b: &[u8],
730    width: u32,
731    height: u32,
732    progress: f64,
733    dx: f32,
734    dy: f32,
735    easing: &EasingType,
736    pan_background: PanBackground,
737) -> Vec<u8> {
738    let t = ease(progress, easing) as f32;
739
740    let mut surface = match create_skia_surface(width, height) {
741        Some(s) => s,
742        None => return bg_a.to_vec(),
743    };
744    let img_fg_a = match frame_to_image(fg_a, width, height) {
745        Some(i) => i,
746        None => return bg_a.to_vec(),
747    };
748    let img_fg_b = match frame_to_image(fg_b, width, height) {
749        Some(i) => i,
750        None => return bg_a.to_vec(),
751    };
752
753    // Offsets: the outgoing plane exits, the incoming one arrives. They tile
754    // exactly, so together they always cover the frame.
755    let (out_x, out_y) = (-dx * t, -dy * t);
756    let (in_x, in_y) = (dx * (1.0 - t), dy * (1.0 - t));
757
758    let blended_bg = match pan_background {
759        // Travelling: each background moves with its own scene, but at a
760        // fraction of the foreground's distance and fading across the pan.
761        //
762        // Two reasons for the fraction. It is how parallax actually works —
763        // what is far away moves less — and it makes the two backgrounds
764        // overlap across most of the frame instead of meeting edge to edge.
765        // Opaque images laid side by side join on a hard line no crossfade can
766        // hide; overlapping ones dissolve into each other.
767        PanBackground::Travel => {
768            let img_bg_a = match frame_to_image(bg_a, width, height) {
769                Some(i) => i,
770                None => return bg_a.to_vec(),
771            };
772            let img_bg_b = match frame_to_image(bg_b, width, height) {
773                Some(i) => i,
774                None => return bg_a.to_vec(),
775            };
776
777            // Backgrounds drift at a fraction of the foreground's distance —
778            // that is how parallax works, and it keeps them overlapping
779            // instead of meeting edge to edge, where two opaque images join on
780            // a line no fade can hide.
781            const BG_PARALLAX: f32 = 0.12;
782            let (bax, bay) = (out_x * BG_PARALLAX, out_y * BG_PARALLAX);
783            let (bbx, bby) = (in_x * BG_PARALLAX, in_y * BG_PARALLAX);
784
785            // Translating an opaque image uncovers a strip on the opposite
786            // side, and that strip reads as a hard edge just as much as a
787            // join would. Each layer is therefore overscaled by exactly its
788            // own current displacement — just enough to cover, never more.
789            //
790            // Sizing it on the *maximum* drift instead makes the margin
791            // constant across the transition, including at both ends where the
792            // displacement is zero. The background then jumps between a normal
793            // frame and an enlarged one at every junction — measured at up to
794            // 128px of halo movement in a single frame, an order of magnitude
795            // beyond the drift itself. Tying the margin to the current offset
796            // makes it vanish exactly where a transition meets a normal frame,
797            // so the two are continuous.
798            let w = width as f32;
799            let h = height as f32;
800            let spread = |ox: f32, oy: f32| {
801                let (mx, my) = (ox.abs(), oy.abs());
802                Rect::from_ltrb(-mx + ox, -my + oy, w + mx + ox, h + my + oy)
803            };
804
805            let layer_a = match render_layer(&img_bg_a, spread(bax, bay), width, height) {
806                Some(p) => p,
807                None => return bg_a.to_vec(),
808            };
809            let layer_b = match render_layer(&img_bg_b, spread(bbx, bby), width, height) {
810                Some(p) => p,
811                None => return bg_a.to_vec(),
812            };
813            blend_fade(&layer_a, &layer_b, t)
814        }
815        // Static: no spatial movement, but still crossfaded in place. When
816        // both scenes share the same background this is a no-op — blending a
817        // frame with itself is that frame, so it stays visually frozen, which
818        // is what makes the junction invisible. When they don't, holding A
819        // for the whole pan and jump-cutting to B on the first normal frame
820        // afterward measured +8.25 mean luminance in a single frame (385x the
821        // local rate) — a hard cut. Crossfading spreads that change across
822        // the whole pan instead of concentrating it at the boundary.
823        PanBackground::Static => blend_fade(bg_a, bg_b, t),
824    };
825    let img_bg = match frame_to_image(&blended_bg, width, height) {
826        Some(i) => i,
827        None => return bg_a.to_vec(),
828    };
829
830    let canvas = surface.canvas();
831    canvas.draw_image(&img_bg, (0.0, 0.0), None);
832
833    // The scene being left behind dissolves rather than sliding off as a solid
834    // slab, and the arriving one materialises. Drift alone gives the two planes
835    // different speeds; letting them also come and go is what reads as depth
836    // instead of a sheet of paper being pulled sideways.
837    //
838    // Both curves are pinned at their own end — `fg_a` is fully opaque at t=0,
839    // `fg_b` fully opaque at t=1 — because a transition frame sits directly
840    // against a normal frame at each junction and any alpha short of 1 there is
841    // a visible step. Mirrored exponents (rather than a plain crossfade) keep
842    // both planes at 67% through the middle instead of 50%, so the frame never
843    // washes out to near-empty half way through.
844    const FG_DISSOLVE: f32 = 1.6;
845    let mut fg_paint = Paint::default();
846
847    fg_paint.set_alpha_f(1.0 - t.powf(FG_DISSOLVE));
848    canvas.draw_image(&img_fg_a, (out_x, out_y), Some(&fg_paint));
849
850    fg_paint.set_alpha_f(1.0 - (1.0 - t).powf(FG_DISSOLVE));
851    canvas.draw_image(&img_fg_b, (in_x, in_y), Some(&fg_paint));
852
853    surface_to_pixels(surface, width, height)
854}
855
856/// Draw `img` into `dest` on a fresh full-frame surface and read back the raw
857/// pixels. Used to pre-render a background plane (with its `Travel` scale +
858/// translate applied) before crossfading it against its counterpart in f32.
859fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> Option<Vec<u8>> {
860    let mut surface = create_skia_surface(width, height)?;
861    surface
862        .canvas()
863        .draw_image_rect(img, None, dest, &Paint::default());
864    Some(surface_to_pixels(surface, width, height))
865}
866
867#[cfg(test)]
868mod camera_pan_tests {
869    use super::*;
870
871    // The junction invariant. A transition frame sits directly against a
872    // normal frame at each end, so the dissolve must be a no-op exactly there:
873    // at progress 0 the outgoing scene is untouched, at progress 1 the
874    // incoming one is. Any alpha short of 1 at an endpoint is a visible step,
875    // which is the class of bug that produced the halo jumps.
876    #[test]
877    fn the_foreground_dissolve_is_a_noop_at_both_junctions() {
878        let (w, h) = (8u32, 4u32);
879        let bg = solid(w, h, 0, 0, 0, 255);
880        let fg_a = solid(w, h, 255, 0, 0, 255);
881        let fg_b = solid(w, h, 0, 0, 255, 255);
882
883        for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] {
884            let out = camera_pan_transition(
885                &bg,
886                &bg,
887                &fg_a,
888                &fg_b,
889                w,
890                h,
891                progress,
892                8.0,
893                0.0,
894                &EasingType::Linear,
895                PanBackground::Static,
896            );
897            assert_eq!(
898                &out[0..3],
899                &expected,
900                "at progress {progress} the adjacent scene must render untouched",
901            );
902        }
903    }
904
905    // Mid-pan both planes are partly transparent — that is the effect — but
906    // neither may collapse to near-nothing or the frame reads as empty.
907    #[test]
908    fn mid_pan_both_planes_stay_substantially_visible() {
909        let (w, h) = (8u32, 4u32);
910        let bg = solid(w, h, 0, 0, 0, 255);
911        let fg_a = solid(w, h, 255, 0, 0, 255);
912        let fg_b = solid(w, h, 0, 0, 255, 255);
913
914        let out = camera_pan_transition(
915            &bg,
916            &bg,
917            &fg_a,
918            &fg_b,
919            w,
920            h,
921            0.5,
922            8.0,
923            0.0,
924            &EasingType::Linear,
925            PanBackground::Static,
926        );
927        // Left half carries the outgoing plane, right half the incoming one.
928        let left_red = out[0];
929        let right_blue = out[((w - 1) * 4 + 2) as usize];
930        assert!(left_red > 128, "outgoing plane faded too far: {left_red}");
931        assert!(
932            right_blue > 128,
933            "incoming plane still too faint: {right_blue}"
934        );
935    }
936
937    fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
938        (0..width * height).flat_map(|_| [r, g, b, a]).collect()
939    }
940
941    // Fully transparent so the foreground planes never contribute — isolates
942    // the background compositing under test.
943    fn transparent(width: u32, height: u32) -> Vec<u8> {
944        solid(width, height, 0, 0, 0, 0)
945    }
946
947    // Issue #124 item 2: `Static` used to hold bg_a for the entire pan and
948    // hard-cut to bg_b afterward. It must now crossfade in place instead —
949    // a mid-pan frame should show a genuine blend of both, not either one
950    // alone.
951    #[test]
952    fn static_background_crossfades_instead_of_freezing() {
953        let (w, h) = (4, 4);
954        let bg_a = solid(w, h, 10, 10, 10, 255);
955        let bg_b = solid(w, h, 200, 200, 200, 255);
956        let fg = transparent(w, h);
957
958        let out = camera_pan_transition(
959            &bg_a,
960            &bg_b,
961            &fg,
962            &fg,
963            w,
964            h,
965            0.5,
966            0.0,
967            0.0,
968            &EasingType::Linear,
969            PanBackground::Static,
970        );
971
972        // blend_fade(10, 200, 0.5) = (10*0.5 + 200*0.5 + 0.5) as u8 = 105.
973        for px in out.chunks_exact(4) {
974            assert_eq!(
975                px,
976                [105, 105, 105, 255],
977                "mid-pan Static frame must be a blend of bg_a and bg_b, not a copy of either"
978            );
979        }
980        assert_ne!(out, bg_a, "must have moved away from bg_a by the midpoint");
981        assert_ne!(
982            out, bg_b,
983            "must not have already reached bg_b at the midpoint"
984        );
985    }
986
987    // Issue #124 item 1 + 3: the crossfade must be exact float math with no
988    // residual once progress reaches 1.0 — no Skia alpha-byte quantization
989    // left over from an `Option`-based alpha blend.
990    #[test]
991    fn static_background_reaches_bg_b_exactly_at_full_progress() {
992        let (w, h) = (4, 4);
993        let bg_a = solid(w, h, 10, 10, 10, 255);
994        let bg_b = solid(w, h, 200, 200, 200, 255);
995        let fg = transparent(w, h);
996
997        let out = camera_pan_transition(
998            &bg_a,
999            &bg_b,
1000            &fg,
1001            &fg,
1002            w,
1003            h,
1004            1.0,
1005            0.0,
1006            0.0,
1007            &EasingType::Linear,
1008            PanBackground::Static,
1009        );
1010        assert_eq!(out, bg_b, "progress=1.0 must land exactly on bg_b");
1011    }
1012
1013    // Travel mode's incoming layer has zero offset at t=1 (in_x = dx*(1-t) =
1014    // 0), so it is drawn 1:1 with no resampling — the crossfade should still
1015    // land exactly on bg_b there too.
1016    #[test]
1017    fn travel_background_reaches_bg_b_exactly_at_full_progress() {
1018        let (w, h) = (4, 4);
1019        let bg_a = solid(w, h, 10, 10, 10, 255);
1020        let bg_b = solid(w, h, 200, 200, 200, 255);
1021        let fg = transparent(w, h);
1022
1023        let out = camera_pan_transition(
1024            &bg_a,
1025            &bg_b,
1026            &fg,
1027            &fg,
1028            w,
1029            h,
1030            1.0,
1031            100.0,
1032            0.0,
1033            &EasingType::Linear,
1034            PanBackground::Travel,
1035        );
1036        assert_eq!(
1037            out, bg_b,
1038            "progress=1.0 must land exactly on bg_b under Travel too"
1039        );
1040    }
1041}
1042
1043#[cfg(test)]
1044mod corner_reveal_tests {
1045    use super::*;
1046
1047    /// Two edges stay on the frame, two travel. Measured on the reference
1048    /// piece: the right and top edges never move while the left runs
1049    /// 2160 -> 0 and the bottom 1480 -> 2152, over 15 frames.
1050    #[test]
1051    fn the_anchored_edges_never_move() {
1052        for p in [0.05, 0.3, 0.5, 0.8, 1.0] {
1053            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1054            assert!((r.right - 1920.0).abs() < 1e-3, "right edge moved at {p}");
1055            assert!(r.top.abs() < 1e-3, "top edge moved at {p}");
1056        }
1057    }
1058
1059    /// …and the travelling edges do move, monotonically, in the direction the
1060    /// corner names.
1061    #[test]
1062    fn the_travelling_edges_open_from_the_corner() {
1063        let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1064        let (a, b, c) = (at(0.2), at(0.5), at(0.9));
1065        assert!(
1066            a.left > b.left && b.left > c.left,
1067            "left edge must travel left"
1068        );
1069        assert!(
1070            a.bottom < b.bottom && b.bottom < c.bottom,
1071            "bottom must travel down"
1072        );
1073    }
1074
1075    /// The ends are the whole point: nothing revealed at 0, everything at 1.
1076    #[test]
1077    fn it_starts_empty_and_ends_full() {
1078        let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0);
1079        assert_eq!((empty.width(), empty.height()), (0.0, 0.0));
1080        let full = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 1.0);
1081        assert_eq!(
1082            (full.left, full.top, full.right, full.bottom),
1083            (0.0, 0.0, 1920.0, 1080.0)
1084        );
1085    }
1086
1087    /// Each corner anchors its own two edges — otherwise `corner` is decoration.
1088    #[test]
1089    fn every_corner_anchors_its_own_edges() {
1090        let (w, h, p) = (1920.0f32, 1080.0f32, 0.4);
1091        let tl = corner_rect(TransitionCorner::TopLeft, w, h, p);
1092        assert!(tl.left.abs() < 1e-3 && tl.top.abs() < 1e-3);
1093        let br = corner_rect(TransitionCorner::BottomRight, w, h, p);
1094        assert!((br.right - w).abs() < 1e-3 && (br.bottom - h).abs() < 1e-3);
1095        let bl = corner_rect(TransitionCorner::BottomLeft, w, h, p);
1096        assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3);
1097    }
1098
1099    /// Progress outside 0..1 must clamp, not invert the rectangle: a negative
1100    /// width would make the clip empty and the transition would look like a cut.
1101    #[test]
1102    fn out_of_range_progress_clamps() {
1103        for p in [-0.5, 1.5] {
1104            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1105            assert!(
1106                r.width() >= 0.0 && r.height() >= 0.0,
1107                "inverted rect at {p}"
1108            );
1109        }
1110    }
1111}
1112
1113#[cfg(test)]
1114mod pixel_dissolve_tests {
1115    use super::*;
1116
1117    /// `edges_in` must turn the border before the middle — that is the whole
1118    /// point: whatever sits in the centre is the last thing to go.
1119    #[test]
1120    fn edges_in_turns_the_border_first() {
1121        let (cols, rows) = (40, 24);
1122        let border: Vec<f32> = (0..cols)
1123            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1124            .collect();
1125        let middle: Vec<f32> = (0..cols)
1126            .map(|c| cell_threshold(c, rows / 2, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1127            .collect();
1128        let avg = |v: &Vec<f32>| v.iter().sum::<f32>() / v.len() as f32;
1129        assert!(
1130            avg(&border) < avg(&middle) - 0.15,
1131            "border {:.2} must clearly precede the middle {:.2}",
1132            avg(&border),
1133            avg(&middle)
1134        );
1135        // The very centre goes last.
1136        let centre = cell_threshold(
1137            cols / 2,
1138            rows / 2,
1139            cols,
1140            rows,
1141            11,
1142            PixelDissolveOrder::EdgesIn,
1143        );
1144        assert!(centre > 0.6, "the centre cell must be late, got {centre}");
1145    }
1146
1147    /// …and `center_out` is its mirror, or the option is decoration.
1148    #[test]
1149    fn center_out_is_the_mirror_of_edges_in() {
1150        let (cols, rows) = (40, 24);
1151        for (c, r) in [(0, 0), (20, 12), (39, 5)] {
1152            let a = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::EdgesIn);
1153            let b = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::CenterOut);
1154            // Same hash contribution, opposite spatial term.
1155            assert!(
1156                (a + b - (SPATIAL_WEIGHT + 2.0 * (1.0 - SPATIAL_WEIGHT) * cell_hash01(c, r, 11)))
1157                    .abs()
1158                    < 1e-5
1159            );
1160        }
1161    }
1162
1163    /// The front has to stay ragged. A purely spatial threshold would close a
1164    /// clean rectangle inward, which reads as a wipe, not a dissolve — so
1165    /// neighbours at the same distance from the centre must still differ.
1166    #[test]
1167    fn the_front_is_ragged_not_a_closing_rectangle() {
1168        let (cols, rows) = (40, 24);
1169        let top: Vec<f32> = (0..cols)
1170            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1171            .collect();
1172        let spread = top.iter().cloned().fold(f32::MIN, f32::max)
1173            - top.iter().cloned().fold(f32::MAX, f32::min);
1174        assert!(
1175            spread > 0.15,
1176            "the border turns as one block: spread {spread}"
1177        );
1178    }
1179
1180    /// `random` keeps its old behaviour — the spatial term must not leak in.
1181    #[test]
1182    fn random_ignores_position() {
1183        let t = cell_threshold(7, 3, 40, 24, 11, PixelDissolveOrder::Random);
1184        assert_eq!(t, cell_hash01(7, 3, 11));
1185    }
1186
1187    /// The same cell must turn at the same moment on every render: a per-frame
1188    /// draw would make the mosaic boil instead of resolve.
1189    #[test]
1190    fn a_cell_keeps_its_threshold() {
1191        assert_eq!(cell_hash01(4, 9, 11), cell_hash01(4, 9, 11));
1192        let t = cell_hash01(4, 9, 11);
1193        assert!(
1194            (0.0..1.0).contains(&t),
1195            "threshold must be a fraction, got {t}"
1196        );
1197    }
1198
1199    /// …and two seeds must dissolve in a different order, or `seed` is a lie.
1200    #[test]
1201    fn the_seed_changes_the_order() {
1202        let a: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 11)).collect();
1203        let b: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 12)).collect();
1204        assert_ne!(a, b);
1205    }
1206
1207    /// Neighbours must not turn in step — a threshold that tracks the
1208    /// coordinate sweeps a diagonal line, which is a wipe, not a dissolve.
1209    #[test]
1210    fn neighbouring_cells_turn_at_unrelated_times() {
1211        let close = (0..30)
1212            .flat_map(|c| (0..30).map(move |r| (c, r)))
1213            .filter(|&(c, r)| (cell_hash01(c, r, 11) - cell_hash01(c + 1, r, 11)).abs() < 0.05)
1214            .count();
1215        // 900 pairs; a swept threshold would put nearly all of them under 0.05.
1216        assert!(
1217            close < 200,
1218            "{close}/900 neighbours turn together — that is a wipe"
1219        );
1220    }
1221
1222    /// The spread is the whole point: at half-way the frame must hold cells in
1223    /// *both* states plus some mid-fade, not one global opacity.
1224    #[test]
1225    fn midway_the_frame_holds_both_scenes_and_a_fading_band() {
1226        const FEATHER: f32 = 0.35;
1227        let p = 0.5 * (1.0 + FEATHER);
1228        let alphas: Vec<f32> = (0..40)
1229            .flat_map(|c| (0..40).map(move |r| cell_hash01(c, r, 11)))
1230            .map(|t| ((p - t) / FEATHER).clamp(0.0, 1.0))
1231            .collect();
1232        let done = alphas.iter().filter(|&&a| a >= 0.999).count();
1233        let waiting = alphas.iter().filter(|&&a| a <= 0.001).count();
1234        let fading = alphas.iter().filter(|&&a| a > 0.001 && a < 0.999).count();
1235        assert!(done > 100 && waiting > 100, "both states must be present");
1236        assert!(
1237            fading > 50,
1238            "cells must fade, not flip: only {fading} mid-transition"
1239        );
1240    }
1241
1242    /// Every cell must be settled by the end, or the last of the outgoing scene
1243    /// survives into the next one.
1244    #[test]
1245    fn every_cell_completes_by_the_end() {
1246        const FEATHER: f32 = 0.35;
1247        let p = 1.0 * (1.0 + FEATHER);
1248        for c in 0..60 {
1249            for r in 0..60 {
1250                let a = ((p - cell_hash01(c, r, 11)) / FEATHER).clamp(0.0, 1.0);
1251                assert!(
1252                    a >= 0.999,
1253                    "cell ({c},{r}) still at {a} when the transition ends"
1254                );
1255            }
1256        }
1257    }
1258}