mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix: resolve computed GSAP timelines + drag improvements in Studio (#1506)
* feat(core): add param-substitution utility for GSAP timeline inlining U1: clone + shadow-aware identifier substitution over acorn ESTree, plus provenance tagging and a GsapProvenance type. Foundation for resolving helper/loop-built timelines in the read parser. * feat(core): inline helper-built and bounded-loop GSAP timelines U2: expansion pre-pass that rewrites the analysis AST so a helper called N times, a literal-bounds for-loop, a for-of, or a forEach over an inline array each become concrete per-call/per-iteration tl.* statements with substituted positions and provenance tags. Transitive timeline-building detection, safe declaration dropping, depth/iteration caps; unresolvable constructs untouched. * feat(core): resolve computed GSAP timelines in the read parser U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so helper-built and bounded-loop timelines resolve at true positions with motionPath arcs recognized; each tween carries provenance. Expansion order is stamped so cloned tweens (sharing source loc) sort correctly. Read path only — parseGsapScriptAcornForWrite is untouched, degrades to current behavior on failure. The add-to-basket addCycle case now yields 7 resolved animations. * feat(studio): runtime-authoritative keyframes for dynamic timelines Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes with per-tween timing and converts them to clip-relative when given clip dims, fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath (shared buildArcPath) so the Arc Motion panel activates for data-driven arcs; the cache leaves statically-unresolvable tweens to the runtime scan. Exempts the pre-existing large useGsapTweenCache effects from fallow health (file-level, like files.ts) rather than suppression comments. * feat(studio): surface keyframe editability from provenance U9: editabilityForProvenance(provenance) -> direct|unroll|override (core, re-exported from the acorn subpath). A ComputedTweenNotice component shows an unroll affordance for helper/loop tweens (wired in U10) and an overrides note for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to remove section/card prop duplication. * feat(core): lint understands computed timelines (acorn parser) U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and related findings reflect true resolved positions for computed timelines — and keeps recast out of the lint graph entirely. Literal compositions are unchanged (parity), all 182 lint tests pass. * docs: document the computed-timeline keyframe editing model U8: keyframes.mdx explains that helper/loop/data-built timelines display correctly, and how each is edited — literal (direct), helper/loop (unroll to edit), dynamic (composition overrides). Nothing is permanently locked. * feat: unroll computed timelines into literal tweens (U10) Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved animations back to literal tl.* statements (arc/keyframe-aware) and surgically replaces the top-level helper-call/loop statements that produced them via magic-string, dropping dead helper declarations — a verified visual no-op. Wires an unroll-timeline studio-api mutation and threads onUnroll to the AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited fingerprints shifted from the prop threading. * feat(runtime): declarative keyframe override layer for dynamic tweens (U11) Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies explicit per-tween value overrides to the live timeline (keyed by selector + tween ordinal), invalidating so GSAP re-reads them — the deterministic, render-safe mechanism (preview + headless) for persisting edits to dynamic tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern; wired into runtime init alongside applyCaptionOverrides. * refactor: drop the keyframe override layer; rely on unroll + source Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests): it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely unresolvable dynamic tweens barely exist) and introduced a parallel persistence path outside the composition. The real cases are covered without it — const/variable values resolve statically, helper/loop tweens unroll to literals and then edit in-script (single source of truth). Renames the editability strategy 'override' -> 'source' (edit in the Code tab) and updates the notice + docs accordingly. * fix(studio): drag outside tween range creates new keyframe, picks nearest tween Fixes the GSAP drag intercept to pick the position tween closest to the playhead (not the one with the most keyframes), and when dragging outside all tweens' ranges, creates a brand-new keyframed tween instead of destructively extending/replacing the nearest one. Reads the runtime position at the tween's start time (via iframe seek) so convert-to-keyframes produces correct 0% keyframes that preserve the interpolation from preceding tweens. * fix(studio): drag outside tween range creates new keyframe, picks nearest tween Also reverts all fallow health.ignore additions — pre-existing complexity in touched files is accepted as inherited, not suppressed.
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Read GSAP keyframe data from the live runtime in the preview iframe.
|
||||
* Used to discover dynamic keyframes that the AST parser can't resolve
|
||||
* (loops, variables, computed selectors).
|
||||
* (data-driven loops, fetched values, computed selectors).
|
||||
*
|
||||
* Keyframe percentages returned here are TWEEN-RELATIVE (0–100 within the
|
||||
* tween), matching the static parser. Callers convert to clip-relative via
|
||||
* `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
|
||||
* does that conversion itself when given a `clipById` map.
|
||||
*/
|
||||
import { parsePercentageKeyframes } from "./gsapShared";
|
||||
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
|
||||
interface RuntimeTween {
|
||||
@@ -18,155 +24,224 @@ interface RuntimeTimeline {
|
||||
duration?: () => number;
|
||||
}
|
||||
|
||||
type Pct = { percentage: number; properties: Record<string, number | string> };
|
||||
type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
|
||||
|
||||
export interface RuntimeKeyframeEntry {
|
||||
keyframes: Pct[];
|
||||
easeEach?: string;
|
||||
/** Present when the live tween uses motionPath — drives the Arc Motion panel. */
|
||||
arcPath?: ArcPathConfig;
|
||||
/** Absolute start time of the source tween (seconds). */
|
||||
tweenStart: number;
|
||||
/** Duration of the source tween (seconds). */
|
||||
tweenDuration: number;
|
||||
}
|
||||
|
||||
/** Clip start/duration per element id, to convert tween-relative % to clip-relative. */
|
||||
export type ClipDims = Map<string, { start: number; duration: number }>;
|
||||
|
||||
const FLAT_SKIP_KEYS = new Set([
|
||||
"ease",
|
||||
"duration",
|
||||
"delay",
|
||||
"stagger",
|
||||
"motionPath",
|
||||
"overwrite",
|
||||
"immediateRender",
|
||||
"onComplete",
|
||||
"onUpdate",
|
||||
"onStart",
|
||||
"keyframes",
|
||||
]);
|
||||
|
||||
function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
|
||||
if (!iframe?.contentWindow) return null;
|
||||
try {
|
||||
return (
|
||||
(iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> })
|
||||
.__timelines ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isXY(p: unknown): p is { x: number; y: number } {
|
||||
return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
|
||||
}
|
||||
|
||||
/** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
|
||||
function coordsFromMotionPath(mp: unknown): {
|
||||
coords: Array<{ x: number; y: number }>;
|
||||
curviness: number;
|
||||
autoRotate: boolean | number;
|
||||
isCubic: boolean;
|
||||
} | null {
|
||||
if (!mp || typeof mp !== "object") return null;
|
||||
const obj = mp as Record<string, unknown>;
|
||||
const pathVal = Array.isArray(mp) ? mp : obj.path;
|
||||
if (!Array.isArray(pathVal)) return null;
|
||||
const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
|
||||
if (coords.length < 2) return null;
|
||||
const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
|
||||
const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
|
||||
return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
|
||||
}
|
||||
|
||||
/** Build an arcPath config from a live `vars.motionPath` value. */
|
||||
export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
|
||||
const parsed = coordsFromMotionPath(mp);
|
||||
if (!parsed) return undefined;
|
||||
return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
|
||||
}
|
||||
|
||||
function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (FLAT_SKIP_KEYS.has(k)) continue;
|
||||
if (typeof v === "number") properties[k] = roundTo3(v);
|
||||
else if (typeof v === "string") properties[k] = v;
|
||||
}
|
||||
if (Object.keys(properties).length === 0) return null;
|
||||
return [
|
||||
{ percentage: 0, properties },
|
||||
{ percentage: 100, properties },
|
||||
];
|
||||
}
|
||||
|
||||
/** Tween-relative keyframes + optional arcPath for one live tween, or null. */
|
||||
function readTween(vars: Record<string, unknown>): ReadTween | null {
|
||||
if (vars.keyframes && typeof vars.keyframes === "object") {
|
||||
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
const mp = coordsFromMotionPath(vars.motionPath);
|
||||
if (mp) {
|
||||
const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
|
||||
if (shape) {
|
||||
const n = shape.waypoints.length;
|
||||
const keyframes = shape.waypoints.map((wp, i) => ({
|
||||
percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
|
||||
properties: { x: wp.x, y: wp.y },
|
||||
}));
|
||||
return { keyframes, arcPath: shape.arcPath };
|
||||
}
|
||||
}
|
||||
const flat = flatTweenKeyframes(vars);
|
||||
return flat ? { keyframes: flat } : null;
|
||||
}
|
||||
|
||||
function matchesElement(tween: RuntimeTween, el: Element): boolean {
|
||||
if (!tween.targets) return false;
|
||||
for (const t of tween.targets()) {
|
||||
if (t === el || (el.id && (t as Element).id === el.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
|
||||
const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
||||
const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
||||
return {
|
||||
start: Number.isFinite(rawStart) ? rawStart : 0,
|
||||
duration: Number.isFinite(rawDur) ? rawDur : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
|
||||
* Returns tween-relative percentages; callers convert to clip-relative.
|
||||
*/
|
||||
export function readRuntimeKeyframes(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string,
|
||||
compositionId?: string,
|
||||
): {
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
} | null {
|
||||
if (!iframe?.contentWindow) return null;
|
||||
|
||||
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
||||
try {
|
||||
timelines = (
|
||||
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
||||
).__timelines;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
): ReadTween | null {
|
||||
const timelines = timelinesOf(iframe);
|
||||
if (!timelines) return null;
|
||||
|
||||
const tlId = compositionId || Object.keys(timelines)[0];
|
||||
if (!tlId) return null;
|
||||
const timeline = timelines[tlId];
|
||||
if (!timeline?.getChildren) return null;
|
||||
|
||||
let doc: Document | null = null;
|
||||
let targetEl: Element | null = null;
|
||||
try {
|
||||
doc = iframe.contentDocument;
|
||||
targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!doc) return null;
|
||||
|
||||
const targetEl = doc.querySelector(selector);
|
||||
if (!targetEl) return null;
|
||||
|
||||
for (const tween of timeline.getChildren(true)) {
|
||||
if (!tween.targets || !tween.vars) continue;
|
||||
let matches = false;
|
||||
for (const t of tween.targets()) {
|
||||
if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matches) continue;
|
||||
|
||||
const vars = tween.vars;
|
||||
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
||||
|
||||
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
||||
if (parsed) return parsed;
|
||||
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
||||
const read = readTween(tween.vars);
|
||||
if (read) return read;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
|
||||
string,
|
||||
{
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
}
|
||||
> {
|
||||
const result = new Map<
|
||||
string,
|
||||
{
|
||||
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||
easeEach?: string;
|
||||
}
|
||||
>();
|
||||
if (!iframe?.contentWindow) return result;
|
||||
/** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
|
||||
function toClipRelative(
|
||||
keyframes: Pct[],
|
||||
tweenStart: number,
|
||||
tweenDuration: number,
|
||||
clip: { start: number; duration: number } | undefined,
|
||||
): Pct[] {
|
||||
if (!clip || clip.duration <= 0) return keyframes;
|
||||
return keyframes.map((kf) => {
|
||||
const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
|
||||
return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
|
||||
});
|
||||
}
|
||||
|
||||
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
||||
try {
|
||||
timelines = (
|
||||
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
||||
).__timelines;
|
||||
} catch {
|
||||
return result;
|
||||
function buildEntry(
|
||||
read: ReadTween,
|
||||
start: number,
|
||||
duration: number,
|
||||
clip: { start: number; duration: number } | undefined,
|
||||
): RuntimeKeyframeEntry {
|
||||
return {
|
||||
keyframes: toClipRelative(read.keyframes, start, duration, clip),
|
||||
tweenStart: start,
|
||||
tweenDuration: duration,
|
||||
...(read.easeEach ? { easeEach: read.easeEach } : {}),
|
||||
...(read.arcPath ? { arcPath: read.arcPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one tween's keyframes under each target id (first-tween-per-id wins). */
|
||||
function addScanEntry(
|
||||
result: Map<string, RuntimeKeyframeEntry>,
|
||||
tween: RuntimeTween,
|
||||
clipById?: ClipDims,
|
||||
): void {
|
||||
if (!tween.targets || !tween.vars) return;
|
||||
const read = readTween(tween.vars);
|
||||
if (!read) return;
|
||||
const { start, duration } = tweenTiming(tween);
|
||||
for (const target of tween.targets()) {
|
||||
const id = (target as HTMLElement).id;
|
||||
if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every live tween, grouping keyframes by element id. Percentages are
|
||||
* tween-relative unless `clipById` is supplied, in which case each entry's
|
||||
* keyframes are converted to clip-relative. First keyframe-bearing tween per
|
||||
* element wins (the common single-primary-tween case).
|
||||
*/
|
||||
export function scanAllRuntimeKeyframes(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
clipById?: ClipDims,
|
||||
): Map<string, RuntimeKeyframeEntry> {
|
||||
const result = new Map<string, RuntimeKeyframeEntry>();
|
||||
const timelines = timelinesOf(iframe);
|
||||
if (!timelines) return result;
|
||||
|
||||
for (const timeline of Object.values(timelines)) {
|
||||
if (!timeline?.getChildren) continue;
|
||||
const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
|
||||
|
||||
for (const tween of timeline.getChildren(true)) {
|
||||
if (!tween.targets || !tween.vars) continue;
|
||||
const vars = tween.vars;
|
||||
|
||||
if (vars.keyframes && typeof vars.keyframes === "object") {
|
||||
const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
|
||||
if (parsed) {
|
||||
for (const target of tween.targets()) {
|
||||
const id = (target as HTMLElement).id;
|
||||
if (id && !result.has(id)) {
|
||||
result.set(id, parsed);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat tweens: synthesize start + end keyframe entries
|
||||
if (!tlDuration || tlDuration <= 0) continue;
|
||||
const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
|
||||
if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
|
||||
const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
|
||||
|
||||
const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
|
||||
const endPct =
|
||||
tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
|
||||
const properties: Record<string, number | string> = {};
|
||||
const skip = new Set([
|
||||
"ease",
|
||||
"duration",
|
||||
"delay",
|
||||
"stagger",
|
||||
"motionPath",
|
||||
"overwrite",
|
||||
"immediateRender",
|
||||
"onComplete",
|
||||
"onUpdate",
|
||||
"onStart",
|
||||
]);
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (skip.has(k)) continue;
|
||||
if (typeof v === "number") properties[k] = roundTo3(v);
|
||||
else if (typeof v === "string") properties[k] = v;
|
||||
}
|
||||
if (Object.keys(properties).length === 0) continue;
|
||||
|
||||
for (const target of tween.targets()) {
|
||||
const id = (target as HTMLElement).id;
|
||||
if (!id) continue;
|
||||
const existing = result.get(id);
|
||||
const entries = existing ?? { keyframes: [] };
|
||||
entries.keyframes.push({ percentage: startPct, properties });
|
||||
if (endPct !== startPct) {
|
||||
entries.keyframes.push({ percentage: endPct, properties });
|
||||
}
|
||||
if (!existing) result.set(id, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of result.values()) {
|
||||
entry.keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user