├── .gitignore ├── LICENSE ├── README.md ├── docs └── static │ ├── ae-expression-engine.jpg │ └── header-img.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── rollup.config.js └── src ├── index.test.ts ├── index.ts └── interpolators.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # Dependency directories 9 | node_modules/ 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Optional npm cache directory 19 | .npm 20 | 21 | # Optional eslint cache 22 | .eslintcache 23 | 24 | # dotenv environment variables file 25 | .env 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage/ 29 | 30 | # IDEs and editors 31 | /.idea 32 | .project 33 | .classpath 34 | .c9/ 35 | *.launch 36 | .settings/ 37 | *.sublime-workspace 38 | 39 | # IDE - VSCode 40 | .vscode/* 41 | !.vscode/settings.json 42 | !.vscode/tasks.json 43 | !.vscode/launch.json 44 | !.vscode/extensions.json 45 | 46 | # misc 47 | /.sass-cache 48 | /connect.lock 49 | /coverage 50 | /libpeerconnection.log 51 | npm-debug.log 52 | testem.log 53 | /typings 54 | yarn-error.log 55 | 56 | # e2e 57 | /e2e/*.js 58 | /e2e/*.map 59 | 60 | # System Files 61 | .DS_Store 62 | Thumbs.db 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tim Haywood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔑 aeFunctions 2 | 3 | **Keyframe animation in After Effects Expressions** 4 | 5 | --- 6 | 7 | ✨ View more details on our website: **[motiondeveloper.com/tools/eKeys](https://www.motiondeveloper.com/tools/ekeys)** 8 | 9 | --- 10 | 11 | - Animate dynamically with expressions 12 | - Full control over easing 13 | - Simple and keyframe-like API 14 | 15 | --- 16 | 17 | 🏗 This project was created with [create-expression-lib](https://github.com/motiondeveloper/create-expression-lib) - our utility for creating and managing After Effects `.jsx` libraries. 18 | 19 | --- 20 | 21 | ## Setup 22 | 23 | 1. Download the latest version from the [releases](https://github.com/motiondeveloper/ekeys/releases) page. 24 | 2. Import it into After Effects 25 | 26 | ## Expression 27 | 28 | Usage: 29 | 30 | ```js 31 | const { animate } = footage('eKeys.jsx').sourceData; 32 | animate([ 33 | { 34 | keyTime: 0, 35 | keyValue: [0, 0], 36 | easeOut: 90, 37 | }, { 38 | keyTime: 3, 39 | keyValue: [960, 540], 40 | easeIn: 80, 41 | } 42 | ]); 43 | ``` 44 | 45 | ## Development 46 | 47 | 1. **Clone project locally** 48 | 49 | ```sh 50 | git clone https://github.com/motiondeveloper/eKeys.git 51 | cd aeFunctions 52 | ``` 53 | 54 | 2. **Start Rollup** 55 | 56 | Start Rollup in watch mode to automatically refresh your code as you make changes, by running: 57 | 58 | ```sh 59 | npm run watch 60 | ``` 61 | 62 | _You can run also run a once off build:_ `npm run build` 63 | 64 | 3. **Edit the `src` files** 65 | 66 | _The `index.ts` contains an example expression setup._ 67 | 68 | Any values exported from this file will be included in your library, for example: 69 | 70 | ```js 71 | export { someValue }; 72 | ``` 73 | 74 | 4. **Import the `dist` file into After Effects** 75 | 76 | Use the compiled output file as you would any other `.jsx` library. Any changes to the `src` files will be live updated, and After Effects will update the result of your expression. 77 | -------------------------------------------------------------------------------- /docs/static/ae-expression-engine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/motiondeveloper/eKeys/7e839a808f1022d0366093d4192676945de57369/docs/static/ae-expression-engine.jpg -------------------------------------------------------------------------------- /docs/static/header-img.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 15 | 17 | 18 | 20 | 21 | 22 | 24 | 25 | 26 | 33 | 40 | 44 | 54 | 58 | 59 | 66 | 70 | 80 | 81 | 82 | 89 | 90 | 97 | 101 | 102 | 105 | 109 | 116 | 123 | 129 | 131 | 132 | 139 | 143 | 144 | 153 | 154 | 157 | 164 | 171 | 172 | 178 | 180 | 186 | 187 | 189 | 196 | 205 | 215 | 222 | 232 | 235 | 239 | 246 | 256 | 266 | 268 | 274 | 276 | 283 | 284 | 291 | 295 | 296 | 299 | 303 | 310 | 317 | 318 | 320 | 321 | 328 | 332 | 333 | 342 | 343 | 346 | 353 | 360 | 361 | 374 | 380 | 382 | 391 | 397 | 403 | 404 | 406 | 413 | 422 | 431 | 438 | 439 | 443 | 450 | 460 | 466 | 468 | 474 | 475 | 480 | 481 | 482 | 483 | 484 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ekeys", 3 | "version": "4.4.1", 4 | "description": "Animation engine for After Effects expressions", 5 | "private": true, 6 | "main": "dist/eKeys.jsx", 7 | "scripts": { 8 | "test": "jest --watch", 9 | "tsc": "tsc", 10 | "build": "rollup -c", 11 | "watch": "rollup -cw", 12 | "release": "npm run build && gh release create $npm_package_version $npm_package_main" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/motiondeveloper/eKeys.git" 17 | }, 18 | "keywords": [ 19 | "after", 20 | "effects", 21 | "expressions", 22 | "easing" 23 | ], 24 | "author": "Tim Haywood", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/motiondeveloper/eKeys/issues" 28 | }, 29 | "homepage": "https://github.com/motiondeveloper/eKeys#readme", 30 | "devDependencies": { 31 | "@rollup/plugin-replace": "^2.3.3", 32 | "@rollup/plugin-typescript": "^5.0.2", 33 | "@types/jest": "^26.0.13", 34 | "jest": "^26.4.2", 35 | "prettier": "^1.19.1", 36 | "rollup": "^2.27.0", 37 | "rollup-plugin-ae-jsx": "^2.0.0", 38 | "ts-jest": "^26.3.0", 39 | "tslib": "^2.0.1", 40 | "typescript": "^3.9.7" 41 | }, 42 | "dependencies": { 43 | "expression-globals-typescript": "^3.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | singleQuote: true 4 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import replace from '@rollup/plugin-replace'; 3 | import afterEffectJsx from 'rollup-plugin-ae-jsx'; 4 | import pkg from './package.json'; 5 | 6 | export default { 7 | input: 'src/index.ts', 8 | output: { 9 | file: pkg.main, 10 | format: 'es', 11 | }, 12 | external: Object.keys(pkg.dependencies), 13 | plugins: [ 14 | replace({ 15 | _npmVersion: pkg.version, 16 | }), 17 | typescript({ 18 | module: 'esnext', 19 | target: 'esnext', 20 | noImplicitAny: true, 21 | moduleResolution: 'node', 22 | strict: true, 23 | }), 24 | afterEffectJsx(), 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from 'expression-globals-typescript'; 2 | import { animate, interpolators } from './index'; 3 | 4 | // This let's Jest: ReferenceError: Cannot access before initialization 5 | // https://stackoverflow.com/questions/61157392/jest-mock-aws-sdk-referenceerror-cannot-access-before-initialization 6 | jest.mock('expression-globals-typescript', () => { 7 | const mLayer = { getItem: jest.fn().mockReturnThis(), promise: jest.fn() }; 8 | return { Layer: jest.fn(() => mLayer) }; 9 | }); 10 | 11 | test('animates between default keys', () => { 12 | expect( 13 | animate( 14 | [ 15 | { keyTime: 0, keyValue: 0 }, 16 | { keyTime: 1, keyValue: 1 }, 17 | ], 18 | { inputTime: 0.5 } 19 | ) 20 | ).toBe(0.5); 21 | }); 22 | 23 | test('animates between eased keys', () => { 24 | expect( 25 | animate( 26 | [ 27 | { keyTime: 0, keyValue: 0, easeOut: 100 }, 28 | { keyTime: 1, keyValue: 1, easeIn: 100 }, 29 | ], 30 | { inputTime: 0.5 } 31 | ) 32 | ).toBe(0.5); 33 | }); 34 | 35 | test('animates between custom velocity keys', () => { 36 | expect( 37 | animate( 38 | [ 39 | { keyTime: 0, keyValue: 0, velocityOut: 20 }, 40 | { keyTime: 1, keyValue: 1, velocityIn: 20 }, 41 | ], 42 | { inputTime: 0.5 } 43 | ) 44 | ).toBe(0.5); 45 | }); 46 | 47 | test('re-orders keys by time', () => { 48 | expect( 49 | animate( 50 | [ 51 | { keyTime: 2, keyValue: 1 }, 52 | { keyTime: 3, keyValue: 0 }, 53 | ], 54 | { inputTime: 2.5 } 55 | ) 56 | ).toBe(0.5); 57 | }); 58 | 59 | test('animates between arrays', () => { 60 | expect( 61 | animate( 62 | [ 63 | { keyTime: 0, keyValue: [0, 0, 0] }, 64 | { keyTime: 1, keyValue: [1, 1, 1] }, 65 | ], 66 | { inputTime: 0.5 } 67 | ) 68 | ).toEqual([0.5, 0.5, 0.5]); 69 | }); 70 | 71 | test('animates with custom linear interpolator', () => { 72 | expect( 73 | animate( 74 | [ 75 | { keyTime: 0, keyValue: 0 }, 76 | { keyTime: 1, keyValue: 1 }, 77 | ], 78 | { inputTime: 0.5, interpolator: x => x } 79 | ) 80 | ).toBe(0.5); 81 | }); 82 | 83 | test('animates with provided linear interpolator', () => { 84 | expect( 85 | animate( 86 | [ 87 | { keyTime: 0, keyValue: 0 }, 88 | { keyTime: 1, keyValue: 1 }, 89 | ], 90 | { inputTime: 0.5, interpolator: interpolators.linear } 91 | ) 92 | ).toBe(0.5); 93 | }); 94 | 95 | test('animates with provided easeInOutQuad interpolator', () => { 96 | expect( 97 | animate( 98 | [ 99 | { keyTime: 0, keyValue: 0 }, 100 | { keyTime: 1, keyValue: 1 }, 101 | ], 102 | { inputTime: 0.25, interpolator: interpolators.easeInOutQuad } 103 | ) 104 | ).toBe(0.125); 105 | }); 106 | 107 | test('animates with provided elastic interpolator', () => { 108 | expect( 109 | animate( 110 | [ 111 | { keyTime: 0, keyValue: 0 }, 112 | { keyTime: 2, keyValue: 1 }, 113 | ], 114 | { inputTime: 2, interpolator: interpolators.easeInElastic } 115 | ) 116 | ).toBe(1); 117 | }); 118 | 119 | test('animates with custom quad interpolator', () => { 120 | expect( 121 | animate( 122 | [ 123 | { keyTime: 0, keyValue: 0 }, 124 | { keyTime: 1, keyValue: 1 }, 125 | ], 126 | { 127 | inputTime: 0.3, 128 | interpolator: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), 129 | } 130 | ) 131 | ).toBe(0.18); 132 | }); 133 | 134 | test('errors if keys are different dimensions', () => { 135 | expect(() => 136 | animate( 137 | [ 138 | { keyTime: 0, keyValue: 0 }, 139 | { keyTime: 1, keyValue: [1, 1, 1] }, 140 | ], 141 | { inputTime: 0.5 } 142 | ) 143 | ).toThrowError( 144 | 'Keyframe 0 and 1 values must be of the same type. Received number and array' 145 | ); 146 | }); 147 | 148 | test('errors if a key value is missing', () => { 149 | expect(() => 150 | // @ts-ignore 151 | animate([{ keyTime: 0 }, { keyTime: 1, keyValue: 1 }], { inputTime: 0.5 }) 152 | ).toThrowError('keyValue is required in keyframe 0'); 153 | }); 154 | 155 | test('errors if a key time is missing', () => { 156 | expect(() => 157 | // @ts-ignore 158 | animate([{ keyValue: 0 }, { keyTime: 1, keyValue: 1 }], { inputTime: 0.5 }) 159 | ).toThrowError('keyValue is required in keyframe 0'); 160 | }); 161 | 162 | test('errors if a key time is a string', () => { 163 | expect(() => 164 | animate( 165 | [ 166 | // @ts-ignore 167 | { keyValue: 0, keyTime: '0' }, 168 | { keyTime: 1, keyValue: 1 }, 169 | ], 170 | { inputTime: 0.5 } 171 | ) 172 | ).toThrowError('Keyframe 0 time must be of type number. Received string'); 173 | }); 174 | 175 | test('errors if a key value is a string', () => { 176 | expect(() => 177 | animate( 178 | [ 179 | // @ts-ignore 180 | { keyValue: '0', keyTime: 0 }, 181 | { keyTime: 1, keyValue: 1 }, 182 | ], 183 | { inputTime: 0.5 } 184 | ) 185 | ).toThrowError( 186 | 'Keyframe 0 value must be of type number,array. Received string' 187 | ); 188 | }); 189 | 190 | test('errors if easeIn is a string', () => { 191 | expect(() => 192 | animate( 193 | [ 194 | // @ts-ignore 195 | { keyValue: 0, keyTime: 0, easeIn: '0' }, 196 | { keyTime: 1, keyValue: 1 }, 197 | ], 198 | { inputTime: 0.5 } 199 | ) 200 | ).toThrowError('Keyframe 0 easeIn must be of type number. Received string'); 201 | }); 202 | 203 | test('errors if easeOut is a string', () => { 204 | expect(() => 205 | animate( 206 | [ 207 | // @ts-ignore 208 | { keyValue: 0, keyTime: 0, easeOut: '0' }, 209 | { keyTime: 1, keyValue: 1 }, 210 | ], 211 | { inputTime: 0.5 } 212 | ) 213 | ).toThrowError('Keyframe 0 easeOut must be of type number. Received string'); 214 | }); 215 | 216 | test('errors if velocityIn is a string', () => { 217 | expect(() => 218 | animate( 219 | [ 220 | // @ts-ignore 221 | { keyValue: 0, keyTime: 0, velocityIn: '0' }, 222 | { keyTime: 1, keyValue: 1 }, 223 | ], 224 | { inputTime: 0.5 } 225 | ) 226 | ).toThrowError( 227 | 'Keyframe 0 velocityIn must be of type number. Received string' 228 | ); 229 | }); 230 | 231 | test('errors if velocityOut is a string', () => { 232 | expect(() => 233 | animate( 234 | [ 235 | // @ts-ignore 236 | { keyValue: 0, keyTime: 0, velocityOut: '0' }, 237 | { keyTime: 1, keyValue: 1 }, 238 | ], 239 | { inputTime: 0.5 } 240 | ) 241 | ).toThrowError( 242 | 'Keyframe 0 velocityOut must be of type number. Received string' 243 | ); 244 | }); 245 | 246 | test('errors on unexpected keyframe property', () => { 247 | expect(() => 248 | animate( 249 | [ 250 | { keyTime: 0, keyValue: 0 }, 251 | // @ts-ignore 252 | { keyTime: 1, keyValue: 1, ease: 0 }, 253 | ], 254 | { inputTime: 0.5 } 255 | ) 256 | ).toThrowError('Unexpected property on keyframe 1: ease'); 257 | }); 258 | 259 | test('errors if values are arrays of different lengths', () => { 260 | expect(() => 261 | // @ts-ignore 262 | animate( 263 | [ 264 | { keyTime: 0, keyValue: [0, 0] }, 265 | { keyTime: 1, keyValue: [1, 1, 1] }, 266 | ], 267 | { inputTime: 0.5 } 268 | ) 269 | ).toThrowError( 270 | 'Keyframe 0 and 1 values must be of the same dimension. Received 2 and 3' 271 | ); 272 | }); 273 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Layer, Vector } from 'expression-globals-typescript'; 2 | import { Interpolator, interpolators } from './interpolators'; 3 | 4 | const thisLayer = new Layer(); 5 | 6 | type KeyVal = number | Vector; 7 | 8 | interface InputKey { 9 | keyTime: number; 10 | keyValue: KeyVal; 11 | easeIn?: number; 12 | easeOut?: number; 13 | velocityIn?: number; 14 | velocityOut?: number; 15 | } 16 | 17 | interface EKey extends InputKey { 18 | easeIn: number; 19 | easeOut: number; 20 | velocityIn: number; 21 | velocityOut: number; 22 | } 23 | 24 | interface AnimateOptions { 25 | inputTime: number; 26 | interpolator?: Interpolator; 27 | } 28 | 29 | // The function that's called from After Effects 30 | // as eKeys.animate() 31 | function animate( 32 | inputKeyframes: InputKey[], 33 | options: AnimateOptions = { inputTime: thisLayer.time } 34 | ) { 35 | const { inputTime = thisLayer.time, interpolator } = options; 36 | // Validate function inputs 37 | checkTypes([ 38 | ['.animate() input keyframes', inputKeyframes, 'array'], 39 | ['.animate() input time', inputTime, 'number'], 40 | ]); 41 | // Validate and sort the given keys 42 | const validKeys: EKey[] = inputKeyframes 43 | .map((key, index) => validateKeyframe(key, index)) 44 | .sort((a, b) => a.keyTime - b.keyTime); 45 | 46 | return animateBetweenKeys(validKeys, inputTime); 47 | 48 | // Returns the final animated value 49 | // This is the function that's returned 50 | function animateBetweenKeys(keys: EKey[], time: number) { 51 | const lastKey: EKey = keys[keys.length - 1]; 52 | const firstKey: EKey = keys[0]; 53 | 54 | // If outside of all keys, return closest 55 | // key value, skip animation 56 | if (time <= firstKey.keyTime) { 57 | return firstKey.keyValue; 58 | } 59 | if (time >= lastKey.keyTime) { 60 | return lastKey.keyValue; 61 | } 62 | 63 | const curKeyNum: number = getCurrentKeyNum(keys, time); 64 | const curKey: EKey = keys[curKeyNum]; 65 | const nextKey: EKey = keys[curKeyNum + 1]; 66 | 67 | // Check to see if no animation is 68 | // required between current keys 69 | if (curKey.keyValue === nextKey.keyValue) { 70 | return curKey.keyValue; 71 | } 72 | 73 | // If we're on the keyframe, 74 | // return its value 75 | if (time === curKey.keyTime) { 76 | return curKey.keyValue; 77 | } 78 | 79 | // Incrementing time value that 80 | // starts from the current keyTime 81 | const movedTime: number = Math.max(time - curKey.keyTime, 0); 82 | 83 | // The total duration of the animation 84 | const animationLength: number = nextKey.keyTime - curKey.keyTime; 85 | 86 | // Animation progress amount between 0 and 1 87 | const linearProgress: number = Math.min(1, movedTime / animationLength); 88 | 89 | const easedProgress: number = 90 | interpolator !== undefined 91 | ? // If they've passed in a custom interpolator, use that 92 | // and opt out of bezier generation 93 | interpolator(linearProgress, curKey.easeOut, nextKey.easeIn) 94 | : // Otherwise generate a bezier function and pass in 95 | // the progress amount 96 | bezier( 97 | curKey.easeOut / 100, 98 | curKey.velocityOut / 100, 99 | 1 - nextKey.easeIn / 100, 100 | 1 - nextKey.velocityIn / 100 101 | )(linearProgress); 102 | 103 | // Animate between values according to 104 | // whether they are arrays 105 | if (Array.isArray(curKey.keyValue) && Array.isArray(nextKey.keyValue)) { 106 | return animateArrayFromProgress( 107 | curKey.keyValue, 108 | nextKey.keyValue, 109 | easedProgress 110 | ); 111 | } 112 | if ( 113 | typeof curKey.keyValue === 'number' && 114 | typeof nextKey.keyValue === 'number' 115 | ) { 116 | return animateValueFromProgress( 117 | curKey.keyValue, 118 | nextKey.keyValue, 119 | easedProgress 120 | ); 121 | } 122 | 123 | // If the keys aren't both arrays 124 | // or numbers, return an error 125 | throw Error( 126 | `Keyframe ${curKeyNum} and ${curKeyNum + 127 | 1} values must be of the same type. Received ${getType( 128 | curKey.keyValue 129 | )} and ${getType(nextKey.keyValue)}` 130 | ); 131 | 132 | // Set current key to most recent keyframe 133 | function getCurrentKeyNum(keys: EKey[], time: number): number { 134 | const lastKeyNum = keys.length - 1; 135 | let curKeyNum = 0; 136 | while (curKeyNum < lastKeyNum && time >= keys[curKeyNum + 1].keyTime) { 137 | curKeyNum++; 138 | } 139 | return curKeyNum; 140 | } 141 | 142 | // Performs animation on each element of array individually 143 | function animateArrayFromProgress( 144 | startArray: Vector, 145 | endArray: Vector, 146 | progressAmount: number 147 | ): Vector { 148 | if (startArray.length !== endArray.length) { 149 | // Check that the key values are the same dimension 150 | throw new TypeError( 151 | `Keyframe ${curKeyNum} and ${curKeyNum + 152 | 1} values must be of the same dimension. Received ${ 153 | startArray.length 154 | } and ${endArray.length}` 155 | ); 156 | } else { 157 | // Arrays are the same length 158 | // TypeScript doesn't know if the Array elements exist in a .map() 159 | // so we need to provide a fallback 160 | // Array Subtraction 161 | const arrayDelta: Vector = endArray.map((dimension, index) => { 162 | const curKeyDim = dimension as number; 163 | const nextKeyDim = startArray[index] as number; 164 | return (curKeyDim - nextKeyDim) as number; 165 | }) as Vector; 166 | // Multiply difference by progress 167 | const deltaProgressed = arrayDelta.map( 168 | item => (item as number) * progressAmount 169 | ); 170 | // Add to current key and return 171 | return startArray.map( 172 | (item, index) => ((item as number) + deltaProgressed[index]) as number 173 | ) as Vector; 174 | } 175 | } 176 | 177 | // Animate between values according to progress 178 | function animateValueFromProgress( 179 | startVal: number, 180 | endVal: number, 181 | progressAmount: number 182 | ): number { 183 | const valueDelta = endVal - startVal; 184 | return startVal + valueDelta * progressAmount; 185 | } 186 | 187 | // Creates bezier curve and returns function 188 | // to calculate eased value 189 | function bezier(mX1: number, mY1: number, mX2: number, mY2: number) { 190 | /** 191 | * https://github.com/gre/bezier-easing 192 | * BezierEasing - use bezier curve for transition easing function 193 | * by Gaëtan Renaudeau 2014 - 2015 – MIT License 194 | */ 195 | 196 | // These values are established by empiricism with tests (tradeoff: performance VS precision) 197 | const NEWTON_ITERATIONS = 4; 198 | const NEWTON_MIN_SLOPE = 0.001; 199 | const SUBDIVISION_PRECISION = 0.0000001; 200 | const SUBDIVISION_MAX_ITERATIONS = 10; 201 | 202 | const kSplineTableSize = 11; 203 | const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0); 204 | 205 | const float32ArraySupported = typeof Float32Array === 'function'; 206 | 207 | const A = (aA1: number, aA2: number) => 1.0 - 3.0 * aA2 + 3.0 * aA1; 208 | const B = (aA1: number, aA2: number) => 3.0 * aA2 - 6.0 * aA1; 209 | const C = (aA1: number) => 3.0 * aA1; 210 | 211 | // Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2. 212 | const calcBezier = (aT: number, aA1: number, aA2: number) => 213 | ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; 214 | 215 | // Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2. 216 | const getSlope = (aT: number, aA1: number, aA2: number) => 217 | 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); 218 | 219 | const binarySubdivide = ( 220 | aX: number, 221 | aA: number, 222 | aB: number, 223 | mX1: number, 224 | mX2: number 225 | ) => { 226 | let currentX; 227 | let currentT; 228 | let i = 0; 229 | do { 230 | currentT = aA + (aB - aA) / 2.0; 231 | currentX = calcBezier(currentT, mX1, mX2) - aX; 232 | if (currentX > 0.0) { 233 | aB = currentT; 234 | } else { 235 | aA = currentT; 236 | } 237 | } while ( 238 | Math.abs(currentX) > SUBDIVISION_PRECISION && 239 | ++i < SUBDIVISION_MAX_ITERATIONS 240 | ); 241 | return currentT; 242 | }; 243 | 244 | const newtonRaphsonIterate = ( 245 | aX: number, 246 | aGuessT: number, 247 | mX1: number, 248 | mX2: number 249 | ) => { 250 | for (let i = 0; i < NEWTON_ITERATIONS; ++i) { 251 | const currentSlope = getSlope(aGuessT, mX1, mX2); 252 | if (currentSlope === 0.0) { 253 | return aGuessT; 254 | } 255 | const currentX = calcBezier(aGuessT, mX1, mX2) - aX; 256 | aGuessT -= currentX / currentSlope; 257 | } 258 | return aGuessT; 259 | }; 260 | 261 | const LinearEasing = (x: number) => x; 262 | 263 | if (!(mX1 >= 0 && mX1 <= 1 && mX2 >= 0 && mX2 <= 1)) { 264 | throw new Error('bezier x values must be in [0, 1] range'); 265 | } 266 | 267 | if (mX1 === mY1 && mX2 === mY2) { 268 | return LinearEasing; 269 | } 270 | 271 | // Precompute samples table 272 | const sampleValues = float32ArraySupported 273 | ? new Float32Array(kSplineTableSize) 274 | : new Array(kSplineTableSize); 275 | for (let i = 0; i < kSplineTableSize; ++i) { 276 | sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2); 277 | } 278 | 279 | const getTForX = (aX: number) => { 280 | let intervalStart = 0.0; 281 | let currentSample = 1; 282 | const lastSample = kSplineTableSize - 1; 283 | 284 | for ( 285 | ; 286 | currentSample !== lastSample && sampleValues[currentSample] <= aX; 287 | ++currentSample 288 | ) { 289 | intervalStart += kSampleStepSize; 290 | } 291 | --currentSample; 292 | 293 | // Interpolate to provide an initial guess for t 294 | const dist = 295 | (aX - sampleValues[currentSample]) / 296 | (sampleValues[currentSample + 1] - sampleValues[currentSample]); 297 | const guessForT = intervalStart + dist * kSampleStepSize; 298 | const initialSlope = getSlope(guessForT, mX1, mX2); 299 | 300 | if (initialSlope >= NEWTON_MIN_SLOPE) { 301 | return newtonRaphsonIterate(aX, guessForT, mX1, mX2); 302 | } 303 | if (initialSlope === 0.0) { 304 | return guessForT; 305 | } 306 | return binarySubdivide( 307 | aX, 308 | intervalStart, 309 | intervalStart + kSampleStepSize, 310 | mX1, 311 | mX2 312 | ); 313 | }; 314 | 315 | const bezierEasing = (x: number) => { 316 | if (x === 0) { 317 | return 0; 318 | } 319 | if (x === 1) { 320 | return 1; 321 | } 322 | return calcBezier(getTForX(x), mY1, mY2); 323 | }; 324 | 325 | return bezierEasing; 326 | } 327 | } 328 | 329 | // Make sure that a given keyframe is valid 330 | // Sets defaults and checks for errors 331 | function validateKeyframe(key: InputKey, index: number): EKey { 332 | // Set keyframe defaults 333 | const { 334 | keyTime, 335 | keyValue, 336 | easeIn = 33, 337 | easeOut = 33, 338 | velocityIn = 0, 339 | velocityOut = 0, 340 | ...nonValidKeyProps 341 | } = key; 342 | const invalidPropNames = Object.getOwnPropertyNames(nonValidKeyProps); 343 | if (invalidPropNames.length !== 0) { 344 | const triedTime = invalidPropNames.indexOf('time') > -1; 345 | const triedValue = invalidPropNames.indexOf('value') > -1; 346 | // Extra parameters were added to a keyframe 347 | throw new Error( 348 | `Unexpected property on keyframe ${index}: ${invalidPropNames.join( 349 | ', ' 350 | )}${triedTime ? `. Did you mean "keyTime"?` : ``}${ 351 | triedValue ? `. Did you mean "keyValue"?` : `` 352 | }` 353 | ); 354 | } 355 | // Alert the user if an eKey is missing 356 | // the required arguments 357 | if (keyTime == null) { 358 | requiredArgumentError('keyValue', `keyframe ${index}`); 359 | } 360 | if (keyValue == null) { 361 | requiredArgumentError('keyValue', `keyframe ${index}`); 362 | } 363 | 364 | // Check data types of keyframe parameters 365 | checkTypes([ 366 | [`Keyframe ${index} time`, keyTime, `number`], 367 | [`Keyframe ${index} value`, keyValue, [`number`, `array`]], 368 | [`Keyframe ${index} easeIn`, easeIn, `number`], 369 | [`Keyframe ${index} easeOut`, easeOut, `number`], 370 | [`Keyframe ${index} velocityIn`, velocityIn, `number`], 371 | [`Keyframe ${index} velocityOut`, velocityOut, `number`], 372 | ]); 373 | 374 | // Return validated keyframe 375 | const validKey: EKey = { 376 | keyTime, 377 | keyValue, 378 | easeIn, 379 | easeOut, 380 | velocityIn, 381 | velocityOut, 382 | }; 383 | 384 | return validKey; 385 | } 386 | 387 | // Loops through an array of the format 388 | // [name, variable, 'expectedType'] 389 | // and checks if each variable is of the expected type and 390 | // returns a TypeError if it's not 391 | type checking = [string, any, string | string[]]; 392 | type checkingArray = checking[]; 393 | function checkTypes(checkingArray: checkingArray) { 394 | checkingArray.map(checking => { 395 | const name: string = checking[0]; 396 | const argumentType: string = getType(checking[1]); 397 | const expectedType: string | string[] = checking[2]; 398 | if (!isValidType(argumentType, expectedType)) { 399 | typeErrorMessage(name, expectedType, argumentType); 400 | } 401 | }); 402 | } 403 | 404 | // More reliable version of standard js typeof 405 | function getType(value: any) { 406 | return Object.prototype.toString 407 | .call(value) 408 | .replace(/^\[object |\]$/g, '') 409 | .toLowerCase(); 410 | } 411 | 412 | // Error message template for an incorrect type 413 | function typeErrorMessage( 414 | variableName: string, 415 | expectedType: string | string[], 416 | receivedType: string 417 | ) { 418 | throw new TypeError( 419 | `${variableName} must be of type ${expectedType}. Received ${receivedType}` 420 | ); 421 | } 422 | 423 | // Error message template for missing required argument 424 | function requiredArgumentError(variableName: string, functionName: string) { 425 | throw new Error(`${variableName} is required in ${functionName}`); 426 | } 427 | 428 | // Checks if a variable type matches the given expected type 429 | // expected type can be array of types 430 | function isValidType(argumentType: string, expectedType: string | string[]) { 431 | if (getType(expectedType) === 'string') { 432 | return argumentType === expectedType; 433 | } 434 | // Could also check using getType() 435 | // but then typescript would complain 436 | if (Array.isArray(expectedType)) { 437 | return expectedType.filter(type => argumentType === type).length > 0; 438 | } 439 | return typeErrorMessage( 440 | 'The expected type', 441 | 'string or array', 442 | getType(expectedType) 443 | ); 444 | } 445 | } 446 | 447 | const version: string = '_npmVersion'; 448 | 449 | export { animate, version, interpolators }; 450 | -------------------------------------------------------------------------------- /src/interpolators.ts: -------------------------------------------------------------------------------- 1 | export type Interpolator = ( 2 | progress: number, 3 | easeOut?: number, 4 | easeIn?: number 5 | ) => number; 6 | 7 | export const interpolators: Record = { 8 | // no easing, no acceleration 9 | linear: t => t, 10 | // accelerating from zero velocity 11 | easeInQuad: t => t * t, 12 | // decelerating to zero velocity 13 | easeOutQuad: t => t * (2 - t), 14 | // acceleration until halfway, then deceleration 15 | easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), 16 | // accelerating from zero velocity 17 | easeInCubic: t => t * t * t, 18 | // decelerating to zero velocity 19 | easeOutCubic: t => --t * t * t + 1, 20 | // acceleration until halfway, then deceleration 21 | easeInOutCubic: t => 22 | t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, 23 | // accelerating from zero velocity 24 | easeInQuart: t => t * t * t * t, 25 | // decelerating to zero velocity 26 | easeOutQuart: t => 1 - --t * t * t * t, 27 | // acceleration until halfway, then deceleration 28 | easeInOutQuart: t => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t), 29 | // accelerating from zero velocity 30 | easeInQuint: t => t * t * t * t * t, 31 | // decelerating to zero velocity 32 | easeOutQuint: t => 1 + --t * t * t * t * t, 33 | // acceleration until halfway, then deceleration 34 | easeInOutQuint: t => 35 | t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t, 36 | easeInElastic: t => (0.04 - 0.04 / t) * Math.sin(25 * t) + 1, 37 | easeOutElastic: t => ((0.04 * t) / --t) * Math.sin(25 * t), 38 | easeInOutElastic: t => 39 | (t -= 0.5) < 0 40 | ? (0.02 + 0.01 / t) * Math.sin(50 * t) 41 | : (0.02 - 0.01 / t) * Math.sin(50 * t) + 1, 42 | }; 43 | --------------------------------------------------------------------------------