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}