Skip to main content

rustmotion_core/schema/
animation.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5#[serde(deny_unknown_fields)]
6pub struct Animation {
7    pub property: String,
8    pub keyframes: Vec<Keyframe>,
9    #[serde(default = "default_easing")]
10    pub easing: EasingType,
11    #[serde(default)]
12    pub spring: Option<SpringConfig>,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct Keyframe {
18    pub time: f64,
19    pub value: KeyframeValue,
20    /// Optional per-keyframe easing (overrides animation-level easing for the segment starting at this keyframe)
21    #[serde(default)]
22    pub easing: Option<EasingType>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
26#[serde(untagged)]
27pub enum KeyframeValue {
28    Number(f64),
29    Color(String),
30}
31
32impl KeyframeValue {
33    pub fn as_f64(&self) -> f64 {
34        match self {
35            KeyframeValue::Number(n) => *n,
36            KeyframeValue::Color(_) => 0.0,
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum EasingType {
44    #[default]
45    Linear,
46    EaseIn,
47    EaseOut,
48    EaseInOut,
49    EaseInQuad,
50    EaseOutQuad,
51    EaseInCubic,
52    EaseOutCubic,
53    EaseInExpo,
54    EaseOutExpo,
55    EaseInOutQuad,
56    EaseInOutExpo,
57    EaseInBack,
58    EaseOutBack,
59    EaseOutElastic,
60    Bounce,
61    Spring,
62    /// Custom cubic-bezier easing curve: cubic_bezier(x1, y1, x2, y2)
63    CubicBezier {
64        x1: f64,
65        y1: f64,
66        x2: f64,
67        y2: f64,
68    },
69    /// Discrete jump easing (CSS `steps(n, jump-end)`): `t` snaps to one of
70    /// `n` equally-sized steps instead of interpolating continuously. The
71    /// value holds at step `i`'s level (`i / n`) for the whole `[i/n,
72    /// (i+1)/n)` span of `t`, then jumps — it never eases through the
73    /// space between two states. `steps(1)` is the degenerate, most useful
74    /// case: hold the start value for the entire segment, then jump to the
75    /// end exactly at `t = 1.0` — a blinking caret wants this (a discrete
76    /// on/off), not a fade. See `engine::animator::ease` for the formula.
77    Steps(u32),
78}
79
80fn default_easing() -> EasingType {
81    EasingType::EaseOut
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
85pub struct SpringConfig {
86    #[serde(default = "default_damping")]
87    pub damping: f64,
88    #[serde(default = "default_stiffness")]
89    pub stiffness: f64,
90    #[serde(default = "default_mass")]
91    pub mass: f64,
92    /// Force the spring to *visually* settle at exactly this many seconds
93    /// (issue #167 lot E), instead of leaving the settle time as an
94    /// emergent, hard-to-predict consequence of `damping`/`stiffness`/
95    /// `mass`. Implemented as a linear rescale of the time axis fed to the
96    /// physics solver (`engine::animator::spring_value`): the spring's
97    /// *shape* — number of oscillations, overshoot amplitude — is a
98    /// function of `damping`/`stiffness`/`mass` alone and is unchanged;
99    /// only how fast that shape plays back changes. `None` (default)
100    /// leaves the natural, emergent settle time in place.
101    ///
102    /// This does not resize the enclosing keyframe segment (the
103    /// `delay`/`duration` on the surrounding `AnimationTiming`, or the
104    /// author's own keyframe times on a `keyframes` effect): those still
105    /// decide when the segment starts and how long it spans. Set that
106    /// enclosing span to at least `duration` (`rustmotion info` reports the
107    /// computed settle time so you don't have to guess), or the segment's
108    /// own end will still cut the spring's motion short.
109    #[serde(default)]
110    pub duration: Option<f64>,
111    /// How close to the target counts as "at rest", as a fraction of the
112    /// total 0→1 travel (e.g. `0.01` = 1%). Defaults to
113    /// `engine::animator::DEFAULT_SPRING_REST_THRESHOLD` (0.5%) when unset.
114    /// Read by `engine::animator::spring_rest_time` — the "how long until
115    /// this spring settles" measurement `rustmotion info` surfaces — and,
116    /// when `duration` is set, by the remap above to know what "settled"
117    /// means. A critically- or over-damped spring approaches its target
118    /// asymptotically and never reaches it exactly, which is precisely why
119    /// this threshold exists.
120    #[serde(default)]
121    pub rest_threshold: Option<f64>,
122}
123
124impl Default for SpringConfig {
125    fn default() -> Self {
126        Self {
127            damping: 15.0,
128            stiffness: 100.0,
129            mass: 1.0,
130            duration: None,
131            rest_threshold: None,
132        }
133    }
134}
135
136fn default_damping() -> f64 {
137    15.0
138}
139fn default_stiffness() -> f64 {
140    100.0
141}
142fn default_mass() -> f64 {
143    1.0
144}
145
146/// Preset animation names that expand to keyframes automatically
147#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "snake_case")]
149pub enum AnimationPreset {
150    FadeIn,
151    FadeInUp,
152    FadeInDown,
153    FadeInLeft,
154    FadeInRight,
155    SlideInLeft,
156    SlideInRight,
157    SlideInUp,
158    SlideInDown,
159    ScaleIn,
160    BounceIn,
161    BlurIn,
162    RotateIn,
163    ElasticIn,
164    PopIn,
165    FadeOut,
166    FadeOutUp,
167    FadeOutDown,
168    SlideOutLeft,
169    SlideOutRight,
170    SlideOutUp,
171    SlideOutDown,
172    ScaleOut,
173    BounceOut,
174    BlurOut,
175    RotateOut,
176    Pulse,
177    Float,
178    Shake,
179    Spin,
180    FlipInX,
181    FlipInY,
182    FlipOutX,
183    FlipOutY,
184    TiltIn,
185    DrawIn,
186    StrokeReveal,
187    #[serde(alias = "float_3d")]
188    Float3d,
189    Typewriter,
190    WipeLeft,
191    WipeRight,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
195pub struct PresetConfig {
196    #[serde(default)]
197    pub delay: f64,
198    #[serde(default = "default_preset_duration")]
199    pub duration: f64,
200    /// Loop the animation continuously. Kept as a plain bool — `true`
201    /// covers both "loop forever" (`repeat_count: None`) and "loop a known
202    /// number of times" (`repeat_count: Some(n)`) — so every reader that
203    /// only ever checked this flag (there were several, outside this
204    /// workstream's owned files) keeps treating a finite `repeat_count` as
205    /// "this loops" rather than mis-reading it as a one-shot animation.
206    #[serde(default, rename = "loop")]
207    pub repeat: bool,
208    /// How many times the animation plays when `AnimationTiming`'s widened
209    /// `"loop"` field named an exact integer count rather than a bare
210    /// bool (issue #330). `None` defers entirely to `repeat`: `true` loops
211    /// forever, `false` plays once. See
212    /// `AnimationTiming::{repeat, repeat_count}` for where this is parsed
213    /// from JSON, and `engine::animator::cycle_time` for how it's played
214    /// back.
215    #[serde(default)]
216    pub repeat_count: Option<u32>,
217    /// Reverse direction on every other play (ping-pong) instead of
218    /// snapping back to the start each cycle — GSAP calls this `yoyo`.
219    /// Only meaningful when the animation actually repeats (`repeat` or
220    /// `repeat_count`); a no-op otherwise. Applies to any looping
221    /// animation, not a fixed set of presets — see
222    /// `engine::animator::cycle_time`.
223    #[serde(default)]
224    pub yoyo: bool,
225    /// Pause between plays, in seconds, held at the resting value of the
226    /// play that just finished before the next one starts. `0.0` (default)
227    /// is a seamless loop.
228    #[serde(default)]
229    pub repeat_delay: f64,
230    /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%).
231    #[serde(default)]
232    pub overshoot: Option<f64>,
233    /// Spring physics applied to the preset's motion keyframes (see
234    /// `AnimationTiming::spring`).
235    #[serde(default)]
236    pub spring: Option<SpringConfig>,
237    /// Travel of an oscillating preset, in pixels (`float_3d`; default 12).
238    ///
239    /// Varying it across elements in one scene is what turns a shared bob into
240    /// parallax: things at different depths move by different amounts.
241    #[serde(default)]
242    pub amplitude: Option<f64>,
243}
244
245impl Default for PresetConfig {
246    fn default() -> Self {
247        Self {
248            delay: 0.0,
249            duration: 0.8,
250            repeat: false,
251            repeat_count: None,
252            yoyo: false,
253            repeat_delay: 0.0,
254            overshoot: None,
255            spring: None,
256            amplitude: None,
257        }
258    }
259}
260
261fn default_preset_duration() -> f64 {
262    0.8
263}
264
265/// Per-element channel-split effect: the red and cyan (green+blue) channels
266/// of the node's own rendered content separate by `amount` px and converge
267/// back to a perfect overlap by the end of `duration` — the same "zero at
268/// the end" guarantee `TransitionType::ChromaticWipe` gives its reveal edge
269/// (see `engine::transition::chromatic_wipe`), so no node is left
270/// permanently fringed once the effect has played.
271#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
272#[serde(deny_unknown_fields)]
273pub struct ChromaticAberrationConfig {
274    /// Delay before the split starts (seconds).
275    #[serde(default)]
276    pub delay: f64,
277    /// How long the channels take to converge back to zero separation
278    /// (seconds).
279    #[serde(default = "default_chromatic_aberration_duration")]
280    pub duration: f64,
281    /// How far the red and cyan channels separate at the peak (px), reached
282    /// the instant the effect starts (`delay`) and decaying to `0` by
283    /// `delay + duration`.
284    #[serde(default = "default_chromatic_aberration_amount")]
285    pub amount: f32,
286    /// Easing applied to the decay from `amount` down to zero.
287    #[serde(default = "default_easing")]
288    pub easing: EasingType,
289}
290
291fn default_chromatic_aberration_duration() -> f64 {
292    0.6
293}
294
295fn default_chromatic_aberration_amount() -> f32 {
296    6.0
297}
298
299#[cfg(test)]
300mod deny_unknown_fields_tests {
301    use super::*;
302    use serde_json::json;
303
304    #[test]
305    fn animation_rejects_unknown_fields() {
306        let json = json!({
307            "property": "opacity",
308            "keyframes": [{ "time": 0.0, "value": 1.0 }],
309            "easing": "ease_out",
310            "duratoin": 5.0
311        });
312        let err = serde_json::from_value::<Animation>(json)
313            .expect_err("a typo'd field on Animation must be rejected, not silently ignored");
314        assert!(err.to_string().contains("duratoin"), "got: {err}");
315    }
316
317    #[test]
318    fn keyframe_rejects_unknown_fields() {
319        let json = json!({ "time": 0.0, "value": 1.0, "eaisng": "linear" });
320        let err = serde_json::from_value::<Keyframe>(json)
321            .expect_err("a typo'd field on Keyframe must be rejected, not silently ignored");
322        assert!(err.to_string().contains("eaisng"), "got: {err}");
323    }
324
325    #[test]
326    fn animation_still_accepts_every_known_field() {
327        let json = json!({
328            "property": "opacity",
329            "keyframes": [
330                { "time": 0.0, "value": 0.0, "easing": "linear" },
331                { "time": 1.0, "value": 1.0 }
332            ],
333            "easing": "ease_out",
334            "spring": { "damping": 10.0, "stiffness": 100.0, "mass": 1.0 }
335        });
336        let a: Animation = serde_json::from_value(json).unwrap();
337        assert_eq!(a.property, "opacity");
338        assert_eq!(a.keyframes.len(), 2);
339        assert!(a.spring.is_some());
340    }
341
342    #[test]
343    fn keyframe_still_accepts_every_known_field() {
344        let json = json!({ "time": 0.5, "value": 10.0, "easing": "ease_in" });
345        let k: Keyframe = serde_json::from_value(json).unwrap();
346        assert_eq!(k.time, 0.5);
347        assert!(k.easing.is_some());
348    }
349
350    #[test]
351    fn chromatic_aberration_config_rejects_unknown_fields() {
352        let json = json!({ "amount": 6.0, "duratoin": 0.6 });
353        let err = serde_json::from_value::<ChromaticAberrationConfig>(json)
354            .expect_err("a typo'd field must be rejected, not silently ignored");
355        assert!(err.to_string().contains("duratoin"), "got: {err}");
356    }
357
358    #[test]
359    fn chromatic_aberration_config_defaults() {
360        let json = json!({});
361        let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap();
362        assert_eq!(cfg.delay, 0.0);
363        assert_eq!(cfg.duration, default_chromatic_aberration_duration());
364        assert_eq!(cfg.amount, default_chromatic_aberration_amount());
365    }
366
367    #[test]
368    fn chromatic_aberration_config_accepts_issue_shape() {
369        let json = json!({ "amount": 6, "duration": 0.6 });
370        let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap();
371        assert_eq!(cfg.amount, 6.0);
372        assert_eq!(cfg.duration, 0.6);
373    }
374}