Skip to main content

rustmotion_core/engine/
transition.rs

1use crate::engine::animator::ease;
2use crate::engine::renderer::{color4f_from_hex, draw_photo, 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    draw_photo(surface.canvas(), img, dest, &Paint::default());
1465    Some(surface_to_pixels(surface, width, height))
1466}
1467
1468#[cfg(test)]
1469mod camera_pan_tests {
1470    use super::*;
1471
1472    #[test]
1473    fn the_foreground_dissolve_is_a_noop_at_both_junctions() {
1474        let (w, h) = (8u32, 4u32);
1475        let bg = solid(w, h, 0, 0, 0, 255);
1476        let fg_a = solid(w, h, 255, 0, 0, 255);
1477        let fg_b = solid(w, h, 0, 0, 255, 255);
1478
1479        for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] {
1480            let out = camera_pan_transition(
1481                &bg,
1482                &bg,
1483                &fg_a,
1484                &fg_b,
1485                w,
1486                h,
1487                progress,
1488                8.0,
1489                0.0,
1490                &EasingType::Linear,
1491                PanBackground::Static,
1492            );
1493            assert_eq!(
1494                &out[0..3],
1495                &expected,
1496                "at progress {progress} the adjacent scene must render untouched",
1497            );
1498        }
1499    }
1500
1501    #[test]
1502    fn mid_pan_both_planes_stay_substantially_visible() {
1503        let (w, h) = (8u32, 4u32);
1504        let bg = solid(w, h, 0, 0, 0, 255);
1505        let fg_a = solid(w, h, 255, 0, 0, 255);
1506        let fg_b = solid(w, h, 0, 0, 255, 255);
1507
1508        let out = camera_pan_transition(
1509            &bg,
1510            &bg,
1511            &fg_a,
1512            &fg_b,
1513            w,
1514            h,
1515            0.5,
1516            8.0,
1517            0.0,
1518            &EasingType::Linear,
1519            PanBackground::Static,
1520        );
1521        let left_red = out[0];
1522        let right_blue = out[((w - 1) * 4 + 2) as usize];
1523        assert!(left_red > 128, "outgoing plane faded too far: {left_red}");
1524        assert!(
1525            right_blue > 128,
1526            "incoming plane still too faint: {right_blue}"
1527        );
1528    }
1529
1530    fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
1531        (0..width * height).flat_map(|_| [r, g, b, a]).collect()
1532    }
1533
1534    fn transparent(width: u32, height: u32) -> Vec<u8> {
1535        solid(width, height, 0, 0, 0, 0)
1536    }
1537
1538    #[test]
1539    fn static_background_crossfades_instead_of_freezing() {
1540        let (w, h) = (4, 4);
1541        let bg_a = solid(w, h, 10, 10, 10, 255);
1542        let bg_b = solid(w, h, 200, 200, 200, 255);
1543        let fg = transparent(w, h);
1544
1545        let out = camera_pan_transition(
1546            &bg_a,
1547            &bg_b,
1548            &fg,
1549            &fg,
1550            w,
1551            h,
1552            0.5,
1553            0.0,
1554            0.0,
1555            &EasingType::Linear,
1556            PanBackground::Static,
1557        );
1558
1559        for px in out.as_chunks::<4>().0.iter() {
1560            assert_eq!(
1561                *px,
1562                [105, 105, 105, 255],
1563                "mid-pan Static frame must be a blend of bg_a and bg_b, not a copy of either"
1564            );
1565        }
1566        assert_ne!(out, bg_a, "must have moved away from bg_a by the midpoint");
1567        assert_ne!(
1568            out, bg_b,
1569            "must not have already reached bg_b at the midpoint"
1570        );
1571    }
1572
1573    #[test]
1574    fn static_background_reaches_bg_b_exactly_at_full_progress() {
1575        let (w, h) = (4, 4);
1576        let bg_a = solid(w, h, 10, 10, 10, 255);
1577        let bg_b = solid(w, h, 200, 200, 200, 255);
1578        let fg = transparent(w, h);
1579
1580        let out = camera_pan_transition(
1581            &bg_a,
1582            &bg_b,
1583            &fg,
1584            &fg,
1585            w,
1586            h,
1587            1.0,
1588            0.0,
1589            0.0,
1590            &EasingType::Linear,
1591            PanBackground::Static,
1592        );
1593        assert_eq!(out, bg_b, "progress=1.0 must land exactly on bg_b");
1594    }
1595
1596    #[test]
1597    fn travel_background_reaches_bg_b_exactly_at_full_progress() {
1598        let (w, h) = (4, 4);
1599        let bg_a = solid(w, h, 10, 10, 10, 255);
1600        let bg_b = solid(w, h, 200, 200, 200, 255);
1601        let fg = transparent(w, h);
1602
1603        let out = camera_pan_transition(
1604            &bg_a,
1605            &bg_b,
1606            &fg,
1607            &fg,
1608            w,
1609            h,
1610            1.0,
1611            100.0,
1612            0.0,
1613            &EasingType::Linear,
1614            PanBackground::Travel,
1615        );
1616        assert_eq!(
1617            out, bg_b,
1618            "progress=1.0 must land exactly on bg_b under Travel too"
1619        );
1620    }
1621}
1622
1623#[cfg(test)]
1624mod corner_reveal_tests {
1625    use super::*;
1626
1627    #[test]
1628    fn the_anchored_edges_never_move() {
1629        for p in [0.05, 0.3, 0.5, 0.8, 1.0] {
1630            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1631            assert!((r.right - 1920.0).abs() < 1e-3, "right edge moved at {p}");
1632            assert!(r.top.abs() < 1e-3, "top edge moved at {p}");
1633        }
1634    }
1635
1636    #[test]
1637    fn the_travelling_edges_open_from_the_corner() {
1638        let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1639        let (a, b, c) = (at(0.2), at(0.5), at(0.9));
1640        assert!(
1641            a.left > b.left && b.left > c.left,
1642            "left edge must travel left"
1643        );
1644        assert!(
1645            a.bottom < b.bottom && b.bottom < c.bottom,
1646            "bottom must travel down"
1647        );
1648    }
1649
1650    #[test]
1651    fn it_starts_empty_and_ends_full() {
1652        let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0);
1653        assert_eq!((empty.width(), empty.height()), (0.0, 0.0));
1654        let full = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 1.0);
1655        assert_eq!(
1656            (full.left, full.top, full.right, full.bottom),
1657            (0.0, 0.0, 1920.0, 1080.0)
1658        );
1659    }
1660
1661    #[test]
1662    fn every_corner_anchors_its_own_edges() {
1663        let (w, h, p) = (1920.0f32, 1080.0f32, 0.4);
1664        let tl = corner_rect(TransitionCorner::TopLeft, w, h, p);
1665        assert!(tl.left.abs() < 1e-3 && tl.top.abs() < 1e-3);
1666        let br = corner_rect(TransitionCorner::BottomRight, w, h, p);
1667        assert!((br.right - w).abs() < 1e-3 && (br.bottom - h).abs() < 1e-3);
1668        let bl = corner_rect(TransitionCorner::BottomLeft, w, h, p);
1669        assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3);
1670    }
1671
1672    #[test]
1673    fn out_of_range_progress_clamps() {
1674        for p in [-0.5, 1.5] {
1675            let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
1676            assert!(
1677                r.width() >= 0.0 && r.height() >= 0.0,
1678                "inverted rect at {p}"
1679            );
1680        }
1681    }
1682}
1683
1684#[cfg(test)]
1685mod pixel_dissolve_tests {
1686    use super::*;
1687
1688    #[test]
1689    fn edges_in_turns_the_border_first() {
1690        let (cols, rows) = (40, 24);
1691        let border: Vec<f32> = (0..cols)
1692            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1693            .collect();
1694        let middle: Vec<f32> = (0..cols)
1695            .map(|c| cell_threshold(c, rows / 2, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1696            .collect();
1697        let avg = |v: &Vec<f32>| v.iter().sum::<f32>() / v.len() as f32;
1698        assert!(
1699            avg(&border) < avg(&middle) - 0.15,
1700            "border {:.2} must clearly precede the middle {:.2}",
1701            avg(&border),
1702            avg(&middle)
1703        );
1704        let centre = cell_threshold(
1705            cols / 2,
1706            rows / 2,
1707            cols,
1708            rows,
1709            11,
1710            PixelDissolveOrder::EdgesIn,
1711        );
1712        assert!(centre > 0.6, "the centre cell must be late, got {centre}");
1713    }
1714
1715    #[test]
1716    fn center_out_is_the_mirror_of_edges_in() {
1717        let (cols, rows) = (40, 24);
1718        for (c, r) in [(0, 0), (20, 12), (39, 5)] {
1719            let a = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::EdgesIn);
1720            let b = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::CenterOut);
1721            assert!(
1722                (a + b - (SPATIAL_WEIGHT + 2.0 * (1.0 - SPATIAL_WEIGHT) * cell_hash01(c, r, 11)))
1723                    .abs()
1724                    < 1e-5
1725            );
1726        }
1727    }
1728
1729    #[test]
1730    fn the_front_is_ragged_not_a_closing_rectangle() {
1731        let (cols, rows) = (40, 24);
1732        let top: Vec<f32> = (0..cols)
1733            .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn))
1734            .collect();
1735        let spread = top.iter().cloned().fold(f32::MIN, f32::max)
1736            - top.iter().cloned().fold(f32::MAX, f32::min);
1737        assert!(
1738            spread > 0.15,
1739            "the border turns as one block: spread {spread}"
1740        );
1741    }
1742
1743    #[test]
1744    fn random_ignores_position() {
1745        let t = cell_threshold(7, 3, 40, 24, 11, PixelDissolveOrder::Random);
1746        assert_eq!(t, cell_hash01(7, 3, 11));
1747    }
1748
1749    #[test]
1750    fn a_cell_keeps_its_threshold() {
1751        assert_eq!(cell_hash01(4, 9, 11), cell_hash01(4, 9, 11));
1752        let t = cell_hash01(4, 9, 11);
1753        assert!(
1754            (0.0..1.0).contains(&t),
1755            "threshold must be a fraction, got {t}"
1756        );
1757    }
1758
1759    #[test]
1760    fn the_seed_changes_the_order() {
1761        let a: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 11)).collect();
1762        let b: Vec<f32> = (0..40).map(|i| cell_hash01(i, 0, 12)).collect();
1763        assert_ne!(a, b);
1764    }
1765
1766    #[test]
1767    fn neighbouring_cells_turn_at_unrelated_times() {
1768        let close = (0..30)
1769            .flat_map(|c| (0..30).map(move |r| (c, r)))
1770            .filter(|&(c, r)| (cell_hash01(c, r, 11) - cell_hash01(c + 1, r, 11)).abs() < 0.05)
1771            .count();
1772        assert!(
1773            close < 200,
1774            "{close}/900 neighbours turn together — that is a wipe"
1775        );
1776    }
1777
1778    #[test]
1779    fn midway_the_frame_holds_both_scenes_and_a_fading_band() {
1780        const FEATHER: f32 = 0.35;
1781        let p = 0.5 * (1.0 + FEATHER);
1782        let alphas: Vec<f32> = (0..40)
1783            .flat_map(|c| (0..40).map(move |r| cell_hash01(c, r, 11)))
1784            .map(|t| ((p - t) / FEATHER).clamp(0.0, 1.0))
1785            .collect();
1786        let done = alphas.iter().filter(|&&a| a >= 0.999).count();
1787        let waiting = alphas.iter().filter(|&&a| a <= 0.001).count();
1788        let fading = alphas.iter().filter(|&&a| a > 0.001 && a < 0.999).count();
1789        assert!(done > 100 && waiting > 100, "both states must be present");
1790        assert!(
1791            fading > 50,
1792            "cells must fade, not flip: only {fading} mid-transition"
1793        );
1794    }
1795
1796    #[test]
1797    fn every_cell_completes_by_the_end() {
1798        const FEATHER: f32 = 0.35;
1799        let p = 1.0 * (1.0 + FEATHER);
1800        for c in 0..60 {
1801            for r in 0..60 {
1802                let a = ((p - cell_hash01(c, r, 11)) / FEATHER).clamp(0.0, 1.0);
1803                assert!(
1804                    a >= 0.999,
1805                    "cell ({c},{r}) still at {a} when the transition ends"
1806                );
1807            }
1808        }
1809    }
1810}
1811
1812#[cfg(test)]
1813mod wipe_fixed_edge_tests {
1814    use super::*;
1815
1816    const W: u32 = 1280;
1817    const H: u32 = 720;
1818
1819    fn frames() -> (Vec<u8>, Vec<u8>) {
1820        let a: Vec<u8> = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect();
1821        let b: Vec<u8> = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect();
1822        (a, b)
1823    }
1824
1825    fn pixel(buf: &[u8], x: u32, y: u32) -> [u8; 4] {
1826        let base = ((y * W + x) * 4) as usize;
1827        [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]]
1828    }
1829
1830    #[test]
1831    fn a_wide_feather_and_band_color_do_not_leak_onto_the_three_fixed_edges_of_a_left_wipe() {
1832        let (a, b) = frames();
1833        let opts = TransitionOptions {
1834            feather: 320.0,
1835            band_color: Some("#FF8CC6".to_string()),
1836            ..TransitionOptions::default()
1837        };
1838        let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);
1839
1840        assert_eq!(
1841            pixel(&out, 640, 3),
1842            [40, 40, 40, 255],
1843            "the top edge never moves for a left wipe — a pixel well inside the revealed area \
1844             but close to y=0 must be the exact incoming colour, not softened or tinted pink \
1845             just because it happens to sit near the frame's own fixed top border"
1846        );
1847        assert_eq!(
1848            pixel(&out, 640, H - 4),
1849            [40, 40, 40, 255],
1850            "the bottom edge never moves for a left wipe either — same leak, opposite border"
1851        );
1852        assert_eq!(
1853            pixel(&out, 2, 360),
1854            [40, 40, 40, 255],
1855            "the left edge is the wipe's own starting edge, always flush with the frame's left \
1856             border — it must never show a blended or tinted pixel, only the moving right edge \
1857             may"
1858        );
1859    }
1860
1861    #[test]
1862    fn the_moving_edge_still_feathers_normally_once_the_fixed_edges_are_excluded() {
1863        let (a, b) = frames();
1864        let opts = TransitionOptions {
1865            feather: 320.0,
1866            ..TransitionOptions::default()
1867        };
1868        let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);
1869        let moving_edge_x = (W as f32 * 0.8) as u32;
1870        let blended = pixel(&out, moving_edge_x, 360);
1871        assert!(
1872            blended[0] != 200 && blended[0] != 40,
1873            "the wipe's own moving reveal boundary must still show a blended pixel from a wide \
1874             feather, got {blended:?} — the fixed-edge fix must not have flattened the real \
1875             moving edge along with the fake ones"
1876        );
1877    }
1878}
1879
1880#[cfg(test)]
1881mod iris_pill_shape_tests {
1882    use super::*;
1883
1884    #[test]
1885    fn a_pill_with_aspect_one_degenerates_into_a_true_circle_not_a_barely_rounded_square() {
1886        let origin = (100.0, 100.0);
1887        let radius = 50.0;
1888        let path = iris_mask_path(origin, IrisShape::Pill, 1.0, radius);
1889        let beyond_the_radius_on_the_diagonal = radius * 1.2 / std::f32::consts::SQRT_2;
1890        let corner_point = (
1891            origin.0 + beyond_the_radius_on_the_diagonal,
1892            origin.1 + beyond_the_radius_on_the_diagonal,
1893        );
1894        assert!(
1895            !path.contains(corner_point),
1896            "a pill with aspect 1.0 must degenerate into a circle — a point just beyond the \
1897             radius on the diagonal must fall outside it; the old 0.2 corner fraction left a \
1898             barely-rounded square whose corner reached out to radius*sqrt(2), well past this \
1899             point"
1900        );
1901    }
1902}
1903
1904#[cfg(test)]
1905mod whip_continuity_tests {
1906    use super::*;
1907
1908    fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
1909        (0..width * height).flat_map(|_| [r, g, b, 255]).collect()
1910    }
1911
1912    fn off_centre_stripe(width: u32, height: u32) -> Vec<u8> {
1913        let mut out = Vec::with_capacity((width * height * 4) as usize);
1914        let (x0, x1) = (width * 5 / 8, width * 7 / 8);
1915        for _y in 0..height {
1916            for x in 0..width {
1917                if x >= x0 && x < x1 {
1918                    out.extend_from_slice(&[240, 240, 240, 255]);
1919                } else {
1920                    out.extend_from_slice(&[10, 10, 10, 255]);
1921                }
1922            }
1923        }
1924        out
1925    }
1926
1927    #[test]
1928    fn a_wide_streak_has_no_large_jump_between_adjacent_pixels() {
1929        const W: u32 = 640;
1930        const H: u32 = 48;
1931        let a = off_centre_stripe(W, H);
1932        let b = solid_rgb(W, H, 40, 40, 40);
1933        let out = whip_transition(&a, &b, W, H, 0.5, 6.0, TransitionDirection::Left);
1934
1935        let row = H / 2;
1936        let value_at = |x: u32| -> i32 {
1937            let i = ((row * W + x) * 4) as usize;
1938            out[i] as i32
1939        };
1940
1941        let shift = (0.5 * W as f32) as u32;
1942        let sharp_edges = [W * 5 / 8 - shift, W * 7 / 8 - shift];
1943        let excluded = |x: u32| sharp_edges.iter().any(|&e| x.abs_diff(e) < 20);
1944
1945        let mut max_jump = 0i32;
1946        let mut jump_at = 0u32;
1947        for x in 0..W - 1 {
1948            if excluded(x) || excluded(x + 1) {
1949                continue;
1950            }
1951            let jump = (value_at(x + 1) - value_at(x)).abs();
1952            if jump > max_jump {
1953                max_jump = jump;
1954                jump_at = x;
1955            }
1956        }
1957
1958        assert!(
1959            max_jump < 40,
1960            "the streak must fade continuously across the trail, not in visible discrete \
1961             bands stepping between a handful of stamped copies — the biggest single-pixel \
1962             jump found was {max_jump} at x={jump_at} (row {row})"
1963        );
1964    }
1965}