mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate A committed moveElement wrote data-x/data-y but nothing rendered them: hosts shimmed CSS translate, which GSAP folds into the cached transform at first parse and then discards on the animated axis at every seek — dragging an animated element kept only the un-animated axis. Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is never read, folded, or cleared across seeks and composes natively with the animated transform. So: - moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y) - the runtime (new core runtime/positionEdits.ts, applied at timeline bind — after GSAP parse) renders translate = (data-x − base), a pure delta that composes with GSAP tweens, tl.set positions, and CSS alike - applyDraft now drives the drag preview through the same translate channel (the --hf-studio-dx/dy vars had no consumer outside authored Studio bridges), and commitPreview mirrors the committed move onto the live element so it holds without an srcdoc reload Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts (real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated, Y-animated, and static elements hold both edited axes across the full seek range. New subpath export @hyperframes/core/runtime/position-edits. Known limitation (documented): a tween created lazily at runtime that first-parses a marked element after apply folds the edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): harden position-edit rendering and the drag draft channel Fixes six issues from adversarial review of the moveElement stack: - Runtime: apply position edits at init as well as at timeline bind, so committed moves render in compositions with no usable GSAP timeline (CSS/WAAPI-animated or fully static) — previously the apply was unreachable outside the boundDuration > 0 bind branch and the edit silently vanished from reloads and renders. - Runtime: guard bind-path re-apply against post-fold double-apply — if the previously written translate was consumed externally (a lazily created tween folding it into GSAP's cached transform), skip instead of re-setting it on top ({force} escape hatch for editor commits). - Adapter: stop writing the --hf-studio-dx/dy custom properties during drags — compositions with the documented var-consuming drag-bridge CSS moved by twice the pointer delta (var transform + new inline translate). The inline translate is now the only draft channel; deltas accumulate in adapter fields. Docs updated to match. - Adapter: switching applyDraft to a new id reverts the abandoned element's draft translate instead of leaving it displaced with no op. - Adapter: cancelPreview restores the raw inline translate (removing it when there was none), so a stylesheet-authored translate is never promoted to a permanent inline style. - Adapter: commitPreview reverts the draft and clears state when dispatch throws, instead of leaving the element shifted by an uncommitted draft. Cleanups: reuse readCurrentTranslate from the core module (was a verbatim copy), drop the dead __hfApplyPositionEdits window hook. Browser acceptance test now also covers the GSAP-free composition path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry Addresses PR #1875 review feedback (Rames, Miga): - Prime the element's GSAP transform parse (gsap.getProperty) before the first translate apply — positioned tl.set()s and tweens that first RENDER after the apply now reuse the cache instead of folding the edit. This closes the lazy-first-parse fold-loss for any page where GSAP is loaded at apply time; the residual limitation is GSAP itself loading after the apply. Proven by the extended browser acceptance test. - Emit position_edit_fold_skipped analytics at the fold-guard skip site so the residual degradation is observable instead of silent. - Browser acceptance test: add a both-axis-animated element (the shape that originated the per-axis loss) and a positioned tl.set() element, asserted across the full seek range. - Simplify the num() null guard (review nit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a2677ca730
commit
74faa4b2a4
@@ -12,7 +12,8 @@ export type RuntimeAnalyticsEvent =
|
||||
| "composition_paused"
|
||||
| "composition_seeked"
|
||||
| "composition_ended"
|
||||
| "element_picked";
|
||||
| "element_picked"
|
||||
| "position_edit_fold_skipped";
|
||||
|
||||
export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { createClipTree } from "./clipTree";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { applyCaptionOverrides } from "./captionOverrides";
|
||||
import { applyPositionEdits } from "./positionEdits";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
import { TransportClock } from "./clock";
|
||||
import { WebAudioTransport } from "./webAudioTransport";
|
||||
@@ -72,6 +73,12 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
|
||||
|
||||
export function initSandboxRuntimeModular(): void {
|
||||
const state = createRuntimeState();
|
||||
// SDK moveElement edits must render even when no usable GSAP timeline ever
|
||||
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
|
||||
// This runs at DOMContentLoaded, after inline composition scripts have
|
||||
// parsed their tweens, so GSAP (when present) won't fold the translate.
|
||||
// Re-applied on every timeline bind for the rebind/soft-reload paths.
|
||||
applyPositionEdits(document);
|
||||
const exportRenderFps = resolveExportRenderFps();
|
||||
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
|
||||
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
|
||||
@@ -1198,6 +1205,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
// during initial rebind (timing race on first load / soft reload).
|
||||
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
|
||||
if (typeof applyFn === "function") applyFn();
|
||||
|
||||
// SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
|
||||
// CSS translate delta. Must run after the timeline is bound so GSAP has
|
||||
// already parsed the elements — a translate present at first parse gets
|
||||
// folded into the cached transform and lost per-axis on seek.
|
||||
applyPositionEdits(document);
|
||||
}
|
||||
if (resolution.diagnostics) {
|
||||
postRuntimeMessage({
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EDIT_BASE_X_ATTR,
|
||||
EDIT_BASE_Y_ATTR,
|
||||
EDIT_ORIGINAL_TRANSLATE_ATTR,
|
||||
applyPositionEditToElement,
|
||||
applyPositionEdits,
|
||||
composeTranslate,
|
||||
} from "./positionEdits";
|
||||
|
||||
function makeElement(attrs: Record<string, string>, style = ""): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
|
||||
if (style) el.setAttribute("style", style);
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("composeTranslate", () => {
|
||||
it("returns the delta alone when there is no original", () => {
|
||||
expect(composeTranslate("", "10px", "20px")).toBe("10px 20px");
|
||||
expect(composeTranslate("none", "10px", "20px")).toBe("10px 20px");
|
||||
});
|
||||
|
||||
it("adds px values numerically", () => {
|
||||
expect(composeTranslate("5px 6px", "10px", "20px")).toBe("15px 26px");
|
||||
expect(composeTranslate("-5.5px 6px", "10px", "-20px")).toBe("4.5px -14px");
|
||||
});
|
||||
|
||||
it("treats a single-part original as x-only", () => {
|
||||
expect(composeTranslate("5px", "10px", "20px")).toBe("15px 20px");
|
||||
});
|
||||
|
||||
it("falls back to calc() for non-px units and preserves z", () => {
|
||||
expect(composeTranslate("10% 6px", "10px", "20px")).toBe("calc(10% + 10px) 26px");
|
||||
expect(composeTranslate("1px 2px 3px", "10px", "20px")).toBe("11px 22px 3px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPositionEdits", () => {
|
||||
it("ignores unmarked elements", () => {
|
||||
const el = makeElement({ "data-x": "100", "data-y": "50" });
|
||||
expect(applyPositionEdits(document)).toBe(0);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("applies the delta between data-x/y and the captured baseline", () => {
|
||||
const el = makeElement({
|
||||
"data-x": "150",
|
||||
"data-y": "-30",
|
||||
[EDIT_BASE_X_ATTR]: "100",
|
||||
[EDIT_BASE_Y_ATTR]: "20",
|
||||
});
|
||||
expect(applyPositionEdits(document)).toBe(1);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("50px -50px");
|
||||
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("treats missing data-x/y or baseline attributes as 0", () => {
|
||||
const el = makeElement({ "data-x": "40", [EDIT_BASE_X_ATTR]: "0" });
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("40px 0px");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("composes with a pre-existing inline translate and stays idempotent", () => {
|
||||
const el = makeElement(
|
||||
{ "data-x": "10", "data-y": "20", [EDIT_BASE_X_ATTR]: "0", [EDIT_BASE_Y_ATTR]: "0" },
|
||||
"translate: 5px 6px",
|
||||
);
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
|
||||
expect(el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)).toBe("5px 6px");
|
||||
// Second application must not compound.
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("15px 26px");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("recomputes from the same baseline after data-x changes", () => {
|
||||
const el = makeElement({
|
||||
"data-x": "10",
|
||||
"data-y": "0",
|
||||
[EDIT_BASE_X_ATTR]: "0",
|
||||
[EDIT_BASE_Y_ATTR]: "0",
|
||||
});
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
|
||||
el.setAttribute("data-x", "70");
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("70px 0px");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("never re-captures the original translate once set", () => {
|
||||
const el = makeElement({
|
||||
"data-x": "10",
|
||||
"data-y": "0",
|
||||
[EDIT_BASE_X_ATTR]: "0",
|
||||
[EDIT_BASE_Y_ATTR]: "0",
|
||||
[EDIT_ORIGINAL_TRANSLATE_ATTR]: "3px 4px",
|
||||
});
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("13px 4px");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("counts and applies multiple marked elements", () => {
|
||||
const a = makeElement({ "data-x": "1", [EDIT_BASE_X_ATTR]: "0" });
|
||||
const b = makeElement({ "data-y": "2", [EDIT_BASE_Y_ATTR]: "0" });
|
||||
expect(applyPositionEdits(document)).toBe(2);
|
||||
a.remove();
|
||||
b.remove();
|
||||
});
|
||||
|
||||
it("skips re-apply when the written translate was consumed externally (GSAP fold)", () => {
|
||||
const el = makeElement({
|
||||
"data-x": "10",
|
||||
"data-y": "20",
|
||||
[EDIT_BASE_X_ATTR]: "0",
|
||||
[EDIT_BASE_Y_ATTR]: "0",
|
||||
});
|
||||
applyPositionEdits(document);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("10px 20px");
|
||||
// GSAP folding the translate into its cached transform writes "none".
|
||||
el.style.setProperty("translate", "none");
|
||||
applyPositionEdits(document);
|
||||
// Re-setting would double the offset on non-animated axes — must skip.
|
||||
expect(el.style.getPropertyValue("translate")).toBe("none");
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("force re-applies over a clobbered translate (editor commit path)", () => {
|
||||
const el = makeElement({
|
||||
"data-x": "10",
|
||||
"data-y": "20",
|
||||
[EDIT_BASE_X_ATTR]: "0",
|
||||
[EDIT_BASE_Y_ATTR]: "0",
|
||||
});
|
||||
applyPositionEditToElement(el);
|
||||
// A drag draft overwrites the translate; the commit must recompute.
|
||||
el.style.setProperty("translate", "999px 999px");
|
||||
el.setAttribute("data-x", "30");
|
||||
applyPositionEditToElement(el, { force: true });
|
||||
expect(el.style.getPropertyValue("translate")).toBe("30px 20px");
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
// (splitTopLevelWhitespace intentionally mirrors the studio-side copies in
|
||||
// manualEditsDom.ts / manualEditsRenderScript.ts — this module ships inside
|
||||
// the self-contained runtime bundle and cannot import studio code.)
|
||||
/**
|
||||
* Editor position edits (SDK `moveElement`) applied at render time.
|
||||
*
|
||||
* The SDK's `moveElement` writes `data-x` / `data-y` plus a captured baseline
|
||||
* (`data-hf-edit-base-x` / `data-hf-edit-base-y` — the values before the first
|
||||
* edit). The runtime renders the edit as the DELTA between the two, via the
|
||||
* independent CSS `translate` longhand, so it composes additively with any
|
||||
* position the composition itself produces (GSAP tweens, `tl.set`, CSS).
|
||||
*
|
||||
* Why `translate` and why after timeline bind: GSAP folds a `translate` that
|
||||
* is present when it FIRST parses an element into its cached transform (and
|
||||
* an absolute tween then discards it on the animated axis — the per-axis loss
|
||||
* bug). A `translate` set AFTER that parse is never read, cleared, or baked
|
||||
* by GSAP 3.x on subsequent seeks, so a single application at bind time holds
|
||||
* for the whole timeline. Before the first apply, the element's transform
|
||||
* parse is primed (gsap.getProperty) so tweens and positioned set()s that
|
||||
* first RENDER later reuse the cache instead of folding the edit. Known
|
||||
* limitation: if GSAP itself loads only after the apply ran, a later tween's
|
||||
* first parse still folds the edit (the fold guard then skips re-apply and
|
||||
* emits position_edit_fold_skipped instead of double-applying).
|
||||
*/
|
||||
|
||||
import { emitAnalyticsEvent } from "./analytics";
|
||||
|
||||
export const EDIT_BASE_X_ATTR = "data-hf-edit-base-x";
|
||||
export const EDIT_BASE_Y_ATTR = "data-hf-edit-base-y";
|
||||
export const EDIT_ORIGINAL_TRANSLATE_ATTR = "data-hf-edit-original-translate";
|
||||
|
||||
const num = (value: string | null): number => {
|
||||
const n = parseFloat(value ?? "");
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/** Split "10px 20px" / "calc(1px + 2px) 3px" on top-level whitespace only. */
|
||||
const splitTopLevelWhitespace = (value: string): string[] => {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
};
|
||||
|
||||
const PX_VALUE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/;
|
||||
|
||||
/** Sum two lengths — numerically when both are plain px, via calc() otherwise. */
|
||||
const addLengths = (a: string, b: string): string => {
|
||||
if (PX_VALUE.test(a) && PX_VALUE.test(b)) return `${parseFloat(a) + parseFloat(b)}px`;
|
||||
return `calc(${a} + ${b})`;
|
||||
};
|
||||
|
||||
/** Compose the edit delta with the element's pre-edit translate value. */
|
||||
export const composeTranslate = (original: string, x: string, y: string): string => {
|
||||
if (!original || original === "none") return `${x} ${y}`;
|
||||
const [ox, oy, oz] = splitTopLevelWhitespace(original);
|
||||
if (ox === undefined) return `${x} ${y}`;
|
||||
if (oy === undefined) return `${addLengths(ox, x)} ${y}`;
|
||||
const z = oz === undefined ? "" : ` ${oz}`;
|
||||
return `${addLengths(ox, x)} ${addLengths(oy, y)}${z}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force GSAP (when present) to parse and cache the element's transform BEFORE
|
||||
* the edit translate is written. GSAP folds a CSS `translate` it sees at an
|
||||
* element's first parse into its cached transform (losing it per-axis on
|
||||
* absolute tweens); once the cache exists, later tweens and positioned set()s
|
||||
* reuse it and never read the translate again. gsap.getProperty parses
|
||||
* without mutating the element. Best-effort — absent or failing GSAP is fine.
|
||||
*/
|
||||
const primeGsapTransformCache = (el: HTMLElement): void => {
|
||||
try {
|
||||
const view = el.ownerDocument.defaultView as
|
||||
| (Window & { gsap?: { getProperty?: (t: Element, p: string) => unknown } })
|
||||
| null;
|
||||
view?.gsap?.getProperty?.(el, "x");
|
||||
} catch {
|
||||
// parse priming is an optimization, never a requirement
|
||||
}
|
||||
};
|
||||
|
||||
/** The element's effective translate: inline if set, computed otherwise ("" = none). */
|
||||
export const readCurrentTranslate = (el: HTMLElement): string => {
|
||||
const inline = el.style.getPropertyValue("translate").trim();
|
||||
if (inline) return inline === "none" ? "" : inline;
|
||||
try {
|
||||
const view = el.ownerDocument.defaultView;
|
||||
const computed = view ? view.getComputedStyle(el).getPropertyValue("translate").trim() : "";
|
||||
return computed === "none" ? "" : computed;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The translate value this module last wrote per element. When a re-apply
|
||||
* (timeline rebind) finds the element's inline translate no longer matching,
|
||||
* something else consumed it — in practice GSAP folding it into the cached
|
||||
* transform when a lazily-created tween first-parsed the element. Re-setting
|
||||
* it then would DOUBLE the offset on every axis the tween doesn't animate, so
|
||||
* the non-forced path skips instead (degrading to the documented fold-loss).
|
||||
*/
|
||||
const lastAppliedTranslate = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/**
|
||||
* Apply one element's position edit. Idempotent — the pre-edit translate is
|
||||
* captured exactly once (into EDIT_ORIGINAL_TRANSLATE_ATTR, empty string
|
||||
* meaning "none") on first application, and every application recomputes from
|
||||
* that baseline.
|
||||
*
|
||||
* `force` re-applies even when the previously written translate was clobbered
|
||||
* externally — used by editor commits, where the current inline translate is
|
||||
* the draft-composed one and must be overwritten.
|
||||
*/
|
||||
export function applyPositionEditToElement(el: HTMLElement, opts?: { force?: boolean }): void {
|
||||
const previous = lastAppliedTranslate.get(el);
|
||||
if (
|
||||
!opts?.force &&
|
||||
previous !== undefined &&
|
||||
el.style.getPropertyValue("translate") !== previous
|
||||
) {
|
||||
// Observable signal for the documented degradation — without it, a
|
||||
// fold-loss surfaces to users only as "my edit didn't stick".
|
||||
emitAnalyticsEvent("position_edit_fold_skipped", {
|
||||
hfId: el.getAttribute("data-hf-id"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dx = num(el.getAttribute("data-x")) - num(el.getAttribute(EDIT_BASE_X_ATTR));
|
||||
const dy = num(el.getAttribute("data-y")) - num(el.getAttribute(EDIT_BASE_Y_ATTR));
|
||||
if (!el.hasAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR)) {
|
||||
el.setAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR, readCurrentTranslate(el));
|
||||
}
|
||||
if (previous === undefined) primeGsapTransformCache(el);
|
||||
const original = el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) ?? "";
|
||||
const value = composeTranslate(original, `${dx}px`, `${dy}px`);
|
||||
el.style.setProperty("translate", value);
|
||||
lastAppliedTranslate.set(el, el.style.getPropertyValue("translate"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all pending position edits in the document. Returns the number of
|
||||
* elements updated.
|
||||
*/
|
||||
export function applyPositionEdits(doc: Document): number {
|
||||
const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`);
|
||||
let applied = 0;
|
||||
for (let i = 0; i < marked.length; i++) {
|
||||
const el = marked[i];
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
applyPositionEditToElement(el);
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
Reference in New Issue
Block a user