1use crate::schema::{
2 Animation, AnimationEffect, AnimationPreset, CharAnimPreset, EasingType, GlowConfig, Keyframe,
3 KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimDirection,
4 TextAnimGranularity, WiggleConfig,
5};
6
7pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0;
14
15#[inline]
20pub fn safe_div(num: f64, denom: f64, fallback: f64) -> f64 {
21 if denom.abs() < 1e-9 {
22 fallback
23 } else {
24 num / denom
25 }
26}
27
28#[inline]
30pub fn safe_div_f32(num: f32, denom: f32, fallback: f32) -> f32 {
31 if denom.abs() < 1e-6 {
32 fallback
33 } else {
34 num / denom
35 }
36}
37
38#[derive(Debug, Clone)]
42pub struct ResolvedCharAnimation {
43 pub preset: CharAnimPreset,
44 pub granularity: TextAnimGranularity,
45 pub stagger: f32,
46 pub duration: f32,
47 pub easing: EasingType,
48 pub delay: f32,
49 pub overshoot: f32,
50 pub blur: f32,
52 pub direction: TextAnimDirection,
54 pub distance: f32,
56 pub scale_from: Option<f32>,
58 pub jitter: f32,
60 pub seed: u32,
62 pub ink_from: Option<String>,
64}
65
66impl ResolvedCharAnimation {
67 pub fn unit_start(&self, idx: usize) -> f64 {
78 let even = self.delay as f64 + idx as f64 * self.stagger as f64;
79 if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 {
80 return even;
81 }
82 let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (self.seed as u64);
85 h ^= h >> 30;
86 h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
87 h ^= h >> 27;
88 h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
89 h ^= h >> 31;
90 let unit = (h >> 11) as f64 / (1u64 << 53) as f64; let nudge = (unit * 2.0 - 1.0) * self.jitter as f64 * self.stagger as f64;
92 (even + nudge).max(self.delay as f64)
93 }
94}
95
96pub struct ExtractedEffects<'a> {
98 pub presets: Vec<(AnimationPreset, PresetConfig)>,
99 pub keyframe_animations: Vec<Animation>,
109 pub keyframes_loop: bool,
116 pub wiggles: Vec<&'a WiggleConfig>,
117 pub orbits: Vec<&'a OrbitConfig>,
118 pub motion_paths: Vec<&'a MotionPathConfig>,
124 pub glow: Option<&'a GlowConfig>,
125 pub motion_blur: Option<f32>,
126 pub char_animation: Option<ResolvedCharAnimation>,
127}
128
129pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> {
140 effects.iter().find_map(|e| match e {
141 AnimationEffect::Glow(cfg) => Some(cfg),
142 _ => None,
143 })
144}
145
146pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> {
148 let mut result = ExtractedEffects {
149 presets: Vec::new(),
150 keyframe_animations: Vec::new(),
151 keyframes_loop: false,
152 wiggles: Vec::new(),
153 orbits: Vec::new(),
154 motion_paths: Vec::new(),
155 glow: None,
156 motion_blur: None,
157 char_animation: None,
158 };
159
160 for effect in effects {
161 if let Some((preset, timing)) = effect.as_preset() {
162 result.presets.push((preset, timing.to_preset_config()));
163 } else {
164 match effect {
165 AnimationEffect::CharScaleIn(t)
166 | AnimationEffect::CharFadeIn(t)
167 | AnimationEffect::CharWave(t)
168 | AnimationEffect::CharBounce(t)
169 | AnimationEffect::CharRotateIn(t)
170 | AnimationEffect::CharSlideUp(t)
171 | AnimationEffect::CharBlurIn(t) => {
172 let preset = match effect {
173 AnimationEffect::CharScaleIn(_) => CharAnimPreset::ScaleIn,
174 AnimationEffect::CharFadeIn(_) => CharAnimPreset::FadeIn,
175 AnimationEffect::CharWave(_) => CharAnimPreset::Wave,
176 AnimationEffect::CharBounce(_) => CharAnimPreset::Bounce,
177 AnimationEffect::CharRotateIn(_) => CharAnimPreset::RotateIn,
178 AnimationEffect::CharSlideUp(_) => CharAnimPreset::SlideUp,
179 AnimationEffect::CharBlurIn(_) => CharAnimPreset::BlurIn,
185 _ => unreachable!(),
186 };
187 result.char_animation = Some(ResolvedCharAnimation {
188 preset,
189 granularity: t.granularity.clone(),
190 stagger: t.stagger as f32,
191 duration: t.duration as f32,
192 easing: t.easing.clone(),
193 delay: t.delay as f32,
194 overshoot: t.overshoot.unwrap_or(0.08) as f32,
195 blur: t.blur.map(|b| b as f32).unwrap_or(DEFAULT_CHAR_BLUR_SIGMA),
196 direction: t.direction,
197 distance: t.distance.unwrap_or(1.0) as f32,
198 scale_from: t.scale_from.map(|s| s as f32),
199 jitter: t.jitter.unwrap_or(0.0) as f32,
200 seed: t.seed.unwrap_or(0),
201 ink_from: t.ink_from.clone(),
202 });
203 }
204 AnimationEffect::Glow(config) => {
205 result.glow = Some(config);
206 }
207 AnimationEffect::Wiggle(config) => {
208 result.wiggles.push(config);
209 }
210 AnimationEffect::Orbit(config) => {
211 result.orbits.push(config);
212 }
213 AnimationEffect::Keyframes(config) => {
214 result
221 .keyframe_animations
222 .extend(config.keyframes.iter().map(|anim| {
223 let mut a = anim.clone();
224 for kf in &mut a.keyframes {
225 kf.time += config.delay;
226 }
227 a
228 }));
229 if config.repeat {
230 result.keyframes_loop = true;
231 }
232 }
233 AnimationEffect::TiltIn(config) => {
234 let delay = config.delay;
235 let end = delay + config.duration;
236 let rx = config.rotate_x.unwrap_or(15.0);
237 let ry = config.rotate_y.unwrap_or(-15.0);
238 let persp = config.perspective.unwrap_or(1000.0);
239 let sc = config.scale_from.unwrap_or(0.9);
240 result.keyframe_animations.extend([
241 kf_anim(
242 "opacity",
243 delay,
244 0.0,
245 delay + config.duration * 0.3,
246 1.0,
247 EasingType::EaseOut,
248 ),
249 kf_anim("rotate_x", delay, rx, end, 0.0, EasingType::EaseOutCubic),
250 kf_anim("rotate_y", delay, ry, end, 0.0, EasingType::EaseOutCubic),
251 kf_anim("perspective", delay, persp, end, persp, EasingType::Linear),
252 kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic),
253 ]);
254 if config.repeat {
255 result.keyframes_loop = true;
256 }
257 }
258 AnimationEffect::MotionBlur(config) => {
259 result.motion_blur = Some(config.intensity);
260 }
261 AnimationEffect::MotionPath(config) => {
262 result.motion_paths.push(config);
263 }
264 _ => {} }
266 }
267 }
268
269 result
270}
271
272pub fn ease(t: f64, easing: &EasingType) -> f64 {
276 let t = t.clamp(0.0, 1.0);
277 match easing {
278 EasingType::Linear => t,
279 EasingType::EaseIn => ease_in_cubic(t),
280 EasingType::EaseOut => ease_out_cubic(t),
281 EasingType::EaseInOut => ease_in_out_cubic(t),
282 EasingType::EaseInQuad => t * t,
283 EasingType::EaseOutQuad => 1.0 - (1.0 - t) * (1.0 - t),
284 EasingType::EaseInCubic => ease_in_cubic(t),
285 EasingType::EaseOutCubic => ease_out_cubic(t),
286 EasingType::EaseInExpo => {
287 if t == 0.0 {
288 0.0
289 } else {
290 (2.0f64).powf(10.0 * (t - 1.0))
291 }
292 }
293 EasingType::EaseOutExpo => {
294 if t == 1.0 {
295 1.0
296 } else {
297 1.0 - (2.0f64).powf(-10.0 * t)
298 }
299 }
300 EasingType::EaseInOutQuad => {
301 if t < 0.5 {
302 2.0 * t * t
303 } else {
304 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
305 }
306 }
307 EasingType::EaseInOutExpo => {
308 if t == 0.0 {
309 0.0
310 } else if t == 1.0 {
311 1.0
312 } else if t < 0.5 {
313 (2.0f64).powf(20.0 * t - 10.0) / 2.0
314 } else {
315 (2.0 - (2.0f64).powf(-20.0 * t + 10.0)) / 2.0
316 }
317 }
318 EasingType::EaseInBack => {
319 let c1 = 1.70158;
320 let c3 = c1 + 1.0;
321 c3 * t * t * t - c1 * t * t
322 }
323 EasingType::EaseOutBack => {
324 let c1 = 1.70158;
325 let c3 = c1 + 1.0;
326 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
327 }
328 EasingType::EaseOutElastic => {
329 if t == 0.0 {
330 0.0
331 } else if t == 1.0 {
332 1.0
333 } else {
334 let c4 = (2.0 * std::f64::consts::PI) / 3.0;
335 (2.0f64).powf(-10.0 * t) * ((t * 10.0 - 0.75) * c4).sin() + 1.0
336 }
337 }
338 EasingType::Bounce => bounce_ease_out(t),
339 EasingType::Spring => t, EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2),
341 }
342}
343
344fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
347 let t_curve = find_bezier_t_for_x(t, x1, x2);
350 bezier_component(t_curve, y1, y2)
351}
352
353fn bezier_component(t: f64, p1: f64, p2: f64) -> f64 {
354 let t2 = t * t;
356 let t3 = t2 * t;
357 let mt = 1.0 - t;
358 let mt2 = mt * mt;
359 3.0 * mt2 * t * p1 + 3.0 * mt * t2 * p2 + t3
360}
361
362fn bezier_component_derivative(t: f64, p1: f64, p2: f64) -> f64 {
363 let mt = 1.0 - t;
364 3.0 * mt * mt * p1 + 6.0 * mt * t * (p2 - p1) + 3.0 * t * t * (1.0 - p2)
365}
366
367fn find_bezier_t_for_x(x: f64, x1: f64, x2: f64) -> f64 {
368 let mut t = x; for _ in 0..8 {
371 let current_x = bezier_component(t, x1, x2);
372 let dx = bezier_component_derivative(t, x1, x2);
373 if dx.abs() < 1e-10 {
374 break;
375 }
376 t -= (current_x - x) / dx;
377 t = t.clamp(0.0, 1.0);
378 }
379 t
380}
381
382fn bounce_ease_out(t: f64) -> f64 {
383 let n1 = 7.5625;
384 let d1 = 2.75;
385 if t < 1.0 / d1 {
386 n1 * t * t
387 } else if t < 2.0 / d1 {
388 let t = t - 1.5 / d1;
389 n1 * t * t + 0.75
390 } else if t < 2.5 / d1 {
391 let t = t - 2.25 / d1;
392 n1 * t * t + 0.9375
393 } else {
394 let t = t - 2.625 / d1;
395 n1 * t * t + 0.984375
396 }
397}
398
399fn ease_in_cubic(t: f64) -> f64 {
400 t * t * t
401}
402
403fn ease_out_cubic(t: f64) -> f64 {
404 1.0 - (1.0 - t).powi(3)
405}
406
407fn ease_in_out_cubic(t: f64) -> f64 {
408 if t < 0.5 {
409 4.0 * t * t * t
410 } else {
411 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
412 }
413}
414
415pub const DEFAULT_SPRING_REST_THRESHOLD: f64 = 0.005;
424
425pub const MAX_SPRING_SEARCH_SECONDS: f64 = 30.0;
436
437pub fn spring_value(t: f64, config: &SpringConfig) -> f64 {
463 let damping = config.damping.max(0.0);
464 let stiffness = config.stiffness.max(1e-6);
465 let mass = config.mass.max(1e-6);
466
467 match config.duration {
468 Some(duration) if duration > 0.0 => {
469 let threshold = spring_rest_threshold(config);
470 let natural_rest = spring_settle_time(
471 damping,
472 stiffness,
473 mass,
474 threshold,
475 MAX_SPRING_SEARCH_SECONDS,
476 );
477 if natural_rest < 1e-9 {
478 spring_value_raw(t, damping, stiffness, mass)
482 } else {
483 let time_scale = natural_rest / duration;
484 spring_value_raw(t * time_scale, damping, stiffness, mass)
485 }
486 }
487 _ => spring_value_raw(t, damping, stiffness, mass),
488 }
489}
490
491fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 {
496 let omega = (stiffness / mass).sqrt();
497 let zeta = damping / (2.0 * (stiffness * mass).sqrt());
498
499 if zeta < 1.0 {
500 let omega_d = omega * (1.0 - zeta * zeta).sqrt();
502 let decay = (-zeta * omega * t).exp();
503 1.0 - decay
504 * ((zeta * omega * t / omega_d).sin() * (zeta * omega / omega_d) + (omega_d * t).cos())
505 } else if (zeta - 1.0).abs() < 1e-6 {
506 let decay = (-omega * t).exp();
508 1.0 - decay * (1.0 + omega * t)
509 } else {
510 let s1 = -omega * (zeta - (zeta * zeta - 1.0).sqrt());
512 let s2 = -omega * (zeta + (zeta * zeta - 1.0).sqrt());
513 let c2 = -s1 / (s2 - s1);
514 let c1 = 1.0 - c2;
515 1.0 - (c1 * (s1 * t).exp() + c2 * (s2 * t).exp())
516 }
517}
518
519const SPRING_SETTLE_MIN_SAMPLES: usize = 2_000;
524const SPRING_SETTLE_MAX_SAMPLES: usize = 20_000;
528const SPRING_SETTLE_SAMPLES_PER_PERIOD: f64 = 48.0;
537
538fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, max_t: f64) -> f64 {
555 let threshold = threshold.max(1e-9);
556 let omega = (stiffness / mass).sqrt();
557 let period = if omega > 1e-9 {
558 std::f64::consts::TAU / omega
559 } else {
560 max_t
561 };
562 let desired_steps = (max_t / (period / SPRING_SETTLE_SAMPLES_PER_PERIOD)).ceil() as usize;
563 let steps = desired_steps.clamp(SPRING_SETTLE_MIN_SAMPLES, SPRING_SETTLE_MAX_SAMPLES);
564 let dt = max_t / steps as f64;
565
566 let mut last_exceed_idx: usize = 0;
567 for i in 0..=steps {
568 let t = i as f64 * dt;
569 if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
570 last_exceed_idx = i;
571 }
572 }
573
574 if last_exceed_idx >= steps {
575 return max_t;
577 }
578
579 let mut lo = last_exceed_idx as f64 * dt;
583 let mut hi = (lo + dt).min(max_t);
584 for _ in 0..40 {
585 let mid = 0.5 * (lo + hi);
586 if (spring_value_raw(mid, damping, stiffness, mass) - 1.0).abs() > threshold {
587 lo = mid;
588 } else {
589 hi = mid;
590 }
591 }
592 hi
593}
594
595fn spring_rest_threshold(config: &SpringConfig) -> f64 {
603 config
604 .rest_threshold
605 .unwrap_or(DEFAULT_SPRING_REST_THRESHOLD)
606 .max(1e-9)
607}
608
609pub fn spring_rest_time(config: &SpringConfig) -> f64 {
620 match config.duration {
621 Some(d) if d > 0.0 => d,
622 _ => {
623 let damping = config.damping.max(0.0);
624 let stiffness = config.stiffness.max(1e-6);
625 let mass = config.mass.max(1e-6);
626 let threshold = spring_rest_threshold(config);
627 spring_settle_time(
628 damping,
629 stiffness,
630 mass,
631 threshold,
632 MAX_SPRING_SEARCH_SECONDS,
633 )
634 }
635 }
636}
637
638#[derive(Debug, Clone)]
642pub struct AnimatedProperties {
643 pub opacity: f32,
644 pub translate_x: f32,
645 pub translate_y: f32,
646 pub scale_x: f32,
647 pub scale_y: f32,
648 pub rotation: f32,
649 pub blur: f32,
650 pub visible_chars: i32,
652 pub visible_chars_progress: f32,
654 pub color: Option<String>,
656 pub border_radius: f32,
658 pub font_size: f32,
659 pub width: f32,
660 pub height: f32,
661 pub gap: f32,
662 pub padding: f32,
663 pub stroke_width: f32,
664 pub shadow_blur: f32,
665 pub glow_radius: f32,
666 pub glow_intensity: f32,
667 pub rotate_x: f32,
669 pub rotate_y: f32,
670 pub perspective: f32,
671 pub draw_progress: f32,
673 pub motion_progress: f32,
675 pub char_animation: Option<ResolvedCharAnimation>,
677}
678
679impl Default for AnimatedProperties {
680 fn default() -> Self {
681 Self {
682 opacity: 1.0,
683 translate_x: 0.0,
684 translate_y: 0.0,
685 scale_x: 1.0,
686 scale_y: 1.0,
687 rotation: 0.0,
688 blur: 0.0,
689 visible_chars: -1,
690 visible_chars_progress: -1.0,
691 color: None,
692 border_radius: -1.0,
693 font_size: -1.0,
694 width: -1.0,
695 height: -1.0,
696 gap: -1.0,
697 padding: -1.0,
698 stroke_width: -1.0,
699 shadow_blur: -1.0,
700 glow_radius: -1.0,
701 glow_intensity: -1.0,
702 rotate_x: 0.0,
703 rotate_y: 0.0,
704 perspective: -1.0,
705 draw_progress: -1.0,
706 motion_progress: -1.0,
707 char_animation: None,
708 }
709 }
710}
711
712impl AnimatedProperties {
713 pub fn merge(&mut self, other: &AnimatedProperties) {
716 if (other.opacity - 1.0).abs() > 0.001 {
719 self.opacity *= other.opacity;
720 }
721 if other.translate_x.abs() > 0.001 {
722 self.translate_x += other.translate_x;
723 }
724 if other.translate_y.abs() > 0.001 {
725 self.translate_y += other.translate_y;
726 }
727 if (other.scale_x - 1.0).abs() > 0.001 {
728 self.scale_x *= other.scale_x;
729 }
730 if (other.scale_y - 1.0).abs() > 0.001 {
731 self.scale_y *= other.scale_y;
732 }
733 if other.rotation.abs() > 0.01 {
734 self.rotation += other.rotation;
735 }
736 if other.blur > 0.001 {
737 self.blur = other.blur;
738 }
739 if other.visible_chars >= 0 {
740 self.visible_chars = other.visible_chars;
741 }
742 if other.visible_chars_progress >= 0.0 {
743 self.visible_chars_progress = other.visible_chars_progress;
744 }
745 if other.color.is_some() {
746 self.color = other.color.clone();
747 }
748 if other.border_radius >= 0.0 {
750 self.border_radius = other.border_radius;
751 }
752 if other.font_size >= 0.0 {
753 self.font_size = other.font_size;
754 }
755 if other.width >= 0.0 {
756 self.width = other.width;
757 }
758 if other.height >= 0.0 {
759 self.height = other.height;
760 }
761 if other.gap >= 0.0 {
762 self.gap = other.gap;
763 }
764 if other.padding >= 0.0 {
765 self.padding = other.padding;
766 }
767 if other.stroke_width >= 0.0 {
768 self.stroke_width = other.stroke_width;
769 }
770 if other.shadow_blur >= 0.0 {
771 self.shadow_blur = other.shadow_blur;
772 }
773 if other.glow_radius >= 0.0 {
774 self.glow_radius = other.glow_radius;
775 }
776 if other.glow_intensity >= 0.0 {
777 self.glow_intensity = other.glow_intensity;
778 }
779 if other.rotate_x.abs() > 0.01 {
781 self.rotate_x += other.rotate_x;
782 }
783 if other.rotate_y.abs() > 0.01 {
784 self.rotate_y += other.rotate_y;
785 }
786 if other.perspective >= 0.0 {
787 self.perspective = other.perspective;
788 }
789 if other.draw_progress >= 0.0 {
790 self.draw_progress = other.draw_progress;
791 }
792 if other.motion_progress >= 0.0 {
793 self.motion_progress = other.motion_progress;
794 }
795 if other.char_animation.is_some() {
796 self.char_animation = other.char_animation.clone();
797 }
798 }
799}
800
801pub fn resolve_props_for_effects(
808 effects: &[AnimationEffect],
809 time: f64,
810 scene_duration: f64,
811) -> AnimatedProperties {
812 let mut props = AnimatedProperties::default();
813 if effects.is_empty() {
814 return props;
815 }
816 let extracted = extract_effects(effects);
817
818 for (preset, preset_config) in &extracted.presets {
819 let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration);
820 props.merge(&p);
821 }
822 if !extracted.keyframe_animations.is_empty() {
832 let loop_cfg = PresetConfig {
833 repeat: extracted.keyframes_loop,
834 ..Default::default()
835 };
836 let kp = resolve_animations(
837 &extracted.keyframe_animations,
838 None,
839 Some(&loop_cfg),
840 time,
841 scene_duration,
842 );
843 props.merge(&kp);
844 }
845 if !extracted.wiggles.is_empty() {
846 let wiggles: Vec<_> = extracted.wiggles.iter().copied().cloned().collect();
847 apply_wiggles(&mut props, &wiggles, time);
848 }
849 if !extracted.orbits.is_empty() {
850 let orbits: Vec<_> = extracted.orbits.iter().copied().cloned().collect();
851 apply_orbits(&mut props, &orbits, time);
852 }
853 if !extracted.motion_paths.is_empty() {
854 let motion_paths: Vec<_> = extracted.motion_paths.iter().copied().cloned().collect();
855 apply_motion_paths(&mut props, &motion_paths, time);
856 }
857 if extracted.char_animation.is_some() {
858 props.char_animation = extracted.char_animation;
859 }
860 props
861}
862
863pub fn resolve_animations(
865 animations: &[Animation],
866 preset: Option<&AnimationPreset>,
867 preset_config: Option<&PresetConfig>,
868 time: f64,
869 scene_duration: f64,
870) -> AnimatedProperties {
871 let mut props = AnimatedProperties::default();
872
873 let config = preset_config.cloned().unwrap_or_default();
874 let should_loop = config.repeat;
875
876 let preset_animations = preset.map(|p| expand_preset(p, &config, scene_duration));
878
879 let all_animations: Vec<&Animation> = preset_animations
881 .as_ref()
882 .map(|pa| pa.iter().collect::<Vec<_>>())
883 .unwrap_or_default()
884 .into_iter()
885 .chain(animations.iter())
886 .collect();
887
888 for anim in all_animations {
889 let anim_time = if should_loop {
890 loop_time(anim, time)
891 } else {
892 time
893 };
894 let resolved = resolve_animation_value_full(anim, anim_time);
895 match resolved {
896 ResolvedValue::Number(value) => apply_property(&mut props, &anim.property, value),
897 ResolvedValue::Color(color) => {
898 if anim.property == "color" {
899 props.color = Some(color);
900 }
901 }
902 }
903 }
904
905 props
906}
907
908fn loop_time(anim: &Animation, time: f64) -> f64 {
910 let keyframes = &anim.keyframes;
911 if keyframes.len() < 2 {
912 return time;
913 }
914 let start = keyframes.first().unwrap().time;
915 let end = keyframes.last().unwrap().time;
916 let duration = end - start;
917 if duration < 1e-9 || time < start {
918 return time;
919 }
920 start + ((time - start) % duration)
921}
922
923enum ResolvedValue {
925 Number(f64),
926 Color(String),
927}
928
929pub fn resolve_keyframe_track(anim: &Animation, time: f64) -> KeyframeValue {
945 match resolve_animation_value_full(anim, time) {
946 ResolvedValue::Number(n) => KeyframeValue::Number(n),
947 ResolvedValue::Color(c) => KeyframeValue::Color(c),
948 }
949}
950
951fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue {
952 let keyframes = &anim.keyframes;
953 if keyframes.is_empty() {
954 return ResolvedValue::Number(0.0);
955 }
956 if keyframes.len() == 1 {
957 return match &keyframes[0].value {
958 KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
959 KeyframeValue::Number(n) => ResolvedValue::Number(*n),
960 };
961 }
962
963 if time <= keyframes[0].time {
964 return match &keyframes[0].value {
965 KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
966 KeyframeValue::Number(n) => ResolvedValue::Number(*n),
967 };
968 }
969 if time >= keyframes.last().unwrap().time {
970 return match &keyframes.last().unwrap().value {
971 KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
972 KeyframeValue::Number(n) => ResolvedValue::Number(*n),
973 };
974 }
975
976 for i in 0..keyframes.len() - 1 {
977 let kf0 = &keyframes[i];
978 let kf1 = &keyframes[i + 1];
979
980 if time >= kf0.time && time <= kf1.time {
981 let segment_duration = kf1.time - kf0.time;
982 if segment_duration < 1e-9 {
983 return match &kf1.value {
984 KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
985 KeyframeValue::Number(n) => ResolvedValue::Number(*n),
986 };
987 }
988
989 let local_t = (time - kf0.time) / segment_duration;
990
991 let segment_easing = kf0.easing.as_ref().unwrap_or(&anim.easing);
993
994 let progress = match segment_easing {
995 EasingType::Spring => {
996 let spring_config = anim.spring.clone().unwrap_or_default();
997 spring_value(local_t * segment_duration, &spring_config)
998 }
999 other => ease(local_t, other),
1000 };
1001
1002 if let (KeyframeValue::Color(c0), KeyframeValue::Color(c1)) = (&kf0.value, &kf1.value) {
1004 return ResolvedValue::Color(lerp_color(c0, c1, progress));
1005 }
1006
1007 let v0 = kf0.value.as_f64();
1008 let v1 = kf1.value.as_f64();
1009 return ResolvedValue::Number(v0 + (v1 - v0) * progress);
1010 }
1011 }
1012
1013 match &keyframes.last().unwrap().value {
1014 KeyframeValue::Color(c) => ResolvedValue::Color(c.clone()),
1015 KeyframeValue::Number(n) => ResolvedValue::Number(*n),
1016 }
1017}
1018
1019fn parse_hex_components(hex: &str) -> (f64, f64, f64, f64) {
1021 let (r, g, b, a) = super::renderer::parse_hex_color(hex);
1022 (r as f64, g as f64, b as f64, a as f64)
1023}
1024
1025pub fn lerp_color(c1: &str, c2: &str, t: f64) -> String {
1027 let (r1, g1, b1, a1) = parse_hex_components(c1);
1028 let (r2, g2, b2, a2) = parse_hex_components(c2);
1029 let r = (r1 + (r2 - r1) * t).clamp(0.0, 255.0) as u8;
1030 let g = (g1 + (g2 - g1) * t).clamp(0.0, 255.0) as u8;
1031 let b = (b1 + (b2 - b1) * t).clamp(0.0, 255.0) as u8;
1032 let a = (a1 + (a2 - a1) * t).clamp(0.0, 255.0) as u8;
1033 if a == 255 {
1034 format!("#{:02X}{:02X}{:02X}", r, g, b)
1035 } else {
1036 format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
1037 }
1038}
1039
1040fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) {
1041 match property {
1042 "opacity" => props.opacity = value as f32,
1043 "position.x" | "translate_x" => props.translate_x = value as f32,
1044 "position.y" | "translate_y" => props.translate_y = value as f32,
1045 "scale" => {
1046 props.scale_x = value as f32;
1047 props.scale_y = value as f32;
1048 }
1049 "scale.x" => props.scale_x = value as f32,
1050 "scale.y" => props.scale_y = value as f32,
1051 "rotation" => props.rotation = value as f32,
1052 "blur" => props.blur = value as f32,
1053 "visible_chars" => props.visible_chars = value as i32,
1054 "visible_chars_progress" => props.visible_chars_progress = value as f32,
1055 "border_radius" => props.border_radius = value as f32,
1056 "font_size" => props.font_size = value as f32,
1057 "width" => props.width = value as f32,
1058 "height" => props.height = value as f32,
1059 "gap" => props.gap = value as f32,
1060 "padding" => props.padding = value as f32,
1061 "stroke_width" => props.stroke_width = value as f32,
1062 "shadow_blur" => props.shadow_blur = value as f32,
1063 "glow_radius" => props.glow_radius = value as f32,
1064 "glow_intensity" => props.glow_intensity = value as f32,
1065 "rotate_x" => props.rotate_x = value as f32,
1066 "rotate_y" => props.rotate_y = value as f32,
1067 "perspective" => props.perspective = value as f32,
1068 "draw_progress" => props.draw_progress = value as f32,
1069 "motion_progress" => props.motion_progress = value as f32,
1070 _ => {} }
1072}
1073
1074fn simplex_noise_1d(x: f64, seed: u64) -> f64 {
1089 use std::f64::consts::TAU;
1090 let s = seed as f64;
1091
1092 (x * TAU + s * 0.1234).sin() * 0.6
1093 + (x * TAU * 1.7 + s * 0.5678).sin() * 0.3
1094 + (x * TAU * 2.9 + s * 0.9012).sin() * 0.1 }
1096
1097fn simplex_noise_1d_ext(x: f64, seed: u64, octaves: u32) -> f64 {
1099 use std::f64::consts::TAU;
1100 let s = seed as f64;
1101 let mut value = 0.0;
1102 let mut amplitude = 0.5;
1103 let mut total_amplitude = 0.0;
1104 for i in 0..octaves {
1105 let freq = 1.0 + i as f64 * 1.3;
1106 let phase_offset = s * (0.1234 + i as f64 * 0.4444);
1107 value += (x * TAU * freq + phase_offset).sin() * amplitude;
1108 total_amplitude += amplitude;
1109 amplitude *= 0.5;
1110 }
1111 if total_amplitude > 0.0 {
1112 value / total_amplitude
1113 } else {
1114 0.0
1115 }
1116}
1117
1118pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], time: f64) {
1120 for wiggle in wiggles {
1121 let has_extras = wiggle.octaves.is_some()
1122 || wiggle.phase.is_some()
1123 || wiggle.decay.is_some()
1124 || wiggle.easing.is_some();
1125
1126 let phase = wiggle.phase.unwrap_or(0.0);
1127 let input = time * wiggle.frequency + phase;
1128
1129 let is_sine = wiggle.mode.as_deref() == Some("sine");
1130
1131 let mut noise_val = if is_sine {
1132 input.sin()
1133 } else if has_extras {
1134 let octaves = wiggle.octaves.unwrap_or(3);
1135 simplex_noise_1d_ext(input, wiggle.seed, octaves)
1136 } else {
1137 simplex_noise_1d(input, wiggle.seed)
1138 };
1139
1140 if let Some(ref easing) = wiggle.easing {
1142 let normalized = (noise_val + 1.0) * 0.5;
1143 let eased = ease(normalized, easing);
1144 noise_val = eased * 2.0 - 1.0;
1145 }
1146
1147 let mut amp = wiggle.amplitude;
1148
1149 if let Some(decay) = wiggle.decay {
1151 amp *= (-decay * time).exp();
1152 }
1153
1154 let offset = amp * noise_val;
1155 apply_property(
1156 props,
1157 &wiggle.property,
1158 get_property_value(props, &wiggle.property) + offset,
1159 );
1160 }
1161}
1162
1163pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time: f64) {
1166 use std::f64::consts::{PI, TAU};
1167
1168 for orbit in orbits {
1169 let angle_offset = orbit.start_angle * PI / 180.0;
1170 let phase_offset = orbit.phase * TAU;
1171 let tilt_rad = orbit.tilt * PI / 180.0;
1172
1173 let theta = TAU * orbit.speed * time + angle_offset + phase_offset;
1174
1175 let raw_x = orbit.radius_x * theta.cos();
1177 let raw_y = orbit.radius_y * theta.sin();
1178
1179 let x_offset = raw_x;
1181 let y_offset = raw_y * tilt_rad.cos();
1182
1183 props.translate_x += x_offset as f32;
1184 props.translate_y += y_offset as f32;
1185
1186 if orbit.depth > 0.0 {
1188 let depth_sin = if tilt_rad.abs() > 0.01 {
1190 theta.sin()
1192 } else {
1193 theta.sin()
1195 };
1196 let scale_factor = 1.0 + orbit.depth * depth_sin;
1197 props.scale_x *= scale_factor as f32;
1198 props.scale_y *= scale_factor as f32;
1199 }
1200
1201 if orbit.opacity_depth > 0.0 {
1203 let depth_sin = theta.sin();
1204 let opacity_factor = 1.0 - orbit.opacity_depth * (1.0 - depth_sin) * 0.5;
1205 props.opacity *= opacity_factor as f32;
1206 }
1207 }
1208}
1209
1210pub const MOTION_PATH_MIN_LENGTH: f32 = 1e-3;
1221
1222pub fn motion_path_length(path_data: &str) -> Option<f32> {
1237 let path = skia_safe::Path::from_svg(path_data)?;
1238 if path.count_points() == 0 {
1239 return None;
1240 }
1241 let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1242 Some(measure.length())
1243}
1244
1245fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 {
1261 let elapsed = time - cfg.delay;
1262 if elapsed <= 0.0 {
1263 return 0.0;
1264 }
1265 let raw = safe_div(elapsed, cfg.duration, 1.0);
1266 let progress = if cfg.repeat {
1267 raw.rem_euclid(1.0)
1268 } else {
1269 raw.clamp(0.0, 1.0)
1270 };
1271 ease(progress, &cfg.easing)
1272}
1273
1274struct MotionPathSample {
1279 dx: f32,
1280 dy: f32,
1281 angle_deg: f32,
1282}
1283
1284fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample {
1303 let zero = MotionPathSample {
1304 dx: 0.0,
1305 dy: 0.0,
1306 angle_deg: 0.0,
1307 };
1308 let Some(path) = skia_safe::Path::from_svg(&cfg.path) else {
1309 return zero;
1310 };
1311 if path.count_points() == 0 {
1312 return zero;
1313 }
1314
1315 let mut measure = skia_safe::PathMeasure::new(&path, false, None);
1316 let length = measure.length();
1317
1318 if length <= MOTION_PATH_MIN_LENGTH {
1326 let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1327 return MotionPathSample {
1328 dx: x,
1329 dy: y,
1330 angle_deg: 0.0,
1331 };
1332 }
1333
1334 let progress = motion_path_progress(cfg, time) as f32;
1335 let distance = (length * progress).clamp(0.0, length);
1336
1337 match measure.pos_tan(distance) {
1338 Some((pos, tangent)) => {
1339 let angle_deg = if cfg.orient {
1340 tangent.y.atan2(tangent.x).to_degrees() + cfg.orient_offset as f32
1341 } else {
1342 0.0
1343 };
1344 MotionPathSample {
1345 dx: pos.x,
1346 dy: pos.y,
1347 angle_deg,
1348 }
1349 }
1350 None => {
1355 let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y));
1356 MotionPathSample {
1357 dx: x,
1358 dy: y,
1359 angle_deg: 0.0,
1360 }
1361 }
1362 }
1363}
1364
1365pub fn apply_motion_paths(props: &mut AnimatedProperties, paths: &[MotionPathConfig], time: f64) {
1377 for cfg in paths {
1378 let sample = motion_path_sample(cfg, time);
1379 props.translate_x += sample.dx;
1380 props.translate_y += sample.dy;
1381 props.rotation += sample.angle_deg;
1382 }
1383}
1384
1385fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 {
1386 match property {
1387 "opacity" => props.opacity as f64,
1388 "position.x" | "translate_x" => props.translate_x as f64,
1389 "position.y" | "translate_y" => props.translate_y as f64,
1390 "scale" => props.scale_x as f64,
1391 "scale.x" => props.scale_x as f64,
1392 "scale.y" => props.scale_y as f64,
1393 "rotation" => props.rotation as f64,
1394 "blur" => props.blur as f64,
1395 "border_radius" => props.border_radius as f64,
1396 "font_size" => props.font_size as f64,
1397 "width" => props.width as f64,
1398 "height" => props.height as f64,
1399 "gap" => props.gap as f64,
1400 "padding" => props.padding as f64,
1401 "stroke_width" => props.stroke_width as f64,
1402 "shadow_blur" => props.shadow_blur as f64,
1403 "glow_radius" => props.glow_radius as f64,
1404 "glow_intensity" => props.glow_intensity as f64,
1405 "rotate_x" => props.rotate_x as f64,
1406 "rotate_y" => props.rotate_y as f64,
1407 "perspective" => props.perspective as f64,
1408 "draw_progress" => props.draw_progress as f64,
1409 "motion_progress" => props.motion_progress as f64,
1410 _ => 0.0,
1411 }
1412}
1413
1414fn is_motion_property(property: &str) -> bool {
1420 matches!(
1421 property,
1422 "position.x"
1423 | "position.y"
1424 | "translate_x"
1425 | "translate_y"
1426 | "scale"
1427 | "scale.x"
1428 | "scale.y"
1429 | "rotation"
1430 | "rotate_x"
1431 | "rotate_y"
1432 )
1433}
1434
1435fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) {
1467 for anim in animations.iter_mut() {
1468 if !is_motion_property(&anim.property) || anim.keyframes.len() < 2 {
1469 continue;
1470 }
1471 if anim.keyframes.len() > 2 {
1472 let first = anim.keyframes.first().unwrap().clone();
1473 let last = anim.keyframes.last().unwrap().clone();
1474 if (first.value.as_f64() - last.value.as_f64()).abs() < 1e-9 {
1475 continue; }
1477 anim.keyframes = vec![first, last];
1478 }
1479 anim.easing = EasingType::Spring;
1480 anim.spring = Some(spring.clone());
1481 }
1482}
1483
1484fn expand_preset(
1485 preset: &AnimationPreset,
1486 config: &PresetConfig,
1487 _scene_duration: f64,
1488) -> Vec<Animation> {
1489 let mut animations = expand_preset_inner(preset, config);
1490 if let Some(spring) = &config.spring {
1491 apply_spring_to_motion(&mut animations, spring);
1492 }
1493 animations
1494}
1495
1496fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec<Animation> {
1497 let delay = config.delay;
1498 let dur = config.duration;
1499 let end = delay + dur;
1500
1501 match preset {
1502 AnimationPreset::FadeIn => vec![kf_anim(
1504 "opacity",
1505 delay,
1506 0.0,
1507 end,
1508 1.0,
1509 EasingType::EaseOut,
1510 )],
1511 AnimationPreset::FadeInUp => vec![
1512 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1513 kf_anim(
1514 "position.y",
1515 delay,
1516 60.0,
1517 end,
1518 0.0,
1519 EasingType::EaseOutCubic,
1520 ),
1521 ],
1522 AnimationPreset::FadeInDown => vec![
1523 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1524 kf_anim(
1525 "position.y",
1526 delay,
1527 -60.0,
1528 end,
1529 0.0,
1530 EasingType::EaseOutCubic,
1531 ),
1532 ],
1533 AnimationPreset::FadeInLeft => vec![
1534 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1535 kf_anim(
1536 "position.x",
1537 delay,
1538 -60.0,
1539 end,
1540 0.0,
1541 EasingType::EaseOutCubic,
1542 ),
1543 ],
1544 AnimationPreset::FadeInRight => vec![
1545 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1546 kf_anim(
1547 "position.x",
1548 delay,
1549 60.0,
1550 end,
1551 0.0,
1552 EasingType::EaseOutCubic,
1553 ),
1554 ],
1555 AnimationPreset::SlideInLeft => vec![
1556 kf_anim(
1557 "opacity",
1558 delay,
1559 0.0,
1560 delay + dur * 0.3,
1561 1.0,
1562 EasingType::EaseOut,
1563 ),
1564 kf_anim(
1565 "position.x",
1566 delay,
1567 -200.0,
1568 end,
1569 0.0,
1570 EasingType::EaseOutCubic,
1571 ),
1572 ],
1573 AnimationPreset::SlideInRight => vec![
1574 kf_anim(
1575 "opacity",
1576 delay,
1577 0.0,
1578 delay + dur * 0.3,
1579 1.0,
1580 EasingType::EaseOut,
1581 ),
1582 kf_anim(
1583 "position.x",
1584 delay,
1585 200.0,
1586 end,
1587 0.0,
1588 EasingType::EaseOutCubic,
1589 ),
1590 ],
1591 AnimationPreset::SlideInUp => vec![
1592 kf_anim(
1593 "opacity",
1594 delay,
1595 0.0,
1596 delay + dur * 0.3,
1597 1.0,
1598 EasingType::EaseOut,
1599 ),
1600 kf_anim(
1601 "position.y",
1602 delay,
1603 200.0,
1604 end,
1605 0.0,
1606 EasingType::EaseOutCubic,
1607 ),
1608 ],
1609 AnimationPreset::SlideInDown => vec![
1610 kf_anim(
1611 "opacity",
1612 delay,
1613 0.0,
1614 delay + dur * 0.3,
1615 1.0,
1616 EasingType::EaseOut,
1617 ),
1618 kf_anim(
1619 "position.y",
1620 delay,
1621 -200.0,
1622 end,
1623 0.0,
1624 EasingType::EaseOutCubic,
1625 ),
1626 ],
1627 AnimationPreset::ScaleIn => {
1628 let overshoot = config.overshoot.unwrap_or(0.08);
1629 vec![
1630 kf_anim(
1631 "opacity",
1632 delay,
1633 0.0,
1634 delay + dur * 0.3,
1635 1.0,
1636 EasingType::EaseOut,
1637 ),
1638 Animation {
1639 property: "scale".to_string(),
1640 keyframes: vec![
1641 kf(delay, 0.0),
1642 kf(delay + dur * 0.7, 1.0 + overshoot),
1643 kf(end, 1.0),
1644 ],
1645 easing: EasingType::EaseOutCubic,
1646 spring: None,
1647 },
1648 ]
1649 }
1650 AnimationPreset::BounceIn => vec![
1651 kf_anim(
1652 "opacity",
1653 delay,
1654 0.0,
1655 delay + dur * 0.2,
1656 1.0,
1657 EasingType::EaseOut,
1658 ),
1659 kf_anim_spring("scale", delay, 0.3, end, 1.0),
1660 ],
1661 AnimationPreset::BlurIn => vec![
1662 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1663 kf_anim("blur", delay, 20.0, end, 0.0, EasingType::EaseOutCubic),
1664 ],
1665 AnimationPreset::RotateIn => vec![
1666 kf_anim("opacity", delay, 0.0, end, 1.0, EasingType::EaseOut),
1667 kf_anim("rotation", delay, -90.0, end, 0.0, EasingType::EaseOutCubic),
1668 kf_anim("scale", delay, 0.5, end, 1.0, EasingType::EaseOutCubic),
1669 ],
1670 AnimationPreset::ElasticIn => {
1671 vec![kf_anim_spring_underdamped("scale", delay, 0.0, end, 1.0)]
1672 }
1673 AnimationPreset::PopIn => {
1674 let pulse = 1.0 + config.overshoot.unwrap_or(0.18);
1679 let placed = delay + dur * 0.6;
1680 let peak = delay + dur * 0.8;
1681 vec![
1682 kf_anim(
1683 "opacity",
1684 delay,
1685 0.0,
1686 delay + dur * 0.25,
1687 1.0,
1688 EasingType::EaseOut,
1689 ),
1690 Animation {
1691 property: "scale".to_string(),
1692 keyframes: vec![
1693 Keyframe {
1694 time: delay,
1695 value: KeyframeValue::Number(0.0),
1696 easing: Some(EasingType::EaseOutBack),
1697 },
1698 Keyframe {
1699 time: placed,
1700 value: KeyframeValue::Number(1.0),
1701 easing: Some(EasingType::EaseOutQuad),
1702 },
1703 Keyframe {
1704 time: peak,
1705 value: KeyframeValue::Number(pulse),
1706 easing: Some(EasingType::EaseOutElastic),
1707 },
1708 Keyframe {
1709 time: end,
1710 value: KeyframeValue::Number(1.0),
1711 easing: None,
1712 },
1713 ],
1714 easing: EasingType::EaseOut,
1715 spring: None,
1716 },
1717 ]
1718 }
1719
1720 AnimationPreset::FadeOut => {
1722 vec![kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn)]
1723 }
1724 AnimationPreset::FadeOutUp => vec![
1725 kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1726 kf_anim(
1727 "position.y",
1728 delay,
1729 0.0,
1730 end,
1731 -60.0,
1732 EasingType::EaseInCubic,
1733 ),
1734 ],
1735 AnimationPreset::FadeOutDown => vec![
1736 kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1737 kf_anim("position.y", delay, 0.0, end, 60.0, EasingType::EaseInCubic),
1738 ],
1739 AnimationPreset::SlideOutLeft => vec![
1740 kf_anim(
1741 "opacity",
1742 delay + dur * 0.7,
1743 1.0,
1744 end,
1745 0.0,
1746 EasingType::EaseIn,
1747 ),
1748 kf_anim(
1749 "position.x",
1750 delay,
1751 0.0,
1752 end,
1753 -200.0,
1754 EasingType::EaseInCubic,
1755 ),
1756 ],
1757 AnimationPreset::SlideOutRight => vec![
1758 kf_anim(
1759 "opacity",
1760 delay + dur * 0.7,
1761 1.0,
1762 end,
1763 0.0,
1764 EasingType::EaseIn,
1765 ),
1766 kf_anim(
1767 "position.x",
1768 delay,
1769 0.0,
1770 end,
1771 200.0,
1772 EasingType::EaseInCubic,
1773 ),
1774 ],
1775 AnimationPreset::SlideOutUp => vec![
1776 kf_anim(
1777 "opacity",
1778 delay + dur * 0.7,
1779 1.0,
1780 end,
1781 0.0,
1782 EasingType::EaseIn,
1783 ),
1784 kf_anim(
1785 "position.y",
1786 delay,
1787 0.0,
1788 end,
1789 -200.0,
1790 EasingType::EaseInCubic,
1791 ),
1792 ],
1793 AnimationPreset::SlideOutDown => vec![
1794 kf_anim(
1795 "opacity",
1796 delay + dur * 0.7,
1797 1.0,
1798 end,
1799 0.0,
1800 EasingType::EaseIn,
1801 ),
1802 kf_anim(
1803 "position.y",
1804 delay,
1805 0.0,
1806 end,
1807 200.0,
1808 EasingType::EaseInCubic,
1809 ),
1810 ],
1811 AnimationPreset::ScaleOut => {
1812 let overshoot = config.overshoot.unwrap_or(0.08);
1813 vec![
1814 kf_anim(
1815 "opacity",
1816 delay + dur * 0.7,
1817 1.0,
1818 end,
1819 0.0,
1820 EasingType::EaseIn,
1821 ),
1822 Animation {
1823 property: "scale".to_string(),
1824 keyframes: vec![
1825 kf(delay, 1.0),
1826 kf(delay + dur * 0.2, 1.0 + overshoot),
1827 kf(end, 0.0),
1828 ],
1829 easing: EasingType::EaseInCubic,
1830 spring: None,
1831 },
1832 ]
1833 }
1834 AnimationPreset::BounceOut => vec![
1835 kf_anim(
1836 "opacity",
1837 delay + dur * 0.8,
1838 1.0,
1839 end,
1840 0.0,
1841 EasingType::EaseIn,
1842 ),
1843 kf_anim_spring("scale", delay, 1.0, end, 0.3),
1844 ],
1845 AnimationPreset::BlurOut => vec![
1846 kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1847 kf_anim("blur", delay, 0.0, end, 20.0, EasingType::EaseInCubic),
1848 ],
1849 AnimationPreset::RotateOut => vec![
1850 kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn),
1851 kf_anim("rotation", delay, 0.0, end, 90.0, EasingType::EaseInCubic),
1852 kf_anim("scale", delay, 1.0, end, 0.5, EasingType::EaseInCubic),
1853 ],
1854
1855 AnimationPreset::Pulse => vec![kf_anim_3kf_over(
1863 "scale",
1864 delay,
1865 end,
1866 0.95,
1867 1.05,
1868 0.95,
1869 EasingType::EaseInOut,
1870 )],
1871 AnimationPreset::Float => vec![kf_anim_3kf_over(
1872 "position.y",
1873 delay,
1874 end,
1875 0.0,
1876 -10.0,
1877 0.0,
1878 EasingType::EaseInOut,
1879 )],
1880 AnimationPreset::Shake => vec![kf_anim_4kf_over(
1881 "position.x",
1882 delay,
1883 end,
1884 0.0,
1885 10.0,
1886 -10.0,
1887 0.0,
1888 EasingType::EaseInOut,
1889 )],
1890 AnimationPreset::Spin => vec![kf_anim(
1891 "rotation",
1892 delay,
1893 0.0,
1894 end,
1895 360.0,
1896 EasingType::Linear,
1897 )],
1898
1899 AnimationPreset::FlipInX => vec![
1901 kf_anim(
1902 "opacity",
1903 delay,
1904 0.0,
1905 delay + dur * 0.3,
1906 1.0,
1907 EasingType::EaseOut,
1908 ),
1909 kf_anim("rotate_x", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1910 kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1911 ],
1912 AnimationPreset::FlipInY => vec![
1913 kf_anim(
1914 "opacity",
1915 delay,
1916 0.0,
1917 delay + dur * 0.3,
1918 1.0,
1919 EasingType::EaseOut,
1920 ),
1921 kf_anim("rotate_y", delay, 90.0, end, 0.0, EasingType::EaseOutCubic),
1922 kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1923 ],
1924 AnimationPreset::FlipOutX => vec![
1925 kf_anim(
1926 "opacity",
1927 delay + dur * 0.7,
1928 1.0,
1929 end,
1930 0.0,
1931 EasingType::EaseIn,
1932 ),
1933 kf_anim("rotate_x", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1934 kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1935 ],
1936 AnimationPreset::FlipOutY => vec![
1937 kf_anim(
1938 "opacity",
1939 delay + dur * 0.7,
1940 1.0,
1941 end,
1942 0.0,
1943 EasingType::EaseIn,
1944 ),
1945 kf_anim("rotate_y", delay, 0.0, end, -90.0, EasingType::EaseInCubic),
1946 kf_anim("perspective", delay, 800.0, end, 800.0, EasingType::Linear),
1947 ],
1948 AnimationPreset::TiltIn => vec![
1949 kf_anim(
1950 "opacity",
1951 delay,
1952 0.0,
1953 delay + dur * 0.3,
1954 1.0,
1955 EasingType::EaseOut,
1956 ),
1957 kf_anim("rotate_x", delay, 15.0, end, 0.0, EasingType::EaseOutCubic),
1958 kf_anim("rotate_y", delay, -15.0, end, 0.0, EasingType::EaseOutCubic),
1959 kf_anim(
1960 "perspective",
1961 delay,
1962 1000.0,
1963 end,
1964 1000.0,
1965 EasingType::Linear,
1966 ),
1967 kf_anim("scale", delay, 0.9, end, 1.0, EasingType::EaseOutCubic),
1968 ],
1969
1970 AnimationPreset::Float3d => {
1972 let amp = config.amplitude.unwrap_or(12.0);
1982 let tilt = amp / 12.0;
1983 vec![
1984 kf_anim_3kf_over(
1985 "position.y",
1986 delay,
1987 end,
1988 0.0,
1989 -amp,
1990 0.0,
1991 EasingType::EaseInOut,
1992 ),
1993 kf_anim_3kf_over(
1994 "rotate_x",
1995 delay,
1996 end,
1997 0.0,
1998 5.0 * tilt,
1999 0.0,
2000 EasingType::EaseInOut,
2001 ),
2002 kf_anim_3kf_over(
2003 "rotate_y",
2004 delay,
2005 end,
2006 0.0,
2007 -8.0 * tilt,
2008 0.0,
2009 EasingType::EaseInOut,
2010 ),
2011 kf_anim(
2012 "perspective",
2013 delay,
2014 1000.0,
2015 end,
2016 1000.0,
2017 EasingType::Linear,
2018 ),
2019 ]
2020 }
2021
2022 AnimationPreset::DrawIn => vec![kf_anim(
2024 "draw_progress",
2025 delay,
2026 0.0,
2027 end,
2028 1.0,
2029 EasingType::EaseInOut,
2030 )],
2031 AnimationPreset::StrokeReveal => vec![
2032 kf_anim("draw_progress", delay, 0.0, end, 1.0, EasingType::EaseOut),
2033 kf_anim(
2034 "opacity",
2035 delay,
2036 0.0,
2037 delay + dur * 0.2,
2038 1.0,
2039 EasingType::EaseOut,
2040 ),
2041 ],
2042 AnimationPreset::Typewriter => vec![kf_anim(
2043 "visible_chars_progress",
2044 delay,
2045 0.0,
2046 end,
2047 1.0,
2048 EasingType::Linear,
2049 )],
2050 AnimationPreset::WipeLeft => vec![
2051 kf_anim(
2052 "opacity",
2053 delay,
2054 0.0,
2055 delay + dur * 0.3,
2056 1.0,
2057 EasingType::EaseOut,
2058 ),
2059 kf_anim("position.x", delay, -200.0, end, 0.0, EasingType::EaseInOut),
2060 ],
2061 AnimationPreset::WipeRight => vec![
2062 kf_anim(
2063 "opacity",
2064 delay,
2065 0.0,
2066 delay + dur * 0.3,
2067 1.0,
2068 EasingType::EaseOut,
2069 ),
2070 kf_anim("position.x", delay, 200.0, end, 0.0, EasingType::EaseInOut),
2071 ],
2072 }
2073}
2074
2075fn kf(time: f64, value: f64) -> Keyframe {
2076 Keyframe {
2077 time,
2078 value: KeyframeValue::Number(value),
2079 easing: None,
2080 }
2081}
2082
2083fn kf_anim(property: &str, t0: f64, v0: f64, t1: f64, v1: f64, easing: EasingType) -> Animation {
2084 Animation {
2085 property: property.to_string(),
2086 keyframes: vec![kf(t0, v0), kf(t1, v1)],
2087 easing,
2088 spring: None,
2089 }
2090}
2091
2092fn kf_anim_spring(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
2093 Animation {
2094 property: property.to_string(),
2095 keyframes: vec![kf(t0, v0), kf(t1, v1)],
2096 easing: EasingType::Spring,
2097 spring: Some(SpringConfig {
2098 damping: 12.0,
2099 stiffness: 100.0,
2100 mass: 1.0,
2101 ..Default::default()
2102 }),
2103 }
2104}
2105
2106fn kf_anim_spring_underdamped(property: &str, t0: f64, v0: f64, t1: f64, v1: f64) -> Animation {
2107 Animation {
2108 property: property.to_string(),
2109 keyframes: vec![kf(t0, v0), kf(t1, v1)],
2110 easing: EasingType::Spring,
2111 spring: Some(SpringConfig {
2112 damping: 6.0,
2113 stiffness: 120.0,
2114 mass: 1.0,
2115 ..Default::default()
2116 }),
2117 }
2118}
2119
2120fn kf_anim_3kf_over(
2123 property: &str,
2124 start: f64,
2125 end: f64,
2126 v0: f64,
2127 v1: f64,
2128 v2: f64,
2129 easing: EasingType,
2130) -> Animation {
2131 Animation {
2132 property: property.to_string(),
2133 keyframes: vec![kf(start, v0), kf((start + end) / 2.0, v1), kf(end, v2)],
2134 easing,
2135 spring: None,
2136 }
2137}
2138
2139#[allow(clippy::too_many_arguments)]
2143fn kf_anim_4kf_over(
2144 property: &str,
2145 start: f64,
2146 end: f64,
2147 v0: f64,
2148 v1: f64,
2149 v2: f64,
2150 v3: f64,
2151 easing: EasingType,
2152) -> Animation {
2153 let quarter = (end - start) / 4.0;
2154 Animation {
2155 property: property.to_string(),
2156 keyframes: vec![
2157 kf(start, v0),
2158 kf(start + quarter, v1),
2159 kf(start + quarter * 2.0, v2),
2160 kf(end, v3),
2161 ],
2162 easing,
2163 spring: None,
2164 }
2165}
2166
2167#[cfg(test)]
2168mod spring_preset_tests {
2169 use super::*;
2173 use crate::schema::AnimationEffect;
2174 use crate::schema::AnimationTiming;
2175
2176 fn timing(duration: f64, spring: Option<SpringConfig>) -> AnimationTiming {
2177 AnimationTiming {
2178 duration,
2179 spring,
2180 ..Default::default()
2181 }
2182 }
2183
2184 fn underdamped() -> SpringConfig {
2185 SpringConfig {
2186 damping: 8.0,
2187 stiffness: 120.0,
2188 mass: 1.0,
2189 ..Default::default()
2190 }
2191 }
2192
2193 fn sample(effects: &[AnimationEffect], duration: f64) -> Vec<(f64, f64, f64)> {
2195 let steps = 80;
2196 (0..=steps)
2197 .map(|i| {
2198 let t = duration * i as f64 / steps as f64;
2199 let p = resolve_props_for_effects(effects, t, 5.0);
2200 (t, p.translate_y as f64, p.opacity as f64)
2201 })
2202 .collect()
2203 }
2204
2205 #[test]
2206 fn fade_in_up_spring_overshoots_position() {
2207 let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2209 let min_plain = plain.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2210 assert!(
2211 min_plain >= -0.01,
2212 "without spring translate_y must never overshoot below 0, got min {min_plain}"
2213 );
2214
2215 let sprung = sample(
2218 &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2219 0.8,
2220 );
2221 let min_sprung = sprung.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min);
2222 assert!(
2223 min_sprung < -0.5,
2224 "with spring translate_y must overshoot below 0, got min {min_sprung}"
2225 );
2226
2227 let y_plain_70 = plain[56].1;
2229 let y_sprung_70 = sprung[56].1;
2230 assert!(
2231 (y_plain_70 - y_sprung_70).abs() > 0.5,
2232 "at 70% duration spring vs plain must differ: {y_plain_70} vs {y_sprung_70}"
2233 );
2234 }
2235
2236 #[test]
2237 fn fade_in_up_spring_does_not_touch_opacity() {
2238 let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8);
2239 let sprung = sample(
2240 &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))],
2241 0.8,
2242 );
2243 for (i, ((_, _, a_plain), (_, _, a_sprung))) in plain.iter().zip(sprung.iter()).enumerate()
2244 {
2245 assert!(
2246 (a_plain - a_sprung).abs() < 1e-6,
2247 "opacity must be identical with/without spring at sample {i}: {a_plain} vs {a_sprung}"
2248 );
2249 }
2250 for w in sprung.windows(2) {
2252 assert!(
2253 w[1].2 >= w[0].2 - 1e-6,
2254 "opacity must be monotone, got {} then {}",
2255 w[0].2,
2256 w[1].2
2257 );
2258 }
2259 }
2260
2261 #[test]
2262 fn bounce_in_custom_spring_differs_from_default() {
2263 let scale_at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2264 let fx = [AnimationEffect::BounceIn(timing(0.8, spring))];
2265 resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2266 };
2267 let overdamped = SpringConfig {
2270 damping: 40.0,
2271 stiffness: 100.0,
2272 mass: 1.0,
2273 ..Default::default()
2274 };
2275 let d = scale_at(None, 0.3);
2276 let c = scale_at(Some(overdamped), 0.3);
2277 assert!(
2278 (d - c).abs() > 0.01,
2279 "custom spring must change bounce_in: default {d} vs custom {c}"
2280 );
2281 }
2282
2283 #[test]
2284 fn scale_in_spring_collapses_manual_overshoot() {
2285 let fx = [AnimationEffect::ScaleIn(timing(0.8, Some(underdamped())))];
2289 let mut max_scale = f64::MIN;
2290 for i in 0..=80 {
2291 let t = 0.8 * i as f64 / 80.0;
2292 let s = resolve_props_for_effects(&fx, t, 5.0).scale_x as f64;
2293 max_scale = max_scale.max(s);
2294 }
2295 assert!(
2299 max_scale > 1.12,
2300 "spring scale_in must overshoot past the manual 1.08 peak, got max {max_scale}"
2301 );
2302 let end = resolve_props_for_effects(&fx, 0.8, 5.0).scale_x as f64;
2303 assert!(
2304 (end - 1.0).abs() < 1e-3,
2305 "scale must converge to 1.0 at window end, got {end}"
2306 );
2307 }
2308
2309 #[test]
2310 fn pulse_oscillator_ignores_spring() {
2311 let at = |spring: Option<SpringConfig>, t: f64| -> f64 {
2315 let fx = [AnimationEffect::Pulse(timing(1.0, spring))];
2316 resolve_props_for_effects(&fx, t, 5.0).scale_x as f64
2317 };
2318 for i in 0..=20 {
2319 let t = i as f64 / 20.0;
2320 let plain = at(None, t);
2321 let sprung = at(Some(underdamped()), t);
2322 assert!(
2323 (plain - sprung).abs() < 1e-9,
2324 "pulse must be unaffected by spring at t={t}: {plain} vs {sprung}"
2325 );
2326 }
2327 }
2328
2329 #[test]
2330 fn animation_timing_spring_serde_round_trip() {
2331 let json = r#"{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 8, "stiffness": 120 } }"#;
2332 let fx: AnimationEffect = serde_json::from_str(json).unwrap();
2333 let AnimationEffect::FadeInUp(t) = &fx else {
2334 panic!("wrong variant");
2335 };
2336 let s = t.spring.as_ref().expect("spring parsed");
2337 assert_eq!(s.damping, 8.0);
2338 assert_eq!(s.stiffness, 120.0);
2339 assert_eq!(s.mass, 1.0, "mass defaults to 1");
2340
2341 let re = serde_json::to_string(&fx).unwrap();
2343 let back: AnimationEffect = serde_json::from_str(&re).unwrap();
2344 assert_eq!(fx, back);
2345
2346 let plain: AnimationEffect = serde_json::from_str(r#"{ "name": "fade_in_up" }"#).unwrap();
2348 let AnimationEffect::FadeInUp(t) = &plain else {
2349 panic!("wrong variant");
2350 };
2351 assert!(t.spring.is_none());
2352 }
2353}
2354
2355#[cfg(test)]
2356mod glow_tests {
2357 use super::*;
2364 use crate::schema::{AnimationEffect, AnimationTiming, GlowConfig};
2365
2366 fn glow(color: &str, radius: f32, intensity: f32) -> AnimationEffect {
2367 AnimationEffect::Glow(GlowConfig {
2368 color: color.to_string(),
2369 radius,
2370 intensity,
2371 })
2372 }
2373
2374 #[test]
2375 fn finds_glow_among_other_effects() {
2376 let effects = vec![
2377 AnimationEffect::FadeIn(AnimationTiming::default()),
2378 glow("#5C39EE", 12.0, 1.0),
2379 ];
2380 let found = find_glow_effect(&effects).expect("glow effect present");
2381 assert_eq!(found.color, "#5C39EE");
2382 assert_eq!(found.radius, 12.0);
2383 }
2384
2385 #[test]
2386 fn returns_none_without_a_glow_effect() {
2387 let effects = vec![AnimationEffect::FadeIn(AnimationTiming::default())];
2388 assert!(find_glow_effect(&effects).is_none());
2389 }
2390
2391 #[test]
2392 fn resolve_props_for_effects_does_not_touch_glow_radius_or_intensity() {
2393 let effects = vec![glow("#5C39EE", 12.0, 1.0)];
2401 let props = resolve_props_for_effects(&effects, 0.0, 1.0);
2402 assert_eq!(
2403 props.glow_radius,
2404 AnimatedProperties::default().glow_radius,
2405 "glow_radius must stay at its sentinel; the named `glow` effect must not set it"
2406 );
2407 assert_eq!(
2408 props.glow_intensity,
2409 AnimatedProperties::default().glow_intensity
2410 );
2411 }
2412}
2413
2414#[cfg(test)]
2415mod float3d_amplitude_tests {
2416 use super::*;
2422 use crate::schema::AnimationEffect;
2423
2424 fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 {
2427 let mut peak = 0.0f64;
2428 let steps = 200;
2429 for i in 0..=steps {
2430 let t = window * i as f64 / steps as f64;
2431 let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64;
2432 if y.abs() > peak.abs() {
2433 peak = y;
2434 }
2435 }
2436 peak
2437 }
2438
2439 #[test]
2440 fn author_supplied_amplitude_reaches_the_solver() {
2441 let default_fx: AnimationEffect =
2445 serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap();
2446 let big_fx: AnimationEffect =
2447 serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#)
2448 .unwrap();
2449
2450 let default_peak = peak_translate_y(&[default_fx], 1.0);
2451 let big_peak = peak_translate_y(&[big_fx], 1.0);
2452
2453 assert!(
2454 (default_peak.abs() - 12.0).abs() < 0.5,
2455 "default float_3d amplitude must stay ~12px, got {default_peak}"
2456 );
2457 assert!(
2458 big_peak.abs() > 50.0,
2459 "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \
2460 (default was {default_peak})"
2461 );
2462 }
2463}
2464
2465#[cfg(test)]
2466mod continuous_preset_timing_tests {
2467 use super::*;
2473 use crate::schema::AnimationEffect;
2474
2475 fn timing(delay: f64, duration: f64) -> AnimationTimingFixture {
2476 AnimationTimingFixture { delay, duration }
2477 }
2478
2479 struct AnimationTimingFixture {
2482 delay: f64,
2483 duration: f64,
2484 }
2485
2486 impl AnimationTimingFixture {
2487 fn json(&self, name: &str) -> String {
2488 format!(
2489 r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#,
2490 name, self.delay, self.duration
2491 )
2492 }
2493 }
2494
2495 #[test]
2496 fn pulse_honours_delay_and_duration() {
2497 let t = timing(1.0, 2.0);
2498 let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap();
2499 let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64;
2506 let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64;
2507 assert!(
2508 (early - late).abs() < 1e-6,
2509 "pulse must be frozen before its delay=1.0 (not yet oscillating): \
2510 t=0.1 -> {early}, t=0.9 -> {late}"
2511 );
2512 assert!(
2513 (early - 0.95).abs() < 0.01,
2514 "pulse before its delay must clamp to the first keyframe (0.95), got {early}"
2515 );
2516 let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64;
2518 assert!(
2519 mid > 1.03,
2520 "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}"
2521 );
2522 }
2523
2524 #[test]
2525 fn float_honours_delay_and_duration() {
2526 let t = timing(1.0, 2.0);
2527 let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap();
2528 let before =
2529 resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64;
2530 assert!(
2531 before.abs() < 0.1,
2532 "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}"
2533 );
2534 let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64;
2535 assert!(
2536 mid < -8.0,
2537 "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}"
2538 );
2539 }
2540
2541 #[test]
2542 fn shake_honours_delay_and_duration() {
2543 let t = timing(1.0, 2.0);
2544 let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap();
2545 let before =
2546 resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64;
2547 assert!(
2548 before.abs() < 0.1,
2549 "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}"
2550 );
2551 let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64;
2553 assert!(
2554 quarter > 8.0,
2555 "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}"
2556 );
2557 }
2558
2559 #[test]
2560 fn spin_honours_delay_and_duration() {
2561 let t = timing(1.0, 2.0);
2562 let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap();
2563 let before =
2564 resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64;
2565 assert!(
2566 before.abs() < 0.1,
2567 "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}"
2568 );
2569 let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64;
2571 assert!(
2572 (mid - 180.0).abs() < 5.0,
2573 "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}"
2574 );
2575 }
2576}
2577
2578#[cfg(test)]
2579mod keyframes_composition_tests {
2580 use super::*;
2597 use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2598
2599 fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect {
2602 AnimationEffect::Keyframes(KeyframesConfig {
2603 keyframes: vec![Animation {
2604 property: property.to_string(),
2605 keyframes: vec![
2606 Keyframe {
2607 time: 0.0,
2608 value: KeyframeValue::Number(0.0),
2609 easing: None,
2610 },
2611 Keyframe {
2612 time: 1.0,
2613 value: KeyframeValue::Number(value),
2614 easing: None,
2615 },
2616 ],
2617 easing: EasingType::Linear,
2618 spring: None,
2619 }],
2620 delay,
2621 duration: 0.8,
2622 repeat: false,
2623 })
2624 }
2625
2626 #[test]
2627 fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() {
2628 let a1 = ramp("translate_x", 100.0, 0.0);
2630 let b1 = ramp("translate_x", 40.0, 0.5);
2631 let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64;
2632 let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64;
2633 assert!(
2634 (combined_1 - b1_alone).abs() < 1e-4,
2635 "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}"
2636 );
2637
2638 let a2 = ramp("translate_x", 100.0, 0.5);
2643 let b2 = ramp("translate_x", 40.0, 0.0);
2644 let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64;
2645 let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64;
2646 assert!(
2647 (combined_2 - b2_alone).abs() < 1e-4,
2648 "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \
2649 combined={combined_2}, B-alone={b2_alone}"
2650 );
2651
2652 assert!(
2656 (combined_1 - combined_2).abs() > 1.0,
2657 "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}"
2658 );
2659 }
2660}
2661
2662#[cfg(test)]
2663mod keyframes_loop_tests {
2664 use super::*;
2672 use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig};
2673
2674 #[test]
2675 fn keyframes_loop_true_wraps_time_past_the_last_keyframe() {
2676 let looping = AnimationEffect::Keyframes(KeyframesConfig {
2677 keyframes: vec![Animation {
2678 property: "opacity".to_string(),
2679 keyframes: vec![
2680 Keyframe {
2681 time: 0.0,
2682 value: KeyframeValue::Number(0.0),
2683 easing: None,
2684 },
2685 Keyframe {
2686 time: 1.0,
2687 value: KeyframeValue::Number(1.0),
2688 easing: None,
2689 },
2690 ],
2691 easing: EasingType::Linear,
2692 spring: None,
2693 }],
2694 delay: 0.0,
2695 duration: 0.8,
2696 repeat: true,
2697 });
2698 let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64;
2703 assert!(
2704 (opacity - 0.5).abs() < 0.05,
2705 "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}"
2706 );
2707 }
2708
2709 #[test]
2710 fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() {
2711 let looping_tilt: AnimationEffect = serde_json::from_str(
2712 r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#,
2713 )
2714 .unwrap();
2715 let settled: AnimationEffect =
2716 serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#)
2717 .unwrap();
2718
2719 let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64;
2723 let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64;
2724
2725 assert!(
2726 (settled_scale - 1.0).abs() < 1e-3,
2727 "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}"
2728 );
2729 assert!(
2730 (looping_scale - 1.0).abs() > 0.01,
2731 "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}"
2732 );
2733 }
2734}
2735
2736#[cfg(test)]
2737mod spring_robustness_tests {
2738 use super::*;
2746
2747 #[test]
2748 fn zero_mass_does_not_produce_nan() {
2749 let config = SpringConfig {
2750 damping: 10.0,
2751 stiffness: 100.0,
2752 mass: 0.0,
2753 ..Default::default()
2754 };
2755 for i in 0..=20 {
2756 let t = i as f64 * 0.25;
2757 let v = spring_value(t, &config);
2758 assert!(
2759 v.is_finite(),
2760 "spring_value(t={t}) with mass=0 must be finite, got {v}"
2761 );
2762 }
2763 }
2764
2765 #[test]
2766 fn zero_stiffness_does_not_produce_nan() {
2767 let config = SpringConfig {
2768 damping: 10.0,
2769 stiffness: 0.0,
2770 mass: 1.0,
2771 ..Default::default()
2772 };
2773 for i in 0..=20 {
2774 let t = i as f64 * 0.25;
2775 let v = spring_value(t, &config);
2776 assert!(
2777 v.is_finite(),
2778 "spring_value(t={t}) with stiffness=0 must be finite, got {v}"
2779 );
2780 }
2781 }
2782
2783 #[test]
2784 fn negative_damping_stays_bounded_instead_of_diverging() {
2785 let config = SpringConfig {
2786 damping: -20.0,
2787 stiffness: 100.0,
2788 mass: 1.0,
2789 ..Default::default()
2790 };
2791 let v_at_5s = spring_value(5.0, &config);
2792 assert!(
2793 v_at_5s.is_finite() && v_at_5s.abs() < 100.0,
2794 "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \
2795 small), got {v_at_5s} — negative damping must not diverge to +-infinity"
2796 );
2797 }
2798}
2799
2800#[cfg(test)]
2801mod spring_duration_tests {
2802 use super::*;
2807
2808 fn brute_force_settle_time(
2813 damping: f64,
2814 stiffness: f64,
2815 mass: f64,
2816 threshold: f64,
2817 max_t: f64,
2818 steps: usize,
2819 ) -> f64 {
2820 let dt = max_t / steps as f64;
2821 let mut last_exceed = 0.0;
2822 for i in 0..=steps {
2823 let t = i as f64 * dt;
2824 if (spring_value_raw(t, damping, stiffness, mass) - 1.0).abs() > threshold {
2825 last_exceed = t;
2826 }
2827 }
2828 last_exceed
2829 }
2830
2831 #[test]
2832 fn red_phase_duration_is_ignored_by_the_raw_physical_solver() {
2833 let v = spring_value_raw(0.8, 6.0, 120.0, 1.0);
2846 assert!(
2847 (v - 1.043467).abs() < 1e-5,
2848 "captured red-phase reference value drifted: got {v}, expected ~1.043467"
2849 );
2850 assert!(
2851 (v - 1.0).abs() > 0.04,
2852 "red-phase claim: at t=duration the unscaled spring must still be far from rest \
2853 (got diff {:.6}, expected > 0.04)",
2854 (v - 1.0).abs()
2855 );
2856 }
2857
2858 #[test]
2859 fn duration_makes_the_spring_settle_exactly_there() {
2860 let config = SpringConfig {
2861 damping: 6.0,
2862 stiffness: 120.0,
2863 mass: 1.0,
2864 duration: Some(0.8),
2865 rest_threshold: None,
2866 };
2867 let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2868
2869 let v_at_duration = spring_value(0.8, &config);
2873 assert!(
2874 (v_at_duration - 1.0).abs() <= threshold,
2875 "spring_value(0.8, ..) with duration=Some(0.8) must be within {threshold} of rest, \
2876 got {v_at_duration} (diff {})",
2877 (v_at_duration - 1.0).abs()
2878 );
2879
2880 let v_at_half = spring_value(0.4, &config);
2884 assert!(
2885 (v_at_half - 1.0).abs() > threshold,
2886 "sanity: spring must not already be at rest at half of duration, got diff {}",
2887 (v_at_half - 1.0).abs()
2888 );
2889 }
2890
2891 #[test]
2892 fn spring_rest_time_returns_duration_verbatim_when_set() {
2893 let config = SpringConfig {
2894 damping: 6.0,
2895 stiffness: 120.0,
2896 mass: 1.0,
2897 duration: Some(0.8),
2898 rest_threshold: None,
2899 };
2900 assert_eq!(spring_rest_time(&config), 0.8);
2901 }
2902
2903 #[test]
2904 fn spring_rest_time_matches_a_brute_force_reference_without_duration() {
2905 let cases: [(f64, f64, f64, &str); 5] = [
2906 (15.0, 100.0, 1.0, "default"),
2907 (12.0, 100.0, 1.0, "kf_anim_spring"),
2908 (6.0, 120.0, 1.0, "underdamped elastic_in-like"),
2909 (
2910 20.0,
2911 100.0,
2912 1.0,
2913 "critically damped (damping = 2*sqrt(stiffness*mass))",
2914 ),
2915 (60.0, 100.0, 1.0, "overdamped"),
2916 ];
2917 for (damping, stiffness, mass, label) in cases {
2918 let config = SpringConfig {
2919 damping,
2920 stiffness,
2921 mass,
2922 duration: None,
2923 rest_threshold: None,
2924 };
2925 let threshold = DEFAULT_SPRING_REST_THRESHOLD;
2926 let got = spring_rest_time(&config);
2927 let reference = brute_force_settle_time(
2928 damping,
2929 stiffness,
2930 mass,
2931 threshold,
2932 MAX_SPRING_SEARCH_SECONDS,
2933 400_000,
2934 );
2935 let abs_err = (got - reference).abs();
2936 assert!(
2937 abs_err < 0.05,
2938 "{label}: spring_rest_time={got:.5}s vs brute-force reference={reference:.5}s \
2939 (|err|={abs_err:.5}s, expected < 0.05s)"
2940 );
2941 }
2942 }
2943
2944 #[test]
2945 fn overdamped_spring_never_reaches_target_exactly_but_settle_time_is_found() {
2946 let config = SpringConfig {
2950 damping: 200.0,
2951 stiffness: 100.0,
2952 mass: 1.0,
2953 duration: None,
2954 rest_threshold: None,
2955 };
2956 let t = spring_rest_time(&config);
2957 assert!(
2958 t > 0.0 && t < MAX_SPRING_SEARCH_SECONDS,
2959 "expected a finite, non-degenerate settle time, got {t}"
2960 );
2961
2962 for i in 1..=200 {
2965 let sample_t = t + i as f64 * 0.1;
2966 let v = spring_value_raw(sample_t, 200.0, 100.0, 1.0);
2967 assert_ne!(
2968 v, 1.0,
2969 "an overdamped spring must never hit its target exactly (t={sample_t})"
2970 );
2971 }
2972 }
2973
2974 #[test]
2975 fn undamped_spring_is_capped_not_infinite() {
2976 let config = SpringConfig {
2980 damping: 0.0,
2981 stiffness: 100.0,
2982 mass: 1.0,
2983 duration: None,
2984 rest_threshold: None,
2985 };
2986 let t = spring_rest_time(&config);
2987 assert_eq!(
2988 t, MAX_SPRING_SEARCH_SECONDS,
2989 "an undamped spring must be reported as capped at the search bound, got {t}"
2990 );
2991 }
2992
2993 #[test]
2994 fn very_lightly_damped_spring_is_also_capped_when_beyond_the_bound() {
2995 let config = SpringConfig {
3001 damping: 0.05,
3002 stiffness: 100.0,
3003 mass: 1.0,
3004 duration: None,
3005 rest_threshold: None,
3006 };
3007 let t = spring_rest_time(&config);
3008 assert_eq!(
3009 t, MAX_SPRING_SEARCH_SECONDS,
3010 "expected the search to hit its cap, got {t}"
3011 );
3012 }
3013
3014 #[test]
3015 fn duration_remap_preserves_shape() {
3016 let damping = 6.0;
3024 let stiffness = 120.0;
3025 let mass = 1.0;
3026 let natural = SpringConfig {
3027 damping,
3028 stiffness,
3029 mass,
3030 duration: None,
3031 rest_threshold: None,
3032 };
3033 let natural_rest = spring_rest_time(&natural);
3034
3035 let pinned_duration = 2.5; let pinned = SpringConfig {
3037 damping,
3038 stiffness,
3039 mass,
3040 duration: Some(pinned_duration),
3041 rest_threshold: None,
3042 };
3043
3044 let mut natural_overshoots = 0;
3045 let mut pinned_overshoots = 0;
3046 let mut max_natural_overshoot = 0.0_f64;
3047 let mut max_pinned_overshoot = 0.0_f64;
3048 let mut prev_natural_over = false;
3049 let mut prev_pinned_over = false;
3050
3051 for i in 0..=1000 {
3052 let frac = i as f64 / 1000.0;
3053 let v_natural = spring_value(frac * natural_rest, &natural);
3054 let v_pinned = spring_value(frac * pinned_duration, &pinned);
3055
3056 assert!(
3060 (v_natural - v_pinned).abs() < 1e-9,
3061 "shape mismatch at fraction {frac}: natural={v_natural} pinned={v_pinned}"
3062 );
3063
3064 let natural_over = v_natural > 1.0;
3065 if natural_over && !prev_natural_over {
3066 natural_overshoots += 1;
3067 }
3068 prev_natural_over = natural_over;
3069 max_natural_overshoot = max_natural_overshoot.max(v_natural - 1.0);
3070
3071 let pinned_over = v_pinned > 1.0;
3072 if pinned_over && !prev_pinned_over {
3073 pinned_overshoots += 1;
3074 }
3075 prev_pinned_over = pinned_over;
3076 max_pinned_overshoot = max_pinned_overshoot.max(v_pinned - 1.0);
3077 }
3078
3079 assert!(
3080 natural_overshoots > 0,
3081 "expected this underdamped spring to overshoot at least once"
3082 );
3083 assert_eq!(
3084 natural_overshoots, pinned_overshoots,
3085 "oscillation count must be identical with/without duration"
3086 );
3087 assert!(
3088 (max_natural_overshoot - max_pinned_overshoot).abs() < 1e-9,
3089 "overshoot amplitude must be identical with/without duration: natural={max_natural_overshoot} pinned={max_pinned_overshoot}"
3090 );
3091 }
3092
3093 #[test]
3094 fn duration_does_not_change_delay_semantics() {
3095 let config = SpringConfig {
3101 damping: 6.0,
3102 stiffness: 120.0,
3103 mass: 1.0,
3104 duration: Some(0.8),
3105 rest_threshold: None,
3106 };
3107 assert_eq!(
3108 spring_value(0.0, &config),
3109 spring_value_raw(0.0, 6.0, 120.0, 1.0)
3110 );
3111 }
3112}
3113
3114#[cfg(test)]
3115mod motion_path_tests {
3116 use super::*;
3117
3118 fn cfg(path: &str) -> MotionPathConfig {
3119 MotionPathConfig {
3120 path: path.to_string(),
3121 delay: 0.0,
3122 duration: 1.0,
3123 repeat: false,
3124 orient: false,
3125 orient_offset: 0.0,
3126 easing: EasingType::Linear,
3127 }
3128 }
3129
3130 #[test]
3143 fn mid_path_progress_lands_on_the_curve_not_on_the_endpoint_chord() {
3144 let c = cfg("M0,0 L100,0 L100,100");
3145 let sample = motion_path_sample(&c, 0.5);
3146
3147 assert!(
3148 (sample.dx - 100.0).abs() < 0.5,
3149 "expected dx≈100 (on the path's corner), got {}",
3150 sample.dx
3151 );
3152 assert!(
3153 (sample.dy - 0.0).abs() < 0.5,
3154 "expected dy≈0 (on the path's corner), got {}",
3155 sample.dy
3156 );
3157
3158 let chord_x = 50.0f32;
3159 let chord_y = 50.0f32;
3160 let dist_from_chord_midpoint =
3161 ((sample.dx - chord_x).powi(2) + (sample.dy - chord_y).powi(2)).sqrt();
3162 assert!(
3163 dist_from_chord_midpoint > 40.0,
3164 "t=0.5 must not land near the endpoint-to-endpoint chord midpoint (50,50) — got \
3165 ({}, {}), which would also pass under a plain linear-interpolation bug",
3166 sample.dx,
3167 sample.dy
3168 );
3169 }
3170
3171 #[test]
3174 fn progress_zero_and_one_land_on_the_paths_own_endpoints() {
3175 let c = cfg("M10,20 L310,20 L310,220");
3176 let start = motion_path_sample(&c, 0.0);
3177 assert!((start.dx - 10.0).abs() < 0.5 && (start.dy - 20.0).abs() < 0.5);
3178
3179 let end = motion_path_sample(&c, 1.0);
3180 assert!((end.dx - 310.0).abs() < 0.5 && (end.dy - 220.0).abs() < 0.5);
3181 }
3182
3183 #[test]
3190 fn path_coordinates_are_used_literally_as_the_translate_delta() {
3191 let c = cfg("M100,50 L300,50");
3192 let sample = motion_path_sample(&c, 0.0);
3193 assert!(
3194 (sample.dx - 100.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5,
3195 "expected the raw path start point (100, 50) as the delta, got ({}, {})",
3196 sample.dx,
3197 sample.dy
3198 );
3199 }
3200
3201 #[test]
3204 fn apply_motion_paths_writes_translate_and_rotation_additively() {
3205 let mut props = AnimatedProperties {
3206 translate_x: 5.0,
3207 translate_y: -5.0,
3208 ..AnimatedProperties::default()
3209 };
3210 let mut c = cfg("M0,0 L100,0");
3211 c.orient = true;
3212 apply_motion_paths(&mut props, &[c], 0.0);
3213
3214 assert!((props.translate_x - 5.0).abs() < 0.5);
3218 assert!((props.translate_y - (-5.0)).abs() < 0.5);
3219 assert!(props.rotation.abs() < 0.5, "got {}", props.rotation);
3221 }
3222
3223 #[test]
3224 fn orient_false_never_touches_rotation() {
3225 let c = cfg("M0,0 L0,100");
3228 let mut props = AnimatedProperties::default();
3229 apply_motion_paths(&mut props, &[c], 0.5);
3230 assert_eq!(props.rotation, 0.0);
3231 }
3232
3233 #[test]
3234 fn orient_true_rotates_toward_the_tangent_and_offset_is_additive() {
3235 let mut vertical = cfg("M0,0 L0,100");
3236 vertical.orient = true;
3237 let sample = motion_path_sample(&vertical, 0.5);
3238 assert!(
3240 (sample.angle_deg - 90.0).abs() < 1.0,
3241 "got {}",
3242 sample.angle_deg
3243 );
3244
3245 let mut with_offset = vertical.clone();
3246 with_offset.orient_offset = 10.0;
3247 let offset_sample = motion_path_sample(&with_offset, 0.5);
3248 assert!(
3249 (offset_sample.angle_deg - 100.0).abs() < 1.0,
3250 "orient_offset must add on top of the tangent angle, got {}",
3251 offset_sample.angle_deg
3252 );
3253 }
3254
3255 #[test]
3258 fn single_point_path_holds_position_and_never_produces_nan() {
3259 let mut c = cfg("M50,50");
3260 c.orient = true;
3261 for t in [-1.0, 0.0, 0.3, 0.5, 1.0, 2.0] {
3262 let sample = motion_path_sample(&c, t);
3263 assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3264 assert!((sample.dx - 50.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5);
3265 assert_eq!(
3266 sample.angle_deg, 0.0,
3267 "orientation is undefined at zero length and must default to 0, not NaN"
3268 );
3269 }
3270 }
3271
3272 #[test]
3273 fn coincident_points_zero_length_path_holds_without_nan() {
3274 let mut c = cfg("M10,10 L10,10 L10,10");
3275 c.orient = true;
3276 let sample = motion_path_sample(&c, 0.5);
3277 assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3278 assert!((sample.dx - 10.0).abs() < 0.5 && (sample.dy - 10.0).abs() < 0.5);
3279 assert_eq!(sample.angle_deg, 0.0);
3280 }
3281
3282 #[test]
3283 fn empty_path_data_never_panics_or_produces_nan() {
3284 let c = cfg("");
3288 let sample = motion_path_sample(&c, 0.5);
3289 assert_eq!((sample.dx, sample.dy, sample.angle_deg), (0.0, 0.0, 0.0));
3290 }
3291
3292 #[test]
3293 fn unparsable_path_data_never_panics_or_produces_nan() {
3294 let c = cfg("definitely not svg path data");
3295 let sample = motion_path_sample(&c, 0.5);
3296 assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite());
3297 }
3298
3299 #[test]
3300 fn zero_or_negative_duration_never_produces_nan() {
3301 for duration in [0.0, -1.0, -0.5] {
3302 let mut c = cfg("M0,0 L100,0");
3303 c.duration = duration;
3304 for t in [0.0, 0.5, 1.0, 5.0] {
3305 let sample = motion_path_sample(&c, t);
3306 assert!(
3307 sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite(),
3308 "duration={duration} time={t} produced a non-finite sample: dx={} dy={}",
3309 sample.dx,
3310 sample.dy
3311 );
3312 }
3313 }
3314 }
3315
3316 #[test]
3317 fn motion_path_length_reports_none_for_empty_or_unparsable_input() {
3318 assert_eq!(motion_path_length(""), None);
3319 assert_eq!(motion_path_length("not a path"), None);
3320 }
3321
3322 #[test]
3323 fn motion_path_length_reports_near_zero_for_a_single_point() {
3324 let len = motion_path_length("M50,50").expect("single point is a valid, parseable path");
3325 assert!(len <= MOTION_PATH_MIN_LENGTH, "got {len}");
3326 }
3327
3328 #[test]
3329 fn motion_path_length_reports_the_real_length_for_a_real_path() {
3330 let len = motion_path_length("M0,0 L100,0").expect("valid path");
3331 assert!((len - 100.0).abs() < 0.5, "got {len}");
3332 }
3333
3334 #[test]
3337 fn looping_wraps_progress_back_toward_the_start() {
3338 let mut c = cfg("M0,0 L100,0 L100,100");
3339 c.repeat = true;
3340 c.duration = 1.0;
3341 let sample = motion_path_sample(&c, 1.5);
3344 assert!((sample.dx - 100.0).abs() < 0.5 && (sample.dy - 0.0).abs() < 0.5);
3345 }
3346
3347 #[test]
3348 fn non_looping_holds_at_the_end_past_delay_plus_duration() {
3349 let c = cfg("M0,0 L100,0 L100,100");
3350 let at_end = motion_path_sample(&c, 1.0);
3351 let past_end = motion_path_sample(&c, 5.0);
3352 assert_eq!(at_end.dx, past_end.dx);
3353 assert_eq!(at_end.dy, past_end.dy);
3354 }
3355
3356 #[test]
3359 fn sampling_is_deterministic_across_repeated_calls() {
3360 let c = cfg("M0,0 C50,-100 150,-100 200,0");
3361 let first = motion_path_sample(&c, 0.37);
3362 for _ in 0..25 {
3363 let again = motion_path_sample(&c, 0.37);
3364 assert_eq!(first.dx, again.dx);
3365 assert_eq!(first.dy, again.dy);
3366 assert_eq!(first.angle_deg, again.angle_deg);
3367 }
3368 }
3369
3370 #[test]
3371 fn resolve_props_for_effects_is_deterministic_and_reaches_translate() {
3372 let effect = AnimationEffect::MotionPath(cfg("M0,0 L400,0"));
3373 let effects = vec![effect];
3374 let a = resolve_props_for_effects(&effects, 0.5, 1.0);
3375 let b = resolve_props_for_effects(&effects, 0.5, 1.0);
3376 assert_eq!(a.translate_x, b.translate_x);
3377 assert_eq!(a.translate_y, b.translate_y);
3378 assert!(
3379 (a.translate_x - 200.0).abs() < 1.0,
3380 "expected ~halfway along a straight 400px path, got {}",
3381 a.translate_x
3382 );
3383 }
3384}
3385
3386#[cfg(test)]
3387mod char_animation_tuning_tests {
3388 use super::*;
3389
3390 fn anim(stagger: f32, jitter: f32, seed: u32) -> ResolvedCharAnimation {
3391 ResolvedCharAnimation {
3392 preset: CharAnimPreset::SlideUp,
3393 granularity: TextAnimGranularity::Word,
3394 stagger,
3395 duration: 0.4,
3396 easing: EasingType::Linear,
3397 delay: 0.5,
3398 overshoot: 0.08,
3399 blur: DEFAULT_CHAR_BLUR_SIGMA,
3400 direction: TextAnimDirection::Up,
3401 distance: 1.0,
3402 scale_from: None,
3403 jitter,
3404 seed,
3405 ink_from: None,
3406 }
3407 }
3408
3409 #[test]
3410 fn without_jitter_units_are_evenly_spaced() {
3411 let a = anim(0.2, 0.0, 0);
3412 for i in 0..6 {
3413 let expected = 0.5 + i as f64 * 0.2;
3414 assert!(
3416 (a.unit_start(i) - expected).abs() < 1e-6,
3417 "unit {i} should start at {expected}, got {}",
3418 a.unit_start(i)
3419 );
3420 }
3421 }
3422
3423 #[test]
3424 fn jitter_is_a_pure_function_of_index_and_seed() {
3425 let a = anim(0.2, 0.6, 42);
3430 let b = anim(0.2, 0.6, 42);
3431 for i in 0..32 {
3432 assert_eq!(
3433 a.unit_start(i).to_bits(),
3434 b.unit_start(i).to_bits(),
3435 "unit {i} must resolve bit-identically for the same seed"
3436 );
3437 }
3438 }
3439
3440 #[test]
3441 fn a_different_seed_reshuffles_the_rhythm() {
3442 let a = anim(0.2, 0.6, 1);
3443 let b = anim(0.2, 0.6, 2);
3444 let differing = (0..32)
3445 .filter(|&i| a.unit_start(i) != b.unit_start(i))
3446 .count();
3447 assert!(
3448 differing > 24,
3449 "changing the seed should move nearly every unit, but only {differing}/32 moved"
3450 );
3451 }
3452
3453 #[test]
3454 fn jitter_actually_perturbs_the_even_spacing() {
3455 let even = anim(0.2, 0.0, 7);
3456 let jittered = anim(0.2, 0.8, 7);
3457 let moved = (1..32)
3458 .filter(|&i| (even.unit_start(i) - jittered.unit_start(i)).abs() > 1e-6)
3459 .count();
3460 assert!(
3461 moved > 20,
3462 "jitter should visibly perturb the march, but only {moved}/31 units moved"
3463 );
3464 }
3465
3466 #[test]
3467 fn no_unit_starts_before_the_effects_own_delay() {
3468 let a = anim(0.2, 2.0, 99);
3471 for i in 0..64 {
3472 assert!(
3473 a.unit_start(i) >= 0.5 - 1e-9,
3474 "unit {i} started at {} — before the effect's own 0.5s delay",
3475 a.unit_start(i)
3476 );
3477 }
3478 }
3479
3480 #[test]
3481 fn a_zero_stagger_is_unaffected_by_jitter() {
3482 let a = anim(0.0, 1.0, 3);
3485 for i in 0..8 {
3486 assert!((a.unit_start(i) - 0.5).abs() < 1e-9);
3487 }
3488 }
3489}