diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6a33c90254..8bbd76c7ad 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -226,6 +226,11 @@ // 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). + // + // 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"], }, } diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx index 74be005978..c2c241799c 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. +- **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. + +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: diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 20e642fbff..0d23ebed0e 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"; diff --git a/packages/core/src/parsers/gsapInline.test.ts b/packages/core/src/parsers/gsapInline.test.ts new file mode 100644 index 0000000000..9d88b7ce60 --- /dev/null +++ b/packages/core/src/parsers/gsapInline.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "acorn"; +import { simple } from "acorn-walk"; +import { + cloneNode, + inlineComputedTimelines, + 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 }); + }); +}); + +// 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 new file mode 100644 index 0000000000..fa382ffa37 --- /dev/null +++ b/packages/core/src/parsers/gsapInline.ts @@ -0,0 +1,584 @@ +/** + * 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. 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"; + +// 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", "__hfOrder"]); + +const FUNCTION_TYPES = new Set([ + "ArrowFunctionExpression", + "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); +} + +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) }; +} + +// ── 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 }; + /** Mutable counter stamping expansion order onto tweens (clones share source loc). */ + order: { 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 }] : []; +} + +/** 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, ctx.timelineVar)) { + tagProvenance(n, { ...prov }); + n.__hfOrder = ctx.order.n++; + } + }); + } +} + +/** 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, prov, ctx); + 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 }, + 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 0000000000..eb20ea757b --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.computed.test.ts @@ -0,0 +1,78 @@ +/** + * 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, 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("source"); + }); +}); + +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 6f16472053..bd6ec59c7c 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,6 +19,20 @@ 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, 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"]); @@ -790,34 +803,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 ──────────────────────────────────────────────────────── @@ -942,6 +928,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 +1004,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 +1099,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)); diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 471fef0148..9f3ea1f04c 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -11,6 +11,42 @@ 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]; +} + +/** How a tween's keyframes can be edited, derived from its provenance. */ +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, 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 "source"; + return "unroll"; +} + export interface GsapAnimation { id: string; targetSelector: string; @@ -37,6 +73,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 { @@ -66,6 +104,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/core/src/parsers/gsapUnroll.test.ts b/packages/core/src/parsers/gsapUnroll.test.ts new file mode 100644 index 0000000000..57e2ae2eaa --- /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 0000000000..5506e757c2 --- /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 55b3cc145a..229ddc1ff0 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 0bc4a8945c..a9165d25bf 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 064872ecb3..3c8d5385fc 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,9 @@ function parseNumericOrString(raw: string): number | string { return Number.isFinite(num) ? num : raw; } -interface AnimationCardProps { +interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; - onUpdateProperty: (animationId: string, property: string, value: number | string) => void; - onUpdateMeta: ( - animationId: string, - updates: { duration?: number; ease?: string; position?: number }, - ) => void; - onDeleteAnimation: (animationId: string) => void; - onAddProperty: (animationId: string, property: string) => void; - onRemoveProperty: (animationId: string, property: string) => void; - onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void; - onAddFromProperty?: (animationId: string, property: string) => void; - onRemoveFromProperty?: (animationId: string, property: string) => void; - onLivePreview?: (property: string, value: number | string) => void; - onLivePreviewEnd?: () => void; - onSetArcPath?: ( - animationId: string, - config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] }, - ) => void; - onUpdateArcSegment?: ( - animationId: string, - segmentIndex: number, - update: Partial, - ) => void; } // fallow-ignore-next-line complexity @@ -278,6 +257,7 @@ export const AnimationCard = memo(function AnimationCard({ onLivePreviewEnd, onSetArcPath, onUpdateArcSegment, + onUnroll, }: AnimationCardProps) { const [expanded, setExpanded] = useState(defaultExpanded); const [addingProp, setAddingProp] = useState(false); @@ -397,6 +377,10 @@ export const AnimationCard = memo(function AnimationCard({ {expanded && (
+ 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 0000000000..05fa46d63c --- /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; runtime-computed values point to the Code tab. Literal tweens + * render nothing. + */ +export function ComputedTweenNotice({ + provenance, + onUnroll, +}: { + provenance?: GsapProvenance; + onUnroll?: () => void; +}) { + const editability = editabilityForProvenance(provenance); + if (editability === "direct") return null; + if (editability === "source") { + return ( +
+ Computed value — edit it in the Code tab. +
+ ); + } + 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 72ecf5186a..90cba6d614 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({ @@ -51,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onLivePreviewEnd, onSetArcPath, onUpdateArcSegment, + onUnroll, }: GsapAnimationSectionProps) { const [addMenuOpen, setAddMenuOpen] = useState(false); @@ -88,6 +68,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ onLivePreviewEnd={onLivePreviewEnd} onSetArcPath={onSetArcPath} onUpdateArcSegment={onUpdateArcSegment} + onUnroll={onUnroll} /> ))} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 4d2887881f..ec431a0588 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 new file mode 100644 index 0000000000..bd9d3f6b14 --- /dev/null +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -0,0 +1,33 @@ +import type { ArcPathSegment } from "@hyperframes/core/gsap-parser"; + +/** + * Edit callbacks shared by GsapAnimationSection and each AnimationCard it + * renders. Extracted so the two prop interfaces don't duplicate the (large) + * signatures the section forwards straight through to the card. + */ +export interface GsapAnimationEditCallbacks { + onUpdateProperty: (animationId: string, property: string, value: number | string) => void; + onUpdateMeta: ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => void; + onDeleteAnimation: (animationId: string) => void; + onAddProperty: (animationId: string, property: string) => void; + onRemoveProperty: (animationId: string, property: string) => void; + onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void; + onAddFromProperty?: (animationId: string, property: string) => void; + onRemoveFromProperty?: (animationId: string, property: string) => void; + onLivePreview?: (property: string, value: number | string) => void; + onLivePreviewEnd?: () => void; + onSetArcPath?: ( + animationId: string, + config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] }, + ) => void; + onUpdateArcSegment?: ( + animationId: string, + segmentIndex: number, + update: Partial, + ) => 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 3c3ed84dc4..dc547a62b3 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 7995f14352..2d910268d5 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/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index d26c12675b..dffc1b07a6 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 1d2b282272..576372ddae 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/gsapRuntimeKeyframes.test.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts new file mode 100644 index 0000000000..91cda720f4 --- /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 981e7a5eae..3b1ac272ce 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/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 0cf1a07e74..d7a4046889 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/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 1997b11ed6..42d0fc2a85 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 1a82ecdada..cd4b55acbf 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); @@ -230,6 +241,15 @@ export function useGsapAwareEditing({ [domEditSelection, gsapCommitMutation], ); + // Unroll all computed (helper/loop) tweens in the active timeline into literal + // tweens, so the clicked keyframe becomes directly editable. Visual no-op. + const handleUnroll = useCallback(() => { + void commitMutation( + { type: "unroll-timeline" }, + { label: "Unroll to literal tweens", softReload: true }, + ); + }, [commitMutation]); + return { handleGsapAwarePathOffsetCommit, handleGsapAwareBoxSizeCommit, @@ -237,6 +257,7 @@ export function useGsapAwareEditing({ commitAnimatedProperty, handleSetArcPath, handleUpdateArcSegment, + handleUnroll, commitMutation, }; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 90b8d7a290..cb872b6cfc 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 } : {}), }, ]; } @@ -358,19 +360,30 @@ export function usePopulateKeyframeCacheForFile( const sf = sourceFile; fetchParsedAnimations(projectId, sf).then((parsed) => { - if (!parsed) return; + if (!parsed) { + return; + } const { setKeyframeCache } = usePlayerStore.getState(); - // Drop the file's stale entries (including the bare keys consumers read) - // before repopulating, so an element whose keyframes were removed and is - // absent from this scan doesn't keep showing diamonds. clearKeyframeCacheForFile(sf); const { elements } = usePlayerStore.getState(); + console.log( + "[kf:static] elements in store:", + elements + .map((e) => e.domId) + .filter(Boolean) + .join(", "), + ); const mergedByElement = new Map(); for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id) continue; + if (anim.hasUnresolvedKeyframes) { + continue; + } const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); - if (!kfData) continue; + if (!kfData) { + continue; + } const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; @@ -402,6 +415,12 @@ export function usePopulateKeyframeCacheForFile( mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); } } + console.log( + "[kf:static] merged elements:", + [...mergedByElement.keys()].join(", "), + "kf counts:", + [...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "), + ); for (const [id, kfData] of mergedByElement) { setKeyframeCache(`${sf}#${id}`, kfData); setKeyframeCache(id, kfData); @@ -428,14 +447,37 @@ export function usePopulateKeyframeCacheForFile( const iframe = iframeRef?.current ?? document.querySelector("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); + 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,