Skip to main content

rustmotion_core/engine/
transition.rs

1use crate::engine::animator::ease;
2use crate::engine::renderer::{color4f_from_hex, paint_from_hex};
3use crate::schema::{
4    EasingType, IrisRing, IrisShape, MaskShape, PanBackground, PixelDissolveOrder, Transition,
5    TransitionCorner, TransitionDirection, TransitionType, ZoomBlurOrigin,
6};
7use skia_safe::{
8    surfaces, BlurStyle, Color4f, ColorType, Image, ImageInfo, MaskFilter, Matrix, Paint,
9    PaintStyle, PathBuilder, Rect,
10};
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct TransitionOptions {
14    pub corner: TransitionCorner,
15    pub cell: f32,
16    pub seed: u32,
17    pub order: PixelDissolveOrder,
18    pub direction: TransitionDirection,
19    pub aberration: f32,
20    pub strength: f32,
21    pub origin: Option<ZoomBlurOrigin>,
22    pub shape: IrisShape,
23    pub aspect: f32,
24    pub fill: Option<String>,
25    pub hold: f32,
26    pub ring: Option<IrisRing>,
27    pub reverse: bool,
28    pub silhouette: Option<MaskShape>,
29    pub from_scale: f32,
30    pub to_scale: f32,
31    pub lobes: u32,
32    pub wobble: f32,
33    pub feather: f32,
34    pub band_color: Option<String>,
35    pub duration: f64,
36}
37
38impl Default for TransitionOptions {
39    fn default() -> Self {
40        Self {
41            corner: TransitionCorner::default(),
42            cell: 48.0,
43            seed: 11,
44            order: PixelDissolveOrder::default(),
45            direction: TransitionDirection::default(),
46            aberration: 1.0,
47            strength: 1.0,
48            origin: None,
49            shape: IrisShape::default(),
50            aspect: 1.0,
51            fill: None,
52            hold: 0.0,
53            ring: None,
54            reverse: false,
55            silhouette: None,
56            from_scale: 0.0,
57            to_scale: 20.0,
58            lobes: 8,
59            wobble: 0.15,
60            feather: 0.0,
61            band_color: None,
62            duration: 0.5,
63        }
64    }
65}
66
67impl From<&Transition> for TransitionOptions {
68    fn from(t: &Transition) -> Self {
69        Self {
70            corner: t.corner,
71            cell: t.cell,
72            seed: t.seed,
73            order: t.order,
74            direction: t.direction,
75            aberration: t.aberration,
76            strength: t.strength,
77            origin: t.origin,
78            shape: t.shape,
79            aspect: t.aspect,
80            fill: t.fill.clone(),
81            hold: t.hold,
82            ring: t.ring.clone(),
83            reverse: t.reverse,
84            silhouette: t.silhouette.clone(),
85            from_scale: t.from_scale,
86            to_scale: t.to_scale,
87            lobes: t.lobes,
88            wobble: t.wobble,
89            feather: t.feather,
90            band_color: t.band_color.clone(),
91            duration: t.duration,
92        }
93    }
94}
95
96pub fn apply_transition(
97    frame_a: &[u8],
98    frame_b: &[u8],
99    width: u32,
100    height: u32,
101    progress: f64,
102    transition_type: &TransitionType,
103    opts: &TransitionOptions,
104) -> Vec<u8> {
105    let progress = progress.clamp(0.0, 1.0) as f32;
106    let TransitionOptions {
107        corner,
108        cell,
109        seed,
110        order,
111        direction,
112        aberration,
113        strength,
114        origin,
115        shape,
116        aspect,
117        fill,
118        hold,
119        ring,
120        reverse,
121        silhouette,
122        from_scale,
123        to_scale,
124        lobes,
125        wobble,
126        feather,
127        band_color,
128        duration,
129    } = opts.clone();
130
131    match transition_type {
132        TransitionType::Fade => blend_fade(frame_a, frame_b, progress),
133        TransitionType::WipeLeft => wipe(
134            frame_a,
135            frame_b,
136            width,
137            height,
138            progress,
139            Direction::Left,
140            feather,
141            band_color.as_deref(),
142        ),
143        TransitionType::WipeRight => wipe(
144            frame_a,
145            frame_b,
146            width,
147            height,
148            progress,
149            Direction::Right,
150            feather,
151            band_color.as_deref(),
152        ),
153        TransitionType::WipeUp => wipe(
154            frame_a,
155            frame_b,
156            width,
157            height,
158            progress,
159            Direction::Up,
160            feather,
161            band_color.as_deref(),
162        ),
163        TransitionType::WipeDown => wipe(
164            frame_a,
165            frame_b,
166            width,
167            height,
168            progress,
169            Direction::Down,
170            feather,
171            band_color.as_deref(),
172        ),
173        TransitionType::ZoomIn => zoom_transition(frame_a, frame_b, width, height, progress, true),
174        TransitionType::ZoomOut => {
175            zoom_transition(frame_a, frame_b, width, height, progress, false)
176        }
177        TransitionType::Flip => flip_transition(frame_a, frame_b, width, height, progress),
178        TransitionType::ClockWipe => clock_wipe(frame_a, frame_b, width, height, progress),
179        TransitionType::Iris => iris_transition(
180            frame_a,
181            frame_b,
182            width,
183            height,
184            progress,
185            origin,
186            shape,
187            aspect,
188            fill.as_deref(),
189            hold,
190            duration,
191            ring.as_ref(),
192            reverse,
193        ),
194        TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress),
195        TransitionType::Dissolve => dissolve_transition(frame_a, frame_b, width, height, progress),
196        TransitionType::CornerReveal => {
197            corner_reveal(frame_a, frame_b, width, height, progress, corner)
198        }
199        TransitionType::PixelDissolve => {
200            pixel_dissolve(frame_a, frame_b, width, height, progress, cell, seed, order)
201        }
202        TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress),
203        TransitionType::ChromaticWipe => chromatic_wipe(
204            frame_a, frame_b, width, height, progress, direction, aberration,
205        ),
206        TransitionType::ZoomBlur => {
207            zoom_blur_transition(frame_a, frame_b, width, height, progress, strength, origin)
208        }
209        TransitionType::Whip => whip_transition(
210            frame_a, frame_b, width, height, progress, strength, direction,
211        ),
212        TransitionType::Mask => mask_transition(
213            frame_a,
214            frame_b,
215            width,
216            height,
217            progress,
218            silhouette.as_ref(),
219            origin,
220            from_scale,
221            to_scale,
222            feather,
223            band_color.as_deref(),
224        ),
225        TransitionType::Blob => blob_transition(
226            frame_a,
227            frame_b,
228            width,
229            height,
230            progress,
231            origin,
232            lobes,
233            wobble,
234            seed,
235            feather,
236            band_color.as_deref(),
237        ),
238        TransitionType::None => {
239            if progress < 0.5 {
240                frame_a.to_vec()
241            } else {
242                frame_b.to_vec()
243            }
244        }
245    }
246}
247
248fn corner_reveal(
249    frame_a: &[u8],
250    frame_b: &[u8],
251    width: u32,
252    height: u32,
253    progress: f32,
254    corner: TransitionCorner,
255) -> Vec<u8> {
256    let mut surface = match create_skia_surface(width, height) {
257        Some(s) => s,
258        None => return blend_fade(frame_a, frame_b, progress),
259    };
260    let img_a = match frame_to_image(frame_a, width, height) {
261        Some(i) => i,
262        None => return blend_fade(frame_a, frame_b, progress),
263    };
264    let img_b = match frame_to_image(frame_b, width, height) {
265        Some(i) => i,
266        None => return blend_fade(frame_a, frame_b, progress),
267    };
268
269    let (w, h) = (width as f32, height as f32);
270    let rect = corner_rect(corner, w, h, progress);
271
272    let canvas = surface.canvas();
273    canvas.draw_image(&img_a, (0.0, 0.0), None);
274    canvas.save();
275    canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false);
276    canvas.draw_image(&img_b, (0.0, 0.0), None);
277    canvas.restore();
278
279    surface_to_pixels(surface, width, height)
280}
281
282fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_safe::Rect {
283    let p = progress.clamp(0.0, 1.0);
284    let (rw, rh) = (w * p, h * p);
285    match corner {
286        TransitionCorner::TopRight => skia_safe::Rect::from_xywh(w - rw, 0.0, rw, rh),
287        TransitionCorner::TopLeft => skia_safe::Rect::from_xywh(0.0, 0.0, rw, rh),
288        TransitionCorner::BottomRight => skia_safe::Rect::from_xywh(w - rw, h - rh, rw, rh),
289        TransitionCorner::BottomLeft => skia_safe::Rect::from_xywh(0.0, h - rh, rw, rh),
290    }
291}
292
293const SPATIAL_WEIGHT: f32 = 0.72;
294
295fn cell_hash01(col: i32, row: i32, seed: u32) -> f32 {
296    let mut h = seed
297        .wrapping_mul(0x9E37_79B9)
298        .wrapping_add((col as u32).wrapping_mul(0x85EB_CA6B))
299        .wrapping_add((row as u32).wrapping_mul(0xC2B2_AE35));
300    h ^= h >> 16;
301    h = h.wrapping_mul(0x7FEB_352D);
302    h ^= h >> 15;
303    (h & 0x00FF_FFFF) as f32 / 0x0100_0000 as f32
304}
305
306fn cell_threshold(
307    col: i32,
308    row: i32,
309    cols: i32,
310    rows: i32,
311    seed: u32,
312    order: PixelDissolveOrder,
313) -> f32 {
314    let noise = cell_hash01(col, row, seed);
315    if order == PixelDissolveOrder::Random {
316        return noise;
317    }
318    let (cx, cy) = ((cols - 1) as f32 / 2.0, (rows - 1) as f32 / 2.0);
319    let dx = if cx > 0.0 {
320        (col as f32 - cx).abs() / cx
321    } else {
322        0.0
323    };
324    let dy = if cy > 0.0 {
325        (row as f32 - cy).abs() / cy
326    } else {
327        0.0
328    };
329    let edge = dx.max(dy).clamp(0.0, 1.0);
330    let spatial = match order {
331        PixelDissolveOrder::EdgesIn => 1.0 - edge,
332        PixelDissolveOrder::CenterOut => edge,
333        PixelDissolveOrder::Random => unreachable!("handled above"),
334    };
335    (spatial * SPATIAL_WEIGHT + noise * (1.0 - SPATIAL_WEIGHT)).clamp(0.0, 1.0)
336}
337
338fn pixel_dissolve(
339    frame_a: &[u8],
340    frame_b: &[u8],
341    width: u32,
342    height: u32,
343    progress: f32,
344    cell: f32,
345    seed: u32,
346    order: PixelDissolveOrder,
347) -> Vec<u8> {
348    let mut surface = match create_skia_surface(width, height) {
349        Some(s) => s,
350        None => return blend_fade(frame_a, frame_b, progress),
351    };
352    let img_a = match frame_to_image(frame_a, width, height) {
353        Some(i) => i,
354        None => return blend_fade(frame_a, frame_b, progress),
355    };
356    let img_b = match frame_to_image(frame_b, width, height) {
357        Some(i) => i,
358        None => return blend_fade(frame_a, frame_b, progress),
359    };
360
361    let cell = cell.max(1.0);
362    let cols = (width as f32 / cell).ceil() as i32;
363    let rows = (height as f32 / cell).ceil() as i32;
364
365    let canvas = surface.canvas();
366    canvas.draw_image(&img_a, (0.0, 0.0), None);
367
368    const FEATHER: f32 = 0.35;
369    let p = progress.clamp(0.0, 1.0) * (1.0 + FEATHER);
370
371    for row in 0..rows {
372        for col in 0..cols {
373            let t = cell_threshold(col, row, cols, rows, seed, order);
374            let alpha = ((p - t) / FEATHER).clamp(0.0, 1.0);
375            if alpha <= 0.001 {
376                continue;
377            }
378            let rect = Rect::from_xywh(col as f32 * cell, row as f32 * cell, cell, cell);
379            canvas.save();
380            canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false);
381            let mut paint = Paint::default();
382            paint.set_alpha_f(alpha);
383            canvas.draw_image(&img_b, (0.0, 0.0), Some(&paint));
384            canvas.restore();
385        }
386    }
387
388    surface_to_pixels(surface, width, height)
389}
390
391fn blend_fade(frame_a: &[u8], frame_b: &[u8], progress: f32) -> Vec<u8> {
392    let inv = 1.0 - progress;
393    frame_a
394        .iter()
395        .zip(frame_b.iter())
396        .map(|(&a, &b)| {
397            let va = a as f32 * inv;
398            let vb = b as f32 * progress;
399            (va + vb + 0.5) as u8
400        })
401        .collect()
402}
403
404enum Direction {
405    Left,
406    Right,
407    Up,
408    Down,
409}
410
411fn wipe_reveal_rect(direction: &Direction, w: f32, h: f32, progress: f32) -> Rect {
412    match direction {
413        Direction::Left => Rect::from_xywh(0.0, 0.0, w * progress, h),
414        Direction::Right => Rect::from_xywh(w * (1.0 - progress), 0.0, w * progress, h),
415        Direction::Up => Rect::from_xywh(0.0, 0.0, w, h * progress),
416        Direction::Down => Rect::from_xywh(0.0, h * (1.0 - progress), w, h * progress),
417    }
418}
419
420const WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR: f32 = 6.0;
421const WIPE_FIXED_EDGE_MARGIN_FLOOR: f32 = 16.0;
422
423fn wipe_fixed_edge_margin(feather: f32) -> f32 {
424    feather.max(0.0) * WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR + WIPE_FIXED_EDGE_MARGIN_FLOOR
425}
426
427fn wipe_feathered_reveal_rect(
428    direction: &Direction,
429    w: f32,
430    h: f32,
431    progress: f32,
432    feather: f32,
433) -> Rect {
434    let margin = wipe_fixed_edge_margin(feather);
435    match direction {
436        Direction::Left => Rect::from_ltrb(-margin, -margin, w * progress, h + margin),
437        Direction::Right => Rect::from_ltrb(w * (1.0 - progress), -margin, w + margin, h + margin),
438        Direction::Up => Rect::from_ltrb(-margin, -margin, w + margin, h * progress),
439        Direction::Down => Rect::from_ltrb(-margin, h * (1.0 - progress), w + margin, h + margin),
440    }
441}
442
443fn wipe(
444    frame_a: &[u8],
445    frame_b: &[u8],
446    width: u32,
447    height: u32,
448    progress: f32,
449    direction: Direction,
450    feather: f32,
451    band_color: Option<&str>,
452) -> Vec<u8> {
453    if progress <= 0.0 {
454        return frame_a.to_vec();
455    }
456    if progress >= 1.0 {
457        return frame_b.to_vec();
458    }
459
460    let (w, h) = (width as f32, height as f32);
461    let reveal = wipe_reveal_rect(&direction, w, h, progress);
462
463    if feather <= 0.0 && band_color.is_none() {
464        let mut surface = match create_skia_surface(width, height) {
465            Some(s) => s,
466            None => return blend_fade(frame_a, frame_b, progress),
467        };
468        let img_a = match frame_to_image(frame_a, width, height) {
469            Some(i) => i,
470            None => return blend_fade(frame_a, frame_b, progress),
471        };
472        let img_b = match frame_to_image(frame_b, width, height) {
473            Some(i) => i,
474            None => return blend_fade(frame_a, frame_b, progress),
475        };
476
477        let canvas = surface.canvas();
478        canvas.draw_image(&img_a, (0.0, 0.0), None);
479        canvas.save();
480        canvas.clip_rect(reveal, skia_safe::ClipOp::Intersect, true);
481        canvas.draw_image(&img_b, (0.0, 0.0), None);
482        canvas.restore();
483
484        return surface_to_pixels(surface, width, height);
485    }
486
487    let feathered_reveal = wipe_feathered_reveal_rect(&direction, w, h, progress, feather);
488    let mut builder = PathBuilder::new();
489    builder.add_rect(feathered_reveal, None, None);
490    let path = builder.detach();
491    composite_through_mask(
492        frame_a,
493        frame_b,
494        width,
495        height,
496        &path,
497        feather.max(0.0),
498        band_color,
499    )
500}
501
502fn create_skia_surface(width: u32, height: u32) -> Option<skia_safe::Surface> {
503    let info = ImageInfo::new(
504        (width as i32, height as i32),
505        ColorType::RGBA8888,
506        skia_safe::AlphaType::Premul,
507        None,
508    );
509    surfaces::raster(&info, None, None)
510}
511
512fn frame_to_image(frame: &[u8], width: u32, height: u32) -> Option<skia_safe::Image> {
513    let info = ImageInfo::new(
514        (width as i32, height as i32),
515        ColorType::RGBA8888,
516        skia_safe::AlphaType::Premul,
517        None,
518    );
519    let data = skia_safe::Data::new_copy(frame);
520    skia_safe::images::raster_from_data(&info, data, width as usize * 4)
521}
522
523fn surface_to_pixels(mut surface: skia_safe::Surface, width: u32, height: u32) -> Vec<u8> {
524    let row_bytes = width as usize * 4;
525    let mut pixels = vec![0u8; row_bytes * height as usize];
526    let info = ImageInfo::new(
527        (width as i32, height as i32),
528        ColorType::RGBA8888,
529        skia_safe::AlphaType::Premul,
530        None,
531    );
532    surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
533    pixels
534}
535
536fn zoom_transition(
537    frame_a: &[u8],
538    frame_b: &[u8],
539    width: u32,
540    height: u32,
541    progress: f32,
542    zoom_in: bool,
543) -> Vec<u8> {
544    let mut surface = match create_skia_surface(width, height) {
545        Some(s) => s,
546        None => return blend_fade(frame_a, frame_b, progress),
547    };
548    let img_a = match frame_to_image(frame_a, width, height) {
549        Some(i) => i,
550        None => return blend_fade(frame_a, frame_b, progress),
551    };
552    let img_b = match frame_to_image(frame_b, width, height) {
553        Some(i) => i,
554        None => return blend_fade(frame_a, frame_b, progress),
555    };
556
557    let canvas = surface.canvas();
558    let w = width as f32;
559    let h = height as f32;
560
561    if zoom_in {
562        let scale = 1.0 + progress * 0.3;
563        canvas.draw_image(&img_b, (0.0, 0.0), None);
564        canvas.save();
565        canvas.translate((w / 2.0, h / 2.0));
566        canvas.scale((scale, scale));
567        canvas.translate((-w / 2.0, -h / 2.0));
568        let mut paint = Paint::default();
569        paint.set_alpha_f(1.0 - progress);
570        canvas.draw_image(&img_a, (0.0, 0.0), Some(&paint));
571        canvas.restore();
572    } else {
573        canvas.draw_image(&img_a, (0.0, 0.0), None);
574        let scale = 1.3 - progress * 0.3;
575        canvas.save();
576        canvas.translate((w / 2.0, h / 2.0));
577        canvas.scale((scale, scale));
578        canvas.translate((-w / 2.0, -h / 2.0));
579        let mut paint = Paint::default();
580        paint.set_alpha_f(progress);
581        canvas.draw_image(&img_b, (0.0, 0.0), Some(&paint));
582        canvas.restore();
583    }
584
585    surface_to_pixels(surface, width, height)
586}
587
588fn flip_transition(
589    frame_a: &[u8],
590    frame_b: &[u8],
591    width: u32,
592    height: u32,
593    progress: f32,
594) -> Vec<u8> {
595    let mut surface = match create_skia_surface(width, height) {
596        Some(s) => s,
597        None => return blend_fade(frame_a, frame_b, progress),
598    };
599    let img_a = match frame_to_image(frame_a, width, height) {
600        Some(i) => i,
601        None => return blend_fade(frame_a, frame_b, progress),
602    };
603    let img_b = match frame_to_image(frame_b, width, height) {
604        Some(i) => i,
605        None => return blend_fade(frame_a, frame_b, progress),
606    };
607
608    let canvas = surface.canvas();
609    let w = width as f32;
610
611    if progress < 0.5 {
612        let scale_x = 1.0 - progress * 2.0;
613        canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
614        canvas.save();
615        canvas.translate((w / 2.0, 0.0));
616        canvas.scale((scale_x.max(0.01), 1.0));
617        canvas.translate((-w / 2.0, 0.0));
618        canvas.draw_image(&img_a, (0.0, 0.0), None);
619        canvas.restore();
620    } else {
621        let scale_x = (progress - 0.5) * 2.0;
622        canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0));
623        canvas.save();
624        canvas.translate((w / 2.0, 0.0));
625        canvas.scale((scale_x.max(0.01), 1.0));
626        canvas.translate((-w / 2.0, 0.0));
627        canvas.draw_image(&img_b, (0.0, 0.0), None);
628        canvas.restore();
629    }
630
631    surface_to_pixels(surface, width, height)
632}
633
634fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: f32) -> Vec<u8> {
635    let mut surface = match create_skia_surface(width, height) {
636        Some(s) => s,
637        None => return blend_fade(frame_a, frame_b, progress),
638    };
639    let img_a = match frame_to_image(frame_a, width, height) {
640        Some(i) => i,
641        None => return blend_fade(frame_a, frame_b, progress),
642    };
643    let img_b = match frame_to_image(frame_b, width, height) {
644        Some(i) => i,
645        None => return blend_fade(frame_a, frame_b, progress),
646    };
647
648    let canvas = surface.canvas();
649    let w = width as f32;
650    let h = height as f32;
651    let cx = w / 2.0;
652    let cy = h / 2.0;
653    let radius = (w * w + h * h).sqrt();
654
655    canvas.draw_image(&img_a, (0.0, 0.0), None);
656
657    let sweep_angle = progress * 360.0;
658    let start_angle = -90.0;
659
660    let mut path = PathBuilder::new();
661    path.move_to((cx, cy));
662    path.arc_to(
663        Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0),
664        start_angle,
665        sweep_angle,
666        false,
667    );
668    path.close();
669
670    canvas.save();
671    canvas.clip_path(&path.detach(), skia_safe::ClipOp::Intersect, true);
672    canvas.draw_image(&img_b, (0.0, 0.0), None);
673    canvas.restore();
674
675    surface_to_pixels(surface, width, height)
676}
677
678const IRIS_PILL_OVERSHOOT: f32 = 1.45;
679const IRIS_PILL_CORNER_FRACTION: f32 = 1.0;
680
681fn iris_max_radius(
682    origin: (f32, f32),
683    width: f32,
684    height: f32,
685    shape: IrisShape,
686    aspect: f32,
687) -> f32 {
688    let fx = origin.0.max(width - origin.0);
689    let fy = origin.1.max(height - origin.1);
690    match shape {
691        IrisShape::Circle => (fx * fx + fy * fy).sqrt(),
692        IrisShape::Pill => {
693            let sqrt_aspect = aspect.max(0.05).sqrt();
694            let base = (fx / sqrt_aspect).max(fy * sqrt_aspect);
695            base * IRIS_PILL_OVERSHOOT
696        }
697    }
698}
699
700fn iris_mask_path(
701    origin: (f32, f32),
702    shape: IrisShape,
703    aspect: f32,
704    radius: f32,
705) -> skia_safe::Path {
706    let radius = radius.max(0.0);
707    let mut builder = PathBuilder::new();
708    match shape {
709        IrisShape::Circle => {
710            builder.add_circle((origin.0, origin.1), radius, None);
711        }
712        IrisShape::Pill => {
713            let sqrt_aspect = aspect.max(0.05).sqrt();
714            let half_w = radius * sqrt_aspect;
715            let half_h = radius / sqrt_aspect;
716            let corner = half_w.min(half_h) * IRIS_PILL_CORNER_FRACTION;
717            let rect = Rect::from_ltrb(
718                origin.0 - half_w,
719                origin.1 - half_h,
720                origin.0 + half_w,
721                origin.1 + half_h,
722            );
723            let rrect = skia_safe::RRect::new_rect_xy(rect, corner, corner);
724            builder.add_rrect(rrect, None, None);
725        }
726    }
727    builder.detach()
728}
729
730fn solid_frame(width: u32, height: u32, hex: &str) -> Vec<u8> {
731    let color = color4f_from_hex(hex);
732    let (r, g, b, a) = (
733        (color.r * 255.0).round() as u8,
734        (color.g * 255.0).round() as u8,
735        (color.b * 255.0).round() as u8,
736        (color.a * 255.0).round() as u8,
737    );
738    (0..width * height).flat_map(|_| [r, g, b, a]).collect()
739}
740
741#[allow(clippy::too_many_arguments)]
742fn iris_composite(
743    outer: &[u8],
744    inner: &[u8],
745    width: u32,
746    height: u32,
747    origin: (f32, f32),
748    shape: IrisShape,
749    aspect: f32,
750    radius: f32,
751    ring: Option<&IrisRing>,
752) -> Vec<u8> {
753    let mut surface = match create_skia_surface(width, height) {
754        Some(s) => s,
755        None => return outer.to_vec(),
756    };
757    let (Some(img_outer), Some(img_inner)): (Option<Image>, Option<Image>) = (
758        frame_to_image(outer, width, height),
759        frame_to_image(inner, width, height),
760    ) else {
761        return outer.to_vec();
762    };
763
764    let path = iris_mask_path(origin, shape, aspect, radius);
765
766    let canvas = surface.canvas();
767    canvas.draw_image(&img_outer, (0.0, 0.0), None);
768    canvas.save();
769    canvas.clip_path(&path, skia_safe::ClipOp::Intersect, true);
770    canvas.draw_image(&img_inner, (0.0, 0.0), None);
771    canvas.restore();
772
773    if let Some(ring) = ring {
774        if radius > 1.0 {
775            let mut paint = paint_from_hex(&ring.color);
776            paint.set_style(PaintStyle::Stroke);
777            paint.set_stroke_width(ring.width.max(0.0));
778            canvas.draw_path(&path, &paint);
779        }
780    }
781
782    surface_to_pixels(surface, width, height)
783}
784
785#[allow(clippy::too_many_arguments)]
786fn iris_transition(
787    frame_a: &[u8],
788    frame_b: &[u8],
789    width: u32,
790    height: u32,
791    progress: f32,
792    origin: Option<ZoomBlurOrigin>,
793    shape: IrisShape,
794    aspect: f32,
795    fill: Option<&str>,
796    hold: f32,
797    duration: f64,
798    ring: Option<&IrisRing>,
799    reverse: bool,
800) -> Vec<u8> {
801    let (w, h) = (width as f32, height as f32);
802    let origin = match origin {
803        Some(o) => (o.x, o.y),
804        None => (w / 2.0, h / 2.0),
805    };
806    let max_radius = iris_max_radius(origin, w, h, shape, aspect);
807
808    let Some(fill_hex) = fill else {
809        let t = progress.clamp(0.0, 1.0);
810        let (outer, inner, radius) = if reverse {
811            (frame_b, frame_a, max_radius * (1.0 - t))
812        } else {
813            (frame_a, frame_b, max_radius * t)
814        };
815        return iris_composite(
816            outer, inner, width, height, origin, shape, aspect, radius, ring,
817        );
818    };
819
820    let hold_fraction = if duration > 0.0 {
821        (hold as f64 / duration).clamp(0.0, 0.9) as f32
822    } else {
823        0.0
824    };
825    let remaining = (1.0 - hold_fraction).max(0.0001);
826    let grow_span = remaining * 0.5;
827    let reveal_start = grow_span + hold_fraction;
828    let filled = solid_frame(width, height, fill_hex);
829
830    if progress < grow_span {
831        let t = (progress / grow_span).clamp(0.0, 1.0);
832        let (outer, inner, radius): (&[u8], &[u8], f32) = if reverse {
833            (&filled, frame_a, max_radius * (1.0 - t))
834        } else {
835            (frame_a, &filled, max_radius * t)
836        };
837        return iris_composite(
838            outer, inner, width, height, origin, shape, aspect, radius, ring,
839        );
840    }
841
842    if progress < reveal_start {
843        return filled;
844    }
845
846    let reveal_span = (1.0 - reveal_start).max(0.0001);
847    let t = ((progress - reveal_start) / reveal_span).clamp(0.0, 1.0);
848    blend_fade(&filled, frame_b, t)
849}
850
851pub fn mask_shape_to_local_path(shape: &MaskShape) -> Option<skia_safe::Path> {
852    match shape {
853        MaskShape::Polygon { points } => {
854            if points.len() < 3 {
855                return None;
856            }
857            let mut builder = PathBuilder::new();
858            for (i, (x, y)) in points.iter().enumerate() {
859                if i == 0 {
860                    builder.move_to((*x, *y));
861                } else {
862                    builder.line_to((*x, *y));
863                }
864            }
865            builder.close();
866            Some(builder.detach())
867        }
868        MaskShape::Path { d } => skia_safe::Path::from_svg(d),
869    }
870}
871
872pub(crate) fn scaled_mask_path(
873    local: &skia_safe::Path,
874    scale: f32,
875    origin: (f32, f32),
876) -> skia_safe::Path {
877    let bounds = *local.bounds();
878    let cx = (bounds.left + bounds.right) / 2.0;
879    let cy = (bounds.top + bounds.bottom) / 2.0;
880    let safe_scale = scale.max(0.0001);
881    let mut matrix = Matrix::default();
882    matrix.pre_translate((origin.0, origin.1));
883    matrix.pre_scale((safe_scale, safe_scale), None);
884    matrix.pre_translate((-cx, -cy));
885    local.with_transform(&matrix)
886}
887
888fn mask_alpha_buffer(
889    path: &skia_safe::Path,
890    width: u32,
891    height: u32,
892    feather: f32,
893) -> Option<Vec<u8>> {
894    let info = ImageInfo::new(
895        (width as i32, height as i32),
896        ColorType::Alpha8,
897        skia_safe::AlphaType::Premul,
898        None,
899    );
900    let mut surface = surfaces::raster(&info, None, None)?;
901    let mut paint = Paint::default();
902    paint.set_anti_alias(true);
903    paint.set_style(PaintStyle::Fill);
904    paint.set_alpha(255);
905    let sigma = (feather.max(0.0) / 3.0).max(0.05);
906    if let Some(mask_filter) = MaskFilter::blur(BlurStyle::Normal, sigma, None) {
907        paint.set_mask_filter(mask_filter);
908    }
909    surface.canvas().draw_path(path, &paint);
910    let row_bytes = width as usize;
911    let mut buf = vec![0u8; row_bytes * height as usize];
912    surface.read_pixels(&info, &mut buf, row_bytes, (0, 0));
913    Some(buf)
914}
915
916fn hard_mask_composite(
917    outer: &[u8],
918    inner: &[u8],
919    width: u32,
920    height: u32,
921    path: &skia_safe::Path,
922) -> Vec<u8> {
923    let mut surface = match create_skia_surface(width, height) {
924        Some(s) => s,
925        None => return outer.to_vec(),
926    };
927    let (Some(img_outer), Some(img_inner)) = (
928        frame_to_image(outer, width, height),
929        frame_to_image(inner, width, height),
930    ) else {
931        return outer.to_vec();
932    };
933
934    let canvas = surface.canvas();
935    canvas.draw_image(&img_outer, (0.0, 0.0), None);
936    canvas.save();
937    canvas.clip_path(path, skia_safe::ClipOp::Intersect, true);
938    canvas.draw_image(&img_inner, (0.0, 0.0), None);
939    canvas.restore();
940
941    surface_to_pixels(surface, width, height)
942}
943
944fn composite_through_mask(
945    outer: &[u8],
946    inner: &[u8],
947    width: u32,
948    height: u32,
949    path: &skia_safe::Path,
950    feather: f32,
951    band_color: Option<&str>,
952) -> Vec<u8> {
953    if feather <= 0.0 {
954        return hard_mask_composite(outer, inner, width, height, path);
955    }
956    let Some(alpha) = mask_alpha_buffer(path, width, height, feather) else {
957        return hard_mask_composite(outer, inner, width, height, path);
958    };
959
960    let band = band_color.map(color4f_from_hex);
961    let pixel_count = (width * height) as usize;
962    let mut out = vec![0u8; pixel_count * 4];
963    for (i, &alpha_byte) in alpha.iter().enumerate().take(pixel_count) {
964        let a = alpha_byte as f32 / 255.0;
965        let base = i * 4;
966        let mut rgb = [0f32; 3];
967        for (c, slot) in rgb.iter_mut().enumerate() {
968            let o = outer[base + c] as f32;
969            let n = inner[base + c] as f32;
970            *slot = o * (1.0 - a) + n * a;
971        }
972        if let Some(band) = &band {
973            let weight = 4.0 * a * (1.0 - a);
974            let band_rgb = [band.r * 255.0, band.g * 255.0, band.b * 255.0];
975            for (slot, band_channel) in rgb.iter_mut().zip(band_rgb) {
976                *slot = *slot * (1.0 - weight) + band_channel * weight;
977            }
978        }
979        for (c, value) in rgb.into_iter().enumerate() {
980            out[base + c] = value.round().clamp(0.0, 255.0) as u8;
981        }
982        out[base + 3] = outer[base + 3].max(inner[base + 3]);
983    }
984    out
985}
986
987#[allow(clippy::too_many_arguments)]
988fn mask_transition(
989    frame_a: &[u8],
990    frame_b: &[u8],
991    width: u32,
992    height: u32,
993    progress: f32,
994    silhouette: Option<&MaskShape>,
995    origin: Option<ZoomBlurOrigin>,
996    from_scale: f32,
997    to_scale: f32,
998    feather: f32,
999    band_color: Option<&str>,
1000) -> Vec<u8> {
1001    if progress <= 0.0 {
1002        return frame_a.to_vec();
1003    }
1004    if progress >= 1.0 {
1005        return frame_b.to_vec();
1006    }
1007    let Some(shape) = silhouette else {
1008        eprintln!(
1009            "rustmotion: transition type \"mask\" needs a `silhouette` (polygon or path); \
1010             falling back to a plain fade"
1011        );
1012        return blend_fade(frame_a, frame_b, progress);
1013    };
1014    let Some(local) = mask_shape_to_local_path(shape) else {
1015        eprintln!(
1016            "rustmotion: transition \"mask\" `silhouette` did not resolve to a path (a \
1017             `polygon` needs at least 3 points, a `path`'s `d` must be valid SVG path data); \
1018             falling back to a plain fade"
1019        );
1020        return blend_fade(frame_a, frame_b, progress);
1021    };
1022
1023    let (w, h) = (width as f32, height as f32);
1024    let origin_px = match origin {
1025        Some(o) => (o.x, o.y),
1026        None => (w / 2.0, h / 2.0),
1027    };
1028    let scale = from_scale.max(0.0) + (to_scale.max(0.0) - from_scale.max(0.0)) * progress;
1029    let path = scaled_mask_path(&local, scale, origin_px);
1030    composite_through_mask(
1031        frame_a,
1032        frame_b,
1033        width,
1034        height,
1035        &path,
1036        feather.max(0.0),
1037        band_color,
1038    )
1039}
1040
1041pub(crate) fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path {
1042    let n = lobes.max(3);
1043    let wobble = wobble.clamp(0.0, 0.95);
1044    let points: Vec<(f32, f32)> = (0..n)
1045        .map(|i| {
1046            let t = i as f32 / n as f32;
1047            let angle = t * std::f32::consts::TAU;
1048            let noise = cell_hash01(i as i32, 0, seed) * 2.0 - 1.0;
1049            let radius = 1.0 + wobble * noise;
1050            (angle.cos() * radius, angle.sin() * radius)
1051        })
1052        .collect();
1053
1054    let midpoint = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);
1055    let n = n as usize;
1056    let mut builder = PathBuilder::new();
1057    builder.move_to(midpoint(points[n - 1], points[0]));
1058    for i in 0..n {
1059        let next = points[(i + 1) % n];
1060        builder.quad_to(points[i], midpoint(points[i], next));
1061    }
1062    builder.close();
1063    builder.detach()
1064}
1065
1066#[allow(clippy::too_many_arguments)]
1067fn blob_transition(
1068    frame_a: &[u8],
1069    frame_b: &[u8],
1070    width: u32,
1071    height: u32,
1072    progress: f32,
1073    origin: Option<ZoomBlurOrigin>,
1074    lobes: u32,
1075    wobble: f32,
1076    seed: u32,
1077    feather: f32,
1078    band_color: Option<&str>,
1079) -> Vec<u8> {
1080    if progress <= 0.0 {
1081        return frame_a.to_vec();
1082    }
1083    if progress >= 1.0 {
1084        return frame_b.to_vec();
1085    }
1086
1087    let (w, h) = (width as f32, height as f32);
1088    let origin_px = match origin {
1089        Some(o) => (o.x, o.y),
1090        None => (w / 2.0, h / 2.0),
1091    };
1092    let coverage_radius = iris_max_radius(origin_px, w, h, IrisShape::Circle, 1.0);
1093    let wobble = wobble.clamp(0.0, 0.95);
1094    let shrunk_lobe_safety = (1.0 - wobble).max(0.05);
1095    let max_radius = coverage_radius / shrunk_lobe_safety;
1096    let local = blob_local_path(lobes, wobble, seed);
1097    let scale = max_radius * progress;
1098    let path = scaled_mask_path(&local, scale, origin_px);
1099    composite_through_mask(
1100        frame_a,
1101        frame_b,
1102        width,
1103        height,
1104        &path,
1105        feather.max(0.0),
1106        band_color,
1107    )
1108}
1109
1110fn slide_transition(
1111    frame_a: &[u8],
1112    frame_b: &[u8],
1113    width: u32,
1114    height: u32,
1115    progress: f32,
1116) -> Vec<u8> {
1117    let mut surface = match create_skia_surface(width, height) {
1118        Some(s) => s,
1119        None => return blend_fade(frame_a, frame_b, progress),
1120    };
1121    let img_a = match frame_to_image(frame_a, width, height) {
1122        Some(i) => i,
1123        None => return blend_fade(frame_a, frame_b, progress),
1124    };
1125    let img_b = match frame_to_image(frame_b, width, height) {
1126        Some(i) => i,
1127        None => return blend_fade(frame_a, frame_b, progress),
1128    };
1129
1130    let canvas = surface.canvas();
1131    let w = width as f32;
1132
1133    let offset = -progress * w;
1134    canvas.draw_image(&img_a, (offset, 0.0), None);
1135    canvas.draw_image(&img_b, (offset + w, 0.0), None);
1136
1137    surface_to_pixels(surface, width, height)
1138}
1139
1140fn direction_vector(direction: TransitionDirection) -> (f32, f32) {
1141    match direction {
1142        TransitionDirection::Left => (-1.0, 0.0),
1143        TransitionDirection::Right => (1.0, 0.0),
1144        TransitionDirection::Up => (0.0, -1.0),
1145        TransitionDirection::Down => (0.0, 1.0),
1146    }
1147}
1148
1149fn directional_slide(
1150    frame_a: &[u8],
1151    frame_b: &[u8],
1152    width: u32,
1153    height: u32,
1154    progress: f32,
1155    ux: f32,
1156    uy: f32,
1157) -> Option<Vec<u8>> {
1158    let mut surface = create_skia_surface(width, height)?;
1159    let (Some(img_a), Some(img_b)) = (
1160        frame_to_image(frame_a, width, height),
1161        frame_to_image(frame_b, width, height),
1162    ) else {
1163        return None;
1164    };
1165    let (w, h) = (width as f32, height as f32);
1166    let canvas = surface.canvas();
1167    let (dx, dy) = (ux * progress * w, uy * progress * h);
1168    canvas.draw_image(&img_a, (dx, dy), None);
1169    canvas.draw_image(&img_b, (dx - ux * w, dy - uy * h), None);
1170    Some(surface_to_pixels(surface, width, height))
1171}
1172
1173fn chromatic_wipe(
1174    frame_a: &[u8],
1175    frame_b: &[u8],
1176    width: u32,
1177    height: u32,
1178    progress: f32,
1179    direction: TransitionDirection,
1180    aberration: f32,
1181) -> Vec<u8> {
1182    let w = width as f32;
1183    let (ux, uy) = direction_vector(direction);
1184
1185    let Some(slid) = directional_slide(frame_a, frame_b, width, height, progress, ux, uy) else {
1186        return blend_fade(frame_a, frame_b, progress);
1187    };
1188
1189    let peak = 1.0 - (progress * 2.0 - 1.0).abs();
1190    let shift = (aberration.max(0.0) * peak * w * 0.012).round() as i32;
1191    if shift == 0 {
1192        return slid;
1193    }
1194
1195    let mut out = slid.clone();
1196    let (sx, sy) = (
1197        (ux * shift as f32).round() as i32,
1198        (uy * shift as f32).round() as i32,
1199    );
1200    let sample = |buf: &[u8], x: i32, y: i32, channel: usize| -> u8 {
1201        let cx = x.clamp(0, width as i32 - 1);
1202        let cy = y.clamp(0, height as i32 - 1);
1203        buf[((cy as u32 * width + cx as u32) * 4) as usize + channel]
1204    };
1205    for y in 0..height as i32 {
1206        for x in 0..width as i32 {
1207            let base = ((y as u32 * width + x as u32) * 4) as usize;
1208            out[base] = sample(&slid, x - sx, y - sy, 0);
1209            out[base + 2] = sample(&slid, x + sx, y + sy, 2);
1210        }
1211    }
1212    out
1213}
1214
1215const ZOOM_BLUR_ZOOM_REACH: f32 = 0.5;
1216const ZOOM_BLUR_STEPS: usize = 10;
1217const ZOOM_BLUR_MAX_EXTRA_SCALE: f32 = 0.6;
1218
1219fn zoom_blur_transition(
1220    frame_a: &[u8],
1221    frame_b: &[u8],
1222    width: u32,
1223    height: u32,
1224    progress: f32,
1225    strength: f32,
1226    origin: Option<ZoomBlurOrigin>,
1227) -> Vec<u8> {
1228    let mut surface = match create_skia_surface(width, height) {
1229        Some(s) => s,
1230        None => return blend_fade(frame_a, frame_b, progress),
1231    };
1232    let (Some(img_a), Some(img_b)) = (
1233        frame_to_image(frame_a, width, height),
1234        frame_to_image(frame_b, width, height),
1235    ) else {
1236        return blend_fade(frame_a, frame_b, progress);
1237    };
1238
1239    let (w, h) = (width as f32, height as f32);
1240    let (ox, oy) = match origin {
1241        Some(o) => (o.x, o.y),
1242        None => (w / 2.0, h / 2.0),
1243    };
1244
1245    let scale_now = 1.0 + progress * ZOOM_BLUR_ZOOM_REACH;
1246    let alpha_a = 1.0 - progress;
1247
1248    {
1249        let canvas = surface.canvas();
1250        canvas.draw_image(&img_b, (0.0, 0.0), None);
1251        canvas.save();
1252        canvas.translate((ox, oy));
1253        canvas.scale((scale_now, scale_now));
1254        canvas.translate((-ox, -oy));
1255        let mut paint = Paint::default();
1256        paint.set_alpha_f(alpha_a);
1257        canvas.draw_image(&img_a, (0.0, 0.0), Some(&paint));
1258        canvas.restore();
1259    }
1260    let sharp = surface_to_pixels(surface, width, height);
1261
1262    let peak = 1.0 - (progress * 2.0 - 1.0).abs();
1263    let reach = strength.max(0.0) * peak;
1264    if reach <= 0.0 {
1265        return sharp;
1266    }
1267
1268    let mut streak_surface = match create_skia_surface(width, height) {
1269        Some(s) => s,
1270        None => return sharp,
1271    };
1272    let extra = reach * ZOOM_BLUR_MAX_EXTRA_SCALE;
1273    let canvas = streak_surface.canvas();
1274    canvas.draw_image(&img_b, (0.0, 0.0), None);
1275    for i in (0..ZOOM_BLUR_STEPS).rev() {
1276        let t = i as f32 / (ZOOM_BLUR_STEPS - 1) as f32;
1277        let s = scale_now + extra * t;
1278        let weight = (1.0 - t).powf(1.5);
1279        let mut streak_paint = Paint::default();
1280        streak_paint.set_alpha_f((alpha_a * weight).clamp(0.0, 1.0));
1281        canvas.save();
1282        canvas.translate((ox, oy));
1283        canvas.scale((s, s));
1284        canvas.translate((-ox, -oy));
1285        canvas.draw_image(&img_a, (0.0, 0.0), Some(&streak_paint));
1286        canvas.restore();
1287    }
1288
1289    surface_to_pixels(streak_surface, width, height)
1290}
1291
1292const WHIP_MAX_REACH: f32 = 0.5;
1293const WHIP_STEPS: usize = 120;
1294
1295fn whip_transition(
1296    frame_a: &[u8],
1297    frame_b: &[u8],
1298    width: u32,
1299    height: u32,
1300    progress: f32,
1301    strength: f32,
1302    direction: TransitionDirection,
1303) -> Vec<u8> {
1304    let (ux, uy) = direction_vector(direction);
1305    let Some(sharp) = directional_slide(frame_a, frame_b, width, height, progress, ux, uy) else {
1306        return blend_fade(frame_a, frame_b, progress);
1307    };
1308
1309    let peak = 1.0 - (progress * 2.0 - 1.0).abs();
1310    let reach = strength.max(0.0) * peak;
1311    if reach <= 0.0 {
1312        return sharp;
1313    }
1314
1315    let (Some(img_a), Some(img_b)) = (
1316        frame_to_image(frame_a, width, height),
1317        frame_to_image(frame_b, width, height),
1318    ) else {
1319        return sharp;
1320    };
1321    let Some(img_sharp) = frame_to_image(&sharp, width, height) else {
1322        return sharp;
1323    };
1324    let mut surface = match create_skia_surface(width, height) {
1325        Some(s) => s,
1326        None => return sharp,
1327    };
1328
1329    let (w, h) = (width as f32, height as f32);
1330    let axis_len = if uy == 0.0 { w } else { h };
1331    let (dx_a, dy_a) = (ux * progress * w, uy * progress * h);
1332    let (dx_b, dy_b) = (dx_a - ux * w, dy_a - uy * h);
1333    let reach_px = reach * axis_len * WHIP_MAX_REACH;
1334
1335    let alpha_a = (1.0 - progress).clamp(0.0, 1.0);
1336    let alpha_b = progress.clamp(0.0, 1.0);
1337
1338    let canvas = surface.canvas();
1339    canvas.draw_image(&img_sharp, (0.0, 0.0), None);
1340
1341    for i in (0..WHIP_STEPS).rev() {
1342        let t = i as f32 / (WHIP_STEPS - 1) as f32;
1343        let trail = reach_px * t;
1344        let weight = (1.0 - t).powf(1.5);
1345
1346        let mut paint_a = Paint::default();
1347        paint_a.set_alpha_f((alpha_a * weight).clamp(0.0, 1.0));
1348        canvas.draw_image(
1349            &img_a,
1350            (dx_a - ux * trail, dy_a - uy * trail),
1351            Some(&paint_a),
1352        );
1353
1354        let mut paint_b = Paint::default();
1355        paint_b.set_alpha_f((alpha_b * weight).clamp(0.0, 1.0));
1356        canvas.draw_image(
1357            &img_b,
1358            (dx_b - ux * trail, dy_b - uy * trail),
1359            Some(&paint_b),
1360        );
1361    }
1362
1363    surface_to_pixels(surface, width, height)
1364}
1365
1366fn dissolve_transition(
1367    frame_a: &[u8],
1368    frame_b: &[u8],
1369    _width: u32,
1370    _height: u32,
1371    progress: f32,
1372) -> Vec<u8> {
1373    blend_fade(frame_a, frame_b, progress)
1374}
1375
1376#[allow(clippy::too_many_arguments)]
1377pub fn camera_pan_transition(
1378    bg_a: &[u8],
1379    bg_b: &[u8],
1380    fg_a: &[u8],
1381    fg_b: &[u8],
1382    width: u32,
1383    height: u32,
1384    progress: f64,
1385    dx: f32,
1386    dy: f32,
1387    easing: &EasingType,
1388    pan_background: PanBackground,
1389) -> Vec<u8> {
1390    let t = ease(progress, easing) as f32;
1391
1392    let mut surface = match create_skia_surface(width, height) {
1393        Some(s) => s,
1394        None => return bg_a.to_vec(),
1395    };
1396    let img_fg_a = match frame_to_image(fg_a, width, height) {
1397        Some(i) => i,
1398        None => return bg_a.to_vec(),
1399    };
1400    let img_fg_b = match frame_to_image(fg_b, width, height) {
1401        Some(i) => i,
1402        None => return bg_a.to_vec(),
1403    };
1404
1405    let (out_x, out_y) = (-dx * t, -dy * t);
1406    let (in_x, in_y) = (dx * (1.0 - t), dy * (1.0 - t));
1407
1408    let blended_bg = match pan_background {
1409        PanBackground::Travel => {
1410            let img_bg_a = match frame_to_image(bg_a, width, height) {
1411                Some(i) => i,
1412                None => return bg_a.to_vec(),
1413            };
1414            let img_bg_b = match frame_to_image(bg_b, width, height) {
1415                Some(i) => i,
1416                None => return bg_a.to_vec(),
1417            };
1418
1419            const BG_PARALLAX: f32 = 0.12;
1420            let (bax, bay) = (out_x * BG_PARALLAX, out_y * BG_PARALLAX);
1421            let (bbx, bby) = (in_x * BG_PARALLAX, in_y * BG_PARALLAX);
1422
1423            let w = width as f32;
1424            let h = height as f32;
1425            let spread = |ox: f32, oy: f32| {
1426                let (mx, my) = (ox.abs(), oy.abs());
1427                Rect::from_ltrb(-mx + ox, -my + oy, w + mx + ox, h + my + oy)
1428            };
1429
1430            let layer_a = match render_layer(&img_bg_a, spread(bax, bay), width, height) {
1431                Some(p) => p,
1432                None => return bg_a.to_vec(),
1433            };
1434            let layer_b = match render_layer(&img_bg_b, spread(bbx, bby), width, height) {
1435                Some(p) => p,
1436                None => return bg_a.to_vec(),
1437            };
1438            blend_fade(&layer_a, &layer_b, t)
1439        }
1440        PanBackground::Static => blend_fade(bg_a, bg_b, t),
1441    };
1442    let img_bg = match frame_to_image(&blended_bg, width, height) {
1443        Some(i) => i,
1444        None => return bg_a.to_vec(),
1445    };
1446
1447    let canvas = surface.canvas();
1448    canvas.draw_image(&img_bg, (0.0, 0.0), None);
1449
1450    const FG_DISSOLVE: f32 = 1.6;
1451    let mut fg_paint = Paint::default();
1452
1453    fg_paint.set_alpha_f(1.0 - t.powf(FG_DISSOLVE));
1454    canvas.draw_image(&img_fg_a, (out_x, out_y), Some(&fg_paint));
1455
1456    fg_paint.set_alpha_f(1.0 - (1.0 - t).powf(FG_DISSOLVE));
1457    canvas.draw_image(&img_fg_b, (in_x, in_y), Some(&fg_paint));
1458
1459    surface_to_pixels(surface, width, height)
1460}
1461
1462fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> Option<Vec<u8>> {
1463    let mut surface = create_skia_surface(width, height)?;
1464    surface
1465        .canvas()
1466        .draw_image_rect(img, None, dest, &Paint::default());
1467    Some(surface_to_pixels(surface, width, height))
1468}
1469
1470#[cfg(test)]
1471mod camera_pan_tests {
1472    use super::*;
1473
1474    #[test]
1475    fn the_foreground_dissolve_is_a_noop_at_both_junctions() {
1476        let (w, h) = (8u32, 4u32);
1477        let bg = solid(w, h, 0, 0, 0, 255);
1478        let fg_a = solid(w, h, 255, 0, 0, 255);
1479        let fg_b = solid(w, h, 0, 0, 255, 255);
1480
1481        for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] {
1482            let out = camera_pan_transition(
1483                &bg,
1484                &bg,
1485                &fg_a,
1486                &fg_b,
1487                w,
1488                h,
1489                progress,
1490                8.0,
1491                0.0,
1492                &EasingType::Linear,
1493                PanBackground::Static,
1494            );
1495            assert_eq!(
1496                &out[0..3],
1497                &expected,
1498                "at progress {progress} the adjacent scene must render untouched",
1499            );
1500        }
1501    }
1502
1503    #[test]
1504    fn mid_pan_both_planes_stay_substantially_visible() {
1505        let (w, h) = (8u32, 4u32);
1506        let bg = solid(w, h, 0, 0, 0, 255);
1507        let fg_a = solid(w, h, 255, 0, 0, 255);
1508        let fg_b = solid(w, h, 0, 0, 255, 255);
1509
1510        let out = camera_pan_transition(
1511            &bg,
1512            &bg,
1513            &fg_a,
1514            &fg_b,
1515            w,
1516            h,
1517            0.5,
1518            8.0,
1519            0.0,
1520            &EasingType::Linear,
1521            PanBackground::Static,
1522        );
1523        let left_red = out[0];
1524        let right_blue = out[((w - 1) * 4 + 2) as usize];
1525        assert!(left_red > 128, "outgoing plane faded too far: {left_red}");
1526        assert!(
1527            right_blue > 128,
1528            "incoming plane still too faint: {right_blue}"
1529        );
1530    }
1531
1532    fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
1533        (0..width * height).flat_map(|_| [r, g, b, a]).collect()
1534    }
1535
1536    fn transparent(width: u32, height: u32) -> Vec<u8> {
1537        solid(width, height, 0, 0, 0, 0)
1538    }
1539
1540    #[test]
1541    fn static_background_crossfades_instead_of_freezing() {
1542        let (w, h) = (4, 4);
1543        let bg_a = solid(w, h, 10, 10, 10, 255);
1544        let bg_b = solid(w, h, 200, 200, 200, 255);
1545        let fg = transparent(w, h);
1546
1547        let out = camera_pan_transition(
1548            &bg_a,
1549            &bg_b,
1550            &fg,
1551            &fg,
1552            w,
1553            h,
1554            0.5,
1555            0.0,
1556            0.0,
1557            &EasingType::Linear,
1558            PanBackground::Static,
1559        );
1560
1561        for px in out.as_chunks::<4>().0.iter() {
1562            assert_eq!(
1563                *px,
1564                [105, 105, 105, 255],
1565                "mid-pan Static frame must be a blend of bg_a and bg_b, not a copy of either"
1566            );
1567        }
1568        assert_ne!(out, bg_a, "must have moved away from bg_a by the midpoint");
1569        assert_ne!(
1570            out, bg_b,
1571            "must not have already reached bg_b at the midpoint"
1572        );
1573    }
1574
1575    #[test]
1576    fn static_background_reaches_bg_b_exactly_at_full_progress() {
1577        let (w, h) = (4, 4);
1578        let bg_a = solid(w, h, 10, 10, 10, 255);
1579        let bg_b = solid(w, h, 200, 200, 200, 255);
1580        let fg = transparent(w, h);
1581
1582        let out = camera_pan_transition(
1583            &bg_a,
1584            &bg_b,
1585            &fg,
1586            &fg,
1587            w,
1588            h,
1589            1.0,
1590            0.0,
1591            0.0,
1592            &EasingType::Linear,
1593            PanBackground::Static,
1594        );
1595        assert_eq!(out, bg_b, "progress=1.0 must land exactly on bg_b");
1596    }
1597
1598    #[test]
1599    fn travel_background_reaches_bg_b_exactly_at_full_progress() {
1600        let (w, h) = (4, 4);
1601        let bg_a = solid(w, h, 10, 10, 10, 255);
1602        let bg_b = solid(w, h, 200, 200, 200, 255);
1603        let fg = transparent(w, h);
1604
1605        let out = camera_pan_transition(
1606            &bg_a,
1607            &bg_b,
1608            &fg,
1609            &fg,
1610            w,
1611            h,
1612            1.0,
1613            100.0,
1614            0.0,
1615            &EasingType::Linear,
1616            PanBackground::Travel,
1617        );
1618        assert_eq!(
1619            out, bg_b,
1620            "progress=1.0 must land exactly on bg_b under Travel too"
1621        );
1622    }
1623}
1624
1625#[cfg(test)]
1626mod corner_reveal_tests {
1627    use super::*;
1628
1629    #[test]
1630    fn the_anchored_edges_never_move() {
1631        for p in [0.05, 0.3, 0.5, 0.8, 1.0] {
1632            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1633            assert!((r.right - 1920.0).abs() < 1e-3, "right edge moved at {p}");
1634            assert!(r.top.abs() < 1e-3, "top edge moved at {p}");
1635        }
1636    }
1637
1638    #[test]
1639    fn the_travelling_edges_open_from_the_corner() {
1640        let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1641        let (a, b, c) = (at(0.2), at(0.5), at(0.9));
1642        assert!(
1643            a.left > b.left && b.left > c.left,
1644            "left edge must travel left"
1645        );
1646        assert!(
1647            a.bottom < b.bottom && b.bottom < c.bottom,
1648            "bottom must travel down"
1649        );
1650    }
1651
1652    #[test]
1653    fn it_starts_empty_and_ends_full() {
1654        let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0);
1655        assert_eq!((empty.width(), empty.height()), (0.0, 0.0));
1656        let full = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 1.0);
1657        assert_eq!(
1658            (full.left, full.top, full.right, full.bottom),
1659            (0.0, 0.0, 1920.0, 1080.0)
1660        );
1661    }
1662
1663    #[test]
1664    fn every_corner_anchors_its_own_edges() {
1665        let (w, h, p) = (1920.0f32, 1080.0f32, 0.4);
1666        let tl = corner_rect(TransitionCorner::TopLeft, w, h, p);
1667        assert!(tl.left.abs() < 1e-3 && tl.top.abs() < 1e-3);
1668        let br = corner_rect(TransitionCorner::BottomRight, w, h, p);
1669        assert!((br.right - w).abs() < 1e-3 && (br.bottom - h).abs() < 1e-3);
1670        let bl = corner_rect(TransitionCorner::BottomLeft, w, h, p);
1671        assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3);
1672    }
1673
1674    #[test]
1675    fn out_of_range_progress_clamps() {
1676        for p in [-0.5, 1.5] {
1677            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1678            assert!(
1679                r.width() >= 0.0 && r.height() >= 0.0,
1680                "inverted rect at {p}"
1681            );
1682        }
1683    }
1684}
1685
1686#[cfg(test)]
1687mod pixel_dissolve_tests {
1688    use super::*;
1689
1690    #[test]
1691    fn edges_in_turns_the_border_first() {
1692        let (cols, rows) = (40, 24);
1693        let border: Vec<f32> = (0..cols)
1694            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1695            .collect();
1696        let middle: Vec<f32> = (0..cols)
1697            .map(|c| cell_threshold(c, rows / 2, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1698            .collect();
1699        let avg = |v: &Vec<f32>| v.iter().sum::<f32>() / v.len() as f32;
1700        assert!(
1701            avg(&border) < avg(&middle) - 0.15,
1702            "border {:.2} must clearly precede the middle {:.2}",
1703            avg(&border),
1704            avg(&middle)
1705        );
1706        let centre = cell_threshold(
1707            cols / 2,
1708            rows / 2,
1709            cols,
1710            rows,
1711            11,
1712            PixelDissolveOrder::EdgesIn,
1713        );
1714        assert!(centre > 0.6, "the centre cell must be late, got {centre}");
1715    }
1716
1717    #[test]
1718    fn center_out_is_the_mirror_of_edges_in() {
1719        let (cols, rows) = (40, 24);
1720        for (c, r) in [(0, 0), (20, 12), (39, 5)] {
1721            let a = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::EdgesIn);
1722            let b = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::CenterOut);
1723            assert!(
1724                (a + b - (SPATIAL_WEIGHT + 2.0 * (1.0 - SPATIAL_WEIGHT) * cell_hash01(c, r, 11)))
1725                    .abs()
1726                    < 1e-5
1727            );
1728        }
1729    }
1730
1731    #[test]
1732    fn the_front_is_ragged_not_a_closing_rectangle() {
1733        let (cols, rows) = (40, 24);
1734        let top: Vec<f32> = (0..cols)
1735            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1736            .collect();
1737        let spread = top.iter().cloned().fold(f32::MIN, f32::max)
1738            - top.iter().cloned().fold(f32::MAX, f32::min);
1739        assert!(
1740            spread > 0.15,
1741            "the border turns as one block: spread {spread}"
1742        );
1743    }
1744
1745    #[test]
1746    fn random_ignores_position() {
1747        let t = cell_threshold(7, 3, 40, 24, 11, PixelDissolveOrder::Random);
1748        assert_eq!(t, cell_hash01(7, 3, 11));
1749    }
1750
1751    #[test]
1752    fn a_cell_keeps_its_threshold() {
1753        assert_eq!(cell_hash01(4, 9, 11), cell_hash01(4, 9, 11));
1754        let t = cell_hash01(4, 9, 11);
1755        assert!(
1756            (0.0..1.0).contains(&t),
1757            "threshold must be a fraction, got {t}"
1758        );
1759    }
1760
1761    #[test]
1762    fn the_seed_changes_the_order() {
1763        let a: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 11)).collect();
1764        let b: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 12)).collect();
1765        assert_ne!(a, b);
1766    }
1767
1768    #[test]
1769    fn neighbouring_cells_turn_at_unrelated_times() {
1770        let close = (0..30)
1771            .flat_map(|c| (0..30).map(move |r| (c, r)))
1772            .filter(|&(c, r)| (cell_hash01(c, r, 11) - cell_hash01(c + 1, r, 11)).abs() < 0.05)
1773            .count();
1774        assert!(
1775            close < 200,
1776            "{close}/900 neighbours turn together — that is a wipe"
1777        );
1778    }
1779
1780    #[test]
1781    fn midway_the_frame_holds_both_scenes_and_a_fading_band() {
1782        const FEATHER: f32 = 0.35;
1783        let p = 0.5 * (1.0 + FEATHER);
1784        let alphas: Vec<f32> = (0..40)
1785            .flat_map(|c| (0..40).map(move |r| cell_hash01(c, r, 11)))
1786            .map(|t| ((p - t) / FEATHER).clamp(0.0, 1.0))
1787            .collect();
1788        let done = alphas.iter().filter(|&&a| a >= 0.999).count();
1789        let waiting = alphas.iter().filter(|&&a| a <= 0.001).count();
1790        let fading = alphas.iter().filter(|&&a| a > 0.001 && a < 0.999).count();
1791        assert!(done > 100 && waiting > 100, "both states must be present");
1792        assert!(
1793            fading > 50,
1794            "cells must fade, not flip: only {fading} mid-transition"
1795        );
1796    }
1797
1798    #[test]
1799    fn every_cell_completes_by_the_end() {
1800        const FEATHER: f32 = 0.35;
1801        let p = 1.0 * (1.0 + FEATHER);
1802        for c in 0..60 {
1803            for r in 0..60 {
1804                let a = ((p - cell_hash01(c, r, 11)) / FEATHER).clamp(0.0, 1.0);
1805                assert!(
1806                    a >= 0.999,
1807                    "cell ({c},{r}) still at {a} when the transition ends"
1808                );
1809            }
1810        }
1811    }
1812}
1813
1814#[cfg(test)]
1815mod wipe_fixed_edge_tests {
1816    use super::*;
1817
1818    const W: u32 = 1280;
1819    const H: u32 = 720;
1820
1821    fn frames() -> (Vec<u8>, Vec<u8>) {
1822        let a: Vec<u8> = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect();
1823        let b: Vec<u8> = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect();
1824        (a, b)
1825    }
1826
1827    fn pixel(buf: &[u8], x: u32, y: u32) -> [u8; 4] {
1828        let base = ((y * W + x) * 4) as usize;
1829        [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]]
1830    }
1831
1832    #[test]
1833    fn a_wide_feather_and_band_color_do_not_leak_onto_the_three_fixed_edges_of_a_left_wipe() {
1834        let (a, b) = frames();
1835        let opts = TransitionOptions {
1836            feather: 320.0,
1837            band_color: Some("#FF8CC6".to_string()),
1838            ..TransitionOptions::default()
1839        };
1840        let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);
1841
1842        assert_eq!(
1843            pixel(&out, 640, 3),
1844            [40, 40, 40, 255],
1845            "the top edge never moves for a left wipe — a pixel well inside the revealed area \
1846             but close to y=0 must be the exact incoming colour, not softened or tinted pink \
1847             just because it happens to sit near the frame's own fixed top border"
1848        );
1849        assert_eq!(
1850            pixel(&out, 640, H - 4),
1851            [40, 40, 40, 255],
1852            "the bottom edge never moves for a left wipe either — same leak, opposite border"
1853        );
1854        assert_eq!(
1855            pixel(&out, 2, 360),
1856            [40, 40, 40, 255],
1857            "the left edge is the wipe's own starting edge, always flush with the frame's left \
1858             border — it must never show a blended or tinted pixel, only the moving right edge \
1859             may"
1860        );
1861    }
1862
1863    #[test]
1864    fn the_moving_edge_still_feathers_normally_once_the_fixed_edges_are_excluded() {
1865        let (a, b) = frames();
1866        let opts = TransitionOptions {
1867            feather: 320.0,
1868            ..TransitionOptions::default()
1869        };
1870        let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);
1871        let moving_edge_x = (W as f32 * 0.8) as u32;
1872        let blended = pixel(&out, moving_edge_x, 360);
1873        assert!(
1874            blended[0] != 200 && blended[0] != 40,
1875            "the wipe's own moving reveal boundary must still show a blended pixel from a wide \
1876             feather, got {blended:?} — the fixed-edge fix must not have flattened the real \
1877             moving edge along with the fake ones"
1878        );
1879    }
1880}
1881
1882#[cfg(test)]
1883mod iris_pill_shape_tests {
1884    use super::*;
1885
1886    #[test]
1887    fn a_pill_with_aspect_one_degenerates_into_a_true_circle_not_a_barely_rounded_square() {
1888        let origin = (100.0, 100.0);
1889        let radius = 50.0;
1890        let path = iris_mask_path(origin, IrisShape::Pill, 1.0, radius);
1891        let beyond_the_radius_on_the_diagonal = radius * 1.2 / std::f32::consts::SQRT_2;
1892        let corner_point = (
1893            origin.0 + beyond_the_radius_on_the_diagonal,
1894            origin.1 + beyond_the_radius_on_the_diagonal,
1895        );
1896        assert!(
1897            !path.contains(corner_point),
1898            "a pill with aspect 1.0 must degenerate into a circle — a point just beyond the \
1899             radius on the diagonal must fall outside it; the old 0.2 corner fraction left a \
1900             barely-rounded square whose corner reached out to radius*sqrt(2), well past this \
1901             point"
1902        );
1903    }
1904}
1905
1906#[cfg(test)]
1907mod whip_continuity_tests {
1908    use super::*;
1909
1910    fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
1911        (0..width * height).flat_map(|_| [r, g, b, 255]).collect()
1912    }
1913
1914    fn off_centre_stripe(width: u32, height: u32) -> Vec<u8> {
1915        let mut out = Vec::with_capacity((width * height * 4) as usize);
1916        let (x0, x1) = (width * 5 / 8, width * 7 / 8);
1917        for _y in 0..height {
1918            for x in 0..width {
1919                if x >= x0 && x < x1 {
1920                    out.extend_from_slice(&[240, 240, 240, 255]);
1921                } else {
1922                    out.extend_from_slice(&[10, 10, 10, 255]);
1923                }
1924            }
1925        }
1926        out
1927    }
1928
1929    #[test]
1930    fn a_wide_streak_has_no_large_jump_between_adjacent_pixels() {
1931        const W: u32 = 640;
1932        const H: u32 = 48;
1933        let a = off_centre_stripe(W, H);
1934        let b = solid_rgb(W, H, 40, 40, 40);
1935        let out = whip_transition(&a, &b, W, H, 0.5, 6.0, TransitionDirection::Left);
1936
1937        let row = H / 2;
1938        let value_at = |x: u32| -> i32 {
1939            let i = ((row * W + x) * 4) as usize;
1940            out[i] as i32
1941        };
1942
1943        let shift = (0.5 * W as f32) as u32;
1944        let sharp_edges = [W * 5 / 8 - shift, W * 7 / 8 - shift];
1945        let excluded = |x: u32| sharp_edges.iter().any(|&e| x.abs_diff(e) < 20);
1946
1947        let mut max_jump = 0i32;
1948        let mut jump_at = 0u32;
1949        for x in 0..W - 1 {
1950            if excluded(x) || excluded(x + 1) {
1951                continue;
1952            }
1953            let jump = (value_at(x + 1) - value_at(x)).abs();
1954            if jump > max_jump {
1955                max_jump = jump;
1956                jump_at = x;
1957            }
1958        }
1959
1960        assert!(
1961            max_jump < 40,
1962            "the streak must fade continuously across the trail, not in visible discrete \
1963             bands stepping between a handful of stamped copies — the biggest single-pixel \
1964             jump found was {max_jump} at x={jump_at} (row {row})"
1965        );
1966    }
1967}