mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): stop tween re-inits from baking runtime opacity transients
Editing commits made elements vanish or dim permanently: invalidating the whole timeline (or re-running the composition script on soft reload) made GSAP re-capture tween bounds while runtime transients were live — the grading hide's opacity 0, or a mid-flight tween value — so from()/to() bounds got poisoned and the element rendered invisible from then on. - patch only the edited tween in place, never timeline.invalidate() - soft reload restores every animated element's authored inline opacity (after-write HTML first, parse-time stamp as fallback) before the script re-runs and re-captures - a paired x/y commit whose second half is a no-op (changed=false) still applies its instant patch, so panel edits reflect without deselecting
This commit is contained in:
@@ -133,6 +133,69 @@ describe("patchRuntimeTweenInPlace — set tweens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchRuntimeTweenInPlace — authored-opacity capture guard", () => {
|
||||
function makeStampedEl(id: string, stamped: string | null, inlineOpacity: string) {
|
||||
const style = new Map<string, string>([["opacity", inlineOpacity]]);
|
||||
return {
|
||||
el: {
|
||||
id,
|
||||
style: {
|
||||
setProperty: (k: string, v: string) => void style.set(k, v),
|
||||
removeProperty: (k: string) => void style.delete(k),
|
||||
},
|
||||
getAttribute: (name: string) => (name === "data-hf-authored-opacity" ? stamped : null),
|
||||
},
|
||||
style,
|
||||
};
|
||||
}
|
||||
|
||||
it("restores the stamped authored opacity before an opacity-touching patch", () => {
|
||||
// Runtime transient (grading hide / mid-flight tween) baked into inline style.
|
||||
const { el, style } = makeStampedEl("box", "0.75", "0");
|
||||
const setTween = makeTween(
|
||||
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
|
||||
el,
|
||||
);
|
||||
const { iframe } = fakeIframe(el, [setTween]);
|
||||
|
||||
const ok = patchRuntimeTweenInPlace(iframe, "#box", {
|
||||
kind: "set",
|
||||
props: { opacity: 0.5 },
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
// The re-init must capture the authored 0.75, not the transient 0.
|
||||
expect(style.get("opacity")).toBe("0.75");
|
||||
expect(setTween.vars.opacity).toBe(0.5);
|
||||
});
|
||||
|
||||
it("removes inline opacity when the stamp recorded no authored value", () => {
|
||||
const { el, style } = makeStampedEl("box", "", "0");
|
||||
const setTween = makeTween(
|
||||
{ vars: { opacity: 0.2, duration: 0 }, targetIds: ["box"], duration: 0 },
|
||||
el,
|
||||
);
|
||||
const { iframe } = fakeIframe(el, [setTween]);
|
||||
|
||||
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { opacity: 0.5 } });
|
||||
|
||||
expect(style.has("opacity")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves inline opacity alone for a position-only patch", () => {
|
||||
const { el, style } = makeStampedEl("box", "0.75", "0");
|
||||
const setTween = makeTween(
|
||||
{ vars: { x: 0, y: 0, duration: 0 }, targetIds: ["box"], duration: 0 },
|
||||
el,
|
||||
);
|
||||
const { iframe } = fakeIframe(el, [setTween]);
|
||||
|
||||
patchRuntimeTweenInPlace(iframe, "#box", { kind: "set", props: { x: 10, y: 20 } });
|
||||
|
||||
expect(style.get("opacity")).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
|
||||
it("patches the {x,y} set, not a co-located rotation-only set", () => {
|
||||
const el = { id: "dual" };
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
|
||||
* uses (`resolveRuntimeTween`), so read and write agree on the target.
|
||||
*/
|
||||
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "../utils/authoredOpacity";
|
||||
import {
|
||||
resolveRuntimeTween,
|
||||
type RuntimeTween,
|
||||
@@ -235,6 +236,34 @@ function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): vo
|
||||
player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
|
||||
}
|
||||
|
||||
/** Does this change touch the opacity channel (whose re-init reads inline style)? */
|
||||
function changeTouchesOpacity(change: RuntimeTweenChange): boolean {
|
||||
if (change.kind === "set" || change.kind === "global-set")
|
||||
return change.props.opacity !== undefined;
|
||||
if (change.kind === "keyframes") return change.keyframes.some((step) => "opacity" in step);
|
||||
return "opacity" in change.props;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tween re-initialization (invalidate, or kill+recreate for keyframe-rebuild)
|
||||
* captures opacity from the element's CURRENT inline style — for a color-graded
|
||||
* source (hidden with `opacity: 0 !important`) or a mid-flight tween that's a
|
||||
* runtime transient, not the authored value, and the capture makes it permanent.
|
||||
* Restore the runtime's parse-time authored capture (data-hf-authored-opacity)
|
||||
* first; the re-seek after the patch re-renders the animated value anyway.
|
||||
* Duck-typed (no instanceof): the targets live in the preview iframe's realm.
|
||||
*/
|
||||
function restoreAuthoredOpacityForCapture(tween: RuntimeTween): void {
|
||||
const targets = typeof tween.targets === "function" ? tween.targets() : [];
|
||||
for (const target of targets ?? []) {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el?.style || typeof el.getAttribute !== "function") continue;
|
||||
const authored = readStampedAuthoredOpacity(el);
|
||||
if (authored === null) continue;
|
||||
applyAuthoredInlineOpacity(el.style, authored);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply `change` to the resolved tween. `true` if applied, `false` to soft-reload.
|
||||
* `global-set` is handled before this (no tween) and never reaches here. */
|
||||
function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
|
||||
@@ -270,13 +299,18 @@ export function patchRuntimeTweenInPlace(
|
||||
if (!resolved) return false;
|
||||
const { tween, timeline } = resolved;
|
||||
|
||||
if (changeTouchesOpacity(change)) restoreAuthoredOpacityForCapture(tween);
|
||||
if (!applyChange(tween, change)) return false;
|
||||
|
||||
// A rebuild already recreated the tween; set/keyframes mutate vars in place, so
|
||||
// invalidate to make GSAP re-read them on the next render. Either way, re-seek.
|
||||
// Invalidate ONLY the edited tween — never the whole timeline. A timeline-wide
|
||||
// invalidate re-initializes every from() tween against the CURRENT inline
|
||||
// styles, and the color-grading engine hides its source elements with
|
||||
// `opacity: 0 !important` — so every graded element's from(opacity) re-captures
|
||||
// 0 as its end value and animates 0→0 forever (all graded elements vanish).
|
||||
if (change.kind !== "keyframe-rebuild") {
|
||||
tween.invalidate?.();
|
||||
timeline.invalidate?.();
|
||||
}
|
||||
seekToCurrent(iframe, timeline);
|
||||
return true;
|
||||
|
||||
@@ -38,6 +38,19 @@ function result(over: Partial<MutationResult> = {}): MutationResult {
|
||||
return { ok: true, scriptText: "tl.set('#a',{})", ...over };
|
||||
}
|
||||
|
||||
/** The canonical drag commit options every path-decision test drives with. */
|
||||
function dragOptions() {
|
||||
return {
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } },
|
||||
};
|
||||
}
|
||||
|
||||
function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
|
||||
applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
|
||||
}
|
||||
|
||||
describe("applyPreviewSync", () => {
|
||||
beforeEach(() => {
|
||||
patchRuntimeTweenInPlace.mockReset();
|
||||
@@ -49,16 +62,7 @@ describe("applyPreviewSync", () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
const reloadPreview = vi.fn();
|
||||
|
||||
applyPreviewSync(
|
||||
FAKE_IFRAME,
|
||||
result(),
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
reloadPreview,
|
||||
);
|
||||
syncDragPreview(result(), reloadPreview);
|
||||
|
||||
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
|
||||
kind: "set",
|
||||
@@ -73,20 +77,17 @@ describe("applyPreviewSync", () => {
|
||||
applySoftReload.mockReturnValue("applied");
|
||||
const reloadPreview = vi.fn();
|
||||
|
||||
applyPreviewSync(
|
||||
FAKE_IFRAME,
|
||||
result({ scriptText: "SCRIPT" }),
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
reloadPreview,
|
||||
);
|
||||
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
||||
|
||||
// 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, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// A successful instant patch is the fast path; here it missed → fallback event.
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -100,20 +101,17 @@ describe("applyPreviewSync", () => {
|
||||
applySoftReload.mockReturnValue("verify-failed");
|
||||
const reloadPreview = vi.fn();
|
||||
|
||||
applyPreviewSync(
|
||||
FAKE_IFRAME,
|
||||
result({ scriptText: "SCRIPT" }),
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
reloadPreview,
|
||||
);
|
||||
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
||||
|
||||
// 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, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// Telemetry records the suppressed transient (escalated: false).
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -131,19 +129,16 @@ describe("applyPreviewSync", () => {
|
||||
applySoftReload.mockReturnValue("cannot-soft-reload");
|
||||
const reloadPreview = vi.fn();
|
||||
|
||||
applyPreviewSync(
|
||||
FAKE_IFRAME,
|
||||
result({ scriptText: "SCRIPT" }),
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
reloadPreview,
|
||||
);
|
||||
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
||||
|
||||
// Structural failure: the preview is genuinely stale/broken → full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -167,7 +162,13 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// "applied" emits no telemetry (only the failure paths do).
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
@@ -185,7 +186,13 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// onAsyncFailure is wired, but the transient result does not trigger it.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -204,7 +211,13 @@ describe("applyPreviewSync", () => {
|
||||
reloadPreview,
|
||||
);
|
||||
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -291,6 +304,32 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
||||
}
|
||||
|
||||
describe("runCommit — instantPatch wiring", () => {
|
||||
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
mockFetchResult({ changed: false });
|
||||
const deps = renderCommitHook();
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ type: "update-property", property: "y", value: 311 },
|
||||
{
|
||||
label: "Move layer",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The file already matched (changed:false) but the runtime patch deferred
|
||||
// from the paired first commit must still land.
|
||||
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
|
||||
kind: "set",
|
||||
props: { x: 485, y: 311 },
|
||||
});
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
patchRuntimeTweenInPlace.mockReset();
|
||||
applySoftReload.mockReset();
|
||||
@@ -308,15 +347,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
const deps = renderCommitHook();
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ x: 10 },
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
);
|
||||
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
|
||||
@@ -333,19 +364,17 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
const deps = renderCommitHook();
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ x: 10 },
|
||||
{
|
||||
label: "drag",
|
||||
softReload: true,
|
||||
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
||||
},
|
||||
);
|
||||
await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
deps.reloadPreview,
|
||||
0,
|
||||
"AFTER",
|
||||
);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -360,7 +389,13 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
deps.reloadPreview,
|
||||
0,
|
||||
"AFTER",
|
||||
);
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,12 +66,19 @@ function softReloadOrEscalate(
|
||||
scriptText: string,
|
||||
reloadPreview: () => void,
|
||||
origin: "preview_sync" | "sdk_refresh",
|
||||
authoredHtml?: string,
|
||||
): void {
|
||||
// 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);
|
||||
const result: SoftReloadResult = applySoftReload(
|
||||
iframe,
|
||||
scriptText,
|
||||
reloadPreview,
|
||||
currentTime,
|
||||
authoredHtml,
|
||||
);
|
||||
if (result === "applied") return;
|
||||
trackStudioEvent("gsap_soft_reload_outcome", {
|
||||
origin,
|
||||
@@ -116,7 +123,13 @@ export function applyPreviewSync(
|
||||
// already correct on screen, and a remount re-flashes the WebGL context AND
|
||||
// re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
|
||||
// load failure escalates separately via `onAsyncFailure`.
|
||||
softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync");
|
||||
softReloadOrEscalate(
|
||||
iframe,
|
||||
result.scriptText,
|
||||
reloadPreview,
|
||||
"preview_sync",
|
||||
result.after ?? undefined,
|
||||
);
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
@@ -149,7 +162,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
if (options.skipReload) return;
|
||||
throw error;
|
||||
}
|
||||
if (result.changed === false) return;
|
||||
if (result.changed === false) {
|
||||
// The FILE already matched, but a deferred instant patch may still be
|
||||
// owed to the RUNTIME: paired commits (x with skipReload, then y carrying
|
||||
// the patch for both) rely on the SECOND commit to sync the preview — if
|
||||
// that half happens to be a no-op (a purely-horizontal drag or resize
|
||||
// compensation), returning here would leave the runtime showing the old
|
||||
// value while the file holds the new one. Patching in place is idempotent
|
||||
// when the values truly match everywhere.
|
||||
if (!options.skipReload && options.instantPatch) {
|
||||
applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
|
||||
}
|
||||
return;
|
||||
}
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
if (result.before != null && result.after != null) {
|
||||
await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
|
||||
@@ -196,7 +221,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
// plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
|
||||
// synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
|
||||
// but a "cannot-soft-reload" (structural failure) does.
|
||||
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
|
||||
softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after);
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Authored-opacity contract, studio side. The runtime stamps every graded
|
||||
* element's authored inline opacity at document parse time (see
|
||||
* installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
|
||||
* GSAP re-initialize tweens (soft reload, in-place patches) restores it so
|
||||
* re-captures never bake a runtime transient in as a tween bound.
|
||||
*/
|
||||
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
|
||||
|
||||
interface AttributeReader {
|
||||
getAttribute(name: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stamped authored inline opacity. Three-state:
|
||||
* "0.98" — the authored value; "" — captured, authored none;
|
||||
* null — never captured (unknown).
|
||||
* Duck-typed so iframe-realm elements (no shared HTMLElement) work.
|
||||
*/
|
||||
export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
|
||||
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
|
||||
}
|
||||
|
||||
/** Write an authored inline opacity back: "" removes the property, a value sets it. */
|
||||
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
|
||||
if (authored === "") style.removeProperty("opacity");
|
||||
else style.setProperty("opacity", authored);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
|
||||
* not the studio window. This is the single way studio gesture code touches an
|
||||
* iframe element's GSAP position outside the commit pipeline — the resize
|
||||
* anchor pin (apply + restore) and the post-commit live correction. The commit
|
||||
* pipeline itself stays the owner of persisted values.
|
||||
*/
|
||||
type ElementGsapWindow = Window & {
|
||||
gsap?: {
|
||||
set?: (target: Element, vars: Record<string, number>) => void;
|
||||
getProperty?: (target: Element, prop: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
|
||||
return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
|
||||
}
|
||||
|
||||
/** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
|
||||
export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
|
||||
const gsap = gsapOf(element);
|
||||
if (!gsap?.set) return false;
|
||||
gsap.set(element, { x, y });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The element's GSAP numeric property, or null when unreadable. */
|
||||
export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
|
||||
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
||||
|
||||
type IframeWindow = Window & {
|
||||
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
||||
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
||||
@@ -176,6 +179,7 @@ export function applySoftReload(
|
||||
scriptText: string,
|
||||
onAsyncFailure?: () => void,
|
||||
currentTimeOverride?: number,
|
||||
authoredHtml?: string,
|
||||
): SoftReloadResult {
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
@@ -227,6 +231,36 @@ export function applySoftReload(
|
||||
// full iframe reload that destroys the very WebGL context we're preserving.
|
||||
let deferredToAsync = false;
|
||||
|
||||
// Authored-opacity resolution for the restore loop below. Three-state:
|
||||
// "0.98" — the element's authored inline opacity
|
||||
// "" — resolved, and the element has NO authored inline opacity
|
||||
// null — unknown (no authored HTML supplied, element not found in it,
|
||||
// and no runtime parse-time stamp)
|
||||
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
||||
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
||||
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
||||
let authoredDoc: Document | null | undefined;
|
||||
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
||||
if (authoredDoc === undefined) {
|
||||
try {
|
||||
authoredDoc = authoredHtml
|
||||
? new DOMParser().parseFromString(authoredHtml, "text/html")
|
||||
: null;
|
||||
} catch {
|
||||
authoredDoc = null;
|
||||
}
|
||||
}
|
||||
if (!authoredDoc) return null;
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
|
||||
return el.id ? authoredDoc.getElementById(el.id) : null;
|
||||
};
|
||||
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
||||
const source = findAuthoredSource(el);
|
||||
if (source instanceof HTMLElement) return source.style.opacity;
|
||||
return readStampedAuthoredOpacity(el);
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const doReload = () => {
|
||||
const timelines = win.__timelines;
|
||||
@@ -283,19 +317,37 @@ export function applySoftReload(
|
||||
// nukes the element's CSS base (position, width, height, etc.) from the
|
||||
// HTML `style=""` attribute. Save → clear → restore → strip `transform`.
|
||||
if (allTargets.length > 0 && win.gsap?.set) {
|
||||
const saved: Array<[Element, string]> = [];
|
||||
const saved: Array<[HTMLElement, string]> = [];
|
||||
for (const el of allTargets) {
|
||||
const s = (el as HTMLElement).style;
|
||||
if (s?.cssText != null) saved.push([el, s.cssText]);
|
||||
if (s?.cssText != null) saved.push([el as HTMLElement, s.cssText]);
|
||||
}
|
||||
try {
|
||||
win.gsap.set(allTargets, { clearProps: "all" });
|
||||
} catch {}
|
||||
for (const [el, css] of saved) {
|
||||
const s = (el as HTMLElement).style;
|
||||
if (!s) continue;
|
||||
const s = el.style;
|
||||
s.cssText = css;
|
||||
s.removeProperty("transform");
|
||||
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
||||
// a mid-flight tween's interpolated value, or the color-grading hide
|
||||
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
||||
// against it — a from() captures it as its END, a to() as its START —
|
||||
// turning the transient into the tween's permanent bound (dimmed or
|
||||
// invisible elements). Put the AUTHORED inline opacity back; the seek
|
||||
// below re-renders the correct animated value either way.
|
||||
const authored = readAuthoredOpacity(el);
|
||||
if (authored !== null) {
|
||||
applyAuthoredInlineOpacity(s, authored);
|
||||
} else if (
|
||||
el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
|
||||
s.getPropertyValue("opacity") === "0" &&
|
||||
s.getPropertyPriority("opacity") === "important"
|
||||
) {
|
||||
// Authored value unknown, but this is definitely the grading hide —
|
||||
// never let a from() capture 0; fall back to the CSS cascade.
|
||||
s.removeProperty("opacity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user