Skip to main content

rust_motion_animation/
spring.rs

1//! `spring()` — Rust port of Remotion's spring animation.
2//!
3//! Computes a physically-based spring animation value at a given frame.
4//! Matches Remotion's `spring-utils.ts` using constant-time frame evaluation
5//! and bounded, thread-local settling-duration caches.
6//!
7//! # Example
8//! ```rust
9//! use rust_motion_animation::spring::{spring, SpringConfig};
10//!
11//! let config = SpringConfig::default(); // damping=10, mass=1, stiffness=100
12//! let value = spring(15, 30.0, config);
13//! println!("frame 15 spring value: {value:.4}");
14//! ```
15
16/// Configuration for the spring physics model.
17///
18/// Defaults match Remotion's `spring()` defaults exactly.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SpringConfig {
21    /// Resistance force — higher = less oscillation. Default: `10`.
22    pub damping: f64,
23    /// Object mass — higher = slower. Default: `1`.
24    pub mass: f64,
25    /// Spring tension — higher = faster. Default: `100`.
26    pub stiffness: f64,
27    /// If `true`, clamps overshoot to `[from, to]`. Default: `false`.
28    pub overshoot_clamping: bool,
29}
30
31impl Default for SpringConfig {
32    fn default() -> Self {
33        Self {
34            damping: 10.0,
35            mass: 1.0,
36            stiffness: 100.0,
37            overshoot_clamping: false,
38        }
39    }
40}
41
42#[derive(Debug, Clone)]
43struct AnimationNode {
44    last_timestamp: f64,
45    to_value: f64,
46    current: f64,
47    velocity: f64,
48}
49
50/// Closed-form oscillator response from Remotion's `advance()`.
51/// Measurement steps retain its 64ms cap; direct evaluation supplies the
52/// equivalent total elapsed time.
53fn advance(node: &AnimationNode, now: f64, config: &SpringConfig, cap_step: bool) -> AnimationNode {
54    let AnimationNode {
55        to_value,
56        last_timestamp,
57        current,
58        velocity,
59        ..
60    } = *node;
61
62    let delta_time = now - last_timestamp;
63    let delta_time = if cap_step {
64        delta_time.min(64.0)
65    } else {
66        delta_time
67    };
68
69    let c = config.damping;
70    let m = config.mass;
71    let k = config.stiffness;
72
73    let v0 = -velocity;
74    let x0 = to_value - current;
75
76    let zeta = c / (2.0 * (k * m).sqrt()); // damping ratio
77    let omega0 = (k / m).sqrt(); // undamped angular frequency (rad/ms)
78    let omega1 = omega0 * (1.0 - zeta.powi(2)).sqrt(); // exponential decay frequency
79
80    let t = delta_time / 1000.0;
81
82    let sin1 = (omega1 * t).sin();
83    let cos1 = (omega1 * t).cos();
84
85    // ── Under-damped (zeta < 1) ───────────────────────────────────────────────
86    let under_damped_envelope = (-zeta * omega0 * t).exp();
87    let under_damped_frag1 =
88        under_damped_envelope * (sin1 * ((v0 + zeta * omega0 * x0) / omega1) + x0 * cos1);
89
90    let under_damped_position = to_value - under_damped_frag1;
91    let under_damped_velocity = zeta * omega0 * under_damped_frag1
92        - under_damped_envelope * (cos1 * (v0 + zeta * omega0 * x0) - omega1 * x0 * sin1);
93
94    // ── Critically damped (zeta >= 1) ─────────────────────────────────────────
95    let critically_damped_envelope = (-omega0 * t).exp();
96    let critically_damped_position =
97        to_value - critically_damped_envelope * (x0 + (v0 + omega0 * x0) * t);
98    let critically_damped_velocity =
99        critically_damped_envelope * (v0 * (t * omega0 - 1.0) + t * x0 * omega0 * omega0);
100
101    let (new_current, new_velocity) = if zeta < 1.0 {
102        (under_damped_position, under_damped_velocity)
103    } else {
104        (critically_damped_position, critically_damped_velocity)
105    };
106
107    AnimationNode {
108        to_value,
109        last_timestamp: now,
110        current: new_current,
111        velocity: new_velocity,
112    }
113}
114
115/// Compute the spring value at `frame` (0-indexed).
116///
117/// # Arguments
118/// * `frame` — current frame number
119/// * `fps`   — frames per second of the composition
120/// * `config` — spring physics configuration
121///
122/// Returns a value interpolated between `0.0` (start) and `1.0` (settled).
123/// Use [`crate::interpolate::interpolate`] to map this to any output range.
124///
125/// # Notes
126/// Uses the same spring model as Remotion's `springCalculation()` from `spring-utils.ts`.
127/// Valid inputs retain the original integer-frame behavior.
128///
129/// # Panics
130/// Panics for invalid parameters or a requested frame beyond 100,000.
131/// Use [`spring_with_options`] to handle errors explicitly.
132pub fn spring(frame: u32, fps: f64, config: SpringConfig) -> f64 {
133    spring_with_options(frame as f64, fps, config, SpringOptions::default())
134        .expect("invalid spring parameters")
135}
136
137/// Playback controls for [`spring_with_options`].
138#[derive(Debug, Clone, PartialEq)]
139pub struct SpringOptions {
140    pub from: f64,
141    pub to: f64,
142    /// Stretch the natural settling duration to this positive number of frames.
143    pub duration_in_frames: Option<f64>,
144    /// Distance from 1 used to measure settling. Default: 0.005.
145    pub duration_rest_threshold: f64,
146    /// Offset in frames, applied before duration scaling. May be fractional.
147    pub delay: f64,
148    /// Play the spring backwards, preserving the delay before playback.
149    pub reverse: bool,
150}
151
152impl Default for SpringOptions {
153    fn default() -> Self {
154        Self {
155            from: 0.0,
156            to: 1.0,
157            duration_in_frames: None,
158            duration_rest_threshold: 0.005,
159            delay: 0.0,
160            reverse: false,
161        }
162    }
163}
164
165/// Invalid input or a spring that cannot be evaluated within the work limit.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum SpringError {
168    InvalidFps,
169    InvalidConfig,
170    InvalidFrame,
171    InvalidRange,
172    InvalidDelay,
173    InvalidDuration,
174    InvalidThreshold,
175    CalculationLimit,
176    NonFiniteResult,
177}
178
179impl std::fmt::Display for SpringError {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        f.write_str(match self {
182            Self::InvalidFps => "spring fps must be finite and positive",
183            Self::InvalidConfig => "spring damping, mass and stiffness must be finite and positive",
184            Self::InvalidFrame => "spring frame must be finite",
185            Self::InvalidRange => "spring endpoints and their difference must be finite",
186            Self::InvalidDelay => "spring delay must be finite",
187            Self::InvalidDuration => "spring duration must be finite and positive",
188            Self::InvalidThreshold => "spring threshold must be finite and nonnegative; timing requires a value strictly between 0 and 1",
189            Self::CalculationLimit => "spring calculation exceeded 100000 simulation frames",
190            Self::NonFiniteResult => "spring calculation produced a non-finite value",
191        })
192    }
193}
194
195impl std::error::Error for SpringError {}
196
197const MAX_SIMULATION_FRAMES: u32 = 100_000;
198
199fn validate(fps: f64, config: &SpringConfig) -> Result<(), SpringError> {
200    if !fps.is_finite() || fps <= 0.0 {
201        return Err(SpringError::InvalidFps);
202    }
203    if [config.damping, config.mass, config.stiffness]
204        .iter()
205        .any(|v| !v.is_finite() || *v <= 0.0)
206    {
207        return Err(SpringError::InvalidConfig);
208    }
209    Ok(())
210}
211
212fn initial_node() -> AnimationNode {
213    AnimationNode {
214        last_timestamp: 0.0,
215        current: 0.0,
216        to_value: 1.0,
217        velocity: 0.0,
218    }
219}
220
221fn checked_advance(
222    node: &AnimationNode,
223    frame: f64,
224    fps: f64,
225    config: &SpringConfig,
226) -> Result<AnimationNode, SpringError> {
227    let next = advance(node, (frame / fps) * 1000.0, config, true);
228    if !next.current.is_finite() || !next.velocity.is_finite() || !next.last_timestamp.is_finite() {
229        return Err(SpringError::NonFiniteResult);
230    }
231    Ok(next)
232}
233
234fn calculate(frame: f64, fps: f64, config: &SpringConfig) -> Result<f64, SpringError> {
235    if !frame.is_finite() {
236        return Err(SpringError::NonFiniteResult);
237    }
238    let frame = frame.max(0.0);
239    if frame > MAX_SIMULATION_FRAMES as f64 {
240        return Err(SpringError::CalculationLimit);
241    }
242    // The oscillator has a closed-form solution. Repeated advances compose
243    // into one elapsed time, including Remotion's per-step 64ms cap. Its final
244    // fractional step skips the integer frame and can span up to two frames.
245    let preceding_frame = (frame.floor() - 1.0).max(0.0);
246    let preceding_timestamp = (preceding_frame / fps) * 1000.0;
247    let timestamp = (frame / fps) * 1000.0;
248    if !timestamp.is_finite() {
249        return Err(SpringError::NonFiniteResult);
250    }
251    let elapsed_prefix = if 1000.0 / fps > 64.0 {
252        preceding_frame * 64.0
253    } else {
254        preceding_timestamp
255    };
256    let elapsed = elapsed_prefix + (timestamp - preceding_timestamp).min(64.0);
257    let node = advance(&initial_node(), elapsed, config, false);
258    if node.current.is_finite() && node.velocity.is_finite() {
259        Ok(node.current)
260    } else {
261        Err(SpringError::NonFiniteResult)
262    }
263}
264
265/// Measure natural settling time using Remotion 4.0.495's 20-frame rest window.
266///
267/// The default threshold used by [`SpringOptions`] is `0.005` (28 frames at
268/// 30 fps with the default config). Overshoot clamping does not affect measurement.
269/// Threshold 0 returns infinity; thresholds >= 1 return 0. Invalid physics and
270/// simulations exceeding 100,000 frames return an error instead of hanging.
271pub fn measure_spring(fps: f64, config: &SpringConfig, threshold: f64) -> Result<f64, SpringError> {
272    validate(fps, config)?;
273    if !threshold.is_finite() || threshold < 0.0 {
274        return Err(SpringError::InvalidThreshold);
275    }
276    if threshold == 0.0 {
277        return Ok(f64::INFINITY);
278    }
279    if threshold >= 1.0 {
280        return Ok(0.0);
281    }
282
283    let key = [
284        fps.to_bits(),
285        config.damping.to_bits(),
286        config.mass.to_bits(),
287        config.stiffness.to_bits(),
288        threshold.to_bits(),
289    ];
290    MEASURE_CACHE.with(|cache| {
291        let mut cache = cache.borrow_mut();
292        if let Some((_, value)) = cache.iter().find(|(cached_key, _)| *cached_key == key) {
293            return *value;
294        }
295        let result = measure_uncached(fps, config, threshold);
296        // A render worker retains at most 64 measurements. Avoid global locks
297        // and unbounded growth when projects use many animated configurations.
298        if cache.len() == MEASURE_CACHE_CAPACITY {
299            cache.pop_back();
300        }
301        cache.push_front((key, result));
302        result
303    })
304}
305
306const MEASURE_CACHE_CAPACITY: usize = 64;
307type MeasureCache = std::collections::VecDeque<([u64; 5], Result<f64, SpringError>)>;
308thread_local! {
309    static MEASURE_CACHE: std::cell::RefCell<MeasureCache> = const {
310        std::cell::RefCell::new(std::collections::VecDeque::new())
311    };
312}
313
314fn measure_uncached(fps: f64, config: &SpringConfig, threshold: f64) -> Result<f64, SpringError> {
315    // Advance incrementally once; subsequent calls reuse the measurement.
316    let mut node = checked_advance(&initial_node(), 0.0, fps, config)?;
317    let mut frame = 0;
318    while (node.current - 1.0).abs() >= threshold {
319        frame += 1;
320        if frame > MAX_SIMULATION_FRAMES {
321            return Err(SpringError::CalculationLimit);
322        }
323        node = checked_advance(&node, frame as f64, fps, config)?;
324    }
325    let mut finished_frame = frame;
326    let mut rest = 0;
327    while rest < 20 {
328        frame += 1;
329        if frame > MAX_SIMULATION_FRAMES {
330            return Err(SpringError::CalculationLimit);
331        }
332        node = checked_advance(&node, frame as f64, fps, config)?;
333        if (node.current - 1.0).abs() >= threshold {
334            // Match upstream's reset followed by the for-loop increment.
335            rest = 0;
336            finished_frame = frame + 1;
337        }
338        rest += 1;
339    }
340    Ok(finished_frame as f64)
341}
342
343/// Evaluate a spring with fractional/negative frames, range, delay and reversal.
344///
345/// Duration scaling and reversal follow Remotion 4.0.495. Negative local frames
346/// hold `from`; frames beyond an explicit duration hold `to`. Reversed playback
347/// traverses that same curve backwards. At the duration itself the value is the
348/// measured settling value, not a forced endpoint.
349///
350/// Unlike upstream's non-unit-range clamping, `overshoot_clamping` clamps the
351/// normalized spring before mapping it into `[from, to]`, including descending
352/// ranges. Duration/reverse require a rest threshold strictly between 0 and 1.
353/// Frame evaluation takes constant time. Settling measurements are cached per
354/// thread (at most 64 entries) and bounded to 100,000 simulation frames.
355/// The supported adjusted frame range remains capped at 100,000 frames.
356///
357/// ```
358/// use rust_motion_animation::{spring_with_options, SpringConfig, SpringOptions};
359/// let options = SpringOptions {
360///     from: 100.0,
361///     to: 0.0,
362///     delay: 10.0,
363///     duration_in_frames: Some(30.0),
364///     ..Default::default()
365/// };
366/// assert_eq!(spring_with_options(10.0, 30.0, SpringConfig::default(), options)?, 100.0);
367/// # Ok::<(), rust_motion_animation::SpringError>(())
368/// ```
369pub fn spring_with_options(
370    frame: f64,
371    fps: f64,
372    config: SpringConfig,
373    options: SpringOptions,
374) -> Result<f64, SpringError> {
375    validate(fps, &config)?;
376    if !frame.is_finite() {
377        return Err(SpringError::InvalidFrame);
378    }
379    if !options.from.is_finite()
380        || !options.to.is_finite()
381        || !(options.to - options.from).is_finite()
382    {
383        return Err(SpringError::InvalidRange);
384    }
385    if !options.delay.is_finite() {
386        return Err(SpringError::InvalidDelay);
387    }
388    if options
389        .duration_in_frames
390        .is_some_and(|d| !d.is_finite() || d <= 0.0)
391    {
392        return Err(SpringError::InvalidDuration);
393    }
394    if !options.duration_rest_threshold.is_finite() || options.duration_rest_threshold < 0.0 {
395        return Err(SpringError::InvalidThreshold);
396    }
397    let natural_duration = if options.reverse || options.duration_in_frames.is_some() {
398        if options.duration_rest_threshold <= 0.0 || options.duration_rest_threshold >= 1.0 {
399            return Err(SpringError::InvalidThreshold);
400        }
401        measure_spring(fps, &config, options.duration_rest_threshold)?
402    } else {
403        0.0
404    };
405    let delayed = if options.reverse {
406        options.duration_in_frames.unwrap_or(natural_duration) - frame + options.delay
407    } else {
408        frame - options.delay
409    };
410    if let Some(duration) = options.duration_in_frames {
411        if delayed > duration {
412            return Ok(options.to);
413        }
414    }
415    let adjusted = match options.duration_in_frames {
416        Some(duration) => delayed / (duration / natural_duration),
417        None => delayed,
418    };
419    let value = calculate(adjusted, fps, &config)?;
420    let value = if config.overshoot_clamping {
421        value.clamp(0.0, 1.0)
422    } else {
423        value
424    };
425    let result = options.from + (options.to - options.from) * value;
426    if result.is_finite() {
427        Ok(result)
428    } else {
429        Err(SpringError::NonFiniteResult)
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn constant_time_matches_iterative_physics_including_capped_fractional_steps() {
439        for fps in [1.0, 15.0, 15.625, 30.0, 59.94, 120.0] {
440            for damping in [0.01, 6.0, 10.0, 20.0, 200.0] {
441                for mass in [0.5, 1.0, 2.0] {
442                    for stiffness in [30.0, 100.0, 200.0] {
443                        let config = SpringConfig {
444                            damping,
445                            mass,
446                            stiffness,
447                            ..Default::default()
448                        };
449                        for frame in [
450                            0.0_f64, 0.2, 0.99, 1.0, 1.9, 5.99, 12.999, 60.5, 300.25, 999.9,
451                        ] {
452                            let floor = frame.floor() as u32;
453                            let mut node = initial_node();
454                            for f in 0..=floor {
455                                let f = if f == floor { frame } else { f as f64 };
456                                node = checked_advance(&node, f, fps, &config).unwrap();
457                            }
458                            let actual = calculate(frame, fps, &config).unwrap();
459                            assert!((actual - node.current).abs() < 1e-9,
460                                "fps={fps} damping={damping} mass={mass} stiffness={stiffness} frame={frame}: {actual} != {}", node.current);
461                        }
462                    }
463                }
464            }
465        }
466    }
467
468    #[test]
469    fn measurement_cache_is_bounded_and_keys_all_physics_inputs() {
470        // The cache belongs to each worker; arbitrary seek order must not affect values.
471        std::thread::spawn(|| {
472            for index in 0..80 {
473                let fps = 30.0 + (index % 3) as f64;
474                let threshold = 0.005 + (index % 4) as f64 * 0.001;
475                let config = SpringConfig {
476                    damping: 6.0 + index as f64 * 0.1,
477                    mass: 1.0 + (index % 5) as f64 * 0.1,
478                    stiffness: 80.0 + index as f64,
479                    ..Default::default()
480                };
481                let expected = measure_uncached(fps, &config, threshold).unwrap();
482                assert_eq!(measure_spring(fps, &config, threshold).unwrap(), expected);
483                assert_eq!(measure_spring(fps, &config, threshold).unwrap(), expected);
484                MEASURE_CACHE.with(|cache| assert!(cache.borrow().len() <= MEASURE_CACHE_CAPACITY));
485            }
486            let config = SpringConfig::default();
487            assert_eq!(measure_spring(30.0, &config, 0.005).unwrap(), 28.0);
488            assert_eq!(
489                measure_spring(
490                    30.0,
491                    &SpringConfig {
492                        overshoot_clamping: true,
493                        ..config
494                    },
495                    0.005
496                )
497                .unwrap(),
498                28.0
499            );
500        })
501        .join()
502        .unwrap();
503    }
504
505    /// Verify the spring settles to 1.0 and starts at 0.0.
506    #[test]
507    fn settles_to_one() {
508        let config = SpringConfig::default();
509        // By frame 60 (2s at 30fps) a default spring should be settled within 0.001 of 1.0
510        let v = spring(60, 30.0, config);
511        assert!(
512            (v - 1.0).abs() < 0.001,
513            "frame 60 should be ~1.0, got {v:.6}"
514        );
515    }
516
517    #[test]
518    fn increases_monotonically_early() {
519        let config = SpringConfig::default();
520        // The spring should increase for the first few frames
521        let v0 = spring(0, 30.0, config.clone());
522        let v1 = spring(1, 30.0, config.clone());
523        let v3 = spring(3, 30.0, config.clone());
524        assert!(v1 > v0, "v1({v1:.4}) should be > v0({v0:.4})");
525        assert!(v3 > v1, "v3({v3:.4}) should be > v1({v1:.4})");
526    }
527
528    #[test]
529    fn overshoot_clamping() {
530        let config = SpringConfig {
531            overshoot_clamping: true,
532            ..Default::default()
533        };
534        // Frame 10 normally overshoots slightly above 1.0
535        let v = spring(10, 30.0, config);
536        assert!(v <= 1.0, "value {v} should be clamped to <= 1.0");
537    }
538
539    #[test]
540    fn frame_zero_is_zero() {
541        let v = spring(0, 30.0, SpringConfig::default());
542        assert!((v - 0.0).abs() < 1e-6, "got {v}");
543    }
544}