# Motion Desk > Convert one animation between five runtimes and see what does not survive. The same > animation is not the same animation in two of them: the iteration count is off by one, > the unit differs by a factor of a thousand, the end state depends on a default that > snaps back, and the easing does not map. Free browser-side converter, five paid lanes. Live at https://motion-desk.skillsafe.ai/ · API at https://motion-desk.skillsafe.ai/api.html Derived from five runtime-adapter skills in `heygen-com/hyperframes` (https://github.com/heygen-com/hyperframes): `waapi`, `css-animations`, `animejs`, `lottie` and `gsap`. Not affiliated with or endorsed by HeyGen. ## The one thing to know **The iteration count is off by one.** WAAPI, CSS and Lottie count PLAYS; GSAP's `repeat` and anime.js's `loop` count ADDITIONAL cycles. Three plays is `iterations: 3` in one and `repeat: 2` in the other, and copying the 3 across gives 4 plays and +300 ms at 300 ms a play - always exactly one cycle, always the same direction. **The unit is not the same unit.** CSS, WAAPI and anime.js are milliseconds; GSAP is SECONDS; Lottie is FRAMES. And a Lottie duration is QUANTISED: 250 ms is 7.5 frames at 30 fps, so it becomes 8 frames and plays for 266.7 ms, +16.7 ms. 200 ms is 6 frames exactly and survives. **The end state differs, and the default is the one that snaps back.** WAAPI's `fill` and CSS's `animation-fill-mode` both default to `none`, so the element reverts the instant the animation finishes; GSAP and anime.js leave the value applied; Lottie holds its last frame. **And the easing does not map.** An easing curve is a FUNCTION, so the nearest equivalent has a measurable error, sampled at 101 points: ```text ease-out -> GSAP sine.out 2.4% of the travel, 9.6 px over 400px power2.out -> CSS cubic-bezier(0.33, 1, 0.68, 1) 0.3% bounce.out -> CSS cubic-bezier(0.33, 1, 0.68, 1) 24.4% elastic.out -> CSS cubic-bezier(0.16, 1, 0.3, 1) 79% — not a substitution steps(4) -> Lottie ease-in 23.3% — a staircase is not a curve spring -> CSS cubic-bezier(0.34, 1.56, 0.64, 1) 19.9% — and it is not a duration either ``` ## What each runtime spells | Runtime | Duration in | Iteration field | It counts | `fill` default | Ends holding? | Curves it spells | | --- | --- | --- | --- | --- | --- | --- | | Web Animations API | **ms** | `iterations` | **plays** | `none` | **no** | cubic-bezier, steps() | | CSS animation | **ms** | `animation-iteration-count` | **plays** | `none` | **no** | cubic-bezier, steps() | | GSAP | **s** | `repeat` | **extra cycles** | `forwards` | **yes** | steps(), power, sine, expo, circ, back, elastic, bounce, none | | anime.js | **ms** | `loop` | **extra cycles** | `forwards` | **yes** | cubic-bezier, steps(), spring(), power, sine, expo, circ, back, elastic, bounce, none | | Lottie | **frames** | `loop` | **plays** | `forwards` | **yes** | cubic-bezier | Read the third and fourth columns together: **`iterations` counts PLAYS and `repeat`/`loop` count EXTRA cycles**, so the same intent is a different number. Read the fifth and sixth together: **two of the five default to reverting**, so an animation that is supposed to leave something moved needs a fill written down. And the second column is why no duration number ports as written. ## The off-by-one | Plays wanted | `iterations` (WAAPI, CSS, Lottie) | `repeat`/`loop` (GSAP, anime.js) | Correct total | Copying the number gives | Out by | | --- | --- | --- | --- | --- | --- | | 1 | `1` | **`0`** | 300 ms | 2 plays = 600 ms | **+300 ms** | | 2 | `2` | **`1`** | 600 ms | 3 plays = 900 ms | **+300 ms** | | 3 | `3` | **`2`** | 900 ms | 4 plays = 1200 ms | **+300 ms** | | 5 | `5` | **`4`** | 1500 ms | 6 plays = 1800 ms | **+300 ms** | | 10 | `10` | **`9`** | 3000 ms | 11 plays = 3300 ms | **+300 ms** | At 300 ms a play. The error is **always exactly one cycle** and always in the same direction, whatever the count - which is why it survives review: nothing about it looks like an off-by-one. `repeat: 0` is one play and `repeat: -1` is forever, so the field has no way to say "none". ## The frame grid | Duration | In frames | Lottie plays | Which is | Out by | On the grid? | | --- | --- | --- | --- | --- | --- | | 120 ms | 3.6 frames | 4 frames | 133.3 ms | **+13.3 ms** | no | | 150 ms | 4.5 frames | 5 frames | 166.7 ms | **+16.7 ms** | no | | 200 ms | 6 frames | 6 frames | 200 ms | — | **yes** | | 250 ms | 7.5 frames | 8 frames | 266.7 ms | **+16.7 ms** | no | | 300 ms | 9 frames | 9 frames | 300 ms | — | **yes** | | 400 ms | 12 frames | 12 frames | 400 ms | — | **yes** | | 500 ms | 15 frames | 15 frames | 500 ms | — | **yes** | | 1000 ms | 30 frames | 30 frames | 1000 ms | — | **yes** | At 30 fps, where one frame is 33.3 ms. A Lottie duration is a whole number of frames, so anything that is not a multiple of a frame is rounded and **the rounded value is what plays**. Raising the rate shrinks the error and never removes it, so the fix is to choose durations on the grid: at 30 that means multiples of 33.3 ms. ## The curve cost | Curve | waapi | css | gsap | animejs | lottie | | --- | --- | --- | --- | --- | --- | | `ease-out` | exact | exact | 2.4% / 9.6 px | exact | exact | | `power2.out` | 0.3% / 1.1 px | 0.3% / 1.1 px | exact | exact | 0.3% / 1.1 px | | `expo.out` | 1.2% / 4.8 px | 1.2% / 4.8 px | exact | exact | 1.2% / 4.8 px | | `back.out` | 0.5% / 2 px | 0.5% / 2 px | exact | exact | 0.5% / 2 px | | `elastic.out` | **79% / 316.1 px** | **79% / 316.1 px** | exact | exact | **79% / 316.1 px** | | `bounce.out` | **24.4% / 97.8 px** | **24.4% / 97.8 px** | exact | exact | **24.4% / 97.8 px** | | `steps(4)` | exact | exact | exact | exact | **23.3% / 93.2 px** | | `spring(1, 100, 10, 0)` | **19.9% / 79.6 px** | **19.9% / 79.6 px** | **19.9% / 79.7 px** | exact | **19.9% / 79.6 px** | Percent of the travel at the worst moment, and what that is over 400px. **Bold is past 8%**, which is not a substitution - it is a different movement. `elastic` oscillates and `bounce` is four parabolas; a cubic-bezier has one hump, so neither has a CSS equivalent at any price. `steps()` has no gradient at all, so Lottie has to be authored with held keyframes instead - a different edit rather than a different number. ## Constants and thresholds | Constant | Value | What it decides | | --- | --- | --- | | `SAMPLES` | 101 | the grid every curve deviation is measured on | | `DEVIATION` | 2% | a curve substitution worth naming | | `BIG_DEVIATION` | 8% | one that is not a substitution at all | | `QUANTISE_MS` | 4 ms | a frame-grid rounding worth naming | | `PIXELS` | 8 px | a curve deviation worth naming, in pixels | | `LONG_MS` | 1000 ms | an animation worth calling long | | `DEFAULT_DURATION_MS` | 300 ms | assumed when a duration cannot be read | | `DEFAULT_EASE` | `ease` | assumed when none is declared — CSS's own default, and not GSAP's | | `DEFAULT_FPS` | 30 | assumed for the Lottie target | | `DEFAULT_DISTANCE_PX` | 400px | assumed when turning a curve deviation into pixels | | `DEFAULT_DECLARED_IN` | `waapi` | assumed when the sheet does not say which runtime it is written in | Only the units, the iteration semantics and the fill defaults are the runtimes'; everything else is this page's threshold for when a difference is worth saying. The deviation figures are measurements and not preferences. ## Sheet grammar A sheet is a header of `KEY: value` lines and an `ANIMATIONS:` block. ```text JOB: what this is (optional, echoed back) DECLARED IN: waapi (which runtime the numbers are written in; one of waapi, css, gsap, animejs, lottie; assumed `waapi`) FPS: 30 (for the Lottie target; assumed 30) DISTANCE: 400 (px, for turning a curve deviation into pixels) ANIMATIONS: dur=