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:
Miguel Ángel
2026-06-16 13:14:31 -04:00
committed by GitHub
parent 2798b97ef1
commit b9bd9ed91d
27 changed files with 1770 additions and 245 deletions
@@ -87,6 +87,7 @@ export function StudioRightPanel({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
@@ -215,6 +216,7 @@ export function StudioRightPanel({
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
@@ -18,7 +18,8 @@ import {
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
import { ArcPathControls } from "./ArcPathControls";
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
import { ComputedTweenNotice } from "./ComputedTweenNotice";
import { P } from "./panelTokens";
const BOOLEAN_PROPS = new Set(["visibility"]);
const STRING_PROPS = new Set(["filter", "clipPath"]);
@@ -235,31 +236,9 @@ function parseNumericOrString(raw: string): number | string {
return Number.isFinite(num) ? num : raw;
}
interface AnimationCardProps {
interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
onAddFromProperty?: (animationId: string, property: string) => void;
onRemoveFromProperty?: (animationId: string, property: string) => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
onSetArcPath?: (
animationId: string,
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
) => void;
onUpdateArcSegment?: (
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
}
// fallow-ignore-next-line complexity
@@ -278,6 +257,7 @@ export const AnimationCard = memo(function AnimationCard({
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
@@ -397,6 +377,10 @@ export const AnimationCard = memo(function AnimationCard({
{expanded && (
<div className="pt-2">
<div className="space-y-3">
<ComputedTweenNotice
provenance={animation.provenance}
onUnroll={onUnroll ? () => onUnroll(animation.id) : undefined}
/>
<div className="flex items-start gap-2">
<div className="flex-1">
<p className="text-[10px] leading-relaxed text-neutral-400 italic">{summary}</p>
@@ -0,0 +1,40 @@
import { editabilityForProvenance, type GsapProvenance } from "@hyperframes/core/gsap-parser-acorn";
/**
* Notice shown for computed tweens: helper/loop tweens offer an "unroll to
* edit" action; runtime-computed values point to the Code tab. Literal tweens
* render nothing.
*/
export function ComputedTweenNotice({
provenance,
onUnroll,
}: {
provenance?: GsapProvenance;
onUnroll?: () => void;
}) {
const editability = editabilityForProvenance(provenance);
if (editability === "direct") return null;
if (editability === "source") {
return (
<div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
Computed value edit it in the Code tab.
</div>
);
}
const source = provenance?.fn ? `${provenance.fn}()` : "a loop";
return (
<div className="flex items-center justify-between gap-2 rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
<span>Generated by {source} not directly editable.</span>
{onUnroll && (
<button
type="button"
onClick={onUnroll}
className="flex-shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium text-panel-accent hover:bg-neutral-800"
title="Rewrite the helper/loop into explicit tweens so this keyframe edits directly"
>
Unroll to edit
</button>
)}
</div>
);
}
@@ -1,37 +1,16 @@
import { memo, useState } from "react";
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import { AnimationCard } from "./AnimationCard";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
interface GsapAnimationSectionProps {
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
animations: GsapAnimation[];
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
onAddFromProperty?: (animationId: string, property: string) => void;
onRemoveFromProperty?: (animationId: string, property: string) => void;
onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
onSetArcPath?: (
animationId: string,
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
) => void;
onUpdateArcSegment?: (
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
}
export const GsapAnimationSection = memo(function GsapAnimationSection({
@@ -51,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
}: GsapAnimationSectionProps) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -88,6 +68,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onLivePreviewEnd={onLivePreviewEnd}
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
/>
))}
@@ -74,6 +74,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddGsapAnimation,
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
@@ -531,6 +532,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddAnimation={onAddGsapAnimation}
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
/>
)}
@@ -0,0 +1,33 @@
import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
/**
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
* renders. Extracted so the two prop interfaces don't duplicate the (large)
* signatures the section forwards straight through to the card.
*/
export interface GsapAnimationEditCallbacks {
onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
onUpdateMeta: (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
onDeleteAnimation: (animationId: string) => void;
onAddProperty: (animationId: string, property: string) => void;
onRemoveProperty: (animationId: string, property: string) => void;
onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
onAddFromProperty?: (animationId: string, property: string) => void;
onRemoveFromProperty?: (animationId: string, property: string) => void;
onLivePreview?: (property: string, value: number | string) => void;
onLivePreviewEnd?: () => void;
onSetArcPath?: (
animationId: string,
config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
) => void;
onUpdateArcSegment?: (
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
onUnroll?: (animationId: string) => void;
}
@@ -56,6 +56,8 @@ export interface PropertyPanelProps {
segmentIndex: number,
update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
) => void;
/** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */
onUnroll?: (animationId: string) => void;
onAddKeyframe?: (
animationId: string,
percentage: number,
@@ -56,6 +56,7 @@ export interface DomEditActionsValue extends Pick<
| "commitAnimatedProperty"
| "handleSetArcPath"
| "handleUpdateArcSegment"
| "handleUnroll"
| "invalidateGsapCache"
| "previewIframeRef"
| "commitMutation"
@@ -160,6 +161,7 @@ export function DomEditProvider({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
commitMutation,
@@ -229,6 +231,7 @@ export function DomEditProvider({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
commitMutation: stableCommitMutation,
@@ -284,6 +287,7 @@ export function DomEditProvider({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache,
previewIframeRef,
stableCommitMutation,
+90 -3
View File
@@ -160,14 +160,93 @@ async function commitFlatViaKeyframes(
properties: Record<string, number>,
callbacks: GsapDragCommitCallbacks,
beforeReload?: () => void,
iframe?: HTMLIFrameElement | null,
selector?: string,
): Promise<void> {
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim);
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
// Read the runtime position at the tween's start time so the 0% keyframe
// captures the actual interpolated value (e.g. x=300 after a preceding slide),
// not the identity value (x=0) that a blind convert would produce.
const resolvedFromValues: Record<string, number | string> = {};
if (iframe && selector && ts !== null) {
try {
const iframeWin = iframe.contentWindow as any;
const gsapLib = iframeWin?.gsap;
const el = iframe.contentDocument?.querySelector(selector);
const timelines = iframeWin?.__timelines;
const mainTl = timelines ? (Object.values(timelines)[0] as any) : null;
if (gsapLib && el && mainTl?.seek) {
mainTl.seek(ts);
for (const key of Object.keys(properties)) {
const v = Number(gsapLib.getProperty(el, key));
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
}
mainTl.seek(ct);
}
} catch {
/* iframe access failed — fall back to identity values */
}
}
if (outsideRange && ts !== null) {
// Outside the tween's range: add a brand new keyframed tween at the drag
// time instead of extending/replacing the existing one. This keeps all
// existing tweens untouched and creates a clean hold at the dragged position.
const tweenEnd = ts + td;
const holdStart = ct > tweenEnd ? tweenEnd : ct;
const holdEnd = ct > tweenEnd ? ct : ts;
const holdDur = Math.max(0.01, holdEnd - holdStart);
const kfs =
ct > tweenEnd
? [
{ percentage: 0, properties: resolvedFromValues },
{ percentage: 100, properties },
]
: [
{ percentage: 0, properties },
{ percentage: 100, properties: resolvedFromValues },
];
console.log(
"[drag:5] outside range — adding new tween",
JSON.stringify({
ct,
ts,
td,
holdStart: roundTo3(holdStart),
holdDur: roundTo3(holdDur),
from: resolvedFromValues,
to: properties,
}),
);
await callbacks.commitMutation(
selection,
{
type: "add-with-keyframes",
targetSelector: anim.targetSelector,
position: roundTo3(holdStart),
duration: roundTo3(holdDur),
keyframes: kfs,
},
{ label: "Move layer (new keyframe)", softReload: true, beforeReload },
);
return;
}
// Inside range: convert the flat tween to keyframes, then add at current %.
const coalesceKey = `gsap:convert-drag:${anim.id}`;
await callbacks.commitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{
type: "convert-to-keyframes",
animationId: anim.id,
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
},
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
);
const pct = computeCurrentPercentage(selection, anim);
await callbacks.commitMutation(
@@ -350,6 +429,14 @@ export async function commitGsapPositionFromDrag(
);
}
} else {
await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
await commitFlatViaKeyframes(
selection,
anim,
{ x: newX, y: newY },
callbacks,
restoreOffset,
iframe,
selector,
);
}
}
+70 -19
View File
@@ -73,7 +73,11 @@ function findGsapPositionAnimation(
else if (a.targetSelector.includes(",")) score -= 5;
const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
const dur = a.duration ?? 0;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
else
score -= Math.round(
Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
);
return { anim: a, score };
});
scored.sort((a, b) => b.score - a.score);
@@ -84,6 +88,34 @@ function findGsapPositionAnimation(
// ── Property-group tween resolution ───────────────────────────────────────
/**
* From a set of candidate tweens, pick the one whose time range is closest to
* the current playhead. A tween that *contains* the playhead wins outright;
* otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
* extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
* gesture-recorded tween beats a stub when both are equidistant).
*/
function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
if (anims.length <= 1) return anims[0] ?? null;
const ct = usePlayerStore.getState().currentTime;
return anims.reduce((best, a) => {
const s = resolveTweenStart(a) ?? 0;
const e = s + resolveTweenDuration(a);
const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
const bestS = resolveTweenStart(best) ?? 0;
const bestE = bestS + resolveTweenDuration(best);
const bestDist =
ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
if (dist < bestDist) return a;
if (
dist === bestDist &&
(a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
)
return a;
return best;
});
}
/**
* Find the tween for a given property group, splitting a legacy mixed tween
* if necessary. Returns the resolved animation or null if none exists.
@@ -101,15 +133,10 @@ async function resolveGroupTween(
commitMutation: GsapDragCommitCallbacks["commitMutation"],
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
// 1. Already-split group tween — prefer the one with the most keyframes
// to avoid targeting a stub when a gesture-recorded tween also exists.
// 1. Already-split group tween — pick the one closest to the current
// playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
const groupAnims = animations.filter((a) => a.propertyGroup === group);
const groupAnim =
groupAnims.length > 1
? groupAnims.sort(
(a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
)[0]
: (groupAnims[0] ?? null);
const groupAnim = pickClosestToPlayhead(groupAnims);
if (groupAnim) return { anim: groupAnim, animations };
// 2. Legacy mixed tween — split it, then re-fetch
@@ -171,9 +198,19 @@ export async function tryGsapDragIntercept(
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
): Promise<boolean> {
const selector = selectorFromSelection(selection);
if (!selector) return false;
console.log(
"[drag:4] tryGsapDragIntercept",
JSON.stringify({
sel: selection.id,
selector,
animCount: animations.length,
groups: animations.map((a) => a.propertyGroup).filter(Boolean),
}),
);
if (!selector) {
return false;
}
// Resolve the position-group tween, splitting legacy mixed tweens if needed.
const resolved = await resolveGroupTween(
"position",
animations,
@@ -181,26 +218,40 @@ export async function tryGsapDragIntercept(
commitMutation,
fetchFallbackAnimations,
);
console.log(
"[drag:4] resolveGroupTween('position') →",
resolved
? JSON.stringify({ id: resolved.anim.id, group: resolved.anim.propertyGroup })
: "null",
);
// Fallback: use the legacy scoring heuristic for compositions that don't
// have group-tagged tweens at all (e.g. hand-written scripts).
let posAnim = resolved?.anim ?? null;
if (!posAnim) {
posAnim = findGsapPositionAnimation(animations, selector);
if (!posAnim && fetchFallbackAnimations) {
const fresh = await fetchFallbackAnimations();
posAnim = findGsapPositionAnimation(fresh, selector);
console.log(
"[drag:4] findGsapPositionAnimation (fetched) →",
posAnim ? posAnim.id : "null",
"freshCount:",
fresh.length,
);
}
}
if (!posAnim) return false;
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
if (!posAnim) {
return false;
}
const gsapPos = readGsapPositionFromIframe(iframe, selector);
if (!gsapPos) return false;
if (!gsapPos) {
return false;
}
console.log(
"[drag:4] committing GSAP position drag",
JSON.stringify({ posAnimId: posAnim.id, gsapPos }),
);
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
commitMutation,
fetchAnimations: fetchFallbackAnimations,
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes";
describe("arcPathFromMotionPathValue", () => {
it("builds arc config from object form { path, curviness }", () => {
const arc = arcPathFromMotionPathValue({
path: [
{ x: 0, y: 0 },
{ x: 100, y: -50 },
{ x: 200, y: 0 },
{ x: 300, y: 80 },
],
curviness: 2,
});
expect(arc?.enabled).toBe(true);
expect(arc?.segments).toHaveLength(3); // 4 waypoints → 3 segments
expect(arc?.segments.every((s) => s.curviness === 2)).toBe(true);
});
it("builds arc config from bare array form (default curviness 1)", () => {
const arc = arcPathFromMotionPathValue([
{ x: 0, y: 0 },
{ x: 50, y: 50 },
]);
expect(arc?.enabled).toBe(true);
expect(arc?.segments).toHaveLength(1);
expect(arc?.segments[0]!.curviness).toBe(1);
});
it("carries autoRotate", () => {
const arc = arcPathFromMotionPathValue({
path: [
{ x: 0, y: 0 },
{ x: 10, y: 10 },
],
autoRotate: true,
});
expect(arc?.autoRotate).toBe(true);
});
it("returns undefined for fewer than 2 points, missing path, or string path", () => {
expect(arcPathFromMotionPathValue({ path: [{ x: 0, y: 0 }] })).toBeUndefined();
expect(arcPathFromMotionPathValue({ curviness: 2 })).toBeUndefined();
expect(arcPathFromMotionPathValue({ path: "M0 0 L10 10" })).toBeUndefined();
expect(arcPathFromMotionPathValue(null)).toBeUndefined();
});
});
+200 -125
View File
@@ -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 (0100 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;
}
@@ -334,6 +334,7 @@ export function useDomEditSession({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
commitMutation,
} = useGsapAwareEditing({
domEditSelection,
@@ -420,6 +421,7 @@ export function useDomEditSession({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
invalidateGsapCache: bumpGsapCache,
previewIframeRef,
commitMutation,
@@ -42,7 +42,15 @@ export function useDomGeometryCommits({
}: UseDomGeometryCommitsParams) {
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element);
console.log(
"[drag:7] handleDomPathOffsetCommit (CSS path)",
JSON.stringify({
sel: selection.id,
gsapBlocked,
}),
);
if (gsapBlocked) {
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
showToast(error.message, "error");
return Promise.reject(error);
@@ -98,6 +98,17 @@ export function useGsapAwareEditing({
const handleGsapAwarePathOffsetCommit = useCallback(
async (selection: DomEditSelection, next: { x: number; y: number }) => {
const hasGsapAnims = selectedGsapAnimations.length > 0;
console.log(
"[drag:3] handleGsapAwarePathOffsetCommit",
JSON.stringify({
sel: selection.id,
offset: next,
hasGsapAnims,
interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
animCount: selectedGsapAnimations.length,
animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5),
}),
);
if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
@@ -230,6 +241,15 @@ export function useGsapAwareEditing({
[domEditSelection, gsapCommitMutation],
);
// Unroll all computed (helper/loop) tweens in the active timeline into literal
// tweens, so the clicked keyframe becomes directly editable. Visual no-op.
const handleUnroll = useCallback(() => {
void commitMutation(
{ type: "unroll-timeline" },
{ label: "Unroll to literal tweens", softReload: true },
);
}, [commitMutation]);
return {
handleGsapAwarePathOffsetCommit,
handleGsapAwareBoxSizeCommit,
@@ -237,6 +257,7 @@ export function useGsapAwareEditing({
commitAnimatedProperty,
handleSetArcPath,
handleUpdateArcSegment,
handleUnroll,
commitMutation,
};
}
+49 -7
View File
@@ -211,6 +211,7 @@ export function useGsapAnimationsForElement(
keyframes: runtime.keyframes,
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
},
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
};
});
}
@@ -243,6 +244,7 @@ export function useGsapAnimationsForElement(
keyframes: runtimeEntry.keyframes,
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
},
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
},
];
}
@@ -358,19 +360,30 @@ export function usePopulateKeyframeCacheForFile(
const sf = sourceFile;
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
if (!parsed) {
return;
}
const { setKeyframeCache } = usePlayerStore.getState();
// Drop the file's stale entries (including the bare keys consumers read)
// before repopulating, so an element whose keyframes were removed and is
// absent from this scan doesn't keep showing diamonds.
clearKeyframeCacheForFile(sf);
const { elements } = usePlayerStore.getState();
console.log(
"[kf:static] elements in store:",
elements
.map((e) => e.domId)
.filter(Boolean)
.join(", "),
);
const mergedByElement = new Map<string, GsapKeyframesData>();
for (const anim of parsed.animations) {
const id = extractIdFromSelector(anim.targetSelector);
if (!id) continue;
if (anim.hasUnresolvedKeyframes) {
continue;
}
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
if (!kfData) {
continue;
}
const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1;
@@ -402,6 +415,12 @@ export function usePopulateKeyframeCacheForFile(
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
console.log(
"[kf:static] merged elements:",
[...mergedByElement.keys()].join(", "),
"kf counts:",
[...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
);
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
@@ -428,14 +447,37 @@ export function usePopulateKeyframeCacheForFile(
const iframe =
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
if (!iframe) return false;
const scanned = scanAllRuntimeKeyframes(iframe);
// Clip dims per element so the scan converts tween-relative keyframes to
// clip-relative (matching the static path) instead of timeline-relative.
const clipById = new Map<string, { start: number; duration: number }>();
for (const el of usePlayerStore.getState().elements) {
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
}
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
console.log(
"[kf:runtime] scanned",
scanned.size,
"elements:",
[...scanned.keys()].join(", "),
);
if (scanned.size === 0) return false;
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
for (const [id, data] of scanned) {
const cacheKey = `${sf}#${id}`;
const fallbackKey = `index.html#${id}`;
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
const alreadyCached =
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
if (alreadyCached) {
continue;
}
console.log(
"[kf:runtime] adding runtime entry:",
id,
"kfs:",
data.keyframes.length,
"arc:",
!!data.arcPath,
);
const entry = {
format: "percentage" as const,
keyframes: data.keyframes,