Skip to main content

rustmotion_core/schema/
animation.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4// `deny_unknown_fields` (reliquat of wave-A's constat, PR #158): closes the
5// last gap in `style.animation[*]` typo detection. Wave A covered the nine
6// effect-config structs in `schema/video.rs`; the types *inside* a
7// `keyframes[*]` entry (this struct and `Keyframe` below) were left
8// uncovered — a typo'd key here (e.g. `duratoin`) used to be silently
9// dropped instead of reported, same as every other struct this wave closed.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct Animation {
13    pub property: String,
14    pub keyframes: Vec<Keyframe>,
15    #[serde(default = "default_easing")]
16    pub easing: EasingType,
17    #[serde(default)]
18    pub spring: Option<SpringConfig>,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct Keyframe {
24    pub time: f64,
25    pub value: KeyframeValue,
26    /// Optional per-keyframe easing (overrides animation-level easing for the segment starting at this keyframe)
27    #[serde(default)]
28    pub easing: Option<EasingType>,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
32#[serde(untagged)]
33pub enum KeyframeValue {
34    Number(f64),
35    Color(String),
36}
37
38impl KeyframeValue {
39    pub fn as_f64(&self) -> f64 {
40        match self {
41            KeyframeValue::Number(n) => *n,
42            KeyframeValue::Color(_) => 0.0,
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum EasingType {
50    #[default]
51    Linear,
52    EaseIn,
53    EaseOut,
54    EaseInOut,
55    EaseInQuad,
56    EaseOutQuad,
57    EaseInCubic,
58    EaseOutCubic,
59    EaseInExpo,
60    EaseOutExpo,
61    EaseInOutQuad,
62    EaseInOutExpo,
63    EaseInBack,
64    EaseOutBack,
65    EaseOutElastic,
66    Bounce,
67    Spring,
68    /// Custom cubic-bezier easing curve: cubic_bezier(x1, y1, x2, y2)
69    CubicBezier {
70        x1: f64,
71        y1: f64,
72        x2: f64,
73        y2: f64,
74    },
75}
76
77fn default_easing() -> EasingType {
78    EasingType::EaseOut
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
82pub struct SpringConfig {
83    #[serde(default = "default_damping")]
84    pub damping: f64,
85    #[serde(default = "default_stiffness")]
86    pub stiffness: f64,
87    #[serde(default = "default_mass")]
88    pub mass: f64,
89    /// Force the spring to *visually* settle at exactly this many seconds
90    /// (issue #167 lot E), instead of leaving the settle time as an
91    /// emergent, hard-to-predict consequence of `damping`/`stiffness`/
92    /// `mass`. Implemented as a linear rescale of the time axis fed to the
93    /// physics solver (`engine::animator::spring_value`): the spring's
94    /// *shape* — number of oscillations, overshoot amplitude — is a
95    /// function of `damping`/`stiffness`/`mass` alone and is unchanged;
96    /// only how fast that shape plays back changes. `None` (default)
97    /// leaves the natural, emergent settle time in place.
98    ///
99    /// This does not resize the enclosing keyframe segment (the
100    /// `delay`/`duration` on the surrounding `AnimationTiming`, or the
101    /// author's own keyframe times on a `keyframes` effect): those still
102    /// decide when the segment starts and how long it spans. Set that
103    /// enclosing span to at least `duration` (`rustmotion info` reports the
104    /// computed settle time so you don't have to guess), or the segment's
105    /// own end will still cut the spring's motion short.
106    #[serde(default)]
107    pub duration: Option<f64>,
108    /// How close to the target counts as "at rest", as a fraction of the
109    /// total 0→1 travel (e.g. `0.01` = 1%). Defaults to
110    /// `engine::animator::DEFAULT_SPRING_REST_THRESHOLD` (0.5%) when unset.
111    /// Read by `engine::animator::spring_rest_time` — the "how long until
112    /// this spring settles" measurement `rustmotion info` surfaces — and,
113    /// when `duration` is set, by the remap above to know what "settled"
114    /// means. A critically- or over-damped spring approaches its target
115    /// asymptotically and never reaches it exactly, which is precisely why
116    /// this threshold exists.
117    #[serde(default)]
118    pub rest_threshold: Option<f64>,
119}
120
121impl Default for SpringConfig {
122    fn default() -> Self {
123        Self {
124            damping: 15.0,
125            stiffness: 100.0,
126            mass: 1.0,
127            duration: None,
128            rest_threshold: None,
129        }
130    }
131}
132
133fn default_damping() -> f64 {
134    15.0
135}
136fn default_stiffness() -> f64 {
137    100.0
138}
139fn default_mass() -> f64 {
140    1.0
141}
142
143/// Preset animation names that expand to keyframes automatically
144#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
145#[serde(rename_all = "snake_case")]
146pub enum AnimationPreset {
147    // Entrées
148    FadeIn,
149    FadeInUp,
150    FadeInDown,
151    FadeInLeft,
152    FadeInRight,
153    SlideInLeft,
154    SlideInRight,
155    SlideInUp,
156    SlideInDown,
157    ScaleIn,
158    BounceIn,
159    BlurIn,
160    RotateIn,
161    ElasticIn,
162    PopIn,
163    // Sorties
164    FadeOut,
165    FadeOutUp,
166    FadeOutDown,
167    SlideOutLeft,
168    SlideOutRight,
169    SlideOutUp,
170    SlideOutDown,
171    ScaleOut,
172    BounceOut,
173    BlurOut,
174    RotateOut,
175    // Effets continus
176    Pulse,
177    Float,
178    Shake,
179    Spin,
180    // 3D
181    FlipInX,
182    FlipInY,
183    FlipOutX,
184    FlipOutY,
185    TiltIn,
186    // Stroke
187    DrawIn,
188    StrokeReveal,
189    // Floating/orbit
190    #[serde(alias = "float_3d")]
191    Float3d,
192    // Spéciaux
193    Typewriter,
194    WipeLeft,
195    WipeRight,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
199pub struct PresetConfig {
200    #[serde(default)]
201    pub delay: f64,
202    #[serde(default = "default_preset_duration")]
203    pub duration: f64,
204    /// Loop the animation continuously
205    #[serde(default, rename = "loop")]
206    pub repeat: bool,
207    /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%).
208    #[serde(default)]
209    pub overshoot: Option<f64>,
210    /// Spring physics applied to the preset's motion keyframes (see
211    /// `AnimationTiming::spring`).
212    #[serde(default)]
213    pub spring: Option<SpringConfig>,
214    /// Travel of an oscillating preset, in pixels (`float_3d`; default 12).
215    ///
216    /// Varying it across elements in one scene is what turns a shared bob into
217    /// parallax: things at different depths move by different amounts.
218    #[serde(default)]
219    pub amplitude: Option<f64>,
220}
221
222impl Default for PresetConfig {
223    fn default() -> Self {
224        Self {
225            delay: 0.0,
226            duration: 0.8,
227            repeat: false,
228            overshoot: None,
229            spring: None,
230            amplitude: None,
231        }
232    }
233}
234
235fn default_preset_duration() -> f64 {
236    0.8
237}
238
239#[cfg(test)]
240mod deny_unknown_fields_tests {
241    use super::*;
242    use serde_json::json;
243
244    // ---- reliquat of the wave-A fix (PR #158): `deny_unknown_fields` was
245    // added to the nine effect-config structs in `schema/video.rs`, but the
246    // types *inside* a `keyframes[*]` entry — `Animation` and `Keyframe`,
247    // both in this file — were left uncovered. A typo'd key inside one of
248    // these (e.g. `duratoin` on an `Animation`, or a per-keyframe field
249    // typo) used to be silently ignored instead of reported. This is the
250    // one change this workstream is authorized to make in this file. ----
251
252    #[test]
253    fn animation_rejects_unknown_fields() {
254        let json = json!({
255            "property": "opacity",
256            "keyframes": [{ "time": 0.0, "value": 1.0 }],
257            "easing": "ease_out",
258            "duratoin": 5.0
259        });
260        let err = serde_json::from_value::<Animation>(json)
261            .expect_err("a typo'd field on Animation must be rejected, not silently ignored");
262        assert!(err.to_string().contains("duratoin"), "got: {err}");
263    }
264
265    #[test]
266    fn keyframe_rejects_unknown_fields() {
267        let json = json!({ "time": 0.0, "value": 1.0, "eaisng": "linear" });
268        let err = serde_json::from_value::<Keyframe>(json)
269            .expect_err("a typo'd field on Keyframe must be rejected, not silently ignored");
270        assert!(err.to_string().contains("eaisng"), "got: {err}");
271    }
272
273    #[test]
274    fn animation_still_accepts_every_known_field() {
275        let json = json!({
276            "property": "opacity",
277            "keyframes": [
278                { "time": 0.0, "value": 0.0, "easing": "linear" },
279                { "time": 1.0, "value": 1.0 }
280            ],
281            "easing": "ease_out",
282            "spring": { "damping": 10.0, "stiffness": 100.0, "mass": 1.0 }
283        });
284        let a: Animation = serde_json::from_value(json).unwrap();
285        assert_eq!(a.property, "opacity");
286        assert_eq!(a.keyframes.len(), 2);
287        assert!(a.spring.is_some());
288    }
289
290    #[test]
291    fn keyframe_still_accepts_every_known_field() {
292        let json = json!({ "time": 0.5, "value": 10.0, "easing": "ease_in" });
293        let k: Keyframe = serde_json::from_value(json).unwrap();
294        assert_eq!(k.time, 0.5);
295        assert!(k.easing.is_some());
296    }
297}