Skip to content

feat(studio): after-effects-style keyframe timeline with mid-segment easing#2387

Draft
miguel-heygen wants to merge 30 commits into
mainfrom
feat/studio-keyframe-timeline
Draft

feat(studio): after-effects-style keyframe timeline with mid-segment easing#2387
miguel-heygen wants to merge 30 commits into
mainfrom
feat/studio-keyframe-timeline

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

An After-Effects / Figma-Motion-style keyframe timeline for Studio, with segment easing moved onto the segment between two keyframes.

  • Per-property-group dope-sheet lanes with diamond keyframes on the time ruler, one consistent diamond size across clip bars and lanes.
  • Left-column layer tree (Figma-style): ▾ ◇ LayerName, with each property nested under it via a tree connector and its own keyframe toggle, prev/next nav, live value, and visibility eye. The expand/collapse disclosure lives here (not on the clip bar).
  • Keyframed clips are expanded by default; collapsing a layer sticks (doesn't auto-reopen).
  • Segment ease on the segment: a mid-segment ease button reveals on hover between two keyframes (no selection required) and edits that segment's ease — it does not move the playhead.
  • Figma-1:1 ease panel: a Custom bezier ▾ dropdown opens the "Graphs" preset grid as a popover; Curve/Spring tabs; a draggable cubic-bezier graph; an editable bezier value field.
  • Motion path is selection-gated (appears when a layer is selected).
  • Deterministic custom-ease render pipeline so custom() / hold / spring() / wiggle() render identically in preview and capture.
  • Reserved 0s gutter so a 0% keyframe diamond centers on the playhead without being clipped by the label column.
  • Multiple elements per track (shared z-index): the lanes + left column scope to the active element (selected, else primary) so several elements on one track never cram; select a clip to switch which element you're keyframing.

Why

Easing is a property of the segment between two keyframes (as in After Effects / Figma Motion), not of individual keyframes — the previous flow edited it per-keyframe in the design panel. The timeline also lacked per-property keyframe visibility, a preset ease library, and a runtime that could render custom / hold / spring / wiggle eases (selecting one produced no motion). Because HyperFrames commonly places several elements on one track (shared z-index), the keyframe view has to handle that without cramming.

How

  • Group-aware keyframe identity is the spine: property groups (position | scale | size | rotation | visual | other) are the finest independent-time unit, each its own tween, so a keyframe is addressed by element:group:percentage. Lanes read source gsapAnimations directly, not a lossy merged cache.
  • Segment ease lives on the destination keyframe (kf.ease); the mid-segment button reads and commits it.
  • Custom-ease runtime shim wraps window.gsap.parseEase to resolve custom(<path>), hold, spring(<bounce>), wiggle(<n>,<type>,<amp>) — compositions pin GSAP 3.14.2 (no CustomEase), so the shim runs in both the preview iframe and the capture path for parity.
  • Per-track active-clip resolution (resolveTrackKeyframeClip): one keyframed element per track drives the lanes + left column + row height, so shared-track elements don't overlap. No changes to the track/drag/drop model.
  • Layout: single source of truth for pointer-x → content-x; the content origin insets a gutter past the fixed-width label column so boundary diamonds stay visible.

Test plan

  • Unit tests added/updated — studio 2196 pass, core 1237 pass, parsers 813 pass; typecheck, oxlint, oxfmt, fallow (complexity 0), and the studio 600-LOC file-size gate all clean.
  • Manual testing — drove Studio live at 1920×1080 and verified the lanes, left-column tree + disclosure, hover-reveal mid-segment ease button (with and without a selection), Figma ease-panel dropdown/popover + graph, selection-gated motion path, the reserved 0s gutter, and shared-track scoping (selecting a clip switches the active element). Confirmed custom / hold / spring / wiggle animate in the composition iframe.
  • Documentation updated (n/a — Studio UI, no doc surface)

Not covered / deferred

  • Single-tween eases (a lone to with an ease and no keyframes) don't get a timeline mid-segment button — they have no between-keyframes segment. Their ease is edited in the Inspector's Animation panel (the same Figma ease editor). Representing single tweens as 2-keyframe lanes is a deferred follow-up.
  • Named-ease definitions still live in two tables (Studio's EASE_CURVES and the parser constants); this pre-existing divergence is untouched here.
  • A few warn-level fallow duplication clone groups remain (test setup + tree-connector spans); non-blocking.

@miguel-heygen
miguel-heygen force-pushed the feat/studio-keyframe-timeline branch 3 times, most recently from da28b92 to eedb660 Compare July 15, 2026 19:49
…main

Squash-merge of the keyframe-timeline feature onto the current main, which
had refactored the timeline in parallel. Preserves main's work (z-order
mirror, track gap menu, gap highlights, hook extraction) and layers the
keyframe feature on top: expandable property lanes, per-segment ease editor
(curve/spring/wiggle), flat-tween lanes, prev/next keyframe nav.

Unified the horizontal origin on the threaded contentOrigin parameter
(GUTTER collapsed, LABEL_COL_W in keyframe mode); main's TRACKS_LEFT_PAD
horizontal pad is superseded by the label-column model.
seekTimelineAndAdapters and applyWebAudioRate were const arrows declared
~370-680 lines after the control bridge + transport that call them. When
the studio issues a seek / set-playback-rate during composition load
(before those consts initialize) the runtime threw ReferenceError: Cannot
access '...' before initialization, cascading to 'l._ease is not a
function' in GSAP. Hoisting both to function declarations makes them
callable regardless of init order.
…toggle

Background thumbnail generation (per-clip composition/video frame capture)
lags timeline scrubbing on long videos (#2428). Adds a persisted
thumbnailsEnabled setting, default OFF, so the timeline is snappy out of
the box; a toggle next to Fit in the zoom toolbar turns thumbnails on.
When off, clips render as plain bars (audio still shows its waveform).
Follow-up to the load-time TDZ fix: the real invariant is that the runtime's
external control surface must not be callable before the helpers its handlers
dispatch to are declared. Moves installRuntimeControlBridge to run after every
transport helper (seekTimelineAndAdapters, applyWebAudioRate, ...), so a
load-time seek / set-playback-rate can never reach an uninitialized helper.
One owner of the 'runtime is controllable' boundary -> the TDZ class can't
recur via a future helper added below the wiring.
…erties

A %-keyed object keyframe step's duration (GSAP array-keyframe segment
timing) was collected as an animatable property, so it surfaced as a bogus
duration keyframe lane and got round-tripped as a property — corrupting the
tween on the next manual edit in Studio. Both the live acorn read parser and
the retired recast parser had the leak; the array-form parser already
excludes it. Skip duration in the %-keyed extraction to match.
…egments resolve

Custom eases (hold/spring/wiggle/custom) only overrode the public gsap.parseEase.
GSAP resolves a keyframe segment's ease through its INTERNAL _parseEase/_easeMap,
which never saw the override, so a custom ease inside keyframes:{...} resolved to
undefined and threw _ease is not a function on the first render (surfacing as a
masked cross-origin Script error on every keyframes composition and every manual
edit's soft-reload rebind). Register the eases in the internal map too; a
configurable ease's .config rejoins GSAP's comma-split params to losslessly
reconstruct the original string, including custom() bezier paths.
…r site

The one-shot ensureStudioCustomEase() in init runs before GSAP may be ready and
never retries, so on a lost load-order race the custom eases are unregistered
when bindRootTimelineIfAvailable fires its prime render (progress/totalTime) —
and a keyframe segment using hold/spring/wiggle/custom throws _ease is not a
function (masked as Script error on composition load). Call the idempotent
ensure at the top of the rebind so registration is guaranteed before any render.
…istration

A GSAP keyframes tween resolves its inner timeline's ease ONCE, at build time,
via the internal ease map. The composition inline script builds these tweens
before the runtime registers the custom eases (hold/spring/wiggle/custom), so a
custom container ease bakes the inner _ease to undefined; GSAP then throws
_ease is not a function on the first render (masked as a cross-origin Script
error) on composition load and every manual edit. Re-resolve each keyframes
tween's inner ease during rebind, once the eases are registered.
Dev-server thumbnail generation only probed three hardcoded system Chrome paths,
so on a machine with no system Chrome install (but a Puppeteer-cached
Chrome-for-Testing) it found no browser and every thumbnail request 500'd.
Resolve the executable from PUPPETEER_EXECUTABLE_PATH / CHROME_PATH / CHROME_BIN,
then a broader set of system browsers, then the Puppeteer cache (highest
version).
…tifier ids

Element ids that aren't valid CSS identifiers — most commonly digit-leading ones
like 01-hook-hero-word — make #id an invalid selector: querySelector and GSAP's
querySelectorAll throw a SyntaxError, surfacing as a masked cross-origin Script
error that crashes the preview the instant such a target is committed (dragging
the element writes gsap.set(#1-...)). Route the studio's commit/selection
selector building through a shared idSelector() that falls back to an
[id=...] attribute selector for those ids.
…licker)

Releasing a bezier control point committed the new ease AND cleared the local
draft synchronously. The committed ease prop only returns a few frames later
(write -> reparse -> re-render), so in the gap activeTuple fell back to curve
derived from the STALE prop -> the curve snapped to the old value for a frame
then jumped to the new one. Keep the draft on screen and clear it via an effect
when the ease prop actually changes, so curve already equals the draft when the
handoff happens.
The mid-point ease button only rendered in the expanded per-property lanes: it
was gated on `groupAware`, and the inline clip-row diamonds are rendered
group-unaware without an onSelectSegment handler. The ease is per-keyframe
(each carries its own animationId/tweenPercentage), so the button resolves
unambiguously on the merged inline row too. Thread animationId into the
keyframe cache, gate the button on onSelectSegment (with an always group-aware
target), and forward onSelectSegment through the inline TimelineClipDiamonds.
…pector

The flat inspector never consumed focusedEaseSegment: FlatMotionSection
rendered AnimationCard without focusedSegment/onFocusSegmentConsumed, and
the accordion left the Motion group collapsed, so clicking the inline
clip-row ease button only selected the element. Mirror the legacy
GsapAnimationSection: pass the focused segment to the matching card and
open the Motion group when a segment on this element is focused.
…e compat, tests)

- useGsapTweenCache: the file-wide cache producer now stamps animationId on
  every keyframe, matching the selected-element and post-mutation writers, so
  the inline clip-row ease button works on a collapsed clip that was never the
  selected element (it silently no-opped before).
- springEase: restore the deprecated re-export of the parsers spring-ease
  symbols on core's still-published ./spring-ease subpath (dropping them was a
  breaking change for external importers).
- gsapKeyframeCacheHelpers.test: update two stale exact-shape assertions that
  omitted animationId and were failing against the producer (pre-commit runs no
  vitest, so they slipped in).
- EaseCurveSection: replace an em dash in the preset hint with a colon.
…control X

- Timeline: hasKeyframedClips now uses animationContributesLane (the same
  predicate the layout/auto-expand use) instead of checking animation.keyframes
  directly, so a composition whose only animated clips are synthesized flat
  tweens still reserves its label column and gutter.
- EaseParamFields: clamp cubic-bezier control-point X to [0,1] on commit so an
  out-of-range entry can't persist as a non-monotonic (broken) custom ease; Y
  stays free for overshoot curves.
…ew on cancel

- focusedEaseSegment now carries elementId (set from the clicked timeline
  element). PropertyPanelFlat and FlatMotionSection only open/consume the ease
  editor when it matches the selected element, so a shared class-selector
  animation id can no longer open the wrong element's editor. Verified the
  common single-element inline-ease path still opens correctly.
- TimelineClipDiamonds: clear the armed drag and preview on pointercancel so an
  interrupted diamond drag no longer leaves a stuck ghost.
…isting failure)

The thumbnails-off-by-default toggle (6b5ea82) left this test asserting
thumbnail content without enabling thumbnails, so it produced no thumbnail
content and failed. Enable thumbnailsEnabled for the case and reset it in
afterEach; the label ownership assertion is unchanged (TimelineClip owns names).
…he selection

onDeleteKeyframe resolved the animation from selectedGsapAnimations, so removing
a flat-tween lane endpoint on a non-selected element found nothing and no-opped.
Resolve the clicked element's animations from the store (source-prefixed /
index.html / bare-id keys) and thread them through resolveKeyframeTarget and
removeKeyframeTarget; the drag-commit path keeps using the selection. Covers
non-selected flat + keyframed deletion and the selected-element regression.
…ntage

The move branch reselected with {...target, percentage: toClipPct}, keeping the
stale pre-move tweenPercentage, so activeKeyframePct and a later canvas edit
targeted the old keyframe. Derive the post-move tween % from the new clip %
via clipToTweenPercentage (scoped to the target animation's keyframes; cache
rebuild is async so we can't read it back synchronously).
A second diamond retime issued before the first commit's async cache rebuild
resolved its delta from the original pointer-down clip %, so it landed on a
stale/no-op target and was silently discarded. Track the last-resolved clip %
per keyframe key and use it as the next drag origin; clear it once the cache
reflects that percentage. Preserves U1's post-move reselect; no optimistic hold.
…egments

The collapsed clip row merges a clip's source animations and dedups keyframes
by percentage (last ease wins), so an inline ease segment where two animations
collide at the same clip % with different eases targeted an arbitrary tween.
Flag such merged keyframes (easeAmbiguous) at both merge sites (deduplicateKeyframes
and updateKeyframeCacheFromParsed), computed before the ease overwrite, and hide
the inline ease button on those segments; unambiguous segments are unchanged and
the user edits colliding ones by expanding the clip's per-lane view.
…p, cleanup)

Re-review of the earlier retime fixes found edge-case gaps:
- Rapid second retime now submits the pending (already-moved) position as the
  mutation's FROM identity, not the stale rendered one, so the serialized
  mutation locates the keyframe the first move relocated.
- Clamp the reselected tween % to the mapped range: clipToTweenPercentage
  extrapolates, so a boundary drag past the range no longer reselects an
  out-of-range % (e.g. 150%) while the mutation clamps the endpoint to 100%.
- Clear pending retime entries by tolerance, not exact-% equality (cache writers
  round), so entries no longer leak after a successful retime.
- Hide the inline ease button on a segment whose keyframe has no source
  animation id (runtime-scanned), avoiding a dead button with no target.
…d element

The earlier removal fix resolved the animation from the clicked element but
still called removeKeyframeTarget without a selection override, so the mutation
persisted through the current domEditSelection — wrong when the clicked element
lives in a different source file. Build the clicked element's own DomEditSelection
and pass it as the override. Tests assert the override fires for a non-selected
element's flat and keyframed endpoints.
…election

- Flag a collapsed-row segment ambiguous on ANY cross-animation collision at the
  same %, not only when raw eases differ: the single inline button can target
  only one arbitrary animation, and each may inherit a different easeEach/animation
  ease that raw comparison misses.
- Scope the flat inspector's ease-focus to the element the panel actually renders
  (its own sourceFile#id) instead of the store's selectedElementId, which flips
  synchronously during async selection resolution and let a stale panel consume a
  focus meant for its successor when both share a class-selector animation id.
- Rename the stale byte-identical cache test to describe the shape+identity it now
  asserts.
Foundation for bulk collapsed-row ease editing: each merged keyframe now carries
collidingAnimationIds (the source animation ids sharing that clip %, first-seen
order, deduped) via a shared accumulateCollidingAnimationIds helper used by both
merge sites (deduplicateKeyframes and updateKeyframeCacheFromParsed), replacing the
boolean easeAmbiguous. The inline ease button gate temporarily derives ambiguity
from the set length; U3 removes that suppression entirely.
onSelectSegment now carries the merged keyframe's collidingAnimationIds through
the target and into focusedEaseSegment (alongside the primary animationId,
tweenPercentage, and elementId scoping). Single-animation segments leave the set
undefined. Sets up the bulk ease commit + editor surface (U4/U5).
Remove the collision-based suppression: the collapsed-row inline ease button now
shows on every segment (bulk edit handles the shared-keyframe case in U4/U5).
Only a keyframe with no source animation id (runtime-scanned) still hides it.
…s (one undo)

Add handleUpdateSegmentEase(animationIds, tweenPct, ease): a single id commits
through the existing single-mutation path; multiple ids dispatch one ease-update
mutation per id through the commit layer's batch path (gsap-mutations-batch) so
the whole fan-out is ONE undo entry, in the given deterministic order.
handleUpdateKeyframeEase is preserved as a thin single-id wrapper (no behavior
change). Exposed as onUpdateSegmentEase through the panel callback bag.
…ations

When the focused segment spans multiple source animations (collidingAnimationIds),
the ease editor commits through onUpdateSegmentEase (one-undo batch fan-out to all
of them) instead of the single-animation path; a single-property segment keeps the
single path. Show an 'Applies to N properties' note for multi-id segments; the
starting curve is the primary animation's ease (mixed eases collapse to primary).
The curve drag has no live-preview callbacks wired, so no preview fan-out is added.
Completes the bulk collapsed-row ease editor; supersedes the hide-on-ambiguity
behavior.
@miguel-heygen
miguel-heygen force-pushed the feat/studio-keyframe-timeline branch from eedb660 to f24e7f3 Compare July 16, 2026 15:34
@github-actions

Copy link
Copy Markdown

Fallow audit report

Found 39 findings.

Duplication (34)
Severity Rule Location Description
minor fallow/code-duplication packages/studio-server/src/helpers/screenshotClip.ts:11 Code clone group 17 (23 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/AnimationCard.test.tsx:213 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/AnimationCard.test.tsx:237 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/GsapAnimationSection.tsx:43 Code clone group 2 (18 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/GsapAnimationSection.tsx:175 Code clone group 3 (21 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/PropertyPanelFlat.tsx:551 Code clone group 4 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/PropertyPanelFlat.tsx:580 Code clone group 4 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx:137 Code clone group 2 (18 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx:280 Code clone group 3 (21 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx:266 Code clone group 5 (14 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx:292 Code clone group 5 (14 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapShared.ts:177 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/gsapShared.ts:196 Code clone group 6 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.test.tsx:32 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.test.tsx:220 Code clone group 8 (20 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.test.tsx:244 Code clone group 9 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.test.tsx:316 Code clone group 8 (20 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useDomEditSession.test.tsx:340 Code clone group 9 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.test.tsx:28 Code clone group 10 (23 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.test.tsx:104 Code clone group 11 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapKeyframeOps.test.tsx:126 Code clone group 11 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/hooks/useGsapScriptCommits.test.tsx:232 Code clone group 10 (23 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/Timeline.test.ts:343 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/Timeline.test.ts:393 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/Timeline.test.ts:474 Code clone group 13 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/Timeline.test.ts:487 Code clone group 13 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/TimelinePropertyLanes.test.tsx:207 Code clone group 14 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/TimelinePropertyLanes.test.tsx:227 Code clone group 14 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx:51 Code clone group 15 (16 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx:73 Code clone group 15 (16 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/useTimelineTrackDerivations.ts:17 Code clone group 16 (19 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/useTimelineTrackLayout.ts:109 Code clone group 16 (19 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/utils/sdkCutover.test.ts:67 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio/vite.browser.ts:204 Code clone group 17 (23 lines, 2 instances)
Health (5)
Severity Rule Location Description
minor fallow/high-crap-score packages/studio/src/components/editor/GsapAnimationSection.tsx:81 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx:185 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/vite.browser.ts:157 'generateThumbnail' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
critical fallow/high-crap-score packages/studio/vite.browser.ts:173 '<arrow>' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/vite.browser.ts:204 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)

Generated by fallow.

@miguel-heygen
miguel-heygen marked this pull request as draft July 19, 2026 09:28
miguel-heygen added a commit that referenced this pull request Jul 20, 2026
## Summary

The Studio preview now installs the runtime bridge only after its transport is ready, preventing initialization-order gaps without changing the bridge contract.

## Stack

Part 1 of 20. Parent: `main`. Next: #2559. The golden reference, #2387, remains open and unchanged.

## Test plan

- [x] Integrated Studio suite: 2,800 tests passed
- [x] Integrated parser suite: 853 tests passed
- [x] Studio and parser typechecks passed
- [x] 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](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![Codex](https://img.shields.io/badge/GPT--5-000000)
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.

1 participant