Skip to content

Commit bbfc398

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. Makes "Add keyframe at playhead" do the right thing for every GSAP animation shape, never disabling or silently no-oping: - Array-form keyframe tweens (keyframes: [{x,y},…]): readElementPosition now derives the captured props from the keyframe stops (top-level properties is empty for array form), and the percentage uses the tween range, not the clip range — so the add lands at the right spot instead of no-oping. - Out of the tween range, the action extends the tween to reach the playhead and adds a hold there, rescaling existing keyframes to keep their absolute timing (was: disabled / destructive). - Flat tweens (to/from/fromTo) convert to their natural keyframes, then take the same add/extend path. - set() is promoted to a two-stop tween from the set's time to the playhead. - motionPath/arc tweens add a waypoint at the on-path position (matching segment, so the curve is preserved) instead of being linearized; outside the range they extend their duration, with a merge threshold against duplicates. Also fixes deep-link hydration. A URL with ?t=…&selId=… restored neither the playhead nor the selection on a fresh load: useStudioUrlState requests the seek before the player runtime mounts its requestedSeekTime subscription, so the request never reached pendingSeekRef, and initializeAdapter (which drained only pendingSeekRef when the adapter became ready) started at 0 — which also blocked selection hydration (gated on the seek settling). Fixed at the source: initializeAdapter now reconciles the store's requestedSeekTime as well, so a seek requested any time before the adapter is ready lands deterministically. Supersedes the separately-reviewed studio PRs #1557, #1558, #1559, #1560, #1561.
1 parent d133735 commit bbfc398

49 files changed

Lines changed: 2977 additions & 304 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)