feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing#1694
Conversation
1f7a259 to
95a3bd2
Compare
3fed042 to
4f97456
Compare
95a3bd2 to
14141de
Compare
64aa720 to
3e496f9
Compare
14141de to
e26eaf8
Compare
3e496f9 to
480d7bc
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
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.ts → update-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
easeEachentirely 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 inAnimationCard.tsx:130 explicitly filters(k) => k === "x" || k === "y" || k === "immediateRender", anduseGsapTweenCache.ts:392 filters with(k) => k !== "immediateRender"then checksevery k === "x" || k === "y". Both guards already know aboutimmediateRender. Consistent with #1692's direction; no obvious interaction risk if #1692 lands first or after. - #1686 overlay validator (already merged
d01c3004): the new per-keyframekf.easeandeaseEachride 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
left a comment
There was a problem hiding this comment.
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; // ← invertedCurve 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
KeyframeEaseListround-trip wiring (handleUpdateKeyframeEaseinuseDomEditSession→update-keyframeroute case) — the route already acceptsease?perexecuteGsapMutationAcornso the per-keyframe ease writes through.gestureSmoother.tspin-first-and-last invariant +radius=0and≤2 keyframesearly returns — defensive shape.AnimationCardParts.tsxextraction — pure refactor, exports are equivalent to deleted internal definitions.- Files-route schema change (
easeEach?inupdate-metaandadd-with-keyframes) flows cleanly throughexecuteGsapMutationAcorn.
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-AnimationCardand reads from a singledomEditSelection. 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. useGestureCommitdoesn'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).
…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>
e26eaf8 to
441f841
Compare
480d7bc to
0b399da
Compare
|
Thanks @vanceingalls / @james-russo-rames-d-jusso — all blockers addressed in the latest push. Blockers (fixed)
Nits (fixed)
Verified / rationale
|
# 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.

Summary
Per-keyframe speed curve editing, velocity-based ease fitting, and gesture smoothing.
(0.333, 0, 0.667, 1)as first-class presets in the Animation panel grid — softer deceleration matching industry-standard motion tools.KeyframeEaseListwithEaseCurveSectionbezier editor per segment. Parser preserves per-keyframe ease through round-trips.PropertyRow,AddPropertyTrigger, and helpers moved toAnimationCardParts.tsxto stay under 600 LOC.Test plan
bun test packages/studio/src/utils/velocityEaseFitter.test.ts— 4 passbun test packages/studio/src/utils/gestureSmoother.test.ts— 4 pass