Skip to content

Commit 68db2eb

Browse files
committed
fix: GSAP callbacks on a frame time fire once in motion-blur renders
1 parent b7343e3 commit 68db2eb

10 files changed

Lines changed: 246 additions & 20 deletions

File tree

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import gsap from "gsap";
3+
import { keepLandedGsapCallbacksSpent } from "./gsapSpentCallbacks";
4+
5+
type Timeline = ReturnType<typeof gsap.timeline>;
6+
7+
// Pins the GSAP private fields keepLandedGsapCallbacksSpent reads and writes. If a GSAP upgrade
8+
// fails here, motion-blur renders fire frame-time callbacks twice again.
9+
describe("keepLandedGsapCallbacksSpent against real GSAP", () => {
10+
// One blurred frame at 0.5: the eventful seek, a sample on each side, the silent return.
11+
const blurredFrameAt05 = (timeline: Timeline, keepSpent: boolean) => {
12+
timeline.totalTime(0.5, false);
13+
if (keepSpent) keepLandedGsapCallbacksSpent(timeline, { keepFiredCallbacksSpent: true });
14+
timeline.totalTime(0.45, true);
15+
timeline.totalTime(0.55, true);
16+
timeline.totalTime(0.5, true);
17+
if (keepSpent) {
18+
keepLandedGsapCallbacksSpent(timeline, {
19+
suppressEvents: true,
20+
keepFiredCallbacksSpent: true,
21+
});
22+
}
23+
};
24+
const timelineWithCallAt = (at: number, fired: () => void) =>
25+
gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 }).call(fired, [], at);
26+
27+
it("leaves a fired call armed after a silent return, with the fields it reads", () => {
28+
const timeline = timelineWithCallAt(0.5, vi.fn());
29+
blurredFrameAt05(timeline, false);
30+
const call = timeline.getChildren(true, true, false)[1] as unknown as Record<string, unknown>;
31+
expect(call).toMatchObject({ _dur: 0, _zTime: 1e-8, ratio: 1 });
32+
});
33+
34+
it("without it, the next eventful seek fires that call again", () => {
35+
const fired = vi.fn();
36+
const timeline = timelineWithCallAt(0.5, fired);
37+
blurredFrameAt05(timeline, false);
38+
timeline.totalTime(0.6, false);
39+
expect(fired).toHaveBeenCalledTimes(2);
40+
});
41+
42+
it("with it, the call stays spent and a call a sample only passed still fires", () => {
43+
const fired = vi.fn();
44+
const passed = vi.fn();
45+
const timeline = timelineWithCallAt(0.5, fired).call(passed, [], 0.52);
46+
blurredFrameAt05(timeline, true);
47+
timeline.totalTime(0.6, false);
48+
expect(fired).toHaveBeenCalledTimes(1);
49+
expect(passed).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it("with it, a call the eventful seek did not fire still fires on the next frame", () => {
53+
const fired = vi.fn();
54+
const scene = gsap.timeline().call(fired, [], 0).to({ y: 0 }, { y: 1, duration: 0.5 }, 0);
55+
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
56+
timeline.add(scene, 0.5);
57+
blurredFrameAt05(timeline, true);
58+
timeline.totalTime(0.6, false);
59+
expect(fired).toHaveBeenCalledTimes(1);
60+
});
61+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { RuntimeSeekOptions, RuntimeTimelineLike } from "../types";
2+
3+
type GsapChildPrivateFields = { _dur?: number; _zTime?: number; ratio?: number };
4+
const GSAP_ARMED_Z_TIME = 1e-8;
5+
const spentAtEventfulSeek = new WeakMap<object, Set<GsapChildPrivateFields>>();
6+
7+
function recordSpent(timeline: Pick<RuntimeTimelineLike, "getChildren">): void {
8+
const children = (timeline.getChildren?.(true, true, true) ?? []) as GsapChildPrivateFields[];
9+
const spent = children.filter((child) => child._dur === 0 && child.ratio === 1);
10+
spentAtEventfulSeek.set(timeline, new Set(spent));
11+
}
12+
13+
function disarmLandedSpent(timeline: object): void {
14+
for (const child of spentAtEventfulSeek.get(timeline) ?? []) {
15+
if (child._zTime === GSAP_ARMED_Z_TIME) child._zTime = 0;
16+
}
17+
spentAtEventfulSeek.delete(timeline);
18+
}
19+
20+
/**
21+
* Only access to GSAP private fields (3.x; 3.12.5 to 3.15 checked). A silent landing on a spent
22+
* zero-duration tween re-arms it (`_zTime` 1e-8) and the next eventful seek fires it again, so the
23+
* eventful seek records what it left spent and its silent return disarms only those.
24+
*/
25+
export function keepLandedGsapCallbacksSpent(
26+
timeline: Pick<RuntimeTimelineLike, "getChildren">,
27+
options: RuntimeSeekOptions | undefined,
28+
): void {
29+
if (options?.keepFiredCallbacksSpent !== true) return;
30+
if (options.suppressEvents === true) disarmLandedSpent(timeline);
31+
else recordSpent(timeline);
32+
}

‎packages/core/src/runtime/init.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2920,6 +2920,70 @@ describe("initSandboxRuntimeModular", () => {
29202920
expect(fired).toHaveBeenCalledTimes(1);
29212921
});
29222922

2923+
describe("with the engine's motion-blur seeks", () => {
2924+
const mountRootAndScene = (sceneStart: number) => {
2925+
const root = document.createElement("div");
2926+
root.setAttribute("data-composition-id", "main");
2927+
root.setAttribute("data-root", "true");
2928+
root.setAttribute("data-start", "0");
2929+
root.setAttribute("data-duration", "10");
2930+
root.setAttribute("data-width", "1920");
2931+
root.setAttribute("data-height", "1080");
2932+
document.body.appendChild(root);
2933+
const scene = document.createElement("div");
2934+
scene.setAttribute("data-composition-id", "scene");
2935+
scene.setAttribute("data-start", String(sceneStart));
2936+
scene.setAttribute("data-duration", "5");
2937+
root.appendChild(scene);
2938+
};
2939+
const timelineWithCallAt = (at: number, fired: () => void) =>
2940+
gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 5 }).call(fired, [], at);
2941+
// Per frame: eventful, a silent sample on each side, a silent return.
2942+
const renderBlurredFrames = (first: number, last: number) => {
2943+
for (let frame = first; frame <= last; frame++) {
2944+
window.__player?.renderSeek(frame / 30, { keepFiredCallbacksSpent: true });
2945+
for (const offset of [-0.25, 0.25]) {
2946+
window.__player?.renderSeek((frame + offset) / 30, {
2947+
suppressEvents: true,
2948+
subFrameDivisions: 4,
2949+
});
2950+
}
2951+
window.__player?.renderSeek(frame / 30, {
2952+
suppressEvents: true,
2953+
keepFiredCallbacksSpent: true,
2954+
});
2955+
}
2956+
};
2957+
2958+
it("fires a call on a frame time once, on the root and on a scene", () => {
2959+
mountRootAndScene(0);
2960+
const fired: string[] = [];
2961+
window.__timelines = {
2962+
main: timelineWithCallAt(2 / 30, () => fired.push("root")),
2963+
scene: timelineWithCallAt(2 / 30, () => fired.push("scene")),
2964+
};
2965+
initSandboxRuntimeModular();
2966+
2967+
renderBlurredFrames(1, 4);
2968+
2969+
expect(fired.sort()).toEqual(["root", "scene"]);
2970+
});
2971+
2972+
it("fires a call at a scene's start once, as with motion blur off", () => {
2973+
mountRootAndScene(1);
2974+
const fired = vi.fn();
2975+
window.__timelines = {
2976+
main: gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 10 }),
2977+
scene: timelineWithCallAt(0, fired),
2978+
};
2979+
initSandboxRuntimeModular();
2980+
2981+
renderBlurredFrames(28, 34);
2982+
2983+
expect(fired).toHaveBeenCalledTimes(1);
2984+
});
2985+
});
2986+
29232987
it("shows pip video at global start time even when host composition starts late", () => {
29242988
// Regression: resolveStartForElement used to add the host composition's start on top of
29252989
// the video's own data-start, causing double-offset. A pip video with data-start="45.40"

‎packages/core/src/runtime/init.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
99
import { injectCompositionCssVariables } from "./getVariables";
1010
import { createCssAdapter } from "./adapters/css";
1111
import { createGsapAdapter, rerenderGsapTimelineAt } from "./adapters/gsap";
12+
import { keepLandedGsapCallbacksSpent } from "./adapters/gsapSpentCallbacks";
1213
import { createAnimeJsAdapter } from "./adapters/animejs";
1314
import { createLottieAdapter } from "./adapters/lottie";
1415
import { createThreeAdapter } from "./adapters/three";
@@ -3920,6 +3921,7 @@ export function initSandboxRuntimeModular(): void {
39203921
const pageAnimations = seekTimelineAndAdapters(state.currentTime, {
39213922
activateChildren: true,
39223923
suppressEvents: options?.suppressEvents,
3924+
keepFiredCallbacksSpent: options?.keepFiredCallbacksSpent,
39233925
});
39243926
runAdapters("pause", 0, pageAnimations);
39253927
syncMediaForCurrentState();
@@ -4190,6 +4192,7 @@ export function initSandboxRuntimeModular(): void {
41904192
pauseTimelineIfPossible(timeline);
41914193
if (typeof timeline.totalTime === "function") {
41924194
timeline.totalTime(timeSeconds, suppressEvents);
4195+
keepLandedGsapCallbacksSpent(timeline, options);
41934196
} else {
41944197
timeline.seek(timeSeconds, suppressEvents);
41954198
}
@@ -4303,7 +4306,7 @@ export function initSandboxRuntimeModular(): void {
43034306
*/
43044307
function seekTimelineAndAdapters(
43054308
t: number,
4306-
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
4309+
opts?: RuntimeSeekOptions & { activateChildren?: boolean },
43074310
): () => Animation[] {
43084311
const tl = state.capturedTimeline;
43094312
// Critical for a sub-composition whose data-start is at or near 0: it is added
@@ -4320,7 +4323,7 @@ export function initSandboxRuntimeModular(): void {
43204323
function seekRootChildrenAndAdapters(
43214324
tl: RuntimeTimelineLike | null,
43224325
t: number,
4323-
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
4326+
opts?: RuntimeSeekOptions & { activateChildren?: boolean },
43244327
): () => Animation[] {
43254328
const suppressEvents = opts?.suppressEvents === true;
43264329
if (tl) {
@@ -4346,6 +4349,7 @@ export function initSandboxRuntimeModular(): void {
43464349
try {
43474350
if (typeof tl.totalTime === "function") {
43484351
tl.totalTime(tlSeekTime, suppressEvents);
4352+
keepLandedGsapCallbacksSpent(tl, opts);
43494353
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
43504354
// The first seek is the only eventful one; the re-render only refreshes styles.
43514355
rerenderGsapTimelineAt(

‎packages/core/src/runtime/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export type RuntimeSeekOptions = {
287287
*/
288288
subFrameDivisions?: number;
289289
exact?: boolean;
290+
keepFiredCallbacksSpent?: boolean;
290291
};
291292

292293
export type RuntimeTimelineChildLike = {

‎packages/engine/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"devDependencies": {
7979
"@types/node": "^25.0.10",
8080
"@webgpu/types": "^0.1.69",
81+
"gsap": "^3.15.0",
8182
"typescript": "^5.7.2",
8283
"vitest": "^4.1.11"
8384
},

‎packages/engine/src/services/frameCapture-motionBlur.test.ts‎

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import gsap from "gsap";
3+
import { keepLandedGsapCallbacksSpent } from "../../../core/src/runtime/adapters/gsapSpentCallbacks";
4+
import { resolveRenderSeekTime } from "../../../core/src/runtime/player";
5+
import type { HfSeekOptions } from "../types.js";
26
import {
37
captureFrame,
48
captureFrameToBuffer,
@@ -24,12 +28,7 @@ vi.mock("./screenshotService.js", async (importOriginal) => ({
2428
* recording that a call happened. What lands in `seeks` is therefore what a browser's
2529
* `window.__hf.seek` would have received.
2630
*/
27-
interface RecordedSeek {
28-
time: number;
29-
suppressEvents?: boolean;
30-
subFrameDivisions?: number;
31-
exact?: boolean;
32-
}
31+
type RecordedSeek = HfSeekOptions & { time: number; exact?: boolean };
3332

3433
function installPageGlobals(seeks: RecordedSeek[]): void {
3534
const root = globalThis as Record<string, unknown>;
@@ -125,12 +124,12 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
125124
expect(seeks[0]).toBe(eventful[0]);
126125
});
127126

128-
it("restores the playhead to the frame time so the next frame's callbacks are not skipped", async () => {
127+
it("pairs the frame's eventful seek with a silent return to the frame time", async () => {
129128
await captureFrameToBuffer(makeSession(), 10, 10 / 30);
130129

130+
expect(seeks[0]).toEqual({ time: 10 / 30, keepFiredCallbacksSpent: true });
131131
const last = seeks[seeks.length - 1];
132-
expect(last?.time).toBe(10 / 30);
133-
expect(last?.suppressEvents).toBe(true);
132+
expect(last).toEqual({ time: 10 / 30, suppressEvents: true, keepFiredCallbacksSpent: true });
134133
expect(seeks).toHaveLength(18);
135134
});
136135

@@ -176,6 +175,65 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
176175
});
177176
});
178177

178+
describe("a composition's callbacks fire once with motion blur on, as with it off", () => {
179+
function installRenderSeek(timeline: ReturnType<typeof gsap.timeline>): void {
180+
(globalThis as Record<string, unknown>).window = {
181+
__hf: {
182+
// Mirrors core renderSeek's snap, seek and keep-spent steps on a real GSAP root timeline.
183+
seek: (time: number, options?: Omit<RecordedSeek, "time">) => {
184+
timeline.totalTime(
185+
resolveRenderSeekTime(time, 30, options),
186+
options?.suppressEvents === true,
187+
);
188+
keepLandedGsapCallbacksSpent(timeline, options);
189+
},
190+
},
191+
};
192+
}
193+
194+
async function captureFrames(count: number): Promise<void> {
195+
const session = makeSession();
196+
for (let frame = 1; frame <= count; frame++)
197+
await captureFrameToBuffer(session, frame, frame / 30);
198+
}
199+
200+
it("fires a call on a frame time once", async () => {
201+
const fired = vi.fn();
202+
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
203+
timeline.call(fired, [], 2 / 30);
204+
installRenderSeek(timeline);
205+
206+
await captureFrames(4);
207+
208+
expect(fired).toHaveBeenCalledTimes(1);
209+
});
210+
211+
it("swaps a caption in a tween's onStart on a frame time", async () => {
212+
const caption = { text: "first line" };
213+
const started = vi.fn(() => (caption.text = "second line"));
214+
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
215+
timeline.to({ y: 0 }, { y: 1, duration: 0.2, onStart: started }, 3 / 30);
216+
installRenderSeek(timeline);
217+
218+
await captureFrames(6);
219+
220+
expect(started).toHaveBeenCalledTimes(1);
221+
expect(caption.text).toBe("second line");
222+
});
223+
224+
it("fires a call at a nested scene's start once", async () => {
225+
const fired = vi.fn();
226+
const scene = gsap.timeline().call(fired, [], 0).to({ y: 0 }, { y: 1, duration: 0.5 }, 0);
227+
const timeline = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 1 });
228+
timeline.add(scene, 2 / 30);
229+
installRenderSeek(timeline);
230+
231+
await captureFrames(5);
232+
233+
expect(fired).toHaveBeenCalledTimes(1);
234+
});
235+
});
236+
179237
describe("frame export keeps its frame grid (issue #4430)", () => {
180238
// `exact` is the snapshot-only opt-out of the runtime's seek quantization. Export must
181239
// never send it, or frames would land between grid points instead of on them.

‎packages/engine/src/services/frameCapture.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4032,9 +4032,9 @@ async function resolveAdaptiveSampleCount(
40324032
*
40334033
* Callback invariant: exactly one eventful seek per output frame, at the frame time,
40344034
* arriving from the previous frame's time. Every sample seek (including a probe)
4035-
* suppresses events and the playhead is restored to the frame time afterwards, so a
4036-
* composition's own onUpdate/onComplete fire on the same interval boundaries as a
4037-
* render with blur off.
4035+
* suppresses events, and the silent return to the frame time keeps the callbacks the
4036+
* eventful seek fired spent, so a composition's callbacks (tl.call, onStart, onComplete)
4037+
* fire once, on the same boundaries as a render with blur off.
40384038
*/
40394039
async function captureAccumulatedFrame(
40404040
session: CaptureSession,
@@ -4046,7 +4046,7 @@ async function captureAccumulatedFrame(
40464046
const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
40474047

40484048
const eventfulSeekStart = Date.now();
4049-
await seekPageTimeline(session.page, frameTime, undefined);
4049+
await seekPageTimeline(session.page, frameTime, { keepFiredCallbacksSpent: true });
40504050
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
40514051

40524052
const sampleSeek: HfSeekOptions = {
@@ -4080,7 +4080,10 @@ async function captureAccumulatedFrame(
40804080
}
40814081

40824082
const restoreSeekStart = Date.now();
4083-
await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
4083+
await seekPageTimeline(session.page, frameTime, {
4084+
suppressEvents: true,
4085+
keepFiredCallbacksSpent: true,
4086+
});
40844087
totals.seekMs += Date.now() - restoreSeekStart;
40854088

40864089
const blended = accumulator.finish();

‎packages/engine/src/types.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,16 @@ export interface HfTransitionMeta {
100100
* as `seek()` produces deterministic visual output for a given time.
101101
*/
102102
/**
103-
* Per-seek controls the page honours. Both default off, which is an ordinary frame seek.
103+
* Per-seek controls the page honours. All default off, which is an ordinary frame seek.
104104
*
105-
* `suppressEvents` stops a composition's own timeline callbacks from firing, and
106-
* `subFrameDivisions` refines the grid the page quantizes onto so a fractional time is
107-
* not floored back onto the output frame. Motion-blur sampling sets both.
105+
* `suppressEvents` stops a composition's own timeline callbacks from firing, `subFrameDivisions`
106+
* refines the quantize grid so a fractional time is not floored onto the output frame, and
107+
* `keepFiredCallbacksSpent` on a frame's eventful seek and its silent return fires callbacks once.
108108
*/
109109
export interface HfSeekOptions {
110110
suppressEvents?: boolean;
111111
subFrameDivisions?: number;
112+
keepFiredCallbacksSpent?: boolean;
112113
}
113114

114115
export interface HfProtocol {

0 commit comments

Comments
 (0)