Skip to content

Commit bcdfd8e

Browse files
committed
feat(studio): keyframes flag, gesture recording + timeline/selection refinements
Enable-keyframes gate, recorded-gesture-stays-in-place, clipToTweenPercentage keyframe-nav, inline-expand timeline elements, and minor header/layout/store touches.
1 parent f9e5f4f commit bcdfd8e

19 files changed

Lines changed: 407 additions & 72 deletions

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/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}
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

packages/studio/src/components/nle/NLELayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ export const NLELayout = memo(function NLELayout({
427427
{/* Preview + player controls */}
428428
<div className="flex-1 min-h-0 flex flex-col">
429429
<div
430-
className="flex-1 min-h-0 relative"
430+
className="flex-1 min-h-0 relative overflow-hidden"
431431
data-preview-pan-surface="true"
432432
onPointerDown={(e) => {
433433
const el = iframeRef.current?.parentElement ?? iframeRef.current;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
119119
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
120120
mov: {
121121
label: "MOV (ProRes 4444)",
122-
desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.",
122+
desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
123123
},
124124
webm: {
125125
label: "WebM (VP9)",

packages/studio/src/hooks/useDomSelection.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
getAllPreviewTargetsFromPointer,
55
getPreviewTargetFromPointer,
66
} from "../utils/studioPreviewHelpers";
7-
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
7+
import {
8+
findMatchingTimelineElementId,
9+
findTimelineIdByAncestor,
10+
type RightPanelTab,
11+
} from "../utils/studioHelpers";
812
import {
913
domEditSelectionsTargetSame,
1014
domEditSelectionInGroup,
@@ -178,10 +182,13 @@ export function useDomSelection({
178182
setRightCollapsed(false);
179183
setRightPanelTab("design");
180184
}
181-
const nextSelectedTimelineId = findMatchingTimelineElementId(
182-
nextSelection,
183-
timelineElements,
184-
);
185+
const nextSelectedTimelineId =
186+
findMatchingTimelineElementId(nextSelection, timelineElements) ??
187+
findTimelineIdByAncestor(
188+
nextSelection.element,
189+
timelineElements,
190+
nextSelection.sourceFile || "index.html",
191+
);
185192
setSelectedTimelineElementId(nextSelectedTimelineId);
186193
return;
187194
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveNewTweenRange } from "./useEnableKeyframes";
3+
4+
describe("resolveNewTweenRange", () => {
5+
// Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps
6+
// data-start="0" + data-duration=<rootDuration> on every GSAP element, so honoring
7+
// data-start as authored timing put the keyframe at 0. Clamping the playhead into
8+
// the element's range fixes it (auto-stamp's full range passes the playhead through).
9+
it("anchors at the playhead through the auto-stamped full-composition range", () => {
10+
// data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9
11+
expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 });
12+
});
13+
14+
it("anchors at the playhead when the element has no authored range", () => {
15+
expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 });
16+
expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123);
17+
});
18+
19+
it("never returns a negative start", () => {
20+
expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0);
21+
});
22+
23+
it("clamps the playhead into a genuinely narrow authored clip", () => {
24+
// clip [2.5, 8]: inside → playhead; before → start; after → end
25+
expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 });
26+
expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5);
27+
expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8);
28+
});
29+
});

packages/studio/src/hooks/useEnableKeyframes.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ function readElementPosition(
5555
const element = sel.element;
5656
if (!element?.isConnected || !gsap?.getProperty) return result;
5757

58-
const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
58+
// ponytail: a brand-new tween captures position only — bundling opacity made it
59+
// a mixed group that the position-only drag intercept couldn't resolve.
60+
const props = anim ? Object.keys(anim.properties) : ["x", "y"];
5961
for (const prop of props) {
6062
const val = Number(gsap.getProperty(element, prop));
6163
if (!Number.isFinite(val)) continue;
@@ -65,6 +67,32 @@ function readElementPosition(
6567
return result;
6668
}
6769

70+
/**
71+
* Range for a brand-new keyframe tween created via "Enable keyframes" on an element
72+
* with no existing animation. "Add a keyframe" must land at the PLAYHEAD.
73+
*
74+
* The runtime auto-stamps `data-start="0"` + `data-duration=<rootDuration>` on every
75+
* timeline element, so we can't treat `data-start` as authored timing (doing so put
76+
* the keyframe at 0). Instead, clamp the playhead into the element's [start, end]
77+
* range: the auto-stamp's full-composition range passes the playhead through
78+
* unchanged, while a genuinely narrow authored clip still clamps sensibly.
79+
*/
80+
export function resolveNewTweenRange(
81+
authoredStart: string | undefined,
82+
authoredDuration: string | undefined,
83+
currentTime: number,
84+
): { start: number; duration: number } {
85+
const t = Math.max(0, roundTo3(currentTime));
86+
const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN;
87+
const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN;
88+
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) {
89+
return { start: t, duration: 1 };
90+
}
91+
const end = start + duration;
92+
const clampedStart = Math.min(Math.max(t, start), end);
93+
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
94+
}
95+
6896
async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
6997
const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
7098
if (!projectId) return [];
@@ -122,9 +150,11 @@ export function useEnableKeyframes(
122150
}
123151
} else {
124152
const position = readElementPosition(iframe, sel, null);
125-
const pct = computeElementPercentage(t, sel);
126-
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
127-
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
153+
const { start: elStart, duration: elDuration } = resolveNewTweenRange(
154+
sel.dataAttributes?.start,
155+
sel.dataAttributes?.duration,
156+
t,
157+
);
128158
const selector = selectorFromSelection(sel);
129159

130160
if (!selector) {
@@ -135,19 +165,13 @@ export function useEnableKeyframes(
135165
if (Object.keys(position).length === 0) {
136166
position.x = 0;
137167
position.y = 0;
138-
position.opacity = 1;
139168
}
140169

170+
// One keyframe at the playhead — a single diamond capturing the current
171+
// value. Motion comes from the user adding/dragging more keyframes later;
172+
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
141173
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
142174
[{ percentage: 0, properties: { ...position } }];
143-
if (pct > 1 && pct < 99) {
144-
keyframes.push({ percentage: pct, properties: { ...position } });
145-
}
146-
keyframes.push({
147-
percentage: 100,
148-
properties: { ...position },
149-
auto: true,
150-
} as (typeof keyframes)[number]);
151175

152176
if (session.commitMutation) {
153177
await session.commitMutation(

0 commit comments

Comments
 (0)