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