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: 6 additions & 1 deletion packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { initSandboxRuntimeModular, installAuthoredMediaCapture } from "./init";
import {
initSandboxRuntimeModular,
installAuthoredMediaCapture,
installFlatGsapTransforms,
} from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { deferMediaUntilDue } from "./preloadMedia";
import { hideTimedClipsUntilFirstPass } from "./timedClipHide";
Expand Down Expand Up @@ -30,6 +34,7 @@ type HyperframeWindow = Window & {
// at script evaluation time, while the document is still parsing.
installAuthoredOpacityCapture();
installAuthoredMediaCapture();
installFlatGsapTransforms();

hideTimedClipsUntilFirstPass();
deferMediaUntilDue();
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/runtime/init.flatGsap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import gsap from "gsap";
import { expect, it, vi } from "vitest";
import { installFlatGsapTransforms } from "./init";

// Its own file: a non-configurable window.gsap cannot be removed again.
it("configures a GSAP held by a non-configurable window.gsap without throwing", () => {
Object.defineProperty(window, "gsap", { value: gsap, writable: true, configurable: false });
const config = vi.spyOn(gsap, "config");
expect(() => installFlatGsapTransforms()).not.toThrow();
expect(config).toHaveBeenCalledWith({ force3D: false });
expect(window.gsap).toBe(gsap);
});
128 changes: 127 additions & 1 deletion packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { initSandboxRuntimeModular } from "./init";
import { initSandboxRuntimeModular, installFlatGsapTransforms } from "./init";
import { collectRuntimeTimelinePayload } from "./timeline";
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
import { WebAudioTransport } from "./webAudioTransport";
Expand Down Expand Up @@ -1792,6 +1792,132 @@ describe("initSandboxRuntimeModular", () => {
});
});

describe("mid-tween transforms under real GSAP", () => {
type Timeline = ReturnType<typeof gsap.timeline>;
afterEach(() => {
delete (window as { gsap?: unknown }).gsap;
gsap.config({ force3D: "auto" });
});
// Page order: the runtime script, the GSAP bundle, the composition's script, then DOMContentLoaded.
const transformAt = (
time: number,
build: (box: HTMLElement) => Timeline,
{ gsapBeforeRuntime = false } = {},
) => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "4");
const box = document.createElement("div");
box.style.clipPath = "inset(0px 35.55px 0px 0px)";
root.appendChild(box);
document.body.appendChild(root);
const raf = createManualRaf();
const now = vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame =
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
try {
window.__timelines = {};
if (gsapBeforeRuntime) window.gsap = gsap as unknown as typeof window.gsap;
installFlatGsapTransforms();
if (!gsapBeforeRuntime) window.gsap = gsap as unknown as typeof window.gsap;
expect(window.gsap).toBe(gsap);
window.__timelines.main = build(box) as unknown as RuntimeTimelineLike;
initSandboxRuntimeModular();
for (let frame = 0; frame < 60; frame += 1) raf.step(16);
window.__player?.seek(time);
return box.style.transform;
} finally {
now.mockRestore();
}
};
const tween = (vars: gsap.TweenVars) => ({ ...vars, duration: 2, ease: "none" });

// A 3D transform puts the element on its own layer, where Chrome snaps a crop edge to whole pixels.
it.each([
[
"a to() tween",
(box: HTMLElement) => gsap.timeline({ paused: true }).to(box, tween({ scale: 1.25 }), 0),
],
[
"a from() tween",
(box: HTMLElement) => gsap.timeline({ paused: true }).from(box, tween({ scale: 1.25 }), 0),
],
[
"a fromTo() tween",
(box: HTMLElement) =>
gsap.timeline({ paused: true }).fromTo(box, { scale: 1 }, tween({ scale: 1.25 }), 0),
],
[
"a tween on an element the script set() first",
(box: HTMLElement) => {
gsap.set(box, { scale: 1 });
return gsap.timeline({ paused: true }).to(box, tween({ scale: 1.25 }), 0);
},
],
])("draws %s in 2D mid-tween", (_name, build) => {
expect(transformAt(1, build)).toBe("scale(1.125, 1.125)");
});

it("draws a set() then moved element in 2D when GSAP loaded before the runtime", () => {
const transform = transformAt(
1,
(box) => {
gsap.set(box, { x: 0 });
return gsap.timeline({ paused: true }).to(box, tween({ x: 40 }), 0);
},
{ gsapBeforeRuntime: true },
);
expect(transform).toBe("translate(20px, 0px)");
});

it("keeps a composition's own force3D setting", () => {
const transform = transformAt(1, (box) => {
gsap.config({ force3D: true });
return gsap.timeline({ paused: true }).to(box, tween({ scale: 1.25 }), 0);
});
expect(transform).toBe("translate3d(0px, 0px, 0px) scale(1.125, 1.125)");
});

it("leaves an element GSAP only fades without an inline transform", () => {
const transform = transformAt(1, (box) =>
gsap.timeline({ paused: true }).to(box, tween({ opacity: 0.5 }), 0),
);
expect(transform).toBe("");
});

it("configures GSAP once when the runtime script runs twice", () => {
const config = vi.spyOn(gsap, "config");
try {
installFlatGsapTransforms();
installFlatGsapTransforms();
window.gsap = gsap as unknown as typeof window.gsap;
expect(config).toHaveBeenCalledTimes(1);
} finally {
config.mockRestore();
}
});

it("still hands GSAP to an accessor that trapped window.gsap before the runtime", () => {
const seen: unknown[] = [];
let held: unknown;
Object.defineProperty(window, "gsap", {
configurable: true,
get: () => held,
set: (g) => {
seen.push(g);
held = g;
},
});
const transform = transformAt(1, (box) =>
gsap.timeline({ paused: true }).to(box, tween({ scale: 1.25 }), 0),
);
expect(seen).toEqual([gsap]);
expect(transform).toBe("scale(1.125, 1.125)");
});
});

// #6: a single timeline registered under a key that does NOT match the root's
// data-composition-id must still bind (sole-timeline fallback) instead of
// silently rendering the frozen t=0 DOM.
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,31 @@ export function installAuthoredMediaCapture(): void {
authoredMediaObserver.observe(document.documentElement, { childList: true, subtree: true });
}

// Mid-tween, GSAP's default force3D writes translate3d, and Chrome snaps a crop edge on it to whole pixels.
// Runs at script evaluation: GSAP is configured as its bundle assigns window.gsap, before a composition's
// set() or from() parses an element. Chains to an accessor already there (the producer's early stub).
export function installFlatGsapTransforms(): void {
const flatten = (g: Window["gsap"]) => g?.config?.({ force3D: false });
const prior = Object.getOwnPropertyDescriptor(window, "gsap");
let loaded = window.gsap;
flatten(loaded);
if (prior?.configurable === false || (prior?.get as { hfFlat?: true } | undefined)?.hfFlat)
return;
const get = Object.assign(() => (prior?.get ? prior.get.call(window) : loaded), {
hfFlat: true,
});
Object.defineProperty(window, "gsap", {
configurable: true,
enumerable: true,
get,
set: (g: Window["gsap"]) => {
if (prior?.set) prior.set.call(window, g);
else loaded = g;
flatten(g);
},
});
}

// URL attributes a scene swap checks besides src, poster and srcset, by tag.
const MEDIA_URL_ATTRS = new Map([
["image", ["href", "xlink:href"]],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ declare global {
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
set?: (target: Element, vars: Record<string, unknown>) => unknown;
config?: (vars: Record<string, unknown>) => unknown;
parseEase?: (
ease: string | ((progress: number) => number),
...args: unknown[]
Expand Down
Git LFS file not shown
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading