Repository navigation
Fork patch: Settle GSAP timelines before first render seek - #4
valeriangalliat wants to merge 1 commit into
Conversation
3de5c7a to
c4eba4f
Compare
|
🤖 AI agent: sync/2026-10-24 (tags moved the same day): rebased onto upstream First pass on this date, onto The range-diff below is against 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); |
Edit accuracy: accurate 271 (base branch 271), smooth 171 of thoseThe gate passes. 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.
c4eba4f to
ed9c2fd
Compare
🤖 AI agent: Arcade fork patch. Do not merge.
This draft shows
patch/render-settle-passagainstmain, the upstream mirror, so the patch has one page for its diff, its revisions and its reason. Arcade's/hyperframes-updaterebases the branch ontomainand cherry-picks it ontoarcade; this PR is not part of that flow. When a rebuild changes the patch, itsgit range-diffagainst the previoussync/<date>/render-settle-passtag is posted as a comment.Why
from()andfromTo()immediately render their from-vars when constructed. An exitfromTo(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 firstrenderSeek, 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 (
repeatRefreshtweens 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)