use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::Path as SkiaPath;
use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig};
use super::style::{FontWeight, TextAlign, VerticalAlign};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "name", rename_all = "snake_case")]
pub enum AnimationEffect {
FadeIn(AnimationTiming),
FadeInUp(AnimationTiming),
FadeInDown(AnimationTiming),
FadeInLeft(AnimationTiming),
FadeInRight(AnimationTiming),
SlideInLeft(AnimationTiming),
SlideInRight(AnimationTiming),
SlideInUp(AnimationTiming),
SlideInDown(AnimationTiming),
ScaleIn(AnimationTiming),
BounceIn(AnimationTiming),
BlurIn(AnimationTiming),
RotateIn(AnimationTiming),
ElasticIn(AnimationTiming),
PopIn(AnimationTiming),
FadeOut(AnimationTiming),
FadeOutUp(AnimationTiming),
FadeOutDown(AnimationTiming),
SlideOutLeft(AnimationTiming),
SlideOutRight(AnimationTiming),
SlideOutUp(AnimationTiming),
SlideOutDown(AnimationTiming),
ScaleOut(AnimationTiming),
BounceOut(AnimationTiming),
BlurOut(AnimationTiming),
RotateOut(AnimationTiming),
Pulse(AnimationTiming),
Float(AnimationTiming),
Shake(AnimationTiming),
Spin(AnimationTiming),
FlipInX(AnimationTiming),
FlipInY(AnimationTiming),
FlipOutX(AnimationTiming),
FlipOutY(AnimationTiming),
TiltIn(TiltInConfig),
DrawIn(AnimationTiming),
StrokeReveal(AnimationTiming),
Typewriter(AnimationTiming),
WipeLeft(AnimationTiming),
WipeRight(AnimationTiming),
#[serde(alias = "float_3d")]
Float3d(AnimationTiming),
CharScaleIn(CharAnimationTiming),
CharFadeIn(CharAnimationTiming),
CharWave(CharAnimationTiming),
CharBounce(CharAnimationTiming),
CharRotateIn(CharAnimationTiming),
CharSlideUp(CharAnimationTiming),
CharBlurIn(CharAnimationTiming),
Glow(GlowConfig),
Shimmer(ShimmerConfig),
Wiggle(WiggleConfig),
Orbit(OrbitConfig),
Keyframes(KeyframesConfig),
MotionBlur(MotionBlurConfig),
Trail(TrailConfig),
MotionPath(MotionPathConfig),
}
impl AnimationEffect {
pub fn shift_delay(&mut self, by: f64) {
use AnimationEffect::*;
match self {
FadeIn(t) | FadeInUp(t) | FadeInDown(t) | FadeInLeft(t) | FadeInRight(t)
| SlideInLeft(t) | SlideInRight(t) | SlideInUp(t) | SlideInDown(t) | ScaleIn(t)
| BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | PopIn(t) | FadeOut(t)
| FadeOutUp(t) | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t)
| SlideOutUp(t) | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t)
| RotateOut(t) | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t)
| FlipOutX(t) | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t)
| WipeLeft(t) | WipeRight(t) | Float3d(t) => t.delay += by,
TiltIn(c) => c.delay += by,
CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c)
| CharSlideUp(c) | CharBlurIn(c) => c.delay += by,
Keyframes(c) => c.delay += by,
MotionPath(c) => c.delay += by,
Shimmer(c) => c.delay += by,
Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {}
}
}
pub fn as_preset(&self) -> Option<(AnimationPreset, &AnimationTiming)> {
match self {
Self::FadeIn(t) => Some((AnimationPreset::FadeIn, t)),
Self::FadeInUp(t) => Some((AnimationPreset::FadeInUp, t)),
Self::FadeInDown(t) => Some((AnimationPreset::FadeInDown, t)),
Self::FadeInLeft(t) => Some((AnimationPreset::FadeInLeft, t)),
Self::FadeInRight(t) => Some((AnimationPreset::FadeInRight, t)),
Self::SlideInLeft(t) => Some((AnimationPreset::SlideInLeft, t)),
Self::SlideInRight(t) => Some((AnimationPreset::SlideInRight, t)),
Self::SlideInUp(t) => Some((AnimationPreset::SlideInUp, t)),
Self::SlideInDown(t) => Some((AnimationPreset::SlideInDown, t)),
Self::ScaleIn(t) => Some((AnimationPreset::ScaleIn, t)),
Self::BounceIn(t) => Some((AnimationPreset::BounceIn, t)),
Self::BlurIn(t) => Some((AnimationPreset::BlurIn, t)),
Self::RotateIn(t) => Some((AnimationPreset::RotateIn, t)),
Self::ElasticIn(t) => Some((AnimationPreset::ElasticIn, t)),
Self::PopIn(t) => Some((AnimationPreset::PopIn, t)),
Self::FadeOut(t) => Some((AnimationPreset::FadeOut, t)),
Self::FadeOutUp(t) => Some((AnimationPreset::FadeOutUp, t)),
Self::FadeOutDown(t) => Some((AnimationPreset::FadeOutDown, t)),
Self::SlideOutLeft(t) => Some((AnimationPreset::SlideOutLeft, t)),
Self::SlideOutRight(t) => Some((AnimationPreset::SlideOutRight, t)),
Self::SlideOutUp(t) => Some((AnimationPreset::SlideOutUp, t)),
Self::SlideOutDown(t) => Some((AnimationPreset::SlideOutDown, t)),
Self::ScaleOut(t) => Some((AnimationPreset::ScaleOut, t)),
Self::BounceOut(t) => Some((AnimationPreset::BounceOut, t)),
Self::BlurOut(t) => Some((AnimationPreset::BlurOut, t)),
Self::RotateOut(t) => Some((AnimationPreset::RotateOut, t)),
Self::Pulse(t) => Some((AnimationPreset::Pulse, t)),
Self::Float(t) => Some((AnimationPreset::Float, t)),
Self::Shake(t) => Some((AnimationPreset::Shake, t)),
Self::Spin(t) => Some((AnimationPreset::Spin, t)),
Self::FlipInX(t) => Some((AnimationPreset::FlipInX, t)),
Self::FlipInY(t) => Some((AnimationPreset::FlipInY, t)),
Self::FlipOutX(t) => Some((AnimationPreset::FlipOutX, t)),
Self::FlipOutY(t) => Some((AnimationPreset::FlipOutY, t)),
Self::TiltIn(_) => None,
Self::DrawIn(t) => Some((AnimationPreset::DrawIn, t)),
Self::StrokeReveal(t) => Some((AnimationPreset::StrokeReveal, t)),
Self::Float3d(t) => Some((AnimationPreset::Float3d, t)),
Self::Typewriter(t) => Some((AnimationPreset::Typewriter, t)),
Self::WipeLeft(t) => Some((AnimationPreset::WipeLeft, t)),
Self::WipeRight(t) => Some((AnimationPreset::WipeRight, t)),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AnimationTiming {
#[serde(default)]
pub delay: f64,
#[serde(default = "default_animation_duration")]
pub duration: f64,
#[serde(default, rename = "loop")]
pub repeat: bool,
#[serde(default)]
pub overshoot: Option<f64>,
#[serde(default)]
pub spring: Option<SpringConfig>,
#[serde(default)]
pub amplitude: Option<f64>,
}
fn default_animation_duration() -> f64 {
0.8
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TiltInConfig {
#[serde(default)]
pub delay: f64,
#[serde(default = "default_animation_duration")]
pub duration: f64,
#[serde(default, rename = "loop")]
pub repeat: bool,
#[serde(default)]
pub rotate_x: Option<f64>,
#[serde(default)]
pub rotate_y: Option<f64>,
#[serde(default)]
pub perspective: Option<f64>,
#[serde(default)]
pub scale_from: Option<f64>,
}
impl Default for AnimationTiming {
fn default() -> Self {
Self {
delay: 0.0,
duration: 0.8,
repeat: false,
overshoot: None,
spring: None,
amplitude: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CharAnimationTiming {
#[serde(default)]
pub delay: f64,
#[serde(default = "default_char_duration_f64")]
pub duration: f64,
#[serde(default = "default_char_stagger_f64")]
pub stagger: f64,
#[serde(default)]
pub granularity: TextAnimGranularity,
#[serde(default)]
pub easing: EasingType,
#[serde(default)]
pub overshoot: Option<f64>,
#[serde(default)]
pub blur: Option<f64>,
#[serde(default)]
pub direction: TextAnimDirection,
#[serde(default)]
pub distance: Option<f64>,
#[serde(default)]
pub scale_from: Option<f64>,
#[serde(default)]
pub jitter: Option<f64>,
#[serde(default)]
pub seed: Option<u32>,
#[serde(default)]
pub ink_from: Option<String>,
}
impl Default for CharAnimationTiming {
fn default() -> Self {
Self {
delay: 0.0,
duration: default_char_duration_f64(),
stagger: default_char_stagger_f64(),
granularity: TextAnimGranularity::default(),
easing: EasingType::default(),
overshoot: None,
blur: None,
direction: TextAnimDirection::default(),
distance: None,
scale_from: None,
jitter: None,
seed: None,
ink_from: None,
}
}
}
fn default_char_stagger_f64() -> f64 {
0.03
}
fn default_char_duration_f64() -> f64 {
0.4
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct CharAnimation {
#[serde(default = "default_char_preset")]
pub preset: CharAnimPreset,
#[serde(default)]
pub granularity: TextAnimGranularity,
#[serde(default = "default_char_stagger")]
pub stagger: f32,
#[serde(default = "default_char_duration")]
pub duration: f32,
#[serde(default)]
pub easing: EasingType,
#[serde(default)]
pub delay: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
#[derive(PartialEq)]
pub enum TextAnimGranularity {
#[default]
Char,
Word,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TextAnimDirection {
#[default]
Up,
Down,
Left,
Right,
}
impl TextAnimDirection {
pub fn offset(self, travel: f32) -> (f32, f32) {
match self {
Self::Up => (0.0, travel),
Self::Down => (0.0, -travel),
Self::Left => (travel, 0.0),
Self::Right => (-travel, 0.0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
#[derive(PartialEq)]
pub enum CharAnimPreset {
#[default]
ScaleIn,
FadeIn,
Wave,
Bounce,
RotateIn,
SlideUp,
BlurIn,
}
fn default_char_preset() -> CharAnimPreset {
CharAnimPreset::ScaleIn
}
fn default_char_stagger() -> f32 {
0.03
}
fn default_char_duration() -> f32 {
0.4
}
impl AnimationTiming {
pub fn to_preset_config(&self) -> PresetConfig {
PresetConfig {
amplitude: self.amplitude,
delay: self.delay,
duration: self.duration,
repeat: self.repeat,
overshoot: self.overshoot,
spring: self.spring.clone(),
}
}
}
const KNOWN_MOTION_PROPERTIES: &[&str] = &[
"opacity",
"position.x",
"translate_x",
"position.y",
"translate_y",
"scale",
"scale.x",
"scale.y",
"rotation",
"rotate_x",
"rotate_y",
"blur",
"visible_chars",
"visible_chars_progress",
"border_radius",
"font_size",
"width",
"height",
"gap",
"padding",
"stroke_width",
"shadow_blur",
"glow_radius",
"glow_intensity",
"perspective",
"draw_progress",
"motion_progress",
"color",
];
fn validate_motion_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
if KNOWN_MOTION_PROPERTIES.contains(&value) {
return Ok(());
}
let normalize = |s: &str| s.replace(['-', ' '], "_").to_lowercase();
let normalized = normalize(value);
if let Some(suggestion) = KNOWN_MOTION_PROPERTIES
.iter()
.find(|known| normalize(known) == normalized)
{
Err(E::custom(format!(
"unknown animation property '{value}' — did you mean '{suggestion}'?"
)))
} else {
Err(E::custom(format!(
"unknown animation property '{value}': expected one of {}",
KNOWN_MOTION_PROPERTIES.join(", ")
)))
}
}
fn deserialize_motion_property<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
validate_motion_property::<D::Error>(&s)?;
Ok(s)
}
fn deserialize_validated_keyframes<'de, D>(deserializer: D) -> Result<Vec<Animation>, D::Error>
where
D: serde::Deserializer<'de>,
{
let animations = Vec::<Animation>::deserialize(deserializer)?;
for anim in &animations {
validate_motion_property::<D::Error>(&anim.property)?;
}
Ok(animations)
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct KeyframesConfig {
#[serde(deserialize_with = "deserialize_validated_keyframes")]
pub keyframes: Vec<Animation>,
#[serde(default)]
pub delay: f64,
#[serde(default = "default_animation_duration")]
pub duration: f64,
#[serde(default, rename = "loop")]
pub repeat: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MotionBlurConfig {
#[serde(default)]
pub intensity: f32,
#[serde(default = "default_motion_blur_samples")]
pub samples: u32,
#[serde(default = "default_motion_blur_shutter")]
pub shutter: f64,
}
fn default_motion_blur_samples() -> u32 {
6
}
fn default_motion_blur_shutter() -> f64 {
0.5
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TrailConfig {
#[serde(default = "default_trail_copies")]
pub copies: u32,
#[serde(default = "default_trail_spacing")]
pub spacing: f64,
#[serde(default = "default_trail_falloff")]
pub falloff: f32,
}
fn default_trail_copies() -> u32 {
4
}
fn default_trail_spacing() -> f64 {
0.05
}
fn default_trail_falloff() -> f32 {
0.6
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct OrbitConfig {
#[serde(default = "default_orbit_radius")]
pub radius_x: f64,
#[serde(default = "default_orbit_radius")]
pub radius_y: f64,
#[serde(default = "default_orbit_speed")]
pub speed: f64,
#[serde(default)]
pub start_angle: f64,
#[serde(default = "default_orbit_depth")]
pub depth: f64,
#[serde(default)]
pub opacity_depth: f64,
#[serde(default)]
pub tilt: f64,
#[serde(default)]
pub phase: f64,
}
fn default_orbit_radius() -> f64 {
30.0
}
fn default_orbit_speed() -> f64 {
0.5
}
fn default_orbit_depth() -> f64 {
0.15
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WiggleConfig {
#[serde(deserialize_with = "deserialize_motion_property")]
pub property: String,
pub amplitude: f64,
pub frequency: f64,
#[serde(default)]
pub seed: u64,
#[serde(default)]
pub octaves: Option<u32>,
#[serde(default)]
pub phase: Option<f64>,
#[serde(default)]
pub decay: Option<f64>,
#[serde(default)]
pub easing: Option<EasingType>,
#[serde(default)]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct MotionPathConfig {
#[serde(deserialize_with = "deserialize_motion_path_data")]
pub path: String,
#[serde(default)]
pub delay: f64,
#[serde(default = "default_animation_duration")]
pub duration: f64,
#[serde(default, rename = "loop")]
pub repeat: bool,
#[serde(default)]
pub orient: bool,
#[serde(default)]
pub orient_offset: f64,
#[serde(default)]
pub easing: EasingType,
}
fn deserialize_motion_path_data<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match SkiaPath::from_svg(&s) {
Some(path) if path.count_points() > 0 => Ok(s),
Some(_) => Err(serde::de::Error::custom(format!(
"motion_path.path '{s}' has no drawable point (an empty path has nothing to travel \
to) — provide at least one command, e.g. \"M0,0 L100,0\""
))),
None => Err(serde::de::Error::custom(format!(
"motion_path.path '{s}' is not valid SVG path data (the same 'd'-attribute \
mini-language shape's type: \"path\" accepts), e.g. \"M0,0 C50,-100 150,-100 200,0\""
))),
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ShapeType {
Rect,
Circle,
RoundedRect,
Ellipse,
Triangle,
Star {
#[serde(default = "default_star_points")]
points: u32,
},
Polygon {
#[serde(default = "default_polygon_sides")]
sides: u32,
},
Path {
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Fill {
Solid(String),
Gradient(Gradient),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Gradient {
#[serde(rename = "type")]
pub gradient_type: GradientType,
pub colors: Vec<String>,
#[serde(default)]
pub stops: Option<Vec<f32>>,
#[serde(default)]
pub angle: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GradientType {
Linear,
Radial,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Stroke {
pub color: String,
#[serde(default = "default_stroke_width")]
pub width: f32,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ImageFit {
Cover,
#[default]
Contain,
Fill,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ShapeText {
pub content: String,
#[serde(default = "default_font_size")]
pub font_size: f32,
#[serde(default = "default_color")]
pub color: String,
#[serde(default = "default_font_family")]
pub font_family: String,
#[serde(default)]
pub font_weight: FontWeight,
#[serde(default)]
pub align: TextAlign,
#[serde(default)]
pub vertical_align: VerticalAlign,
#[serde(default)]
pub line_height: Option<f32>,
#[serde(default)]
pub letter_spacing: Option<f32>,
#[serde(default)]
pub padding: Option<f32>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CaptionWord {
pub text: String,
pub start: f64,
pub end: f64,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum CaptionStyle {
#[default]
Highlight,
Karaoke,
WordByWord,
WordPop,
KaraokePop,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GradientBorder {
pub colors: Vec<String>,
#[serde(default = "default_gradient_border_width")]
pub width: f32,
#[serde(default)]
pub angle: f32,
}
fn default_gradient_border_width() -> f32 {
2.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct InnerShadow {
pub color: String,
#[serde(default)]
pub offset_x: f32,
#[serde(default)]
pub offset_y: f32,
#[serde(default = "default_inner_shadow_blur")]
pub blur: f32,
}
fn default_inner_shadow_blur() -> f32 {
10.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TextShadow {
#[serde(default = "default_shadow_color")]
pub color: String,
#[serde(default = "default_shadow_offset")]
pub offset_x: f32,
#[serde(default = "default_shadow_offset")]
pub offset_y: f32,
#[serde(default = "default_shadow_blur")]
pub blur: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TextBackground {
pub color: String,
#[serde(default = "default_text_bg_padding")]
pub padding: f32,
#[serde(default)]
pub corner_radius: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct GlowConfig {
#[serde(default = "default_glow_color")]
pub color: String,
#[serde(default = "default_glow_radius")]
pub radius: f32,
#[serde(default = "default_glow_intensity")]
pub intensity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TextState {
pub at: f64,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TextSwapConfig {
#[serde(default = "default_swap_duration")]
pub duration: f64,
#[serde(default = "default_swap_distance")]
pub distance: f32,
#[serde(default = "default_swap_blur")]
pub blur: f32,
}
impl Default for TextSwapConfig {
fn default() -> Self {
Self {
duration: default_swap_duration(),
distance: default_swap_distance(),
blur: default_swap_blur(),
}
}
}
fn default_swap_duration() -> f64 {
0.45
}
fn default_swap_distance() -> f32 {
18.0
}
fn default_swap_blur() -> f32 {
8.0
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CaretShape {
#[default]
Line,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CaretConfig {
#[serde(default)]
pub shape: CaretShape,
#[serde(default)]
pub color: Option<String>,
#[serde(default = "default_caret_blink")]
pub blink: f32,
#[serde(default)]
pub hide_when_done: bool,
}
impl Default for CaretConfig {
fn default() -> Self {
Self {
shape: CaretShape::default(),
color: None,
blink: default_caret_blink(),
hide_when_done: false,
}
}
}
fn default_caret_blink() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ShimmerConfig {
#[serde(default)]
pub delay: f64,
#[serde(default = "default_shimmer_duration")]
pub duration: f64,
#[serde(default = "default_shimmer_color")]
pub color: String,
#[serde(default = "default_shimmer_width")]
pub width: f32,
#[serde(default = "default_shimmer_intensity")]
pub intensity: f32,
#[serde(default = "default_shimmer_angle")]
pub angle: f32,
#[serde(default, rename = "loop")]
pub repeat: bool,
}
fn default_shimmer_duration() -> f64 {
0.9
}
fn default_shimmer_color() -> String {
"#FFFFFF".to_string()
}
fn default_shimmer_width() -> f32 {
0.35
}
fn default_shimmer_intensity() -> f32 {
0.75
}
fn default_shimmer_angle() -> f32 {
20.0
}
fn default_glow_color() -> String {
"#FFFFFF80".to_string()
}
fn default_glow_radius() -> f32 {
10.0
}
fn default_glow_intensity() -> f32 {
1.0
}
fn default_font_size() -> f32 {
48.0
}
fn default_color() -> String {
"#FFFFFF".to_string()
}
fn default_font_family() -> String {
"Inter".to_string()
}
fn default_stroke_width() -> f32 {
2.0
}
fn default_star_points() -> u32 {
5
}
fn default_polygon_sides() -> u32 {
6
}
fn default_shadow_color() -> String {
"#00000080".to_string()
}
fn default_shadow_offset() -> f32 {
2.0
}
fn default_shadow_blur() -> f32 {
4.0
}
fn default_text_bg_padding() -> f32 {
8.0
}
#[cfg(test)]
mod motion_property_tests {
use super::*;
use serde_json::json;
#[test]
fn wiggle_known_property_still_works() {
let json = json!({
"name": "wiggle",
"property": "translate_x",
"amplitude": 10.0,
"frequency": 1.0
});
let effect: AnimationEffect = serde_json::from_value(json).unwrap();
match effect {
AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "translate_x"),
other => panic!("expected Wiggle, got {other:?}"),
}
}
#[test]
fn wiggle_unknown_property_is_a_named_error_not_a_silent_no_op() {
let json = json!({
"name": "wiggle",
"property": "skew",
"amplitude": 10.0,
"frequency": 1.0
});
let err = serde_json::from_value::<AnimationEffect>(json)
.expect_err("an unrecognised wiggle property must be rejected, not silently inert");
assert!(err.to_string().contains("skew"), "got: {err}");
}
#[test]
fn wiggle_kebab_case_property_gets_a_did_you_mean() {
let json = json!({
"name": "wiggle",
"property": "translate-x",
"amplitude": 10.0,
"frequency": 1.0
});
let err = serde_json::from_value::<AnimationEffect>(json)
.expect_err("kebab-case must not silently resolve to a snake_case no-op");
let msg = err.to_string();
assert!(msg.contains("translate-x"), "got: {msg}");
assert!(
msg.contains("translate_x"),
"expected a did-you-mean nudge toward the correct spelling, got: {msg}"
);
}
#[test]
fn keyframes_animation_known_property_still_works() {
let json = json!({
"name": "keyframes",
"keyframes": [
{ "property": "opacity", "keyframes": [
{ "time": 0.0, "value": 0.0 },
{ "time": 1.0, "value": 1.0 }
]}
]
});
let effect: AnimationEffect = serde_json::from_value(json).unwrap();
match effect {
AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "opacity"),
other => panic!("expected Keyframes, got {other:?}"),
}
}
#[test]
fn keyframes_animation_unknown_property_is_a_named_error() {
let json = json!({
"name": "keyframes",
"keyframes": [
{ "property": "positionX", "keyframes": [
{ "time": 0.0, "value": 0.0 },
{ "time": 1.0, "value": 1.0 }
]}
]
});
let err = serde_json::from_value::<AnimationEffect>(json).expect_err(
"an unrecognised keyframe animation property must be rejected, not silently inert",
);
assert!(err.to_string().contains("positionX"), "got: {err}");
}
#[test]
fn keyframes_animation_color_property_still_works() {
let json = json!({
"name": "keyframes",
"keyframes": [
{ "property": "color", "keyframes": [
{ "time": 0.0, "value": "#000000" },
{ "time": 1.0, "value": "#ffffff" }
]}
]
});
let effect: AnimationEffect = serde_json::from_value(json).unwrap();
assert!(matches!(effect, AnimationEffect::Keyframes(_)));
}
}
#[cfg(test)]
mod motion_path_schema_tests {
use super::*;
use serde_json::json;
#[test]
fn motion_path_deserializes_with_known_fields() {
let json = json!({
"name": "motion_path",
"path": "M0,0 L100,0 L100,100",
"delay": 0.2,
"duration": 1.5,
"loop": true,
"orient": true,
"orient_offset": 90.0,
"easing": "ease_out"
});
let effect: AnimationEffect = serde_json::from_value(json).unwrap();
match effect {
AnimationEffect::MotionPath(cfg) => {
assert_eq!(cfg.path, "M0,0 L100,0 L100,100");
assert_eq!(cfg.delay, 0.2);
assert_eq!(cfg.duration, 1.5);
assert!(cfg.repeat);
assert!(cfg.orient);
assert_eq!(cfg.orient_offset, 90.0);
assert_eq!(cfg.easing, EasingType::EaseOut);
}
other => panic!("expected MotionPath, got {other:?}"),
}
}
#[test]
fn motion_path_defaults_match_documented_values() {
let json = json!({ "name": "motion_path", "path": "M0,0 L10,0" });
let effect: AnimationEffect = serde_json::from_value(json).unwrap();
match effect {
AnimationEffect::MotionPath(cfg) => {
assert_eq!(cfg.delay, 0.0);
assert_eq!(cfg.duration, default_animation_duration());
assert!(!cfg.repeat);
assert!(!cfg.orient);
assert_eq!(cfg.orient_offset, 0.0);
assert_eq!(cfg.easing, EasingType::Linear);
}
other => panic!("expected MotionPath, got {other:?}"),
}
}
#[test]
fn motion_path_rejects_an_empty_path_string() {
let json = json!({ "name": "motion_path", "path": "" });
let err = serde_json::from_value::<AnimationEffect>(json)
.expect_err("an empty motion_path.path must be rejected, not silently accepted");
assert!(err.to_string().contains("no drawable point"), "got: {err}");
}
#[test]
fn motion_path_rejects_unparsable_svg_path_data() {
let json = json!({ "name": "motion_path", "path": "this is not svg path data !!" });
let err = serde_json::from_value::<AnimationEffect>(json)
.expect_err("garbage path data must be rejected, not silently accepted");
assert!(
err.to_string().contains("not valid SVG path data"),
"got: {err}"
);
}
#[test]
fn motion_path_accepts_a_single_point_path() {
let json = json!({ "name": "motion_path", "path": "M50,50" });
let effect: AnimationEffect = serde_json::from_value(json)
.expect("a syntactically valid single-point path must be accepted");
assert!(matches!(effect, AnimationEffect::MotionPath(_)));
}
#[test]
fn motion_path_rejects_unknown_fields() {
let json = json!({ "name": "motion_path", "path": "M0,0 L10,0", "detla": 0.2 });
let err = serde_json::from_value::<AnimationEffect>(json)
.expect_err("a typo'd field on motion_path must be rejected, not silently ignored");
assert!(err.to_string().contains("detla"), "got: {err}");
}
#[test]
fn motion_path_shift_delay_shifts_the_configs_own_delay() {
let mut effect = AnimationEffect::MotionPath(MotionPathConfig {
path: "M0,0 L10,0".to_string(),
delay: 0.5,
duration: 1.0,
repeat: false,
orient: false,
orient_offset: 0.0,
easing: EasingType::Linear,
});
effect.shift_delay(0.25);
match effect {
AnimationEffect::MotionPath(cfg) => assert!((cfg.delay - 0.75).abs() < 1e-9),
other => panic!("expected MotionPath, got {other:?}"),
}
}
}