Skip to content

Fork patch: Settle GSAP timelines before first render seek - #4

Draft
valeriangalliat wants to merge 1 commit into
mainfrom
patch/render-settle-pass
Draft

valeriangalliat wants to merge 1 commit into
mainfrom
patch/render-settle-pass

Conversation

@valeriangalliat

Copy link
Copy Markdown
Collaborator

🤖 AI agent: Arcade fork patch. Do not merge.

This draft shows patch/render-settle-pass against main, the upstream mirror, so the patch has one page for its diff, its revisions and its reason. Arcade's /hyperframes-update rebases the branch onto main and cherry-picks it onto arcade; this PR is not part of that flow. When a rebuild changes the patch, its git range-diff against the previous sync/<date>/render-settle-pass tag is posted as a comment.

Why

from() and fromTo() immediately render their from-vars when constructed. An exit fromTo(el, { opacity: 1 }, …) on an element hidden by CSS therefore paints it before its reveal tween, because a render only ever seeks from 0 upward. A looped preview hides this by rendering backward. The patch plays the root and registered timelines through once on the first renderSeek, then rewinds to 0.

The pass seeks only the frames around a tween starting or finishing: that frame, the one before and the one after. Between those frames tweens only move with time, so the settled state equals a pass over every frame (repeatRefresh tweens aside). Real-GSAP tests compare the two. An earlier pass over every frame outlasted the capture's protocol timeout on a scene that redraws WebGL on every seek (ARC-15938).

Upstream

Upstream treats this as authoring: lint gsap_repeated_fromto_without_baseline (#3699, 0.8.32). heygen-com#2075 and heygen-com#3466 are closed. That only helps compositions written from now on.

Drop when

Upstream settles timelines before a render, or a fresh sample of saved compositions no longer shows the leak. Review around 2026-12-15 (ARC-15394).

Arcade

ARC-14878 · ARC-14842 · corpus scene exit-fromto-clobber (ArcadeHQ/arcade#10062)

@valeriangalliat
valeriangalliat force-pushed the patch/render-settle-pass branch from 3de5c7a to c4eba4f Compare October 24, 2026 23:23
@valeriangalliat

valeriangalliat commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

🤖 AI agent: sync/2026-10-24 (tags moved the same day): rebased onto upstream 2e397dba5 (v0.8.194). Replayed clean.

First pass on this date, onto 722481214 (0.8.193): Hard port: upstream rewrote seekStandaloneRegisteredTimelines (scene clock caching, skip scenes at rest, siblings unpaused by activateSiblingTimelines must be paused again). The settle pass now reuses that seek, re-pauses the siblings it woke, and runs inside showHiddenClips() like upstream first-render seek (heygen-com#5690). Test recorders guard time !== undefined because upstream now reads totalTime() with no argument.

The range-diff below is against sync/2026-10-05.

git range-diff sync/2026-10-05/base..sync/2026-10-05/render-settle-pass sync/2026-10-24/base..sync/2026-10-24/render-settle-pass
1:  3de5c7ae7 ! 1:  ed9c2fd29 fix(core): settle GSAP timelines before the first render seek
    @@ packages/core/src/runtime/init.test.ts: describe("initSandboxRuntimeModular", ()
      
          window.__player?.renderSeek(2);
     @@ packages/core/src/runtime/init.test.ts: describe("initSandboxRuntimeModular", () => {
    -     expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]);
    +     vi.useRealTimers();
        });
      
     +  it("settles on the frames around each tween start and finish before the first render seek", () => {
    @@ packages/core/src/runtime/init.test.ts: describe("initSandboxRuntimeModular", ()
     +    const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
     +    const originalTotalTime = rootTimeline.totalTime;
     +    rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
    -+      seekCalls.push({ time, suppressEvents });
    ++      if (time !== undefined) seekCalls.push({ time, suppressEvents });
     +      return originalTotalTime?.(time, suppressEvents);
     +    };
     +
    @@ packages/core/src/runtime/init.test.ts: describe("initSandboxRuntimeModular", ()
     +    const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
     +    const originalTotalTime = rootTimeline.totalTime;
     +    rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
    -+      seekCalls.push({ time, suppressEvents });
    ++      if (time !== undefined) seekCalls.push({ time, suppressEvents });
     +      return originalTotalTime?.(time, suppressEvents);
     +    };
     +
    @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular():
            heldSeek = null;
     +      if (!renderCaptureSeekStarted) settleTimelinesForRender();
            renderCaptureSeekStarted = true;
    -       const quantized = quantizeSeekTime(
    +       const seekTime = resolveRenderSeekTime(
              Math.max(0, Number(timeSeconds) || 0),
     @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular(): void {
    +       return false;
          }
        };
    - 
    --  const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => {
    +-  const seekStandaloneRegisteredTimelines = (
     +  // Each non-root registered timeline with the local time it shows at a root time.
    -+  const standaloneRegisteredTimelineTimes = (timeSeconds: number) => {
    ++  const standaloneRegisteredTimelineTimes = (
    +     timeSeconds: number,
    +     revision: number,
    +-    options?: RuntimeSeekOptions & { activateChildren?: boolean },
    ++    options?: { activateChildren?: boolean },
    +   ) => {
    +     if (sceneClocksRevision !== revision || renderCaptureSeekStarted) {
    +       sceneClocks = new WeakMap();
    +@@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular(): void {
          const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
          const rootCompositionId =
            resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
     +    const out: Array<{ timeline: RuntimeTimelineLike; localTime: number }> = [];
          for (const [compositionId, timeline] of Object.entries(timelines)) {
    -       if (!timeline || compositionId === rootCompositionId) continue;
    -       const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`);
    +       if (!timeline || typeof timeline !== "object" || compositionId === rootCompositionId)
    +         continue;
     @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular(): void {
    -           ? Math.min(timelineDuration, sourceTime)
    +           ? Math.min(scene.timelineDuration, sourceTime)
                : sourceTime,
            );
     +      out.push({ timeline, localTime });
    @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular():
     +    return out;
     +  };
     +
    -+  const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => {
    -+    for (const { timeline, localTime } of standaloneRegisteredTimelineTimes(timeSeconds)) {
    ++  const seekStandaloneRegisteredTimelines = (
    ++    timeSeconds: number,
    ++    revision: number,
    ++    options?: RuntimeSeekOptions & { activateChildren?: boolean },
    ++  ) => {
    ++    for (const { timeline, localTime } of standaloneRegisteredTimelineTimes(
    ++      timeSeconds,
    ++      revision,
    ++      options,
    ++    )) {
    +       // A scene before or after its clip rests where the last frame left it: no redraw.
    +       if (!renderCaptureSeekStarted && restsAt(timeline, localTime)) continue;
            seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
    -     }
    -   };
     @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular(): void {
          return pageAnimations;
        }
    @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular():
     +    const duration = getSafeTimelineDurationSeconds(tl, 0);
     +    const fps = state.canonicalFps;
     +    if (!(duration > 0) || !(fps > 0)) return;
    -+    for (const t of settleFrameTimes(tl, duration, fps)) {
    -+      activateSiblingTimelines(tl);
    -+      seekRuntimeTimeline(tl, t, "runtime.init.transport.settle", { suppressEvents: true });
    -+      seekStandaloneRegisteredTimelines(t, { suppressEvents: true });
    ++    const revision = readCompositionTimingRevision();
    ++    const seek = { suppressEvents: true, activateChildren: true };
    ++    // Every settle seek is a first render, so tweens read the authored style, not a hidden clip's.
    ++    const hideClipsAgain = showHiddenClips();
    ++    try {
    ++      for (const t of [...settleFrameTimes(tl, duration, fps, revision), 0]) {
    ++        const rearmed = activateSiblingTimelines(tl);
    ++        try {
    ++          seekRuntimeTimeline(tl, t, "runtime.init.transport.settle", seek);
    ++          seekStandaloneRegisteredTimelines(t, revision, seek);
    ++        } finally {
    ++          for (const sibling of rearmed) pauseTimelineIfPossible(sibling);
    ++        }
    ++      }
    ++    } finally {
    ++      hideClipsAgain();
     +    }
    -+    activateSiblingTimelines(tl);
    -+    seekRuntimeTimeline(tl, 0, "runtime.init.transport.settle", { suppressEvents: true });
    -+    seekStandaloneRegisteredTimelines(0, { suppressEvents: true });
     +  }
     +
     +  // Times are compared with this slack: GSAP starts a child a hair before its
    @@ packages/core/src/runtime/init.ts: export function initSandboxRuntimeModular():
     +  // settled state unchanged; a repeatRefresh tween, which re-reads its start
     +  // values on each repeat, is the exception. Every frame when a timeline cannot
     +  // list its tweens.
    -+  function settleFrameTimes(root: RuntimeTimelineLike, duration: number, fps: number): number[] {
    ++  function settleFrameTimes(
    ++    root: RuntimeTimelineLike,
    ++    duration: number,
    ++    fps: number,
    ++    revision: number,
    ++  ): number[] {
     +    const frameCount = Math.ceil(duration * fps);
     +    const frameTime = (frame: number) => Math.min(duration, frame / fps);
    ++    const registered = (t: number) =>
    ++      standaloneRegisteredTimelineTimes(t, revision, { activateChildren: true });
     +    const localTimes = (t: number) =>
    -+      [{ timeline: root, localTime: t }, ...standaloneRegisteredTimelineTimes(t)].map(
    -+        ({ localTime }) => localTime,
    -+      );
    -+    const timelines = [
    -+      root,
    -+      ...standaloneRegisteredTimelineTimes(0).map(({ timeline }) => timeline),
    -+    ];
    ++      [{ timeline: root, localTime: t }, ...registered(t)].map(({ localTime }) => localTime);
    ++    const timelines = [root, ...registered(0).map(({ timeline }) => timeline)];
     +    const tracks: Array<{ events: number[]; next: number }> = [];
     +    for (const timeline of timelines) {
     +      const events = settleEventTimes(timeline);

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 271 (base branch 271), smooth 171 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Play the root and registered timelines through once, then rewind to 0, on
the first renderSeek. from()/fromTo() immediateRender their from-vars at
construction, so an exit fromTo(el, { opacity: 1 }) on a CSS-hidden element
paints it before its reveal tween when the render only ever seeks
0 -> ascending. A looped preview settles this by rendering backward; the
render never did.

The pass lands on the render's own frames, because a tween reads its lazy
start values on the frame it starts, but only on the frames around a tween
starting or finishing: the frame itself, the one before (a start reads
values another tween wrote on the previous frame) and the one after (GSAP
flushes a lazily started tween on the next render). Between those frames
every tween only moves with time, so the settled state equals a pass over
every frame, repeatRefresh tweens aside. Real-GSAP tests compare the two for
finishes, nested timeScale, stretched keyframes and staggers. Every seek
runs the page's own tween setters, and a scene redrawing WebGL on each made
the every-frame pass outlast the capture's protocol timeout.

Residual cost: still one synchronous call, proportional to the frames
around tween events (about a sixth of all frames in the compositions we
sampled, up to about half; every frame for a timeline that cannot list its
tweens), times the cost of the page's setters.

Why custom: upstream treats the leak as authoring (lint
gsap_repeated_fromto_without_baseline, immediateRender: false), which only
helps content written from now on. A third of the saved compositions we
sampled show an element before its reveal without this pass. Drop it once
upstream settles timelines before a render, or a fresh sample of saved
compositions no longer shows the leak.
@valeriangalliat
valeriangalliat force-pushed the patch/render-settle-pass branch from c4eba4f to ed9c2fd Compare October 25, 2026 02:43

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant