Skip to content

feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing#1694

Merged
miguel-heygen merged 12 commits into
mainfrom
feat/speed-curves
Jun 24, 2026
Merged

feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing#1694
miguel-heygen merged 12 commits into
mainfrom
feat/speed-curves

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Per-keyframe speed curve editing, velocity-based ease fitting, and gesture smoothing.

  • Easy Ease presets: correct bezier values (0.333, 0, 0.667, 1) as first-class presets in the Animation panel grid — softer deceleration matching industry-standard motion tools.
  • Per-keyframe ease editing: each keyframe segment has its own ease, editable via expandable KeyframeEaseList with EaseCurveSection bezier editor per segment. Parser preserves per-keyframe ease through round-trips.
  • Velocity-based curve fitting: gesture recordings analyze the velocity profile of raw samples and assign per-keyframe custom eases — decelerating segments get Easy Ease In, accelerating get Easy Ease Out, constant speed stays linear.
  • Gaussian gesture smoothing: recorded pointer movements pass through a Gaussian-weighted moving average (radius 3) before commit, eliminating jitter while preserving motion shape.
  • Position-only set tweens: show compact "Position x, y — drag to move" row instead of full animation card.
  • AnimationCard extraction: PropertyRow, AddPropertyTrigger, and helpers moved to AnimationCardParts.tsx to stay under 600 LOC.
  • 4 velocity fitter + 4 gesture smoother unit tests.

Test plan

  • Easy Ease preset in grid shows correct curve shape
  • Record gesture → Animation panel shows per-keyframe ease rows
  • Click segment row → expands bezier curve editor for that segment
  • Change ease on one segment → only that segment updates
  • Record gesture with deliberate slowdown → last segment gets Easy Ease In
  • Position-only set tweens show "Position" row, not full card
  • bun test packages/studio/src/utils/velocityEaseFitter.test.ts — 4 pass
  • bun test packages/studio/src/utils/gestureSmoother.test.ts — 4 pass

miguel-heygen commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

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

Review of #1694 — per-keyframe ease presets, velocity fitting, gesture smoothing

Verdict: BLOCK (one band-aid contradicts stated contract; one dead-code block).
Head: 480d7bc8 · Base: feat/marquee-multi-selection (stacked on #1693).

I read the velocity fitter, gesture smoother, KeyframeEaseList, AnimationCard, useGestureCommit, the route handler, and confirmed handleUpdateKeyframeEase is wired in useDomEditSession.tsupdate-keyframe route case (which already accepts ease?). Round-trip for per-keyframe ease is intact. No prior reviewers as of this post.


Findings

B1 — easeEach: "power1.inOut" hardcoded in useGestureCommit directly contradicts the velocity fitter's "constant speed → linear" contract

packages/studio/src/utils/velocityEaseFitter.ts:115 says explicitly:

// Otherwise leave ease undefined → linear (constant speed)

…and the test "constant speed → no ease assigned" asserts result[1].ease).toBeUndefined(). The intent is unambiguous: constant-speed segments stay linear.

But packages/studio/src/hooks/useGestureCommit.ts:252 and :266 now pass easeEach: "power1.inOut" on both add-with-keyframes paths. In GSAP keyframes, easeEach is the per-segment fallback when an individual keyframe's ease is undefined. That means the fitter's intentionally-undefined segments inherit power1.inOut — a sigmoidal ease — not linear. This is precisely the band-aid Miguel flags as request-changes: documented contract in module A is silently overridden in module B's call site, with no comment acknowledging the divergence.

This is also a regression — pre-PR the call did not pass easeEach at all (verified at feat/marquee-multi-selection:235-242), so constant-speed gesture segments behaved per the materializer default. Either:

  • pass easeEach: "none" (matches fitter's "linear" contract); or
  • drop easeEach entirely and let the materializer's existing default apply; or
  • delete the "stays linear" comment + test assertion and own the easeInOut default explicitly.

The merge and replace-with-keyframes branches don't set easeEach and look correct.

B2 — Dead optimistic-ease state in AnimationCard.tsx

packages/studio/src/components/editor/AnimationCard.tsx:115-121:

const [optimisticEase, setOptimisticEase] = useState<string | null>(null);
...
const easeName = optimisticEase ?? propEase;
if (optimisticEase && propEase === optimisticEase) setOptimisticEase(null);

setOptimisticEase is never called with a non-null value anywhere in the file — confirmed via grep. The state is always null, the fallback always picks propEase, and the setState-during-render branch is unreachable. Either wire it up (e.g. on the new ease-grid click path) or remove the four dead lines. Also: setState-during-render is a sharp tool; if you do wire it up, the standard React pattern is useEffect, or at least leave a // React docs: update during render breadcrumb.

N1 — Gaussian gesture smoother uses array index, not time

gestureSmoother.ts:31 weights by j - i (index distance) with sigma = radius / 2. RDP simplification produces non-uniform percentage gaps, so a sample 50% away in time can have the same weight as one 5% away if their array indices are adjacent. For a 3-radius window this is usually fine, but worth a comment acknowledging the index-domain choice — or switch to percentage-domain weighting if you ever bump radius.

N2 — KeyframeEaseList keys on kf.percentage

KeyframeEaseList.tsx:18 uses key={kf.percentage}. Two keyframes at the same percentage (transiently possible during dense editing) will collide. key={${i}-${kf.percentage}} is cheap insurance.

N3 — ?_t=${Date.now()} cache-buster on every animation fetch

useGsapTweenCache.ts:109 unconditionally appends a timestamp to /api/projects/.../gsap-animations/.... This defeats HTTP caching forever and is hot-path on every element selection. Out of scope-ish — looks like a debugging artifact that snuck in. Drop it, or use cache headers on the route.


Cross-PR

  • #1692 immediateRender for set tweens + array timeline normalization: the new position-only-set-tween display in AnimationCard.tsx:130 explicitly filters (k) => k === "x" || k === "y" || k === "immediateRender", and useGsapTweenCache.ts:392 filters with (k) => k !== "immediateRender" then checks every k === "x" || k === "y". Both guards already know about immediateRender. Consistent with #1692's direction; no obvious interaction risk if #1692 lands first or after.
  • #1686 overlay validator (already merged d01c3004): the new per-keyframe kf.ease and easeEach ride the existing GSAP serializer; the validator inspects rendered DOM. No interaction concern.

Tests

4 velocity-fitter tests, 4 smoother tests — both pass the basics (constant/decel/accel + ≤2-keyframe + pin-endpoints + zigzag + radius-0). Missing: round-trip test for per-keyframe kf.ease through materialize-keyframes / update-keyframe mutations. Not a blocker — route-level coverage probably exists.

CI

At post time: preflights green, 14 regression-shards + 5 perf + preview-parity + Graphite mergeability still in-progress.

Review by 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.

R1 @ 480d7bc8 — Rames D Jusso review

Layering on Via's review (read at HEAD). Agree with B1 (easeEach: "power1.inOut" contradicts the fitter's documented "constant speed → linear" contract), B2 (dead optimisticEase state), N1 (smoother index-domain weighting), N2 (KeyframeEaseList key collision risk), N3 (cache-buster). Below are findings I caught from a different angle — there are two 🛑 blockers I'd like a response on before this lands.


🛑 Blockers

B-NEW-1 — AE_EASE_IN and AE_EASE_OUT are swapped in velocityEaseFitter.ts; the fitter inverts the gesture's velocity profile

packages/studio/src/utils/velocityEaseFitter.ts:7-9:

const AE_EASE_IN  = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
const AE_EASE_OUT = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";

And the assignment at lines 108-111:

if (slowEnd)        result[i].ease = AE_EASE_IN;   // ← inverted
else if (slowStart) result[i].ease = AE_EASE_OUT;  // ← inverted

Curve analysis at the cubic endpoints:

  • (0.333, 0, 0.667, 0.667)AE_EASE_IN — CP1=(0.333, 0) → slope at t=0 is 0 → zero velocity at start, fast/linear at end. This is a slow-start curve (semantically "ease out" in After Effects terms, since AE names eases by the outgoing side of a keyframe).
  • (0.333, 0.333, 0.667, 1)AE_EASE_OUT — CP2=(0.667, 1) → slope at t=1 is 0 → fast/linear at start, zero velocity at end. This is a slow-end curve (semantically "ease in" in AE terms).

When the user records a gesture that decelerates at the end (slowEnd=true), the fitter assigns the slow-START curve. Playback then has the element start slow and end fast — the exact inverse of what the user just gestured. The whole pitch of "velocity-based ease fitting" is preserving motion shape, and this breaks that goal for every non-constant gesture.

The unit tests on lines 25-37 of velocityEaseFitter.test.ts lock in the swapped behavior. From the test:

it("decelerate at end → ease-in shape", () => {
  // Start fast, end slow
  const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
  expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
});

The comment says "start fast, end slow" → the expected curve should also be fast-start-slow-end, which is (0.333, 0.333, 0.667, 1) (currently named AE_EASE_OUT). The test asserts the inverted curve.

Note the same swap exists in gsapAnimationConstants.ts:147-149:

"ae-ease-in":  [0.333, 0, 0.667, 0.667],   // labeled "Easy Ease In" but is slow-start (= AE Easy Ease Out, = CSS ease-in)
"ae-ease-out": [0.333, 0.333, 0.667, 1],   // labeled "Easy Ease Out" but is slow-end  (= AE Easy Ease In, = CSS ease-out)

The naming is internally consistent with CSS conventions (where ease-in = slow start), but the PR body, code comments ("ponytail: AE Easy Ease"), and the EASE_LABELS UI string "Easy Ease In (AE)" all invoke After Effects conventions — where the polarity flips. Pick one convention and align the constant names, the labels, and the fitter's slowEnd → AE_EASE_* mapping. Right now they disagree.

This is the dominant user-visible behavior of the whole "velocity fitting" feature — recording a decelerating gesture and playing it back inverted is exactly the bug the feature is supposed to prevent.

B-NEW-2 — Two silent reverts that look like Graphite restack mishaps

Both probably unintentional, both should be caught before merge:

(a) packages/studio/package.json:3 downgrades 0.7.5 → 0.7.3. Main is at 0.7.5 (release commit 649c2163, two intermediate releases 0.7.4 0.7.5 after the base of this stack). This downgrade will overwrite the version on merge.

(b) packages/studio/src/player/components/timelineIcons.tsx removes the null-tag guard that you yourself added 1 day ago in #1679 (d4aa5f93"fix(studio): guard against null tag in timeline track style"console.warn + safeTag = tag || "div"):

- if (!tag) console.warn("[Timeline] getTrackStyle received empty tag, defaulting to div");
- const safeTag = tag || "div";
- const trackStyle = getTimelineTrackStyle(safeTag);
- const normalized = safeTag.toLowerCase();
+ const trackStyle = getTimelineTrackStyle(tag);
+ const normalized = tag.toLowerCase();

And timelineDOM.ts deletes resolveClipTag (which had || "div" fallback) → tag: clip.tagName || clip.kind propagates undefined to TimelineElement.tag when both source fields are missing. Next caller into getTrackStyle will throw on .toLowerCase() of undefined — the exact runtime regression #1679 fixed.

Both look like a stale restack where #1679 + the v0.7.4/v0.7.5 release commits landed on main after this branch was last restacked, and the conflict resolution dropped them. Suggest restacking the tip on current main and verifying the diff is +0/-0 on package.json and timelineIcons.tsx/timelineDOM.ts is left alone unless you have specific intent for those files in this PR.


⚠️ Concerns

C1 — useGsapAnimationOps.ts:60 adds softReload: true to update-meta mutations

{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },

Previously meta updates didn't soft-reload; now they do. Combined with the ?_t=${Date.now()} cache-buster Via flagged (useGsapTweenCache.ts:109), every ease tweak from the per-keyframe panel triggers a soft reload + a fresh GET of the animations JSON. For a user iterating on an ease curve via the bezier handles, this is one full preview round-trip per drag commit. Worth a perf check — this is the feedback loop the whole feature is built around.

C2 — easeName.startsWith("custom(") runs on propEase which is derived from animation.keyframes.easeEach

AnimationCard.tsx:118-122. Per the fitter's contract, easeEach is now a fallback default — individual kf.ease overrides it. But easeName in this scope is used to drive the global "Speed" SelectField and the EaseCurveSection at lines 263-285. If animation.keyframes is set, the user is shown the per-keyframe KeyframeEaseList instead (line 257), so this path is for non-keyframed tweens only. ✓ correct on inspection. Worth a doc comment on easeName clarifying that.

🟡 Nits

N4 — useGsapTweenCache.ts:194 dep change from target to target?.id, target?.selector

Equivalent and better for referential stability of target, but target?.kind (if it exists) isn't in the dep — quick sanity check that GsapElementTarget only has id + selector fields and you're not silently dropping a third on this.

N5 — AnimationCard.tsx:131-150 early return for position-only set tweens

The every predicate (k) => k === "x" || k === "y" || k === "immediateRender" returns true on an empty properties object (vacuous-truth) — would render Position with x:0,y:0 for any set with no properties. Probably impossible in practice (parser would emit set with at least one property), but a Object.keys(...).length > 0 guard before the .every would close the edge case.

🟢 What's clean

  • KeyframeEaseList round-trip wiring (handleUpdateKeyframeEase in useDomEditSessionupdate-keyframe route case) — the route already accepts ease? per executeGsapMutationAcorn so the per-keyframe ease writes through.
  • gestureSmoother.ts pin-first-and-last invariant + radius=0 and ≤2 keyframes early returns — defensive shape.
  • AnimationCardParts.tsx extraction — pure refactor, exports are equivalent to deleted internal definitions.
  • Files-route schema change (easeEach? in update-meta and add-with-keyframes) flows cleanly through executeGsapMutationAcorn.

Cross-PR (#1693#1694)

Looked for the obvious cross-coupling — marquee-multi-select + per-keyframe ease editing:

  • The per-keyframe ease editor (KeyframeEaseList) is rendered per-AnimationCard and reads from a single domEditSelection. There's no "apply to all marquee-selected elements' keyframes at once" path. With #1693's marquee selecting N elements, the right panel will show the primary selection's animations only — the rest of the group is selected for canvas drag/move but their per-keyframe eases aren't reachable from the panel. Likely a deliberate scope decision (this PR adds per-keyframe editing for one element at a time; bulk-apply is a follow-up), but the product story "marquee + per-keyframe ease together" is incomplete. Worth a follow-up issue if not already filed.
  • useGestureCommit doesn't appear to be parameterized by group selection — a gesture-record with multiple elements selected still operates on the active dragging element. ✓ no race introduced by #1693.

CI snapshot

At post time: Preflights ✓, preview-parity ✓. 5 perf shards + 8 regression shards + Graphite mergeability still in-progress. Will revisit if anything fails after this.


— Rames D Jusso

Stamp routing: @rames Jusso once findings addressed (or punted with rationale).

miguel-heygen and others added 8 commits June 24, 2026 17:07
…tice

- Delete empty if-blocks left after console removal (snapTargetCollection,
  Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording
  dev guard + now-unused isDevBuild) and the stale "surface in dev" comment.
- Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher.
- Restore the one-time telemetry consent disclosure in showNoticeOnce (kept
  behind a pragma — it is a user-facing notice, not debug noise).
- Remove the missed timelineIcons console.warn while preserving the
  `tag || "div"` null-safety fallback.
- Route caption auto-save failures (a data-loss path) through telemetry
  instead of swallowing silently.
- Restore the accidentally-clobbered css-var-fonts output.mp4 fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zation

- Set tweens now emit immediateRender:true so they render on page load
  without requiring the runtime to seek past position 0
- Runtime IIFE normalizes array timelines (window.__timelines = [tl])
  to keyed objects, and auto-adds data-start on root elements
- Drag teardown clears translate:none to prevent #1673 fly-off
- Position-only set tweens hidden from timeline diamonds (3 cache paths)
- Parser: ease-only keyframe update preserves existing properties
…b restore

- Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on
  restack — avoids ~2400 wasted per-tick seeks on video-heavy renders.
- Restore the console.debug body + docstring bullet of swallow() in
  diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an
  empty if-block.
- Rebind: after the progress-cycle set() kick, seek to state.currentTime via
  totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload
  restore keeps the playhead.
- Array __timelines normalization + data-start default now resolve the root
  via a shared findRootCompositionEl() that honors data-root="true" first
  (matches resolveRootCompositionElement, which now delegates to it).
- Ease-only keyframe update leaves a primitive (non-object) keyframe value
  untouched instead of wiping it to {}; add a preservation unit test.
- Document the boundDuration<=0 progress(1) kick + restore the STATIC-case
  comment in gsapRuntimeBridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Click+drag on empty canvas draws dashed selection rectangle
- SAT/OBB intersection handles rotated/scaled/skewed elements
- Shift+marquee adds to existing selection
- Click on empty canvas deselects
- Off-canvas elements show dashed outline indicators (clickable)
- Dashed border only shows outside canvas, solid inside (clip-path)
- 12 geometry unit tests
- Off-canvas indicator suppression now skips every selected element (primary
  AND marquee group members), not just the primary, so group members no longer
  render a doubled overlay (group rect + dashed indicator).
- Drop selection from the off-canvas layout effect deps; the selected-element
  filter runs at render time. Avoids re-walking geometry on each selection change.
- applyMarqueeSelection now honors STUDIO_INSPECTOR_PANELS_ENABLED.
- Restore the stale-selection clear in useDomEditPreviewSync when the selected
  element no longer resolves after a re-sync. Drag-release stays handled by
  suppressNextBoxClickRef.
- Off-canvas indicator is keyboard-accessible; canvas cursor driven by marquee
  rect state, not a render-time ref read.
- Rename partiallyOutside -> extendsOutsideComp + comment the clip-path hit-test.
- Extract OffCanvasIndicators into its own component (DomEditOverlay was already
  over the 600-LOC cap on this branch; extraction brings it under).
- Declare onUpdateKeyframeEase on PropertyPanelProps so this branch typechecks
  standalone (handler + wiring already here; only the type had leaked upstack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oothing

- Easy Ease presets (correct bezier values) in preset grid
- Per-keyframe ease editing via expandable KeyframeEaseList
- Velocity-based curve fitting for gesture recordings
- Gaussian smoothing for gesture recordings
- Position-only set tweens show compact "Position" row
- AnimationCard extracted to AnimationCardParts
- 4 velocity fitter + 4 gesture smoother unit tests
…sion

- velocityEaseFitter: the AE ease control points were defined with inverted
  (CSS) polarity, so a decelerating gesture was fitted with a slow-START curve
  — playback inverted the recorded motion. Swap the constant values to match
  the AE convention the docstring/labels/assignment already use; align the
  ae-ease-in/out presets in gsapAnimationConstants; update fitter tests to
  assert the motion-preserving curves.
- useGestureCommit: easeEach fallback "power1.inOut" -> "none" so the fitter's
  intentionally-linear constant-speed segments stay linear.
- Restore studio package version 0.7.5 (a restack had dropped it to 0.7.3).
- timelineIcons null-guard (tag || "div") restored during the rebase (the
  #1679 fix had been dropped).
- AnimationCard: remove the dead optimisticEase state (never set non-null; also
  a setState-during-render); guard the position-only set row against the
  vacuous-truth empty-properties case.
- useGsapTweenCache: drop the ?_t=Date.now() cache-buster (defeated caching +
  non-deterministic) for cache: "no-store".
- KeyframeEaseList: key by index+percentage to avoid collisions.
- gestureSmoother: document the index-domain weighting choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@miguel-heygen miguel-heygen force-pushed the feat/marquee-multi-selection branch from e26eaf8 to 441f841 Compare June 24, 2026 21:08
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks @vanceingalls / @james-russo-rames-d-jusso — all blockers addressed in the latest push.

Blockers (fixed)

  • B-NEW-1 — inverted AE ease fitting — confirmed: the AE control points were defined with CSS polarity, so a decelerating gesture got a slow-START curve (playback inverted the recorded motion). The docstring, the slowEnd → AE_EASE_IN assignment, and the (AE) UI labels are all already AE-convention — the bug was purely the constant values. Swapped the value strings in velocityEaseFitter and the ae-ease-in/ae-ease-out presets in gsapAnimationConstants; updated the fitter tests to assert the motion-preserving curves (4/4 pass).
  • B-NEW-2a — version downgrade — restored @hyperframes/studio to 0.7.5 (the restack had dropped it to 0.7.3).
  • B-NEW-2b — timelineIcons null-guard — restored the tag || "div" fallback (the fix(studio): guard against null tag in timeline track style #1679 fix had been dropped here). Note: resolveClipTag is absent on main too, so there was nothing to "delete"; the null-guard restoration covers the undefined-tag path regardless.
  • B1 — easeEach: "power1.inOut" vs the linear contract — the keyframes are velocity-fitted, so easeEach is the fallback for the fitter's intentionally-linear constant-speed segments. Changed to "none" at both call sites so those segments stay linear.

Nits (fixed)

  • B2 — removed the dead optimisticEase state (never set non-null; also a setState-during-render).
  • N3 — dropped the ?_t=${Date.now()} cache-buster for cache: "no-store".
  • N2KeyframeEaseList now keys by index-percentage.
  • N1 — commented the gesture smoother's index-domain weighting choice.
  • N5 — guarded the position-only set row against the vacuous-truth empty-properties case.

Verified / rationale

  • C1 (softReload on update-meta) — soft-reload is needed for the preview to reflect an ease change; with the cache-buster removed the per-commit cost is reduced. Left as intentional.
  • C2 / N4 — confirmed correct on inspection (easeName path is non-keyframed-only; GsapElementTarget is id + selector only).

Base automatically changed from feat/marquee-multi-selection to main June 24, 2026 21:53
# Conflicts:
#	packages/studio/src/player/components/timelineIcons.tsx
…ort internal helpers

- Restore resolveClipTag (with the || "div" fallback) in timelineDOM, reverting
  the unintended removal flagged in review (B-NEW-2b); this also makes the file
  identical to main again.
- Drop the unused `export` on adjustedValue / RemoveButton in AnimationCardParts
  (used only within the module).
…iles

Add // fallow-ignore-next-line complexity to the pre-existing complex functions
in useGsapTweenCache / useGestureCommit that this PR's edits pulled into the
fallow changed-set, and extract PropertyPanel's inline copy-element-info onClick
into a named handler so it can carry the same directive.
@miguel-heygen miguel-heygen merged commit 97db811 into main Jun 24, 2026
49 checks passed
@miguel-heygen miguel-heygen deleted the feat/speed-curves branch June 24, 2026 22:43
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