feat(studio): after-effects-style keyframe timeline with mid-segment easing#2387
Draft
miguel-heygen wants to merge 30 commits into
Draft
feat(studio): after-effects-style keyframe timeline with mid-segment easing#2387miguel-heygen wants to merge 30 commits into
miguel-heygen wants to merge 30 commits into
Conversation
miguel-heygen
force-pushed
the
feat/studio-keyframe-timeline
branch
3 times, most recently
from
July 15, 2026 19:49
da28b92 to
eedb660
Compare
…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
force-pushed
the
feat/studio-keyframe-timeline
branch
from
July 16, 2026 15:34
eedb660 to
f24e7f3
Compare
Fallow audit reportFound 39 findings. Duplication (34)
Health (5)
Generated by fallow. |
This was referenced Jul 16, 2026
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. --- [](https://github.com/EveryInc/compound-engineering-plugin) 
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
An After-Effects / Figma-Motion-style keyframe timeline for Studio, with segment easing moved onto the segment between two keyframes.
▾ ◇ 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).Custom bezier ▾dropdown opens the "Graphs" preset grid as a popover; Curve/Spring tabs; a draggable cubic-bezier graph; an editable bezier value field.custom()/hold/spring()/wiggle()render identically in preview and capture.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
position | scale | size | rotation | visual | other) are the finest independent-time unit, each its own tween, so a keyframe is addressed byelement:group:percentage. Lanes read sourcegsapAnimationsdirectly, not a lossy merged cache.kf.ease); the mid-segment button reads and commits it.window.gsap.parseEaseto resolvecustom(<path>),hold,spring(<bounce>),wiggle(<n>,<type>,<amp>)— compositions pin GSAP 3.14.2 (noCustomEase), so the shim runs in both the preview iframe and the capture path for parity.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.Test plan
Not covered / deferred
towith 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.EASE_CURVESand the parser constants); this pre-existing divergence is untouched here.