fix: GSAP callbacks on a frame time fire once in motion-blur renders

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-03 13:02:52 -07:00
parent b7343e3a95
commit eb83a8069a
10 changed files with 244 additions and 20 deletions
+1
View File
@@ -148,6 +148,7 @@
"devDependencies": {
"@types/node": "^25.0.10",
"@webgpu/types": "^0.1.69",
"gsap": "^3.15.0",
"typescript": "^5.7.2",
"vitest": "^4.1.11",
},
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import gsap from "gsap";
import { keepLandedGsapCallbacksSpent } from "./gsapSpentCallbacks";
type Timeline = ReturnType<typeof gsap.timeline>;
// Pins the GSAP private fields keepLandedGsapCallbacksSpent reads and writes. If a GSAP upgrade
// fails here, motion-blur renders fire frame-time callbacks twice again.
describe("keepLandedGsapCallbacksSpent against real GSAP", () => {
// One blurred frame at 0.5: the eventful seek, a sample on each side, the silent return.
const blurredFrameAt05 = (timeline: Timeline, keepSpent: boolean) => {
timeline.totalTime(0.5, false);
if (keepSpent) keepLandedGsapCallbacksSpent(timeline, { keepFiredCallbacksSpent: true });
timeline.totalTime(0.45, true);
timeline.totalTime(0.55, true);
timeline.totalTime(0.5, true);
if (keepSpent) {
keepLandedGsapCallbacksSpent(timeline, {
suppressEvents: true,
keepFiredCallbacksSpent: true,
});
}
};
const timelineWithCallAt = (at: number, fired: () => void) =>
gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 }).call(fired, [], at);
it("leaves a fired call armed after a silent return, with the fields it reads", () => {
const timeline = timelineWithCallAt(0.5, vi.fn());
blurredFrameAt05(timeline, false);
const call = timeline.getChildren(true, true, false)[1] as unknown as Record<string, unknown>;
expect(call).toMatchObject({ _dur: 0, _zTime: 1e-8, ratio: 1 });
});
it("without it, the next eventful seek fires that call again", () => {
const fired = vi.fn();
const timeline = timelineWithCallAt(0.5, fired);
blurredFrameAt05(timeline, false);
timeline.totalTime(0.6, false);
expect(fired).toHaveBeenCalledTimes(2);
});
it("with it, the call stays spent and a call a sample only passed still fires", () => {
const fired = vi.fn();
const passed = vi.fn();
const timeline = timelineWithCallAt(0.5, fired).call(passed, [], 0.52);
blurredFrameAt05(timeline, true);
timeline.totalTime(0.6, false);
expect(fired).toHaveBeenCalledTimes(1);
expect(passed).toHaveBeenCalledTimes(1);
});
it("with it, a call the eventful seek did not fire still fires on the next frame", () => {
const fired = vi.fn();
const scene = gsap.timeline().call(fired, [], 0).to({ y: 0 }, { y: 1, duration: 0.5 }, 0);
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
timeline.add(scene, 0.5);
blurredFrameAt05(timeline, true);
timeline.totalTime(0.6, false);
expect(fired).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,30 @@
import type { RuntimeSeekOptions, RuntimeTimelineLike } from "../types";
type GsapChildPrivateFields = { _dur?: number; _zTime?: number; ratio?: number };
const GSAP_ARMED_Z_TIME = 1e-8;
const spentAtEventfulSeek = new WeakMap<object, Set<GsapChildPrivateFields>>();
function recordSpent(timeline: Pick<RuntimeTimelineLike, "getChildren">): void {
const children = (timeline.getChildren?.(true, true, true) ?? []) as GsapChildPrivateFields[];
const spent = children.filter((child) => child._dur === 0 && child.ratio === 1);
spentAtEventfulSeek.set(timeline, new Set(spent));
}
function disarmLandedSpent(timeline: object): void {
for (const child of spentAtEventfulSeek.get(timeline) ?? []) {
if (child._zTime === GSAP_ARMED_Z_TIME) child._zTime = 0;
}
spentAtEventfulSeek.delete(timeline);
}
// Only access to GSAP private fields (3.x; 3.12.5 to 3.15 checked). A silent landing on a spent
// zero-duration tween re-arms it (`_zTime` 1e-8) and the next eventful seek fires it again, so the
// eventful seek records what it left spent and its silent return disarms only those.
export function keepLandedGsapCallbacksSpent(
timeline: Pick<RuntimeTimelineLike, "getChildren">,
options: RuntimeSeekOptions | undefined,
): void {
if (options?.keepFiredCallbacksSpent !== true) return;
if (options.suppressEvents === true) disarmLandedSpent(timeline);
else recordSpent(timeline);
}
+64
View File
@@ -2920,6 +2920,70 @@ describe("initSandboxRuntimeModular", () => {
expect(fired).toHaveBeenCalledTimes(1);
});
describe("with the engine's motion-blur seeks", () => {
const mountRootAndScene = (sceneStart: number) => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const scene = document.createElement("div");
scene.setAttribute("data-composition-id", "scene");
scene.setAttribute("data-start", String(sceneStart));
scene.setAttribute("data-duration", "5");
root.appendChild(scene);
};
const timelineWithCallAt = (at: number, fired: () => void) =>
gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 5 }).call(fired, [], at);
// Per frame: eventful, a silent sample on each side, a silent return.
const renderBlurredFrames = (first: number, last: number) => {
for (let frame = first; frame <= last; frame++) {
window.__player?.renderSeek(frame / 30, { keepFiredCallbacksSpent: true });
for (const offset of [-0.25, 0.25]) {
window.__player?.renderSeek((frame + offset) / 30, {
suppressEvents: true,
subFrameDivisions: 4,
});
}
window.__player?.renderSeek(frame / 30, {
suppressEvents: true,
keepFiredCallbacksSpent: true,
});
}
};
it("fires a call on a frame time once, on the root and on a scene", () => {
mountRootAndScene(0);
const fired: string[] = [];
window.__timelines = {
main: timelineWithCallAt(2 / 30, () => fired.push("root")),
scene: timelineWithCallAt(2 / 30, () => fired.push("scene")),
};
initSandboxRuntimeModular();
renderBlurredFrames(1, 4);
expect(fired.sort()).toEqual(["root", "scene"]);
});
it("fires a call at a scene's start once, as with motion blur off", () => {
mountRootAndScene(1);
const fired = vi.fn();
window.__timelines = {
main: gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 10 }),
scene: timelineWithCallAt(0, fired),
};
initSandboxRuntimeModular();
renderBlurredFrames(28, 34);
expect(fired).toHaveBeenCalledTimes(1);
});
});
it("shows pip video at global start time even when host composition starts late", () => {
// Regression: resolveStartForElement used to add the host composition's start on top of
// the video's own data-start, causing double-offset. A pip video with data-start="45.40"
+6 -2
View File
@@ -9,6 +9,7 @@ import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css";
import { createGsapAdapter, rerenderGsapTimelineAt } from "./adapters/gsap";
import { keepLandedGsapCallbacksSpent } from "./adapters/gsapSpentCallbacks";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
@@ -3920,6 +3921,7 @@ export function initSandboxRuntimeModular(): void {
const pageAnimations = seekTimelineAndAdapters(state.currentTime, {
activateChildren: true,
suppressEvents: options?.suppressEvents,
keepFiredCallbacksSpent: options?.keepFiredCallbacksSpent,
});
runAdapters("pause", 0, pageAnimations);
syncMediaForCurrentState();
@@ -4190,6 +4192,7 @@ export function initSandboxRuntimeModular(): void {
pauseTimelineIfPossible(timeline);
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, suppressEvents);
keepLandedGsapCallbacksSpent(timeline, options);
} else {
timeline.seek(timeSeconds, suppressEvents);
}
@@ -4303,7 +4306,7 @@ export function initSandboxRuntimeModular(): void {
*/
function seekTimelineAndAdapters(
t: number,
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
opts?: RuntimeSeekOptions & { activateChildren?: boolean },
): () => Animation[] {
const tl = state.capturedTimeline;
// Critical for a sub-composition whose data-start is at or near 0: it is added
@@ -4320,7 +4323,7 @@ export function initSandboxRuntimeModular(): void {
function seekRootChildrenAndAdapters(
tl: RuntimeTimelineLike | null,
t: number,
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
opts?: RuntimeSeekOptions & { activateChildren?: boolean },
): () => Animation[] {
const suppressEvents = opts?.suppressEvents === true;
if (tl) {
@@ -4346,6 +4349,7 @@ export function initSandboxRuntimeModular(): void {
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(tlSeekTime, suppressEvents);
keepLandedGsapCallbacksSpent(tl, opts);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// The first seek is the only eventful one; the re-render only refreshes styles.
rerenderGsapTimelineAt(
+1
View File
@@ -287,6 +287,7 @@ export type RuntimeSeekOptions = {
*/
subFrameDivisions?: number;
exact?: boolean;
keepFiredCallbacksSpent?: boolean;
};
export type RuntimeTimelineChildLike = {
+1
View File
@@ -78,6 +78,7 @@
"devDependencies": {
"@types/node": "^25.0.10",
"@webgpu/types": "^0.1.69",
"gsap": "^3.15.0",
"typescript": "^5.7.2",
"vitest": "^4.1.11"
},
@@ -1,4 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import gsap from "gsap";
import { keepLandedGsapCallbacksSpent } from "../../../core/src/runtime/adapters/gsapSpentCallbacks";
import { resolveRenderSeekTime } from "../../../core/src/runtime/player";
import type { HfSeekOptions } from "../types.js";
import {
captureFrame,
captureFrameToBuffer,
@@ -24,12 +28,7 @@ vi.mock("./screenshotService.js", async (importOriginal) => ({
* recording that a call happened. What lands in `seeks` is therefore what a browser's
* `window.__hf.seek` would have received.
*/
interface RecordedSeek {
time: number;
suppressEvents?: boolean;
subFrameDivisions?: number;
exact?: boolean;
}
type RecordedSeek = HfSeekOptions & { time: number; exact?: boolean };
function installPageGlobals(seeks: RecordedSeek[]): void {
const root = globalThis as Record<string, unknown>;
@@ -125,12 +124,12 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
expect(seeks[0]).toBe(eventful[0]);
});
it("restores the playhead to the frame time so the next frame's callbacks are not skipped", async () => {
it("pairs the frame's eventful seek with a silent return to the frame time", async () => {
await captureFrameToBuffer(makeSession(), 10, 10 / 30);
expect(seeks[0]).toEqual({ time: 10 / 30, keepFiredCallbacksSpent: true });
const last = seeks[seeks.length - 1];
expect(last?.time).toBe(10 / 30);
expect(last?.suppressEvents).toBe(true);
expect(last).toEqual({ time: 10 / 30, suppressEvents: true, keepFiredCallbacksSpent: true });
expect(seeks).toHaveLength(18);
});
@@ -176,6 +175,65 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
});
});
describe("a composition's callbacks fire once with motion blur on, as with it off", () => {
function installRenderSeek(timeline: ReturnType<typeof gsap.timeline>): void {
(globalThis as Record<string, unknown>).window = {
__hf: {
// Mirrors core renderSeek's snap, seek and keep-spent steps on a real GSAP root timeline.
seek: (time: number, options?: Omit<RecordedSeek, "time">) => {
timeline.totalTime(
resolveRenderSeekTime(time, 30, options),
options?.suppressEvents === true,
);
keepLandedGsapCallbacksSpent(timeline, options);
},
},
};
}
async function captureFrames(count: number): Promise<void> {
const session = makeSession();
for (let frame = 1; frame <= count; frame++)
await captureFrameToBuffer(session, frame, frame / 30);
}
it("fires a call on a frame time once", async () => {
const fired = vi.fn();
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
timeline.call(fired, [], 2 / 30);
installRenderSeek(timeline);
await captureFrames(4);
expect(fired).toHaveBeenCalledTimes(1);
});
it("swaps a caption in a tween's onStart on a frame time", async () => {
const caption = { text: "first line" };
const started = vi.fn(() => (caption.text = "second line"));
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
timeline.to({ y: 0 }, { y: 1, duration: 0.2, onStart: started }, 3 / 30);
installRenderSeek(timeline);
await captureFrames(6);
expect(started).toHaveBeenCalledTimes(1);
expect(caption.text).toBe("second line");
});
it("fires a call at a nested scene's start once", async () => {
const fired = vi.fn();
const scene = gsap.timeline().call(fired, [], 0).to({ y: 0 }, { y: 1, duration: 0.5 }, 0);
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
timeline.add(scene, 2 / 30);
installRenderSeek(timeline);
await captureFrames(5);
expect(fired).toHaveBeenCalledTimes(1);
});
});
describe("frame export keeps its frame grid (issue #4430)", () => {
// `exact` is the snapshot-only opt-out of the runtime's seek quantization. Export must
// never send it, or frames would land between grid points instead of on them.
+8 -5
View File
@@ -4032,9 +4032,9 @@ async function resolveAdaptiveSampleCount(
*
* Callback invariant: exactly one eventful seek per output frame, at the frame time,
* arriving from the previous frame's time. Every sample seek (including a probe)
* suppresses events and the playhead is restored to the frame time afterwards, so a
* composition's own onUpdate/onComplete fire on the same interval boundaries as a
* render with blur off.
* suppresses events, and the silent return to the frame time keeps the callbacks the
* eventful seek fired spent, so a composition's callbacks (tl.call, onStart, onComplete)
* fire once, on the same boundaries as a render with blur off.
*/
async function captureAccumulatedFrame(
session: CaptureSession,
@@ -4046,7 +4046,7 @@ async function captureAccumulatedFrame(
const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
const eventfulSeekStart = Date.now();
await seekPageTimeline(session.page, frameTime, undefined);
await seekPageTimeline(session.page, frameTime, { keepFiredCallbacksSpent: true });
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
const sampleSeek: HfSeekOptions = {
@@ -4080,7 +4080,10 @@ async function captureAccumulatedFrame(
}
const restoreSeekStart = Date.now();
await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
await seekPageTimeline(session.page, frameTime, {
suppressEvents: true,
keepFiredCallbacksSpent: true,
});
totals.seekMs += Date.now() - restoreSeekStart;
const blended = accumulator.finish();
+5 -4
View File
@@ -100,15 +100,16 @@ export interface HfTransitionMeta {
* as `seek()` produces deterministic visual output for a given time.
*/
/**
* Per-seek controls the page honours. Both default off, which is an ordinary frame seek.
* Per-seek controls the page honours. All default off, which is an ordinary frame seek.
*
* `suppressEvents` stops a composition's own timeline callbacks from firing, and
* `subFrameDivisions` refines the grid the page quantizes onto so a fractional time is
* not floored back onto the output frame. Motion-blur sampling sets both.
* `suppressEvents` stops a composition's own timeline callbacks from firing, `subFrameDivisions`
* refines the quantize grid so a fractional time is not floored onto the output frame, and
* `keepFiredCallbacksSpent` on a frame's eventful seek and its silent return fires callbacks once.
*/
export interface HfSeekOptions {
suppressEvents?: boolean;
subFrameDivisions?: number;
keepFiredCallbacksSpent?: boolean;
}
export interface HfProtocol {