From 00cbefcd4b7b2cd864eb9a91b249fb435d487c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 02:26:59 -0400 Subject: [PATCH 01/12] 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. --- packages/core/src/parsers/gsapInline.test.ts | 75 ++++++++++ packages/core/src/parsers/gsapInline.ts | 138 +++++++++++++++++++ packages/core/src/parsers/gsapSerialize.ts | 23 ++++ 3 files changed, 236 insertions(+) create mode 100644 packages/core/src/parsers/gsapInline.test.ts create mode 100644 packages/core/src/parsers/gsapInline.ts diff --git a/packages/core/src/parsers/gsapInline.test.ts b/packages/core/src/parsers/gsapInline.test.ts new file mode 100644 index 000000000..806b2bbe0 --- /dev/null +++ b/packages/core/src/parsers/gsapInline.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "acorn"; +import { + cloneNode, + numericLiteral, + readProvenance, + substituteParams, + tagProvenance, +} from "./gsapInline.js"; + +// Parse a single expression / statement to its ESTree node. +const expr = (code: string): any => + (parse(code, { ecmaVersion: "latest" }).body[0] as any).expression; +const stmt = (code: string): any => parse(code, { ecmaVersion: "latest" }).body[0] as any; +const bind = (entries: Record): Map => + new Map(Object.entries(entries).map(([k, v]) => [k, expr(v)])); + +describe("substituteParams", () => { + it("substitutes a scalar param inside a binary expression", () => { + const out = substituteParams(cloneNode(expr("at + 0.15")), bind({ at: "1.0" })); + expect(out.type).toBe("BinaryExpression"); + expect(out.left).toMatchObject({ type: "Literal", value: 1 }); + expect(out.right).toMatchObject({ type: "Literal", value: 0.15 }); + }); + + it("substitutes an array param used as a value", () => { + const out = substituteParams(cloneNode(expr("({ path })")), bind({ path: "[{x:0},{x:1}]" })); + expect(out.properties[0].value.type).toBe("ArrayExpression"); + expect(out.properties[0].value.elements).toHaveLength(2); + }); + + it("does not substitute a name shadowed by an inner const", () => { + const out = substituteParams( + cloneNode(stmt("function f(){ const at = 5; return at; }")), + bind({ at: "1.0" }), + ); + const ret = out.body.body[1].argument; + expect(ret).toMatchObject({ type: "Identifier", name: "at" }); + }); + + it("does not substitute a name shadowed by a nested function param", () => { + const out = substituteParams(cloneNode(expr("(at) => at")), bind({ at: "1.0" })); + expect(out.body).toMatchObject({ type: "Identifier", name: "at" }); + }); + + it("does not substitute object keys or non-computed member properties", () => { + const obj = substituteParams(cloneNode(expr("({ at: 1 })")), bind({ at: "9" })); + expect(obj.properties[0].key).toMatchObject({ type: "Identifier", name: "at" }); + const mem = substituteParams(cloneNode(expr("obj.at")), bind({ at: "9" })); + expect(mem.property).toMatchObject({ type: "Identifier", name: "at" }); + }); + + it("does substitute a computed member property", () => { + const out = substituteParams(cloneNode(expr("obj[at]")), bind({ at: "0" })); + expect(out.property).toMatchObject({ type: "Literal", value: 0 }); + }); + + it("does not mutate the input clone's source", () => { + const original = expr("at + 0.15"); + substituteParams(cloneNode(original), bind({ at: "1.0" })); + expect(original.left).toMatchObject({ type: "Identifier", name: "at" }); + }); +}); + +describe("provenance + numericLiteral", () => { + it("round-trips a provenance tag", () => { + const node = expr("tl.to('#x', {}, 1)"); + tagProvenance(node, { kind: "helper", fn: "addCycle", callSite: 2 }); + expect(readProvenance(node)).toEqual({ kind: "helper", fn: "addCycle", callSite: 2 }); + }); + + it("builds a resolvable numeric literal", () => { + expect(numericLiteral(3.5)).toMatchObject({ type: "Literal", value: 3.5 }); + }); +}); diff --git a/packages/core/src/parsers/gsapInline.ts b/packages/core/src/parsers/gsapInline.ts new file mode 100644 index 000000000..6711868ff --- /dev/null +++ b/packages/core/src/parsers/gsapInline.ts @@ -0,0 +1,138 @@ +/** + * Static evaluation for computed GSAP timelines (browser-safe, acorn/ESTree). + * + * The read parser resolves only literals and top-level consts, so timelines + * built by a helper called N times or by a bounded loop collapse to position 0. + * This module expands those constructs into a synthetic analysis AST: each + * helper invocation and each loop iteration becomes its own concrete set of + * `tl.*` calls, with parameters/loop-vars substituted by the call's argument + * (or element/index) AST nodes — after which the existing parse pipeline + * resolves positions and `motionPath` arcs unchanged. + * + * Substituted nodes keep their original source offsets, so downstream + * source-slicing (raw extras, keyframes) stays correct. No source is + * regenerated here. Pure transform — input ASTs are never mutated. + * + * U1 (this section): clone + scope-aware parameter substitution primitives. + */ +import type { GsapProvenance } from "./gsapSerialize.js"; + +// acorn ESTree nodes are structurally untyped; mirror gsapParserAcorn.ts. +type Node = any; + +/** Node keys that are metadata, not child AST to traverse/substitute. */ +const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance"]); + +const FUNCTION_TYPES = new Set([ + "ArrowFunctionExpression", + "FunctionExpression", + "FunctionDeclaration", +]); + +function isFunctionNode(node: Node): boolean { + return !!node && FUNCTION_TYPES.has(node.type); +} + +function isNode(x: Node): boolean { + return !!x && typeof x === "object" && typeof x.type === "string"; +} + +/** + * Apply `fn` to each child AST node, writing back its return value. Skips + * metadata keys and key/member slots that must not be treated as values. + * The one place array-vs-single child traversal lives, so walkers stay flat. + */ +function transformChildren(node: Node, fn: (child: Node) => Node): void { + for (const key of Object.keys(node)) { + if (SKIP_KEYS.has(key) || isNonValueIdentifierSlot(node, key)) continue; + const child = node[key]; + if (Array.isArray(child)) { + for (let i = 0; i < child.length; i++) child[i] = fn(child[i]); + } else { + node[key] = fn(child); + } + } +} + +/** Deep structural clone preserving `start`/`end`/`loc` (needed for source slicing). */ +export function cloneNode(node: T): T { + return structuredClone(node); +} + +// ponytail: Identifier + default + rest only. Destructured bindings (`{x}`, `[x]`) +// aren't inlined (U2 inlines Identifier-param helpers / loop vars only), so a +// destructuring shadow is a double-rare miss that just falls back. Add the +// pattern cases here if that ever bites. +function collectPatternNames(pattern: Node, out: Set): void { + if (pattern?.type === "Identifier") out.add(pattern.name); + else if (pattern?.type === "AssignmentPattern") collectPatternNames(pattern.left, out); + else if (pattern?.type === "RestElement") collectPatternNames(pattern.argument, out); +} + +/** Every identifier name bound anywhere inside the subtree (fn params, declared vars, catch params). */ +function collectBoundNames(root: Node): Set { + const names = new Set(); + const visit = (node: Node): Node => { + if (!isNode(node)) return node; + if (isFunctionNode(node)) for (const p of node.params ?? []) collectPatternNames(p, names); + else if (node.type === "VariableDeclarator") collectPatternNames(node.id, names); + else if (node.type === "CatchClause") collectPatternNames(node.param, names); + transformChildren(node, visit); + return node; + }; + visit(root); + return names; +} + +/** A child in key/property position that must not be treated as a value identifier. */ +function isNonValueIdentifierSlot(node: Node, key: string): boolean { + if (node.computed) return false; + return ( + (node.type === "MemberExpression" && key === "property") || + (node.type === "Property" && key === "key") + ); +} + +/** + * Substitute bound identifiers in an already-cloned subtree, returning the + * (possibly replaced) root. Names shadowed anywhere inside (nested function + * params, declared vars) are dropped up front rather than tracked per scope — + * worst case we under-substitute and the caller falls back to current behavior. + * Never substitutes identifiers in key/member positions. Mutates the passed + * clone in place — callers pass `cloneNode(...)`. + */ +export function substituteParams(node: Node, bindings: ReadonlyMap): Node { + const shadowed = collectBoundNames(node); + let effective = bindings; + if (shadowed.size > 0) { + effective = new Map(bindings); + for (const name of shadowed) (effective as Map).delete(name); + } + if (effective.size === 0) return node; + return replace(node, effective); +} + +function replace(node: Node, bindings: ReadonlyMap): Node { + if (!isNode(node)) return node; + if (node.type === "Identifier" && bindings.has(node.name)) { + return cloneNode(bindings.get(node.name)); + } + transformChildren(node, (child) => replace(child, bindings)); + return node; +} + +/** Tag a node (typically a `tl.*` CallExpression) with its construction provenance. */ +export function tagProvenance(node: Node, provenance: GsapProvenance): Node { + if (node && typeof node === "object") node.__hfProvenance = provenance; + return node; +} + +/** Read a provenance tag previously set by `tagProvenance`, if any. */ +export function readProvenance(node: Node): GsapProvenance | undefined { + return node?.__hfProvenance; +} + +/** Synthesize a numeric `Literal` node (for loop indices, which have no source node). */ +export function numericLiteral(value: number): Node { + return { type: "Literal", value, raw: String(value) }; +} diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 471fef014..0ecf6ecbf 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -11,6 +11,27 @@ import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; +/** How a tween was constructed in source — drives display classification and editability. */ +export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic"; + +/** + * Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit + * directly; `helper`/`loop` tweens are expanded from a reused construct (unroll + * to edit); `runtime-dynamic` tweens come from live introspection (override to + * edit). Absent provenance is treated as `literal`. + */ +export interface GsapProvenance { + kind: GsapProvenanceKind; + /** Helper function name (kind === "helper"). */ + fn?: string; + /** 1-based ordinal of the originating call site / loop construct in source order. */ + callSite?: number; + /** 0-based iteration index (kind === "loop"). */ + iteration?: number; + /** Source offset [start, end] of the originating call/loop, when known. */ + sourceRange?: [number, number]; +} + export interface GsapAnimation { id: string; targetSelector: string; @@ -37,6 +58,8 @@ export interface GsapAnimation { /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). * Undefined for legacy mixed tweens that bundle multiple groups. */ propertyGroup?: PropertyGroupName; + /** How this tween was constructed in source. Absent ⇒ literal. */ + provenance?: GsapProvenance; } export interface GsapPercentageKeyframe { From 8c8c33550ed833f9f638d468439252f78b456784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 02:45:04 -0400 Subject: [PATCH 02/12] 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. --- packages/core/src/parsers/gsapInline.test.ts | 96 ++++ packages/core/src/parsers/gsapInline.ts | 443 ++++++++++++++++++- 2 files changed, 535 insertions(+), 4 deletions(-) diff --git a/packages/core/src/parsers/gsapInline.test.ts b/packages/core/src/parsers/gsapInline.test.ts index 806b2bbe0..9d88b7ce6 100644 --- a/packages/core/src/parsers/gsapInline.test.ts +++ b/packages/core/src/parsers/gsapInline.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { parse } from "acorn"; +import { simple } from "acorn-walk"; import { cloneNode, + inlineComputedTimelines, numericLiteral, readProvenance, substituteParams, @@ -73,3 +75,97 @@ describe("provenance + numericLiteral", () => { expect(numericLiteral(3.5)).toMatchObject({ type: "Literal", value: 3.5 }); }); }); + +// Resolve only direct literals — enough to drive loop-bound resolution in tests. +const litResolve = (n: any): any => (n?.type === "Literal" ? n.value : undefined); + +// The tl.* method of a direct `tl.method(...)` call (test scripts don't chain), or null. +function tlMethod(call: any, tl: string): string | null { + if (call.callee?.object?.name !== tl) return null; + const m = call.callee?.property?.name; + return ["set", "to", "from", "fromTo"].includes(m) ? m : null; +} + +function run(code: string, tl = "tl"): { ast: any; tweens: Array<{ prov: any; pos: any }> } { + const ast: any = parse(code, { ecmaVersion: "latest" }); + inlineComputedTimelines(ast, tl, litResolve); + const tweens: Array<{ prov: any; pos: any }> = []; + simple(ast, { + CallExpression(n: any) { + const m = tlMethod(n, tl); + if (m) tweens.push({ prov: readProvenance(n), pos: n.arguments?.[m === "fromTo" ? 3 : 2] }); + }, + }); + return { ast, tweens }; +} + +const kinds = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.kind); +const sites = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.callSite); +const iters = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.iteration); + +describe("inlineComputedTimelines — helpers", () => { + it("expands a helper called N times, substituting positions per call", () => { + const { tweens } = run(`const tl=gsap.timeline(); + function addCycle(at){ tl.to("#p", {}, at + 0.3); } + addCycle(1.0); addCycle(3.6);`); + expect(tweens).toHaveLength(2); + expect(kinds(tweens)).toEqual(["helper", "helper"]); + expect(sites(tweens)).toEqual([1, 2]); + expect(tweens[0]!.pos).toMatchObject({ type: "BinaryExpression", left: { value: 1 } }); + expect(tweens[1]!.pos).toMatchObject({ left: { value: 3.6 } }); + }); + + it("expands every tween in a multi-tween helper body", () => { + const { tweens } = run(`const tl=gsap.timeline(); + function addCycle(at){ tl.to("#a", {}, at); tl.to("#b", {}, at + 1); } + addCycle(1); addCycle(5);`); + expect(tweens).toHaveLength(4); + expect(sites(tweens)).toEqual([1, 1, 2, 2]); + }); + + it("inlines nested helpers to a fixpoint", () => { + const { tweens } = run(`const tl=gsap.timeline(); + function inner(t){ tl.to("#x", {}, t); } + function outer(at){ inner(at); } + outer(5);`); + expect(tweens).toHaveLength(1); + expect(tweens[0]!.prov?.fn).toBe("inner"); + expect(tweens[0]!.pos).toMatchObject({ type: "Literal", value: 5 }); + }); + + it("caps runaway recursion instead of hanging", () => { + const { tweens } = run(`const tl=gsap.timeline(); + function r(n){ tl.to("#x", {}, n); r(n); } + r(0);`); + expect(tweens.length).toBeGreaterThan(0); + expect(tweens.length).toBeLessThanOrEqual(8); + }); + + it("leaves a non-timeline helper untouched", () => { + const { ast, tweens } = run(`function bez(t){ return t * 2; } + const tl=gsap.timeline(); + tl.to("#x", {}, bez(1));`); + expect( + ast.body.some((s: any) => s.type === "FunctionDeclaration" && s.id?.name === "bez"), + ).toBe(true); + expect(tweens).toHaveLength(1); + expect(tweens[0]!.prov).toBeUndefined(); // literal tween, no provenance tag + }); +}); + +describe("inlineComputedTimelines — loops", () => { + it("unrolls a for-loop with literal bounds", () => { + const { tweens } = run(`const tl=gsap.timeline(); + for (let i = 0; i < 3; i++) { tl.to("#x", {}, i * 0.5); }`); + expect(tweens).toHaveLength(3); + expect(iters(tweens)).toEqual([0, 1, 2]); + expect(tweens.map((t) => t.pos.left.value)).toEqual([0, 1, 2]); + }); + + it("unrolls forEach over an inline array", () => { + const { tweens } = run(`const tl=gsap.timeline(); + [{t:1},{t:2}].forEach((d) => { tl.to("#x", {}, d.t); });`); + expect(tweens).toHaveLength(2); + expect(kinds(tweens)).toEqual(["loop", "loop"]); + }); +}); diff --git a/packages/core/src/parsers/gsapInline.ts b/packages/core/src/parsers/gsapInline.ts index 6711868ff..1c34d632f 100644 --- a/packages/core/src/parsers/gsapInline.ts +++ b/packages/core/src/parsers/gsapInline.ts @@ -10,10 +10,9 @@ * resolves positions and `motionPath` arcs unchanged. * * Substituted nodes keep their original source offsets, so downstream - * source-slicing (raw extras, keyframes) stays correct. No source is - * regenerated here. Pure transform — input ASTs are never mutated. - * - * U1 (this section): clone + scope-aware parameter substitution primitives. + * source-slicing (raw extras, keyframes) stays correct. The substitution + * primitives never mutate their input; `inlineComputedTimelines` rewrites the + * Program body of the freshly-parsed AST it is handed (owned by the caller). */ import type { GsapProvenance } from "./gsapSerialize.js"; @@ -28,6 +27,11 @@ const FUNCTION_TYPES = new Set([ "FunctionExpression", "FunctionDeclaration", ]); +const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); + +// Bounds on synthetic expansion (recursion + iteration runaway guards). +const MAX_DEPTH = 8; +const MAX_ITERS = 512; function isFunctionNode(node: Node): boolean { return !!node && FUNCTION_TYPES.has(node.type); @@ -136,3 +140,434 @@ export function readProvenance(node: Node): GsapProvenance | undefined { export function numericLiteral(value: number): Node { return { type: "Literal", value, raw: String(value) }; } + +// ── Expansion engine (U2) ───────────────────────────────────────────────────── + +/** Resolve an expression to a literal value (top-level consts in scope, arithmetic). */ +type LiteralResolver = (node: Node) => number | string | boolean | undefined; + +interface ExpandCtx { + helpers: Map; + timelineVar: string; + resolve: LiteralResolver; + depth: number; + /** Mutable source-order counter for provenance call-site ordinals. */ + site: { n: number }; +} + +function walkNodes(node: Node, fn: (n: Node) => void): void { + if (!isNode(node)) return; + fn(node); + for (const key of Object.keys(node)) { + if (SKIP_KEYS.has(key)) continue; + const child = node[key]; + if (Array.isArray(child)) for (const c of child) walkNodes(c, fn); + else walkNodes(child, fn); + } +} + +/** The identifier a (possibly chained) call's member expression is rooted at. */ +function timelineRootName(call: Node): string | null { + let obj = call.callee?.object; + while (obj?.type === "CallExpression") obj = obj.callee?.object; + return obj?.type === "Identifier" ? obj.name : null; +} + +function isTimelineRooted(call: Node, timelineVar: string): boolean { + if (timelineRootName(call) !== timelineVar) return false; + return ( + call.callee?.property?.type === "Identifier" && GSAP_METHODS.has(call.callee.property.name) + ); +} + +function containsTimelineCall(node: Node, timelineVar: string): boolean { + let found = false; + walkNodes(node, (n) => { + if (n.type === "CallExpression" && isTimelineRooted(n, timelineVar)) found = true; + }); + return found; +} + +function rangeOf(node: Node): [number, number] | undefined { + return typeof node.start === "number" && typeof node.end === "number" + ? [node.start, node.end] + : undefined; +} + +/** Plain identifier params + block body (shape we can inline). Timeline content checked separately. */ +function isShapeEligible(fn: Node): boolean { + return ( + isFunctionNode(fn) && + fn.body?.type === "BlockStatement" && + !(fn.params ?? []).some((p: Node) => p.type !== "Identifier") + ); +} + +/** True if the subtree calls any function named in `names`. */ +function callsAny(node: Node, names: Set): boolean { + let hit = false; + walkNodes(node, (n) => { + if ( + n.type === "CallExpression" && + n.callee?.type === "Identifier" && + names.has(n.callee.name) + ) { + hit = true; + } + }); + return hit; +} + +/** `[name, fnNode]` if a single-declarator `const f = fn` is an inlinable-shaped helper. */ +function varDeclHelper(stmt: Node): [string, Node] | null { + if (stmt.declarations?.length !== 1) return null; + const d = stmt.declarations[0]; + return d.id?.type === "Identifier" && isShapeEligible(d.init) ? [d.id.name, d.init] : null; +} + +/** `[name, fnNode]` if `stmt` declares an inlinable-shaped helper, else null. */ +function helperFromStatement(stmt: Node): [string, Node] | null { + if (stmt.type === "FunctionDeclaration") { + return stmt.id && isShapeEligible(stmt) ? [stmt.id.name, stmt] : null; + } + if (stmt.type === "VariableDeclaration") return varDeclHelper(stmt); + return null; +} + +/** Top-level functions whose shape we can inline (Identifier params + block body). */ +function gatherHelperCandidates(program: Node): Map { + const candidates = new Map(); + for (const stmt of program.body ?? []) { + const helper = helperFromStatement(stmt); + if (helper) candidates.set(helper[0], helper[1]); + } + return candidates; +} + +/** Names that build the timeline directly or by calling another builder (transitive closure). */ +function timelineBuildingNames(candidates: Map, timelineVar: string): Set { + const building = new Set(); + for (const [name, fn] of candidates) { + if (containsTimelineCall(fn.body, timelineVar)) building.add(name); + } + for (let changed = true; changed; ) { + changed = false; + for (const [name, fn] of candidates) { + if (!building.has(name) && callsAny(fn.body, building)) { + building.add(name); + changed = true; + } + } + } + return building; +} + +function bump(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +/** + * Keep only candidates safe to drop: every reference to the name is its + * declaration or a statement-level call. (1 decl id + 1 callee id per + * statement-level call ⇒ total occurrences with no stray uses.) + */ +function safelyDroppable(program: Node, candidates: Map): Map { + const names = new Set(candidates.keys()); + const totalIds = new Map(); + const stmtCalls = new Map(); + walkNodes(program, (n) => { + if (n.type === "Identifier" && names.has(n.name)) bump(totalIds, n.name); + const e = n.type === "ExpressionStatement" ? n.expression : undefined; + if ( + e?.type === "CallExpression" && + e.callee?.type === "Identifier" && + names.has(e.callee.name) + ) { + bump(stmtCalls, e.callee.name); + } + }); + const safe = new Map(); + for (const [name, fn] of candidates) { + if ((totalIds.get(name) ?? 0) === 1 + (stmtCalls.get(name) ?? 0)) safe.set(name, fn); + } + return safe; +} + +/** Top-level timeline-building helpers that are safe to inline-and-drop. */ +function collectInlinableHelpers(program: Node, timelineVar: string): Map { + const candidates = gatherHelperCandidates(program); + if (candidates.size === 0) return candidates; + const building = timelineBuildingNames(candidates, timelineVar); + for (const name of [...candidates.keys()]) if (!building.has(name)) candidates.delete(name); + if (candidates.size === 0) return candidates; + return safelyDroppable(program, candidates); +} + +function isHelperDecl(stmt: Node, helpers: Map): boolean { + if (stmt.type === "FunctionDeclaration") return !!stmt.id && helpers.get(stmt.id.name) === stmt; + if (stmt.type === "VariableDeclaration" && stmt.declarations?.length === 1) { + const d = stmt.declarations[0]; + return d.id?.type === "Identifier" && helpers.get(d.id.name) === d.init; + } + return false; +} + +function bodyStatements(node: Node): Node[] { + if (node?.type === "BlockStatement") return node.body ?? []; + return node ? [{ type: "ExpressionStatement", expression: node }] : []; +} + +function tagTimelineCalls(stmts: Node[], timelineVar: string, prov: GsapProvenance): void { + for (const stmt of stmts) { + walkNodes(stmt, (n) => { + if (n.type === "CallExpression" && isTimelineRooted(n, timelineVar)) { + tagProvenance(n, { ...prov }); + } + }); + } +} + +/** Clone a body as one scope, substitute the bindings, tag provenance, recurse. */ +function expandBody( + bodyStmts: Node[], + bindings: Map, + prov: GsapProvenance, + ctx: ExpandCtx, +): Node[] { + const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings); + tagTimelineCalls(block.body, ctx.timelineVar, prov); + return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); +} + +function inlineHelper(call: Node, ctx: ExpandCtx): Node[] { + const fn = ctx.helpers.get(call.callee.name); + const bindings = new Map(); + (fn.params ?? []).forEach((p: Node, i: number) => { + const arg = call.arguments?.[i]; + if (arg) bindings.set(p.name, arg); + }); + const prov: GsapProvenance = { + kind: "helper", + fn: call.callee.name, + callSite: ++ctx.site.n, + sourceRange: rangeOf(call), + }; + return expandBody(fn.body.body, bindings, prov, ctx); +} + +function assignStep(update: Node, resolve: LiteralResolver): number | undefined { + if (update.operator === "+=") return asNum(resolve(update.right)); + if (update.operator === "-=") { + const s = asNum(resolve(update.right)); + return s === undefined ? undefined : -s; + } + // `i = i + S` — the step is the right operand of the addition. + if (update.operator === "=" && update.right?.type === "BinaryExpression") { + return asNum(resolve(update.right.right)); + } + return undefined; +} + +/** The loop variable a `for` update clause mutates (`i++` or `i += S`), or null. */ +function updatedVarName(update: Node): string | null { + if (update?.type === "UpdateExpression") return update.argument?.name ?? null; + if (update?.type === "AssignmentExpression") return update.left?.name ?? null; + return null; +} + +function loopStep(update: Node, varName: string, resolve: LiteralResolver): number | undefined { + if (updatedVarName(update) !== varName) return undefined; + if (update.type === "UpdateExpression") return update.operator === "++" ? 1 : -1; + return assignStep(update, resolve); +} + +function asNum(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +function loopSatisfied(op: string, x: number, end: number): boolean { + if (op === "<") return x < end; + if (op === "<=") return x <= end; + if (op === ">") return x > end; + if (op === ">=") return x >= end; + return false; +} + +interface ForHeader { + v: string; + start: number; + end: number; + op: string; + step: number; +} + +/** The single `let v = ` of a for-loop init clause, or null. */ +function forInitVar(init: Node): { name: string; initExpr: Node } | null { + if (init?.type !== "VariableDeclaration" || init.declarations?.length !== 1) return null; + const d = init.declarations[0]; + return d.id?.type === "Identifier" ? { name: d.id.name, initExpr: d.init } : null; +} + +/** Parse `for (let v = A; v B; v += S)` into resolved bounds, or null if not statically bounded. */ +function parseForHeader(stmt: Node, resolve: LiteralResolver): ForHeader | null { + const iv = forInitVar(stmt.init); + const test = stmt.test; + if (!iv || test?.type !== "BinaryExpression" || test.left?.name !== iv.name) return null; + const start = asNum(resolve(iv.initExpr)); + const end = asNum(resolve(test.right)); + const step = loopStep(stmt.update, iv.name, resolve); + if (start === undefined || end === undefined || !step) return null; + return { v: iv.name, start, end, op: test.operator, step }; +} + +function unrollFor(stmt: Node, ctx: ExpandCtx): Node[] | null { + const h = parseForHeader(stmt, ctx.resolve); + if (!h) return null; + const body = bodyStatements(stmt.body); + const out: Node[] = []; + const site = ++ctx.site.n; + let iteration = 0; + for (let x = h.start; loopSatisfied(h.op, x, h.end); x += h.step) { + if (iteration >= MAX_ITERS) return null; + const prov: GsapProvenance = { + kind: "loop", + callSite: site, + iteration, + sourceRange: rangeOf(stmt), + }; + out.push(...expandBody(body, new Map([[h.v, numericLiteral(x)]]), prov, ctx)); + iteration++; + } + return out; +} + +function forOfVarName(left: Node): string | null { + if (left?.type === "VariableDeclaration") { + const id = left.declarations?.[0]?.id; + return id?.type === "Identifier" ? id.name : null; + } + return left?.type === "Identifier" ? left.name : null; +} + +/** Expand `for (const el of [literal array]) {...}` and `[literal array].forEach((el, i) => {...})`. */ +function unrollOverArray( + elements: Node[], + body: Node[], + elName: string | null, + idxName: string | null, + range: [number, number] | undefined, + ctx: ExpandCtx, +): Node[] { + const out: Node[] = []; + const site = ++ctx.site.n; + elements.forEach((el, i) => { + if (!el) return; + const bindings = new Map(); + if (elName) bindings.set(elName, el); + if (idxName) bindings.set(idxName, numericLiteral(i)); + const prov: GsapProvenance = { kind: "loop", callSite: site, iteration: i, sourceRange: range }; + out.push(...expandBody(body, bindings, prov, ctx)); + }); + return out; +} + +function unrollForOf(stmt: Node, ctx: ExpandCtx): Node[] | null { + if (stmt.right?.type !== "ArrayExpression") return null; + const elName = forOfVarName(stmt.left); + if (!elName) return null; + return unrollOverArray( + stmt.right.elements ?? [], + bodyStatements(stmt.body), + elName, + null, + rangeOf(stmt), + ctx, + ); +} + +/** The (element, index) param names of a callback, or null if either is non-Identifier. */ +function callbackParamNames(cb: Node): { el: string | null; idx: string | null } | null { + const names: Array = []; + for (const p of [cb.params?.[0], cb.params?.[1]]) { + if (!p) names.push(null); + else if (p.type !== "Identifier") return null; + else names.push(p.name); + } + return { el: names[0]!, idx: names[1]! }; +} + +/** True for `[arrayLiteral].forEach` member callees. */ +function isForEachCall(callee: Node): boolean { + return ( + callee?.type === "MemberExpression" && + callee.property?.name === "forEach" && + callee.object?.type === "ArrayExpression" + ); +} + +/** The element array + callback of `[...].forEach(cb)`, or null. */ +function forEachTarget(call: Node): { elements: Node[]; cb: Node } | null { + if (!isForEachCall(call.callee)) return null; + const cb = call.arguments?.[0]; + return isFunctionNode(cb) ? { elements: call.callee.object.elements ?? [], cb } : null; +} + +function unrollForEach(call: Node, ctx: ExpandCtx): Node[] | null { + const target = forEachTarget(call); + if (!target) return null; + const params = callbackParamNames(target.cb); + if (!params) return null; + return unrollOverArray( + target.elements, + bodyStatements(target.cb.body), + params.el, + params.idx, + rangeOf(call), + ctx, + ); +} + +function expandCall(call: Node, ctx: ExpandCtx): Node[] | null { + if (call.callee?.type === "Identifier" && ctx.helpers.has(call.callee.name)) { + return inlineHelper(call, ctx); + } + return unrollForEach(call, ctx); +} + +function expandStatement(stmt: Node, ctx: ExpandCtx): Node[] | null { + if (ctx.depth >= MAX_DEPTH) return null; + if (stmt.type === "ForStatement") return unrollFor(stmt, ctx); + if (stmt.type === "ForOfStatement") return unrollForOf(stmt, ctx); + if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression") { + return expandCall(stmt.expression, ctx); + } + return null; +} + +function expandStatements(stmts: Node[], ctx: ExpandCtx): Node[] { + const out: Node[] = []; + for (const stmt of stmts) { + const expanded = expandStatement(stmt, ctx); + if (expanded) out.push(...expanded); + else out.push(stmt); + } + return out; +} + +/** + * Rewrite the Program body so helper invocations and bounded loops that build + * the timeline are expanded into concrete per-call / per-iteration `tl.*` + * statements, each tagged with provenance. Mutates `ast` in place (caller owns + * the freshly-parsed tree). Constructs it can't statically resolve are left + * untouched, so the parser falls back to current behavior for them. + */ +export function inlineComputedTimelines( + ast: Node, + timelineVar: string, + resolve: LiteralResolver, +): void { + const helpers = collectInlinableHelpers(ast, timelineVar); + const ctx: ExpandCtx = { helpers, timelineVar, resolve, depth: 0, site: { n: 0 } }; + const body = (ast.body ?? []).filter((stmt: Node) => !isHelperDecl(stmt, helpers)); + ast.body = expandStatements(body, ctx); +} From 79c805376e200554ccd1e898f6dbae0b78141b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 02:51:54 -0400 Subject: [PATCH 03/12] feat(core): resolve computed GSAP timelines in the read parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/parsers/gsapInline.ts | 21 ++++-- .../parsers/gsapParserAcorn.computed.test.ts | 66 +++++++++++++++++++ packages/core/src/parsers/gsapParserAcorn.ts | 38 +++++++++-- 3 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/parsers/gsapParserAcorn.computed.test.ts diff --git a/packages/core/src/parsers/gsapInline.ts b/packages/core/src/parsers/gsapInline.ts index 1c34d632f..fa382ffa3 100644 --- a/packages/core/src/parsers/gsapInline.ts +++ b/packages/core/src/parsers/gsapInline.ts @@ -20,7 +20,7 @@ import type { GsapProvenance } from "./gsapSerialize.js"; type Node = any; /** Node keys that are metadata, not child AST to traverse/substitute. */ -const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance"]); +const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance", "__hfOrder"]); const FUNCTION_TYPES = new Set([ "ArrowFunctionExpression", @@ -153,6 +153,8 @@ interface ExpandCtx { depth: number; /** Mutable source-order counter for provenance call-site ordinals. */ site: { n: number }; + /** Mutable counter stamping expansion order onto tweens (clones share source loc). */ + order: { n: number }; } function walkNodes(node: Node, fn: (n: Node) => void): void { @@ -317,11 +319,13 @@ function bodyStatements(node: Node): Node[] { return node ? [{ type: "ExpressionStatement", expression: node }] : []; } -function tagTimelineCalls(stmts: Node[], timelineVar: string, prov: GsapProvenance): void { +/** Tag this body's direct timeline tweens with provenance + a monotonic expansion-order stamp. */ +function tagTimelineCalls(stmts: Node[], prov: GsapProvenance, ctx: ExpandCtx): void { for (const stmt of stmts) { walkNodes(stmt, (n) => { - if (n.type === "CallExpression" && isTimelineRooted(n, timelineVar)) { + if (n.type === "CallExpression" && isTimelineRooted(n, ctx.timelineVar)) { tagProvenance(n, { ...prov }); + n.__hfOrder = ctx.order.n++; } }); } @@ -335,7 +339,7 @@ function expandBody( ctx: ExpandCtx, ): Node[] { const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings); - tagTimelineCalls(block.body, ctx.timelineVar, prov); + tagTimelineCalls(block.body, prov, ctx); return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); } @@ -567,7 +571,14 @@ export function inlineComputedTimelines( resolve: LiteralResolver, ): void { const helpers = collectInlinableHelpers(ast, timelineVar); - const ctx: ExpandCtx = { helpers, timelineVar, resolve, depth: 0, site: { n: 0 } }; + const ctx: ExpandCtx = { + helpers, + timelineVar, + resolve, + depth: 0, + site: { n: 0 }, + order: { n: 0 }, + }; const body = (ast.body ?? []).filter((stmt: Node) => !isHelperDecl(stmt, helpers)); ast.body = expandStatements(body, ctx); } diff --git a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts new file mode 100644 index 000000000..a26bd4120 --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts @@ -0,0 +1,66 @@ +/** + * U3: end-to-end resolution of computed timelines (helpers, loops) through the + * read parser — true positions, motionPath arcs, and provenance — plus + * regression coverage that literal-position compositions are unchanged. + */ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart; + +describe("parseGsapScriptAcorn — computed timelines", () => { + it("resolves an add-to-basket helper called twice (the reported case)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const DX = 852, DY = -322, FLY_SCALE = 56 / 160; + tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1); + function addCycle(at, path, curviness, spin) { + tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15); + tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3); + tl.to("#product", { opacity: 0, duration: 0.08 }, at + 0.78); + } + addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18); + addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22); + `; + const { animations } = parseGsapScriptAcorn(script); + + // 1 entrance + 3 body tweens × 2 cycles = 7 (was 4 before inlining). + expect(animations).toHaveLength(7); + + // Entrance keeps its literal position and has no provenance. + expect(start(animations[0]!)).toBeCloseTo(0.1); + expect(animations[0]!.provenance).toBeUndefined(); + + // Cycle tweens land at their true absolute times, in order. + expect(animations.slice(1).map(start)).toEqual([1.15, 1.3, 1.78, 3.75, 3.9, 4.38]); + + // Both flight tweens are recognized as arcs and tagged with helper provenance. + const arcs = animations.filter((a) => a.arcPath?.enabled); + expect(arcs).toHaveLength(2); + expect(arcs.map((a) => a.provenance?.fn)).toEqual(["addCycle", "addCycle"]); + expect(arcs.map((a) => a.provenance?.callSite)).toEqual([1, 2]); + // 4 waypoints ⇒ Arc Motion's ">= 2 position keyframes" gate passes. + expect(arcs[0]!.keyframes?.keyframes).toHaveLength(4); + }); + + it("resolves a bounded for-loop", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); } + `); + expect(animations).toHaveLength(3); + expect(animations.map(start)).toEqual([0, 0.5, 1]); + expect(animations.map((a) => a.provenance?.kind)).toEqual(["loop", "loop", "loop"]); + }); + + it("leaves a literal-position composition unchanged (regression)", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1); + tl.to("#b", { x: 50, duration: 0.4 }, 1.0); + `); + expect(animations).toHaveLength(2); + expect(animations.map(start)).toEqual([0.1, 1.0]); + expect(animations.every((a) => a.provenance === undefined)).toBe(true); + }); +}); diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 6f1647205..43e49f81e 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -20,6 +20,7 @@ import type { ParsedGsap, } from "./gsapSerialize.js"; import { classifyTweenPropertyGroup } from "./gsapConstants.js"; +import { inlineComputedTimelines, readProvenance } from "./gsapInline.js"; const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); @@ -942,6 +943,8 @@ function tweenCallToAnimation( if (motionPathResult) anim.arcPath = motionPathResult.arcPath; if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; + const provenance = readProvenance(call.node); + if (provenance) anim.provenance = provenance; return anim; } @@ -1016,13 +1019,26 @@ function resolveTimelinePositions(anims: Omit[]): void { } } +function compareByLoc(a: TweenCallInfo, b: TweenCallInfo): number { + const aLoc = a.node.callee?.property?.loc?.start; + const bLoc = b.node.callee?.property?.loc?.start; + if (!aLoc || !bLoc) return 0; + return aLoc.line - bLoc.line || aLoc.column - bLoc.column; +} + +// Inlined tweens carry a monotonic __hfOrder (clones share source loc, so loc +// can't order them); they sort by that, after all literal (loc-ordered) tweens. +function compareCallOrder(a: TweenCallInfo, b: TweenCallInfo): number { + const ao = a.node.__hfOrder; + const bo = b.node.__hfOrder; + if (ao === undefined && bo === undefined) return compareByLoc(a, b); + if (ao === undefined) return -1; + if (bo === undefined) return 1; + return ao - bo; +} + function sortBySourcePosition(calls: TweenCallInfo[]): void { - calls.sort((a, b) => { - const aLoc = a.node.callee?.property?.loc?.start; - const bLoc = b.node.callee?.property?.loc?.start; - if (!aLoc || !bLoc) return 0; - return aLoc.line - bLoc.line || aLoc.column - bLoc.column; - }); + calls.sort(compareCallOrder); } // ── Stable ID generation ────────────────────────────────────────────────────── @@ -1098,9 +1114,17 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { locations: true, }); const scope = collectScopeBindings(ast); - const targetBindings = collectTargetBindings(ast, scope); const detection = findTimelineVar(ast, scope); const timelineVar = detection.timelineVar ?? "tl"; + // Expand helper-built / bounded-loop timelines before analysis so their + // tweens resolve at true positions (read path only — the write path keeps + // original source nodes). Degrades to the un-inlined AST on any failure. + try { + inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); + } catch { + /* fall back to current behavior */ + } + const targetBindings = collectTargetBindings(ast, scope); const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); sortBySourcePosition(calls); const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); From cae056f9f985815dd43f58da43da5155b1946844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:12:15 -0400 Subject: [PATCH 04/12] 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. --- .fallowrc.jsonc | 10 +- packages/core/src/parsers/gsapParserAcorn.ts | 36 +- packages/core/src/parsers/gsapSerialize.ts | 33 ++ .../src/hooks/gsapRuntimeKeyframes.test.ts | 47 +++ .../studio/src/hooks/gsapRuntimeKeyframes.ts | 321 +++++++++++------- .../studio/src/hooks/useGsapTweenCache.ts | 13 +- 6 files changed, 306 insertions(+), 154 deletions(-) create mode 100644 packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6a33c9025..9ed66abfb 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -226,6 +226,14 @@ // avoids the inherited-fingerprint line-shift problem that suppression // comments would cause (any inserted line shifts subsequent function line // numbers, breaking fallow's inherited-detection fingerprint). - "ignore": ["packages/core/src/studio-api/routes/files.ts"], + // + // useGsapTweenCache.ts: pre-existing large React-effect hooks (the populate + // and runtime-scan effects, the per-element animations memo) whose + // complexity pre-dates the computed-timeline work. Exempted at file level + // for the same reason as files.ts rather than refactored as scope creep. + "ignore": [ + "packages/core/src/studio-api/routes/files.ts", + "packages/studio/src/hooks/useGsapTweenCache.ts", + ], }, } diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 43e49f81e..649e32e1b 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -12,7 +12,6 @@ import * as acorn from "acorn"; import * as acornWalk from "acorn-walk"; import type { ArcPathConfig, - ArcPathSegment, GsapAnimation, GsapKeyframesData, GsapMethod, @@ -20,8 +19,14 @@ import type { ParsedGsap, } from "./gsapSerialize.js"; import { classifyTweenPropertyGroup } from "./gsapConstants.js"; +import { buildArcPath } from "./gsapSerialize.js"; import { inlineComputedTimelines, readProvenance } from "./gsapInline.js"; +// Browser-safe re-exports so studio code can build arc config without importing +// the recast parser (this acorn module is the browser-safe gsap subpath). +export { buildArcPath } from "./gsapSerialize.js"; +export type { ArcPathConfig, ArcPathSegment, MotionPathShape } from "./gsapSerialize.js"; + const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); const ITERATION_METHODS = new Set(["forEach", "map"]); @@ -791,34 +796,7 @@ function parseMotionPathNode( if (x !== undefined && y !== undefined) coords.push({ x, y }); } - if (coords.length < 2) return undefined; - - let waypoints: Array<{ x: number; y: number }>; - const segments: ArcPathSegment[] = []; - - if (isCubic && coords.length >= 4) { - waypoints = []; - const first = coords[0]; - if (first) waypoints.push(first); - for (let i = 1; i + 2 < coords.length; i += 3) { - const cp1 = coords[i]; - const cp2 = coords[i + 1]; - const anchor = coords[i + 2]; - if (!cp1 || !cp2 || !anchor) continue; - waypoints.push(anchor); - segments.push({ curviness, cp1, cp2 }); - } - } else { - waypoints = coords; - for (let i = 0; i < waypoints.length - 1; i++) { - segments.push({ curviness }); - } - } - - return { - arcPath: { enabled: true, autoRotate, segments }, - waypoints, - }; + return buildArcPath(coords, curviness, autoRotate, isCubic); } // ── Animation assembly ──────────────────────────────────────────────────────── diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 0ecf6ecbf..6e44cf0a8 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -89,6 +89,39 @@ export interface ArcPathConfig { segments: ArcPathSegment[]; } +export interface MotionPathShape { + arcPath: ArcPathConfig; + waypoints: Array<{ x: number; y: number }>; +} + +/** + * Build arcPath segments + waypoints from resolved path coordinates. Shared by + * the AST parser (coords from literal nodes) and the runtime scanner (coords + * from a live `vars.motionPath`), so both produce identical arc config. + */ +export function buildArcPath( + coords: Array<{ x: number; y: number }>, + curviness: number, + autoRotate: boolean | number, + isCubic: boolean, +): MotionPathShape | undefined { + if (coords.length < 2) return undefined; + const segments: ArcPathSegment[] = []; + let waypoints: Array<{ x: number; y: number }>; + if (isCubic && coords.length >= 4) { + // coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]. + waypoints = [coords[0]!]; + for (let i = 1; i + 2 < coords.length; i += 3) { + waypoints.push(coords[i + 2]!); + segments.push({ curviness, cp1: coords[i]!, cp2: coords[i + 1]! }); + } + } else { + waypoints = coords; + for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness }); + } + return { arcPath: { enabled: true, autoRotate, segments }, waypoints }; +} + export interface ParsedGsap { animations: GsapAnimation[]; timelineVar: string; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts new file mode 100644 index 000000000..91cda720f --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 981e7a5ea..3b1ac272c 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -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; } -export function readRuntimeKeyframes( - iframe: HTMLIFrameElement | null, - selector: string, - compositionId?: string, -): { - keyframes: Array<{ percentage: number; properties: Record }>; +type Pct = { percentage: number; properties: Record }; +type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig }; + +export interface RuntimeKeyframeEntry { + keyframes: Pct[]; easeEach?: string; -} | null { - if (!iframe?.contentWindow) return null; + /** 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; +} - let timelines: Record | undefined; +/** Clip start/duration per element id, to convert tween-relative % to clip-relative. */ +export type ClipDims = Map; + +const FLAT_SKIP_KEYS = new Set([ + "ease", + "duration", + "delay", + "stagger", + "motionPath", + "overwrite", + "immediateRender", + "onComplete", + "onUpdate", + "onStart", + "keyframes", +]); + +function timelinesOf(iframe: HTMLIFrameElement | null): Record | null { + if (!iframe?.contentWindow) return null; try { - timelines = ( - iframe.contentWindow as unknown as { __timelines?: Record } - ).__timelines; + return ( + (iframe.contentWindow as unknown as { __timelines?: Record }) + .__timelines ?? null + ); } catch { return null; } - if (!timelines) 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; + 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): Pct[] | null { + const properties: Record = {}; + 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): ReadTween | null { + if (vars.keyframes && typeof vars.keyframes === "object") { + const parsed = parsePercentageKeyframes(vars.keyframes as Record); + 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, +): 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); - 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 }>; - easeEach?: string; - } -> { - const result = new Map< - string, - { - keyframes: Array<{ percentage: number; properties: Record }>; - 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 | undefined; - try { - timelines = ( - iframe.contentWindow as unknown as { __timelines?: Record } - ).__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, + 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))); } - if (!timelines) return result; +} +/** + * 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 { + const result = new Map(); + 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); - 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 = {}; - 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; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 90b8d7a29..f2ba55efa 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -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 } : {}), }, ]; } @@ -369,6 +371,9 @@ export function usePopulateKeyframeCacheForFile( for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id) continue; + // Leave statically-unresolvable tweens to the runtime scan below, which + // reads the live timeline — don't claim them with a (wrong) static entry. + if (anim.hasUnresolvedKeyframes) continue; const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kfData) continue; const tweenPos = @@ -428,7 +433,13 @@ export function usePopulateKeyframeCacheForFile( const iframe = iframeRef?.current ?? document.querySelector("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(); + 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); if (scanned.size === 0) return false; const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); for (const [id, data] of scanned) { From e289dac7034fc89150b6bb098b763f25d0124a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:23:43 -0400 Subject: [PATCH 05/12] 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. --- .fallowrc.jsonc | 4 ++ .../parsers/gsapParserAcorn.computed.test.ts | 14 ++++++- packages/core/src/parsers/gsapParserAcorn.ts | 11 ++++- packages/core/src/parsers/gsapSerialize.ts | 15 +++++++ .../src/components/editor/AnimationCard.tsx | 34 +++++----------- .../components/editor/ComputedTweenNotice.tsx | 40 +++++++++++++++++++ .../editor/GsapAnimationSection.tsx | 27 ++----------- .../editor/gsapAnimationCallbacks.ts | 31 ++++++++++++++ 8 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 packages/studio/src/components/editor/ComputedTweenNotice.tsx create mode 100644 packages/studio/src/components/editor/gsapAnimationCallbacks.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 9ed66abfb..5f3cabd37 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -234,6 +234,10 @@ "ignore": [ "packages/core/src/studio-api/routes/files.ts", "packages/studio/src/hooks/useGsapTweenCache.ts", + // AnimationCard.tsx: pre-existing large presentational components + // (PropertyRow, the card body — the latter already carries an inline + // fallow-ignore). Exempted at file level to avoid scope-creep refactors. + "packages/studio/src/components/editor/AnimationCard.tsx", ], }, } diff --git a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts index a26bd4120..5d3876b07 100644 --- a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts +++ b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts @@ -4,7 +4,19 @@ * regression coverage that literal-position compositions are unchanged. */ import { describe, it, expect } from "vitest"; -import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; +import { parseGsapScriptAcorn, editabilityForProvenance } from "./gsapParserAcorn.js"; + +describe("editabilityForProvenance", () => { + it("maps provenance kinds to an editing strategy", () => { + expect(editabilityForProvenance(undefined)).toBe("direct"); + expect(editabilityForProvenance({ kind: "literal" })).toBe("direct"); + expect(editabilityForProvenance({ kind: "helper", fn: "addCycle", callSite: 1 })).toBe( + "unroll", + ); + expect(editabilityForProvenance({ kind: "loop", callSite: 1, iteration: 0 })).toBe("unroll"); + expect(editabilityForProvenance({ kind: "runtime-dynamic" })).toBe("override"); + }); +}); const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart; diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 649e32e1b..bd6ec59c7 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -24,8 +24,15 @@ import { inlineComputedTimelines, readProvenance } from "./gsapInline.js"; // Browser-safe re-exports so studio code can build arc config without importing // the recast parser (this acorn module is the browser-safe gsap subpath). -export { buildArcPath } from "./gsapSerialize.js"; -export type { ArcPathConfig, ArcPathSegment, MotionPathShape } from "./gsapSerialize.js"; +export { buildArcPath, editabilityForProvenance } from "./gsapSerialize.js"; +export type { + ArcPathConfig, + ArcPathSegment, + MotionPathShape, + GsapProvenance, + GsapProvenanceKind, + KeyframeEditability, +} from "./gsapSerialize.js"; const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 6e44cf0a8..90dcbf433 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -32,6 +32,21 @@ export interface GsapProvenance { sourceRange?: [number, number]; } +/** How a tween's keyframes can be edited, derived from its provenance. */ +export type KeyframeEditability = "direct" | "unroll" | "override"; + +/** + * Map provenance to an editing strategy: + * - `direct` — literal tween, maps 1:1 to source; edit in place. + * - `unroll` — helper/loop expansion; unroll to literal tweens first. + * - `override` — runtime-dynamic; edits persist as a composition override. + */ +export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability { + if (!provenance || provenance.kind === "literal") return "direct"; + if (provenance.kind === "runtime-dynamic") return "override"; + return "unroll"; +} + export interface GsapAnimation { id: string; targetSelector: string; diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 064872ecb..febdce269 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -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,11 @@ 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, - ) => void; + /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */ + onUnroll?: (animationId: string) => void; } // fallow-ignore-next-line complexity @@ -278,6 +259,7 @@ export const AnimationCard = memo(function AnimationCard({ onLivePreviewEnd, onSetArcPath, onUpdateArcSegment, + onUnroll, }: AnimationCardProps) { const [expanded, setExpanded] = useState(defaultExpanded); const [addingProp, setAddingProp] = useState(false); @@ -397,6 +379,10 @@ export const AnimationCard = memo(function AnimationCard({ {expanded && (
+ onUnroll(animation.id) : undefined} + />

{summary}

diff --git a/packages/studio/src/components/editor/ComputedTweenNotice.tsx b/packages/studio/src/components/editor/ComputedTweenNotice.tsx new file mode 100644 index 000000000..c8c97d133 --- /dev/null +++ b/packages/studio/src/components/editor/ComputedTweenNotice.tsx @@ -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; dynamic tweens explain edits persist as composition overrides. + * Literal tweens render nothing. + */ +export function ComputedTweenNotice({ + provenance, + onUnroll, +}: { + provenance?: GsapProvenance; + onUnroll?: () => void; +}) { + const editability = editabilityForProvenance(provenance); + if (editability === "direct") return null; + if (editability === "override") { + return ( +
+ Dynamic value — edits are saved as composition overrides. +
+ ); + } + const source = provenance?.fn ? `${provenance.fn}()` : "a loop"; + return ( +
+ Generated by {source} — not directly editable. + {onUnroll && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 72ecf5186..b5791b862 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -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, - ) => void; } export const GsapAnimationSection = memo(function GsapAnimationSection({ diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts new file mode 100644 index 000000000..44a505511 --- /dev/null +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -0,0 +1,31 @@ +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, + ) => void; +} From 89fc0762ad07a581436d84fa4c1489f97a187b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:25:59 -0400 Subject: [PATCH 06/12] feat(core): lint understands computed timelines (acorn parser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/lint/rules/gsap.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 20e642fbf..0d23ebed0 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -11,13 +11,13 @@ interface LintParsedGsap { timelineVar: string; } -// The recast-based GSAP parser lives behind the Node-only -// `@hyperframes/core/gsap-parser` subpath. The linter runs server-side only -// (CLI + studio-api `/lint` route), so loading it via dynamic import keeps -// recast out of any browser/SSR-traced static graph. +// Use the acorn read parser: it resolves computed timelines (helpers, bounded +// loops) so lint findings like overlapping_gsap_tweens reflect true positions +// instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast +// out of the lint graph entirely. Dynamic import preserves the lazy load. async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> { - const mod = await import("../../parsers/gsapParser.js"); - return mod.parseGsapScript as unknown as (script: string) => LintParsedGsap; + const mod = await import("../../parsers/gsapParserAcorn.js"); + return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap; } import type { LintContext } from "../context"; import type { HyperframeLintFinding, LintRule } from "../types"; From d0f9fe8ca216bd5a1edc0435014af60282ce509b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:26:59 -0400 Subject: [PATCH 07/12] docs: document the computed-timeline keyframe editing model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guides/keyframes.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx index 74be00597..ff2a32ce5 100644 --- a/docs/guides/keyframes.mdx +++ b/docs/guides/keyframes.mdx @@ -124,6 +124,18 @@ Record motion by physically dragging an element in the preview while the timelin +## Computed Timelines (Helpers, Loops, Dynamic Data) + +Studio reads your timeline statically, so a composition that builds its tweens with a **helper function called several times**, a **bounded loop**, or **data-driven values** still shows every keyframe at its true time on the timeline — and the Arc Motion panel still activates for `motionPath` tweens, even when the path comes from a variable. + +How those keyframes are **edited** depends on how they were authored: + +- **Literal tweens** (`tl.to("#x", { x: 100 }, 1.3)`) — edit directly in the Design Panel. One source call, one tween. +- **Helper / loop tweens** — a single source line (e.g. `addCycle(1.0, ...)`) expands into many runtime tweens, so editing one keyframe is ambiguous. The Animation card shows a **"Generated by `addCycle()` — not directly editable"** notice with an **Unroll to edit** action: it rewrites the helper or loop into explicit literal tweens (a visual no-op — the render is identical), after which each keyframe edits directly. Undo restores the helper. +- **Dynamic tweens** (positions from `fetch`, `Math.random`, props) — can't be unrolled (there's no value to bake at edit time). Edits are saved as **composition overrides** applied at render, leaving the dynamic logic intact. + +Nothing is permanently locked — the notice on each computed tween tells you which path applies. + ## Clipboard Context The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard: From f756edeb89944c0d7c0fccfa993c2628ab83509c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:45:51 -0400 Subject: [PATCH 08/12] feat: unroll computed timelines into literal tweens (U10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .fallowrc.jsonc | 6 + packages/core/src/parsers/gsapUnroll.test.ts | 62 ++++++++ packages/core/src/parsers/gsapUnroll.ts | 143 ++++++++++++++++++ packages/core/src/studio-api/routes/files.ts | 9 ++ .../src/components/StudioRightPanel.tsx | 2 + .../src/components/editor/AnimationCard.tsx | 2 - .../editor/GsapAnimationSection.tsx | 2 + .../src/components/editor/PropertyPanel.tsx | 2 + .../editor/gsapAnimationCallbacks.ts | 2 + .../components/editor/propertyPanelHelpers.ts | 2 + .../studio/src/contexts/DomEditContext.tsx | 4 + .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useGsapAwareEditing.ts | 10 ++ 13 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/parsers/gsapUnroll.test.ts create mode 100644 packages/core/src/parsers/gsapUnroll.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 5f3cabd37..a16683840 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -238,6 +238,12 @@ // (PropertyRow, the card body — the latter already carries an inline // fallow-ignore). Exempted at file level to avoid scope-creep refactors. "packages/studio/src/components/editor/AnimationCard.tsx", + // propertyPanelHelpers.ts + PropertyPanel.tsx: pre-existing complexity + // (readGsapRuntimeValuesForPanel, the panel body) whose inherited + // fingerprint shifted when the onUnroll prop threaded through. Exempted at + // file level per the line-shift rationale above rather than refactored. + "packages/studio/src/components/editor/propertyPanelHelpers.ts", + "packages/studio/src/components/editor/PropertyPanel.tsx", ], }, } diff --git a/packages/core/src/parsers/gsapUnroll.test.ts b/packages/core/src/parsers/gsapUnroll.test.ts new file mode 100644 index 000000000..57e2ae2ea --- /dev/null +++ b/packages/core/src/parsers/gsapUnroll.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { unrollComputedTimeline } from "./gsapUnroll.js"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +const ARC_SCRIPT = ` +const tl = gsap.timeline({ paused: true }); +const DX = 852, DY = -322, FLY_SCALE = 56 / 160; +tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1); +function addCycle(at, path, curviness, spin) { + tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15); + tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3); + tl.to("#basket", { keyframes: { "0%": { y: 0 }, "50%": { y: -12 }, "100%": { y: 0 }, easeEach: "power2.out" }, duration: 0.5 }, at + 0.85); +} +addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18); +addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22); +`; + +const sig = (anims: ReturnType["animations"]) => + anims + .map( + (a) => + `${a.targetSelector}|${a.method}|${a.resolvedStart}|arc:${a.arcPath?.segments.length ?? 0}|kf:${a.keyframes?.keyframes.length ?? 0}`, + ) + .join("\n"); + +describe("unrollComputedTimeline", () => { + it("unrolls helper calls into literal tweens (visual no-op)", () => { + const before = parseGsapScriptAcorn(ARC_SCRIPT); + const unrolled = unrollComputedTimeline(ARC_SCRIPT); + const after = parseGsapScriptAcorn(unrolled); + + // Same animations, same times, same arcs/keyframes — the render is unchanged. + expect(after.animations).toHaveLength(before.animations.length); + expect(sig(after.animations)).toBe(sig(before.animations)); + }); + + it("produces only literal tweens (no helper, no provenance)", () => { + const unrolled = unrollComputedTimeline(ARC_SCRIPT); + expect(unrolled).not.toContain("addCycle"); + expect(unrolled).not.toContain("function "); + const after = parseGsapScriptAcorn(unrolled); + expect(after.animations.every((a) => a.provenance === undefined)).toBe(true); + // Arc tweens survive as real motionPath arcs. + expect(after.animations.filter((a) => a.arcPath?.enabled)).toHaveLength(2); + }); + + it("unrolls a bounded for-loop", () => { + const script = `const tl = gsap.timeline(); + for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); }`; + const unrolled = unrollComputedTimeline(script); + expect(unrolled).not.toContain("for ("); + const after = parseGsapScriptAcorn(unrolled); + expect(after.animations.map((a) => a.resolvedStart)).toEqual([0, 0.5, 1]); + expect(after.animations.every((a) => a.provenance === undefined)).toBe(true); + }); + + it("leaves a fully-literal composition unchanged", () => { + const script = `const tl = gsap.timeline(); +tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1);`; + expect(unrollComputedTimeline(script)).toBe(script); + }); +}); diff --git a/packages/core/src/parsers/gsapUnroll.ts b/packages/core/src/parsers/gsapUnroll.ts new file mode 100644 index 000000000..5506e757c --- /dev/null +++ b/packages/core/src/parsers/gsapUnroll.ts @@ -0,0 +1,143 @@ +/** + * Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal + * tweens — the source-rewrite behind the Studio "Unroll to edit" action. + * + * Strategy: the read parser already resolves each computed tween (positions, + * motionPath arcs, keyframes, provenance). We serialize those resolved + * animations back to literal `tl.*` statements and surgically replace the + * top-level helper-call / loop statements that produced them (and drop the now + * dead helper declarations) via magic-string, leaving the rest of the source — + * literal tweens, comments, formatting — untouched. The result is a visual + * no-op: re-parsing it yields the same animations, now all literal. + * + * Scope: top-level helper calls and loops (the common authoring shape). Tweens + * whose origin can't be mapped to a top-level statement (e.g. helpers nested + * inside other helpers) are left as-is rather than guessed at. + */ +import * as acorn from "acorn"; +import MagicString from "magic-string"; +import type { GsapAnimation } from "./gsapSerialize.js"; +import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +// acorn nodes are structurally untyped here. +type Node = any; + +function propEntries(props: Record): string[] { + return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); +} + +function motionPathEntry(anim: GsapAnimation): string { + const waypoints = (anim.keyframes?.keyframes ?? []) + .filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number") + .map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`); + const curviness = anim.arcPath?.segments[0]?.curviness ?? 1; + const autoRotate = anim.arcPath?.autoRotate; + const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : ""; + return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`; +} + +function keyframesEntry(anim: GsapAnimation): string { + const kfs = (anim.keyframes?.keyframes ?? []).map((k) => { + const body = propEntries(k.properties); + if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`); + return `"${k.percentage}%": { ${body.join(", ")} }`; + }); + if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`); + return `keyframes: { ${kfs.join(", ")} }`; +} + +/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */ +function buildVarsParts(anim: GsapAnimation): string[] { + const parts: string[] = []; + if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim)); + else if (anim.keyframes) parts.push(keyframesEntry(anim)); + parts.push(...propEntries(anim.properties)); + if (anim.method !== "set" && anim.duration !== undefined) { + parts.push(`duration: ${valueToCode(anim.duration)}`); + } + if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`); + for (const [k, v] of Object.entries(anim.extras ?? {})) { + parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); + } + return parts; +} + +/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */ +function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string { + const obj = `{ ${buildVarsParts(anim).join(", ")} }`; + const pos = valueToCode( + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0), + ); + const sel = valueToCode(anim.targetSelector); + if (anim.method === "fromTo") { + const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`; + return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`; + } + return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`; +} + +/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */ +function isComputed(anim: GsapAnimation): boolean { + return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop"; +} + +/** Top-level statements of the parsed program. */ +function topLevelStatements(script: string): Node[] { + return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? []; +} + +/** The top-level statement whose source span contains [start, end], or null. */ +function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null { + for (const stmt of statements) { + if (stmt.start <= start && stmt.end >= end) return stmt; + } + return null; +} + +function isHelperDeclNamed(stmt: Node, names: Set): boolean { + if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name); + if (stmt.type === "VariableDeclaration") { + return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name)); + } + return false; +} + +/** + * Rewrite `script` so top-level helper calls / loops that build the timeline + * become explicit literal tweens. Returns the original script unchanged when + * there is nothing statically-resolvable to unroll. + */ +export function unrollComputedTimeline(script: string): string { + const parsed = parseGsapScriptAcorn(script); + const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange); + if (computed.length === 0) return script; + + const statements = topLevelStatements(script); + + // Group computed animations by the top-level statement that produced them, + // preserving source order within each group. + const byStatement = new Map(); + const helperNames = new Set(); + for (const anim of computed) { + if (anim.provenance?.fn) helperNames.add(anim.provenance.fn); + const [s, e] = anim.provenance!.sourceRange!; + const stmt = enclosingTopLevel(statements, s, e); + if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit + const list = byStatement.get(stmt) ?? []; + list.push(anim); + byStatement.set(stmt, list); + } + if (byStatement.size === 0) return script; + + const ms = new MagicString(script); + for (const [stmt, anims] of byStatement) { + const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n"); + ms.overwrite(stmt.start, stmt.end, literals); + } + // Drop the now-dead helper declarations. + for (const stmt of statements) { + if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end); + } + return ms.toString(); +} diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 55b3cc145..229ddc1ff 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -25,6 +25,7 @@ import { } from "../helpers/finiteMutation.js"; import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; +import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; import { removeElementFromHtml, patchElementInHtml, @@ -474,6 +475,11 @@ type GsapMutationRequest = type: "delete-all-for-selector"; targetSelector: string; } + | { + // Rewrite all top-level helper/loop constructs into literal tweens so + // computed keyframes become directly editable (visual no-op). + type: "unroll-timeline"; + } | { type: "shift-positions"; targetSelector: string; @@ -734,6 +740,9 @@ async function executeGsapMutation( const result = splitIntoPropertyGroups(block.scriptText, body.animationId); return result.script; } + case "unroll-timeline": { + return unrollComputedTimeline(block.scriptText); + } case "shift-positions": { const { targetSelector, delta } = body; if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText; diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 0bc4a8945..a9165d25b 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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} diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index febdce269..3c8d5385f 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -239,8 +239,6 @@ function parseNumericOrString(raw: string): number | string { interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; - /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */ - onUnroll?: (animationId: string) => void; } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index b5791b862..90cba6d61 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -30,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onLivePreviewEnd, onSetArcPath, onUpdateArcSegment, + onUnroll, }: GsapAnimationSectionProps) { const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -67,6 +68,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onLivePreviewEnd={onLivePreviewEnd} onSetArcPath={onSetArcPath} onUpdateArcSegment={onUpdateArcSegment} + onUnroll={onUnroll} /> ))} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 4d2887881..ec431a058 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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} /> )} diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index 44a505511..bd9d3f6b1 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -28,4 +28,6 @@ export interface GsapAnimationEditCallbacks { segmentIndex: number, update: Partial, ) => void; + /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */ + onUnroll?: (animationId: string) => void; } diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index 3c3ed84dc..dc547a62b 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -56,6 +56,8 @@ export interface PropertyPanelProps { segmentIndex: number, update: Partial, ) => void; + /** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */ + onUnroll?: (animationId: string) => void; onAddKeyframe?: ( animationId: string, percentage: number, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 7995f1435..2d910268d 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 0cf1a07e7..d7a404688 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -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, diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 1a82ecdad..949285b8b 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -230,6 +230,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 +246,7 @@ export function useGsapAwareEditing({ commitAnimatedProperty, handleSetArcPath, handleUpdateArcSegment, + handleUnroll, commitMutation, }; } From 6206bb06c9793209a5a8038b687e6a58440106c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 03:49:36 -0400 Subject: [PATCH 09/12] feat(runtime): declarative keyframe override layer for dynamic tweens (U11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/runtime/gsapKeyframeOverrides.test.ts | 68 ++++++++++++++++++ .../core/src/runtime/gsapKeyframeOverrides.ts | 71 +++++++++++++++++++ packages/core/src/runtime/init.ts | 5 +- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/runtime/gsapKeyframeOverrides.test.ts create mode 100644 packages/core/src/runtime/gsapKeyframeOverrides.ts diff --git a/packages/core/src/runtime/gsapKeyframeOverrides.test.ts b/packages/core/src/runtime/gsapKeyframeOverrides.test.ts new file mode 100644 index 000000000..da427c99e --- /dev/null +++ b/packages/core/src/runtime/gsapKeyframeOverrides.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyKeyframeOverrides, + applyOverrideToTweens, + selectOverrideTweens, +} from "./gsapKeyframeOverrides"; + +const mk = (start: number, vars: Record) => { + const t: any = { vars, startTime: () => start }; + t.invalidate = vi.fn(() => t); + return t; +}; + +describe("selectOverrideTweens", () => { + it("returns all tweens (sorted) when no index", () => { + const a = mk(2, {}), + b = mk(0, {}), + c = mk(1, {}); + expect(selectOverrideTweens([a, b, c], { selector: "#x" })).toEqual([b, c, a]); + }); + it("picks the nth tween by start order when tweenIndex is set", () => { + const a = mk(2, {}), + b = mk(0, {}), + c = mk(1, {}); + expect(selectOverrideTweens([a, b, c], { selector: "#x", tweenIndex: 2 })).toEqual([a]); + expect(selectOverrideTweens([a, b, c], { selector: "#x", tweenIndex: 5 })).toEqual([]); + }); +}); + +describe("applyOverrideToTweens", () => { + it("merges vars and invalidates the targeted tween", () => { + const a = mk(0, { x: 0, opacity: 1 }); + const applied = applyOverrideToTweens([a], { selector: "#x", tweenIndex: 0, vars: { x: 99 } }); + expect(applied).toBe(1); + expect(a.vars).toEqual({ x: 99, opacity: 1 }); + expect(a.invalidate).toHaveBeenCalledOnce(); + }); +}); + +describe("applyKeyframeOverrides", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("fetches the sidecar and applies overrides to matching tweens", async () => { + const target = mk(0, { x: 0 }); + Object.defineProperty(window, "gsap", { + configurable: true, + value: { getTweensOf: (sel: string) => (sel === "#product" ? [target] : []) }, + }); + vi.stubGlobal("fetch", async () => ({ + ok: true, + async json() { + return [{ selector: "#product", tweenIndex: 0, vars: { x: 250 } }]; + }, + })); + + applyKeyframeOverrides(); + for (let i = 0; i < 4; i++) await Promise.resolve(); + + expect(target.vars.x).toBe(250); + expect(target.invalidate).toHaveBeenCalled(); + }); + + it("no-ops without gsap", () => { + Object.defineProperty(window, "gsap", { configurable: true, value: undefined }); + expect(() => applyKeyframeOverrides()).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/gsapKeyframeOverrides.ts b/packages/core/src/runtime/gsapKeyframeOverrides.ts new file mode 100644 index 000000000..0cfcd1343 --- /dev/null +++ b/packages/core/src/runtime/gsapKeyframeOverrides.ts @@ -0,0 +1,71 @@ +/** + * GSAP keyframe overrides — applies per-tween value overrides from a sidecar + * JSON file (`gsap-overrides.json`) after the timeline is built. + * + * This is how the Studio persists edits to *dynamic* tweens (values from + * variables / data that can't be unrolled to literals): rather than rewriting + * source, the edit is recorded as an override keyed by a stable identity + * (element selector + tween ordinal) and re-applied here at load — in both the + * Studio preview and headless render, so exports reflect the edit. The override + * stores explicit values, so application needs no live data and stays + * deterministic. Mirrors the caption-overrides mechanism. + */ + +export interface KeyframeOverride { + /** Element selector the tween targets, e.g. "#product". */ + selector: string; + /** Ordinal among the element's tweens, sorted by start time (stable identity). */ + tweenIndex?: number; + /** Tween vars to override (positions/transform/style values). */ + vars?: Record; +} + +interface GsapTween { + vars: Record; + startTime(): number; + invalidate?: () => GsapTween; +} + +interface GsapStatic { + getTweensOf: (target: string) => GsapTween[]; +} + +/** + * The tween(s) an override applies to: a specific ordinal (by start time) when + * `tweenIndex` is set, otherwise every tween of the selector. + */ +export function selectOverrideTweens(tweens: GsapTween[], override: KeyframeOverride): GsapTween[] { + const sorted = [...tweens].sort((a, b) => a.startTime() - b.startTime()); + if (override.tweenIndex === undefined) return sorted; + const tween = sorted[override.tweenIndex]; + return tween ? [tween] : []; +} + +/** Apply one override's vars to its target tweens, invalidating so GSAP re-reads them. */ +export function applyOverrideToTweens(tweens: GsapTween[], override: KeyframeOverride): number { + if (!override.vars) return 0; + let applied = 0; + for (const tween of selectOverrideTweens(tweens, override)) { + Object.assign(tween.vars, override.vars); + tween.invalidate?.(); + applied++; + } + return applied; +} + +/** Fetch `gsap-overrides.json` and apply each override to the live timeline. */ +export function applyKeyframeOverrides(): void { + const gsap = (window as unknown as { gsap?: GsapStatic }).gsap; + if (!gsap?.getTweensOf) return; + + void fetch("gsap-overrides.json") + .then((r) => (r.ok ? r.json() : null)) + .then((data: KeyframeOverride[] | null) => { + if (!Array.isArray(data)) return; + for (const override of data) { + if (!override?.selector || !override.vars) continue; + applyOverrideToTweens(gsap.getTweensOf(override.selector), override); + } + }) + .catch(() => {}); +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8ee49183c..97723fe96 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -23,6 +23,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { applyCaptionOverrides } from "./captionOverrides"; +import { applyKeyframeOverrides } from "./gsapKeyframeOverrides"; import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; @@ -1666,11 +1667,13 @@ export function initSandboxRuntimeModular(): void { bindMediaMetadataListeners(); installAssetFailureDiagnostics(); applyCaptionOverrides(); + applyKeyframeOverrides(); maybePublishRenderReady(); }); } else { - // No external/inline compositions to load — apply caption overrides immediately + // No external/inline compositions to load — apply overrides immediately applyCaptionOverrides(); + applyKeyframeOverrides(); } const picker = createPickerModule({ From c9632fb34d5d25fd90a70cb66193213196af48ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 11:21:42 -0400 Subject: [PATCH 10/12] refactor: drop the keyframe override layer; rely on unroll + source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guides/keyframes.mdx | 4 +- .../parsers/gsapParserAcorn.computed.test.ts | 2 +- packages/core/src/parsers/gsapSerialize.ts | 10 +-- .../src/runtime/gsapKeyframeOverrides.test.ts | 68 ------------------ .../core/src/runtime/gsapKeyframeOverrides.ts | 71 ------------------- packages/core/src/runtime/init.ts | 5 +- .../components/editor/ComputedTweenNotice.tsx | 8 +-- 7 files changed, 13 insertions(+), 155 deletions(-) delete mode 100644 packages/core/src/runtime/gsapKeyframeOverrides.test.ts delete mode 100644 packages/core/src/runtime/gsapKeyframeOverrides.ts diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx index ff2a32ce5..c2c241799 100644 --- a/docs/guides/keyframes.mdx +++ b/docs/guides/keyframes.mdx @@ -132,9 +132,9 @@ How those keyframes are **edited** depends on how they were authored: - **Literal tweens** (`tl.to("#x", { x: 100 }, 1.3)`) — edit directly in the Design Panel. One source call, one tween. - **Helper / loop tweens** — a single source line (e.g. `addCycle(1.0, ...)`) expands into many runtime tweens, so editing one keyframe is ambiguous. The Animation card shows a **"Generated by `addCycle()` — not directly editable"** notice with an **Unroll to edit** action: it rewrites the helper or loop into explicit literal tweens (a visual no-op — the render is identical), after which each keyframe edits directly. Undo restores the helper. -- **Dynamic tweens** (positions from `fetch`, `Math.random`, props) — can't be unrolled (there's no value to bake at edit time). Edits are saved as **composition overrides** applied at render, leaving the dynamic logic intact. +- **Computed-value tweens** (the rare case of a value derived at runtime that can't be resolved or unrolled) — stay display-only; edit them in the **Code tab**. HyperFrames compositions are deterministic, so this case is uncommon. -Nothing is permanently locked — the notice on each computed tween tells you which path applies. +The notice on each computed tween tells you which path applies. ## Clipboard Context diff --git a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts index 5d3876b07..eb20ea757 100644 --- a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts +++ b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts @@ -14,7 +14,7 @@ describe("editabilityForProvenance", () => { "unroll", ); expect(editabilityForProvenance({ kind: "loop", callSite: 1, iteration: 0 })).toBe("unroll"); - expect(editabilityForProvenance({ kind: "runtime-dynamic" })).toBe("override"); + expect(editabilityForProvenance({ kind: "runtime-dynamic" })).toBe("source"); }); }); diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 90dcbf433..9f3ea1f04 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -33,17 +33,17 @@ export interface GsapProvenance { } /** How a tween's keyframes can be edited, derived from its provenance. */ -export type KeyframeEditability = "direct" | "unroll" | "override"; +export type KeyframeEditability = "direct" | "unroll" | "source"; /** * Map provenance to an editing strategy: - * - `direct` — literal tween, maps 1:1 to source; edit in place. - * - `unroll` — helper/loop expansion; unroll to literal tweens first. - * - `override` — runtime-dynamic; edits persist as a composition override. + * - `direct` — literal tween, maps 1:1 to source; edit in place. + * - `unroll` — helper/loop expansion; unroll to literal tweens, then edit. + * - `source` — runtime-dynamic value; not statically editable, edit the code. */ export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability { if (!provenance || provenance.kind === "literal") return "direct"; - if (provenance.kind === "runtime-dynamic") return "override"; + if (provenance.kind === "runtime-dynamic") return "source"; return "unroll"; } diff --git a/packages/core/src/runtime/gsapKeyframeOverrides.test.ts b/packages/core/src/runtime/gsapKeyframeOverrides.test.ts deleted file mode 100644 index da427c99e..000000000 --- a/packages/core/src/runtime/gsapKeyframeOverrides.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - applyKeyframeOverrides, - applyOverrideToTweens, - selectOverrideTweens, -} from "./gsapKeyframeOverrides"; - -const mk = (start: number, vars: Record) => { - const t: any = { vars, startTime: () => start }; - t.invalidate = vi.fn(() => t); - return t; -}; - -describe("selectOverrideTweens", () => { - it("returns all tweens (sorted) when no index", () => { - const a = mk(2, {}), - b = mk(0, {}), - c = mk(1, {}); - expect(selectOverrideTweens([a, b, c], { selector: "#x" })).toEqual([b, c, a]); - }); - it("picks the nth tween by start order when tweenIndex is set", () => { - const a = mk(2, {}), - b = mk(0, {}), - c = mk(1, {}); - expect(selectOverrideTweens([a, b, c], { selector: "#x", tweenIndex: 2 })).toEqual([a]); - expect(selectOverrideTweens([a, b, c], { selector: "#x", tweenIndex: 5 })).toEqual([]); - }); -}); - -describe("applyOverrideToTweens", () => { - it("merges vars and invalidates the targeted tween", () => { - const a = mk(0, { x: 0, opacity: 1 }); - const applied = applyOverrideToTweens([a], { selector: "#x", tweenIndex: 0, vars: { x: 99 } }); - expect(applied).toBe(1); - expect(a.vars).toEqual({ x: 99, opacity: 1 }); - expect(a.invalidate).toHaveBeenCalledOnce(); - }); -}); - -describe("applyKeyframeOverrides", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("fetches the sidecar and applies overrides to matching tweens", async () => { - const target = mk(0, { x: 0 }); - Object.defineProperty(window, "gsap", { - configurable: true, - value: { getTweensOf: (sel: string) => (sel === "#product" ? [target] : []) }, - }); - vi.stubGlobal("fetch", async () => ({ - ok: true, - async json() { - return [{ selector: "#product", tweenIndex: 0, vars: { x: 250 } }]; - }, - })); - - applyKeyframeOverrides(); - for (let i = 0; i < 4; i++) await Promise.resolve(); - - expect(target.vars.x).toBe(250); - expect(target.invalidate).toHaveBeenCalled(); - }); - - it("no-ops without gsap", () => { - Object.defineProperty(window, "gsap", { configurable: true, value: undefined }); - expect(() => applyKeyframeOverrides()).not.toThrow(); - }); -}); diff --git a/packages/core/src/runtime/gsapKeyframeOverrides.ts b/packages/core/src/runtime/gsapKeyframeOverrides.ts deleted file mode 100644 index 0cfcd1343..000000000 --- a/packages/core/src/runtime/gsapKeyframeOverrides.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * GSAP keyframe overrides — applies per-tween value overrides from a sidecar - * JSON file (`gsap-overrides.json`) after the timeline is built. - * - * This is how the Studio persists edits to *dynamic* tweens (values from - * variables / data that can't be unrolled to literals): rather than rewriting - * source, the edit is recorded as an override keyed by a stable identity - * (element selector + tween ordinal) and re-applied here at load — in both the - * Studio preview and headless render, so exports reflect the edit. The override - * stores explicit values, so application needs no live data and stays - * deterministic. Mirrors the caption-overrides mechanism. - */ - -export interface KeyframeOverride { - /** Element selector the tween targets, e.g. "#product". */ - selector: string; - /** Ordinal among the element's tweens, sorted by start time (stable identity). */ - tweenIndex?: number; - /** Tween vars to override (positions/transform/style values). */ - vars?: Record; -} - -interface GsapTween { - vars: Record; - startTime(): number; - invalidate?: () => GsapTween; -} - -interface GsapStatic { - getTweensOf: (target: string) => GsapTween[]; -} - -/** - * The tween(s) an override applies to: a specific ordinal (by start time) when - * `tweenIndex` is set, otherwise every tween of the selector. - */ -export function selectOverrideTweens(tweens: GsapTween[], override: KeyframeOverride): GsapTween[] { - const sorted = [...tweens].sort((a, b) => a.startTime() - b.startTime()); - if (override.tweenIndex === undefined) return sorted; - const tween = sorted[override.tweenIndex]; - return tween ? [tween] : []; -} - -/** Apply one override's vars to its target tweens, invalidating so GSAP re-reads them. */ -export function applyOverrideToTweens(tweens: GsapTween[], override: KeyframeOverride): number { - if (!override.vars) return 0; - let applied = 0; - for (const tween of selectOverrideTweens(tweens, override)) { - Object.assign(tween.vars, override.vars); - tween.invalidate?.(); - applied++; - } - return applied; -} - -/** Fetch `gsap-overrides.json` and apply each override to the live timeline. */ -export function applyKeyframeOverrides(): void { - const gsap = (window as unknown as { gsap?: GsapStatic }).gsap; - if (!gsap?.getTweensOf) return; - - void fetch("gsap-overrides.json") - .then((r) => (r.ok ? r.json() : null)) - .then((data: KeyframeOverride[] | null) => { - if (!Array.isArray(data)) return; - for (const override of data) { - if (!override?.selector || !override.vars) continue; - applyOverrideToTweens(gsap.getTweensOf(override.selector), override); - } - }) - .catch(() => {}); -} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 97723fe96..8ee49183c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -23,7 +23,6 @@ import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { applyCaptionOverrides } from "./captionOverrides"; -import { applyKeyframeOverrides } from "./gsapKeyframeOverrides"; import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; @@ -1667,13 +1666,11 @@ export function initSandboxRuntimeModular(): void { bindMediaMetadataListeners(); installAssetFailureDiagnostics(); applyCaptionOverrides(); - applyKeyframeOverrides(); maybePublishRenderReady(); }); } else { - // No external/inline compositions to load — apply overrides immediately + // No external/inline compositions to load — apply caption overrides immediately applyCaptionOverrides(); - applyKeyframeOverrides(); } const picker = createPickerModule({ diff --git a/packages/studio/src/components/editor/ComputedTweenNotice.tsx b/packages/studio/src/components/editor/ComputedTweenNotice.tsx index c8c97d133..05fa46d63 100644 --- a/packages/studio/src/components/editor/ComputedTweenNotice.tsx +++ b/packages/studio/src/components/editor/ComputedTweenNotice.tsx @@ -2,8 +2,8 @@ import { editabilityForProvenance, type GsapProvenance } from "@hyperframes/core /** * Notice shown for computed tweens: helper/loop tweens offer an "unroll to - * edit" action; dynamic tweens explain edits persist as composition overrides. - * Literal tweens render nothing. + * edit" action; runtime-computed values point to the Code tab. Literal tweens + * render nothing. */ export function ComputedTweenNotice({ provenance, @@ -14,10 +14,10 @@ export function ComputedTweenNotice({ }) { const editability = editabilityForProvenance(provenance); if (editability === "direct") return null; - if (editability === "override") { + if (editability === "source") { return (
- Dynamic value — edits are saved as composition overrides. + Computed value — edit it in the Code tab.
); } From e761736dee3d8c4e128c2a0b2419a7a3be06293e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 12:51:39 -0400 Subject: [PATCH 11/12] 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. --- packages/studio/src/hooks/gsapDragCommit.ts | 93 ++++++++++++++++++- .../studio/src/hooks/gsapRuntimeBridge.ts | 89 ++++++++++++++---- .../studio/src/hooks/useDomGeometryCommits.ts | 10 +- .../studio/src/hooks/useGsapAwareEditing.ts | 11 +++ .../studio/src/hooks/useGsapTweenCache.ts | 49 ++++++++-- 5 files changed, 220 insertions(+), 32 deletions(-) diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index d26c12675..dffc1b07a 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -160,14 +160,93 @@ async function commitFlatViaKeyframes( properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, + iframe?: HTMLIFrameElement | null, + selector?: string, ): Promise { + 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 = {}; + 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, + ); } } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 1d2b28227..576372dda 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -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, ): 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, ): Promise { 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, diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 1997b11ed..42d0fc2a8 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -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); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index 949285b8b..cd4b55acb 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -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); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index f2ba55efa..cb872b6cf 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -360,22 +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(); for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id) continue; - // Leave statically-unresolvable tweens to the runtime scan below, which - // reads the live timeline — don't claim them with a (wrong) static entry. - if (anim.hasUnresolvedKeyframes) 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; @@ -407,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); @@ -440,13 +454,30 @@ export function usePopulateKeyframeCacheForFile( 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, From 980a6552f51aeeb0972c2f0755e0081072e64540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 16 Jun 2026 12:55:05 -0400 Subject: [PATCH 12/12] fix(studio): drag outside tween range creates new keyframe, picks nearest tween MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also reverts all fallow health.ignore additions — pre-existing complexity in touched files is accepted as inherited, not suppressed. --- .fallowrc.jsonc | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a16683840..8bbd76c7a 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -231,19 +231,6 @@ // and runtime-scan effects, the per-element animations memo) whose // complexity pre-dates the computed-timeline work. Exempted at file level // for the same reason as files.ts rather than refactored as scope creep. - "ignore": [ - "packages/core/src/studio-api/routes/files.ts", - "packages/studio/src/hooks/useGsapTweenCache.ts", - // AnimationCard.tsx: pre-existing large presentational components - // (PropertyRow, the card body — the latter already carries an inline - // fallow-ignore). Exempted at file level to avoid scope-creep refactors. - "packages/studio/src/components/editor/AnimationCard.tsx", - // propertyPanelHelpers.ts + PropertyPanel.tsx: pre-existing complexity - // (readGsapRuntimeValuesForPanel, the panel body) whose inherited - // fingerprint shifted when the onUnroll prop threaded through. Exempted at - // file level per the line-shift rationale above rather than refactored. - "packages/studio/src/components/editor/propertyPanelHelpers.ts", - "packages/studio/src/components/editor/PropertyPanel.tsx", - ], + "ignore": ["packages/core/src/studio-api/routes/files.ts"], }, }