rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Animation {
    pub property: String,
    pub keyframes: Vec<Keyframe>,
    #[serde(default = "default_easing")]
    pub easing: EasingType,
    #[serde(default)]
    pub spring: Option<SpringConfig>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Keyframe {
    pub time: f64,
    pub value: KeyframeValue,
    /// Optional per-keyframe easing (overrides animation-level easing for the segment starting at this keyframe)
    #[serde(default)]
    pub easing: Option<EasingType>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum KeyframeValue {
    Number(f64),
    Color(String),
}

impl KeyframeValue {
    pub fn as_f64(&self) -> f64 {
        match self {
            KeyframeValue::Number(n) => *n,
            KeyframeValue::Color(_) => 0.0,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum EasingType {
    #[default]
    Linear,
    EaseIn,
    EaseOut,
    EaseInOut,
    EaseInQuad,
    EaseOutQuad,
    EaseInCubic,
    EaseOutCubic,
    EaseInExpo,
    EaseOutExpo,
    EaseInOutQuad,
    EaseInOutExpo,
    EaseInBack,
    EaseOutBack,
    EaseOutElastic,
    Bounce,
    Spring,
    /// Custom cubic-bezier easing curve: cubic_bezier(x1, y1, x2, y2)
    CubicBezier {
        x1: f64,
        y1: f64,
        x2: f64,
        y2: f64,
    },
    /// Discrete jump easing (CSS `steps(n, jump-end)`): `t` snaps to one of
    /// `n` equally-sized steps instead of interpolating continuously. The
    /// value holds at step `i`'s level (`i / n`) for the whole `[i/n,
    /// (i+1)/n)` span of `t`, then jumps — it never eases through the
    /// space between two states. `steps(1)` is the degenerate, most useful
    /// case: hold the start value for the entire segment, then jump to the
    /// end exactly at `t = 1.0` — a blinking caret wants this (a discrete
    /// on/off), not a fade. See `engine::animator::ease` for the formula.
    Steps(u32),
}

fn default_easing() -> EasingType {
    EasingType::EaseOut
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SpringConfig {
    #[serde(default = "default_damping")]
    pub damping: f64,
    #[serde(default = "default_stiffness")]
    pub stiffness: f64,
    #[serde(default = "default_mass")]
    pub mass: f64,
    /// Force the spring to *visually* settle at exactly this many seconds
    /// (issue #167 lot E), instead of leaving the settle time as an
    /// emergent, hard-to-predict consequence of `damping`/`stiffness`/
    /// `mass`. Implemented as a linear rescale of the time axis fed to the
    /// physics solver (`engine::animator::spring_value`): the spring's
    /// *shape* — number of oscillations, overshoot amplitude — is a
    /// function of `damping`/`stiffness`/`mass` alone and is unchanged;
    /// only how fast that shape plays back changes. `None` (default)
    /// leaves the natural, emergent settle time in place.
    ///
    /// This does not resize the enclosing keyframe segment (the
    /// `delay`/`duration` on the surrounding `AnimationTiming`, or the
    /// author's own keyframe times on a `keyframes` effect): those still
    /// decide when the segment starts and how long it spans. Set that
    /// enclosing span to at least `duration` (`rustmotion info` reports the
    /// computed settle time so you don't have to guess), or the segment's
    /// own end will still cut the spring's motion short.
    #[serde(default)]
    pub duration: Option<f64>,
    /// How close to the target counts as "at rest", as a fraction of the
    /// total 0→1 travel (e.g. `0.01` = 1%). Defaults to
    /// `engine::animator::DEFAULT_SPRING_REST_THRESHOLD` (0.5%) when unset.
    /// Read by `engine::animator::spring_rest_time` — the "how long until
    /// this spring settles" measurement `rustmotion info` surfaces — and,
    /// when `duration` is set, by the remap above to know what "settled"
    /// means. A critically- or over-damped spring approaches its target
    /// asymptotically and never reaches it exactly, which is precisely why
    /// this threshold exists.
    #[serde(default)]
    pub rest_threshold: Option<f64>,
}

impl Default for SpringConfig {
    fn default() -> Self {
        Self {
            damping: 15.0,
            stiffness: 100.0,
            mass: 1.0,
            duration: None,
            rest_threshold: None,
        }
    }
}

fn default_damping() -> f64 {
    15.0
}
fn default_stiffness() -> f64 {
    100.0
}
fn default_mass() -> f64 {
    1.0
}

/// Preset animation names that expand to keyframes automatically
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AnimationPreset {
    FadeIn,
    FadeInUp,
    FadeInDown,
    FadeInLeft,
    FadeInRight,
    SlideInLeft,
    SlideInRight,
    SlideInUp,
    SlideInDown,
    ScaleIn,
    BounceIn,
    BlurIn,
    RotateIn,
    ElasticIn,
    PopIn,
    FadeOut,
    FadeOutUp,
    FadeOutDown,
    SlideOutLeft,
    SlideOutRight,
    SlideOutUp,
    SlideOutDown,
    ScaleOut,
    BounceOut,
    BlurOut,
    RotateOut,
    Pulse,
    Float,
    Shake,
    Spin,
    FlipInX,
    FlipInY,
    FlipOutX,
    FlipOutY,
    TiltIn,
    DrawIn,
    StrokeReveal,
    #[serde(alias = "float_3d")]
    Float3d,
    Typewriter,
    WipeLeft,
    WipeRight,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PresetConfig {
    #[serde(default)]
    pub delay: f64,
    #[serde(default = "default_preset_duration")]
    pub duration: f64,
    /// Loop the animation continuously. Kept as a plain bool — `true`
    /// covers both "loop forever" (`repeat_count: None`) and "loop a known
    /// number of times" (`repeat_count: Some(n)`) — so every reader that
    /// only ever checked this flag (there were several, outside this
    /// workstream's owned files) keeps treating a finite `repeat_count` as
    /// "this loops" rather than mis-reading it as a one-shot animation.
    #[serde(default, rename = "loop")]
    pub repeat: bool,
    /// How many times the animation plays when `AnimationTiming`'s widened
    /// `"loop"` field named an exact integer count rather than a bare
    /// bool (issue #330). `None` defers entirely to `repeat`: `true` loops
    /// forever, `false` plays once. See
    /// `AnimationTiming::{repeat, repeat_count}` for where this is parsed
    /// from JSON, and `engine::animator::cycle_time` for how it's played
    /// back.
    #[serde(default)]
    pub repeat_count: Option<u32>,
    /// Reverse direction on every other play (ping-pong) instead of
    /// snapping back to the start each cycle — GSAP calls this `yoyo`.
    /// Only meaningful when the animation actually repeats (`repeat` or
    /// `repeat_count`); a no-op otherwise. Applies to any looping
    /// animation, not a fixed set of presets — see
    /// `engine::animator::cycle_time`.
    #[serde(default)]
    pub yoyo: bool,
    /// Pause between plays, in seconds, held at the resting value of the
    /// play that just finished before the next one starts. `0.0` (default)
    /// is a seamless loop.
    #[serde(default)]
    pub repeat_delay: f64,
    /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%).
    #[serde(default)]
    pub overshoot: Option<f64>,
    /// Spring physics applied to the preset's motion keyframes (see
    /// `AnimationTiming::spring`).
    #[serde(default)]
    pub spring: Option<SpringConfig>,
    /// Travel of an oscillating preset, in pixels (`float_3d`; default 12).
    ///
    /// Varying it across elements in one scene is what turns a shared bob into
    /// parallax: things at different depths move by different amounts.
    #[serde(default)]
    pub amplitude: Option<f64>,
}

impl Default for PresetConfig {
    fn default() -> Self {
        Self {
            delay: 0.0,
            duration: 0.8,
            repeat: false,
            repeat_count: None,
            yoyo: false,
            repeat_delay: 0.0,
            overshoot: None,
            spring: None,
            amplitude: None,
        }
    }
}

fn default_preset_duration() -> f64 {
    0.8
}

/// Per-element channel-split effect: the red and cyan (green+blue) channels
/// of the node's own rendered content separate by `amount` px and converge
/// back to a perfect overlap by the end of `duration` — the same "zero at
/// the end" guarantee `TransitionType::ChromaticWipe` gives its reveal edge
/// (see `engine::transition::chromatic_wipe`), so no node is left
/// permanently fringed once the effect has played.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ChromaticAberrationConfig {
    /// Delay before the split starts (seconds).
    #[serde(default)]
    pub delay: f64,
    /// How long the channels take to converge back to zero separation
    /// (seconds).
    #[serde(default = "default_chromatic_aberration_duration")]
    pub duration: f64,
    /// How far the red and cyan channels separate at the peak (px), reached
    /// the instant the effect starts (`delay`) and decaying to `0` by
    /// `delay + duration`.
    #[serde(default = "default_chromatic_aberration_amount")]
    pub amount: f32,
    /// Easing applied to the decay from `amount` down to zero.
    #[serde(default = "default_easing")]
    pub easing: EasingType,
}

fn default_chromatic_aberration_duration() -> f64 {
    0.6
}

fn default_chromatic_aberration_amount() -> f32 {
    6.0
}

#[cfg(test)]
mod deny_unknown_fields_tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn animation_rejects_unknown_fields() {
        let json = json!({
            "property": "opacity",
            "keyframes": [{ "time": 0.0, "value": 1.0 }],
            "easing": "ease_out",
            "duratoin": 5.0
        });
        let err = serde_json::from_value::<Animation>(json)
            .expect_err("a typo'd field on Animation must be rejected, not silently ignored");
        assert!(err.to_string().contains("duratoin"), "got: {err}");
    }

    #[test]
    fn keyframe_rejects_unknown_fields() {
        let json = json!({ "time": 0.0, "value": 1.0, "eaisng": "linear" });
        let err = serde_json::from_value::<Keyframe>(json)
            .expect_err("a typo'd field on Keyframe must be rejected, not silently ignored");
        assert!(err.to_string().contains("eaisng"), "got: {err}");
    }

    #[test]
    fn animation_still_accepts_every_known_field() {
        let json = json!({
            "property": "opacity",
            "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "linear" },
                { "time": 1.0, "value": 1.0 }
            ],
            "easing": "ease_out",
            "spring": { "damping": 10.0, "stiffness": 100.0, "mass": 1.0 }
        });
        let a: Animation = serde_json::from_value(json).unwrap();
        assert_eq!(a.property, "opacity");
        assert_eq!(a.keyframes.len(), 2);
        assert!(a.spring.is_some());
    }

    #[test]
    fn keyframe_still_accepts_every_known_field() {
        let json = json!({ "time": 0.5, "value": 10.0, "easing": "ease_in" });
        let k: Keyframe = serde_json::from_value(json).unwrap();
        assert_eq!(k.time, 0.5);
        assert!(k.easing.is_some());
    }

    #[test]
    fn chromatic_aberration_config_rejects_unknown_fields() {
        let json = json!({ "amount": 6.0, "duratoin": 0.6 });
        let err = serde_json::from_value::<ChromaticAberrationConfig>(json)
            .expect_err("a typo'd field must be rejected, not silently ignored");
        assert!(err.to_string().contains("duratoin"), "got: {err}");
    }

    #[test]
    fn chromatic_aberration_config_defaults() {
        let json = json!({});
        let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap();
        assert_eq!(cfg.delay, 0.0);
        assert_eq!(cfg.duration, default_chromatic_aberration_duration());
        assert_eq!(cfg.amount, default_chromatic_aberration_amount());
    }

    #[test]
    fn chromatic_aberration_config_accepts_issue_shape() {
        let json = json!({ "amount": 6, "duration": 0.6 });
        let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap();
        assert_eq!(cfg.amount, 6.0);
        assert_eq!(cfg.duration, 0.6);
    }
}