airbnb / lottie-web

Render After Effects animations natively on Web, Android and iOS, and React Native. http://airbnb.io/lottie/
MIT License
30.57k stars 2.87k forks source link

[feature request] Make it possible to initialize a lottie instance without giving a reference to a DOM node #1860

Open wujekbogdan opened 4 years ago

wujekbogdan commented 4 years ago

Sometimes it takes very long for the animations to load. While the animations are being loaded the main thread is blocked by Lottie for a significant amount of time - even hundreds of milliseconds on Core i7 CPU.

I was trying to offload Lottie initialization to a Web Worker - I wanted to call lottie.loadAnimation from within a Web Worker thread. But it turned out It is not possible because WebWorkers don't have access to DOM while lottie.loadAnimation method requires a reference to a DOM node to be passed as the container argument.

So I wonder is it possible to make the loadAnimation method DOM agnostic? It would be great if we could initialize the Lottie instance in a Web Worker thread, return it, and then mount on a DOM element.


PS I know there's a canvas rendered, but Offscreen Canvas, which is supported by WebWorkers has still very poor browsers' support: https://caniuse.com/#feat=offscreencanvas

bodymovin commented 4 years ago

Hi, I'll think about what could be done about this. But do you have an example of a slow loading animation? Also keep in mind that you can pass progressiveLoad as true to the renderer options which will load animations gradually while layers are needed. This should distribute loading across multiple frames.

wujekbogdan commented 4 years ago

@bodymovin

Hi, I'll think about what could be done about this.

That's great, I really appreciate it.

But do you have an example of a slow loading animation?

This problem is not specific to a particular animation. Loading any large animation file (by large I mean > ~200kb) block the main thread for a significant amount of time.

Do a simple test:

console.time('lottie');
lottie.loadAnimation(json);
console.timeEnd('lottie');

Also keep in mind that you can pass progressiveLoad as true to the renderer options which will load animations gradually while layers are needed. This should distribute loading across multiple frames.

That's interesting, I'll check it out. It might be a good workaround. But still it would be great if the DOM reference wasn't required by loadAnimation.

I mean something like:

const lottieInstance = lottie.loadAnimation(json); // initialization part would be offloaded to a WebWorker
const $el = document.querySelector('#js--animation-container');
lottieInstance.mount($el)

Let's make sure that my issue was understood correctly. I'm not talking about the animation being laggy. After the animation is loaded it works perfectly. The issue I'm talking about affects only the inial load.

bodymovin commented 4 years ago

I'll think about it and try some things. Meanwhile can you check if progressiveLoad makes any difference?

wujekbogdan commented 4 years ago

I'll think about it and try some things.

Thank you!

Meanwhile can you check if progressiveLoad makes any difference?

progressiveLoad disabled.

219.840087890625ms
219.47412109375ms
228.85302734375ms
294.090087890625ms

progresiveLoad enabled

229.35302734375ms
248.43798828125ms
318.260986328125ms
218.35107421875ms

The numbers vary on each execution, but I can't see a significant difference. Sometimes progressiveLoad enabled is faster, sometimes the opposite.

bodymovin commented 4 years ago

can you share one of the animations?

wujekbogdan commented 4 years ago

I shared them with you via e-mail (the one that's visible on your user's profile). I don't want to share these resources publicly because they belong to the company I work for.

bodymovin commented 4 years ago

Hi, I've been doing some progress on this. Would you be willing to try it out?

wujekbogdan commented 4 years ago

Hi, I've been doing some progress on this.

Cool!

Would you be willing to try it out?

Yes, of course. Is there any branch I can pull to try it out?

bodymovin commented 4 years ago

branch is 76_mount_dom. It only works on the svg renderer for now. You can get the player from the build folder: https://github.com/airbnb/lottie-web/tree/76_mount_dom/build/player

you need to pass mount as false in the rendererSettings. Once it's ready, you can call mount() on the animation instance.

If you try it out, let me know how it goes. Thanks!

wujekbogdan commented 4 years ago

Thanks, I gave it a try, but I can't get it to work:

Issues

Workers don't have an access to:

Source: https://www.html5rocks.com/en/tutorials/workers/basics/#toc-enviornment-features

PS It's not an issue with the build because the library, used in a standard way (not in a WebWorker thread) works fine. The mount() method also works fine. The problem only applies to running it in as a Web Worker thread.


Here's the WebWorker code:

I use webpack together with GoogleChromeLabs/worker-plugin

A generic module that loads and promisifies all the workers in my app:

// web-worker.js
import isFunction from 'lodash/isFunction';
import ExtendableError from '@/models/ExtendableError';

export class WorkerFactoryError extends ExtendableError {}

export const WORKERS = {
  LOAD_LOTTIE_ANIMATION: () => new Worker('./load-lottie-animation.js', { type: 'module' }),
  // ... more workers here
};

/**
 * @param {String} msg - error message
 * @param {Function} reject - a reference to Promise.reject
 */
const throwException = (msg, reject) => {
  const error = new WorkerFactoryError(msg);
  reject(error);
  throw error;
};

/**
 * @param {Function} worker - a reference to a function that returns a new Worker instance
 * @param {*} postMessageData
 * @return {Promise<any>}
 */
export default (worker, postMessageData) =>
  new Promise((resolve, reject) => {
    if (!isFunction(worker)) {
      throwException(`worker is expected to be a function, instead got ${typeof worker}.`, reject);
    }

    const workerInstance = worker();

    if (!(workerInstance instanceof Worker)) {
      throwException(
        `worker callback is supposed to return a Worker instance, instead got ${typeof workerInstance}.`,
        reject
      );
    }

    const handleError = (e) => {
      workerInstance.terminate();
      throwException(e.message, reject);
    };

    const handleMessage = ({ data }) => {
      workerInstance.terminate();
      resolve(data);
    };

    workerInstance.addEventListener('message', handleMessage, false);
    workerInstance.addEventListener('error', handleError, false);
    workerInstance.postMessage(postMessageData);
  });

A tiny postMessage wrapper:

// register-worker.js
export default (callback) => {
  addEventListener('message', ({ data }) => {
    postMessage(callback(data));
  });
};

The lottie worker that returns a lottie instance.

// load-lottie-animation.js
import '@/3rd-party/lottie/lottie';
import registerWorker from './register-worker';

export default registerWorker((options = {}) => {
  const { lottie } = self;
  return lottie.loadAnimation({
    ...options,
    renderer: 'svg',
    rendererSettings: {
      mount: false,
    }
  });
});

Here's the Lottie initialization code

import webWorker, { WORKERS } from '@/web-worker';
import animation from './animation.json';

const loadAnimation = async (animationData, container) => {
  const lottie = await webWorker(WORKERS.LOAD_LOTTIE_ANIMATION, {
    animationData,
    container, // this element can't be here. It can't be sent via postMessage
    loop: false,
    autoplay: true,
  });
  lottie.renderer.mount(); // I was expecting to be able to do lottie.renderer.mount(container)
};

loadAnimation(animation, document.getElementById('#js--lottie'));
bodymovin commented 4 years ago

Hi, you are right, if you are initializing it on the webworker side, the container needs to be passed afterwards. I'll work on that next. Regarding the window reference, it is actually not being used that much, but it does expect window to be available. I can perhaps try to handle it at the module level with an inner reference, but as a quick workaround, can you try declaring a global window variable before initializing the library?

wujekbogdan commented 4 years ago

Hi, you are right, if you are initializing it on the webworker side, the container needs to be passed afterwards. I'll work on that next.

Thanks.

Regarding the window reference, it is actually not being used that much, but it does expect window to be available. I can perhaps try to handle it at the module level with an inner reference, but as a quick workaround, can you try declaring a global window variable before initializing the library?

I added these lines at the very top of the player.js file in order to "mock" all the objects and methods that lottie needed to access.

// player.js
var window = {};
var document = {
  createElement: function() {
    return {
      getContext: function() {
        return {
          fillRect: function() {
            return {
            }
          }
        }
      }
    }
  },
  getElementsByTagName: function() {
    return {
    }
  }
};

I started with var window = {} and kept on adding the remaining ones until I reach a stage when the errors caused by missing methods/objects stopped showing up.

Now lottie.loadAnimation() returns an instance of the AnimationItem object, but this object can't be transferred from the WebWorker thread to the main thread via postMessage.

  // After mocking `window` and `document` objects the following piece of code executes without rasing any arror:
  const lt = lottie.loadAnimation({
    ...options,
    renderer: 'svg',
    rendererSettings: {
      mount: false,
    },
  });

  // I get an AnimationItem here
  console.log(lt);

  // But here where the `registerWorker` tries to send the data via `postMessage` I'm getting an error
  return lt;

After postMessage executes, I'm getting the following error:

Uncaught DataCloneError: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': function removeElement(ev){
        var i = 0;
        var animItem = ev.target;
        while(i<len) {
       ...<omitted>... } could not be cloned.

I'm afraid that this feature will require much more work. At the moment the whole lottie library is imported by the WebWorker - the main thread receives a complete, initialized lottie instance. IMO the architecture should be broken into 2 modules:

I'm thinking of something like this:

Main thread:

// main-thread.js
import webWorker, { WORKERS } from '@/web-worker';
import animation from './animation.json';
import { player } from 'lottie';

const loadAnimation = async (animationData, container) => {
  // Returns an initialized lottie animation
  const lottie = await webWorker(WORKERS.LOAD_LOTTIE_ANIMATION, {
    animationData,
    loop: false,
    autoplay: true,
  });

  // passes the animation to the player and mounts it on DOM
  player(lottie).mount(container);
};

loadAnimation(animation, document.getElementById('#js--lottie'));

WebWorker thread:

// load-lottie-animation.js
import { initializer } 'lottie';
import registerWorker from './register-worker';

export default registerWorker((options = {}) => {
  return initializer.loadAnimation(options);
});
wujekbogdan commented 4 years ago

@bodymovin

Is there any progress on this feature? Can we expect a solution to this problem in future releases of lottie-web?

dnix commented 4 years ago

also interested in this feature 👍

bodymovin commented 4 years ago

@wujekbogdan @dnix what are the use cases where you would need this?

wujekbogdan commented 4 years ago

I've described the reason in my first post in this topic: https://github.com/airbnb/lottie-web/issues/1860#issue-521131207


Lottie initialization process is a very CPU-heavy process, in case of complex animations the main thread gets busy for hundreds of milliseconds, even up to a 1s and more (depending on the animation complexity and the CPU). JS is single-threaded, because of this, while the animation is being processed by lottie-web, the whole application's UI is frozen (all the animations are stopped, hovers don't work, events are being triggered, etc). It gives a very bad user experience.

WebWorkers run in a separate thread, so offloading the init process to a separate WebWorker thread would make the init process way smoother.

So basically, this is a very standard use case for WebWorkers.

dnix commented 4 years ago

I have a use case for passing a canvas/2D context to a WebGL texture, without a DOM element. Appreciate your thoughts on this!

On May 9, 2020, at 9:15 AM, hernan notifications@github.com wrote:

 @wujekbogdan @dnix what are the use cases where you would need this?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub, or unsubscribe.

hr6134 commented 3 years ago

@bodymovin

We are also stumbled with this issue. We are showing loading animation while initialising game level. FPS drops almost to 0 when lottie.loadAnimation starts.

michaeljherrmann commented 3 years ago

@bodymovin this would be useful for me to too. I'm using Lottie with PixiJS and don't want the Lottie renderer to render to the DOM. I'm just creating a WebGL texture to use as a resource for Pixi.

bodymovin commented 3 years ago

on the last release, I've added a lottie_worker player that delegates all js execution to a worker and only transfers to the main thread changed on the svg DOM. I think there are several things that could be extended from this work, but still not sure how it would integrate with other needs. @wujekbogdan would that work for your scenario?

wujekbogdan commented 3 years ago

Thank you.

I didn't have a look at it yet, but from your description it looks like you implemented Web Workers support in the library - the lib spawns a Web Worker on it's own.

It might work well although It's not exactly what I requested for. My original request was to make Lottie Web Workers enabled so that devs could offload Lottie tasks to the Web Worker thread on their own using their own Web Workers implementation (e.g. "raw" Web Workers or tools like comlink or workerize-loader.

Anyway, I'll give it a try and post a comment here.

bodymovin commented 3 years ago

since web workers only can transfer structured-clonable type of objects, it's not that straightforward to initiate lottie on a web worker and then run it on the main thread, regardless of its dependencies on window and document. https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm I did two explorations so far:

I still don't see different way of doing what you originally requested. The first exploration seems closer to your suggestion, but it almost makes no difference in the end.

yisibl commented 2 years ago

Hi @bodymovin

Can you add some documentation and examples on how to use Web Workers on v5.8.1?

mustafaabobakr commented 2 years ago

Any updates ? How to init Lottie in a web worker Nextjs?