mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
/**
|
|
* Patch ONE tween's values in the preview iframe's runtime timeline in place, so a
|
|
* value-only manual edit (a `tl.set` position/rotation/scale, or a keyframe
|
|
* value/position change) is reflected INSTANTLY — without re-running the whole
|
|
* composition.
|
|
*
|
|
* Defensive by default: the caller (`runCommit`) falls back to the existing soft
|
|
* reload whenever this returns `false`. It returns `false` (never silently
|
|
* mis-patches) when the tween can't be confidently located or the requested change
|
|
* can't be safely expressed (dynamic/computed values, motionPath arcs, shape
|
|
* mismatch), and never throws.
|
|
*
|
|
* "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,
|
|
type RuntimeTimeline,
|
|
} from "./gsapRuntimeKeyframes";
|
|
|
|
/** Value-only channels a `tl.set(...)` patch may touch. */
|
|
export interface SetPatchProps {
|
|
x?: number;
|
|
y?: number;
|
|
rotation?: number;
|
|
rotationX?: number;
|
|
rotationY?: number;
|
|
rotationZ?: number;
|
|
z?: number;
|
|
transformPerspective?: number;
|
|
scaleX?: number;
|
|
scaleY?: number;
|
|
scale?: number;
|
|
opacity?: number;
|
|
}
|
|
|
|
/** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
|
|
export type KeyframeStep = Record<string, number>;
|
|
|
|
export type RuntimeTweenChange =
|
|
| { kind: "set"; props: SetPatchProps }
|
|
| { kind: "keyframes"; keyframes: KeyframeStep[] }
|
|
// Edit ONE step (at `pct`) of an object-form keyframe tween — the form the studio
|
|
// writes (`keyframes: { "0%": {...} }`). GSAP pre-compiles those into sub-tweens
|
|
// and won't re-read `vars.keyframes` on `invalidate()`, so this REBUILDS the tween
|
|
// (kill + recreate at the same position) instead of mutating it. Lets a design-panel
|
|
// keyframe edit show instantly rather than soft-reloading the iframe (a flash).
|
|
| { kind: "keyframe-rebuild"; pct: number; props: KeyframeStep }
|
|
// Apply a base `gsap.set` value to the element directly (`gsap.set(el, props)`).
|
|
// A base set lives OFF the timeline, so there's no runtime tween to patch — but
|
|
// the element is static on these channels, so setting them immediately reflects
|
|
// the edit with no soft reload (no flash) and leaves no keyframe marker.
|
|
| { kind: "global-set"; props: SetPatchProps };
|
|
|
|
const SET_CHANNELS: Array<keyof SetPatchProps> = [
|
|
"x",
|
|
"y",
|
|
"rotation",
|
|
"rotationX",
|
|
"rotationY",
|
|
"rotationZ",
|
|
"z",
|
|
"transformPerspective",
|
|
"scaleX",
|
|
"scaleY",
|
|
"scale",
|
|
"opacity",
|
|
];
|
|
|
|
type IframeWindow = Window & {
|
|
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
|
gsap?: { set?: (target: Element, vars: Record<string, number>) => void };
|
|
};
|
|
|
|
/**
|
|
* Apply a base `gsap.set` value to the element in the live runtime. Returns `true`
|
|
* if applied. Used for off-timeline static holds (position / 3D transform) — there's
|
|
* no tween to patch, so we set the channels directly. Safe because the element is
|
|
* static on these channels (the caller only uses this for non-animated values).
|
|
*/
|
|
/** The props as finite numbers, or null if any value is non-finite / none present. */
|
|
function finiteNumericProps(props: SetPatchProps): Record<string, number> | null {
|
|
const numeric: Record<string, number> = {};
|
|
for (const [k, v] of Object.entries(props)) {
|
|
if (typeof v !== "number" || !Number.isFinite(v)) return null;
|
|
numeric[k] = v;
|
|
}
|
|
return Object.keys(numeric).length > 0 ? numeric : null;
|
|
}
|
|
|
|
function applyGlobalSet(
|
|
iframe: HTMLIFrameElement,
|
|
selector: string,
|
|
props: SetPatchProps,
|
|
): boolean {
|
|
try {
|
|
const gsapLib = (iframe.contentWindow as IframeWindow | null)?.gsap;
|
|
const el = iframe.contentDocument?.querySelector(selector) ?? null;
|
|
const numeric = finiteNumericProps(props);
|
|
if (!gsapLib?.set || !el || !numeric) return false;
|
|
gsapLib.set(el, numeric);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null {
|
|
try {
|
|
return (iframe.contentWindow as IframeWindow | null)?.__player ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Every step value must be a finite number — string/computed values can't round-trip. */
|
|
function keyframesAreStatic(keyframes: KeyframeStep[]): boolean {
|
|
if (keyframes.length === 0) return false;
|
|
for (const step of keyframes) {
|
|
if (!step || typeof step !== "object") return false;
|
|
for (const v of Object.values(step)) {
|
|
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function patchSet(tween: RuntimeTween, props: SetPatchProps): boolean {
|
|
const vars = tween.vars;
|
|
if (!vars) return false;
|
|
// A `set` carrying a motionPath isn't a plain value set — defer.
|
|
if ("motionPath" in vars) return false;
|
|
let touched = false;
|
|
for (const ch of SET_CHANNELS) {
|
|
const next = props[ch];
|
|
if (next === undefined) continue;
|
|
if (typeof next !== "number" || !Number.isFinite(next)) return false;
|
|
// A string value here is a dynamic/computed GSAP expression (e.g. "+=100",
|
|
// "random(...)"). Overwriting it with a plain number would silently drop the
|
|
// dynamic intent — decline so the caller soft-reloads from the (edited) source.
|
|
if (typeof vars[ch] === "string") return false;
|
|
vars[ch] = next;
|
|
touched = true;
|
|
}
|
|
return touched;
|
|
}
|
|
|
|
function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean {
|
|
const vars = tween.vars;
|
|
if (!vars) return false;
|
|
// motionPath arc tweens express motion as a path, not channel keyframes — defer.
|
|
if ("motionPath" in vars) return false;
|
|
// Only the array-keyframe form is patched in place; the existing tween must
|
|
// already carry array keyframes (shape match), or this is a structural change.
|
|
if (!Array.isArray(vars.keyframes)) return false;
|
|
if (!keyframesAreStatic(keyframes)) return false;
|
|
vars.keyframes = keyframes.map((step) => ({ ...step }));
|
|
return true;
|
|
}
|
|
|
|
/** The object-form keyframes map with `props` merged into the step at `pct`. */
|
|
function mergeKeyframeStep(
|
|
map: Record<string, Record<string, number>>,
|
|
pct: number,
|
|
props: KeyframeStep,
|
|
): Record<string, Record<string, number>> {
|
|
const next: Record<string, Record<string, number>> = {};
|
|
for (const [k, step] of Object.entries(map)) next[k] = { ...step };
|
|
// Match the existing percentage key numerically ("50%" ≡ pct 50), else add one.
|
|
let key: string | null = null;
|
|
for (const k of Object.keys(next)) {
|
|
const n = parseFloat(k);
|
|
if (Number.isFinite(n) && Math.abs(n - pct) < 0.05) {
|
|
key = k;
|
|
break;
|
|
}
|
|
}
|
|
if (key === null) key = `${pct}%`;
|
|
next[key] = { ...(next[key] ?? {}), ...props };
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Rebuild an object-form keyframe tween with `props` merged into the step at `pct`,
|
|
* in place: kill the old tween and recreate it on the SAME parent timeline at the
|
|
* SAME position, with all other vars (duration, ease, repeat, …) preserved. This
|
|
* is the only way to reflect an object-form keyframe edit live — GSAP compiles
|
|
* those keyframes into sub-tweens at creation and ignores later `vars.keyframes`
|
|
* mutations. Declines (→ caller soft-reloads) for array-form, motionPath arcs,
|
|
* non-finite/dynamic values, or a tween whose parent/targets can't be resolved.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
function rebuildKeyframeTween(tween: RuntimeTween, pct: number, props: KeyframeStep): boolean {
|
|
const vars = tween.vars;
|
|
if (!vars || "motionPath" in vars) return false;
|
|
const kf = vars.keyframes;
|
|
if (!kf || typeof kf !== "object" || Array.isArray(kf)) return false;
|
|
for (const v of Object.values(props)) {
|
|
if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
}
|
|
const parent = tween.parent;
|
|
const targets = tween.targets?.();
|
|
if (!parent?.to || !targets || targets.length === 0) return false;
|
|
if (typeof tween.startTime !== "function" || typeof tween.kill !== "function") return false;
|
|
|
|
const next = mergeKeyframeStep(kf as Record<string, Record<string, number>>, pct, props);
|
|
const newVars = { ...vars, keyframes: next };
|
|
const position = tween.startTime();
|
|
tween.kill();
|
|
parent.to(targets, newVars, position);
|
|
return true;
|
|
}
|
|
|
|
/** The channels a change writes, for the resolver to disambiguate co-located tweens. */
|
|
function changeChannels(change: RuntimeTweenChange): string[] | undefined {
|
|
if (change.kind === "set") {
|
|
return Object.keys(change.props).filter(
|
|
(k) => change.props[k as keyof SetPatchProps] !== undefined,
|
|
);
|
|
}
|
|
if (change.kind === "keyframe-rebuild") return Object.keys(change.props);
|
|
return undefined;
|
|
}
|
|
|
|
/** Re-render the timeline at the current playhead after an in-place edit. */
|
|
function seekToCurrent(iframe: HTMLIFrameElement, timeline: RuntimeTimeline): void {
|
|
const player = playerOf(iframe);
|
|
const currentTime =
|
|
typeof player?.getTime === "function"
|
|
? player.getTime()
|
|
: typeof timeline.time === "function"
|
|
? timeline.time()
|
|
: 0;
|
|
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 {
|
|
if (change.kind === "set") return patchSet(tween, change.props);
|
|
if (change.kind === "keyframes") return patchKeyframes(tween, change.keyframes);
|
|
if (change.kind === "keyframe-rebuild")
|
|
return rebuildKeyframeTween(tween, change.pct, change.props);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Edit one tween in `window.__timelines` in place + re-seek to the current playhead.
|
|
* Returns `true` on a confident patch, `false` otherwise (caller soft-reloads).
|
|
*/
|
|
export function patchRuntimeTweenInPlace(
|
|
iframe: HTMLIFrameElement | null,
|
|
selector: string,
|
|
change: RuntimeTweenChange,
|
|
compositionId?: string,
|
|
): boolean {
|
|
if (!iframe) return false;
|
|
// A base `gsap.set` has no timeline tween to resolve — apply the value straight
|
|
// to the element so the edit shows instantly (no soft reload, no flash).
|
|
if (change.kind === "global-set") return applyGlobalSet(iframe, selector, change.props);
|
|
try {
|
|
const resolved = resolveRuntimeTween(
|
|
iframe,
|
|
selector,
|
|
change.kind === "set" ? "set" : "keyframe",
|
|
compositionId,
|
|
changeChannels(change),
|
|
);
|
|
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?.();
|
|
}
|
|
seekToCurrent(iframe, timeline);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|