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}