Skip to content

Commit 4614fb4

Browse files
committed
feat(studio): GSAP keyframe + motion-path editing
Consolidates the studio side of the GSAP keyframe/motion-path work into one PR: runtime read layer + shared helpers, drag/commit/bridge editing infra, motion-path geometry + commit helpers, on-canvas motion-path overlay, and the keyframes flag with gesture recording + timeline/selection refinements. Also fixes "Add keyframe at playhead" for array-form keyframe tweens (keyframes: [{x,y},…]): - readElementPosition derived the captured props from anim.properties, which is empty for array-form keyframes, so the position came back empty and the add silently no-oped. It now falls back to the union of the keyframe stops' property keys (then x/y). - The add site and the toolbar button state computed the playhead percentage from the element clip range, not the tween range, so keyframes landed at the wrong percentage. Both now use the tween-relative basis. - When the playhead is outside the tween range, "add keyframe at playhead" now extends the tween to reach it and adds a keyframe there, keeping the existing keyframes at their absolute times (percentages rescale into the new range), instead of disabling or no-oping. Supersedes the separately-reviewed studio PRs #1557, #1558, #1559, #1560, #1561.
1 parent e450a92 commit 4614fb4

48 files changed

Lines changed: 2819 additions & 283 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/studio/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<title>HyperFrames Studio</title>
88
</head>
99
<body>
10-
<div id="root"></div>
10+
<div data-hf-id="hf-aph5" id="root"></div>
1111
<script type="module" src="/src/main.tsx"></script>
1212
</body>
1313
</html>

packages/studio/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ export function StudioApp() {
428428
domEditSelection: domEditSession.domEditSelection,
429429
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
430430
applyDomSelection: domEditSession.applyDomSelection,
431+
setRightPanelTab: panelLayout.setRightPanelTab,
431432
initialState: initialUrlStateRef.current,
432433
});
433434
const studioCtxValue = buildStudioContextValue({

packages/studio/src/components/StudioHeader.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
99
import { useStudioShellContext } from "../contexts/StudioContext";
1010
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
11-
import { useDomEditActionsContext } from "../contexts/DomEditContext";
1211
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
1312
import { trackStudioEvent } from "../utils/studioTelemetry";
1413

@@ -194,7 +193,6 @@ export function StudioHeader({
194193
}: StudioHeaderProps) {
195194
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
196195
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
197-
const { clearDomSelection } = useDomEditActionsContext();
198196

199197
return (
200198
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -279,7 +277,8 @@ export function StudioHeader({
279277
return;
280278
}
281279
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
282-
clearDomSelection();
280+
// Keep the current selection when collapsing the Inspector — closing
281+
// the panel shouldn't deselect the element.
283282
setRightCollapsed(true);
284283
}}
285284
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { NLELayout } from "./nle/NLELayout";
33
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
44
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
55
import { DomEditOverlay } from "./editor/DomEditOverlay";
6+
import { MotionPathOverlay } from "./editor/MotionPathOverlay";
7+
import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
68
import { SnapToolbar } from "./editor/SnapToolbar";
79
import { StudioFeedbackBar } from "./StudioFeedbackBar";
810
import type { TimelineElement } from "../player";
911
import { usePlayerStore } from "../player/store/playerStore";
1012
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
1113
import {
1214
STUDIO_INSPECTOR_PANELS_ENABLED,
15+
STUDIO_KEYFRAMES_ENABLED,
1316
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
1417
STUDIO_PREVIEW_SELECTION_ENABLED,
1518
} from "./editor/manualEditingAvailability";
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
108111
isPlaying,
109112
refreshPreviewDocumentVersion,
110113
} = useStudioPlaybackContext();
114+
const compositionDimensions = useCompositionDimensions();
111115

112116
const {
113117
domEditHoverSelection,
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
337341
onToggleRecording={onToggleRecording}
338342
/>
339343
<SnapToolbar onSnapChange={setSnapPrefs} />
344+
{STUDIO_KEYFRAMES_ENABLED && (
345+
<MotionPathOverlay
346+
iframeRef={previewIframeRef}
347+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
348+
compositionSize={compositionDimensions}
349+
isPlaying={isPlaying}
350+
/>
351+
)}
340352
{gestureOverlay}
341353
</>
342354
) : null

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { useRef } from "react";
2-
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
2+
import {
3+
useEnableKeyframes,
4+
isPlayheadWithinTween,
5+
type EnableKeyframesSession,
6+
} from "../hooks/useEnableKeyframes";
7+
import { computeElementPercentage } from "../hooks/gsapShared";
38
import {
49
getNextTimelineZoomPercent,
510
getTimelineZoomPercent,
@@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
4449
const anims = session.selectedGsapAnimations;
4550
const kfAnim = anims.find((a) => a.keyframes);
4651

47-
const computePct = (time: number) => {
48-
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
49-
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
50-
return elDuration > 0
51-
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
52-
: 0;
53-
};
54-
5552
let state: "active" | "inactive" | "none" = "none";
53+
// Outside the tween, clicking extends the animation to the playhead rather than
54+
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
55+
let willExtend = false;
5656
if (kfAnim?.keyframes && sel) {
57-
const pct = computePct(currentTime);
58-
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
59-
? "active"
60-
: "inactive";
57+
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
58+
state = "inactive";
59+
willExtend = true;
60+
} else {
61+
// Tween-relative percentage (not the clip range) so the button state matches
62+
// where the keyframe would actually land.
63+
const pct = computeElementPercentage(currentTime, sel, kfAnim);
64+
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
65+
? "active"
66+
: "inactive";
67+
}
6168
}
6269

63-
return { state, onToggle: sel ? onToggle : undefined };
70+
return { state, willExtend, onToggle: sel ? onToggle : undefined };
6471
}
6572

6673
// fallow-ignore-next-line complexity
@@ -76,7 +83,11 @@ export function TimelineToolbar({
7683
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
7784
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
7885
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
79-
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
86+
const {
87+
state: keyframeState,
88+
willExtend: keyframeWillExtend,
89+
onToggle: onToggleKeyframe,
90+
} = useKeyframeToggle(domEditSession);
8091

8192
return (
8293
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
@@ -124,7 +135,9 @@ export function TimelineToolbar({
124135
keyframeState === "active"
125136
? "Remove keyframe at playhead"
126137
: keyframeState === "inactive"
127-
? "Add keyframe at playhead"
138+
? keyframeWillExtend
139+
? "Add keyframe at playhead (extends animation)"
140+
: "Add keyframe at playhead"
128141
: "Enable keyframes"
129142
}
130143
>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
243243
if (!selection) return "none";
244244
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
245245
}, [selection]);
246+
246247
const groupBounds = useMemo(
247248
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
248249
[groupOverlayItems],
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { clipToTweenPercentage } from "./KeyframeNavigation";
3+
4+
/**
5+
* Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the
6+
* GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek.
7+
* The Layout-panel diamond used to emit clip-relative %, so the mutation missed
8+
* every keyframe (off by the tween's offset/scale) → a silent no-op on disk that
9+
* the optimistic cache hid, so the motion path never refreshed.
10+
*/
11+
12+
// A tween that starts partway through the element's lifetime and is shorter than
13+
// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60].
14+
const KEYFRAMES = [
15+
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
16+
{ percentage: 30, tweenPercentage: 25, properties: { x: -180 } },
17+
{ percentage: 50, tweenPercentage: 75, properties: { x: -320 } },
18+
{ percentage: 60, tweenPercentage: 100, properties: { x: -460 } },
19+
];
20+
21+
describe("clipToTweenPercentage", () => {
22+
it("maps anchor keyframes to their tween-relative percentages", () => {
23+
expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
24+
expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
25+
});
26+
27+
it("linearly interpolates a clip-relative playhead into tween space", () => {
28+
// clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
29+
expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
30+
});
31+
32+
it("falls back to the input when there's no usable mapping", () => {
33+
expect(clipToTweenPercentage([], 40)).toBe(40);
34+
expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
35+
});
36+
});

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
33

44
interface KeyframeNavigationProps {
55
property: string;
6-
/** All keyframes for this element's tween, or null if no keyframes exist */
6+
/** All keyframes for this element's tween, or null if no keyframes exist.
7+
* `percentage` is clip-relative (element lifetime) for display/seek;
8+
* `tweenPercentage` is the tween-relative value the writer/runtime key on. */
79
keyframes: Array<{
810
percentage: number;
11+
tweenPercentage?: number;
912
properties: Record<string, number | string>;
1013
ease?: string;
1114
}> | null;
@@ -19,6 +22,26 @@ interface KeyframeNavigationProps {
1922

2023
const TOLERANCE = 0.5;
2124

25+
/**
26+
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
27+
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
28+
* map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
29+
* pairs. Falls back to the input when there's no usable mapping (e.g. parser
30+
* keyframes that are already tween-relative, or fewer than two anchors).
31+
*/
32+
export function clipToTweenPercentage(
33+
keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
34+
clipPct: number,
35+
): number {
36+
const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
37+
if (mapped.length < 2) return clipPct;
38+
const a = mapped[0]!;
39+
const b = mapped[mapped.length - 1]!;
40+
if (b.percentage === a.percentage) return a.tweenPercentage!;
41+
const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
42+
return a.tweenPercentage! + (clipPct - a.percentage) * slope;
43+
}
44+
2245
function ArrowLeft({ disabled }: { disabled: boolean }) {
2346
return (
2447
<svg
@@ -94,13 +117,20 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
94117
diamondState = "ghost";
95118
}
96119

120+
// Keyframe add/remove are keyed by TWEEN-relative percentage (what the GSAP
121+
// writer + runtime use), not the clip-relative `currentPercentage` used for
122+
// display/seek. Removing on an existing keyframe uses its own tweenPercentage;
123+
// adding converts the clip-relative playhead through the keyframes' own
124+
// clip→tween linear mapping. Passing clip-relative % made the mutation miss
125+
// every keyframe (off by the tween's offset/scale) → a silent no-op on disk
126+
// while the optimistic cache hid it, so the motion path never refreshed.
97127
const handleDiamondClick = () => {
98128
if (diamondState === "ghost") {
99129
onConvertToKeyframes();
100-
} else if (diamondState === "active") {
101-
onRemoveKeyframe(currentPercentage);
130+
} else if (diamondState === "active" && atCurrent) {
131+
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
102132
} else {
103-
onAddKeyframe(currentPercentage);
133+
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
104134
}
105135
};
106136

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type React from "react";
2+
3+
// Editor primary color (themeable via --hf-accent). Applied through inline
4+
// style because CSS var() isn't valid in SVG presentation attributes.
5+
export const ACCENT = "var(--hf-accent, #3CE6AC)";
6+
7+
/** One path node: a diamond (matching the timeline keyframe), a wider transparent
8+
* grab target (when editable), and a hover-revealed × delete badge (when removable). */
9+
export function MotionPathNode(props: {
10+
cx: number;
11+
cy: number;
12+
r: number;
13+
interactive: boolean;
14+
removable: boolean;
15+
grabbing: boolean;
16+
selected: boolean;
17+
onEnter: () => void;
18+
onLeave: () => void;
19+
onPointerDown: (e: React.PointerEvent) => void;
20+
onPointerMove: (e: React.PointerEvent) => void;
21+
onPointerUp: (e: React.PointerEvent) => void;
22+
onRemove: (e: React.PointerEvent) => void;
23+
onContextMenu?: (e: React.MouseEvent) => void;
24+
}) {
25+
const { cx, cy, r, interactive, removable, grabbing, selected } = props;
26+
const bx = cx + r * 1.8;
27+
const by = cy - r * 1.8;
28+
const k = r * 0.55;
29+
// Diamond matching the timeline keyframe (a 45°-rotated rounded square).
30+
// `side` is chosen so the diamond's points reach ~`r` from center, matching the
31+
// old dot's footprint; selection is shown by enlarging it (no extra shape).
32+
const side = (selected ? r * 1.5 : r) * 1.414;
33+
return (
34+
<g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
35+
<rect
36+
x={cx - side / 2}
37+
y={cy - side / 2}
38+
width={side}
39+
height={side}
40+
rx={side * 0.17}
41+
transform={`rotate(45 ${cx} ${cy})`}
42+
stroke="#0b0f1a"
43+
strokeWidth={1.5}
44+
vectorEffect="non-scaling-stroke"
45+
style={{ fill: ACCENT }}
46+
/>
47+
{interactive && (
48+
<circle
49+
cx={cx}
50+
cy={cy}
51+
r={r * 2.4}
52+
fill="transparent"
53+
className="pointer-events-auto"
54+
style={{ cursor: grabbing ? "grabbing" : "grab" }}
55+
onPointerDown={props.onPointerDown}
56+
onPointerMove={props.onPointerMove}
57+
onPointerUp={props.onPointerUp}
58+
onContextMenu={props.onContextMenu}
59+
/>
60+
)}
61+
{removable && (
62+
<g
63+
className="pointer-events-auto"
64+
style={{ cursor: "pointer" }}
65+
onPointerDown={props.onRemove}
66+
>
67+
<circle
68+
cx={bx}
69+
cy={by}
70+
r={r * 1.3}
71+
stroke="#0b0f1a"
72+
strokeWidth={1}
73+
vectorEffect="non-scaling-stroke"
74+
style={{ fill: ACCENT }}
75+
/>
76+
<line
77+
x1={bx - k}
78+
y1={by - k}
79+
x2={bx + k}
80+
y2={by + k}
81+
stroke="#0b0f1a"
82+
strokeWidth={1.5}
83+
vectorEffect="non-scaling-stroke"
84+
/>
85+
<line
86+
x1={bx + k}
87+
y1={by - k}
88+
x2={bx - k}
89+
y2={by + k}
90+
stroke="#0b0f1a"
91+
strokeWidth={1.5}
92+
vectorEffect="non-scaling-stroke"
93+
/>
94+
</g>
95+
)}
96+
</g>
97+
);
98+
}

0 commit comments

Comments
 (0)