mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(studio): re-sync soft-reloaded timelines to the studio's own scrub time
Dragging a motion-path keyframe node committed correctly, but the soft reload that refreshes the preview re-seeked the freshly rebuilt GSAP timeline using the iframe's raw __player.getTime(), which can lag the studio's authoritative currentTime right after a keyframe drag parks the playhead. The stale seek left the element (and its selection/motion-path overlay) rendered at an unrelated position after the edit. applySoftReload now takes the caller's currentTime instead of trusting the iframe's own clock, and the re-seek runs before __hfForceTimelineRebind so its internal force-render picks up the correct time.
This commit is contained in:
@@ -86,7 +86,7 @@ describe("applyPreviewSync", () => {
|
||||
|
||||
// reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
|
||||
// CDN load failure escalates to a full reload — but it is NOT called eagerly.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// A successful instant patch is the fast path; here it missed → fallback event.
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -113,7 +113,7 @@ describe("applyPreviewSync", () => {
|
||||
|
||||
// U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
|
||||
// is correct, so we must NOT escalate to a full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// Telemetry records the suppressed transient (escalated: false).
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -143,7 +143,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// Structural failure: the preview is genuinely stale/broken → full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -167,7 +167,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// "applied" emits no telemetry (only the failure paths do).
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
@@ -185,7 +185,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// onAsyncFailure is wired, but the transient result does not trigger it.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -204,7 +204,7 @@ describe("applyPreviewSync", () => {
|
||||
reloadPreview,
|
||||
);
|
||||
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -345,7 +345,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -360,7 +360,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
|
||||
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
@@ -66,7 +67,11 @@ function softReloadOrEscalate(
|
||||
reloadPreview: () => void,
|
||||
origin: "preview_sync" | "sdk_refresh",
|
||||
): void {
|
||||
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview);
|
||||
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
|
||||
// not the iframe's raw `__player.getTime()` — see the comment in
|
||||
// applySoftReload for why the two can desync after a keyframe-node drag.
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime);
|
||||
if (result === "applied") return;
|
||||
trackStudioEvent("gsap_soft_reload_outcome", {
|
||||
origin,
|
||||
|
||||
@@ -100,6 +100,18 @@ describe("applySoftReload", () => {
|
||||
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeks to the caller-supplied currentTime override instead of the iframe's own __player.getTime()", () => {
|
||||
// Regression: the iframe's raw __player.getTime() (2.0 here, per the mock)
|
||||
// can desync from the studio's authoritative scrub position — e.g. a
|
||||
// keyframe-node drag parks the playhead via the store before this reload's
|
||||
// async commit resolves. The rebuilt timeline must re-seek to the caller's
|
||||
// value, not the iframe's possibly-stale one.
|
||||
const { iframe, contentWindow } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
|
||||
expect(result).toBe("applied");
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => {
|
||||
// Repro: an element dragged via gsap.set whose keyframes were then removed is
|
||||
// no longer a timeline child, so the timeline-children sweep misses it. Its
|
||||
|
||||
@@ -175,6 +175,7 @@ export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
onAsyncFailure?: () => void,
|
||||
currentTimeOverride?: number,
|
||||
): SoftReloadResult {
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
@@ -210,7 +211,14 @@ export function applySoftReload(
|
||||
// rather than killing the target timeline and appending an orphan script.
|
||||
if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload";
|
||||
|
||||
const currentTime = win.__player?.getTime?.() ?? 0;
|
||||
// Prefer the caller-supplied scrub position (the studio's own authoritative
|
||||
// currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`:
|
||||
// the two can desync (a keyframe-node drag parks the playhead via the store
|
||||
// BEFORE this reload's async commit resolves, and the iframe's own GSAP clock
|
||||
// doesn't reliably reflect that yet), which re-seeks the freshly rebuilt
|
||||
// timeline to the wrong frame and leaves the element (and its overlay)
|
||||
// rendered at a stale/unrelated position.
|
||||
const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0;
|
||||
|
||||
// Track whether the MotionPath async path was taken. When it is, the script
|
||||
// executes inside pluginScript.onload — after applySoftReload has already
|
||||
@@ -300,8 +308,13 @@ export function applySoftReload(
|
||||
const s = doc.createElement("script");
|
||||
s.textContent = `(function(){${scriptText}\n})();`;
|
||||
doc.body.appendChild(s);
|
||||
win.__hfForceTimelineRebind?.();
|
||||
// Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
||||
// (see init.ts) renders the freshly-created timeline at whatever the
|
||||
// runtime's internal scrub position already is, not at whatever we pass
|
||||
// here afterward — a redundant seek() call after rebind can be a GSAP
|
||||
// no-op if the timeline already reports being at that time internally.
|
||||
win.__player?.seek?.(currentTime);
|
||||
win.__hfForceTimelineRebind?.();
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user