Skip to content

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

Merged
miguel-heygen merged 1 commit into
mainfrom
codex/studio-timeline-a-runtime-eases-v2
Jul 25, 2026
Merged

feat(core): add deterministic keyframe ease runtime#2672
miguel-heygen merged 1 commit into
mainfrom
codex/studio-timeline-a-runtime-eases-v2

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Add the deterministic easing runtime used by Studio-authored keyframes, including custom curves, hold, spring, and wiggle easing.

This is Family A, PR 1 of 4. It is rooted on main; the next PR is codex/studio-timeline-a-parser-intent-v2.

Replacement review history: #2561. Golden reference: #2387.

Why

Studio must interpret and render every authored ease identically in preview and capture. Keeping the easing parser and runtime registration together gives the stack one deterministic owner for those semantics.

How

The runtime registers the supported custom-ease forms once per GSAP instance, exposes the shared motion-ease model through Core, and keeps caches scoped to each installation. Install, parse-fallback, and per-child repair failures emit diagnostics instead of silently changing visual output.

Review hardening also adds finite-input guards, makes hot-reloaded GSAP instances reinstall safely, keeps public and segment parsing on the same fallback path, isolates malformed children during repair, and regenerates the checked-in position-edit runtime artifact. Custom curves intentionally use the single-cubic M0,0 C… 1,1 contract; PR #2674 validates that contract before committing.

Current main already contains the merged runtime bridge from #2558; this PR does not recreate it. The wiggle subpath is consumed by the Studio editor in PR #2674.

Test plan

  • Unit tests added/updated — 84 focused Core tests passed on the rebased stack
  • Manual testing performed — covered by the certified integrated Studio QA matrix before stack materialization
  • Full workspace production build passed on latest main
  • Documentation updated (not applicable; internal runtime and Studio behavior)

miguel-heygen commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-a-runtime-eases-v2 branch 4 times, most recently from b64c98d to 73b793d Compare July 21, 2026 10:18
@miguel-heygen
miguel-heygen marked this pull request as ready for review July 25, 2026 10:41

@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 (with notes)

Rule applied: RIGHT-direction non-blocker findings → APPROVE with findings inline (per COMMENTED-vs-APPROVE gate). Deterministic runtime contract holds; noted items are all defensive nits.

Multi-reviewer inventory: parallel review with Rames. At submission time no other review had posted, so this is the first read. Overlap section will fill in on their post.

What holds up

  • No non-deterministic sources in the eval path — no Math.random / Date.now / performance.now in customEase.ts, wiggleEase.ts, or springEase.ts. Deterministic across arches.
  • Endpoint guards (progress <= 0 → 0, progress >= 1 → 1) are explicit in all three evaluators. Boundary behavior is anchored, no float slop at t=0/t=1.
  • evaluateCubicBezier control-point bounds check (Math.min(x1,x2) < 0 || Math.max(x1,x2) > 1 → null) rejects out-of-range curves, matching the CSS cubic-bezier compat contract.
  • parseSpringBounce clamps to [0,1] and rejects non-finite; evaluateSpringEase normalizes the tail via endpoint = 1 - exp(-decay)cos(ω) so final = 1 exactly. Well-formed.
  • parseWiggleEase validates Number.isSafeInteger(wiggles) && wiggles >= 1 and amplitude bounds. Solid input hygiene.
  • Cache keying (WIGGLE_CACHE, springEaseCache, customEaseCache) is stable and re-entrant — same input → same cached function reference.
  • installStudioCustomEase routes both the public parseEase override and internal registerEase configs through the same resolveHyperframesEase — single-source-of-truth resolution. The block comment about GSAP's internal _easeMap fallback is exactly the right rationale.

Nits / follow-ups (non-blocking)

  1. packages/core/src/runtime/customEase.ts:31 — bisection accepts non-monotonic-in-t curves. The x-bounds check keeps x1, x2 ∈ [0,1] but does not guarantee dx/dt ≥ 0 on t ∈ [0,1]. For a curve like custom(M0,0 C1,0 0,1 1,1) (x1=1, x2=0) the x-parametrization has a stationary point at t≈0.5; bisection then returns a t where progress-to-t is not unique. Not a P1 (CSS itself has the same limitation), but if you ever accept authored curves that fall in this window you'll get a silent evaluation drift downstream — worth either documenting the compat contract or adding a monotonicity check.

  2. packages/core/src/runtime/customEase.ts:26 — NaN progress falls through both endpoint guards. NaN <= 0 and NaN >= 1 are both false, so bisection runs with < progress always false (< NaN is false), high stays at 1, low stays at 0, and the function returns cubicCoordinate(0.5, y1, y2) — a garbage but plausible number. Consider if (!Number.isFinite(progress)) return progress before the guards so upstream bugs surface instead of hide.

  3. packages/core/src/runtime/wiggleEase.ts:40 — mid-progress values can exit [0,1]. By design for wiggle (envelope × sine), but for uniform type with amplitude=1 and higher wiggles, sample values can reach ~1.25 or ~-0.25 mid-progress. Endpoints stay pinned at 0/1 so the ease itself is well-formed, but downstream consumers that clamp opacity / scale to [0,1] will visibly clip. Worth a comment on the function, or a tighter amplitude bound (e.g. ≤ 0.5) if wiggle is meant for opacity-family properties.

  4. packages/core/src/runtime/customEase.ts:14 — 24-step bisection is safe but conservative. Converges to ~6e-8, fine. Newton-Raphson on dx/dt would hit machine epsilon in ~4-5 iterations; if this ever shows up on a per-frame CPU flame the swap is cheap. Not needed now.

None of these block the merge. Runtime contract is the anchor for #2673/#2674/#2675 and the anchor holds.

— 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.

Reviewed at 73b793d4db79c6a72e63292eb9de37029f313f81.

Foundational contract for Family A — the deterministic ease runtime that #2673, #2674, #2675 all build on. The math is arithmetically sound (no PRNG, no Date.now, no Math.random; wiggle is closed-form sinusoidal, spring endpoints verified numerically stable, cubic-bezier bisection at 24 steps for ~6e-8 precision). Regex families (WIGGLE_TOKEN, SPRING_TOKEN, CUSTOM_CUBIC_PATH) are anchored with bounded quantifiers — no ReDoS. Endpoint invariants (progress=0 → 0, progress=1 → 1) hold across all four ease modes.

No blockers. Structural concerns focus on OBSERVABILITY, GRAMMAR STRICTNESS, and EXTENSIBILITY — the classes of debt that compound when 3 more slices build on top.

Cross-cutting theme: SILENT-CATCH CHAIN with zero prod observability

Four different silent degradations, each individually reasonable, together forming an observability black hole:

  1. init.ts:181/186 — install throw or installStudioCustomEase(g) === false silently swallowed
  2. customEase.ts:123 — segment-path .config() fallback resolves to (progress) => progress (identity/linear) on any malformed ease
  3. customEase.ts:107 — public parseEase falls through to GSAP's original parser (different fallback than #2!)
  4. init.ts:1237 — repair loop has no try/catch; mid-iteration parseEase throw leaves timeline half-repaired

None of these fire a diagnostic. If the runtime is subtly wrong in prod, the ONLY signal is user-visible wrong easing. With 3 downstream slices consuming this runtime, one silent-catch trip cascades to "every user-authored ease renders wrong" with no bug ticket ever getting filed.

Recommendation: emit emitAnalyticsEvent(...) on every silent-catch fallback. Same instrumentation shape already lives elsewhere in the file. Cost is trivial; the alternative is debugging a prod regression across 4 PRs' worth of code.

Cross-cutting theme: GRAMMAR STRICTNESS mismatches downstream consumer flexibility

CUSTOM_CUBIC_PATH at customEase.ts:16 accepts EXACTLY one cubic Bezier segment ending at (1,1). This locks the authorable custom-ease space to what the regex accepts. Meanwhile #2674 will surface a curve editor — if the editor emits any richer path (overshoot, multi-segment, hold intervals mid-curve), the parser silently rejects and the ease renders as GSAP default. Failure mode is: user drags the curve editor, thinks it committed, animation renders with a different ease.

Recommendation: extend the grammar to match Figma's authorable custom-ease space (overshoot is common), OR add Studio-side validation before commit so the failure is visible client-side, not silently at runtime. Inline anchor + fix options.

Cross-cutting theme: CACHE-SCOPING inconsistency will drive slice-N pattern imitation

WIGGLE_CACHE is module-global; customEaseCache and springEaseCache are per-install-call. The first slice that adds a new cache will imitate whichever is closest to their file — 50/50 they pick wrong. In-tab liveness (Studio hot-reload) is the pain point for module-global; there's no LRU cap.

Recommendation: pick one policy for the family and document it in a CONTRACT comment before slice 2 imitates the wrong one.

Contract observations for the next 3 PRs

Slices #2673 / #2674 / #2675 will inherit these decisions from this file. Documenting so they're not silently pattern-matched:

  • Ease grammar is FROZEN as: hold, spring(bounce∈[0,1]), wiggle(int≥1, type, amp∈[0,1]?), custom(M0,0 C x1,y1 x2,y2 1,1). Any Studio UI or parser that emits outside this grammar silently falls through to GSAP's default parser and diverges from render. Slice 2 (parser intent) must not emit outside this grammar; slice 3 (ease editor) must gate its emissions against these regexes.
  • Silent-catch chain at four call sites — slice 4 (duration-authored timing) depends on ease correctness; if a golden-hash CI test isn't pinned before then, silent-linear rendering will not be detected.
  • Cache scoping is inconsistent (module-global wiggle, per-install spring/custom). Do NOT propagate the module-global pattern in slice 2 — pick one and stick.
  • No plugin/DI hook on installStudioCustomEase. If any of slices 2-4 introduces a new ease family, refactor to a registry FIRST or accept N-way merge conflict.
  • New public subpath ./wiggle-ease locks wiggle types + fns as public API. Don't cargo-cult new subpaths in the follow-ups.
  • Cross-engine determinism is ASSERTED not PROVEN. Math within a single JS engine is deterministic (no PRNG, no time, no iteration-order). But Math.sin/cos/exp are not bit-identical across JS engines (V8 vs SpiderMonkey vs JavaScriptCore vs even engine versions). Slice 4's duration-authored timing preservation claim needs a byte-level golden hash test to close this proof — otherwise preview/capture ULP divergence is a lurking prod issue.
  • window.gsap-based idempotence guard at :182 is fragile against instance replacement (hot-reload). If any slice touches gsap-instance lifecycle, revisit.
  • inner._ease mutation at :1244 writes to a GSAP-private field. If any GSAP minor upgrade renames _ease_e (mangled builds do this already in some versions), the repair silently no-ops.

🟢 Verified-safe

  • Zero non-determinism sources: no Math.random, no Date.now, no PRNG seed for wiggle (closed-form sinusoidal), no Map/Set iteration for logic (only cache reads).
  • Wiggle endpoints: progress=0 and progress=1 return exactly 0 and 1 regardless of type/amplitude.
  • Spring endpoints: endpoint = 1 - exp(-decay)*cos(angularFrequency) is nonzero over the clamped bounce range (decay ∈ [6,12], angularFrequency ∈ [2π, 5π]); divide can't blow up.
  • Wiggle regex rejects wiggles=0, non-integers, unknown types, amp<0, amp>1. Number.isSafeInteger caps count so wiggle(1e100, easeOut) returns null (no DoS).
  • HOLD_EASE is a pure step at progress≥1 — matches Figma motionEase.test.ts:22.
  • .config(...params) → params.join(\",\") losslessly round-trips custom() bezier paths.
  • installStudioCustomEase is NOT re-exported from packages/core/src/index.ts — kept internal.
  • Bisection accuracy at 24 steps ≈ 6e-8 precision, adequate for keyframe timing.
  • Cache keys numerically normalize (0.2 vs 0.20 → same key).

Nits

  • Bezier X-controls clamp is silent (customEase.ts:33): Math.min(x1,x2) < 0 || Math.max(x1,x2) > 1 rejects otherwise-valid overshoot bezier paths (rare in Studio; bisection assumes monotonic X so the clamp is algorithmically necessary). Document why + log rejections.
  • parseGsap* object-array duration drop (gsapParser.ts:689 + gsapParserAcorn.ts:978): duplicate-time collapse currently drops duration from %-keyed object-keyframe properties. Test asserts absence but not tween-level round-trip — worth a round-trip idempotence assertion.

Solid arithmetic foundation. The observability + grammar-strictness gaps are the ones that will bite in prod; they're cheap to close now and expensive to retrofit after slices 2-4 land. Please fold in the silent-catch instrumentation + a Studio-side validator for the custom() grammar before this family merges as a unit.

Review by Rames D Jusso

Comment thread packages/core/src/runtime/init.ts
Comment thread packages/core/src/runtime/customEase.ts Outdated
Comment thread packages/core/src/runtime/customEase.ts
Comment thread packages/core/src/runtime/wiggleEase.ts
Comment thread packages/core/src/runtime/init.ts Outdated
Comment thread packages/core/src/runtime/init.ts
Comment thread packages/core/src/runtime/customEase.ts
Comment thread packages/core/package.json
@miguel-heygen
miguel-heygen force-pushed the codex/studio-timeline-a-runtime-eases-v2 branch from 73b793d to 5acbf24 Compare July 25, 2026 12:15
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Review follow-up: addressed the runtime observability, consistent fallback, instance-scoped cache/idempotence, finite-input, and isolated repair concerns. Vance notes are also covered: NaN is guarded and wiggle overshoot is documented as intentional. The supported production engine remains V8, so a cross-engine byte-golden is outside this PR contract; the description now states the bounded custom-curve scope.

@miguel-heygen
miguel-heygen merged commit 22d9b75 into main Jul 25, 2026
72 of 89 checks passed
@miguel-heygen
miguel-heygen deleted the codex/studio-timeline-a-runtime-eases-v2 branch July 25, 2026 12:33

@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.

Reviewed at 5acbf240cbab38594d06b59b9ef7f2a4b37d31b7 (post-Rames R1 fix pass; also post-merge).

Verdict: COMMENT

Rule applied: PR merged (MERGED state) so REQUEST_CHANGES has no teeth; the findings below are all follow-up items for the family's next slices (#2673, #2674, #2675). One P1 semantic-contract miss, three P2 defensive/observability gaps, two P3 nits — nothing that would have blocked merge alone, but the P1 is worth catching in a follow-up before the runtime widens.

R1 delta: the eight Rames items are landed cleanly — I re-verified each at head:

  • init.ts:206 install-throw / install-false now both emit custom_ease_install_failed (reasons threw / no_parseEase / missing_gsap); ✓
  • customEase.ts parseEaseWithFallback is now the single owner used by both the public parseEase override and the .config internal path, so segment-mode malformed eases share the GSAP fallback and emit custom_ease_parse_failed on hold/spring/wiggle/custom-prefixed tokens; ✓
  • WIGGLE_CACHE is gone — wiggle cache is now per-install (wiggleEaseCache closure at customEase.ts:82); ✓
  • Idempotence moved from window.__hfCustomEaseRegistered to gsap.__hfCustomEaseInstalled on the instance; ✓
  • repairKeyframeInnerEase (init.ts:1262) wraps each child in its own try/catch and emits keyframe_ease_repair_failed { ease } per failure. ✓

Multi-reviewer inventory: parallel with James/Rames' R1 (COMMENTED, no blockers). R1 findings are all [FIXED] per Miguel's inline replies; my R2 adds NEW findings only — no overlap. Vance's own R1 APPROVE covered the deterministic-source claim and endpoint invariants; the P1 below is a specific counter-example to that endpoint claim.

P1 — Infinity leaks through the endpoint guards in all three evaluators

packages/core/src/runtime/customEase.ts:37, packages/core/src/runtime/wiggleEase.ts:33, packages/core/src/parsers/springEase.ts:20 — the ordering is:

if (!Number.isFinite(progress)) return progress;
if (progress <= 0) return 0;
if (progress >= 1) return 1;

Number.isFinite is false for BOTH NaN and ±Infinity, so +Infinity returns +Infinity (should clamp to 1 per the stated progress ≥ 1 → 1 invariant) and -Infinity returns -Infinity (should clamp to 0). The R1 review body explicitly praised "Boundary behavior is anchored, no float slop at t=0/t=1" — but the deterministic-endpoint claim does not survive ±Infinity input. The only test at customEase.test.ts:29 covers Number.NaN; there is no ±Infinity test.

In practice GSAP internally clamps progress to [0, 1] so this is unlikely to fire in production today, but any future caller invoking the exported parseEase chain with a computed progress (e.g. a duration division that hit Infinity on a zero-duration segment) will get Infinity cascading through the numeric pipeline instead of a pinned endpoint.

Fix in the next slice. Two equivalent shapes:

// Option A: reject only NaN, let ±Infinity fall through to the clamps
if (Number.isNaN(progress)) return progress;
if (progress <= 0) return 0;      // catches -Infinity
if (progress >= 1) return 1;      // catches +Infinity
// Option B: swap the order
if (progress <= 0) return 0;
if (progress >= 1) return 1;
if (!Number.isFinite(progress)) return progress;

Please add expect(evaluateSpringEase(Number.POSITIVE_INFINITY, 0.5)).toBe(1) and the negative counterpart to each evaluator's test file.

P2 — Silent typo classification gap in custom_ease_parse_failed diagnostic

packages/core/src/runtime/customEase.ts:107:

if (typeof ease === "string" && /^(?:hold|spring|wiggle|custom)(?:\(|$)/.test(ease.trim())) {
  emitAnalyticsEvent("custom_ease_parse_failed", { ease });
}

The diagnostic only fires when the ease string starts with one of the four HF prefixes. A Studio-authored typo like sprong(0.5), holr, or custome(...) does not match the anchor, falls to originalParseEase.call(...) ?? IDENTITY_EASE, and if GSAP's own parser also can't resolve it, silently becomes linear — no analytics event on the failure path. Because Studio's ease UI likely constrains authored values, this is low probability today, but when #2674 exposes the ease widget to broader authoring, the observability window closes exactly when it needs to open. Consider either a custom_ease_unrecognized_string event when both HF and GSAP resolvers return null, or extend the prefix regex to include a near-miss ruleset.

P2 — params.join(",") reconstruction is a closed contract with GSAP internals

packages/core/src/runtime/customEase.ts:143-146:

base.config = (...params) => parseEaseWithFallback(`${name}(${params.join(",")})`, gsap);

The inline comment claims GSAP does not trim or whitespace-normalize when it comma-splits .config params, so the round-trip is lossless. The customEase.test.ts:60-65 test asserts this by calling customConfig("M0", "0 C0.25", "0.1 0.25", "1 1", 1) — but that ONLY tests the identity of .join(",") on already-untrimmed splits. If a future GSAP release trims each param (.trim() inside its splitter) or normalizes internal whitespace, the reconstruction yields custom(M0,0 C0.25,0.1 0.25,1 1,1) unchanged — but the round-trip drops the intended leading-space at each comma. That's benign for the current CUSTOM_CUBIC_PATH regex (\s* between numbers) but is a brittle contract worth pinning with a version-check on GSAP or a .config invariant test that exercises the segment-mode path end-to-end against a real GSAP build (not the mock at customEase.test.ts:34).

P2 — Math.exp / Math.cos / Math.sin are implementation-defined per ECMA-262

springEase.ts:25-27, wiggleEase.ts:56 — the "deterministic keyframe ease runtime" contract is stronger than what ECMAScript actually guarantees for the transcendentals. Per ECMA-262 §21.3.2, the choice of algorithm for Math.exp, Math.cos, Math.sin is left to the implementation, and V8/JavaScriptCore/SpiderMonkey have historically returned different last-bit values for identical inputs. In practice HeyGen renders preview and capture on the same Chromium build so the collapse is safe today, but the "deterministic" label in the PR title survives only under that assumption — a Chromium version bump or a captor-vs-preview browser split will silently drift. Not blocking; worth documenting as an "assumes-same-engine" caveat in the runtime module doc so future consumers do not lean on the label as a cross-engine guarantee.

P3 — Ease caches grow monotonically

customEase.ts:82-84customEaseCache, springEaseCache, wiggleEaseCache are per-install Maps with no eviction. For statically-authored compositions this is trivially bounded. For any consumer of #2674's ease widget that regenerates the ease string dynamically (e.g. per-frame spring(bounce) with a changing bounce parameter), each unique string leaks a RuntimeEase closure for the lifetime of the GSAP instance. Consider a soft cap or LRU when the widget lands.

P3 — custom_ease_parse_failed has no per-string dedupe

customEase.ts:108 fires per-call. If a bad ease is used at 60 fps for a 10s render, we ship 600 identical analytics events per render. Cheap to dedupe with a Set<string> guard inside the closure.

Adversarial lenses

  • A — Numerical determinism: fired. Infinity guard-ordering bug (P1); transcendental-function portability caveat (P2). No other determinism issues surfaced (endpoint pinning is bit-exact at t = 0/t = 1 for finite input; cache keys are float-equality safe for the normalized bounce values and integer wiggles counts; no Math.random / Date.now / performance.now in the eval path).
  • B — Concurrency & lifecycle: clean. Reinstall-safety is now stamped on the GSAP instance; closures capture only per-install maps; no shared module state remains after the WIGGLE_CACHE fix.
  • C — Bezier / bisection / wiggle math: clean at head. createCubicBezierEase rejects x1, x2 ∉ [0, 1] which is the CSS-spec sufficient condition for x-monotonicity, so the R1 non-monotonic-bisection concern does not fire. Bisection converges to ~6e-8 in 24 steps. easeInOut wiggle is symmetric under time-reversal (sin(π·(1-p)) = sin(π·p); sin(2π·N·(1-p)) = -sin(2π·N·p) for integer wiggles, and the leading direction sign preserves symmetry after subtracting from 1-p). Wiggle overshoot outside [0, 1] mid-progress is documented as intentional.
  • D — Boundary contracts: endpoint pinning holds bit-exactly for finite progress; fails on ±Infinity (see P1). Cross-segment continuity is one-sided: prior segment yields 1 at progress=1, next segment yields 0 at progress=0, and consumers see whichever from/to value the two adjacent tweens declare.
  • E — Backwards compatibility: packages/core/src/parsers/springEase.ts retains export * from "@hyperframes/parsers/spring-ease" — the deprecation JSDoc was replaced with a "why the re-export stays" comment; legacy importers of SpringPreset, SPRING_PRESETS, generateSpringEaseData still resolve. New subpath @hyperframes/core/wiggle-ease is additive. No existing golden required a behavior-locked update.

— Via

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