fix(producer): suppress GSAP call side effects during render seeks (#2037)

* fix(producer): suppress GSAP call side effects during render seeks

* fix(core): preserve GSAP root render nudge safely
This commit is contained in:
Miguel Ángel
2026-07-07 21:06:59 -04:00
committed by GitHub
parent c6408b620e
commit 5b9b71df25
17 changed files with 462 additions and 51 deletions
+3 -2
View File
@@ -13,13 +13,14 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
if (!timeline) return;
timeline.pause();
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, false);
timeline.totalTime(safeTime, suppressEvents);
} else {
timeline.seek(safeTime, false);
timeline.seek(safeTime, suppressEvents);
}
},
pause: () => {
+72
View File
@@ -1031,6 +1031,78 @@ describe("initSandboxRuntimeModular", () => {
expect(hookHost.style.visibility).toBe("visible");
});
it("keeps the root GSAP render nudge for normal frames but not silent probes", () => {
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 seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;
window.__player?.renderSeek(2);
expect(seekCalls).toEqual([
{ time: 2, suppressEvents: false },
{ time: 2.001, suppressEvents: true },
{ time: 2, suppressEvents: true },
]);
seekCalls.length = 0;
window.__player?.renderSeek(3, { suppressEvents: true });
expect(seekCalls).toEqual([{ time: 3, suppressEvents: true }]);
});
it("does not nudge root GSAP timelines that contain zero-duration callbacks", () => {
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 seekCalls: Array<{ time: number; suppressEvents?: boolean }> = [];
const rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
Object.assign(rootTimeline, {
getChildren: () => [
{
vars: { onComplete: () => {} },
duration: () => 0,
totalDuration: () => 0,
},
],
});
window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
seekCalls.length = 0;
window.__player?.renderSeek(2);
expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]);
});
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"
+106 -16
View File
@@ -34,7 +34,12 @@ import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type {
RuntimeDeterministicAdapter,
RuntimeJson,
RuntimeSeekOptions,
RuntimeTimelineLike,
} from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
@@ -205,7 +210,7 @@ export function initSandboxRuntimeModular(): void {
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
renderSeek: (timeSeconds: number) => void;
renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
}): PlayerAPI => {
const defaultStageZoom: ReturnType<PlayerAPI["getStageZoom"]> = {
scale: 1,
@@ -1901,7 +1906,7 @@ export function initSandboxRuntimeModular(): void {
}
if (method === "discover") {
try {
adapter.seek({ time: timeSeconds });
adapter.seek({ time: timeSeconds, suppressEvents: true });
} catch (err) {
// ignore seek bootstrap failures
swallow("runtime.init.site9", err);
@@ -2064,10 +2069,14 @@ export function initSandboxRuntimeModular(): void {
syncMediaForCurrentState();
},
onStatePost: postState,
onDeterministicSeek: (timeSeconds) => {
onDeterministicSeek: (timeSeconds, options) => {
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && state.capturedTimeline) continue;
try {
adapter.seek({ time: Number(timeSeconds) || 0 });
adapter.seek({
time: Number(timeSeconds) || 0,
suppressEvents: options?.suppressEvents,
});
} catch (err) {
// ignore adapter failure
swallow("runtime.init.site11", err);
@@ -2351,20 +2360,22 @@ export function initSandboxRuntimeModular(): void {
timeline: RuntimeTimelineLike,
timeSeconds: number,
swallowLabel: string,
options?: RuntimeSeekOptions,
) => {
try {
const suppressEvents = options?.suppressEvents === true;
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, false);
timeline.totalTime(timeSeconds, suppressEvents);
} else {
timeline.seek(timeSeconds, false);
timeline.seek(timeSeconds, suppressEvents);
}
} catch (err) {
swallow(swallowLabel, err);
}
};
const seekStandaloneRegisteredTimelines = (timeSeconds: number) => {
const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const rootCompositionId =
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
@@ -2386,7 +2397,7 @@ export function initSandboxRuntimeModular(): void {
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline");
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options);
}
};
@@ -2410,8 +2421,76 @@ export function initSandboxRuntimeModular(): void {
}
};
const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => {
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const gsapCallbackTweenCache = new WeakMap<RuntimeTimelineLike, boolean>();
const GSAP_CALLBACK_NAMES = [
"onStart",
"onUpdate",
"onComplete",
"onReverseComplete",
"onRepeat",
];
const readGsapDuration = (child: Record<string, unknown>, property: string): number | null => {
const getter = child[property];
if (typeof getter !== "function") return null;
try {
const value = Number(getter.call(child));
return Number.isFinite(value) ? value : null;
} catch (err) {
swallow("runtime.init.gsapCallbackDuration", err);
return null;
}
};
const hasZeroDurationCallbackTween = (timeline: RuntimeTimelineLike): boolean => {
const cached = gsapCallbackTweenCache.get(timeline);
if (cached != null) return cached;
if (!("getChildren" in timeline) || typeof timeline.getChildren !== "function") {
return false;
}
let children: unknown;
try {
children = timeline.getChildren(true, true, true);
} catch (err) {
swallow("runtime.init.gsapCallbackChildren", err);
gsapCallbackTweenCache.set(timeline, false);
return false;
}
if (!Array.isArray(children)) {
gsapCallbackTweenCache.set(timeline, false);
return false;
}
for (const child of children) {
if (!isObjectRecord(child) || !isObjectRecord(child.vars)) continue;
const hasCallback = GSAP_CALLBACK_NAMES.some(
(name) => typeof child.vars[name] === "function",
);
if (!hasCallback) continue;
const totalDuration = readGsapDuration(child, "totalDuration");
const duration = totalDuration ?? readGsapDuration(child, "duration");
if (duration != null && duration <= 0.000001) {
gsapCallbackTweenCache.set(timeline, true);
return true;
}
}
gsapCallbackTweenCache.set(timeline, false);
return false;
};
const seekTimelineAndAdapters = (
t: number,
opts?: { activateChildren?: boolean; suppressEvents?: boolean },
) => {
const tl = state.capturedTimeline;
const suppressEvents = opts?.suppressEvents === true;
if (tl) {
// When rendering frame-by-frame (activateChildren=true), ensure all
// sibling timelines are unpaused before seeking the root. GSAP
@@ -2445,9 +2524,16 @@ export function initSandboxRuntimeModular(): void {
}
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(tlSeekTime, false);
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);
}
} else {
tl.seek(tlSeekTime, false);
tl.seek(tlSeekTime, suppressEvents);
}
} catch (err) {
swallow("runtime.init.transport.seek", err);
@@ -2460,11 +2546,12 @@ export function initSandboxRuntimeModular(): void {
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t);
seekStandaloneRegisteredTimelines(t, opts);
}
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && tl) continue;
try {
adapter.seek({ time: t });
adapter.seek({ time: t, suppressEvents });
} catch (err) {
swallow("runtime.init.transport.adapter", err);
}
@@ -2791,7 +2878,7 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};
player.renderSeek = (timeSeconds: number) => {
player.renderSeek = (timeSeconds: number, options?: RuntimeSeekOptions) => {
const quantized = quantizeTimeToFrame(
Math.max(0, Number(timeSeconds) || 0),
state.canonicalFps,
@@ -2801,7 +2888,10 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
seekTimelineAndAdapters(state.currentTime, { activateChildren: true });
seekTimelineAndAdapters(state.currentTime, {
activateChildren: true,
suppressEvents: options?.suppressEvents,
});
syncMediaForCurrentState();
colorGrading.redraw();
postState(true);
+14
View File
@@ -466,6 +466,20 @@ describe("createRuntimePlayer", () => {
expect(deps.onRenderFrameSeek).toHaveBeenCalled();
});
it("can suppress timeline events during administrative render seeks", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
const player = createRuntimePlayer(deps);
const renderSeek = player.renderSeek as (
time: number,
options?: { suppressEvents?: boolean },
) => void;
renderSeek(5, { suppressEvents: true });
expect(timeline.totalTime).toHaveBeenCalledWith(5, true);
});
it("renderSeek rearms paused siblings and keeps them active for export frames", () => {
const { master, scene1, scene2, scene5 } = createNestedTimelineHarness();
const deps = createMockDeps(master);
+11 -8
View File
@@ -1,4 +1,4 @@
import type { RuntimePlayer, RuntimeTimelineLike } from "./types";
import type { RuntimePlayer, RuntimeSeekOptions, RuntimeTimelineLike } from "./types";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { swallow } from "./diagnostics";
@@ -41,7 +41,7 @@ type PlayerDeps = {
getCanonicalFps: () => number;
onSyncMedia: (timeSeconds: number, playing: boolean) => void;
onStatePost: (force: boolean) => void;
onDeterministicSeek: (timeSeconds: number) => void;
onDeterministicSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
onDeterministicPause: () => void;
onDeterministicPlay: () => void;
onRenderFrameSeek: (timeSeconds: number) => void;
@@ -79,13 +79,15 @@ function seekTimelineDeterministically(
timeline: RuntimeTimelineLike,
timeSeconds: number,
canonicalFps: number,
options?: RuntimeSeekOptions,
): number {
const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps);
const suppressEvents = options?.suppressEvents === true;
safeVoid(timeline, "pause");
if (typeof timeline.totalTime === "function") {
timeline.totalTime(quantized, false);
timeline.totalTime(quantized, suppressEvents);
} else {
if (typeof timeline.seek === "function") timeline.seek(quantized, false);
if (typeof timeline.seek === "function") timeline.seek(quantized, suppressEvents);
}
return quantized;
}
@@ -95,6 +97,7 @@ function seekMasterAndSiblingTimelinesDeterministically(
master: RuntimeTimelineLike,
timeSeconds: number,
canonicalFps: number,
options?: RuntimeSeekOptions,
): number {
const rearmedSiblings: RuntimeTimelineLike[] = [];
forEachSiblingTimeline(registry, master, (tl) => {
@@ -102,7 +105,7 @@ function seekMasterAndSiblingTimelinesDeterministically(
rearmedSiblings.push(tl);
});
try {
return seekTimelineDeterministically(master, timeSeconds, canonicalFps);
return seekTimelineDeterministically(master, timeSeconds, canonicalFps, options);
} finally {
for (const tl of rearmedSiblings) {
try {
@@ -206,7 +209,7 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
deps.onRenderFrameSeek(quantized);
deps.onStatePost(true);
},
renderSeek: (timeSeconds: number) => {
renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => {
const timeline = deps.getTimeline();
const canonicalFps = deps.getCanonicalFps();
// When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie /
@@ -219,10 +222,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
// If nested siblings stay paused, GSAP collapses the root back to the
// authored master duration and later frames clamp incorrectly.
activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline);
return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps);
return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps, options);
})()
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
deps.onDeterministicSeek(quantized);
deps.onDeterministicSeek(quantized, options);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
deps.onRenderFrameSeek(quantized);
+6 -2
View File
@@ -212,7 +212,7 @@ export type RuntimePlayer = {
play: () => void;
pause: () => void;
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void;
renderSeek: (timeSeconds: number) => void;
renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
@@ -220,6 +220,10 @@ export type RuntimePlayer = {
getPlaybackRate: () => number;
};
export type RuntimeSeekOptions = {
suppressEvents?: boolean;
};
export type RuntimeTimelineLike = {
play: () => void;
pause: () => void;
@@ -236,7 +240,7 @@ export type RuntimeTimelineLike = {
export type RuntimeDeterministicAdapter = {
name: string;
discover: () => void;
seek: (ctx: { time: number }) => void;
seek: (ctx: { time: number; suppressEvents?: boolean }) => void;
pause: () => void;
play?: () => void;
revert?: () => void;
+3 -1
View File
@@ -1,4 +1,4 @@
import type { RuntimeTimelineMessage, RuntimeTimelineLike } from "./types";
import type { RuntimeSeekOptions, RuntimeTimelineMessage, RuntimeTimelineLike } from "./types";
import type { RuntimeColorGradingApi } from "./colorGrading";
import type { HyperframePickerApi } from "../inline-scripts/pickerApi";
import type { PlayerAPI } from "../core.types";
@@ -35,6 +35,8 @@ declare global {
__hf?: {
colorGrading?: RuntimeColorGradingApi;
onSwallowed?: (label: string, err: unknown) => void;
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
duration?: number;
};
__playerReady?: boolean;
__renderReady?: boolean;