Skip to content
Closed
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
97 changes: 97 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2920,6 +2920,103 @@ describe("initSandboxRuntimeModular", () => {
expect(fired).toHaveBeenCalledTimes(1);
});

it("leaves the next runtime's timeline alone after a runtime is torn down", () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
document.body.innerHTML = `<div data-composition-id="main" data-root="true" data-duration="30"></div>`;
window.__timelines = { main: createMockTimeline(30) };
initSandboxRuntimeModular();
window.__player?.seek(16.2);
window.__hfForceTimelineRebind?.();
window.__hfRuntimeTeardown?.();

const next = createMockTimeline(30);
window.__timelines = { main: next };
initSandboxRuntimeModular();
window.__player?.seek(0.25);
vi.runOnlyPendingTimers();

expect(next.time()).toBeCloseTo(0.25, 1);
vi.useRealTimers();
});

it("fires a call on the playhead once when a readiness pass runs between seeks", () => {
vi.useFakeTimers();
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 fired = vi.fn();
const main = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 10 });
main.call(fired, [], 2);
window.__timelines = { main };
initSandboxRuntimeModular();

window.__player?.renderSeek(2);
vi.runOnlyPendingTimers();
window.__player?.renderSeek(3);

expect(fired).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});

// Discovery no longer re-seeks a bound GSAP root, so binding alone must land it on the playhead.
it.each([
{ root: "seek-only", declared: ' data-duration="10"', reported: 10 },
{ root: "seek-only", declared: "", reported: 0 },
{ root: "totalTime", declared: "", reported: 0 },
])(
"shows the playhead's frame when a $root root (duration $reported) binds late during a paused drag",
({ root, declared, reported }) => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
window.requestAnimationFrame =
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
document.body.innerHTML = `
<div data-composition-id="main" data-root="true"${declared} data-width="1920" data-height="1080">
<div id="sibling"></div>
<div id="dragged" data-hf-studio-manual-edit-gesture="tok-1"></div>
</div>
`;
window.__hfTimelinesBuilding = true;
initSandboxRuntimeModular();
window.__player?.seek(5);

const fired = vi.fn();
const tl = gsap
.timeline({ paused: true })
.to("#sibling", { x: 100, duration: 10, ease: "none" })
.call(fired, [], 2);
const facade: RuntimeTimelineLike = {
play: () => tl.play(),
pause: () => tl.pause(),
seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents),
time: () => tl.time(),
duration: () => reported,
add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at),
paused: (paused) => tl.paused(paused),
set: (target, vars, at) => tl.set(target, vars, at),
...(root === "totalTime" && {
totalTime: (time, suppressEvents) => tl.totalTime(time ?? 0, suppressEvents),
}),
};
window.__timelines = { main: facade };
window.__hfTimelinesBuilding = false;
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
raf.step(16);
raf.step(16);

expect(tl.time()).toBeCloseTo(5);
expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50);
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"
Expand Down
44 changes: 25 additions & 19 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1923,28 +1923,30 @@ export function initSandboxRuntimeModular(): void {
pauseTimelineIfPossible(state.capturedTimeline);
}
}
// Seek to the prior playhead (state.currentTime) so a rebind after a user
// scrub or soft-reload restore doesn't snap back to 0, whether or not the
// duration is known yet: discovery no longer re-seeks a bound root.
const seekTime = Math.max(0, state.currentTime || 0);
if (typeof state.capturedTimeline.totalTime === "function") {
// GSAP won't render tl.set() at position 0 when the paused timeline
// starts there — play/pause/seek/totalTime are all no-ops at the
// creation position. Force the set to render by cycling progress past
// 0 (when the timeline implements it).
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(0.0001, true);
}
state.capturedTimeline.totalTime(seekTime, false);
} else {
state.capturedTimeline.seek(seekTime, false);
}
pauseTimelineIfPossible(state.capturedTimeline);
if (boundDuration > 0) {
try {
clock.setDuration(boundDuration);
} catch {
// clock not yet initialized — duration will be set during TransportClock setup
}

if (typeof state.capturedTimeline.totalTime === "function") {
// GSAP won't render tl.set() at position 0 when the paused timeline
// starts there — play/pause/seek/totalTime are all no-ops at the
// creation position. Force the set to render by cycling progress past
// 0 (when the timeline implements it), then seek to the prior playhead
// (state.currentTime) so a rebind after a user scrub or soft-reload
// restore doesn't snap back to 0.
if (typeof state.capturedTimeline.progress === "function") {
state.capturedTimeline.progress(0.0001, true);
}
const seekTime = Math.max(0, state.currentTime || 0);
state.capturedTimeline.totalTime(seekTime, false);
pauseTimelineIfPossible(state.capturedTimeline);
}

// GSAP bakes the CSS `translate` into style.transform on seek.
// The Studio seek wrapper (installStudioManualEditSeekReapply) calls
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
Expand Down Expand Up @@ -3297,6 +3299,9 @@ export function initSandboxRuntimeModular(): void {
postState(true);
};

const isSeekedByRoot = (adapter: RuntimeDeterministicAdapter): boolean =>
adapter.name === "gsap" && Boolean(state.capturedTimeline);

const runAdapters = (
method: "discover" | "pause" | "play",
timeSeconds = 0,
Expand All @@ -3311,7 +3316,7 @@ export function initSandboxRuntimeModular(): void {
// keep runtime resilient against adapter-specific failures
swallow("runtime.init.site8", err);
}
if (method === "discover") {
if (method === "discover" && !isSeekedByRoot(adapter)) {
try {
adapter.seek({ time: timeSeconds, suppressEvents: true });
} catch (err) {
Expand Down Expand Up @@ -3968,7 +3973,7 @@ export function initSandboxRuntimeModular(): void {
onStatePost: postState,
onDeterministicSeek: (timeSeconds, options) => {
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && state.capturedTimeline) continue;
if (isSeekedByRoot(adapter)) continue;
try {
adapter.seek({
time: Number(timeSeconds) || 0,
Expand Down Expand Up @@ -4152,9 +4157,10 @@ export function initSandboxRuntimeModular(): void {
// timelines in __timelines haven't executed yet (they run in the browser's next
// microtask). Defer a rebinding attempt to catch them.
if (externalCompositionsReady) {
setTimeout(() => {
const deferredRebindTimerId = window.setTimeout(() => {
maybePublishRenderReady();
}, 0);
registerRuntimeCleanup(() => window.clearTimeout(deferredRebindTimerId));
}
let transportTickCount = 0;
let inTransportTick = false;
Expand Down Expand Up @@ -4370,7 +4376,7 @@ export function initSandboxRuntimeModular(): void {
seekStandaloneRegisteredTimelines(t, opts);
const pageAnimations = pageAnimationsForOnePass();
for (const adapter of state.deterministicAdapters) {
if (adapter.name === "gsap" && tl) continue;
if (isSeekedByRoot(adapter)) continue;
try {
adapter.seek({ time: t, suppressEvents, pageAnimations });
} catch (err) {
Expand Down
Loading