├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json └── src ├── assets └── flixier_demo.gif ├── plugin.js └── utils.js /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "printWidth": 150 5 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## V2.1.0 - 22/06/2020 4 | 5 | - [ADDED] replaceKeyframeColo 6 | - [ADDED] getKeyframeColors 7 | 8 | ## V2.0.0 (breaking change) - 28/05/2020 9 | 10 | - [ADDED] Changelog 11 | - [CHANGED] Change response of `parseTexts` function. Now it returns information about `fontFamily` as well as `fontName`. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexandru Pavaloi 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 | # lottie-web-parser 2 | 3 | Utility functions for parsing color & text information from [lottie-web](https://github.com/airbnb/lottie-web) JSONs. 4 | 5 | ## Motivation 6 | 7 | [lottie-web](https://github.com/airbnb/lottie-web) is a great way of rendering After Effects animations natively on the Web. These animations are exported from After Effects as JSON files that hold all animation data: layers, shapes, colors, keyframes etc. 8 | 9 | At [Flixier](https://flixier.com) we wanted to build an editor for these lottie animations, so you can customize yours as you please. Demo below: 10 | 11 | 12 | 13 | The hardest part was understanding the JSON format and parsing/modifying it in such a way that 14 | 15 | 1) it's still valid 16 | 2) it produces the result we want. 17 | 18 | 19 | ## Features 20 | 21 | * determine if the lottie has text information or not 22 | * find and replace all shape colors (fill or stroke), including those that have the color specified as an [JavaScript expression](https://helpx.adobe.com/after-effects/using/expression-basics.html) 23 | * parse texts 24 | 25 | ## Installation 26 | 27 | ```bash 28 | $ npm install lottie-web-parser 29 | ``` 30 | 31 | Then, import this package into your app (you might need a build tool like [webpack](https://webpack.js.org/) if wanting to run in the browser. 32 | 33 | ## API 34 | 35 | 36 | ### hasTextLayers(animaitonData): boolean 37 | 38 | Checks if the animation data passed as argument has text information or not. 39 | 40 | ```javascript 41 | import LottieWebParser from 'lottie-web-parser'; 42 | import animationData from './data.js'; 43 | 44 | LottieWebParser.hasTextLayers(); 45 | ``` 46 | 47 | ### parseColors(animationData): Array<{ name: string, path: string, color: number[] }> 48 | 49 | Parses the animationData and returns an array of color information, including the name of the shape/layer. 50 | 51 | ```javascript 52 | import LottieWebParser from 'lottie-web-parser'; 53 | import animationData from './data.js'; 54 | 55 | let colorInfo = LottieWebParser.parseColors(animationData); 56 | console.log(colorInfo); 57 | ``` 58 | 59 | ### replaceColor(rgba, path, animationData) 60 | 61 | Params: 62 | * rgba: Array 63 | * path: string 64 | * animationData: JSON object 65 | 66 | Modifies the animationData in place, by replacing the color value found at that path after it adjusts the values: 67 | 68 | 69 | * if the current color values are in `[0-1]` then it will normalize to this interval 70 | * otherwise it will use the real values 71 | 72 | ```javascript 73 | import LottieWebParser from 'lottie-web-parser'; 74 | import animationData from './data.js'; 75 | 76 | let colorInfo = LottieWebParser.parseColors(animationData); 77 | LottieWebParser.replaceColor([255, 0, 0, 1], colorInfo[0].path, animationData); 78 | ``` 79 | 80 | ### replaceKeyframeColor(rgba, path, animationData) 81 | 82 | Params: 83 | * rgba: Array 84 | * path: string 85 | * animationData: JSON object 86 | 87 | Modifies the animation data in place, by replacing the value found at that path. Similar to `replaceColor` above, it adjusts the values. 88 | 89 | ```javascript 90 | import LottieWebParser from 'lottie-web-parser'; 91 | import animationData from './data.js'; 92 | 93 | let path = 'layers.5.shapes.2.c.k.0'; 94 | LottieWebParser.replaceColor([255, 0, 0, 1], path, animationData); 95 | ``` 96 | 97 | ### getKeyframeColors(path, animationData) 98 | 99 | Params: 100 | * path: string 101 | * animationData: JSON object 102 | 103 | Returns the color values at `path`. If the values are in `[0, 1]` interval it will adjust them to the RGB interval `[0-255]`. 104 | 105 | ### parseTexts(animationData) : Array<{name: string, text: string, fontFamily: string, fontName: string, path: string}> 106 | 107 | Parses the animationData and returns an array of text information. 108 | 109 | ```javascript 110 | import LottieWebParser from 'lottie-web-parser'; 111 | import animationData from './data.js'; 112 | 113 | if (LottieWebParser.hasTextLayers(animationData)) { 114 | let textInfo = LottieWebParser.parseTexts(animationData); 115 | console.log(textInfo); 116 | } 117 | ``` 118 | 119 | ```js 120 | // Example response 121 | [{ 122 | name: 't1', 123 | text: 'Type something here', 124 | fontName: 'Roboto-Black', 125 | fontFamily: 'Roboto', 126 | path: `layers.2.t.d.k.0.s.t` 127 | }] 128 | ``` 129 | 130 | 131 |
132 | 133 |

Made with ❤ by Pava for Flixier

134 | 135 | PS: special thanks to [sonaye/lottie-editor](https://github.com/sonaye/lottie-editor), whose code was the foundation of this package. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lottie-web-parser", 3 | "version": "2.1.0", 4 | "description": "Utility functions for parsing color & text information from lottie-web JSONs.", 5 | "author": { 6 | "name": "Alexandru Pavaloi", 7 | "email": "pava@iampava.com", 8 | "url": "https://iampava.com" 9 | }, 10 | "license": "MIT", 11 | "main": "src/plugin.js", 12 | "keywords": [ 13 | "lottie", 14 | "lottie-web", 15 | "after-effects" 16 | ], 17 | "repository": "https://github.com/iampava/lottie-web-parser.git", 18 | "bugs": "https://github.com/iampava/lottie-web-parser/issues", 19 | "homepage": "https://github.com/iampava/lottie-web-parser", 20 | "dependencies": {} 21 | } -------------------------------------------------------------------------------- /src/assets/flixier_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iampava/lottie-web-parser/2581a3b8d8f4ec87f211c2e97c95808bba99606b/src/assets/flixier_demo.gif -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import { get, toUnitVector, fromUnitVector, getNewColors, findEffectFromJSCode, } from './utils.js'; 2 | 3 | function hasTextLayers(animationData) { 4 | if (animationData.chars || animationData.fonts) { 5 | return true; 6 | } 7 | 8 | return false; 9 | } 10 | 11 | function parseColors(json) { 12 | const colorInfo = []; 13 | const existingColorPaths = []; 14 | 15 | if (Array.isArray(json.layers)) { 16 | colorInfo.push(...getNewColors(json, 'layers', existingColorPaths)); 17 | } 18 | 19 | if (Array.isArray(json.assets)) { 20 | for (let i = 0; i < json.assets.length; i++) { 21 | colorInfo.push(...getNewColors(json, `assets.${i}.layers`, existingColorPaths)); 22 | } 23 | } 24 | 25 | return colorInfo; 26 | } 27 | 28 | function replaceColor(rgba, path, animationData) { 29 | if (typeof animationData !== 'object') { 30 | throw new Error('Expecting a JSON-based format animation data'); 31 | } 32 | const [r, g, b, a] = rgba; 33 | const target = get(path, animationData); 34 | 35 | if (target.v && target.v.k) { 36 | // Effect 37 | if (target.v.k.every(value => value <= 1)) { 38 | target.v.k = [toUnitVector(r), toUnitVector(g), toUnitVector(b), a]; 39 | } else { 40 | target.v.k = [r, g, b, a]; 41 | } 42 | } else if (target.c && target.c.k) { 43 | // Shape 44 | if (target.c.k.every(value => value <= 1)) { 45 | target.c.k = [toUnitVector(r), toUnitVector(g), toUnitVector(b), a]; 46 | } else { 47 | target.c.k = [r, g, b, a]; 48 | } 49 | } 50 | 51 | return animationData; 52 | } 53 | 54 | function replaceKeyframeColor(rgba, path, animationData) { 55 | if (typeof animationData !== 'object') { 56 | throw new Error('Expecting a JSON-based format animation data'); 57 | } 58 | const [r, g, b, a] = rgba; 59 | const target = get(path, animationData); 60 | 61 | if (target && target.s) { 62 | if (target.s.every(value => value <= 1)) { 63 | target.s = [toUnitVector(r), toUnitVector(g), toUnitVector(b), a]; 64 | } else { 65 | target.s = [r, g, b, a]; 66 | } 67 | 68 | } 69 | } 70 | 71 | function getKeyframeColors(path, animationData) { 72 | const target = get(path, animationData); 73 | 74 | if (target && target.c.k && target.c.k.every(value => typeof value !== 'number')) { 75 | const keyframeValues = target.c.k.map(value => value.s) 76 | return keyframeValues.map(value => { 77 | const isUnitFormat = value.every(v => v <= 1); 78 | 79 | return isUnitFormat ? value.map(fromUnitVector) : value 80 | }) 81 | } 82 | } 83 | 84 | function parseTexts(json) { 85 | let fontList = json.fonts.list; 86 | 87 | return json.layers 88 | .filter(l => l.ty === 5) 89 | .map(l => { 90 | let fontName = l.t.d.k[0].s.f; 91 | let matchedFont = fontList.find(f => f.fName === l.t.d.k[0].s.f); 92 | 93 | return { 94 | name: l.nm, 95 | text: l.t.d.k[0].s.t, 96 | fontName, 97 | fontFamily: matchedFont ? matchedFont.fFamily : undefined, 98 | path: `layers.${l.ind - 1}.t.d.k.0.s.t` 99 | } 100 | }); 101 | } 102 | 103 | export default { 104 | hasTextLayers, 105 | findEffectFromJSCode, 106 | parseColors, 107 | parseTexts, 108 | replaceColor, 109 | replaceKeyframeColor, 110 | getKeyframeColors 111 | }; 112 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function get(path, object) { 2 | if (typeof path !== 'string') { 3 | throw new TypeError('Expecting a string value!'); 4 | } 5 | 6 | let target = object; 7 | 8 | path.split('.').forEach(next => { 9 | try { 10 | target = target[next]; 11 | } catch (err) { 12 | target = {}; 13 | } 14 | }); 15 | 16 | return target 17 | } 18 | 19 | /** 20 | * Convert a value from [0,255] ➡ [0,1] interval 21 | * @param {number} n 22 | */ 23 | export function toUnitVector(n) { 24 | if (typeof n !== 'number') { 25 | throw new TypeError('Expecting a number value!'); 26 | } 27 | return Math.round((n / 255) * 1000) / 1000; 28 | } 29 | 30 | /** 31 | * Convert a value from [0,1] ➡ [0,255] interval 32 | * @param {number} n 33 | */ 34 | export function fromUnitVector(n) { 35 | if (typeof n !== 'number') { 36 | throw new TypeError('Expecting a number value!'); 37 | } 38 | return Math.round(n * 255); 39 | } 40 | 41 | 42 | export function findEffectFromJSCode(jsCode, json) { 43 | const safeCode = ` 44 | let thisComp = new Composition(json) 45 | let window = null; 46 | let document = null; 47 | ${jsCode} 48 | 49 | return $bm_rt; 50 | `; 51 | 52 | return Function('json', 'Composition', safeCode).bind(null)(json, Composition); 53 | } 54 | 55 | /** 56 | * 57 | * @returns { name: string, path: string, type: string, color: Array } 58 | */ 59 | export function getNewColors(animationData, startingPath, existingColorPaths = []) { 60 | const result = []; 61 | const layersOrShapes = deepFind(animationData, startingPath); 62 | 63 | if (!Array.isArray(layersOrShapes)) { 64 | throw new TypeError('Expected an array of layers or shapes'); 65 | } 66 | 67 | layersOrShapes.forEach((el, layerIndex) => { 68 | if (!Array.isArray(el.shapes)) { 69 | return; 70 | } 71 | 72 | const layerInfo = { name: el.nm, shapes: [] }; 73 | 74 | el.shapes.forEach((outerShape, outerShapeIndex) => { 75 | const actualShapes = outerShape.it || [outerShape]; 76 | 77 | actualShapes.forEach((shape, innerShapeIndex) => { 78 | if (shape.ty !== 'fl' && shape.ty !== 'st') { 79 | return; 80 | } 81 | 82 | const meta = { 83 | name: shape.nm, 84 | type: shape.ty, 85 | path: `${startingPath}.${layerIndex}.shapes.${outerShapeIndex}${outerShape.it ? `.it.${innerShapeIndex}` : ''}` 86 | }; 87 | 88 | let color = shape.c.k; 89 | if (shape.c.x) { 90 | // Color based on effect 91 | const effect = findEffectFromJSCode(shape.c.x, { 92 | layers: layersOrShapes 93 | }); 94 | 95 | meta.name = effect.parentNm; 96 | meta.path = effect.path; 97 | color = effect.v.k; 98 | } 99 | 100 | let [r, g, b] = color.slice(0, 3); 101 | if (r <= 1 && g <= 1 && b <= 1) { 102 | // Colors are in [0-1] interval 103 | [r, g, b] = [r, g, b].map(c => fromUnitVector(c)); 104 | } 105 | const a = color[3]; 106 | 107 | meta.rgba = [r, g, b, a]; 108 | 109 | if (existingColorPaths.includes(meta.path)) { 110 | return; 111 | } 112 | 113 | layerInfo.shapes.push(meta); 114 | existingColorPaths.push(meta.path); 115 | }); 116 | }); 117 | 118 | result.push(layerInfo); 119 | }); 120 | 121 | return result; 122 | } 123 | 124 | /** 125 | * Utility class for parsing the JS code in AE Effects. (eg: var $bm_rt;\n$bm_rt = thisComp.layer('color_settings').effect('Fill 3')('Color')') 126 | * In the example above, 'thisComp' is an instance of the Composition class 127 | */ 128 | class Composition { 129 | constructor(animationData) { 130 | try { 131 | if (typeof animationData === 'string') { 132 | this.animationData = JSON.parse(animationData); 133 | } else { 134 | this.animationData = JSON.parse(JSON.stringify(animationData)); 135 | } 136 | } catch (err) { 137 | throw new TypeError('Expecting animationData to be a JSON object or a stringified JSON.'); 138 | } 139 | } 140 | 141 | /** 142 | * Find an effect layer at a certain index or with a certain name. 143 | * If multiple layers have the same name, return the first one. 144 | * @param {number|string} indexOrName 145 | */ 146 | layer(indexOrName) { 147 | if (!Array.isArray(this.animationData.layers)) { 148 | throw new TypeError('Expecting animationData to contain a "layers" property'); 149 | } 150 | 151 | const effectLayers = this.animationData.layers.filter(l => l.hasOwnProperty('ef')); 152 | let layer = null; 153 | let layerIndex; 154 | 155 | switch (typeof indexOrName) { 156 | case 'number': 157 | layer = effectLayers[indexOrName] || null; 158 | layerIndex = indexOrName; 159 | break; 160 | case 'string': 161 | layerIndex = effectLayers.findIndex(l => l.nm === indexOrName); 162 | layer = effectLayers[layerIndex] || null; 163 | break; 164 | default: 165 | throw new TypeError('Expecting to get layer by "index" or "name". None of those supplied!'); 166 | } 167 | 168 | return { 169 | /** Find the first outer-effect on this layer with the specified name 170 | * @param {string} name 171 | */ 172 | effect: name => { 173 | if (!Array.isArray(layer.ef)) { 174 | throw new TypeError(`The ${layer.nm} layer doesn't have effects`); 175 | } 176 | 177 | const outerEffectIndex = layer.ef.findIndex(ef => ef.nm === name); 178 | const outerEffect = layer.ef[outerEffectIndex]; 179 | 180 | return specificName => { 181 | if (!outerEffect) { 182 | return null; 183 | } 184 | 185 | if (!Array.isArray(outerEffect.ef)) { 186 | throw new TypeError(`The ${outerEffect.name} effect doesn't have child-effects`); 187 | } 188 | 189 | const effectIndex = outerEffect.ef.findIndex(ef => ef.nm === specificName); 190 | 191 | return { 192 | ...outerEffect.ef[effectIndex], 193 | parentNm: outerEffect.nm, 194 | path: `layers.${layerIndex}.ef.${outerEffectIndex}.ef.${effectIndex}` 195 | }; 196 | }; 197 | } 198 | }; 199 | } 200 | } 201 | 202 | /** 203 | * Return a value deep inside an object or null if it doesn't exist 204 | * @param {Object} object 205 | * @param {string} path 206 | */ 207 | function deepFind(object, path) { 208 | if (typeof path !== 'string') { 209 | throw new TypeError('Expecting "path" to be a string!'); 210 | } 211 | 212 | const pathParts = path.split('.'); 213 | for (let next of pathParts) { 214 | try { 215 | object = object[next]; 216 | } catch (err) { 217 | return null; 218 | } 219 | } 220 | 221 | return object; 222 | } 223 | --------------------------------------------------------------------------------