Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/core/scripts/test-hyperframe-runtime-seek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,12 @@ function testGsapAdapterPreservesTotalTime(): void {
calls,
[
{ method: "pause" },
// Nudge to force GSAP 3.x dirty state before the real seek
{ method: "totalTime", time: seekTime + 0.001, suppressEvents: true },
{ method: "totalTime", time: seekTime, suppressEvents: false },
// Silent re-render arriving from below, so same-time steps apply in authored order
{ method: "totalTime", time: seekTime - 0.001, suppressEvents: true },
{ method: "totalTime", time: seekTime, suppressEvents: true },
],
"GSAP adapter should nudge then seek via totalTime() (not downgrade to seek())",
"GSAP adapter should seek via totalTime() then re-render from just below",
);
}

Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/runtime/adapters/gsap.steps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import gsap from "gsap";
import { describe, expect, it } from "vitest";
import { createGsapAdapter } from "./gsap";
import type { RuntimeTimelineLike } from "../types";

// Every 0.1 s: hide all three frames, then show one, as frame-by-frame films do.
function steppedFilm() {
const frames = [0, 1, 2].map(() => document.body.appendChild(document.createElement("div")));
let calls = 0;
const timeline = gsap.timeline({ paused: true });
for (let k = 0; k < 30; k++) {
timeline.set(frames, { visibility: "hidden" }, k * 0.1);
timeline.set(frames[k % 3]!, { visibility: "visible" }, k * 0.1);
}
timeline.call(() => void calls++, [], 2.5);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
const shown = () => frames.map((frame) => frame.style.visibility === "visible");
return { timeline, adapter, shown, calls: () => calls };
}

describe("gsap adapter on a step", () => {
it.each([0, 1, 2.7])("shows the step's frame when seeking onto it from %s s", (from) => {
const film = steppedFilm();
film.timeline.totalTime(from, true);
film.adapter.seek({ time: 2.5 });
expect(film.shown()).toEqual([false, true, false]);
});

it("fires a call on the step once when seeking onto it twice", () => {
const film = steppedFilm();
film.adapter.seek({ time: 2.5 });
film.adapter.seek({ time: 2.5 });
expect(film.calls()).toBe(1);
});

it("applies a set at 0 on a timeline that has not moved yet", () => {
const intro = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.set(intro, { display: "block" }, 0).to(intro, { opacity: 0, duration: 1 }, 1);
intro.style.display = "none";
createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({
time: 0,
});
expect(intro.style.display).toBe("block");
});

it("restores a value changed outside the timeline when seeking 0 again", () => {
const box = document.body.appendChild(document.createElement("div"));
const timeline = gsap.timeline({ paused: true });
timeline.fromTo(box, { x: 10 }, { x: 100, duration: 1 }, 0);
const adapter = createGsapAdapter({
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
});
adapter.seek({ time: 0 });
gsap.set(box, { x: 999 });
adapter.seek({ time: 0 });
expect(gsap.getProperty(box, "x")).toBe(10);
});
});
16 changes: 13 additions & 3 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ type GsapAdapterDeps = {
getTimeline: () => RuntimeTimelineLike | null;
};

/**
* Re-renders a timeline already moved to `t`, silently. It arrives from just below, since GSAP
* applies same-time zero-duration steps in authored order only going forward; at 0 it comes from above.
*/
export function rerenderGsapTimelineAt(
timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
t: number,
): void {
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
timeline.totalTime(t, true);
}

export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
return {
name: "gsap",
Expand All @@ -15,10 +27,8 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
const safeTime = Math.max(0, Number(ctx.time) || 0);
const suppressEvents = ctx.suppressEvents === true;
if (typeof timeline.totalTime === "function") {
// GSAP 3.x skips rendering when the new totalTime equals _tTime.
// Nudge first to force a dirty state, then seek to the exact time.
timeline.totalTime(safeTime + 0.001, true);
timeline.totalTime(safeTime, suppressEvents);
rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
} else {
timeline.seek(safeTime, suppressEvents);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2851,7 +2851,7 @@ describe("initSandboxRuntimeModular", () => {

expect(seekCalls).toEqual([
{ time: 2, suppressEvents: false },
{ time: 2.001, suppressEvents: true },
{ time: 1.999, suppressEvents: true },
{ time: 2, suppressEvents: true },
]);

Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_UPCOMING_ATTR } from "../studi
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css";
import { createGsapAdapter } from "./adapters/gsap";
import { createGsapAdapter, rerenderGsapTimelineAt } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
Expand Down Expand Up @@ -4360,11 +4360,8 @@ export function initSandboxRuntimeModular(): void {
if (typeof tl.totalTime === "function") {
tl.totalTime(tlSeekTime, suppressEvents);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// Preserve GSAP's forced-render nudge for root timelines without
// firing callbacks a second time. The first seek is the only
// eventful one; the follow-up nudges only refresh computed styles.
tl.totalTime(tlSeekTime + 0.001, true);
tl.totalTime(tlSeekTime, true);
// The first seek is the only eventful one; the re-render only refreshes styles.
rerenderGsapTimelineAt({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime);
}
} else {
tl.seek(tlSeekTime, suppressEvents);
Expand Down
4 changes: 2 additions & 2 deletions packages/producer/tests/style-4-prod/output/output.mp4
Git LFS file not shown
Loading