Skip to content

feat(core): add deterministic keyframe ease runtime#2561

Open
miguel-heygen wants to merge 1 commit into
codex/pr2387-thumbnail-infrafrom
codex/pr2387-ease-runtime
Open

feat(core): add deterministic keyframe ease runtime#2561
miguel-heygen wants to merge 1 commit into
codex/pr2387-thumbnail-infrafrom
codex/pr2387-ease-runtime

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Keyframe easing now evaluates deterministically in preview and render, including custom curves, spring, wiggle, and hold semantics.

Stack

Part 3 of 20. Parent: #2559. Next: #2572. The golden reference, #2387, remains open and unchanged.

Test plan

  • Integrated Studio suite: 2,800 tests passed
  • Integrated parser suite: 853 tests passed
  • Studio and parser typechecks passed
  • Studio and parser production builds passed
  • Per-PR CI completes on the submitted stack

Post-Deploy Monitoring & Validation

Validation window: first 24 hours after the stack merges. Owner: Studio maintainers. Watch browser console and support reports for [Timeline], gsap-parser, failed keyframe mutations, or preview/render easing mismatches. Healthy means edits persist and preview/render agree; revert the first failing layer if authored animation data changes unexpectedly.


Compound Engineering
Codex

@miguel-heygen
miguel-heygen changed the base branch from codex/pr2387-runtime-bridge to graphite-base/2561 July 16, 2026 20:47
@miguel-heygen
miguel-heygen force-pushed the codex/pr2387-ease-runtime branch from 1e325a7 to 3a59b01 Compare July 16, 2026 20:47
@miguel-heygen
miguel-heygen changed the base branch from graphite-base/2561 to codex/pr2387-thumbnail-infra July 16, 2026 20:47

miguel-heygen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE — new mechanism, backward-compat verified, deterministic by construction, well-tested.

Mechanism

In-runtime evaluators for four Studio ease tokens — hold, spring(<bounce>), wiggle(<n>,<type>[,amp]), custom(M0,0 C x1,y1 x2,y2 1,1) — plus installStudioCustomEase patches BOTH parseEase (public) and registerEase (internal _easeMap used at keyframe-segment resolution). Determinism comes from closed-form evaluation (spring: endpoint-normalized damped cosine; wiggle: sinusoidal envelope by type) and fixed-step bisection (24 iters → ~6e-8 accuracy on t) for the cubic-bezier path. Bisection over Newton is a deliberate determinism tradeoff — a doc-comment naming that would help future maintainers resist "optimizing" it, non-blocking.

Ground-truth-first — no pre-existing runtime evaluator

Grepped base branch: no installStudioCustomEase, no parseSpringBounce/evaluateSpringEase, no wiggleEase. The custom(...) token was parser-recognized (gsapParser.test.ts:644 at base) but had no runtime evaluator. Prior parsers/springEase exported SVG-path-based SpringPreset/SPRING_PRESETS used at figma emit time via CustomEase.create(...) — a distinct mechanism (path-based via GSAP plugin) that stays in place. Figma-emitted CustomEase.create(...) still routes through GSAP's plugin; spring(0.5) routes through the new closed-form evaluator. Two mechanisms coexist because they cover disjoint notations. No redundant-alias risk.

Spec correctness per ease type

  • hold(p) => p >= 1 ? 1 : 0. figma NAMED_EASE.hold shifts from "steps(1)""hold"; the two are functionally equivalent (step at end).
  • custom(...) — bisection on x(t) = progress, then y(t). Monotonicity guard rejects x1,x2 ∉ [0,1]. y1,y2 deliberately unbounded (overshoot beziers legitimate). Value-side lock: first(0.5).toBeCloseTo(0.5, 6) — I re-derived analytically for the symmetric (0.42,0,0.58,1): x(0.5) = 3·0.25·0.5·(0.42+0.58) + 0.125 = 0.5 and y(0.5) = 0.5. Matches. first(0.25).toBeCloseTo(0.1292, 4) matches CSS ease-in-out reference.
  • spring(<bounce>)y = (1 − e^{−decay·p}·cos(ω·p)) / endpoint, decay = 12−6·bounce, ω = 2π(1+1.5·bounce). Endpoint never crosses zero for bounce ∈ [0,1] (checked bounds: ≈0.999994 at bounce=0, ≈1.00248 at bounce=1). Bounce clamped [0,1] at parse.
  • wiggle(N, type[, amp]) — sinusoidal envelope by type; endpoint-pinned; Number.isSafeInteger guard on N; amplitude in [0,1] at parse.

All four short-circuit progress <= 0 → 0 and progress >= 1 → 1. Invalid inputs return null from resolve* and fall through to originalParseEase. GSAP config-args path handled: custom(M0,0 C0.25,0.1 0.25,1 1,1) comma-splits into 5 args, params.join(",") reconstitutes losslessly — customEase.test.ts:66 exercises this.

Backward-compat on existing goldens

The 22 regression shards + preview-parity + player-perf lanes are all green at head — the load-bearing signal for byte-level parity on existing compositions. Two potentially-breaking touchpoints checked:

  • hold → hold vs prior hold → steps(1) for figma-imported motion. Functionally equivalent (both step-at-end).
  • Parser fix: duration field in %-keyframe object entries now skipped (was previously round-tripping as a bogus animatable lane — a bug that no base-branch test exercised, hence no golden regression from correcting it).

Both safe.

Test coverage — value-side and reachability-side

  • Value-side: exact toBeCloseTo on cubic-bezier decimals; endpoint pins on wiggle/spring; monotonicity on spring bounce; extrema count ≥ 8 on 6-wiggle 401-sample series.
  • Reachability-side: init.test.ts fixtures route through initSandboxRuntimeModular — the tests verify window.gsap.parseEase("hold") and parseEase("custom(...)") resolve via the installed path, not just via direct-import of the exported helpers. The inner-timeline _ease-repair path has its own fixture that pre-builds a keyframes tween with _ease: undefined and asserts it becomes a function post-init, which pins the load-order race repair.

GSAP-race handling

ensureStudioCustomEase is idempotent via __hfCustomEaseRegistered and gets re-called from bindRootTimelineIfAvailable — closes the "GSAP not ready at DOMContentLoaded" race. repairKeyframeInnerEase iterates getChildren(true, true, true) at bind-time and re-resolves any keyframe tween whose inner timeline._ease bakes to undefined from _parseEase running before registration completed. Well-motivated: the composition inline script builds tweens before this runtime's install call, and GSAP resolves the inner ease exactly once at build (gsap-core: tl._ease = _parseEase(keyframes.ease || vars.ease || "none")), so registering-after can't retro-fix an already-baked undefined without this explicit repair.

Perf

Per-sample: 24 fixed-iteration bisection (bezier) or 1-2 transcendentals (spring/wiggle). All eases cached per-parameter-signature; wiggle cache is module-level but bounded in practice by few integers × 4 types × few amplitudes. No per-sample allocation. Determinism → frame-time-independence: same (progress, params) → byte-identical output across platforms, which is the render-reproducibility invariant the PR title claims.

CI / peer

All 22 regression shards + preview-parity + player-perf + preflight green at 3a59b01. mergeable_state=unstable because downstack #2559 open — expected for the Graphite stack; Graphite mergeability_check is IN_PROGRESS and non-blocking. No prior reviews.

Nits (non-blocking)

  • WIGGLE_CACHE module-level (bounded, small state — no real leak).
  • Bisection-over-Newton is intentional but undocumented in-file; a one-line comment would future-proof against optimizer-drift.
  • No test for custom(...) with y1,y2 > 1 (overshoot bezier). Behavior trivially correct by construction; a doctest would catch a future guard that accidentally clamps.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial pass at HEAD 3a59b01f6ff5d719e8712822c29500f2fe7b1e94. Context: HF#2561 is slice 3/20 — the foundational deterministic keyframe ease runtime that the next 17 PRs in the keyframe-UI series will build on. Decomposition from unreviewed golden reference #2387 (OPEN, REVIEW_REQUIRED). Deepest scrutiny goes here because contract flaws are exponentially harder to walk back at slice 15/20 than at 3/20.

Byte-identity vs #2387 (informational)

19/20 IDENTICAL — only init.test.ts differs (ref 99e235b vs new 072b3416), presumably because the ease tests exist at HEAD but not in the golden reference (or the reference has other unrelated test churn). The mechanism code is a clean carve-out from the unreviewed parent.

Determinism invariants (verified)

  • No Math.random / Date / performance.now / crypto / new Date in the ease evaluation path — grep-verified across wiggleEase.ts, customEase.ts, springEase.ts.
  • No PRNG for wiggle — closed-form sin(TAU * wiggles * progress) envelope. Deterministic by construction given (progress, wiggles, type, amplitude).
  • Cubic bezier x-monotonicity preserved — guard Math.min(x1,x2) < 0 || Math.max(x1,x2) > 1 at customEase.ts:53 ensures x(t) is monotonic non-decreasing when x1, x2 ∈ [0, 1], so bisection converges to a unique t.
  • Boundary exact — all three curves return exact 0 at progress <= 0 and exact 1 at progress >= 1 (customEase.ts:33-34, springEase.ts:22-23, wiggleEase.ts:36-37). No float drift at endpoints.
  • Spring endpoint never zero — with bounce ∈ [0,1], endpoint = 1 - exp(-decay) * cos(ω) ∈ ~[0.9996, 1.0025]. No div-by-zero risk.
  • Regex not ReDoS-vulnerable — bounded, non-nested quantifiers with disjoint alternations.

Cross-engine caveat (unverified, not violation): ECMAScript spec does NOT mandate correctly-rounded Math.exp/sin/cos. Preview + render both run in V8 (Chrome + headless Chrome), so should agree in practice — but this PR has zero cross-engine determinism test. The claim "preview and render agree" is asserted, not proven. Worth pinning a golden byte-level hash of sampled outputs on CI (proves stability across V8 versions too).

Findings

Medium concerns (4):

  • C1 WIGGLE_CACHE is a module-global unbounded Map — worker-fleet memory leak. packages/core/src/runtime/wiggleEase.ts:13 declares const WIGGLE_CACHE = new Map<string, WiggleEase>() at module scope with no eviction. A render worker services many compositions and accumulates entries forever. Meanwhile springEaseCache and customEaseCache are install-scoped (per installStudioCustomEase call) — inconsistent scoping for no stated reason. User-controlled ease strings feed keys, so adversarial input can drive unbounded growth. Fix: either move WIGGLE_CACHE into installStudioCustomEase (matches spring/custom) or bound all three with an LRU.

  • C2 Silent-catch on registration hides a total-failure mode with no telemetry. packages/core/src/runtime/init.ts:180-184ensureStudioCustomEase swallows a throw from installStudioCustomEase with a comment "falling back to GSAP's default ease is preferable to a broken runtime". If the install throws, hold/spring(...)/wiggle(...)/custom(...) all silently fall through to GSAP's originalParseEase, which returns null/undefined for these strings → downstream "_ease is not a function" masked as cross-origin Script error (which the PR itself calls out at :1216). This is the "invalidation story" — a subtly wrong render will produce no observable failure in production monitoring. Given this is the foundational contract for the next 17 PRs, silent registration failure is a landmine. Fix: swallow(err) through ./diagnostics (imported at :52) or emit a postMessage analytics event on catch. Zero-cost signal.

  • C3 __hfCustomEaseRegistered guards on window, not window.gsap identity. packages/core/src/runtime/init.ts:174-184 — the flag is set on window after the first successful install. If window.gsap is later replaced with a fresh GSAP instance (composition script reloads, race with the CDN import re-executing), the guard says "already done" and skips re-installation on the NEW gsap. parseEase on the new gsap is unpatched → hold/spring/etc. fall through to GSAP native → break. The test at init.test.ts:482 reproduces this exact scenario by explicit deletion, but a natural GSAP replacement in production wouldn't clear the flag. Fix: WeakSet<gsap> or stamp gsap.__hfCustomEase = true (key on gsap identity, not on window).

  • C4 evaluateSpringEase recomputes decay, angularFrequency, and endpoint per call. packages/core/src/parsers/springEase.ts:25-29. The spring closure (progress) => evaluateSpringEase(progress, bounce) in customEase.ts:65 captures only bounce; every eval call recomputes 1 - Math.exp(-decay) * Math.cos(angularFrequency) — a fixed value once bounce is fixed. At 60 fps × N springs × M keyframes-per-clip this is O(N·M·60) redundant exp + cos on the hot path. Fix: in resolveSpringEase (customEase.ts:63), precompute the three values once and close over them.

  • C13 repairKeyframeInnerEase runs only inside bindRootTimelineIfAvailable, not on subsequent rebinds. packages/core/src/runtime/init.ts:1204-1274 (repair helper at :1216ff, invocation at ~:1276). If a keyframes tween is added to the timeline AFTER the initial bind (e.g. via a delayed inline script or a requestAnimationFrame-deferred build), its inner _ease bakes to undefined and never gets repaired — subsequent renders throw the "_ease is not a function" the fix was supposed to prevent. The comment at :1204 acknowledges the load-order race but the repair only runs at bindRootTimelineIfAvailable; periodic rebinds per timelineRebindPolicy do re-run it, but only if the polling still catches new tweens. Worth confirming with the timelineRebindPolicy owner whether "children added after first bind" is a supported flow. If yes, repair should run on every rebind pass; the current code only runs it after a successful primary bind.

Low / informational (8):

  • C5 evaluateCubicBezier bisection has no early-termination — always 24 iterations. Consistent-time is arguably a feature for determinism; but Newton's method converges in ~4 steps. 24 × 2 × 4 = ~200 float ops per eval, ×60 fps ×N tweens. Perf note, not a defect.
  • C6 custom(...) regex "i" flag is dead code — customEase.ts:16-19 has case-insensitive regex, but customEase.ts:71 prefix-checks "custom(" case-sensitively. Pick one.
  • C7 wiggle anticipate amplitude default (0.12 at wiggleEase.ts:41) is untested. Add mirror assertion evaluateWiggleEase(p,6,"anticipate") === evaluateWiggleEase(p,6,"anticipate",0.12).
  • C8 parseSpringBounce grammar (springEase.ts:6) doesn't allow scientific notation; custom's NUMBER_SOURCE does. Asymmetric grammars.
  • C9 hold semantic — motionEase.ts:31 changed hold: "steps(1)" to hold: "hold". Behaviorally identical, but re-imported Figma comps now serialize hold instead of steps(1) — round-trip identity broken for that specific case (visual output identical). Existing on-disk "steps(1)" still works via GSAP native. Consider making the Figma mapper leave "steps(1)" for backwards-compat, or document the string migration.
  • C10 custom(...) grammar is strictly 4-point single-segment SVG (M 0,0 C x1,y1 x2,y2 1,1). GSAP's CustomEase plugin supports multi-segment. If any future slice or user-supplied path uses multi-segment C c1..c6 or S/Q/L, this runtime returns null and falls through to originalParseEase which requires the CustomEase plugin loaded → silent preview↔render parity break if plugin isn't injected in headless render. studio-server/src/routes/preview.ts:20 injects the CDN CustomEase only when hasCustomEase === true, based on motion.customEase detection — but the new custom(...) string form is NOT scanned by that detector. Fix: (a) document that only the exact 4-point form is supported and pin an assertion; or (b) extend htmlHasCustomEase/hasCustomEase at studio-server/src/routes/preview.ts:54-58 to also check for custom( strings.
  • C11 SUPPORTED_EASES at gsapConstants.ts:92-124 lists hold but doesn't list spring(0)/spring(1), wiggle(N,...), custom(...). Whatever validates ease strings against SUPPORTED_EASES (e.g. validateCompositionGsap, referenced in gsapWriter.acorn.test.ts:399) will accept hold but reject spring(0.5), wiggle(6,easeInOut), custom(...). Discoverability contract needs to know both.
  • C12 [informational] No property-based/fuzz test on determinism. All determinism assertions compare the same-run output twice — that's sequence-idempotency, not real determinism. A hidden Math.random() would still pass every current test. Consider pinning a golden byte-level hash of sampled outputs, committed to the repo, asserted on CI (also proves V8-version stability).

Green surface (verified)

  • Non-hyperframes eases (power2.out, native GSAP names) fall through to the original parser unchanged (customEase.ts:107-108).
  • installStudioCustomEase short-circuits on non-string input (customEase.ts:96).
  • Studio already emits custom(M0,0 C... 1,1) today (velocityEaseFitter.ts:12-14, EaseCurveSection.tsx:99) — this PR is backfilling a resolver for strings already in the wild, not introducing a new grammar.
  • installStudioCustomEase idempotency confirmed — second-invocation test at init.test.ts:478-480 verifies the installed parseEase is the same function object across repeated init calls.
  • Bezier bisection precision — 24 iterations ⇒ ~6e-8 t-precision, ample for pixel-accurate rendering.

Contract observations for the next 17 PRs

  • custom(...) is 4-point-only. Multi-segment path support = resolver upgrade + extend the CDN-injection detector in studio-server/src/routes/preview.ts:82 — otherwise silent preview↔render parity break.
  • Spring is single-parameter (bounce ∈ [0, 1]). GSAP supports 2-parameter (mass/damping); legacy Studio has spring-gentle/spring-bouncy named presets. If a slice introduces 2-param spring, SPRING_TOKEN grammar at springEase.ts:6 is exclusive of scientific notation and multi-arg.
  • Wiggle types are hard-enumerated to 4 at wiggleEase.ts:12 regex and again at :22-24. Adding a type = touching both.
  • Ease-registration side effectsinstallStudioCustomEase mutates gsap.parseEase and gsap.registerEase's internal map (via registerEase("hold"/"spring"/"wiggle"/"custom", ...)). If any future slice tries to register a plugin named "custom"/"spring"/"wiggle" via registerPlugin, it collides. Unlikely but noted.
  • SUPPORTED_EASES is the discoverability contract. New tokenized forms are silently supported by the runtime but not listed. Downstream UI slices need to know both surfaces.
  • Cache scoping is inconsistentWIGGLE_CACHE module-scope vs spring/custom install-scoped (C1). Whichever pattern is right, later slices should match.
  • Determinism claim relies on V8 Math.exp/sin/cos cross-context stability — undocumented anywhere in the PR. Later slices that add golden-frame comparisons should be aware and pin a byte-level golden hash on CI.

What I didn't verify

  • Did NOT run vitest suites; the 2,800/853 test counts in the PR body are as-claimed.
  • Did NOT verify "_ease is not a function" cross-origin script error root cause in a real GSAP build (trusting the comment at init.ts:1204-1215).
  • Did NOT check whether getChildren(true, true, true) in the vendored GSAP returns fully-flattened descendants — third arg is ignoreBeforeTime in newer GSAP releases, a semantic pun that could bite.
  • Did NOT verify installStudioCustomEase runs in the Studio browser preview path — tests are all vitest jsdom.
  • Did NOT verify gsap.registerEase API uniformity across the versions of GSAP hyperframes ships and pulls from CDN.
  • Did NOT benchmark the 24-iteration bisection cost numerically.

Merge gate

Solid mechanism, cleanly scoped, deterministic by construction. The four medium concerns (C1 unbounded cache, C2 silent registration failure, C3 gsap-identity guard, C4 hot-path recomputation) are the ones I'd address before merge because they compound over the next 17 slices — subtly wrong renders + memory leaks + registration-race breakage will be harder to root-cause once more of the stack is in play. C13 (repair scope) merits a quick verification with the timelineRebindPolicy owner. The low-severity items are follow-up candidates.

LGTM from my side on the core determinism contract — leaving as COMMENTED.

Review by Rames D Jusso

This was referenced Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants