another post?
available lang for this post:en

BCC #3 -- change the default behavior of `commitStyles` (web animation)




"BCC" stands for "Browser Contribution/Crash Club" :)

Background

Motivation

(original: https://github.com/w3c/csswg-drafts/issues/5394#issuecomment-681542821)

commitStyles is a bit tricky.

To persist animation, currently you have to do like this...

test(t => {
  const div = createDiv(t);

  div.style.opacity = '0';
  const animation = div.animate(
    { opacity: 1 },
    { duration: 1000, fill: 'forwards' } // We need to set fill-mode as "forwards".
  );
  animation.finish();
  animation.commitStyles();

  animation.cancel(); // We have to cancel the animation to avoid animation leak.

  assert_numeric_style_equals(getComputedStyle(div).opacity, 0.2);
}, 'Commits styles');

...or this.

// Birtle's code
// ref: https://github.com/w3c/csswg-drafts/issues/5394#issuecomment-672394173

function finishAndPersistAnimation(anim) {
  anim.finish();
  anim.effect.updateTiming({ fill: 'both' }); // This is the trick.
  anim.commitStyles();
  anim.cancel();
}

But if we can do like this, it will be convenient :)

test(t => {
  const div = createDiv(t);

  div.style.opacity = '0;';
  const animation = div.animate(
    { opacity: 1 },
    { duration: 1000 }
  );
  animation.finish();
  animation.commitStyles();
  // We don't have to cancel because fill mode is none by default.

  assert_numeric_style_equals(getComputedStyle(div).opacity, 1);
}, 'CommitStyles should sample the end value for a finished animation even without forwards fill mode.');

FYI

It's recommended to read MDN from the below section, in order to know about the current usage of commitStyles(), fill:'forwards' and cancel().

https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API#persisting_animation_styles

Develop

Plan

Birtles, web animation spec owner, has proposed the direction.
https://github.com/w3c/csswg-drafts/issues/5394#issuecomment-759145509

What will be changed is commitStyles() behavior on the animation endpoint. On just the moment when the animation has finished, commitStyles() is supposed to be able to get the end value. That is the case.

So, this change should not affect the behavior of commitStyles() with the fill mode "forwards". And the definition of fill mode enums should be kept as-is.

Let's go :)

We have to know about the belows.

  1. How is the value that commitStyles uses decided internally?
  2. How can I change the behavior just only on the boundary?

Point 2 seems a bit complex. I wanna make changes only on the boundary case.

<div id='aa' style="width:100px;height:100px;background-color:black;color:white;">target</div>
<script>
    Object.assign(aa.style, {
        opacity: "0",
    });
    const animation = aa.animate({opacity:1},{duration:1000});
    // choose one.
    // animation.currentTime = 999.999;     -> final opacity = 1. wanna KEEP it.
    // animation.finish();                  -> final opacity = 0. wanna CHANGE it to 1.
    // animation.currentTime = 1000;        -> final opacity = 0. wanna CHANGE it to 1.
    // animation.currentTime = 1000.001;    -> final opacity = 0. wanna KEEP it.
    animation.commitStyles();
    animation.cancel();
</script>

Anyway, clarifying point 1 will solve anything :) we'll see.

1. How is the value that commitStyles uses decided internally?

In Animation::CommitStyles, you can see this part. Here the animation values to be committed seems to be calculated. Let's dive in :)

// Comments are mine.
Animation::CommitStyles(/*...*/) {
    // ...
    // `mEffect` is a member var paralleled to `mTimeline`.
    // Web animation spec defines "Animation model" and "Timing model".
    // Animation in an abstract sense is called "Animation Effect". (ref: https://www.w3.org/TR/web-animations-1/#animation-effects)
    // So `keyframeEffect` here is like an animation as keyframes.
    RefPtr<KeyframeEffect> keyframeEffect = mEffect->AsKeyframeEffect();
    // ...
    UniquePtr<StyleAnimationValueMap> animationValues(
        Servo_AnimationValueMap_Create());
    if (!presContext->EffectCompositor()->ComposeServoAnimationRuleForEffect(
            *keyframeEffect, CascadeLevel(),
            animationValues
                .get())) {
        NS_WARNING("Failed to compose animation style to commit");
        return;
    }
    // ...
}

After sorting animations on the receiver (target) of commitStyles(), KeyframeEffect::ComposeStyle is called on each animation as KeyframeEffect. AFAIK, web animation as the integration of SVG and CSS animations does not have any ways describing animation ("animation effect") other than keyframe. So composing style from corresponding animation is always via keyframe representing it. Anyway, KeyframeEffect::ComposeStyle is this.

void KeyframeEffect::ComposeStyle(
    StyleAnimationValueMap& aComposeResult,
    const InvertibleAnimatedPropertyIDSet& aPropertiesToSkip) {
  // `computedTiming` is a kind of "what time is it?" for the keyframe.
  ComputedTiming computedTiming = GetComputedTiming();
  if (computedTiming.mProgress.IsNull()) {
    return;
  }

  for (size_t propIdx = 0, propEnd = mProperties.Length(); propIdx != propEnd;
       ++propIdx) {
    const AnimationProperty& prop = mProperties[propIdx];
    // ...
    const AnimationPropertySegment *segment = prop.mSegments.Elements(),
                                   *segmentEnd =
                                       segment + prop.mSegments.Length();
    while (segment->mToKey <= computedTiming.mProgress.Value()) {
      MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys");
      if ((segment + 1) == segmentEnd) {
        break;
      }
      ++segment;
      MOZ_ASSERT(segment->mFromKey == (segment - 1)->mToKey, "incorrect keys");
    }
    MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys");
    MOZ_ASSERT(segment >= prop.mSegments.Elements() &&
                   size_t(segment - prop.mSegments.Elements()) <
                       prop.mSegments.Length(),
               "out of array bounds");
    ComposeStyleRule(aComposeResult, prop, *segment, computedTiming);
  }
  // ...
}

mSegment (AnimationPropertySegment) seems to define the interpolation range between two keyframes for each property. (I don't confirm it by actual debug. Can be wrong.)

Let's say we have the below CSS.

@keyframes example {
  0%   { opacity: 0; transform: translateX(0px); }
  30%  { transform: translateX(100px); }  /* `opacity` is not defined */
  50%  { opacity: 0.5; }
  100% { opacity: 1; transform: translateX(200px); }
}

opacity segments are like this.

mFromKey mToKey fromValue toValue
0.0 0.5 0.0 0.5
0.5 1.0 0.5 1.0

On the other hand, transform ones are like this.

mFromKey mToKey fromValue toValue
0.0 0.3 translateX(0px) translateX(100px)
0.3 1.0 translateX(100px) translateX(200px)

Which segment should be used depends on properties, because properties can have different way of progress like above. This is why the while loop is done for each prop.

I don't know, but this part may be related to spec "5.3.4".

Digging and digging, reached to the below code. (fyi, it's convenient to use Searchfox when IDE code jump doesn't work (e.g. the glue code like glue.rs between C++ (Gecko) and Rust (Servo)))

// Comments are mine.
// This func seems to calc what `commitStyles` writes to the inline style.
pub extern "C" fn Servo_AnimationCompose(/*...*/) {
    // ...

    // 0.0 <= progress <= 1.0 (ref: `dom/animation/ComputedTiming.h`)
    let progress = unsafe { Gecko_GetProgressFromComputedTiming(computed_timing) };
    
    // ...

    // It may be the key how this func behaves when `progress` is just 1.0.
    let result = compose_animation_segment(
        segment,
        underlying_value,
        last_value,
        iteration_composite,
        computed_timing.mCurrentIteration,
        progress,
        position,
    );
    value_map.insert(property, result);
}

After debugging compose_animation_segment with a tremendous amount of print, I noticed the calc doesn't reach to compose_animation_segment on the boundary case, because animation is already neither neither animating nor filling at the sampled time and in such case mProgress of ComputedTiming is null.

void KeyframeEffect::ComposeStyle(
    StyleAnimationValueMap& aComposeResult,
    const InvertibleAnimatedPropertyIDSet& aPropertiesToSkip) {
  ComputedTiming computedTiming = GetComputedTiming();
  if (computedTiming.mProgress.IsNull()) {
    return; // The calc ends here!!!!!!
  }

  // the loop to determine a segment
  // ...
}

Now, GetComputedTiming is essentially GetComputedTimingAt. So, by tweaking the boundary behavior, I managed to achieve the goal :) Yay!

  if (localTime > activeAfterBoundary ||
++      (isEndpointExclusive && // this is set as true only when `commitStyles` uses this func.
       (aPlaybackRate >= 0 && localTime == activeAfterBoundary &&
        !atProgressTimelineBoundary))) {
    result.mPhase = ComputedTiming::AnimationPhase::After;
    if (!result.FillsForwards()) {
      // The animation isn't active or filling at this time.
      return result;
    }
    result.mActiveTime =
        std::max(std::min(StickyTimeDuration(localTime - aTiming.Delay()),
                          result.mActiveDuration),
                 zeroDuration);
  }

This is a prototype impl for discussion. So it may be enough for the moment.

Update (2025/2/17)

I'm working on this patch to ship a kind of trial for this version of commitStyles.

Here, you can see the progress (and really informative review by Birtles!).

https://phabricator.services.mozilla.com/D237510

another post?