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
81 changes: 80 additions & 1 deletion packages/core/src/runtime/adapters/gsap.order.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import gsap from "gsap";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { rerenderGsapTimelineAt, seekGsapTimeline } from "./gsap";
import { stampAuthoredOrder } from "./gsapAuthoredOrder";
import { producerStandIn } from "./gsapStandIn.test-helpers";
import type { RuntimeTimelineLike } from "../types";

// The runtime stamps the order a film writes its tweens in as GSAP loads, before any composition script runs.
beforeAll(() => stampAuthoredOrder(gsap));

const FPS = 60;
const FRAMES = 150;

Expand Down Expand Up @@ -228,3 +232,78 @@ describe("a fromTo beside a set on another property of the same element", () =>
expect(tile.x).toBeCloseTo(100 + 100 * (59 / 30 - 1), 5);
});
});

// An init written as a set before the tween that fades in from it: the set sits inside the tween's span, but the
// author wrote the tween last, so the tween's end is what the film shows.
function setBeforeFadeIn() {
const cards = [0, 1, 2, 3].map(() => ({ opacity: 0, scale: 1 }));
const timeline = gsap.timeline({ paused: true });
timeline.to({ x: 0 }, { x: 1, duration: 0.8 }, 5.5);
timeline.set(cards, { opacity: 0, scale: 0.95 });
timeline.fromTo(
cards,
{ opacity: 0, scale: 0.95 },
{ opacity: 1, scale: 1, duration: 0.8, stagger: 0.15 },
6.0,
);
timeline.to(cards, { opacity: 0, scale: 0.8, duration: 1 }, 8.5);
return { cards, timeline: timeline as unknown as RuntimeTimelineLike };
}

const CARD_FRAMES = range(0, 300);
// Inside the overlap GSAP's own in-order stepping shows the set's value for one frame (190) that the runtime's
// re-render does not; this test is about what the film shows once the last card has faded in.
const FADED_IN = Math.ceil((6.0 + 3 * 0.15 + 0.8) * SET_FPS);

/** Card 0's opacity at each frame, through the render's seek, or through the runtime's seek and re-render. */
function cardsAt(frames: number[], through: "render" | "runtime"): Map<number, number> {
const { cards, timeline } = setBeforeFadeIn();
const structure = { owner: timeline, version: "1" };
const opacity = new Map<number, number>();
for (const frame of frames) {
const t = frame / SET_FPS;
if (through === "render") seekGsapTimeline(timeline, t, true, structure, SET_FPS);
else if (!seekGsapTimeline(timeline, t, false, undefined, SET_FPS)) {
rerenderGsapTimelineAt(
{
totalTime: timeline.totalTime!.bind(timeline),
totalDuration: timeline.totalDuration?.bind(timeline),
getChildren: timeline.getChildren?.bind(timeline),
},
t,
);
}
opacity.set(frame, cards[0]!.opacity);
}
return opacity;
}

describe("a set written before the tween it sits inside", () => {
// What GSAP itself shows playing the film in order, frame by frame.
const inOrder = (() => {
const { cards, timeline } = setBeforeFadeIn();
return new Map(
CARD_FRAMES.map((frame) => {
timeline.totalTime!(frame / SET_FPS, true);
return [frame, cards[0]!.opacity] as const;
}),
);
})();

it("starts from a film where the fade-in ends fully visible", () => {
expect(inOrder.get(Math.round(7.5 * SET_FPS))).toBe(1);
});

for (const through of ["render", "runtime"] as const) {
it(`shows the tween's end after the fade-in through the ${through} seek, however frames are shared out`, () => {
for (const workers of [1, 2, 3, 4, 6]) {
for (let worker = 0; worker < workers; worker++) {
for (const [frame, opacity] of cardsAt(range(worker, 300, workers), through)) {
if (through === "runtime" && frame < FADED_IN) continue;
expect([workers, frame, opacity]).toEqual([workers, frame, inOrder.get(frame)]);
}
}
}
});
}
});
17 changes: 14 additions & 3 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "../types";
import { authoredOrderOf } from "./gsapAuthoredOrder";

type GsapAdapterDeps = {
getTimeline: () => RuntimeTimelineLike | null;
Expand Down Expand Up @@ -140,7 +141,12 @@ type PlacedTween = GsapPlaced & {
vars?: Record<string, unknown>;
targets?: () => object[];
};
type PropertyWrite = { start: number; end: number; properties: Set<string> | null };
type PropertyWrite = {
start: number;
end: number;
properties: Set<string> | null;
order: number | undefined;
};

function propertiesWritten({ startAt, ...vars }: Record<string, unknown>): Set<string> | null {
const to = ownPropertiesWritten(vars);
Expand Down Expand Up @@ -190,6 +196,7 @@ function writeOf(tween: PlacedTween, timeline: RuntimeTimelineLike): PropertyWri
start: span[0],
end: span[0] + span[1],
properties: propertiesWritten(tween.vars ?? {}),
order: authoredOrderOf(tween, owner),
}
);
}
Expand All @@ -210,6 +217,10 @@ function sharesAProperty(a: PropertyWrite, b: PropertyWrite): boolean {
return !a.properties || !b.properties || [...a.properties].some((p) => b.properties!.has(p));
}

function writtenBefore(a: PropertyWrite, b: PropertyWrite): boolean {
return a.order !== undefined && b.order !== undefined && a.order < b.order;
}

function overlapsOnOneTarget(writes: PropertyWrite[]): OverlapSpan[] {
const spans: OverlapSpan[] = [];
writes.sort((x, y) => x.start - y.start);
Expand All @@ -221,7 +232,7 @@ function overlapsOnOneTarget(writes: PropertyWrite[]): OverlapSpan[] {
laterStart: second.start,
sharedEnd: Math.min(first.end, second.end),
earlierEnd: first.end,
laterIsSet: second.end === second.start,
laterIsSet: second.end === second.start && !writtenBefore(second, first),
});
}
});
Expand Down Expand Up @@ -377,7 +388,7 @@ const landingMarksCache = new WeakMap<object, PlacedMarks>();

const LANDING_MARK_REACH = 0.05;

type GsapPlaced = {
export type GsapPlaced = {
_start?: number;
_ts?: number;
_repeat?: number;
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/runtime/adapters/gsapAuthoredOrder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { GsapPlaced } from "./gsap";

// GSAP sorts a timeline's children by start time and keeps no record of the order a film wrote them in. A set written
// before the tween it sits inside is that tween's initial state; one written after it is an override of that tween.
type Stamped = { _hfAuthored?: number };
type Adder = (this: { _recent?: Stamped }, ...args: unknown[]) => unknown;
type TimelineProto = Record<string, unknown> & { _hfStamped?: true };

const ADDERS = ["to", "from", "fromTo", "set"] as const;
let written = 0;

/** Numbers every child a timeline adds with `to`, `from`, `fromTo` and `set` by when the film wrote it. */
export function stampAuthoredOrder(gsap: object | undefined): void {
const core = (gsap as { core?: { Timeline?: { prototype: object } } } | undefined)?.core;
const proto = core?.Timeline?.prototype as TimelineProto | undefined;
if (!proto || proto._hfStamped) return;
proto._hfStamped = true;
for (const name of ADDERS) {
const add = proto[name] as Adder;
proto[name] = function (this: { _recent?: Stamped }, ...args: unknown[]) {
const result = add.apply(this, args);
if (this._recent && this._recent._hfAuthored === undefined)
this._recent._hfAuthored = ++written;
return result;
};
}
}

/** When the film wrote the child of `owner` holding `tween`; undefined for a film built before the runtime loaded. */
export function authoredOrderOf(tween: GsapPlaced, owner: object): number | undefined {
let order: number | undefined;
for (
let node: GsapPlaced | null | undefined = tween;
node && node !== owner;
node = node.parent
) {
order = (node as Stamped)._hfAuthored ?? order;
}
return order;
}
16 changes: 16 additions & 0 deletions packages/core/src/runtime/init.authoredOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import gsap from "gsap";
import { expect, it } from "vitest";
import { installFlatGsapTransforms } from "./init";

// Its own file: the stamp patches GSAP's Timeline once for the whole process.
it("numbers the order a film writes its tweens in when GSAP loads after the runtime", () => {
installFlatGsapTransforms();
window.gsap = gsap as unknown as typeof window.gsap;
const timeline = gsap.timeline({ paused: true });
timeline.set({ a: 0 }, { a: 1 }).to({ b: 0 }, { b: 1, duration: 1 });
const [first, second] = timeline.getChildren(false, true, true) as unknown as {
_hfAuthored?: number;
}[];
expect(first?._hfAuthored).toBeDefined();
expect(second?._hfAuthored).toBeGreaterThan(first?._hfAuthored ?? Infinity);
});
10 changes: 7 additions & 3 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
seekGsapTimeline,
type GsapStructureKey,
} from "./adapters/gsap";
import { stampAuthoredOrder } from "./adapters/gsapAuthoredOrder";
import { createAnimeJsAdapter } from "./adapters/animejs";
import { createLottieAdapter } from "./adapters/lottie";
import { createThreeAdapter } from "./adapters/three";
Expand Down Expand Up @@ -459,10 +460,13 @@ export function installAuthoredMediaCapture(): void {
}

// 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).
// Runs at script evaluation, as GSAP's bundle assigns window.gsap and before a composition's set() or from() parses
// an element: force3D off, and tweens numbered by write order. Chains to an accessor already there (the early stub).
export function installFlatGsapTransforms(): void {
const flatten = (g: Window["gsap"]) => g?.config?.({ force3D: false });
const flatten = (g: Window["gsap"]) => {
g?.config?.({ force3D: false });
stampAuthoredOrder(g);
};
const prior = Object.getOwnPropertyDescriptor(window, "gsap");
let loaded = window.gsap;
flatten(loaded);
Expand Down
Loading