Skip to main content

rust_motion_animation/
transform.rs

1//! CSS transform builder — Remotion `makeTransform` / `@remotion/animation-utils`.
2//!
3//! # Example
4//! ```rust
5//! use rust_motion_animation::transform::{make_transform, rotate, scale, translate_x};
6//!
7//! let css = make_transform(&[rotate(45.0), scale(1.5), translate_x(100.0)]);
8//! assert_eq!(css, "rotate(45deg) scale(1.5) translateX(100px)");
9//! ```
10
11/// A single CSS transform function token.
12#[derive(Debug, Clone, PartialEq)]
13pub enum TransformOp {
14    Rotate(f32),
15    RotateX(f32),
16    RotateY(f32),
17    RotateZ(f32),
18    Rotate3d(f32, f32, f32, f32),
19    Scale(f32),
20    ScaleX(f32),
21    ScaleY(f32),
22    ScaleZ(f32),
23    Scale3d(f32, f32, f32),
24    TranslateX(f32),
25    TranslateY(f32),
26    TranslateZ(f32),
27    Translate(f32, f32),
28    Translate3d(f32, f32, f32),
29    SkewX(f32),
30    SkewY(f32),
31    Skew(f32, f32),
32    Perspective(f32),
33    Matrix(f32, f32, f32, f32, f32, f32),
34    Matrix3d([f32; 16]),
35}
36
37impl TransformOp {
38    pub fn to_css(&self) -> String {
39        match self {
40            Self::Rotate(deg) => format!("rotate({deg}deg)"),
41            Self::RotateX(deg) => format!("rotateX({deg}deg)"),
42            Self::RotateY(deg) => format!("rotateY({deg}deg)"),
43            Self::RotateZ(deg) => format!("rotateZ({deg}deg)"),
44            Self::Rotate3d(x, y, z, d) => format!("rotate3d({x}, {y}, {z}, {d}deg)"),
45            Self::Scale(s) => format!("scale({s})"),
46            Self::ScaleX(s) => format!("scaleX({s})"),
47            Self::ScaleY(s) => format!("scaleY({s})"),
48            Self::ScaleZ(s) => format!("scaleZ({s})"),
49            Self::Scale3d(x, y, z) => format!("scale3d({x}, {y}, {z})"),
50            Self::TranslateX(px) => format!("translateX({px}px)"),
51            Self::TranslateY(px) => format!("translateY({px}px)"),
52            Self::TranslateZ(px) => format!("translateZ({px}px)"),
53            Self::Translate(x, y) => format!("translate({x}px, {y}px)"),
54            Self::Translate3d(x, y, z) => format!("translate3d({x}px, {y}px, {z}px)"),
55            Self::SkewX(deg) => format!("skewX({deg}deg)"),
56            Self::SkewY(deg) => format!("skewY({deg}deg)"),
57            Self::Skew(x, y) => format!("skew({x}deg, {y}deg)"),
58            Self::Perspective(px) => format!("perspective({px}px)"),
59            Self::Matrix(a, b, c, d, e, f) => format!("matrix({a}, {b}, {c}, {d}, {e}, {f})"),
60            Self::Matrix3d(m) => {
61                let vals: Vec<String> = m.iter().map(|v| v.to_string()).collect();
62                format!("matrix3d({})", vals.join(", "))
63            }
64        }
65    }
66}
67
68pub fn rotate(deg: f32) -> TransformOp {
69    TransformOp::Rotate(deg)
70}
71pub fn rotate_x(deg: f32) -> TransformOp {
72    TransformOp::RotateX(deg)
73}
74pub fn rotate_y(deg: f32) -> TransformOp {
75    TransformOp::RotateY(deg)
76}
77pub fn rotate_z(deg: f32) -> TransformOp {
78    TransformOp::RotateZ(deg)
79}
80pub fn rotate3d(x: f32, y: f32, z: f32, deg: f32) -> TransformOp {
81    TransformOp::Rotate3d(x, y, z, deg)
82}
83pub fn scale(s: f32) -> TransformOp {
84    TransformOp::Scale(s)
85}
86pub fn scale_x(s: f32) -> TransformOp {
87    TransformOp::ScaleX(s)
88}
89pub fn scale_y(s: f32) -> TransformOp {
90    TransformOp::ScaleY(s)
91}
92pub fn scale_z(s: f32) -> TransformOp {
93    TransformOp::ScaleZ(s)
94}
95pub fn scale3d(x: f32, y: f32, z: f32) -> TransformOp {
96    TransformOp::Scale3d(x, y, z)
97}
98pub fn translate_x(px: f32) -> TransformOp {
99    TransformOp::TranslateX(px)
100}
101pub fn translate_y(px: f32) -> TransformOp {
102    TransformOp::TranslateY(px)
103}
104pub fn translate_z(px: f32) -> TransformOp {
105    TransformOp::TranslateZ(px)
106}
107pub fn translate(x: f32, y: f32) -> TransformOp {
108    TransformOp::Translate(x, y)
109}
110pub fn translate3d(x: f32, y: f32, z: f32) -> TransformOp {
111    TransformOp::Translate3d(x, y, z)
112}
113pub fn skew_x(deg: f32) -> TransformOp {
114    TransformOp::SkewX(deg)
115}
116pub fn skew_y(deg: f32) -> TransformOp {
117    TransformOp::SkewY(deg)
118}
119pub fn skew(x: f32, y: f32) -> TransformOp {
120    TransformOp::Skew(x, y)
121}
122pub fn perspective(px: f32) -> TransformOp {
123    TransformOp::Perspective(px)
124}
125pub fn matrix(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> TransformOp {
126    TransformOp::Matrix(a, b, c, d, e, f)
127}
128pub fn matrix3d(m: [f32; 16]) -> TransformOp {
129    TransformOp::Matrix3d(m)
130}
131
132/// Combines CSS transform operations into a single `transform` property string.
133/// Equivalent to Remotion's `makeTransform(transforms)`.
134pub fn make_transform(ops: &[TransformOp]) -> String {
135    ops.iter()
136        .map(|op| op.to_css())
137        .collect::<Vec<_>>()
138        .join(" ")
139}
140
141// ── interpolate_styles ────────────────────────────────────────────────────────
142
143/// A CSS style value — string, pixel, percent, or unitless number.
144#[derive(Debug, Clone, PartialEq)]
145pub enum StyleValue {
146    String(String),
147    Px(f64),
148    Percent(f64),
149    Number(f64),
150}
151
152impl StyleValue {
153    pub fn to_css_string(&self) -> String {
154        match self {
155            Self::String(s) => s.clone(),
156            Self::Px(n) => format!("{n}px"),
157            Self::Percent(n) => format!("{n}%"),
158            Self::Number(n) => n.to_string(),
159        }
160    }
161}
162
163/// A keyframe style map: property name → `StyleValue`.
164pub type StyleMap = std::collections::HashMap<String, StyleValue>;
165
166/// Interpolates CSS style properties across keyframes.
167/// Equivalent to Remotion's `interpolateStyles(input, inputRange, outputStylesRange)`.
168pub fn interpolate_styles(input: f64, input_range: &[f64], output_styles: &[StyleMap]) -> StyleMap {
169    assert!(
170        input_range.len() >= 2,
171        "input_range must have at least 2 entries"
172    );
173    assert_eq!(input_range.len(), output_styles.len());
174
175    let clamped = input.clamp(input_range[0], *input_range.last().unwrap());
176    let mut seg = input_range.len() - 2;
177    for i in 0..input_range.len() - 1 {
178        if clamped <= input_range[i + 1] {
179            seg = i;
180            break;
181        }
182    }
183
184    let t0 = input_range[seg];
185    let t1 = input_range[seg + 1];
186    let t = if (t1 - t0).abs() < 1e-12 {
187        1.0
188    } else {
189        (clamped - t0) / (t1 - t0)
190    };
191
192    let left = &output_styles[seg];
193    let right = &output_styles[seg + 1];
194    let mut result = StyleMap::new();
195    let keys: std::collections::HashSet<&String> = left.keys().chain(right.keys()).collect();
196
197    for key in keys {
198        let lv = left.get(key);
199        let rv = right.get(key);
200        let v = match (lv, rv) {
201            (Some(StyleValue::Number(a)), Some(StyleValue::Number(b))) => {
202                StyleValue::Number(a + (b - a) * t)
203            }
204            (Some(StyleValue::Px(a)), Some(StyleValue::Px(b))) => StyleValue::Px(a + (b - a) * t),
205            (Some(StyleValue::Percent(a)), Some(StyleValue::Percent(b))) => {
206                StyleValue::Percent(a + (b - a) * t)
207            }
208            (Some(v), _) => v.clone(),
209            (None, Some(v)) => v.clone(),
210            (None, None) => continue,
211        };
212        result.insert(key.clone(), v);
213    }
214    result
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn rotate_css() {
223        assert_eq!(rotate(45.0).to_css(), "rotate(45deg)");
224    }
225    #[test]
226    fn scale_css() {
227        assert_eq!(scale(1.5).to_css(), "scale(1.5)");
228    }
229    #[test]
230    fn translate_x_css() {
231        assert_eq!(translate_x(100.0).to_css(), "translateX(100px)");
232    }
233    #[test]
234    fn translate_css() {
235        assert_eq!(translate(10.0, 20.0).to_css(), "translate(10px, 20px)");
236    }
237    #[test]
238    fn skew_x_css() {
239        assert_eq!(skew_x(15.0).to_css(), "skewX(15deg)");
240    }
241    #[test]
242    fn perspective_css() {
243        assert_eq!(perspective(800.0).to_css(), "perspective(800px)");
244    }
245    #[test]
246    fn rotate3d_css() {
247        assert_eq!(
248            rotate3d(0.0, 1.0, 0.0, 45.0).to_css(),
249            "rotate3d(0, 1, 0, 45deg)"
250        );
251    }
252    #[test]
253    fn translate3d_css() {
254        assert_eq!(
255            translate3d(10.0, 20.0, 30.0).to_css(),
256            "translate3d(10px, 20px, 30px)"
257        );
258    }
259    #[test]
260    fn scale3d_css() {
261        assert_eq!(scale3d(2.0, 1.0, 0.5).to_css(), "scale3d(2, 1, 0.5)");
262    }
263    #[test]
264    fn matrix_css() {
265        assert_eq!(
266            matrix(1.0, 0.0, 0.0, 1.0, 50.0, 100.0).to_css(),
267            "matrix(1, 0, 0, 1, 50, 100)"
268        );
269    }
270
271    #[test]
272    fn make_transform_empty() {
273        assert_eq!(make_transform(&[]), "");
274    }
275
276    #[test]
277    fn make_transform_chain() {
278        assert_eq!(
279            make_transform(&[rotate(45.0), scale(1.5), translate_x(100.0)]),
280            "rotate(45deg) scale(1.5) translateX(100px)"
281        );
282    }
283
284    #[test]
285    fn make_transform_3d() {
286        assert_eq!(
287            make_transform(&[
288                perspective(500.0),
289                rotate_y(30.0),
290                translate3d(0.0, 0.0, -100.0)
291            ]),
292            "perspective(500px) rotateY(30deg) translate3d(0px, 0px, -100px)"
293        );
294    }
295
296    #[test]
297    fn interpolate_styles_midpoint() {
298        let kf0: StyleMap = [("opacity".to_string(), StyleValue::Number(0.0))].into();
299        let kf1: StyleMap = [("opacity".to_string(), StyleValue::Number(1.0))].into();
300        let r = interpolate_styles(15.0, &[0.0, 30.0], &[kf0, kf1]);
301        if let Some(StyleValue::Number(v)) = r.get("opacity") {
302            assert!((v - 0.5).abs() < 1e-9);
303        } else {
304            panic!("opacity missing");
305        }
306    }
307
308    #[test]
309    fn interpolate_styles_clamps() {
310        let kf0: StyleMap = [("o".to_string(), StyleValue::Number(0.0))].into();
311        let kf1: StyleMap = [("o".to_string(), StyleValue::Number(1.0))].into();
312        let lo = interpolate_styles(-999.0, &[0.0, 30.0], &[kf0.clone(), kf1.clone()]);
313        let hi = interpolate_styles(999.0, &[0.0, 30.0], &[kf0, kf1]);
314        if let Some(StyleValue::Number(v)) = lo.get("o") {
315            assert!((v - 0.0).abs() < 1e-9);
316        }
317        if let Some(StyleValue::Number(v)) = hi.get("o") {
318            assert!((v - 1.0).abs() < 1e-9);
319        }
320    }
321
322    #[test]
323    fn interpolate_styles_px() {
324        let kf0: StyleMap = [("width".to_string(), StyleValue::Px(100.0))].into();
325        let kf1: StyleMap = [("width".to_string(), StyleValue::Px(200.0))].into();
326        let r = interpolate_styles(0.5, &[0.0, 1.0], &[kf0, kf1]);
327        if let Some(StyleValue::Px(v)) = r.get("width") {
328            assert!((v - 150.0).abs() < 1e-9);
329        }
330    }
331
332    #[test]
333    fn interpolate_styles_multi_segment() {
334        let kf0: StyleMap = [("o".to_string(), StyleValue::Number(0.0))].into();
335        let kf1: StyleMap = [("o".to_string(), StyleValue::Number(0.5))].into();
336        let kf2: StyleMap = [("o".to_string(), StyleValue::Number(1.0))].into();
337        let r = interpolate_styles(45.0, &[0.0, 30.0, 60.0], &[kf0, kf1, kf2]);
338        if let Some(StyleValue::Number(v)) = r.get("o") {
339            assert!((v - 0.75).abs() < 1e-9);
340        }
341    }
342
343    #[test]
344    fn style_value_css_strings() {
345        assert_eq!(StyleValue::Number(1.0).to_css_string(), "1");
346        assert_eq!(StyleValue::Px(32.5).to_css_string(), "32.5px");
347        assert_eq!(StyleValue::Percent(50.0).to_css_string(), "50%");
348        assert_eq!(StyleValue::String("auto".into()).to_css_string(), "auto");
349    }
350}