Web Animations/Shared Timing

From Effects Task Force

Summary

This page contain some thoughts about the proposal for TimedItems to have a separate Timing object that contains timing parameters and which can be shared.

The proposal

Based on the notes in [1], the proposed API would look something like the following:

 anim.startTime // READ ONLY - don't even provide and beef up AnimationController?
 anim.currentTime // READ ONLY - don't even provide and beef up AnimationController?
 anim.currentIteration
 anim.filteredTime
 anim.endTime // READ ONLY - don't even provide and beef up AnimationController?
 anim.timing.startDelay
 anim.timing.iterationStart
 anim.timing.iterationCount
 anim.timing.iterationDuration
 anim.timing.fillMode
 anim.timing.playbackRate
 anim.timing.direction
 anim.timing.timingFunction
 anim.getActiveDuration() // getActiveDuration
 anim.getIterationDuration() // for groups, maybe even put on TimingGroup interface.

The proposal also expects that in the future Timing would be extended to allow a cascade where Timing items would be chained together such that undefined items in one Timing object would defer to the equivalent item on the next Timing object in the chain.

For the first version it is proposed that Timing objects could be shared between TimedItems but not chained.

Concerns

Local changes have wide-ranging side effects

If Timing objects are shared, it is not possible to know if a change made to a given Timing object has side effects. This is particularly hazardous for new users and libraries and lends itself to surprising bugs.

For example, consider the use case of a library that implements timing based on a fixed rate of change, e.g. "2px/s". This has been proposed for SVG 2 and is a likely candidate for a script library to implement. The library takes an Animation, reads the animation values and adjusts the keyframe offsets and iteration duration to achieve the desired rate of change.

If such a library were passed an animation with timing shared in the proposed approach, it would possibly cause undesired results for the other connected animations. Although it is possible that the author intended all linked animations to match the passed-in animation's duration even after the scaling it is more likely that their intention was either that (a) only the given animation change, or (b) all linked animations have a "2px/s" rate of change. Furthermore, the library is unable to do anything to detect or prevent this misuse. Generally speaking, libraries are unable to safely make local changes.

Furthermore, this property of seemingly local changes having possibly wide-ranging side effects, easily lends itself to misunderstanding by new users. For example,

animA = document.createAnimation(...);
animB = document.createAnimation(...);
// I just want animB to have the same timing as animA
animB.timing = animA.timing;
// But I also want animB to have a duration of 5
animB.duration = 5;
// Hey, what happened to animA ??

While one might argue that this is idiomatic javascript and could be worked around by adding a clone() method to Timing, it is sure to catch authors out (especially when the change is subtle) and we would do best to avoid loading this footgun.

Sharing is very limited

The sharing offered by Timing objects in the first version is very limited. It is all or nothing. If, for example, it is desired to share all timing properties but not the start delay, this is not possible. Perhaps start delay could be factored out into groups, but then what about fill mode, or duration etc. ?

As a result, the number of cases where sharing of Timing is useful in version one is very limited.

Where you set the duration is different to where you get it

anim.timing.iterationDuration = 5;
anim.getIterationDuration();

A similar inconsistency is seen in startTime and startDelay.

var actualStart = anim.startTime + anim.timing.startDelay;

In short, it's difficult for authors to remember where each property/method lives. Even assuming we establish some sensible rules that dictate where each item lives, it's still another concept the author must learn.

Unclear migration path

While the API for a version 2 that supports cascading of Timing items is yet to be proposed, an initial consideration of options suggests that simply adding Timing objects to version 1 does not necessarily pave a clear path for adding chaining in version 2.

For example, in a chain of Timing objects a means is required for indicating which values fall-through and which stick. So far it has been suggested that null or undefined values would fall-through. Using null would imply all members of the Timing interface are null-able from version 1 (to avoid breaking content that assumes otherwise). Also, changes would be required to the way Timing objects are created so that not all members are set.

Also, assuming that members of Timing objects are allowed to be unspecified (be it null or undefined), this would imply there is no single place where final values are available. We would need to either (a) add getFinalTiming()—which has the same problem of introducing a gap between where values are set and where they are queries, or (b) rely on client code for filling in default values when performing calculations. (Other options? I thought maybe we could say that the Timing attached to a TimedItem would have to be fully specified but that doesn't work.)

Similarly, if timing.iterationDuraton = null is used, in version 1, to mean, "Use the intrinsic iteration duration", then an alternative mechanism would be required to mean, "Use the inherited iteration duration" in version 2. Most likely a sentinel value (undefined would not pass from a usability point of view) or resetIterationDuration() would be needed. In any case, this would need to be present from version 1.

Complexity

As well as these specific issues, my concern is that there are a raft of minor complexities introduced by this approach which outweigh its benefit in version 1.

Adding the Timing object to version one introduces an additional step in the chain when interacting with timing properties (literally every single other API reviewed puts the duration etc. directly on the Animation), it is another interface to look up when coding, it differs from every other animation API including HTML—not such a big deal by itself but makes the API a little less guessable, it is another concept (albeit a simple one, but if we have guidelines for when items appear on TimedItem vs Timing it is still another concept to learn), it is another moving piece for implementations to track (a minor concern but not insignificant since sharing means either you fragment the memory by allocating lots of smaller objects, or you do plumbing to keep track of them and split them off as needed), and it lends itself to misuse (covered above).

All of these costs may be justifiable if this feature provided significant value that addressed common use cases faced by the majority of authors, but, certainly in version one, it serves at best a very limited range of use cases (due to the limited sharing) of interest only to advanced developers many of which could probably be achieved by cloning or iterating over a list. Meanwhile the cost of the complexity is paid by all authors.

Proposed solution

In light of the above concerns I think a preferable approach would:

  • Provide a single place where all final values can be queried regardless of any chaining that may be added in future
  • Provide a place where local changes can be safely made by libraries etc. without having side effects
  • Make the two above places one and the same

This is a fairly easy set of requirements to satisfy. The question, however, is, "Could such an approach be extended to support the kind of sharing of timing information proposed above?" I believe it could.

Firstly, the version 1 API:

 anim.startTime
 anim.currentTime
 anim.currentIteration
 anim.filteredTime
 anim.endTime
 anim.startDelay
 anim.iterationStart
 anim.iterationCount
 anim.iterationDuration
 anim.activeDuration
 anim.fillMode
 anim.playbackRate
 anim.direction
 anim.timingFunction
 anim.resetIterationDuraton()

Notes:

  • Local changes have no side effects
  • All final values are in one place
  • Setting and getting the iteration duration are in the same place (the same attribute)
  • startTime and startDelay are in the same place
  • Consistent with other APIs

Also, when reading code:

anim.resetIterationDuration();

is easier to understand than:

anim.timing.iterationDuration = null;

What if we want to share timing information in a future version?

One (straw-man) possibility:

partial interface TimedItem {
  attribute Timing? shared;
};

Setting shared to a Timing object makes all the properties on the TimedItem take their values from the Timing object.

Subsequently setting any properties directly on the TimedItem (e.g. anim.startDelay) is effectively a local override. Timed items keep track internally of which properties are specified locally and which are derived from shared.

shared is initially null.

partial interface TimedItem {
  Timing shareTiming();
};

Returns a Timing object filled with the current values of the timing properties on the TimedItem and makes all the timing properties on the TimedItem take their values from the newly created Timing object.

(iterationDuration probably requires special handling)

partial interface TimedItem {
  Timing useShared(Timing timing, DOMString properties...);
};

Sets shared to timing. If properties is provided it limits which properties are read from shared and which ones are specified locally.

(I'm not really sure how to represent the last property yet, but it probably should take either (a) nothing, (b) a DOMString, or (c) a sequence of DOMStrings. The important thing for now is that it's optional).

Putting this API into practice:

var animA = document.createAnimation(...);
var animB = document.createAnimation(...);

// Make animA and animB share all timing
animB.shared = animA.shareTiming();
// Or:
animB.useShared(animA.shareTiming());

// animC wants to share just the duration with A and B
animC.useShared(animA.shared, 'duration');

// Locally override startDelay on B
animB.startDelay = 5;

// Reset the local override
animB.useShared(animB.shared, 'duration');

The key feature of this is that sharing behaviour is explicit so users don't accidentally share properties and get surprising bugs.

Consider the following code snippet:

anim.shared.duration = 3;

An author who has never used this API before can make a pretty good guess about what is going on here, and they will certainly be aware of the possibility of side effects from this action.

At the same time, there is still a single place where all final values can be queried (and changed). Furthermore, even before adding cascading it is possible to share just some timing and not all. (In fact, I'm not quite sure how cascading would work or if it's even necessary—one level of sharing is probably enough for most uses?)

The point is not that we should do this. The API needs work, for sure. The point is to try and establish, if we leave out sharing in version 1, whether we can add it later. I hope this proposal suggests it is possible, but we can follow this path a bit further and try to write out something more concrete if others are concerned it won't work.