├── example ├── App.css ├── main.jsx ├── index.css └── App.tsx ├── .gitignore ├── vite.config.js ├── .prettierrc.yml ├── .prettierrc.js ├── index.html ├── src ├── index.ts ├── property │ ├── ValueProperty.ts │ ├── MultiDimensionalProperty.ts │ ├── KeyframedValueProperty.ts │ ├── BaseProperty.ts │ └── KeyframedMultidimensionalProperty.ts ├── element │ ├── CompLottieElement.ts │ ├── SpriteLottieElement.ts │ ├── TextLottieElement.ts │ └── BaseLottieElement.ts ├── tools.ts ├── EditorLottieLoader.ts ├── bezier.ts ├── LottieLoader.ts ├── Expression.ts ├── TransformFrames.ts ├── LottieResource.ts └── LottieAnimation.ts ├── LEGAL.md ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── rollup.miniprogram.plugin.js ├── .github └── workflows │ └── release.yml ├── README.md ├── package.json └── rollup.config.js /example/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | #canvas { 6 | display: block; 7 | width: 100vw; 8 | height: 100vh; 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | coverage 5 | types 6 | tsconfig.tsbuildinfo 7 | .aci.yml 8 | package-lock.json 9 | yarn.lock 10 | .npmrc 11 | 12 | pnpm-lock.yaml 13 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | const config = { 5 | jsx: "react", 6 | optimizeDeps: { 7 | exclude: ["@galacean/engine"] 8 | } 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # More detail:https://prettier.io/docs/en/configuration.html 2 | 3 | --- 4 | printWidth: 120 5 | proseWrap: never 6 | singleQuote: false 7 | trailingComma: none 8 | semi: true 9 | tabWidth: 2 10 | useTabs: false 11 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | singleQuote: false, 5 | trailingComma: 'all', 6 | bracketSpacing: true, 7 | jsxBracketSameLine: true, 8 | insertPragma: false, 9 | tabWidth: 2, 10 | useTabs: true, 11 | } 12 | -------------------------------------------------------------------------------- /example/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ) 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite App 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Loader } from "@galacean/engine"; 2 | import { LottieAnimation } from "./LottieAnimation"; 3 | 4 | export { EditorLottieLoader } from "./EditorLottieLoader"; 5 | export { LottieLoader } from "./LottieLoader"; 6 | export { LottieResource } from "./LottieResource"; 7 | export { LottieAnimation }; 8 | 9 | Loader.registerClass("LottieAnimation", LottieAnimation); 10 | -------------------------------------------------------------------------------- /LEGAL.md: -------------------------------------------------------------------------------- 1 | Legal Disclaimer 2 | 3 | Within this source code, the comments in Chinese shall be the original, governing version. Any comment in other languages are for reference only. In the event of any conflict between the Chinese language version comments and other language version comments, the Chinese language version shall prevail. 4 | 5 | 法律免责声明 6 | 7 | 关于代码注释部分,中文注释为官方版本,其它语言注释仅做参考。中文注释可能与其它语言注释存在不一致,当中文注释与其它语言注释存在不一致时,请以中文注释为准。 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "declaration": true, 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "experimentalDecorators": true, 9 | "sourceMap": true, 10 | "incremental": true, 11 | "skipLibCheck": true, 12 | "declarationDir": "types", 13 | "resolveJsonModule": true 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /example/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", //定义ESLint的解析器 3 | extends: ["alloy", "alloy/typescript", "prettier/@typescript-eslint", "plugin:prettier/recommended"], //定义文件继承的子规范 4 | plugins: ["@typescript-eslint"], //定义了该eslint文件所依赖的插件 5 | parserOptions: { 6 | ecmaVersion: 2019, 7 | sourceType: "module", 8 | ecmaFeatures: { 9 | jsx: true, 10 | }, 11 | }, 12 | env: { 13 | //指定代码的运行环境 14 | browser: true, 15 | node: true, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/property/ValueProperty.ts: -------------------------------------------------------------------------------- 1 | import BaseProperty from "./BaseProperty"; 2 | import { TypeValueProperty } from "./BaseProperty"; 3 | 4 | /** 5 | * unidimensional value property 6 | * @internal 7 | */ 8 | export default class ValueProperty extends BaseProperty { 9 | constructor(data: TypeValueProperty, mult: number = 1) { 10 | super(data, mult); 11 | this.v = mult ? this.value * mult : this.value; 12 | } 13 | 14 | update() { 15 | this.v = this.value * this.mult; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/property/MultiDimensionalProperty.ts: -------------------------------------------------------------------------------- 1 | import BaseProperty, { TypeMultiDimensionalProperty } from "./BaseProperty"; 2 | 3 | /** 4 | * multidimensional value property 5 | */ 6 | export default class MultiDimensionalProperty extends BaseProperty { 7 | constructor(data: TypeMultiDimensionalProperty, mult = 1) { 8 | super(data, mult); 9 | const len = this.value.length; 10 | this.v = new Float32Array(len); 11 | this.newValue = new Float32Array(len); 12 | 13 | for (let i = 0; i < len; i += 1) { 14 | this.v[i] = this.value[i] * this.mult; 15 | } 16 | } 17 | 18 | update() { 19 | let finalValue: number[]; 20 | 21 | finalValue = this.value; 22 | 23 | for (let i = 0, len = this.v.length; i < len; i++) { 24 | this.v[i] = finalValue[i] * this.mult; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/element/CompLottieElement.ts: -------------------------------------------------------------------------------- 1 | import { Engine, Entity } from "@galacean/engine"; 2 | import BaseLottieElement from "./BaseLottieElement"; 3 | 4 | /** 5 | * @internal 6 | */ 7 | export default class CompLottieElement extends BaseLottieElement { 8 | layers: any; 9 | comps: []; 10 | 11 | constructor(layer, engine?: Engine, entity?: Entity, name?: string) { 12 | super(layer); 13 | 14 | this.layers = layer.layers; 15 | this.comps = layer.comps; 16 | 17 | if (entity) { 18 | this.entity = entity; 19 | if (name) { 20 | this.entity.name = name; 21 | } 22 | } else { 23 | const compEntity = new Entity(engine, name); 24 | this.entity = compEntity; 25 | } 26 | } 27 | 28 | destroy() { 29 | super.destroy(); 30 | this.layers = null; 31 | this.comps = null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * some useful toolkit 3 | * @namespace 4 | */ 5 | const Tools = { 6 | /** 7 | * euclidean modulo 8 | * @method 9 | * @param {Number} n input value 10 | * @param {Number} m modulo 11 | * @return {Number} re-map to modulo area 12 | */ 13 | euclideanModulo: function (n, m) { 14 | return ((n % m) + m) % m; 15 | }, 16 | 17 | /** 18 | * bounce value when value spill codomain 19 | * @method 20 | * @param {Number} n input value 21 | * @param {Number} min lower boundary 22 | * @param {Number} max upper boundary 23 | * @return {Number} bounce back to boundary area 24 | */ 25 | codomainBounce: function (n, min, max) { 26 | if (n < min) return 2 * min - n; 27 | if (n > max) return 2 * max - n; 28 | return n; 29 | }, 30 | 31 | /** 32 | * clamp a value in range 33 | * @method 34 | * @param {Number} x input value 35 | * @param {Number} a lower boundary 36 | * @param {Number} b upper boundary 37 | * @return {Number} clamp in range 38 | */ 39 | clamp: function (x, a, b) { 40 | return x < a ? a : x > b ? b : x; 41 | } 42 | }; 43 | 44 | export default Tools; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) bob (Ant Financial) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/EditorLottieLoader.ts: -------------------------------------------------------------------------------- 1 | import { AssetPromise, AssetType, LoadItem, Loader, ResourceManager, Utils, resourceLoader } from "@galacean/engine"; 2 | import { LottieResource, TypeRes } from "./LottieResource"; 3 | 4 | /** 5 | * @internal 6 | */ 7 | // @ts-ignore 8 | @resourceLoader("EditorLottie", ["json"]) 9 | export class EditorLottieLoader extends Loader { 10 | // @ts-ignore 11 | load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { 12 | return new AssetPromise((resolve) => { 13 | const { url } = item; 14 | // @ts-ignore 15 | resourceManager._request(url, { type: "json" }).then((data) => { 16 | const { jsonUrl, atlasUrl } = data; 17 | // @ts-ignore 18 | const jsonPromise = resourceManager._request(Utils.resolveAbsoluteUrl(url, jsonUrl), resourceManager); 19 | const atlasPromise = resourceManager.load({ 20 | url: Utils.resolveAbsoluteUrl(url, atlasUrl), 21 | type: AssetType.SpriteAtlas 22 | }); 23 | 24 | AssetPromise.all([jsonPromise, atlasPromise]).then(([res, atlas]) => { 25 | const { engine } = resourceManager; 26 | const resource = new LottieResource(engine, res as TypeRes, atlas); 27 | resolve(resource); 28 | }); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/property/KeyframedValueProperty.ts: -------------------------------------------------------------------------------- 1 | import Expression from "../Expression"; 2 | import BaseProperty, { TypeKeyframe, TypeValueKeyframedProperty } from "./BaseProperty"; 3 | 4 | /** 5 | * keyframed unidimensional value property 6 | */ 7 | export default class KeyframedValueProperty extends BaseProperty { 8 | private _value: number = 0; 9 | 10 | constructor(data: TypeValueKeyframedProperty, mult: number = 1) { 11 | super(data, mult); 12 | this.v = 0; 13 | 14 | if (Expression.hasSupportExpression(data)) { 15 | this.expression = Expression.getExpression(data); 16 | } 17 | } 18 | 19 | reset() { 20 | this._value = 0; 21 | } 22 | 23 | update(frameNum: number) { 24 | if (this.expression) { 25 | frameNum = this.expression.update(frameNum); 26 | } 27 | 28 | const { value } = this; 29 | 30 | let keyData: TypeKeyframe; 31 | let nextKeyData: TypeKeyframe; 32 | 33 | // Find current frame 34 | for (let i = 0, l = value.length - 1; i < l; i++) { 35 | keyData = value[i]; 36 | nextKeyData = value[i + 1]; 37 | if (nextKeyData.t > frameNum) { 38 | break; 39 | } 40 | } 41 | 42 | if (!keyData.beziers) { 43 | keyData.beziers = []; 44 | } 45 | 46 | this.v = this.getValue(frameNum, 0, keyData, nextKeyData) * this.mult; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rollup.miniprogram.plugin.js: -------------------------------------------------------------------------------- 1 | import inject from "@rollup/plugin-inject"; 2 | import modify from "rollup-plugin-modify"; 3 | const path = require("path"); 4 | 5 | const module = "@galacean/engine-miniprogram-adapter"; 6 | 7 | function register(name) { 8 | return [module, name]; 9 | } 10 | 11 | const adapterArray = [ 12 | "btoa", 13 | "URL", 14 | "Blob", 15 | "window", 16 | "atob", 17 | "devicePixelRatio", 18 | "document", 19 | "Element", 20 | "Event", 21 | "EventTarget", 22 | "HTMLCanvasElement", 23 | "HTMLElement", 24 | "HTMLMediaElement", 25 | "HTMLVideoElement", 26 | "Image", 27 | "navigator", 28 | "Node", 29 | "requestAnimationFrame", 30 | "cancelAnimationFrame", 31 | "screen", 32 | "XMLHttpRequest", 33 | "performance", 34 | "WebGLRenderingContext", 35 | "WebGL2RenderingContext", 36 | "ImageData", 37 | "location", 38 | "OffscreenCanvas", 39 | ]; 40 | const adapterVars = {}; 41 | 42 | adapterArray.forEach((name) => { 43 | adapterVars[name] = register(name); 44 | }); 45 | 46 | const regStr = [`"@galacean/engine"`, `'@galacean/engine'`].join("|"); 47 | 48 | export default [ 49 | inject(adapterVars), 50 | modify({ 51 | find: new RegExp(regStr, "g"), 52 | replace: (match, moduleName) => { 53 | return `${match.substr(0, match.length - 1)}/dist/miniprogram"`; 54 | }, 55 | }), 56 | ]; 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Release 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*" 9 | workflow_dispatch: 10 | inputs: 11 | specific_tag: 12 | description: 'Specific tag to release' 13 | required: false 14 | default: '' 15 | jobs: 16 | release: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | id-token: write 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v4 28 | with: 29 | run_install: true 30 | 31 | # after pnpm 32 | - name: Use Node.js LTS 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: '>=22.6.0' 36 | registry-url: https://registry.npmjs.org/ 37 | cache: pnpm 38 | 39 | - name: Build 40 | run: pnpm build 41 | 42 | - name: Release current monorepo 43 | uses: galacean/publish@main 44 | with: 45 | specific_tag: ${{ inputs.specific_tag }} 46 | publish: false 47 | packages: | 48 | ./ 49 | env: 50 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 51 | NPM_CONFIG_PROVENANCE: true 52 | OASISBE_UPLOAD_URL: https://oasisbe.alipay.com/api/file/no-auth/crypto/upload 53 | OASISBE_REQUEST_HEADER: ${{secrets.OASISBE_REQUEST_HEADER}} 54 | OASISBE_PUBLIC_KEY: ${{secrets.OASISBE_PUBLIC_KEY}} 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Galacean Lottie 2 | 3 | 4 | ![npm-size](https://img.shields.io/bundlephobia/minzip/@galacean/engine-lottie) 5 | ![npm-download](https://img.shields.io/npm/dm/@galacean/engine-lottie) 6 | 7 | This is a [lottie](https://airbnb.io/lottie) runtime created by [Galacean Engine](https://github.com/galacean/engine). It is high-performance owing to drawing all sprites in batch, which is different from svg or canvas renderer of [lottie-web](https://github.com/airbnb/lottie-web). 8 | 9 | See more info: [documentation](https://galacean.antgroup.com/engine/docs/graphics/2D/lottie/) 10 | 11 | ## Features 12 | - [x] Sprite element: transform and opacity animation. 13 | - [x] Text element 14 | - [x] 3D rotation: rotate element in 3D space. 15 | - [x] Animation clip: play animation clip. 16 | 17 | #### TODO 18 | - [ ] Sprite mask 19 | - [ ] Shape element 20 | - [ ] Expression 21 | 22 | ## Usage 23 | 24 | See: https://galacean.antgroup.com/engine/docs/graphics/2D/lottie/ 25 | 26 | ## Install 27 | 28 | ```bash 29 | npm i @galacean/engine-lottie --save 30 | ``` 31 | 32 | ## Contributing 33 | Everyone is welcome to create issues or submit pull requests. Make sure to read the [Contributing Guide](https://github.com/galacean/engine/blob/main/.github/HOW_TO_CONTRIBUTE.md) before submitting changes. 34 | 35 | ## Dev 36 | 37 | 1.Clone this repository and install the dependencies: 38 | 39 | ```bash 40 | npm i 41 | ``` 42 | 43 | 2.Run the example: 44 | 45 | ```bash 46 | npm run example 47 | ``` 48 | ## License 49 | 50 | MIT 51 | -------------------------------------------------------------------------------- /src/element/SpriteLottieElement.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Sprite, SpriteAtlas, SpriteRenderer, TextureWrapMode } from "@galacean/engine"; 2 | import BaseLottieElement from "./BaseLottieElement"; 3 | 4 | /** 5 | * @internal 6 | */ 7 | export default class SpriteLottieElement extends BaseLottieElement { 8 | sprite: Sprite; 9 | spriteRenderer: SpriteRenderer; 10 | 11 | constructor(layer, atlas: SpriteAtlas, entity: Entity, childEntity?: Entity) { 12 | super(layer); 13 | 14 | let spriteRenderer; 15 | 16 | if (layer.refId) { 17 | if (childEntity) { 18 | this.entity = childEntity; 19 | spriteRenderer = childEntity.getComponent(SpriteRenderer); 20 | this.sprite = spriteRenderer.sprite; 21 | } else { 22 | this.sprite = atlas.getSprite(layer.refId); 23 | const spriteEntity = new Entity(entity.engine, layer.nm); 24 | spriteRenderer = spriteEntity.addComponent(SpriteRenderer); 25 | spriteRenderer.sprite = this.sprite; 26 | this.entity = spriteEntity; 27 | } 28 | 29 | const { atlasRegion, texture } = this.sprite; 30 | texture.wrapModeU = texture.wrapModeV = TextureWrapMode.Clamp; 31 | this.spriteRenderer = spriteRenderer; 32 | 33 | // local priority 范围控制在 (0, 1),同时为了尽可能避免精度问题,this.index * 1000000 34 | spriteRenderer.priority = (Number.MAX_SAFE_INTEGER - this.index * 1000000) / Number.MAX_SAFE_INTEGER; 35 | 36 | this.width = atlasRegion.width * texture.width; 37 | this.height = atlasRegion.height * texture.height; 38 | } 39 | } 40 | 41 | destroy() { 42 | super.destroy(); 43 | this.sprite = null; 44 | this.spriteRenderer = null; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/element/TextLottieElement.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Font, Engine, TextRenderer, Logger } from "@galacean/engine"; 2 | import BaseLottieElement from "./BaseLottieElement"; 3 | import { TypeTextKeyframe } from "../LottieResource"; 4 | 5 | /** 6 | * @internal 7 | */ 8 | export default class TextLottieElement extends BaseLottieElement { 9 | constructor(layer, engine?: Engine, entity?: Entity, name?: string) { 10 | super(layer); 11 | 12 | if (entity) { 13 | this.entity = entity; 14 | if (name) { 15 | this.entity.name = name; 16 | } 17 | } else { 18 | this.entity = new Entity(engine, layer.nm); 19 | } 20 | const textRenderer = this.entity.addComponent(TextRenderer); 21 | const keyframes: TypeTextKeyframe[] = layer?.t?.d?.k; 22 | if (keyframes.length === 1) { 23 | // only one frame 24 | const firstKeyframeStart = keyframes?.[0]?.s; 25 | if (firstKeyframeStart) { 26 | const { t: text, f: font, s: fontSize, fc: fontColor, lh: lineHeight } = firstKeyframeStart; 27 | // set the Font object by font 28 | textRenderer.font = Font.createFromOS(engine, font); 29 | // set the text to be displayed by text 30 | textRenderer.text = text; 31 | // set the font size by fontSize 32 | textRenderer.fontSize = fontSize; 33 | // set the text color by color 34 | textRenderer.color.set(fontColor[0], fontColor[1], fontColor[2], 1); 35 | // set line spacing via lineSpacing 36 | textRenderer.lineSpacing = lineHeight; 37 | } else { 38 | Logger.warn(`TextLottieElement: ${name}, No corresponding text data found.`); 39 | } 40 | } else { 41 | // TODO: multi keyframes 42 | Logger.warn(`TextLottieElement: multi keyframes feature is not supported in this version.`); 43 | } 44 | 45 | textRenderer.priority = (Number.MAX_SAFE_INTEGER - this.index * 1000000) / Number.MAX_SAFE_INTEGER; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/main.js", 3 | "browser": "dist/browser.js", 4 | "module": "dist/module.js", 5 | "dependencies": { 6 | "bezier-easing": "^2.1.0" 7 | }, 8 | "peerDependencies": { 9 | "@galacean/engine": ">=1.6.0-0" 10 | }, 11 | "devDependencies": { 12 | "@babel/core": "^7.22.5", 13 | "@babel/plugin-proposal-class-properties": "^7.12.1", 14 | "@babel/plugin-proposal-decorators": "^7.12.1", 15 | "@babel/plugin-proposal-optional-chaining": "^7.12.1", 16 | "@babel/plugin-transform-object-assign": "^7.12.1", 17 | "@babel/preset-env": "^7.12.1", 18 | "@babel/preset-typescript": "^7.12.1", 19 | "@galacean/engine": ">=1.6.0-0", 20 | "@rollup/plugin-babel": "^5.2.1", 21 | "@rollup/plugin-commonjs": "^16.0.0", 22 | "@rollup/plugin-inject": "^4.0.2", 23 | "@rollup/plugin-node-resolve": "^10.0.0", 24 | "@rollup/plugin-replace": "^2.3.4", 25 | "@rollup/plugin-terser": "^0.4.3", 26 | "@swc/core": "^1.3.32", 27 | "@swc/helpers": "^0.5", 28 | "@types/dat.gui": "^0.7.10", 29 | "cross-env": "^5.2.0", 30 | "dat.gui": "^0.7.7", 31 | "react": "^16.14.0", 32 | "react-dom": "^16.14.0", 33 | "rollup": "^2.32.0", 34 | "rollup-plugin-glslify": "^1.2.0", 35 | "rollup-plugin-modify": "^3.0.0", 36 | "rollup-plugin-serve": "^1.1.0", 37 | "rollup-plugin-swc3": "^0.8.0", 38 | "typescript": "^4.8.4", 39 | "vite": "^4.3.9" 40 | }, 41 | "version": "1.6.1", 42 | "description": "Lottie runtime of oasis engine", 43 | "name": "@galacean/engine-lottie", 44 | "repository": "https://github.com/galacean/engine-lottie.git", 45 | "ci": { 46 | "type": "aci", 47 | "versions": "12" 48 | }, 49 | "files": [ 50 | "types", 51 | "dist" 52 | ], 53 | "scripts": { 54 | "example": "vite", 55 | "test": "jest", 56 | "test-cov": "jest --coverage", 57 | "dev": "rollup -cw", 58 | "build": "npm run b:types && cross-env BUILD_TYPE=ALL rollup -c", 59 | "prepublishOnly": "npm run build", 60 | "ci": "npm run lint && npm run test-cov", 61 | "lint": "eslint src --fix --ext .ts,.tsx", 62 | "b:types": "tsc --emitDeclarationOnly" 63 | }, 64 | "types": "types/index.d.ts", 65 | "packageManager": "pnpm@9.3.0" 66 | } 67 | -------------------------------------------------------------------------------- /src/bezier.ts: -------------------------------------------------------------------------------- 1 | import BezierEasing from "bezier-easing"; 2 | 3 | const defaultCurveSegments = 200; 4 | const beziers = {}; 5 | 6 | /** 7 | * get a bezierEasing from real time or cache 8 | */ 9 | function getBezierEasing(a: number, b: number, c: number, d: number, nm?: string): BezierEasing.EasingFunction { 10 | const str = nm || ("bez_" + a + "_" + b + "_" + c + "_" + d).replace(/\./g, "p"); 11 | let bezier = beziers[str]; 12 | 13 | if (bezier) { 14 | return bezier; 15 | } 16 | 17 | bezier = BezierEasing(a, b, c, d); 18 | beziers[str] = bezier; 19 | 20 | return bezier; 21 | } 22 | 23 | const storedData = {}; 24 | 25 | function buildBezierData(s: number[], e: number[], to: number[], ti: number[], segments?: number) { 26 | const curveSegments: number = segments ? Math.min(segments, defaultCurveSegments) : defaultCurveSegments; 27 | const bezierName = ( 28 | s[0] + 29 | "_" + 30 | s[1] + 31 | "_" + 32 | e[0] + 33 | "_" + 34 | e[1] + 35 | "_" + 36 | to[0] + 37 | "_" + 38 | to[1] + 39 | "_" + 40 | ti[0] + 41 | "_" + 42 | ti[1] + 43 | "_" + 44 | curveSegments 45 | ).replace(/\./g, "p"); 46 | 47 | if (!storedData[bezierName]) { 48 | let segmentLength: number = 0; 49 | let lastPoint: number[]; 50 | let points = []; 51 | 52 | for (let k = 0; k < curveSegments; k++) { 53 | const len = to.length; 54 | const point: number[] = new Array(len); 55 | const perc: number = k / (curveSegments - 1); 56 | let ptDistance: number = 0; 57 | 58 | for (let i = 0; i < len; i += 1) { 59 | const ptCoord = 60 | Math.pow(1 - perc, 3) * s[i] + 61 | 3 * Math.pow(1 - perc, 2) * perc * (s[i] + to[i]) + 62 | 3 * (1 - perc) * Math.pow(perc, 2) * (e[i] + ti[i]) + 63 | Math.pow(perc, 3) * e[i]; 64 | 65 | point[i] = ptCoord; 66 | 67 | if (lastPoint) { 68 | ptDistance += Math.pow(point[i] - lastPoint[i], 2); 69 | } 70 | } 71 | 72 | ptDistance = Math.sqrt(ptDistance); 73 | segmentLength += ptDistance; 74 | 75 | points.push({ 76 | partialLength: ptDistance, 77 | point 78 | }); 79 | 80 | lastPoint = point; 81 | } 82 | 83 | storedData[bezierName] = { 84 | segmentLength, 85 | points 86 | }; 87 | } 88 | 89 | return storedData[bezierName]; 90 | } 91 | 92 | export default { 93 | buildBezierData, 94 | getBezierEasing 95 | }; 96 | -------------------------------------------------------------------------------- /src/element/BaseLottieElement.ts: -------------------------------------------------------------------------------- 1 | import { TypeLayer } from "../LottieResource"; 2 | import TransformFrames from "../TransformFrames"; 3 | import { Entity } from "@galacean/engine"; 4 | 5 | /** 6 | * @internal 7 | */ 8 | export default class BaseLottieElement { 9 | transform: TransformFrames; 10 | is3D: boolean; 11 | offsetTime: number; 12 | name: string; 13 | index: number; 14 | stretch: number = 1; 15 | parent: any = null; 16 | inPoint: any; 17 | outPoint: any; 18 | timeRemapping: any; 19 | width: number; 20 | height: number; 21 | visible: boolean = true; 22 | entity: Entity; 23 | startTime: number = 0; 24 | treeIndex: number[] = []; 25 | 26 | private childLayers = []; 27 | 28 | constructor(layer: TypeLayer) { 29 | this.is3D = !!layer.ddd; 30 | this.name = layer.nm || ""; 31 | this.index = layer.index; 32 | this.timeRemapping = layer.tm; 33 | this.width = layer.w; 34 | this.height = layer.h; 35 | 36 | this.inPoint = layer.ip; 37 | this.outPoint = layer.op; 38 | 39 | if (layer.st) { 40 | this.startTime = layer.st; 41 | } 42 | 43 | this.stretch = layer.stretch || 1; 44 | this.offsetTime = layer.offsetTime || 0; 45 | if (layer.ks) { 46 | this.transform = new TransformFrames(layer.ks); 47 | } 48 | } 49 | 50 | reset() { 51 | if (this.transform) { 52 | this.transform.reset(); 53 | } 54 | 55 | for (let i = 0; i < this.childLayers.length; i++) { 56 | this.childLayers[i].reset(); 57 | } 58 | } 59 | 60 | update(frameNum: number = 0, isParentVisible?: boolean) { 61 | const frame = (frameNum - this.offsetTime) / this.stretch; 62 | 63 | if (isParentVisible === true) { 64 | this.visible = this.inPoint <= frame && this.outPoint >= frame; 65 | } else if (isParentVisible === false) { 66 | this.visible = false; 67 | } 68 | 69 | if (this.transform && this.visible) { 70 | this.transform.update(frame); 71 | } 72 | 73 | for (let i = 0; i < this.childLayers.length; i++) { 74 | this.childLayers[i].update(frameNum, this.visible); 75 | } 76 | } 77 | 78 | /** 79 | * add child layer 80 | */ 81 | addChild(node) { 82 | node.parent = this; 83 | node.entity.parent = this.entity; 84 | this.childLayers.push(node); 85 | } 86 | 87 | destroy() { 88 | this.entity.parent = null; 89 | this.entity.destroy(); 90 | this.entity = null; 91 | this.transform = null; 92 | this.parent = null; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/LottieLoader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssetPromise, 3 | AssetType, 4 | Engine, 5 | Entity, 6 | LoadItem, 7 | Loader, 8 | ResourceManager, 9 | Sprite, 10 | Texture2D, 11 | resourceLoader 12 | } from "@galacean/engine"; 13 | 14 | import { LottieAnimation } from "./LottieAnimation"; 15 | import { LottieResource, TypeRes } from "./LottieResource"; 16 | 17 | class Base64Atlas { 18 | private sprites = {}; 19 | private assetsPromises = []; 20 | 21 | constructor(assets, engine: Engine) { 22 | this.assetsPromises = assets.map((asset) => { 23 | return engine.resourceManager 24 | .load({ 25 | url: asset.p, 26 | type: AssetType.Texture2D 27 | }) 28 | .then((texture) => { 29 | const sprite = new Sprite(engine); 30 | sprite.texture = texture; 31 | this.sprites[asset.id] = sprite; 32 | }); 33 | }); 34 | } 35 | 36 | request() { 37 | return Promise.all(this.assetsPromises); 38 | } 39 | 40 | getSprite(id) { 41 | return this.sprites[id]; 42 | } 43 | } 44 | 45 | /** 46 | * @internal 47 | */ 48 | // @ts-ignore 49 | @resourceLoader("lottie", ["json"]) 50 | export class LottieLoader extends Loader { 51 | // @ts-ignore 52 | load(item: LoadItem, resourceManager: ResourceManager): Promise { 53 | const { urls } = item; 54 | // @ts-ignore 55 | const jsonPromise = resourceManager._request(urls[0], { type: "json" }); 56 | 57 | // atlas 58 | if (urls[1]) { 59 | const atlasPromise = resourceManager.load({ 60 | url: urls[1], 61 | type: AssetType.SpriteAtlas 62 | }); 63 | 64 | return AssetPromise.all([jsonPromise, atlasPromise]).then(([res, atlas]) => { 65 | const { engine } = resourceManager; 66 | const resource = new LottieResource(engine, res as TypeRes, atlas); 67 | 68 | const lottieEntity = new Entity(engine); 69 | const lottie = lottieEntity.addComponent(LottieAnimation); 70 | 71 | lottie.resource = resource; 72 | 73 | return lottieEntity; 74 | }); 75 | } 76 | // base64 77 | else { 78 | return AssetPromise.all([jsonPromise]).then(([res]) => { 79 | const { engine } = resourceManager; 80 | const spriteAssets = (res as TypeRes).assets.filter((asset) => asset.p); 81 | (res as TypeRes).assets = (res as TypeRes).assets.filter((asset) => !asset.p); 82 | 83 | const atlas = new Base64Atlas(spriteAssets, engine); 84 | 85 | return atlas.request().then(() => { 86 | const resource = new LottieResource(engine, res as TypeRes, atlas); 87 | 88 | const lottieEntity = new Entity(engine); 89 | const lottie = lottieEntity.addComponent(LottieAnimation); 90 | 91 | lottie.resource = resource; 92 | 93 | return lottieEntity; 94 | }); 95 | }); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/property/BaseProperty.ts: -------------------------------------------------------------------------------- 1 | import bez from "../bezier"; 2 | import Expression from "../Expression"; 3 | 4 | export type TypeValueProperty = { 5 | k: number; 6 | x?: Function; 7 | ix?: number; 8 | a: boolean; 9 | }; 10 | 11 | type TypePoint = { 12 | partialLength: number; 13 | point: number[]; 14 | }; 15 | 16 | export type TypeKeyframe = { 17 | e: number[]; 18 | s: number[]; 19 | t: number; 20 | i?: { x: number; y: number }; 21 | o?: { x: number; y: number }; 22 | h?: number; 23 | ti?: number[]; 24 | to?: number[]; 25 | beziers?: BezierEasing.EasingFunction[]; 26 | timeBezier?: BezierEasing.EasingFunction; 27 | bezierData?: { segmentLength: number; points: TypePoint[] }; 28 | n?: string; 29 | }; 30 | 31 | export type TypeValueKeyframedProperty = { 32 | k: TypeKeyframe[]; 33 | x?: Function; 34 | ix?: number; 35 | a: boolean; 36 | }; 37 | 38 | export type TypeMultiDimensionalProperty = { 39 | k: number[]; 40 | x?: Function; 41 | ix?: number; 42 | a: boolean; 43 | }; 44 | 45 | export type TypeMultiDimensionalKeyframedProperty = { 46 | k: TypeKeyframe[]; 47 | x?: Function; 48 | ix?: number; 49 | a: boolean; 50 | }; 51 | /** 52 | * basic property for animate property unit 53 | * @internal 54 | */ 55 | export default class BaseProperty { 56 | mult: number; 57 | v: any; 58 | 59 | value: any; 60 | newValue: any; 61 | expression: any; 62 | animated: boolean; 63 | 64 | constructor( 65 | data: 66 | | TypeValueProperty 67 | | TypeValueKeyframedProperty 68 | | TypeMultiDimensionalProperty 69 | | TypeMultiDimensionalKeyframedProperty, 70 | mult?: number 71 | ) { 72 | this.mult = mult || 1; 73 | this.value = data.k; 74 | this.animated = data.a; 75 | 76 | if (Expression.hasSupportExpression(data)) { 77 | this.expression = Expression.getExpression(data); 78 | } 79 | } 80 | 81 | getValue(frameNum: number, i: number, keyData: TypeKeyframe, nextKeyData: TypeKeyframe) { 82 | let perc: number; 83 | const keyTime = keyData.t; 84 | const nextKeyTime = nextKeyData.t; 85 | const startValue = keyData.s[i]; 86 | const endValue = (nextKeyData.s || keyData.e)[i]; 87 | 88 | if (keyData.h === 1) { 89 | return startValue; 90 | } 91 | 92 | if (frameNum >= nextKeyTime) { 93 | perc = 1; 94 | } else if (frameNum < keyTime) { 95 | perc = 0; 96 | } else { 97 | let bezier: BezierEasing.EasingFunction = keyData.beziers[i]; 98 | 99 | if (!bezier) { 100 | if (typeof keyData.o.x === "number") { 101 | bezier = bez.getBezierEasing(keyData.o.x, keyData.o.y, keyData.i.x, keyData.i.y); 102 | } else { 103 | bezier = bez.getBezierEasing(keyData.o.x[i], keyData.o.y[i], keyData.i.x[i], keyData.i.y[i]); 104 | } 105 | keyData.beziers[i] = bezier; 106 | } 107 | 108 | perc = bezier((frameNum - keyTime) / (nextKeyTime - keyTime)); 109 | } 110 | 111 | return startValue + (endValue - startValue) * perc; 112 | } 113 | 114 | protected reset() {} 115 | } 116 | -------------------------------------------------------------------------------- /src/property/KeyframedMultidimensionalProperty.ts: -------------------------------------------------------------------------------- 1 | import bez from "../bezier"; 2 | import BaseProperty, { TypeKeyframe, TypeMultiDimensionalKeyframedProperty } from "./BaseProperty"; 3 | 4 | /** 5 | * keyframed multidimensional value property 6 | */ 7 | export default class KeyframedMultidimensionalProperty extends BaseProperty { 8 | private _lastPoint: number = 0; 9 | private _addedLength: number = 0; 10 | private _frames: number; 11 | 12 | constructor(data: TypeMultiDimensionalKeyframedProperty, mult: number = 1, frames?: number) { 13 | super(data, mult); 14 | 15 | let arrLen = this.value[0].s.length; 16 | 17 | // Set bezier segments according to frames, which is better for performance. 18 | if (frames) { 19 | this._frames = frames >> 0; 20 | } 21 | 22 | this.newValue = new Float32Array(arrLen); 23 | this.v = new Float32Array(arrLen); 24 | } 25 | 26 | update(frameNum: number) { 27 | if (this.expression) { 28 | frameNum = this.expression.update(frameNum); 29 | } 30 | 31 | const { value } = this; 32 | let { newValue } = this; 33 | 34 | let keyData: TypeKeyframe; 35 | let nextKeyData: TypeKeyframe; 36 | 37 | // Find current frame 38 | for (let i = 0, l = value.length - 1; i < l; i++) { 39 | keyData = value[i]; 40 | nextKeyData = value[i + 1]; 41 | 42 | if (nextKeyData.t > frameNum) { 43 | this._lastPoint = 0; 44 | this._addedLength = 0; 45 | break; 46 | } 47 | } 48 | 49 | if (frameNum > nextKeyData.t) { 50 | for (let i = 0, len = this.v.length; i < len; i++) { 51 | this.v[i] = this.getValue(frameNum, i, keyData, nextKeyData) * this.mult; 52 | } 53 | return; 54 | } 55 | 56 | if (keyData.to) { 57 | let nextKeyTime: number = nextKeyData.t; 58 | let keyTime: number = keyData.t; 59 | 60 | if (!keyData.bezierData) { 61 | keyData.bezierData = bez.buildBezierData( 62 | keyData.s, 63 | nextKeyData.s || keyData.e, 64 | keyData.to, 65 | keyData.ti, 66 | this._frames 67 | ); 68 | } 69 | 70 | const { points, segmentLength } = keyData.bezierData; 71 | 72 | let bezier = keyData.timeBezier; 73 | 74 | // Cache time bezier easing 75 | if (!bezier) { 76 | bezier = bez.getBezierEasing(keyData.o.x, keyData.o.y, keyData.i.x, keyData.i.y, keyData.n); 77 | keyData.timeBezier = bezier; 78 | } 79 | 80 | let t = 0; 81 | 82 | if (nextKeyTime >= 0) { 83 | t = (frameNum - keyTime) / (nextKeyTime - keyTime); 84 | t = Math.min(Math.max(0, t), 1); 85 | } 86 | 87 | const percent: number = bezier(t); 88 | 89 | let distanceInLine: number = segmentLength * percent; 90 | 91 | let addedLength = this._addedLength; 92 | let lastPoint = this._lastPoint; 93 | 94 | for (let i = lastPoint, l = points.length; i < l; i++) { 95 | if (i === l - 1) { 96 | lastPoint = 0; 97 | addedLength = 0; 98 | 99 | break; 100 | } 101 | 102 | lastPoint = i; 103 | 104 | const point = points[i]; 105 | const nextPoint = points[i + 1]; 106 | const { partialLength } = nextPoint; 107 | 108 | if (distanceInLine >= addedLength && distanceInLine < addedLength + partialLength) { 109 | const segmentPercent: number = (distanceInLine - addedLength) / partialLength; 110 | 111 | for (let k = 0, l = point.point.length; k < l; k += 1) { 112 | newValue[k] = point.point[k] + (nextPoint.point[k] - point.point[k]) * segmentPercent; 113 | } 114 | 115 | break; 116 | } 117 | 118 | // Add partial length util the distanceInLine is between two points. 119 | addedLength += partialLength; 120 | } 121 | 122 | this._lastPoint = lastPoint; 123 | this._addedLength = addedLength; 124 | } else { 125 | if (!keyData.beziers) { 126 | keyData.beziers = []; 127 | } 128 | 129 | for (let i = 0, len = keyData.s.length; i < len; i++) { 130 | newValue[i] = this.getValue(frameNum, i, keyData, nextKeyData); 131 | } 132 | } 133 | 134 | for (let i = 0, len = this.v.length; i < len; i++) { 135 | this.v[i] = newValue[i] * this.mult; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Expression.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import Tools from "./tools"; 3 | 4 | const EX_REG = /(loopIn|loopOut)\(([^)]+)/; 5 | const STR_REG = /["']\w+["']/; 6 | 7 | /** 8 | * Cycle 9 | * @class 10 | * @private 11 | */ 12 | class Cycle { 13 | /** 14 | * Pingpong 15 | * @param {*} type Pingpong 16 | * @param {*} begin Pingpong 17 | * @param {*} end Pingpong 18 | */ 19 | constructor(type, begin, end) { 20 | this.begin = begin; 21 | this.end = end; 22 | this.total = this.end - this.begin; 23 | this.type = type; 24 | } 25 | 26 | /** 27 | * progress 28 | * @param {number} progress progress 29 | * @return {number} progress 30 | */ 31 | update(progress) { 32 | if (this.type === "in") { 33 | if (progress >= this.begin) return progress; 34 | return this.end - Tools.euclideanModulo(this.begin - progress, this.total); 35 | } else if (this.type === "out") { 36 | if (progress <= this.end) return progress; 37 | return this.begin + Tools.euclideanModulo(progress - this.end, this.total); 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * Pingpong 44 | * @class 45 | * @private 46 | */ 47 | class Pingpong { 48 | /** 49 | * Pingpong 50 | * @param {*} type Pingpong 51 | * @param {*} begin Pingpong 52 | * @param {*} end Pingpong 53 | */ 54 | constructor(type, begin, end) { 55 | this.begin = begin; 56 | this.end = end; 57 | this.total = this.end - this.begin; 58 | this.type = type; 59 | } 60 | 61 | /** 62 | * progress 63 | * @param {number} progress progress 64 | * @return {number} progress 65 | */ 66 | update(progress) { 67 | if ((this.type === "in" && progress < this.begin) || (this.type === "out" && progress > this.end)) { 68 | const space = progress - this.end; 69 | return this.pingpong(space); 70 | } 71 | return progress; 72 | } 73 | 74 | /** 75 | * pingpong 76 | * @param {number} space 77 | * @return {number} 78 | */ 79 | pingpong(space) { 80 | const dir = Math.floor(space / this.total) % 2; 81 | if (dir) { 82 | return this.begin + Tools.euclideanModulo(space, this.total); 83 | } else { 84 | return this.end - Tools.euclideanModulo(space, this.total); 85 | } 86 | } 87 | } 88 | 89 | const FN_MAPS = { 90 | loopIn(datak, mode, offset) { 91 | const begin = datak[0].t; 92 | const last = datak.length - 1; 93 | const endIdx = Math.min(last, offset); 94 | const end = datak[endIdx].t; 95 | switch (mode) { 96 | case "cycle": 97 | return new Cycle("in", begin, end); 98 | case "pingpong": 99 | return new Pingpong("in", begin, end); 100 | default: 101 | break; 102 | } 103 | return null; 104 | }, 105 | loopOut(datak, mode, offset) { 106 | const last = datak.length - 1; 107 | const beginIdx = Math.max(0, last - offset); 108 | const begin = datak[beginIdx].t; 109 | const end = datak[last].t; 110 | switch (mode) { 111 | case "cycle": 112 | return new Cycle("out", begin, end); 113 | case "pingpong": 114 | return new Pingpong("out", begin, end); 115 | default: 116 | break; 117 | } 118 | return null; 119 | } 120 | }; 121 | 122 | /** 123 | * parseParams 124 | * @ignore 125 | * @param {string} pStr string 126 | * @return {array} 127 | */ 128 | function parseParams(pStr) { 129 | const params = pStr.split(/\s*,\s*/); 130 | return params.map((it) => { 131 | if (STR_REG.test(it)) return it.replace(/"|'/g, ""); 132 | return parseInt(it); 133 | }); 134 | } 135 | 136 | /** 137 | * parseEx 138 | * @ignore 139 | * @param {string} ex string 140 | * @return {object} 141 | */ 142 | function parseEx(ex) { 143 | const rs = ex.match(EX_REG); 144 | const ps = parseParams(rs[2]); 145 | return { 146 | name: rs[1], 147 | mode: ps[0], 148 | offset: ps[1] 149 | }; 150 | } 151 | 152 | /** 153 | * hasSupportExpression 154 | * @ignore 155 | * @param {string} ksp string 156 | * @return {boolean} 157 | */ 158 | function hasSupportExpression(ksp) { 159 | return ksp.x && EX_REG.test(ksp.x); 160 | } 161 | 162 | /** 163 | * getExpression 164 | * @ignore 165 | * @param {object} ksp ksp 166 | * @return {object} 167 | */ 168 | function getExpression(ksp) { 169 | const { name, mode, offset = 0 } = parseEx(ksp.x); 170 | const _offset = offset === 0 ? ksp.k.length - 1 : offset; 171 | return FN_MAPS[name] && FN_MAPS[name](ksp.k, mode, _offset); 172 | } 173 | 174 | export default { hasSupportExpression, getExpression }; 175 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | import resolve from "@rollup/plugin-node-resolve"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | import glslify from "rollup-plugin-glslify"; 7 | import serve from "rollup-plugin-serve"; 8 | import miniProgramPlugin from "./rollup.miniprogram.plugin"; 9 | import replace from "@rollup/plugin-replace"; 10 | import { swc, defineRollupSwcOption } from "rollup-plugin-swc3"; 11 | 12 | const { BUILD_TYPE, NODE_ENV } = process.env; 13 | 14 | const pkgs = [ 15 | { 16 | location: __dirname, 17 | pkgJson: require(path.resolve(__dirname, "package.json")) 18 | } 19 | ]; 20 | 21 | // toGlobalName 22 | 23 | const extensions = [".js", ".jsx", ".ts", ".tsx"]; 24 | const mainFields = NODE_ENV === "development" ? ["debug", "module", "main"] : undefined; 25 | 26 | const commonPlugins = [ 27 | resolve({ extensions, preferBuiltins: true, mainFields }), 28 | glslify({ 29 | include: [/\.glsl$/] 30 | }), 31 | swc( 32 | defineRollupSwcOption({ 33 | include: /\.[mc]?[jt]sx?$/, 34 | exclude: /node_modules/, 35 | jsc: { 36 | loose: true, 37 | externalHelpers: true, 38 | target: "es5" 39 | }, 40 | sourceMaps: true 41 | }) 42 | ), 43 | commonjs(), 44 | NODE_ENV === "development" 45 | ? serve({ 46 | contentBase: "packages", 47 | port: 9999 48 | }) 49 | : null 50 | ]; 51 | 52 | function config({ location, pkgJson }) { 53 | const input = path.join(location, "src", "index.ts"); 54 | const dependencies = Object.assign(pkgJson.peerDependencies ?? {}); 55 | const commonExternal = Object.keys(dependencies); 56 | commonPlugins.push( 57 | replace({ 58 | preventAssignment: true, 59 | __buildVersion: pkgJson.version 60 | }) 61 | ); 62 | 63 | return { 64 | umd: () => { 65 | let file = path.join(location, "dist", "browser.js"); 66 | const plugins = [...commonPlugins]; 67 | return { 68 | input, 69 | external: commonExternal, 70 | output: [ 71 | { 72 | file, 73 | name: "Galacean.Lottie", 74 | format: "umd", 75 | globals: { 76 | "@galacean/engine": "Galacean" 77 | } 78 | } 79 | ], 80 | plugins 81 | }; 82 | }, 83 | mini: () => { 84 | const external = commonExternal 85 | .concat("@galacean/engine-miniprogram-adapter") 86 | .map((name) => `${name}/dist/miniprogram`); 87 | const plugins = [...commonPlugins, ...miniProgramPlugin]; 88 | return { 89 | input, 90 | output: [ 91 | { 92 | format: "cjs", 93 | file: path.join(location, "dist/miniprogram.js"), 94 | sourcemap: false 95 | } 96 | ], 97 | external, 98 | plugins 99 | }; 100 | }, 101 | module: () => { 102 | const plugins = [...commonPlugins]; 103 | return { 104 | input, 105 | external: commonExternal, 106 | output: [ 107 | { 108 | file: path.join(location, pkgJson.module), 109 | format: "es", 110 | sourcemap: true 111 | }, 112 | { 113 | file: path.join(location, pkgJson.main), 114 | sourcemap: true, 115 | format: "commonjs" 116 | } 117 | ], 118 | plugins 119 | }; 120 | } 121 | }; 122 | } 123 | 124 | async function makeRollupConfig({ type, compress = true, visualizer = true, ..._ }) { 125 | return config({ ..._ })[type](compress, visualizer); 126 | } 127 | 128 | let promises = []; 129 | 130 | switch (BUILD_TYPE) { 131 | case "UMD": 132 | promises.push(...getUMD()); 133 | break; 134 | case "MODULE": 135 | promises.push(...getModule()); 136 | break; 137 | case "MINI": 138 | promises.push(...getMini()); 139 | break; 140 | case "ALL": 141 | promises.push(...getAll()); 142 | break; 143 | default: 144 | break; 145 | } 146 | 147 | function getUMD() { 148 | const configs = [...pkgs]; 149 | return configs.map((config) => makeRollupConfig({ ...config, type: "umd" })); 150 | } 151 | 152 | function getModule() { 153 | const configs = [...pkgs]; 154 | return configs.map((config) => makeRollupConfig({ ...config, type: "module" })); 155 | } 156 | 157 | function getMini() { 158 | const configs = [...pkgs]; 159 | return configs.map((config) => makeRollupConfig({ ...config, type: "mini" })); 160 | } 161 | 162 | function getAll() { 163 | return [...getModule(), ...getMini(), ...getUMD()]; 164 | } 165 | 166 | export default Promise.all(promises); 167 | -------------------------------------------------------------------------------- /src/TransformFrames.ts: -------------------------------------------------------------------------------- 1 | import ValueProperty from "./property/ValueProperty"; 2 | import MultiDimensionalProperty from "./property/MultiDimensionalProperty"; 3 | import KeyframedValueProperty from "./property/KeyframedValueProperty"; 4 | import KeyframedMultidimensionalProperty from "./property/KeyframedMultidimensionalProperty"; 5 | import { TypeMultiDimensionalKeyframedProperty, TypeValueKeyframedProperty } from "./property/BaseProperty"; 6 | 7 | type KeyFrames = { 8 | a: TypeMultiDimensionalKeyframedProperty; 9 | p: TypeMultiDimensionalKeyframedProperty; 10 | s: TypeMultiDimensionalKeyframedProperty; 11 | or?: TypeMultiDimensionalKeyframedProperty; 12 | o: TypeValueKeyframedProperty; 13 | r: TypeValueKeyframedProperty; 14 | rx?: TypeValueKeyframedProperty; 15 | ry?: TypeValueKeyframedProperty; 16 | rz?: TypeValueKeyframedProperty; 17 | }; 18 | 19 | /** 20 | * transform property origin from tr or ks 21 | */ 22 | export default class TransformFrames { 23 | p: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 24 | x: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 25 | y: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 26 | z: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 27 | a: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 28 | s: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 29 | or: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 30 | r: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 31 | o: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 32 | rx: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 33 | ry: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 34 | rz: ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty; 35 | private properties = []; 36 | private autoOrient: boolean = false; 37 | 38 | static create( 39 | data, 40 | type = 0, 41 | mult = 1 42 | ): ValueProperty | MultiDimensionalProperty | KeyframedValueProperty | KeyframedMultidimensionalProperty { 43 | if (!data.k.length) { 44 | return new ValueProperty(data, mult); 45 | } else if (typeof data.k[0] === "number") { 46 | return new MultiDimensionalProperty(data, mult); 47 | } else { 48 | if (type) { 49 | return new KeyframedMultidimensionalProperty(data, mult, data.k[data.k.length - 1].t - data.k[0].t); 50 | } else { 51 | return new KeyframedValueProperty(data, mult); 52 | } 53 | } 54 | } 55 | 56 | constructor(data: KeyFrames) { 57 | const { create } = TransformFrames; 58 | 59 | if (data.p.k) { 60 | this.p = create(data.p, 1); 61 | this.properties.push(this.p); 62 | } else { 63 | if (data.p.x) { 64 | this.x = create(data.p.x, 1); 65 | this.properties.push(this.x); 66 | } 67 | 68 | // @ts-ignore 69 | if (data.p.y) { 70 | // @ts-ignore 71 | this.y = create(data.p.y, 1); 72 | this.properties.push(this.y); 73 | } 74 | 75 | // @ts-ignore 76 | if (data.p.z) { 77 | // @ts-ignore 78 | this.z = create(data.p.z, 1); 79 | this.properties.push(this.z); 80 | } 81 | } 82 | 83 | this.a = create(data.a, 1); 84 | this.properties.push(this.a); 85 | 86 | this.s = create(data.s, 1, 0.01); 87 | this.properties.push(this.s); 88 | 89 | this.o = create(data.o, 0, 0.01); 90 | this.properties.push(this.o); 91 | 92 | // 2d rotation 93 | if (data.r) { 94 | this.r = create(data.r, 0); 95 | this.properties.push(this.r); 96 | } 97 | // 3d rotation 98 | else if (data.rx || data.ry || data.rz) { 99 | if (data.rx) { 100 | this.rx = create(data.rx, 0); 101 | this.properties.push(this.rx); 102 | } 103 | if (data.ry) { 104 | this.ry = create(data.ry, 0); 105 | this.properties.push(this.ry); 106 | } 107 | if (data.rz) { 108 | this.rz = create(data.rz, 0); 109 | this.properties.push(this.rz); 110 | } 111 | } else if (data.or) { 112 | this.or = create(data.or, 1); 113 | this.properties.push(this.or); 114 | } 115 | 116 | if (!this.properties.length) { 117 | this.update(); 118 | } 119 | } 120 | 121 | reset() { 122 | for (let i = 0, len = this.properties.length; i < len; i++) { 123 | this.properties[i].reset(); 124 | } 125 | } 126 | 127 | update(frameNum: number = 0) { 128 | const len = this.properties.length; 129 | 130 | for (let i = 0; i < len; i++) { 131 | this.properties[i].update(frameNum); 132 | } 133 | 134 | if (this.autoOrient) { 135 | // TODO 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/LottieResource.ts: -------------------------------------------------------------------------------- 1 | import { Engine, EngineObject } from "@galacean/engine"; 2 | 3 | export type TypeLayer = { 4 | ddd: number; 5 | sr: number; 6 | st: number; 7 | nm: string; 8 | op: number; 9 | ks: any; 10 | ao: number; 11 | ip: any; 12 | ind: number; 13 | refId: string; 14 | tm: any; 15 | w: number; 16 | h: number; 17 | fr: number; 18 | layers: TypeLayer[]; 19 | 20 | // todo: delete 21 | // 继承了时间条的时序与渲染关系 22 | offsetTime: number; 23 | stretch: number; 24 | index: number; 25 | 26 | t?: TypeText; 27 | }; 28 | 29 | export type TypeAnimationClip = { 30 | name: string; 31 | start: number; 32 | end: number; 33 | auto: boolean; 34 | }; 35 | 36 | export type TypeRes = { 37 | v: string; 38 | nm: string; 39 | ddd: number; 40 | fr: number; 41 | w: number; 42 | h: number; 43 | ip: number; 44 | op: number; 45 | layers: TypeLayer[]; 46 | assets: any[]; 47 | lolitaAnimations?: TypeAnimationClip[]; 48 | }; 49 | 50 | export type TypeText = { 51 | d: { 52 | k: TypeTextKeyframe[]; 53 | }; 54 | }; 55 | 56 | // field explanation:https://lottiefiles.github.io/lottie-docs/text/#text-document 57 | export type TypeTextKeyframe = { 58 | s: { 59 | t: string; 60 | f: string; 61 | s: number; 62 | fc: number[]; 63 | lh: number; 64 | }; 65 | t: number; 66 | }; 67 | 68 | /** 69 | * @internal 70 | */ 71 | export class LottieResource extends EngineObject { 72 | duration: number; 73 | timePerFrame: number; 74 | inPoint: number; 75 | outPoint: number; 76 | height: number; 77 | width: number; 78 | layers: TypeLayer[]; 79 | comps: any[]; 80 | atlas: any; 81 | name: string; 82 | clips: { [name: string]: TypeAnimationClip }; 83 | refCount: number = 0; 84 | 85 | constructor(engine: Engine, res: TypeRes, atlas: any) { 86 | super(engine); 87 | 88 | this.timePerFrame = 1000 / res.fr; 89 | this.duration = Math.floor(res.op - res.ip); 90 | this.width = res.w; 91 | this.height = res.h; 92 | this.inPoint = res.ip; 93 | this.outPoint = res.op; 94 | this.atlas = atlas; 95 | this.layers = res.layers; 96 | this.comps = res.assets; 97 | this.name = res.nm; 98 | this.clips = {}; 99 | 100 | const compsMap = {}; 101 | const { comps } = this; 102 | 103 | if (comps) { 104 | for (let i = 0, l = comps.length; i < l; i++) { 105 | const comp = comps[i]; 106 | if (comp.id) { 107 | compsMap[comp.id] = comp; 108 | } 109 | } 110 | } 111 | 112 | this._buildTree(this.layers, compsMap); 113 | 114 | if (res.lolitaAnimations) { 115 | this._parseAnimations(res.lolitaAnimations); 116 | } 117 | } 118 | 119 | setClips(v: TypeAnimationClip[]) { 120 | this.clips = {}; 121 | this._parseAnimations(v); 122 | } 123 | 124 | private _parseAnimations(clips: TypeAnimationClip[]) { 125 | clips.forEach((clip) => { 126 | this.clips[clip.name] = { ...clip }; 127 | }); 128 | } 129 | 130 | /** 131 | * 在构建树结构的同时,继承合成的时间条关系 132 | * @param layers 133 | * @param compsMap 134 | * @param startTime - 这条合成的 offsetTime 135 | * @param stretch - 这条合成的 stretch 136 | * @param indStart - 这条合成的基础 ind 137 | * @param indFactor - 这条合成的 ind 缩放因子 138 | */ 139 | private _buildTree( 140 | layers, 141 | compsMap, 142 | startTime: number = 0, 143 | stretch: number = 1, 144 | indStart: number = 0, 145 | indFactor: number = 1 146 | ) { 147 | const layersMap = {}; 148 | 149 | for (let i = 0, l = layers.length; i < l; i++) { 150 | const layer = layers[i]; 151 | layersMap[layer.ind ?? i] = layer; 152 | } 153 | 154 | for (let i = layers.length - 1; i >= 0; i--) { 155 | const layer = layers[i]; 156 | const { refId, parent } = layer; 157 | layer.offsetTime = startTime; 158 | layer.stretch = stretch; 159 | layer.index = (layer.ind ?? i) * indFactor + indStart; 160 | if (parent) { 161 | if (!layersMap[parent].layers) { 162 | layersMap[parent].layers = []; 163 | } 164 | 165 | layersMap[parent].layers.push(layer); 166 | layers.splice(i, 1); 167 | } 168 | 169 | if (refId && compsMap[refId]) { 170 | const refLayers = []; 171 | // deep clone the layers in comp asset 172 | for (let j = 0; j < compsMap[refId].layers.length; j++) { 173 | refLayers.push(this._deepClone(compsMap[refId].layers[j])); 174 | } 175 | const offsetTime = (layer.offsetTime || 0) + (layer.st || 0); 176 | const stretch = (layer.stretch || 1) * (layer.sr || 1); 177 | const lastIndex = refLayers.length - 1; 178 | const compIndFactor = (indFactor / ((refLayers[lastIndex].ind ?? lastIndex) + 1)) * indFactor; 179 | this._buildTree(refLayers, compsMap, offsetTime, stretch, layer.index, compIndFactor); 180 | if (layer.layers) { 181 | layer.layers.push(...refLayers); 182 | } else { 183 | layer.layers = [...refLayers]; 184 | } 185 | } 186 | } 187 | } 188 | 189 | private _deepClone(from: Object): Object { 190 | let out = Array.isArray(from) ? [...from] : { ...from }; 191 | Reflect.ownKeys(out).map((key) => { 192 | out[key] = this._isObject(from[key]) ? this._deepClone(from[key]) : from[key]; 193 | }); 194 | return out; 195 | } 196 | 197 | private _isObject(obj: Object) { 198 | return (typeof obj === "object" || typeof obj === "function") && typeof obj !== null; 199 | } 200 | 201 | destroy(): void { 202 | // Maybe atlas is Base64Atlas 203 | this.atlas.destroy?.(); 204 | this.atlas = null; 205 | this.layers = null; 206 | this.clips = null; 207 | this.comps = null; 208 | 209 | super.destroy(); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /example/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { LottieAnimation, LottieResource } from "../src"; 3 | import { Camera, Entity, Layer, Vector3, WebGLEngine } from "@galacean/engine"; 4 | import * as dat from "dat.gui"; 5 | import "./App.css"; 6 | 7 | async function init() { 8 | // gui 9 | const gui = new dat.GUI({ name: "My GUI" }); 10 | 11 | const demos = { 12 | 贝壳红包: [ 13 | "https://gw.alipayobjects.com/os/bmw-prod/01e685be-4090-4e9c-bdef-f437038a4a78.json", 14 | "https://gw.alipayobjects.com/os/bmw-prod/07dcd051-b3d2-4f36-9459-725ae66d9cbf.atlas" 15 | ], 16 | "3d": [ 17 | "https://gw.alipayobjects.com/os/bmw-prod/70bed2d5-7284-44bf-9df6-638f66945ffd.json", 18 | "https://gw.alipayobjects.com/os/bmw-prod/a2853204-2d4a-48e5-9cb7-b89de8dcc7bf.atlas" 19 | ], 20 | 芝麻工作证: [ 21 | "https://gw.alipayobjects.com/os/bmw-prod/32420b26-7305-46ef-bfa1-48c5d6b2a45e.json", 22 | "https://gw.alipayobjects.com/os/bmw-prod/3c054399-2b10-4d68-96f7-0973e3d9ace6.atlas" 23 | ], 24 | 花花卡: [ 25 | "https://gw.alipayobjects.com/os/bmw-prod/b46be138-e48b-4957-8071-7229661aba53.json", 26 | "https://gw.alipayobjects.com/os/bmw-prod/6447fc36-db32-4834-9579-24fe33534f55.atlas" 27 | ], 28 | 灯牌: [ 29 | "https://gw.alipayobjects.com/os/bmw-prod/bbf83713-c23f-4981-8b8d-241d905fc3bf.json", 30 | "https://gw.alipayobjects.com/os/bmw-prod/d9b42223-b1ae-4f51-b489-75b2f36a2b2d.atlas" 31 | ], 32 | 频道氛围1: [ 33 | "https://gw.alipayobjects.com/os/OasisHub/bbbbd4a1-6356-46f8-8c5e-eab55b5a137a/lottie.json", 34 | "https://gw.alipayobjects.com/os/OasisHub/c0730585-4f56-4bf8-9dca-4d027b4826dd/lottie.atlas" 35 | ], 36 | 频道氛围2: [ 37 | "https://gw.alipayobjects.com/os/OasisHub/cb3c7c17-d0c0-4ba3-bbb6-bb009ccd6f96/lottie.json", 38 | "https://gw.alipayobjects.com/os/OasisHub/62c1e950-49c7-4428-a47d-d628696330ea/lottie.atlas" 39 | ], 40 | 频道氛围3: [ 41 | "https://gw.alipayobjects.com/os/OasisHub/58bce243-16e0-45f4-b1e7-3475e03b8f7a/lottie.json", 42 | "https://gw.alipayobjects.com/os/OasisHub/01469a2c-b8ed-4f13-9886-b235a2e326b0/lottie.atlas" 43 | ], 44 | 小狮子: [ 45 | "https://gw.alipayobjects.com/os/bmw-prod/9ad65a42-9171-47ab-9218-54cf175f6201.json", 46 | "https://gw.alipayobjects.com/os/bmw-prod/90779ce2-50f1-4780-ae74-725083eba852.atlas" 47 | ], 48 | 宝箱: [ 49 | "https://gw.alipayobjects.com/os/bmw-prod/84c13df1-567c-4a67-aa1e-c378ee698c55.json", 50 | "https://gw.alipayobjects.com/os/bmw-prod/965eb2ca-ee3c-4c54-a502-7fdc0673f1d7.atlas" 51 | ], 52 | 大桔: [ 53 | "https://gw.alipayobjects.com/os/bmw-prod/da290d57-5d7a-4169-bfa3-b61e3dbe34f9.json", 54 | "https://gw.alipayobjects.com/os/bmw-prod/7e1416d6-64d6-4649-8bc1-fefce8d45adc.atlas" 55 | ], 56 | 年年有鱼: [ 57 | "https://gw.alipayobjects.com/os/OasisHub/14a29798-ea24-42db-93be-462be45f2a85/lottie.json", 58 | "https://gw.alipayobjects.com/os/OasisHub/b60595c5-3d59-42a8-8bf9-f4323c704189/lottie.atlas" 59 | ], 60 | base64: [ 61 | "https://gw.alipayobjects.com/os/bmw-prod/6521d990-6218-4308-aa98-bd7514b9e18f.json" 62 | // 'https://gw.alipayobjects.com/os/finxbff/lolita/97cecb8f-ff16-4fe1-8344-3b8f04ac3713/lottie.json' 63 | // 'https://gw.alipayobjects.com/os/OasisHub/d9d330ca-26fe-45c4-8127-d59a2620dc15/data.json' 64 | // 'https://gw.alipayobjects.com/os/OasisHub/62ee911f-04ac-414c-b100-a18bae585f35/data.json' 65 | // 'https://gw.alipayobjects.com/os/OasisHub/13a05f71-8e93-4569-847f-eb7fbd8dca2d/data.json' 66 | ], 67 | "818": [ 68 | "https://gw.alipayobjects.com/os/bmw-prod/3cb395d8-5196-4382-9459-e4379f9414f3.json", 69 | "https://gw.alipayobjects.com/os/bmw-prod/4bd3f75c-ce9f-4d67-bf28-adbc65fad8b2.atlas" 70 | ], 71 | 碎片: ["https://mdn.alipayobjects.com/huamei_w1o8la/afts/file/A*GxWPRbeur0MAAAAAAAAAAAAADsB_AQ"], 72 | "福袋(金额文字)": [ 73 | "https://mdn.alipayobjects.com/marketing/afts/file/A*LmhJSp_Owt8AAAAAAAAAAAAADviWAQ", 74 | "https://mdn.alipayobjects.com/marketing/afts/file/A*XnCbR7BvZGAAAAAAAAAAAAAADviWAQ" 75 | ] 76 | }; 77 | 78 | let curLottie: LottieAnimation | null; 79 | 80 | let lastLottieEntity: Entity; 81 | const reloadLottie = async (v: string) => { 82 | const lottieEntity = await engine.resourceManager.load({ 83 | urls: demos[v], 84 | type: "lottie" 85 | }); 86 | 87 | if (lastLottieEntity) { 88 | lastLottieEntity.destroy(); 89 | } 90 | 91 | root.addChild(lottieEntity); 92 | lastLottieEntity = lottieEntity; 93 | 94 | const lottie: LottieAnimation = lottieEntity.getComponent(LottieAnimation); 95 | lottie.isLooping = true; 96 | lottie.layer = Layer.Layer1; 97 | // lottie.speed = 0.5; 98 | // destroy resource if need not clone 99 | lottie.resource.destroy(); 100 | lottie.play(); 101 | 102 | // lottieEntity.clone(); 103 | 104 | // test destroy 105 | // setTimeout(() => { 106 | // console.log('destroy') 107 | // lottieEntity.destroy(); 108 | // }, 2000); 109 | 110 | return lottie; 111 | }; 112 | 113 | let alphaController: dat.GUIController; 114 | 115 | gui.add({ name: "base64" }, "name", Object.keys(demos)).onChange(async (v) => { 116 | curLottie = await reloadLottie(v); 117 | alphaController.setValue(curLottie.alpha); 118 | }); 119 | 120 | alphaController = gui.add({ alpha: 1 }, "alpha", 0, 1).onChange((v) => { 121 | if (curLottie) { 122 | curLottie.alpha = v; 123 | } 124 | }); 125 | 126 | // gui add button 127 | gui 128 | .add( 129 | { 130 | play: () => { 131 | if (curLottie) { 132 | if (curLottie.isPlaying) { 133 | curLottie.pause(); 134 | } else { 135 | curLottie.play(); 136 | } 137 | } 138 | } 139 | }, 140 | "play" 141 | ) 142 | .name("Play"); 143 | 144 | const engine = await WebGLEngine.create({ canvas: "canvas" }); 145 | 146 | engine.canvas.resizeByClientSize(); 147 | 148 | const root = engine.sceneManager.activeScene.createRootEntity(); 149 | 150 | const cameraEntity = root.createChild("camera"); 151 | const camera = cameraEntity.addComponent(Camera); 152 | // camera.cullingMask = Layer.Layer1; 153 | cameraEntity.transform.setPosition(0, 0, 10); 154 | cameraEntity.transform.lookAt(new Vector3(0, 0, 0)); 155 | // cameraEntity.addComponent(OrbitControl); 156 | 157 | // curLottie = await reloadLottie('base64'); 158 | // lastLottieEntity = curLottie.entity; 159 | const lottieResource = await engine.resourceManager.load({ 160 | url: "https://mdn.alipayobjects.com/oasis_be/afts/file/A*vGadR5BXK6kAAAAAAAAAAAAADkp5AQ/CCB.json", 161 | type: "EditorLottie" 162 | }); 163 | const lottieResource1 = await engine.resourceManager.load({ 164 | url: "https://mdn.alipayobjects.com/oasis_be/afts/file/A*hBHUQJOpmxUAAAAAAAAAAAAADkp5AQ/CMB.json", 165 | type: "EditorLottie" 166 | }); 167 | const lottieResource2 = await engine.resourceManager.load({ 168 | url: "https://mdn.alipayobjects.com/oasis_be/afts/file/A*9RpGQYQ7FIUAAAAAAAAAAAAADkp5AQ/SPABANK.json", 169 | type: "EditorLottie" 170 | }); 171 | const lottieEntity = root.createChild('lottie'); 172 | const lottieComponent = lottieEntity.addComponent(LottieAnimation); 173 | lottieComponent.resource = lottieResource; 174 | lottieComponent.isLooping = true; 175 | lottieComponent.speed = 1; 176 | lottieComponent.play(); 177 | 178 | setTimeout(() => { 179 | lottieComponent.resource = lottieResource1; 180 | setTimeout(async () => { 181 | lottieComponent.resource = await engine.resourceManager.load({ 182 | url: "https://mdn.alipayobjects.com/oasis_be/afts/file/A*vGadR5BXK6kAAAAAAAAAAAAADkp5AQ/CCB.json", 183 | type: "EditorLottie" 184 | });; 185 | }, 1000); 186 | }, 1000); 187 | 188 | engine.run(); 189 | } 190 | 191 | function App() { 192 | useEffect(() => { 193 | init(); 194 | }, []); 195 | 196 | return ; 197 | } 198 | 199 | export default App; 200 | -------------------------------------------------------------------------------- /src/LottieAnimation.ts: -------------------------------------------------------------------------------- 1 | import CompLottieElement from "./element/CompLottieElement"; 2 | import SpriteLottieElement from "./element/SpriteLottieElement"; 3 | import TextLottieElement from "./element/TextLottieElement"; 4 | import Tools from "./tools"; 5 | import { Script, Vector2, ignoreClone, Entity, Layer, Engine, Renderer } from "@galacean/engine"; 6 | import { LottieResource, TypeAnimationClip } from "./LottieResource"; 7 | import BaseLottieLayer from "./element/BaseLottieElement"; 8 | 9 | export class LottieAnimation extends Script { 10 | private static _pivotVector: Vector2 = new Vector2(); 11 | private static _tempRenderers: Array = []; 12 | 13 | /** The number of units in world space that correspond to one pixel in the sprite. */ 14 | /** Repeat times of the animation. */ 15 | repeats: number = 0; 16 | /** whether the animation loop or not. */ 17 | isLooping: boolean = false; 18 | /** whether the animation play back and forth */ 19 | isAlternate: boolean = false; 20 | /** The direction of animation, 1 means play for */ 21 | direction: 1 | -1 = 1; 22 | speed: number = 1; 23 | // @ts-ignore 24 | pixelsPerUnit: number = Engine._pixelsPerUnit; 25 | 26 | private _alpha: number = 1; 27 | 28 | private _width: number; 29 | private _height: number; 30 | 31 | private _resource: LottieResource; 32 | private _priority: number = 0; 33 | private _priorityDirty: boolean = true; 34 | private _layer: Layer = Layer.Layer0; 35 | private _layerDirty: boolean = true; 36 | private _clip: TypeAnimationClip; 37 | private _autoPlay: boolean = false; 38 | 39 | @ignoreClone 40 | private _clipEndCallbacks: Object = {}; 41 | @ignoreClone 42 | private _isPlaying: boolean = false; 43 | @ignoreClone 44 | private _curFrame: number = 0; 45 | @ignoreClone 46 | private _frame: number = 0; 47 | @ignoreClone 48 | private _root: CompLottieElement = null; 49 | @ignoreClone 50 | private _elements: BaseLottieLayer[]; 51 | 52 | set resource(value: LottieResource) { 53 | if (this._resource === value) { 54 | return; 55 | } 56 | 57 | if (this._resource) { 58 | this.pause(); 59 | this._destroy(); 60 | this._resource.refCount--; 61 | if (this._resource.refCount <= 0) { 62 | this._resource.destroy(); 63 | } 64 | } 65 | 66 | this._resource = value; 67 | if (value) { 68 | value.refCount++; 69 | this._width = value.width; 70 | this._height = value.height; 71 | 72 | this._createElements(value); 73 | this._priorityDirty = true; 74 | this._layerDirty = true; 75 | } 76 | 77 | // update the first frame 78 | this._curFrame = 0; 79 | this.play(); 80 | this.onUpdate(0); 81 | 82 | if (!this.autoPlay) { 83 | this.pause(); 84 | } 85 | } 86 | 87 | get resource(): LottieResource { 88 | return this._resource; 89 | } 90 | 91 | set priority(value: number) { 92 | if (this._priority !== value) { 93 | this._priority = value; 94 | this._priorityDirty = true; 95 | } 96 | } 97 | 98 | get priority(): number { 99 | return this._priority; 100 | } 101 | 102 | set layer(value: Layer) { 103 | if (this._layer !== value) { 104 | this._layer = value; 105 | this._layerDirty = true; 106 | } 107 | } 108 | 109 | get layer(): Layer { 110 | return this._layer; 111 | } 112 | 113 | /** 114 | * global alpha 115 | */ 116 | set alpha(value: number) { 117 | // update in updateElement 118 | if (this._alpha !== value) { 119 | this._alpha = value; 120 | 121 | if (!this.isPlaying) { 122 | this.play(); 123 | this.onUpdate(0); 124 | this.pause(); 125 | } 126 | } 127 | } 128 | 129 | get alpha(): number { 130 | return this._alpha; 131 | } 132 | 133 | set autoPlay(value: boolean) { 134 | this._autoPlay = value; 135 | 136 | if (value) { 137 | this.play(); 138 | } 139 | } 140 | 141 | get autoPlay(): boolean { 142 | return this._autoPlay; 143 | } 144 | 145 | get frame(): number { 146 | return this._frame; 147 | } 148 | 149 | get isPlaying(): boolean { 150 | return this._isPlaying; 151 | } 152 | 153 | /** 154 | * Play the lottie animation 155 | */ 156 | play(name?: string): Promise { 157 | if (name) { 158 | const clip = this.resource.clips[name]; 159 | this._clip = clip; 160 | } else { 161 | this._clip = null; 162 | } 163 | 164 | this._isPlaying = true; 165 | this._frame = this._curFrame; 166 | 167 | return new Promise((resolve) => { 168 | if (name) { 169 | this._clipEndCallbacks[name] = resolve; 170 | } else { 171 | this._clipEndCallbacks["ALL"] = resolve; 172 | } 173 | }); 174 | } 175 | 176 | /** 177 | * Pause the lottie animation 178 | */ 179 | pause(): void { 180 | this._isPlaying = false; 181 | this._curFrame = this._frame; 182 | } 183 | 184 | stop() { 185 | this._isPlaying = false; 186 | this._curFrame = 0; 187 | this._frame = 0; 188 | } 189 | 190 | private _setLayer(layer: Layer, entity?: Entity) { 191 | if (!entity) { 192 | entity = this.entity; 193 | } 194 | 195 | entity.layer = layer; 196 | const children = entity.children; 197 | 198 | for (let i = children.length - 1; i >= 0; i--) { 199 | const child = children[i]; 200 | child.layer = layer; 201 | this._setLayer(layer, child); 202 | } 203 | } 204 | 205 | private _createLayerElements(layers, elements, parent, isCloned?: boolean) { 206 | if (!layers) return; 207 | 208 | for (let i = 0, l = layers.length; i < l; i++) { 209 | const layer = layers[i]; 210 | let element = null; 211 | 212 | if (layer.td !== undefined) continue; 213 | 214 | const treeIndex = parent.treeIndex.concat(i); 215 | 216 | let childEntity: Entity = isCloned && this._findEntityInTree(treeIndex); 217 | 218 | switch (layer.ty) { 219 | case 0: 220 | element = new CompLottieElement(layer, this.engine, childEntity, layer.id); 221 | break; 222 | 223 | case 2: 224 | element = new SpriteLottieElement(layer, this._resource.atlas, this.entity, childEntity); 225 | 226 | break; 227 | 228 | case 3: 229 | if (layer?.ks?.o?.k === 0) { 230 | layer.ks.o.k = 100; 231 | } 232 | 233 | element = new CompLottieElement(layer, this.engine, childEntity, layer.id); 234 | 235 | break; 236 | 237 | case 5: 238 | element = new TextLottieElement(layer, this.engine, childEntity, layer.id); 239 | 240 | break; 241 | } 242 | 243 | if (element) { 244 | element.treeIndex = treeIndex; 245 | 246 | elements.push(element); 247 | parent.addChild(element); 248 | if (layer.layers) { 249 | this._createLayerElements(layer.layers, elements, element, isCloned); 250 | } 251 | } 252 | } 253 | } 254 | 255 | private _findEntityInTree(treeIndex) { 256 | let childEntity: Entity; 257 | 258 | for (let i = 0, l = treeIndex.length; i < l; i++) { 259 | const index = treeIndex[i]; 260 | 261 | if (childEntity) { 262 | childEntity = childEntity.children[index]; 263 | } else { 264 | childEntity = this.entity.children[index]; 265 | } 266 | } 267 | 268 | return childEntity; 269 | } 270 | 271 | private _createElements(value, isCloned?: boolean) { 272 | const root = new CompLottieElement(value, this.engine, this.entity); 273 | this._root = root; 274 | 275 | const { layers } = root; 276 | 277 | const elements = []; 278 | 279 | this._createLayerElements(layers, elements, root, isCloned); 280 | 281 | this._elements = elements; 282 | } 283 | 284 | private _updateElements(correctedFrame: number): void { 285 | this._root.update(correctedFrame); 286 | 287 | const elements = this._elements; 288 | 289 | for (let i = 0, l = elements.length; i < l; i++) { 290 | const layer = elements[i]; 291 | 292 | this._updateElement(layer); 293 | } 294 | } 295 | 296 | private _updateElement(layer: T) { 297 | // @ts-ignore 298 | const { transform, entity, sprite, spriteRenderer, parent, width, height } = layer; 299 | const entityTransform = entity.transform; 300 | const a = transform.a.v; 301 | const s = transform.s.v; 302 | let o = transform.o.v; 303 | const { pixelsPerUnit } = this; 304 | 305 | let x: number = 0, 306 | y: number = 0, 307 | z: number = 0; 308 | 309 | if (transform.p) { 310 | const p = transform.p.v; 311 | x = p[0] ?? 0; 312 | y = p[1] ?? 0; 313 | z = p[2] ?? 0; 314 | } else { 315 | if (transform.x) { 316 | x = transform.x.v; 317 | } 318 | 319 | if (transform.y) { 320 | y = transform.y.v; 321 | } 322 | 323 | if (transform.z) { 324 | z = transform.z.v; 325 | } 326 | } 327 | 328 | let rx = 0; 329 | let ry = 0; 330 | let rz = 0; 331 | 332 | if (!layer.visible) { 333 | entity.isActive = layer.visible; 334 | return; 335 | } 336 | 337 | // 2d rotation 338 | if (transform.r) { 339 | rz = transform.r.v; 340 | } 341 | // 3d rotation 342 | else if (transform.rx || transform.ry || transform.rz) { 343 | rx = transform.rx ? transform.rx.v : 0; 344 | ry = transform.ry ? transform.ry.v : 0; 345 | rz = transform.rz ? transform.rz.v : 0; 346 | } else if (transform.or) { 347 | const { v } = transform.or; 348 | rx = v[0]; 349 | ry = v[1]; 350 | rz = v[2]; 351 | } 352 | 353 | // parent opacity 354 | if (parent?.transform?.o) { 355 | o *= parent?.transform.o.v; 356 | } 357 | 358 | if (sprite) { 359 | // update color of sprite 360 | const { r, g, b } = spriteRenderer.color; 361 | spriteRenderer.color.set(r, g, b, o * this._alpha); 362 | 363 | // update pixels per unit of sprite 364 | sprite.pixelsPerUnit = pixelsPerUnit; 365 | 366 | // update pivot of sprite 367 | sprite.pivot = LottieAnimation._pivotVector.set(a[0] / width, (height - a[1]) / height); 368 | } 369 | 370 | entity.isActive = layer.visible; 371 | 372 | // scale 373 | entityTransform.setScale(s[0], s[1], s[2]); 374 | entityTransform.setRotation(rx, ry, -rz); 375 | 376 | // anchor 377 | if (parent?.transform?.a) { 378 | entityTransform.setPosition( 379 | (x - parent.transform.a.v[0]) / pixelsPerUnit, 380 | (-y + parent.transform.a.v[1]) / pixelsPerUnit, 381 | z / pixelsPerUnit 382 | ); 383 | } else { 384 | entityTransform.setPosition( 385 | (x - this._width / 2) / pixelsPerUnit, 386 | (-y + this._height / 2) / pixelsPerUnit, 387 | z / pixelsPerUnit 388 | ); 389 | } 390 | } 391 | 392 | private _resetElements() { 393 | const elements = this._elements; 394 | 395 | for (let i = 0, l = elements.length; i < l; i++) { 396 | elements[i].reset(); 397 | } 398 | } 399 | 400 | /** 401 | * @override 402 | */ 403 | onUpdate(deltaTime: number): void { 404 | if (!this._isPlaying || !this._resource) { 405 | return null; 406 | } 407 | 408 | if (this._priorityDirty) { 409 | this._priorityDirty = false; 410 | const renderers = LottieAnimation._tempRenderers; 411 | renderers.length = 0; 412 | this.entity.getComponentsIncludeChildren(Renderer, renderers); 413 | // the diff of global priority 414 | let priorityDiff = 0; 415 | for (let i = 0, l = renderers.length; i < l; ++i) { 416 | const renderer = renderers[i]; 417 | if (i === 0) { 418 | // this._priority represent global priority,Math.floor(renderer.priority) get current global priority 419 | priorityDiff = this._priority - Math.floor(renderer.priority); 420 | } 421 | renderer.priority = renderer.priority + priorityDiff; 422 | } 423 | } 424 | 425 | if (this._layerDirty) { 426 | this._layerDirty = false; 427 | this._setLayer(this.layer, this.entity); 428 | } 429 | 430 | const time = this.direction * this.speed * deltaTime * 1000; 431 | this._frame += time / this._resource.timePerFrame; 432 | const clip = this._clip; 433 | 434 | if (this._spill()) { 435 | const { duration } = this._resource; 436 | this._resetElements(); 437 | 438 | if (this.repeats > 0 || this.isLooping) { 439 | if (this.repeats > 0) { 440 | --this.repeats; 441 | } 442 | 443 | if (this.isAlternate) { 444 | this.direction *= -1; 445 | if (clip) { 446 | this._frame = Tools.codomainBounce(this._frame, 0, clip.end - clip.start); 447 | } else { 448 | this._frame = Tools.codomainBounce(this._frame, 0, duration); 449 | } 450 | } else { 451 | this.direction = 1; 452 | if (clip) { 453 | this._frame = Tools.euclideanModulo(this._frame, clip.end - clip.start); 454 | } else { 455 | this._frame = Tools.euclideanModulo(this._frame, duration); 456 | } 457 | } 458 | } else { 459 | if (clip) { 460 | if (this._frame >= clip.end - clip.start) { 461 | const endCallback = this._clipEndCallbacks[clip.name]; 462 | if (endCallback) { 463 | endCallback(clip); 464 | } 465 | } 466 | 467 | this._frame = Tools.clamp(this._frame, 0, clip.end - clip.start); 468 | } else { 469 | if (this._frame >= duration) { 470 | const endCallback = this._clipEndCallbacks["ALL"]; 471 | if (endCallback) { 472 | endCallback(); 473 | } 474 | } 475 | 476 | this._frame = Tools.clamp(this._frame, 0, duration); 477 | } 478 | } 479 | } 480 | 481 | if (clip) { 482 | this._updateElements(this._resource.inPoint + this._frame + clip.start); 483 | } else { 484 | this._updateElements(this._resource.inPoint + this._frame); 485 | } 486 | } 487 | 488 | /** 489 | * is this time frame spill the range 490 | */ 491 | private _spill(): boolean { 492 | let duration: number; 493 | 494 | if (this._clip) { 495 | const clip = this._clip; 496 | duration = clip.end - clip.start; 497 | } else { 498 | duration = this._resource.duration; 499 | } 500 | 501 | const bottomSpill = this._frame <= 0 && this.direction === -1; 502 | const topSpill = this._frame >= duration && this.direction === 1; 503 | return bottomSpill || topSpill; 504 | } 505 | 506 | /** 507 | * @override 508 | * @param target 509 | */ 510 | _cloneTo(target) { 511 | target._createElements(this._resource, true); 512 | } 513 | 514 | private _destroy() { 515 | const elements = this._elements; 516 | if (elements) { 517 | for (let i = 0, l = elements.length; i < l; i++) { 518 | elements[i].destroy(); 519 | } 520 | } 521 | } 522 | 523 | onDestroy(): void { 524 | this._destroy(); 525 | } 526 | } 527 | --------------------------------------------------------------------------------