Animation library

Overview

All animations generated by the Prysm exporter are controlled through the Prysm animation library. It is responsible for the execution of complex animations, frame scripts, timeline control, etc. The library was created so that the runtime of documents can be as close as possible to the Flash runtime. Therefore, there are very strict rules regarding how and when elements are updated.

The library works with timelines, animation states, and property tracks.

  • A property track defines an animation for a certain property, much like CSS keyframes.
  • An animation state is a container for multiple property tracks.
  • A timeline is a container for animation states and other timelines. It also has a current time and is responsible for updating all of its animation states at any given time.

Animation is then achieved by constantly updating timelines to change the values of animation states.

Initialization

The library attaches an object named animationSystem to the window.prysm namespace. The object has an animationData key that configures the animations of objects.

Associating animation data to DOM nodes is done through their data-prysm-id attribute. When a node is connected to the DOM, it checks whether there is an entry for its data-prysm-id in the object. If there is, the element is assigned a prysm instance that contains an Animation State and/or a Timeline depending on its configuration. Elements that don't have a data-prysm-id are ignored by the library.

window.prysm.animationSystem.animationData = {
    "prysm_0": {
        timeline: {
            duration: 1000,
            labels: {
                "aa": {
                    "Layer_2": 0
                }
            }, 
            playbackRate: -1,
            frameCatchUpCount: 5,
            isPaused: false
        }
    },
    "prysm_0_0": {
        timeline: {
            duration: 41.667,
            playbackRate: 1,
            frameCatchUpCount: 2,
            isPaused: true
        },
        animationState: {
            showTime: 0,
            hideTime: 1000,
            "550x400": [
                new window.prysm.ASPropertyTrack(window.prysm.ASPropertyType.LEFT, [
                    new window.prysm.ASPropertyStop(0,
                        new window.prysm.ASValueUnit(0, window.prysm.ASUnitType.VH),
                        false
                    ),
                    new window.prysm.ASPropertyStop(958.333,
                        new window.prysm.ASValueUnit(112.5, window.prysm.ASUnitType.VH),
                        false
                    )
                ]),
                new window.prysm.ASPropertyTrack(window.prysm.ASPropertyType.TOP, [
                    new window.prysm.ASPropertyStop(75,
                        new window.prysm.ASValueUnit(0, window.prysm.ASUnitType.VH),
                        false
                    )
                ]),
                new window.prysm.ASPropertyTrack(window.prysm.ASPropertyType.Z_INDEX, [
                    new window.prysm.ASPropertyStop(0,
                        1,
                        false
                    )
                ]),
                new window.prysm.ASPropertyTrack(window.prysm.ASPropertyType.TRANSFORM_ORIGIN, [
                    new window.prysm.ASPropertyStop(0,
                        [
                            new window.prysm.ASValueUnit(6.125, window.prysm.ASUnitType.VH),
                            new window.prysm.ASValueUnit(6.125, window.prysm.ASUnitType.VH)
                        ],
                        false
                    )
                ])
            ]
        }
    },
    "prysm_0_0_0": {
        animationState: {
            showTime: 0,
            hideTime: 41.667,
        }
    }
};

Updating animations

When the start method of the library is called it starts updating animations whenever possible. This is done through a request animation frame loop, that executes JavaScript before the screen can be updated.

To have correctly synchronized animations, it is important to have all elements registered before starting the animation system. This is why the animation system starts on engine.whenReady that in turn waits for the window.load event.

It is important to note that animation states aren't standalone. Instead, they are animated through timelines.

When updating animations, the system notifies all root timelines that they have to update. They notify all of their animation states that in turn update the properties of their associated DOM nodes. Timelines also notify their nested timelines, hence the entire document is updated.

A timeline spawns and despawns nested timelines and animation states. The play state of a timeline determines whether it will update or not - A paused timeline will not advance its animation states until played.

Note: In the scenario where there is a Movie Clip(timeline B) nested in the timeline(A) and A is stopped while B is alive, the timeline B will continue animating.

As the state of animations is within the DOM nodes duplicate nodes created via data binding automatically receive an animation.

Updating animations specifics

The main building blocks of the library are the AnimationSystem, Timeline, AnimationState, and PropertyTrack classes.

  • A PropertyTrack is a sequence of animated or static values for a property. The property track defines how values are interpolated and can return the value of the property for any given time. Properties are animated according to the CSS specification.
  • An AnimationState represents an element in a timeline. It has a reference to a DOM node, the show time and hide time of the node, and animations in the form of property tracks. The animation state is responsible for updating the styles of the dom node.
  • A Timeline contains animation states, labels, and frame scripts. It is responsible for advancing all elements at the same time, executing frame scripts, and seeking time on timeline control methods.
  • The AnimationSystem contains the top-level timelines and is responsible for updating them when possible. Timelines in turn update their nested timelines, hence the whole document is updated each time that the animation system is updated.

Documents start hidden by default (via display: none).

When the animation library is started it queues a request animation frame loop, makes the document visible and spawns all timelines and animation states that have to be visible. Timelines with a positive playback rate are spawned at time 0, while timelines with a negative playback rate are spawned at a time duration - 0.1.

Each timeline knows its last update time. The time between the previous and the current request animation call is the delta. The animation system executes all frame scripts that are between these two points and reacts accordingly.

If there are no frame scripts, the library just updates all animation states to the new update time which is (lastUpdate + delta) % duration. When the new time exceeds the timeline duration, the timeline loops.

If there are frame scripts, the system updates the timeline states to the time of the script and then executes it. If there are multiple frame scripts for a frame, they are all executed. When the scripts are executed, the system checks whether the timeline has to seek to a different time, pause or change its playback rate. This forces the following rules.

  1. Methods are called from top to bottom (animate order).
  2. The last self.play() or self.stop() call takes precedence.
  3. The last self.gotoAndPlay(), self.gotoAndStop() or self.playFromTo() call takes precedence.
  4. If a self.gotoAndPlay(), self.gotoAndStop() or self.playFromTo() is called, self.play() and self.stop() calls are ignored.

When a frame script is called one of the following things can happen.

  1. The timeline has to seek somewhere.
  2. The timeline has to stop.
  3. The timeline changes its playback rate.

Timelines seek immediately and that doesn't waste any delta, which can lead to infinite loops e.g. a frame script at frame 1 seeks to frame 5 and at frame 5 a frame script seeks to frame 1. This behavior is the same in Flash.

If a timeline has to stop, the update doesn't continue.

If the timeline changes direction, the remaining time for the update is spent in the opposite direction.

If there is a play from to that ends before the last update or the method to be called, it is iterated and the delta is adjusted.

The actions that are done after a frame script are the following.

  1. While the timeline has to seek somewhere, seek it.
    • This updates the states to the new (seek) time and calls all frame scripts at that time.
  2. If the timeline is stopped, stop updating.
  3. If there is a play from to that ends at the time of the frame script, iterate it.
    • Internally this performs the same actions, as play from to implies a seek.
  4. Otherwise continue.

This means that the precedence of actions is the following.

  1. Seek - self.gotoAndStop(), self.gotoAndPlay(), self.playFromTo().
  2. Stop - self.stop()
  3. End play from to

If a frame script seeks the current position of the timeline the frame scripts at that position are not called. This is because they have already been executed and follow the implementation of Flash.

Triggering animations on an event

The abovementioned has an interesting effect when we want to create an animation that is stopped by default and triggered on an event.

To create an animation that is stopped by default you write a self.stop() frame script on the first frame.

To trigger it you can create a click event on the symbol wrapper that plays the animation from the first frame.

Since frame scripts on the same frame are not executed when the timeline is sought to the same time the animation will start, however, clicking it a second time will execute them and stop the animation again.

To avoid that you can create a dummy first frame that has the self.stop() frame script and the first state of the animated content. That way the element will be rendered correctly. Since clicking on the element seeks its timeline to the START label that is on the second frame, the stop script is not executed when the element is clicked again and the animation is retriggered on multiple clicks. What is more, since timelines loop when they end by default the self.stop() script will be executed when the animation ends. This way the animation is correct when clicked once.

Frames catch up

The speed with which a timeline is updated depends on the number of frame scripts in it. If the delta is big and there are a lot of frame scripts, the update can be quite slow. Therefore, the framesCatchUp parameter of timelines is implemented. It specifies how many frames will be executed at maximum. Frame scripts and actions that have happened before the framesCatchUp time are ignored.

When the delta is greater than the framesCatchUp * fps, the timeline is sought to lastUpdate + delta - framesCatchUp * fps and the algorithm continues.

Frame scripts specifics

Frame scripts are written through the Actions panel of Adobe Animate. You can write random JavaScript in it and it will be executed.

  • The this in frame scripts is bound to the movie clip DOM node.
  • The first parameter of the frame script is a self variable that is the timeline object instance.
  • The timeline object contains labels, seeking methods, etc.
  • The movie clip DOM node is an instance of a JavaScript class that can be controlled via a custom class definition.

Custom class definitions

You can provide a custom implementation of a movie clip (or scene) class through the custom class definition UI. When you write something in the input field of the custom class definition a boilerplate class is generated.

class boilerplate extends window.prysm.MovieClip {
    connectedCallback() {
        super.connectedCallback();
    }

    disconnectedCallback() {
        super.disconnectedCallback();
    }

    attributeChangedCallback(name, oldValue, newValue) {
        super.attributeChangedCallback(name, oldValue, newValue);
    }
}

It is important to note that the class implementation must extend the window.prysm.MovieClip class and it must call the super methods from the boilerplate to correctly work with the animation system.

Timeline control methods

  • The play method resumes playing a timeline.
  • The stop method stops playing a timeline.
  • The gotoAndStop method seeks the timeline to a different frame, executes all frame scripts in that frame, and stops the timeline.
  • The gotoAndPlay method seeks the timeline to a different frame, executes all frame scripts in that frame, and resumes playing the timeline.
  • The playFromTo method seeks the timeline to a different frame and remembers a frame where the timeline has to stop.
  • The playFromTo(start, end), gotoAndStop(frame), gotoAndStart(frame) methods accept a number, treated as a frame index or an object. The object must contain a name key that specifies a label name and can contain a layerName key that specifies the layer of the label in the source document.
  • The playFromTo method has an iteration count parameter. -1 is infinite, 0 is an error. When the iteration count is >1 the timeline will return to the start time when the end time is reached.
  • The playFromTo method returns a Promise that resolves when the to time is reached iterationCount times. If the playFromTo is cleared the Promise remains pending.
  • All timeline control methods clear the previous playFromTo.
  • The playbackRate property of timelines is used to change its direction. The playbackRate can be a float number. Greater absolute values force the timeline to update faster e.g a playbackRate of 2 will make a timeline long 1000ms to be iterated twice for 1000ms.

Freezing states and timelines

Sometimes you want to hide an element or a subtree of elements from the screen. Using the hideDomNode method will not stop the animation library from showing it again and advancing its nested timelines. To achieve the desired effect you have to freeze the element first. Frozen animation states are not changed by parent timelines. Therefore, the following code will stop the element from being animated and hide it.

const element = document.querySelector(".myElement");
element.prysmInstance.animationState.freeze();
element.style.display = "none";

If the element is a MovieClip its prysm instance will have both an animation state and a timeline. When you freeze its state the animation of the movie clip, defined in the timeline where it is instantiated, will not be advanced. That implies that that movie clip element won't be hidden/shown by its parent timeline and its visual appearance won't be updated. However, its timeline will still be updated and its nested animations will advance.

To hide a subtree of elements, you can recursively freeze all nested states in the timeline and stop all timelines, but that is slow. For that, we implemented a freeze method on timelines as well. When a timeline is frozen it will be ignored by its parent timeline, hence all nested animations will not advance.

The following code freezes a movie clip instance and hides it. This stops the animation system from advancing it and provides a performance benefit for hidden elements.

const element = document.querySelector(".myElement");
element.prysmInstance.animationState.freeze();
element.prysmInstance.timeline.freeze();
element.style.display = "none";

Note: Frozen elements will still rescale when the screen is resized to correctly support responsive UI.

It is important to note that freezing takes precedence higher in the DOM tree. So if you freeze a timeline it will freeze everything nested within it. Freezing/Unfreezing an animation state nested inside it won't have an effect while the parent is frozen. When the parent is unfrozen, elements that are explicitly frozen within it will remain frozen.

When a timeline or animation state is unfrozen it will be synchronized with the parent timeline and will continue from where it left off.

Initialization specifics

The window.prysm.animationSystem.animationData collection is a map from data-prysm-id to animation data objects.

window.prysm.animationSystem.animationData = {
    "prysm_0": {},
    "prysm_0_0": {},
    "prysm_0_0_0": {},
};

An animation data object can contain a timeline and an animationState key.

window.prysm.animationSystem.animationData = {
    "prysm_0": {
        timeline: {},
        animationState: {}
    }
};

The timeline entry can contain the following keys.

  • duration - the duration of the timeline MANDATORY
  • frameCatchUpCount - the frame catch up count
  • labels - a labels map used by timeline control methods
  • isPaused - whether the timeline starts paused
  • playbackRate - the initial playback rate of the timeline
window.prysm.animationSystem.animationData = {
    "prysm_0": {
        timeline: {
            duration: 1000,
            labels: {
                "aa": {
                    "Layer_2": 0
                }
            }, 
            playbackRate: -1,
            frameCatchUpCount: 5,
            isPaused: false
        }
    }
};

The animationState contains the following keys.

  • showTime is the time when the element is shown.
  • hideTime is the time when the element is hidden.

The other keys in the animationState are treated as resolution keys. The resolution keys contain the animation for a certain screen resolution.

window.prysm.animationSystem.animationData = {
    "prysm_0_0": {
        animationState: {
            showTime: 0,
            hideTime: 1000,
            "550x400": {}
        }
    }
};

A screen resolution key can contain the following keys.

  • top
  • left
  • right
  • bottom
  • width
  • height
  • transformOrigin
  • transform
  • filter
  • opacity
  • clipPath
  • zIndex
  • mixBlendMode
  • maskImage
  • maskPosition
  • maskSize

Each of the abovementioned keys defines a property track.

window.prysm.animationSystem.animationData = {
    "prysm_0_0": {
        animationState: {
            showTime: 0,
            hideTime: 1000,
            "550x400": [
                new window.prysm.ASPropertyTrack(window.prysm.ASPropertyType.LEFT, [
                    new window.prysm.ASPropertyStop(0,
                        new window.prysm.ASValueUnit(0, window.prysm.ASUnitType.VH),
                        false
                    ),
                    new window.prysm.ASPropertyStop(41.667,
                        new window.prysm.ASValueUnit(4.887, window.prysm.ASUnitType.VH),
                        true
                    ),
                    new window.prysm.ASPropertyStop(83.333,
                        new window.prysm.ASValueUnit(9.775, window.prysm.ASUnitType.VH),
                        true
                    ),
                    new window.prysm.ASPropertyStop(125,
                        new window.prysm.ASValueUnit(14.662, window.prysm.ASUnitType.VH),
                        true
                    ),
                    //...
                    new window.prysm.ASPropertyStop(958.333,
                        new window.prysm.ASValueUnit(112.5, window.prysm.ASUnitType.VH),
                        true
                    ),
                ])
            ]
        }
    }
};

The first ASPropertyStop argument is the time, the second is the value and the third specifies whether the system will interpolate values between the previous one and the current one.

Nested animations

Nested animations are updated in the following scenario.

  • Each time that a timeline is sought, its nested timelines are spawned/despawned based on the new time.
  • When a frame script is called, nested timelines are ticked with the passed delta from the last update to the frame script time.
  • When a timeline loops, nested timelines are ticked.
  • When the timeline is updated to the last time of the tick, nested timelines are updated.

Timeline control in the UI

For each timeline, you can change its playbackRate and framesCatchUp through the UI.

These options are available for the scene or symbol.

You can modify them from the Scene tab, Scene timeline control accordion, and from the Symbol tab, Basic properties accordion.

Changing them for the symbol will affect the symbol's timeline animations.

Changing them for the scene will affect scenes' timeline animations.

Changes in 2020.4.0

Initialization optimization

Firstly, the whenRegistered method is removed in 2020.4.0 as all connectedCallbacks (when nodes are registered in the animation system) are called before the Window: load event. This makes it possible to skip creating all promises in the library and still properly synchronize initialization. Since the engine.whenReady event waits for the Window: load event, the animation system is started after engine.whenReady. After the load event, connectedCallbacks are called immediately after a node is appended to the DOM, so any JavaScript that requires the element to be registered can be executed immediately after the element is added.

Furthermore, there was a lot of duplication in the initialization state of the animation system. We mitigated that, by making it possible to refer to a different animation state. This way there won't be many copies of the same state when the same symbol is used multiple times.

"prysm_0_1_0": "prysm_0_0_0",
"prysm_0_1_0_0": "prysm_0_0_0_0",

What is more, the animation system accepted string values that required parsers to transform them into internal types that can be animated. We changed the input of the library to the internal types directly. This skips the parsing steps and makes loading times faster. Since many documents are already created with the previous version of the library we created a CLPrysmAnimationSystemBackwardsCompatability.js script that provides backward compatibility. The goal is to deprecate the old input format, but in the meantime, you can just include the script as an external file when needed.

Last but not least, the animation system works with requestAnimationFrame(timestamp). To know how much time has passed since the last update the timestamp of that method is used. In the previous version, we queued a requestAnimationFrame on start and the first update was an initial setup step. The next step was the first update, so there was 1 frame delay. With the current version, we added a requestAnimationFrame that keeps track of timestamps while the animation system is idle. This way, when the animation system is started we immediately execute the initial step and the next update already has a delta and there isn't a 1 frame delay.

Data binding

There wasn't a well-defined place where data binding should have been created/synchronized in the previous version to correctly work with the animation system. We exposed the addCallback method to the animation system and the window.prysm.ASCallbackType options for it. The window.prysm.ASCallbackType.WILL_START is called before the animation system starts. This is the place, where data binding code should be executed. When that method is invoked, all elements will be registered in the animation system, so structural data binding will be synchronized with the animation system. If the document is exported through the Exporter, engine.whenReady is guaranteed to have been called.

window.prysm.animationSystem.addCallback(window.prysm.ASCallbackType.WILL_START, function() {
    engine.createJSModel("model", myModel);
    engine.synchronizeModels();
});

You can remove the callbacks through the removeCallback method. If you don't remove them they will be called every time before the animation system starts.

Error handling

When an element fails to register in the animation system it will call all callbacks associated with window.prysm.ASCallbackType.REGISTRATION_ERROR, so you can add your error handling code to that callback.