Skip to content

Commit e347d3a

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: on a fresh full-page load the player runtime isn't ready to honor the first requestSeek, so the one-shot seek latched without moving the playhead, and the selection hydration (gated on the seek settling) never ran — a URL with ?t=…&selId=… restored neither. A bounded heartbeat now retries the seek until the player honors it, then the selection restores. Supersedes the separately-reviewed studio PRs #1557, #1558, #1559, #1560, #1561.
1 parent e450a92 commit e347d3a

48 files changed

Lines changed: 3009 additions & 309 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)