Skip to content

Parallel render: first frame of each worker chunk is 16.7 ms late for a WAAPI animation created after load #5502

Closed

Description

@cathrynlavery

Describe the bug

A WAAPI animation that the page creates after the runtime's discover pass is still playing when a render worker makes its first seek. That first frame shows the animation 16.7 ms past the frame's time. Every later frame in the chunk is exact.

With --workers 1 the only affected frame is frame 0. With several workers, the first frame of every chunk after the first is affected, so which frames are wrong depends on the worker count.

The cause is the order of two calls in the WAAPI adapter's seek (packages/core/src/runtime/adapters/waapi.ts):

animation.currentTime = localTimeMs;
animation.pause();

On a running animation, pause() does not stop it at once. It schedules a pending pause. When the pause completes on the next animation frame, the browser sets the hold time from the timeline at that moment, which is one frame (16.7 ms at 60 Hz) after the time just written. See the Web Animations spec, pausing an animation.

If pause() runs first, writing currentTime completes the pending pause right away with the written time (setting the current time).

Later seeks are exact because the animation is already paused by then. The runtime's discover seek normally absorbs the late pause for animations that exist at load. An animation created later, for example inside document.fonts.ready.then(...) or a timer, gets its first seek from the worker instead.

packages/core/src/runtime/adapters/css.ts (seekAnimations) uses the same order.

Possibly related, but a different symptom: #4435 and #2477 (a worker's first frame shows the pre-seek page).

Minimal reproduction

index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      html, body { margin: 0; width: 1280px; height: 80px; overflow: hidden; background: #000; }
      #box { position: absolute; left: 20px; top: 20px; width: 8px; height: 40px; background: #fff; }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-no-timeline data-start="0" data-duration="4" data-width="1280" data-height="80">
      <div id="box"></div>
    </div>
    <script>
      // 300 px per second, linear: the box's x tells the time the frame was drawn at.
      setTimeout(() => document.getElementById("box").animate(
        [{ transform: "translateX(0px)" }, { transform: "translateX(1200px)" }],
        { duration: 4000, fill: "both", easing: "linear" },
      ), 100);
    </script>
  </body>
</html>

measure.py prints each frame whose box position is more than 4 ms away from frame / fps:

import subprocess, sys
import numpy as np

W, H, fps = 1280, 80, float(sys.argv[1])
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", sys.argv[2], "-f", "rawvideo",
                      "-pix_fmt", "gray", "-"], check=True, capture_output=True).stdout
row = np.frombuffer(raw, np.uint8).reshape(-1, H, W)[:, 30:50, :].mean(axis=1)
row = np.where(row > 16, row, 0)
centre = (row * np.arange(W)).sum(axis=1) / row.sum(axis=1)
err_ms = ((centre - 23.5) / 300 - np.arange(len(centre)) / fps) * 1000
print([(f, round(e, 1)) for f, e in enumerate(err_ms) if abs(e) > 4 and 0 < f < len(err_ms) - 1])

The 100 ms timer is only there to create the animation after the runtime's discover seek and before the workers start capturing. On my machine it lands in that window for most workers. Real pages hit the same window when they build animations after web fonts load.

Steps to reproduce

  1. Save the page above as repro/index.html.
  2. npx [email protected] render repro --fps 30 --workers 4 -o w4.mp4
  3. python3 measure.py 30 w4.mp4
  4. Repeat with --workers 1, and with --fps 60.

Expected behavior

Every frame shows the box at 24 + 300 * frame / fps px, at any worker count. measure.py prints [].

Actual behavior

4 workers, 30 fps, 120 frames (chunks start at 30, 60 and 90), three runs each:

Version Run 1 Run 2 Run 3
0.8.150 60, 90: +16.7 ms 30, 60, 90: +16.7 ms 60, 90: +16.7 ms
0.8.144 90: +16.7 ms 30, 60: +16.7 ms 1, 30: +16.7 ms

+16.7 ms is 5 px ahead of where the frame should be. The frame after each one is exact.

  • 4 workers at 60 fps (240 frames): frames 60 and 180 are +16.7 ms. The offset is 16.7 ms at both frame rates, which is one browser animation frame at 60 Hz.
  • 1 worker at 30 fps: no late frames after frame 0.
  • In some 0.8.144 runs the timer fired after a worker's first seek, and that whole chunk was shifted by the chunk's start time. That comes from how the adapter sets the start of an animation created mid-render, and is separate from this report.

With the two calls swapped (pause, then write currentTime) in the 0.8.150 runtime, three runs at 4 workers print []. A larger composition that builds its animations inside document.fonts.ready.then(...) had frame 462 drawn 16.6 ms late at 4 workers. With the swap it is exact at 4 and 6 workers on all 189 measured frames in motion.

Environment

hyperframes 0.8.150 (also seen on 0.8.144)
chrome-headless-shell 152.0.7977.30 (ANGLE Metal), screenshot capture
Node v22.23.1, ffmpeg 9.0.2
macOS 27.0, Apple M3 Ultra

(npx hyperframes doctor did not finish within two minutes on this machine, so these values come from the render log.)

Additional context

I have a fix with regression tests on a branch and can open a PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions