mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): address review findings on graded-element editing
Review follow-ups (both reviewers, all findings): - resize captures scope to the resize group: convert-to-keyframes resolvedFromValues and the whole-offset backfill pass the group filter, so an opacity-touching intro tween can't ride into a converted scale tween (the rotation fix's contract, now uniform across intercepts) - commitStaticSet resolves every group's target set BEFORE committing and coalesces groups landing on the same legacy mixed set into one commit — the second commit can no longer chase a stale group-derived id - installAuthoredOpacityCapture also stamps an element the moment it GAINS data-color-grading at runtime (attributeFilter), not just at insertion - both writer twins now share the same emitted-set dedupe shape - applySoftReload's positional tail becomes a SoftReloadOptions object - readAllAnimatedProperties builds the group-filtered key set immutably instead of deleting from the set mid-iteration - applyAuthoredInlineOpacity documents the priority-lossy round-trip - the marquee hit-test reads activeCompositionPathRef like its neighbors New tests: resize intercept (scale route + group filter + non-uniform longhands), after-write-HTML / stamp / empty-stamp opacity restore, the no-op-commit-with-missed-instant-patch soft-reload contract, and the runtime-gained-grading stamp.
This commit is contained in:
@@ -643,4 +643,26 @@ describe("installAuthoredOpacityCapture", () => {
|
||||
expect(el.getAttribute("data-hf-authored-opacity")).toBe("");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("stamps an already-inserted element the moment it GAINS grading at runtime", async () => {
|
||||
installAuthoredOpacityCapture();
|
||||
const el = document.createElement("img");
|
||||
el.style.opacity = "0.9";
|
||||
document.body.appendChild(el);
|
||||
await Promise.resolve();
|
||||
expect(el.hasAttribute("data-hf-authored-opacity")).toBe(false);
|
||||
|
||||
// Studio applies a preset to a previously ungraded element — no re-insert.
|
||||
el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.5 } }));
|
||||
await Promise.resolve();
|
||||
expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
|
||||
|
||||
// Later attribute rewrites (preset tweaks) never overwrite the stamp,
|
||||
// even if a transient is live by then.
|
||||
el.style.opacity = "0";
|
||||
el.setAttribute(HF_COLOR_GRADING_ATTR, serializeHfColorGrading({ adjust: { exposure: 0.9 } }));
|
||||
await Promise.resolve();
|
||||
expect(el.getAttribute("data-hf-authored-opacity")).toBe("0.9");
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,8 +239,21 @@ export function installAuthoredOpacityCapture(): void {
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) scan(node);
|
||||
// An element can also GAIN grading at runtime (studio applies a preset to
|
||||
// a previously ungraded element). Stamp at that moment — strictly earlier
|
||||
// than the engine's hide, so the captured value can never be worse than
|
||||
// the hide-time fallback, and after a soft reload's authored restore it
|
||||
// IS the authored value. stamp() is idempotent: an existing stamp wins.
|
||||
if (mutation.type === "attributes" && mutation.target instanceof Element) {
|
||||
if (mutation.target.hasAttribute(HF_COLOR_GRADING_ATTR)) stamp(mutation.target);
|
||||
}
|
||||
}
|
||||
}).observe(root, { childList: true, subtree: true });
|
||||
}).observe(root, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: [HF_COLOR_GRADING_ATTR],
|
||||
});
|
||||
}
|
||||
|
||||
// Map insertion order gives us simple FIFO eviction for authoring sessions that cycle LUTs.
|
||||
|
||||
@@ -57,11 +57,15 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
|
||||
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
|
||||
if (anim.ease) props.ease = anim.ease;
|
||||
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
const emitted = new Set(Object.keys(props));
|
||||
if (anim.extras) {
|
||||
for (const [k, v] of Object.entries(anim.extras)) {
|
||||
// A key carried by both properties and extras (a set's parsed
|
||||
// `immediateRender: true`) must emit once — properties win.
|
||||
if (!(k in props)) entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
||||
// `immediateRender: true`) must emit once — properties win. Same
|
||||
// dedupe shape as the recast twin (gsapParser.ts buildTweenStatementCode).
|
||||
if (emitted.has(k)) continue;
|
||||
emitted.add(k);
|
||||
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
}
|
||||
const objCode = `{ ${entries.join(", ")} }`;
|
||||
|
||||
@@ -315,7 +315,12 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const freshTarget = iframe
|
||||
? getPreviewTargetFromPointer(iframe, event.clientX, event.clientY, activeCompositionPath)
|
||||
? getPreviewTargetFromPointer(
|
||||
iframe,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
activeCompositionPathRef.current,
|
||||
)
|
||||
: null;
|
||||
if (freshTarget) return;
|
||||
const overlayEl = overlayRef.current;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
||||
});
|
||||
|
||||
/**
|
||||
* Scale-route resize: an element whose visual size is driven by a scale-group
|
||||
* tween. The intercept must (a) route the commit through SCALE, never
|
||||
* width/height, and (b) resolve convert-to-keyframes from-values through the
|
||||
* group filter — an opacity-touching intro tween on the same element must not
|
||||
* ride into the converted keyframes (the disappearance bake class).
|
||||
*/
|
||||
function makeGradedElement(): HTMLElement {
|
||||
const el = document.createElement("img");
|
||||
el.id = "clip";
|
||||
el.setAttribute("data-hf-studio-original-width", "640");
|
||||
el.setAttribute("data-hf-studio-original-height", "360");
|
||||
// Grading contract: source hidden, canvas carries effective opacity.
|
||||
el.setAttribute("data-hf-color-grading-source-hidden", "");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "__hf_color_grading_clip";
|
||||
canvas.style.opacity = "0.98";
|
||||
document.body.append(el, canvas);
|
||||
return el;
|
||||
}
|
||||
|
||||
function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
|
||||
// The element's OPACITY intro tween lives on the timeline: unfiltered
|
||||
// capture would pick `opacity` up via the other-tween sweep.
|
||||
const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
|
||||
return {
|
||||
contentWindow: {
|
||||
__timelines: { main: { getChildren: () => [opacityIntro] } },
|
||||
gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
|
||||
},
|
||||
contentDocument: document,
|
||||
} as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function scaleFromTween(): GsapAnimation {
|
||||
return {
|
||||
id: "#clip-from-200-scale",
|
||||
targetSelector: "#clip",
|
||||
propertyGroup: "scale",
|
||||
method: "from",
|
||||
properties: { scale: 0.9 },
|
||||
position: 0.2,
|
||||
resolvedStart: 0.2,
|
||||
duration: 0.8,
|
||||
} as unknown as GsapAnimation;
|
||||
}
|
||||
|
||||
function keyframedScaleFixture(): GsapAnimation {
|
||||
return {
|
||||
...scaleFromTween(),
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { scale: 0.9 } },
|
||||
{ percentage: 100, properties: { scale: 1 } },
|
||||
],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
}
|
||||
|
||||
/** Drive one resize through the intercept, returning every committed mutation. */
|
||||
async function runResize(
|
||||
el: HTMLElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
size: { width: number; height: number },
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const selection = { id: "clip", selector: "#clip", element: el } as unknown as DomEditSelection;
|
||||
usePlayerStore.setState({ currentTime: 0.5 }); // inside the tween's range
|
||||
const committed: Array<Record<string, unknown>> = [];
|
||||
const commitMutation = vi.fn(async (_sel: unknown, mutation: Record<string, unknown>) => {
|
||||
committed.push(mutation);
|
||||
});
|
||||
const handled = await tryGsapResizeIntercept(
|
||||
selection,
|
||||
size,
|
||||
[scaleFromTween()],
|
||||
iframe,
|
||||
commitMutation as never,
|
||||
async () => [keyframedScaleFixture()],
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
return committed;
|
||||
}
|
||||
|
||||
it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
|
||||
const el = makeGradedElement();
|
||||
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
|
||||
// uniform: 800/640 === 450/360
|
||||
const committed = await runResize(el, iframe, { width: 800, height: 450 });
|
||||
|
||||
const convert = committed.find((m) => m.type === "convert-to-keyframes");
|
||||
expect(convert).toBeDefined();
|
||||
const fromValues = convert!.resolvedFromValues as Record<string, number>;
|
||||
// Group filter: the opacity intro tween must NOT leak into the conversion.
|
||||
expect(fromValues).not.toHaveProperty("opacity");
|
||||
expect(fromValues).toHaveProperty("scale");
|
||||
|
||||
// Every committed property is scale-group — the resize never writes
|
||||
// width/height for a scale-driven element (the double-apply bug class).
|
||||
const allProps = committed.flatMap((m) => [
|
||||
...Object.keys((m.properties as Record<string, unknown>) ?? {}),
|
||||
...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
|
||||
]);
|
||||
expect(allProps).not.toContain("width");
|
||||
expect(allProps).not.toContain("height");
|
||||
expect(allProps.some((p) => p === "scale" || p === "scaleX")).toBe(true);
|
||||
});
|
||||
|
||||
it("non-uniform drag commits scaleX/scaleY longhands", async () => {
|
||||
const el = makeGradedElement();
|
||||
const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0 });
|
||||
// scaleX 1.25 vs scaleY 1.0 → non-uniform
|
||||
const committed = await runResize(el, iframe, { width: 800, height: 360 });
|
||||
|
||||
const serialized = JSON.stringify(committed);
|
||||
expect(serialized).toContain("scaleX");
|
||||
expect(serialized).toContain("scaleY");
|
||||
});
|
||||
@@ -106,7 +106,12 @@ export async function tryGsapResizeIntercept(
|
||||
const coalesceKey = `gsap:resize:${anim.id}`;
|
||||
|
||||
const selector = selectorFromSelection(selection);
|
||||
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
||||
// Scope every capture to the resize group — same contract as the rotation
|
||||
// intercept. Unfiltered, an opacity-touching intro tween on the element
|
||||
// would ride into resize conversions/backfills (the Fix-2 bake class).
|
||||
const runtimeProps = selector
|
||||
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
|
||||
: {};
|
||||
|
||||
let resizeProps: Record<string, number>;
|
||||
let scaleDraftEl: HTMLElement | null = null;
|
||||
@@ -254,7 +259,7 @@ export async function tryGsapResizeIntercept(
|
||||
if (newId) anim = { ...anim, id: newId };
|
||||
} else if (!anim.keyframes) {
|
||||
const resolvedFromValues = selector
|
||||
? readAllAnimatedProperties(iframe, selector, anim)
|
||||
? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
|
||||
: undefined;
|
||||
await commitMutation(
|
||||
selection,
|
||||
|
||||
@@ -106,11 +106,9 @@ export function readAllAnimatedProperties(
|
||||
// point of property-group tweens is that a rotation commit never carries
|
||||
// opacity/rotationX/etc. captured from unrelated tweens on the element.
|
||||
const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group;
|
||||
for (const p of propKeys) {
|
||||
if (!inGroup(p)) propKeys.delete(p);
|
||||
}
|
||||
const groupedPropKeys = new Set([...propKeys].filter(inGroup));
|
||||
|
||||
for (const prop of propKeys) {
|
||||
for (const prop of groupedPropKeys) {
|
||||
const val = readLiveGsapValue(gsap, el, prop);
|
||||
if (Number.isFinite(val)) {
|
||||
result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val);
|
||||
@@ -142,7 +140,7 @@ export function readAllAnimatedProperties(
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
for (const p of propKeys) otherTweenProps.delete(p);
|
||||
for (const p of groupedPropKeys) otherTweenProps.delete(p);
|
||||
|
||||
// Tier 1: Transform + visual properties with universal CSS defaults.
|
||||
// Safe to compare against hardcoded values — these are always 0 or 1
|
||||
@@ -174,7 +172,7 @@ export function readAllAnimatedProperties(
|
||||
// Collect all properties that ANY tween on this element explicitly targets.
|
||||
// Only capture baseline values for these — GSAP reports non-default values
|
||||
// (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
|
||||
const allTweenedProps = new Set([...propKeys, ...otherTweenProps]);
|
||||
const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
|
||||
for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
|
||||
if (prop in result) continue;
|
||||
if (!allTweenedProps.has(prop)) continue;
|
||||
|
||||
@@ -186,19 +186,40 @@ async function commitStaticSet(
|
||||
byGroup.set(group, batch);
|
||||
}
|
||||
const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
|
||||
// Resolve every group's target BEFORE committing anything, and coalesce
|
||||
// groups that land on the SAME set into one commit: the `sets` snapshot is
|
||||
// captured once, so if two groups resolved to one legacy mixed set, a first
|
||||
// commit could re-shape it server-side and leave the second chasing a stale
|
||||
// id (404 on legacy pre-split files).
|
||||
const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
|
||||
const newSetBatches: [string, number | string][][] = [];
|
||||
for (const [group, batch] of byGroup) {
|
||||
const existingSet =
|
||||
// A set already dedicated to this group wins; else a mixed set that
|
||||
// already carries a property of this group (merging same-group values
|
||||
// there beats spawning a second writer for the same channel).
|
||||
sets.find((a) => a.propertyGroup === group) ??
|
||||
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group));
|
||||
const existingSet = findGroupOwningSet(sets, group);
|
||||
if (existingSet) {
|
||||
await commitSetProps(selection, existingSet, batch, selector, animations, commit);
|
||||
byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
|
||||
} else {
|
||||
await addGlobalStaticSet(selection, batch, selector, commit);
|
||||
newSetBatches.push(batch);
|
||||
}
|
||||
}
|
||||
for (const [targetSet, batch] of byTargetSet) {
|
||||
await commitSetProps(selection, targetSet, batch, selector, animations, commit);
|
||||
}
|
||||
// Fresh adds don't reshape existing sets, so their ids can't go stale.
|
||||
for (const batch of newSetBatches) {
|
||||
await addGlobalStaticSet(selection, batch, selector, commit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set that owns a property group: one already dedicated to the group wins;
|
||||
* else a mixed set that already carries a property of the group (merging
|
||||
* same-group values there beats spawning a second writer for the channel).
|
||||
*/
|
||||
function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
|
||||
return (
|
||||
sets.find((a) => a.propertyGroup === group) ??
|
||||
sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,14 @@ function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
|
||||
applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
|
||||
}
|
||||
|
||||
function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) {
|
||||
expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", {
|
||||
onAsyncFailure,
|
||||
currentTimeOverride: 0,
|
||||
authoredHtml,
|
||||
});
|
||||
}
|
||||
|
||||
describe("applyPreviewSync", () => {
|
||||
beforeEach(() => {
|
||||
patchRuntimeTweenInPlace.mockReset();
|
||||
@@ -81,13 +89,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,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// A successful instant patch is the fast path; here it missed → fallback event.
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -105,13 +107,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,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// Telemetry records the suppressed transient (escalated: false).
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
@@ -132,13 +128,7 @@ describe("applyPreviewSync", () => {
|
||||
syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
|
||||
|
||||
// Structural failure: the preview is genuinely stale/broken → full reload.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -162,13 +152,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
// "applied" emits no telemetry (only the failure paths do).
|
||||
expect(trackStudioEvent).not.toHaveBeenCalled();
|
||||
@@ -186,13 +170,7 @@ describe("applyPreviewSync", () => {
|
||||
);
|
||||
|
||||
// onAsyncFailure is wired, but the transient result does not trigger it.
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -211,13 +189,7 @@ describe("applyPreviewSync", () => {
|
||||
reloadPreview,
|
||||
);
|
||||
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
reloadPreview,
|
||||
0,
|
||||
undefined,
|
||||
);
|
||||
expectSoftReloadedWith(reloadPreview, undefined);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"gsap_soft_reload_outcome",
|
||||
@@ -330,6 +302,31 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
|
||||
// Server contract: gsap-mutations returns scriptText on EVERY response,
|
||||
// including changed:false — so the fallback re-runs the identical script
|
||||
// ("applied") instead of escalating a genuine no-op to a full reload.
|
||||
patchRuntimeTweenInPlace.mockReturnValue(false);
|
||||
applySoftReload.mockReturnValue("applied");
|
||||
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 } } },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
patchRuntimeTweenInPlace.mockReset();
|
||||
applySoftReload.mockReset();
|
||||
@@ -368,13 +365,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
deps.reloadPreview,
|
||||
0,
|
||||
"AFTER",
|
||||
);
|
||||
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -389,13 +380,7 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
});
|
||||
|
||||
expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
|
||||
expect(applySoftReload).toHaveBeenCalledWith(
|
||||
FAKE_IFRAME,
|
||||
"SCRIPT",
|
||||
deps.reloadPreview,
|
||||
0,
|
||||
"AFTER",
|
||||
);
|
||||
expectSoftReloadedWith(deps.reloadPreview, "AFTER");
|
||||
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,13 +72,11 @@ function softReloadOrEscalate(
|
||||
// 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, {
|
||||
onAsyncFailure: reloadPreview,
|
||||
currentTimeOverride: currentTime,
|
||||
authoredHtml,
|
||||
);
|
||||
});
|
||||
if (result === "applied") return;
|
||||
trackStudioEvent("gsap_soft_reload_outcome", {
|
||||
origin,
|
||||
|
||||
@@ -21,7 +21,14 @@ export function readStampedAuthoredOpacity(element: AttributeReader): string | n
|
||||
return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
|
||||
}
|
||||
|
||||
/** Write an authored inline opacity back: "" removes the property, a value sets it. */
|
||||
/**
|
||||
* Write an authored inline opacity back: "" removes the property, a value sets
|
||||
* it. Priority-lossy by design: the capture reads `style.opacity` (value only)
|
||||
* and the write sets no priority, so an authored `opacity: X !important`
|
||||
* round-trips as `opacity: X`. The only `!important` opacity in the pipeline
|
||||
* is the color-grading runtime hide — a transient this contract exists to
|
||||
* discard — and authored compositions don't `!important` their opacity.
|
||||
*/
|
||||
export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
|
||||
if (authored === "") style.removeProperty("opacity");
|
||||
else style.setProperty("opacity", authored);
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
|
||||
// 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);
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
|
||||
expect(result).toBe("applied");
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
|
||||
});
|
||||
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
|
||||
(iframe.contentDocument as unknown as { head: unknown }).head = head;
|
||||
|
||||
const onAsyncFailure = vi.fn();
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
|
||||
const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
|
||||
|
||||
// Optimistically "applied" (script will run once the plugin loads) — and the
|
||||
// script has NOT executed yet, so the timeline isn't rebound synchronously.
|
||||
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
|
||||
expect(appendedScripts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// The authored-opacity restore: before the script re-runs (and its tweens
|
||||
// re-capture bounds), every animated element's inline opacity must be put back
|
||||
// to its AUTHORED value — from the after-write file HTML when provided, else
|
||||
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
||||
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
||||
describe("applySoftReload authored-opacity restore", () => {
|
||||
function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
||||
const tl = {
|
||||
kill: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
getChildren: () => [{ targets: () => [el] }],
|
||||
};
|
||||
const contentWindow = {
|
||||
gsap: { timeline: vi.fn(), set: vi.fn() },
|
||||
__hfForceTimelineRebind: vi.fn(),
|
||||
__timelines: { root: tl } as Record<string, unknown>,
|
||||
__player: { getTime: () => 2.0, seek: vi.fn() },
|
||||
__hfStudioManualEditsApply: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(scriptEl);
|
||||
// Intercept only POST-SETUP appends: simulate the re-run script
|
||||
// repopulating __timelines (as in buildMockIframe).
|
||||
const realAppendChild = container.appendChild.bind(container);
|
||||
container.appendChild = <T extends Node>(node: T): T => {
|
||||
const result = realAppendChild(node);
|
||||
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
|
||||
contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
head: document.createElement("div"),
|
||||
};
|
||||
return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
|
||||
}
|
||||
|
||||
/** Run one restore cycle over `el` and return the final inline opacity. */
|
||||
function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
|
||||
const { iframe } = buildIframeWithTarget(el);
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
|
||||
"applied",
|
||||
);
|
||||
return el.style.getPropertyValue("opacity");
|
||||
}
|
||||
|
||||
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-id", "hf-1");
|
||||
el.style.setProperty("opacity", "0", "important"); // the grading hide
|
||||
|
||||
const opacity = restoreOpacity(
|
||||
el,
|
||||
'<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
|
||||
);
|
||||
|
||||
expect(opacity).toBe("0.98");
|
||||
expect(el.style.getPropertyPriority("opacity")).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-authored-opacity", "0.75");
|
||||
el.style.opacity = "0.123"; // mid-flight tween transient
|
||||
|
||||
expect(restoreOpacity(el)).toBe("0.75");
|
||||
});
|
||||
|
||||
it("an empty stamp (authored none) removes the inline opacity", () => {
|
||||
const el = document.createElement("img");
|
||||
el.setAttribute("data-hf-authored-opacity", "");
|
||||
el.style.opacity = "0";
|
||||
|
||||
expect(restoreOpacity(el)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,13 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
|
||||
* caller should perform a full reload to recover. It never fires on the
|
||||
* synchronous paths.
|
||||
*/
|
||||
export interface SoftReloadOptions {
|
||||
/** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
|
||||
onAsyncFailure?: () => void;
|
||||
/** Seek target for the rebuilt timeline; defaults to the iframe player time. */
|
||||
currentTimeOverride?: number;
|
||||
/** After-write file HTML — the primary source for authored-opacity restore. */
|
||||
authoredHtml?: string;
|
||||
}
|
||||
|
||||
export function applySoftReload(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
scriptText: string,
|
||||
onAsyncFailure?: () => void,
|
||||
currentTimeOverride?: number,
|
||||
authoredHtml?: string,
|
||||
options: SoftReloadOptions = {},
|
||||
): SoftReloadResult {
|
||||
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
||||
if (!iframe || !scriptText) return "cannot-soft-reload";
|
||||
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
|
||||
Reference in New Issue
Block a user