Skip to content

Commit 4140770

Browse files
committed
fix(studio): keyframe bug bash — 7 fixes rebased onto refactor
Gesture recording: - Always read CSS var offsets regardless of translate state - Clear translate after seek during preview - Restore visibility + translate on recording stop - Gate recording behind STUDIO_KEYFRAMES_ENABLED Design panel: - Subscribe to liveTime for real-time value refresh during playback - Use update-keyframe instead of add-keyframe when editing existing keyframe - Prefer single-element tween over group selector when multiple target same element Path offset system: - reapplyPathOffsets passes updateBase:false to prevent corrupting translate base - gsapAnimatesProperty handles array-form keyframes
1 parent 81416ab commit 4140770

8 files changed

Lines changed: 209 additions & 75 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Keyframes Bug Bash — 2026-06-09
2+
3+
Attendees: Executive Suite (SF Office), James Russo, Miguel Angel Simon Sierra, Terence Cho, Ular Kimsanov, Vance Ingalls, Xiaye Wang
4+
5+
## Bugs
6+
7+
### Keyframe editing
8+
9+
- [x] **Editing a keyframe value creates a duplicate instead of updating** — Selecting a keyframe and adjusting a property (e.g., bumping scale to 9) adds a new keyframe in the same frame instead of modifying the existing one. Deleting one removes both. _Fix: `useAnimatedPropertyCommit` now checks for existing keyframe at the current percentage and dispatches `update-keyframe` instead of `add-keyframe`._
10+
11+
- [x] **Editing a keyframe in the last scene makes element visible in all prior scenes** — After editing a keyframe on the circle, it became visible throughout the entire composition instead of only its scene. _Fix: `stopRecording` now restores element `visibility` and `translate` to pre-recording values; `reapplyPathOffsets` passes `updateBase: false` to prevent corrupting the translate base._
12+
13+
- [x] **Moving element down causes it to go up** — Adjusting the Y position of the circle in the design panel caused it to move in the wrong direction. Multiple overlapping tweens (single-element + group selector) competed, and the wrong tween was being edited. _Fix: `pickBestAnimation` scores candidate tweens by selector specificity and time-range overlap to pick the correct one._
14+
15+
- [x] **Clicking elements repositions them randomly** — Just clicking on elements in the preview caused them to jump to a different position. Undo didn't help since it wasn't part of the edit stack. _Fix: `reapplyPathOffsets` no longer overwrites the original translate base during re-application (`updateBase: false`); `gsapAnimatesProperty` now handles array-form keyframes._
16+
17+
- [x] **Design panel shows stale position values during playback** — The X/Y/rotation fields in the layout panel didn't update when scrubbing or playing. _Fix: PropertyPanel now subscribes to `liveTime` (RAF-throttled) in addition to the store's `currentTime`._
18+
19+
- [ ] **Scale doesn't respect the keyframe property at the moment it was created** — When modifying scale at a keyframe position, the value doesn't stick correctly; it snaps back or applies globally instead of at that keyframe.
20+
21+
- [ ] **Baked properties conflict with GSAP tween positions** — Manual dragging properties (path offsets) interfere with the animated position of GSAP elements. After editing, the animation doesn't play back correctly until the page is refreshed.
22+
23+
### Gesture recording
24+
25+
- [x] **Recorded gestures appear at wrong position** — Gesture recording produced keyframes with a significant offset from the pointer location, both during live preview and after commit. _Fix: CSS var offsets always read regardless of translate state; translate cleared to "none" after each seek during preview; offset subtracted from committed samples._
26+
27+
- [x] **Gesture recording not gated behind keyframes env flag** — The record gesture button and hotkey were available even when `VITE_STUDIO_ENABLE_KEYFRAMES` was not set. _Fix: Button and hotkey now gated behind `STUDIO_KEYFRAMES_ENABLED`._
28+
29+
- [ ] **Gesture recording sometimes works, sometimes doesn't** — Miguel noted it's intermittent: "sometimes it works and when it works is really good." Needs investigation of what conditions cause it to fail.
30+
31+
### Keyframe conversion / creation
32+
33+
- [ ] **Add-keyframe button does nothing on elements that already have keyframes** — Clicking the "add keyframe at playhead" button on an element that already has keyframes at other positions does nothing. Only moving the element auto-creates keyframes. The button should be disabled with a tooltip when keyframes already exist, or it should work correctly.
34+
35+
- [ ] **GSAP import missing in non-GSAP scripts** — When trying to add keyframes to a composition that doesn't import GSAP, the script fails because it tries to call GSAP functions that don't exist in that context. Fix: ensure GSAP import is added to the script when creating keyframe tweens.
36+
37+
### Timeline UX
38+
39+
- [ ] **Timeline jumps around when clicking small keyframe diamonds** — When zoomed in and clicking a keyframe diamond that's too small, the timeline view scrolls/jumps erratically.
40+
41+
- [ ] **Keyframe navigation arrows don't jump between keyframes** — The left/right arrows next to property fields in the design panel were expected to navigate to the previous/next keyframe, but they don't.
42+
43+
- [ ] **No drag-to-reposition for keyframe diamonds** — Can't drag keyframe diamonds to move them to a different time position on the timeline. Would be a useful feature.
44+
45+
### Animation section confusion
46+
47+
- [ ] **Confusing distinction between keyframe values vs tween values** — When a keyframe is selected, the layout panel shows keyframe-scoped values, but the animation section shows tween-level values. Editing in the animation section modifies the tween directly (one line of code), while editing in the layout section modifies the keyframe. This distinction is unclear to users.
48+
49+
- [ ] **Scale value resets to 1 after editing** — Setting scale to 4 in the animation section caused it to snap back to 1 (the CSS maximum). The value didn't persist correctly.
50+
51+
## Summary
52+
53+
- **Total bugs found**: 16
54+
- **Fixed in PR #1312**: 7
55+
- **Remaining**: 9

packages/studio/src/App.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AskAgentModal } from "./components/AskAgentModal";
3535
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
3636
import { StudioHeader } from "./components/StudioHeader";
3737
import { useGestureCommit } from "./hooks/useGestureCommit";
38+
import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
3839

3940
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
4041
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -251,7 +252,9 @@ export function StudioApp() {
251252
onResetKeyframes: () => resetKeyframesRef.current(),
252253
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
253254
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
254-
onToggleRecording: () => handleToggleRecordingRef.current(),
255+
onToggleRecording: STUDIO_KEYFRAMES_ENABLED
256+
? () => handleToggleRecordingRef.current()
257+
: undefined,
255258
});
256259
const selectSidebarTabStable = useCallback(
257260
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
@@ -529,7 +532,7 @@ export function StudioApp() {
529532
}}
530533
recordingState={gestureState}
531534
recordingDuration={gestureRecording.recordingDuration}
532-
onToggleRecording={handleToggleRecording}
535+
onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
533536
/>
534537
)}
535538
</div>

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useRef, useState } from "react";
1+
import { memo, useEffect, useRef, useState } from "react";
22
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
33
import { useStudioContext } from "../../contexts/StudioContext";
44
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -17,7 +17,7 @@ import { GsapAnimationSection } from "./GsapAnimationSection";
1717
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
1818
import { KeyframeNavigation } from "./KeyframeNavigation";
1919
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
20-
import { usePlayerStore } from "../../player";
20+
import { usePlayerStore, liveTime } from "../../player";
2121
import { TimingSection } from "./propertyPanelTimingSection";
2222
import { type PropertyPanelProps } from "./propertyPanelHelpers";
2323

@@ -88,7 +88,26 @@ export const PropertyPanel = memo(function PropertyPanel({
8888
const { showToast } = useStudioContext();
8989
const [clipboardCopied, setClipboardCopied] = useState(false);
9090
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
91-
const currentTime = usePlayerStore((s) => s.currentTime);
91+
const storeTime = usePlayerStore((s) => s.currentTime);
92+
const [liveRenderTime, setLiveRenderTime] = useState(storeTime);
93+
useEffect(() => {
94+
setLiveRenderTime(storeTime);
95+
}, [storeTime]);
96+
useEffect(() => {
97+
let rafId = 0;
98+
const unsub = liveTime.subscribe((t) => {
99+
if (!rafId)
100+
rafId = requestAnimationFrame(() => {
101+
rafId = 0;
102+
setLiveRenderTime(t);
103+
});
104+
});
105+
return () => {
106+
unsub();
107+
cancelAnimationFrame(rafId);
108+
};
109+
}, []);
110+
const currentTime = liveRenderTime;
92111

93112
if (!element) {
94113
return (

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -527,10 +527,14 @@ function reapplyPathOffsets(doc: Document): void {
527527
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
528528
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
529529
if (x || y) {
530-
applyStudioPathOffset(el, {
531-
x: Number.parseFloat(x) || 0,
532-
y: Number.parseFloat(y) || 0,
533-
});
530+
applyStudioPathOffset(
531+
el,
532+
{
533+
x: Number.parseFloat(x) || 0,
534+
y: Number.parseFloat(y) || 0,
535+
},
536+
{ updateBase: false },
537+
);
534538
}
535539
}
536540
}

packages/studio/src/components/renders/RenderQueueItem.tsx

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
142142
)}
143143
</div>
144144

145-
{/* Actions */}
146-
{hovered && (
147-
<div className="flex items-center gap-1 flex-shrink-0">
148-
{isComplete && (
149-
<button
150-
onClick={handleDownload}
151-
className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
152-
title="Download"
153-
>
154-
<svg
155-
width="12"
156-
height="12"
157-
viewBox="0 0 24 24"
158-
fill="none"
159-
stroke="currentColor"
160-
strokeWidth="2"
161-
strokeLinecap="round"
162-
strokeLinejoin="round"
163-
>
164-
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
165-
<polyline points="7 10 12 15 17 10" />
166-
<line x1="12" y1="15" x2="12" y2="3" />
167-
</svg>
168-
</button>
169-
)}
170-
<button
171-
onClick={(e) => {
172-
e.stopPropagation();
173-
onDelete();
174-
}}
175-
className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
176-
title="Remove"
145+
{/* Actions — always visible to prevent layout shifts */}
146+
<div className="flex items-center gap-1 flex-shrink-0">
147+
<button
148+
onClick={isComplete ? handleDownload : undefined}
149+
className={`p-1 rounded transition-colors ${
150+
isComplete
151+
? "text-panel-text-5 hover:text-panel-accent"
152+
: "text-panel-text-5/30 pointer-events-none"
153+
}`}
154+
title={isComplete ? "Download" : "Rendering..."}
155+
disabled={!isComplete}
156+
>
157+
<svg
158+
width="12"
159+
height="12"
160+
viewBox="0 0 24 24"
161+
fill="none"
162+
stroke="currentColor"
163+
strokeWidth="2"
164+
strokeLinecap="round"
165+
strokeLinejoin="round"
177166
>
178-
<svg
179-
width="12"
180-
height="12"
181-
viewBox="0 0 24 24"
182-
fill="none"
183-
stroke="currentColor"
184-
strokeWidth="2"
185-
strokeLinecap="round"
186-
>
187-
<path d="M18 6L6 18M6 6l12 12" />
188-
</svg>
189-
</button>
190-
</div>
191-
)}
167+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
168+
<polyline points="7 10 12 15 17 10" />
169+
<line x1="12" y1="15" x2="12" y2="3" />
170+
</svg>
171+
</button>
172+
<button
173+
onClick={(e) => {
174+
e.stopPropagation();
175+
onDelete();
176+
}}
177+
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
178+
title="Remove"
179+
>
180+
<svg
181+
width="12"
182+
height="12"
183+
viewBox="0 0 24 24"
184+
fill="none"
185+
stroke="currentColor"
186+
strokeWidth="2"
187+
strokeLinecap="round"
188+
>
189+
<path d="M18 6L6 18M6 6l12 12" />
190+
</svg>
191+
</button>
192+
</div>
192193
</div>
193194
</div>
194195
);

packages/studio/src/hooks/gsapRuntimeBridge.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1212
import type { DomEditSelection } from "../components/editor/domEditingTypes";
1313

14-
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
1514
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
1615
import {
1716
commitGsapPositionFromDrag,
@@ -125,6 +124,10 @@ export async function tryGsapDragIntercept(
125124
const selector = selectorForSelection(selection);
126125
if (!selector) return false;
127126

127+
// Keyframe writes at 0%/100% when outside the tween range. Acceptable
128+
// trade-off — CSS path must NEVER touch GSAP-targeted elements because
129+
// changing the CSS offset corrupts all existing keyframes (baked mismatch).
130+
128131
const gsapPos = readGsapPositionFromIframe(iframe, selector);
129132
if (!gsapPos) return false;
130133

packages/studio/src/hooks/useAnimatedPropertyCommit.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ function computePercentage(selection: DomEditSelection): number {
4545
: 0;
4646
}
4747

48+
function pickBestAnimation(
49+
animations: GsapAnimation[],
50+
selector: string | null,
51+
): GsapAnimation | undefined {
52+
if (animations.length <= 1) return animations[0];
53+
const currentTime = usePlayerStore.getState().currentTime;
54+
55+
const scored = animations.map((a) => {
56+
let score = 0;
57+
if (a.keyframes) score += 10;
58+
// Prefer single-element selectors over comma-separated groups
59+
if (selector && a.targetSelector === selector) score += 5;
60+
else if (a.targetSelector.includes(",")) score -= 3;
61+
// Prefer tweens active at the current time
62+
const pos = typeof a.position === "number" ? a.position : 0;
63+
const dur = a.duration ?? 0;
64+
if (currentTime >= pos && currentTime <= pos + dur) score += 8;
65+
return { anim: a, score };
66+
});
67+
scored.sort((a, b) => b.score - a.score);
68+
return scored[0]?.anim;
69+
}
70+
4871
function selectorFor(selection: DomEditSelection): string | null {
4972
if (selection.id) return `#${selection.id}`;
5073
if (selection.selector) return selection.selector;
@@ -72,8 +95,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
7295
const selector = selectorFor(selection);
7396
const pct = computePercentage(selection);
7497

75-
let anim: GsapAnimation | undefined =
76-
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
98+
let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector);
7799

78100
// Case 3: No animation — create one first
79101
if (!anim) {
@@ -112,15 +134,26 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
112134
}
113135
backfillDefaults[property] = typeof value === "number" ? value : value;
114136

137+
const existingKf = anim.keyframes?.keyframes.some(
138+
(kf) => Math.abs(kf.percentage - pct) < 0.05,
139+
);
140+
115141
await gsapCommitMutation(
116142
selection,
117-
{
118-
type: "add-keyframe",
119-
animationId: anim.id,
120-
percentage: pct,
121-
properties,
122-
backfillDefaults,
123-
},
143+
existingKf
144+
? {
145+
type: "update-keyframe",
146+
animationId: anim.id,
147+
percentage: pct,
148+
properties,
149+
}
150+
: {
151+
type: "add-keyframe",
152+
animationId: anim.id,
153+
percentage: pct,
154+
properties,
155+
backfillDefaults,
156+
},
124157
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
125158
);
126159
},

0 commit comments

Comments
 (0)