mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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: () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user