mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
fix: GSAP callbacks on a frame time fire once in motion-blur renders
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -287,6 +287,7 @@ export type RuntimeSeekOptions = {
|
||||
*/
|
||||
subFrameDivisions?: number;
|
||||
exact?: boolean;
|
||||
keepFiredCallbacksSpent?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeTimelineChildLike = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user