diff --git a/docs/docs.json b/docs/docs.json
index 3982833c1..c8a1caac6 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -84,6 +84,7 @@
"guides/prompting",
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
+ "guides/keyframes",
"guides/rendering",
"guides/remove-background",
"guides/hdr",
diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx
new file mode 100644
index 000000000..74be00597
--- /dev/null
+++ b/docs/guides/keyframes.mdx
@@ -0,0 +1,141 @@
+---
+title: Keyframes & Arc Motion
+description: "Edit GSAP keyframes visually in Studio — timeline diamonds, arc motion paths, and gesture recording."
+---
+
+Studio gives you visual tools to create and edit GSAP keyframes without writing code. You can adjust animation properties in the Design Panel, convert straight-line motion into curved arcs, and record gesture-based motion by dragging elements in the preview.
+
+## Timeline Keyframe Diamonds
+
+When you open a composition in Studio, the timeline shows **diamond markers** on clips that have GSAP animations. Each diamond represents a keyframe — a point in time where a property value is set.
+
+- **Start diamond** — where the tween begins (e.g., `x: 0`)
+- **End diamond** — where the tween ends (e.g., `x: 1000`)
+- Elements with multiple tweens show multiple diamond pairs
+
+
+ Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline.
+
+
+## Editing Animation Properties
+
+Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows:
+
+- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`)
+- **Timing** — Length (duration) and Starts at (position on timeline)
+- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`)
+- **Speed curve** — Visual preview of the easing function
+- **Properties** — Each animated property (Move X, Move Y, Scale, Opacity, etc.) with its target value
+
+
+
+ Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right.
+
+
+ Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload.
+
+
+ Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live.
+
+
+ Switch to the Code tab to see the generated GSAP code. Every Design Panel edit writes valid GSAP that renders identically in preview and headless export.
+
+
+
+## Arc Motion
+
+Arc Motion converts a straight-line x/y animation into a curved path using GSAP's MotionPathPlugin. Instead of moving in a straight diagonal, the element follows a smooth arc — like tossing an object into a basket.
+
+### When to Use It
+
+Use Arc Motion when an element has both `x` and `y` properties in a single tween. Common examples:
+- Add-to-cart animations (item arcs from product to cart icon)
+- Throw/toss effects
+- Any motion that should feel physical rather than robotic
+
+### Step-by-Step
+
+
+
+ The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline.
+
+
+ In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON.
+
+
+ The **Curviness** slider controls how exaggerated the arc is:
+ - `0` — straight line (no curve)
+ - `1` — gentle natural arc
+ - `1.5–2.0` — smooth throw feel (recommended)
+ - `3.0` — extreme loop
+
+ Scrub the timeline to preview the arc in real time.
+
+
+ Enable **Auto-Rotate** to make the element rotate to face the direction of travel along the arc. This adds a "thrown" feel vs. a "floating" feel.
+
+
+ Switch to the Code tab. You'll see:
+
+ ```javascript
+ tl.to("#element", {
+ scale: 0.4,
+ opacity: 0,
+ duration: 1.0,
+ ease: "power2.inOut",
+ motionPath: {
+ path: [{x: 0, y: 0}, {x: 1400, y: -280}],
+ curviness: 1.5,
+ autoRotate: true
+ }
+ }, 1.0);
+ ```
+
+ The MotionPathPlugin CDN script is added automatically.
+
+
+ Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values.
+
+
+
+
+ Arc Motion works for flat `.to()` tweens with x/y properties. It synthesizes waypoints from `{x: 0, y: 0}` (start) to `{x: targetX, y: targetY}` (end). For more complex paths with intermediate waypoints, edit the `motionPath.path` array directly in the Code tab.
+
+
+## Gesture Recording
+
+Record motion by physically dragging an element in the preview while the timeline plays. The pointer path is simplified and converted into GSAP keyframes automatically.
+
+
+
+ Click the element you want to animate in the preview.
+
+
+ In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing.
+
+
+ Move the element in the preview by dragging it. Your pointer motion is sampled at ~60fps. A trail overlay shows the path you're drawing.
+
+
+ Press R again or wait for the timeline to reach the end. Recording stops, the motion is simplified (reducing ~180 raw samples to 5–15 clean keyframes), and the keyframes are written to the GSAP script immediately.
+
+
+ The timeline seeks back to the recording start so you can scrub through the result. If you don't like it, press **Cmd+Z** to undo and try again.
+
+
+
+## Clipboard Context
+
+The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard:
+
+```
+Element: Title (#title)
+File: index.html:15
+Position: x=100, y=40
+Size: 264×43
+Tag:
+Animation: from() 0.5s at 0s, ease: power2.out
+Properties: x: -40, opacity: 0
+```
+
+Paste this into any AI agent prompt to give it spatial context about the element — its position, size, animation, and source location.
diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts
index 0a9888d32..8471cc925 100644
--- a/packages/core/src/parsers/gsapParser.ts
+++ b/packages/core/src/parsers/gsapParser.ts
@@ -1354,10 +1354,25 @@ export function addKeyframeToScript(
ease?: string,
backfillDefaults?: Record,
): string {
- const loc = locateAnimation(script, animationId);
+ let loc = locateAnimation(script, animationId);
+ if (!loc) {
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
+ loc = locateAnimation(script, convertedId);
+ }
if (!loc) return script;
- const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
- if (!kfNode) return script;
+ let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
+
+ if (!kfNode) {
+ script = convertToKeyframesInScript(script, animationId);
+ loc = locateAnimation(script, animationId);
+ if (!loc) {
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
+ loc = locateAnimation(script, convertedId);
+ }
+ if (!loc) return script;
+ kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
+ if (!kfNode) return script;
+ }
const pctKey = `${percentage}%`;
const newValueNode = buildKeyframeValueNode(properties, ease);
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index a4758d62f..530ede9f8 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -1002,25 +1002,12 @@ export function initSandboxRuntimeModular(): void {
});
// Stamp data-start / data-duration on GSAP-targeted elements that lack
// them so the Studio timeline can discover individual animated elements.
- // Skip elements whose ancestor already carries timing — stamping them
- // would override the parent's clip visibility and cause preview/render
- // parity drift.
{
const rootComp = resolveRootCompositionElement();
const rootDuration = boundDuration > 0 ? boundDuration : 0;
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set();
- const hasTimedAncestor = (el: HTMLElement): boolean => {
- let cursor = el.parentElement;
- while (cursor) {
- if (cursor.hasAttribute("data-start")) return true;
- if (cursor === rootComp) return false;
- cursor = cursor.parentElement;
- }
- return false;
- };
-
// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
@@ -1030,7 +1017,6 @@ export function initSandboxRuntimeModular(): void {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
- if (hasTimedAncestor(target)) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
@@ -1050,7 +1036,6 @@ export function initSandboxRuntimeModular(): void {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
- if (hasTimedAncestor(el)) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index 8cdc5ae05..a09f9baf7 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -195,7 +195,11 @@ export function patchElementInHtml(
}
break;
case "text-content":
- if (op.value != null) htmlEl.textContent = op.value;
+ if (op.value != null) {
+ const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
+ const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
+ textTarget.textContent = op.value;
+ }
break;
}
}
@@ -219,6 +223,35 @@ export interface SplitElementResult {
newId: string | null;
}
+function resolveElementTiming(el: Element): {
+ start: number;
+ duration: number;
+ usesDataEnd: boolean;
+} {
+ const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
+ const usesDataEnd = el.hasAttribute("data-end");
+ const duration = usesDataEnd
+ ? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
+ : parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
+ return { start, duration, usesDataEnd };
+}
+
+function setElementDuration(
+ el: Element,
+ start: number,
+ duration: number,
+ usesDataEnd: boolean,
+): void {
+ if (usesDataEnd) {
+ const endTime = String(Math.round((start + duration) * 1000) / 1000);
+ el.setAttribute("data-end", endTime);
+ el.removeAttribute("data-duration");
+ } else {
+ el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
+ el.removeAttribute("data-end");
+ }
+}
+
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
@@ -229,8 +262,7 @@ export function splitElementInHtml(
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
- const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
- const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
+ const { start, duration, usesDataEnd } = resolveElementTiming(el);
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
@@ -241,7 +273,7 @@ export function splitElementInHtml(
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
- clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
+ setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
@@ -251,7 +283,8 @@ export function splitElementInHtml(
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
- const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
+ const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
+ const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
@@ -259,7 +292,7 @@ export function splitElementInHtml(
}
// Trim the original element's duration
- el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
+ setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
if (el.nextSibling) {
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index af9edf131..6f8d897c0 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -35,6 +35,10 @@ import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
import { StudioHeader } from "./components/StudioHeader";
+import { useGestureRecording } from "./hooks/useGestureRecording";
+import { simplifyGestureSamples } from "./utils/rdpSimplify";
+import { fetchParsedAnimations, getAnimationsForElement } from "./hooks/useGsapTweenCache";
+import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
import { StudioRightPanel } from "./components/StudioRightPanel";
@@ -128,7 +132,7 @@ export function StudioApp() {
return !v;
});
}, []);
- const { appToast, showToast } = useToast();
+ const { appToast, showToast, dismissToast } = useToast();
const panelLayout = usePanelLayout({
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
@@ -306,6 +310,7 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
+ onToggleRecording: () => handleToggleRecordingRef.current(),
});
const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
@@ -400,6 +405,138 @@ export function StudioApp() {
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
+ // Gesture recording
+ const gestureRecording = useGestureRecording();
+ const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
+ const recordingAutoStopRef = useRef>(undefined);
+ const recordingStartTimeRef = useRef(0);
+ const commitInFlightRef = useRef(false);
+ const handleToggleRecordingRef = useRef<() => void>(() => {});
+ const domEditSessionRef = useRef(domEditSession);
+ domEditSessionRef.current = domEditSession;
+
+ // Unmount: clear auto-stop interval
+ useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
+
+ // fallow-ignore-next-line complexity
+ const stopAndCommitRecording = useCallback(async () => {
+ if (commitInFlightRef.current) return;
+ commitInFlightRef.current = true;
+ clearInterval(recordingAutoStopRef.current);
+ const frozenSamples = gestureRecording.stopRecording();
+ const store = usePlayerStore.getState();
+ store.setIsPlaying(false);
+ try {
+ const liveSession = domEditSessionRef.current;
+ const sel = liveSession.domEditSelection;
+ const selId = sel?.id;
+ let anims = liveSession.selectedGsapAnimations;
+ if (anims.length === 0 && sel) {
+ const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
+ if (projectId) {
+ try {
+ const parsed = await fetchParsedAnimations(projectId, sel.sourceFile || "index.html");
+ if (parsed)
+ anims = getAnimationsForElement(parsed.animations, {
+ id: sel.id,
+ selector: sel.selector,
+ });
+ } catch {
+ /* fetch failed — proceed with empty anims */
+ }
+ }
+ }
+ // Bail if selection changed during async fetch
+ if (selId && domEditSessionRef.current.domEditSelection?.id !== selId) return;
+
+ const anim = anims?.[0];
+ const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0;
+ console.log("[GR-COMMIT]", { samples: frozenSamples.length, animId: anim?.id, hasKf: !!anim?.keyframes, duration, hasBatch: !!liveSession.handleGsapAddKeyframeBatch, hasCommit: !!liveSession.commitMutation });
+
+ if (sel && frozenSamples.length > 2 && duration > 0) {
+ const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
+ const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
+
+ if (anim) {
+ if (!anim.keyframes) {
+ await liveSession.handleGsapConvertToKeyframes(anim.id);
+ }
+ for (const pct of sortedPcts) {
+ const props = simplified.get(pct);
+ if (!props) continue;
+ if (liveSession.handleGsapAddKeyframeBatch) {
+ await liveSession.handleGsapAddKeyframeBatch(anim.id, pct, props);
+ }
+ }
+ } else if (liveSession.commitMutation) {
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
+ if (selector) {
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
+ const keyframes = sortedPcts.map((pct) => ({
+ percentage: pct,
+ properties: simplified.get(pct) as Record,
+ }));
+ await liveSession.commitMutation(
+ {
+ type: "add-with-keyframes",
+ targetSelector: selector,
+ position: Math.round(elStart * 1000) / 1000,
+ duration: Math.round(elDuration * 1000) / 1000,
+ keyframes,
+ },
+ { label: "Gesture recording", softReload: true },
+ );
+ try {
+ const { clearStudioPathOffset } = await import(
+ "./components/editor/manualEdits"
+ );
+ clearStudioPathOffset(sel.element);
+ } catch {
+ /* non-critical */
+ }
+ }
+ }
+ showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
+ } else if (frozenSamples.length <= 2) {
+ showToast("No gesture detected — move the pointer while recording", "error");
+ }
+ } finally {
+ store.requestSeek(recordingStartTimeRef.current);
+ gestureRecording.clearSamples();
+ setGestureState("idle");
+ commitInFlightRef.current = false;
+ }
+ }, [gestureRecording, showToast]);
+
+ const handleToggleRecording = useCallback(() => {
+ if (gestureState === "recording") {
+ stopAndCommitRecording();
+ return;
+ }
+ const sel = domEditSessionRef.current.domEditSelection;
+ if (!sel) {
+ showToast("Select an element first", "error");
+ return;
+ }
+ const iframe = previewIframeRef.current;
+ if (!iframe) return;
+
+ const store = usePlayerStore.getState();
+ recordingStartTimeRef.current = store.currentTime;
+ gestureRecording.startRecording(sel.element, iframe);
+ setGestureState("recording");
+
+ clearInterval(recordingAutoStopRef.current);
+ recordingAutoStopRef.current = setInterval(() => {
+ const { currentTime: t, duration: d } = usePlayerStore.getState();
+ if (d > 0 && t >= d - 0.05) {
+ stopAndCommitRecording();
+ }
+ }, 100);
+ }, [gestureState, gestureRecording, showToast, stopAndCommitRecording]);
+ handleToggleRecordingRef.current = handleToggleRecording;
+
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
@@ -435,6 +572,7 @@ export function StudioApp() {
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
+ gestureState === "recording",
);
useStudioUrlState({
@@ -541,6 +679,7 @@ export function StudioApp() {
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
+ isGestureRecording={gestureState === "recording"}
blockPreview={blockPreview}
/>
@@ -554,6 +693,9 @@ export function StudioApp() {
setActiveBlockParams(null);
panelLayout.setRightPanelTab("design");
}}
+ recordingState={gestureState}
+ recordingDuration={gestureRecording.recordingDuration}
+ onToggleRecording={handleToggleRecording}
/>
)}
@@ -585,8 +727,27 @@ export function StudioApp() {
/>
)}
+ {gestureState === "recording" && previewIframe && (
+ {
+ const r = previewIframe.getBoundingClientRect();
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
+ })()}
+ compositionSize={compositionDimensions ?? undefined}
+ mode="recording"
+ />
+ )}
{dragOverlay.active && }
- {appToast && }
+ {appToast && (
+
+ )}
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index 3404df15c..6cc5005b7 100644
--- a/packages/studio/src/components/StudioHeader.tsx
+++ b/packages/studio/src/components/StudioHeader.tsx
@@ -10,6 +10,8 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
+export type RecordingState = "idle" | "recording" | "preview";
+
export interface StudioHeaderProps {
captureFrameHref: string;
captureFrameFilename: string;
diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx
index 5399d3f42..45e52711d 100644
--- a/packages/studio/src/components/StudioPreviewArea.tsx
+++ b/packages/studio/src/components/StudioPreviewArea.tsx
@@ -56,6 +56,7 @@ export interface StudioPreviewAreaProps {
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
+ isGestureRecording?: boolean;
}
// fallow-ignore-next-line complexity
@@ -74,6 +75,7 @@ export function StudioPreviewArea({
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
+ isGestureRecording,
blockPreview,
}: StudioPreviewAreaProps) {
const {
@@ -112,6 +114,7 @@ export function StudioPreviewArea({
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapDeleteAnimation,
+ handleGsapRemoveAllKeyframes,
} = useDomEditContext();
const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -144,9 +147,13 @@ export function StudioPreviewArea({
onSplitElement={handleTimelineElementSplit}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
- const anim =
- selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
- if (anim) handleGsapDeleteAnimation(anim.id);
+ const kfAnim = selectedGsapAnimations.find((a) => a.keyframes);
+ if (kfAnim) {
+ handleGsapRemoveAllKeyframes(kfAnim.id);
+ } else {
+ const anyAnim = selectedGsapAnimations[0];
+ if (anyAnim) handleGsapDeleteAnimation(anyAnim.id);
+ }
}}
onDeleteKeyframe={(_elId, pct) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
@@ -241,7 +248,7 @@ export function StudioPreviewArea({
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
- allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index ed68072a8..f965802df 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -32,6 +32,9 @@ export interface StudioRightPanelProps {
compositionPath: string;
} | null;
onCloseBlockParams?: () => void;
+ recordingState?: "idle" | "recording" | "preview";
+ recordingDuration?: number;
+ onToggleRecording?: () => void;
}
// fallow-ignore-next-line complexity
@@ -41,6 +44,9 @@ export function StudioRightPanel({
motionPanelActive,
activeBlockParams,
onCloseBlockParams,
+ recordingState,
+ recordingDuration,
+ onToggleRecording,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -230,6 +236,9 @@ export function StudioRightPanel({
onCommitAnimatedProperty={commitAnimatedProperty}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
+ recordingState={recordingState}
+ recordingDuration={recordingDuration}
+ onToggleRecording={onToggleRecording}
/>
) : motionPanelActive ? (
void;
}
-export function StudioToast({ message, tone }: StudioToastProps) {
+export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
+ const isError = tone === "error";
return (
- {message}
+
+
{message}
+ {onDismiss && (
+
{
+ e.stopPropagation();
+ onDismiss();
+ }}
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
+ aria-label="Dismiss"
+ >
+
+
+
+
+ )}
+
);
}
diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx
index 943c12bd4..abbb471bf 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -1,3 +1,5 @@
+import { useRef } from "react";
+import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
@@ -7,123 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
-import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
-function interpolateKeyframeProperties(
- keyframes: GsapPercentageKeyframe[],
- pct: number,
-): Record {
- const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
- const allProps = new Set();
- for (const kf of sorted) {
- for (const p of Object.keys(kf.properties)) {
- if (typeof kf.properties[p] === "number") allProps.add(p);
- }
- }
- const result: Record = {};
- for (const prop of allProps) {
- let prev: { pct: number; val: number } | null = null;
- let next: { pct: number; val: number } | null = null;
- for (const kf of sorted) {
- const v = kf.properties[prop];
- if (typeof v !== "number") continue;
- if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
- if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
- }
- if (prev && next && prev.pct !== next.pct) {
- const t = (pct - prev.pct) / (next.pct - prev.pct);
- result[prop] = Math.round(prev.val + t * (next.val - prev.val));
- } else if (prev) {
- result[prop] = Math.round(prev.val);
- } else if (next) {
- result[prop] = Math.round(next.val);
- }
- }
- return result;
-}
-
-function readRuntimeKeyframeValues(
- iframe: HTMLIFrameElement | null,
- sel: DomEditSelection,
- keyframes: GsapPercentageKeyframe[],
-): Record {
- if (!iframe?.contentWindow) return {};
- let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
- try {
- gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
- } catch {
- return {};
- }
- if (!gsap?.getProperty) return {};
- const selector = sel.id ? `#${sel.id}` : sel.selector;
- if (!selector) return {};
- let doc: Document | null = null;
- try {
- doc = iframe.contentDocument;
- } catch {
- return {};
- }
- const element = doc?.querySelector(selector);
- if (!element) return {};
- const allProps = new Set();
- for (const kf of keyframes) {
- for (const p of Object.keys(kf.properties)) {
- if (typeof kf.properties[p] === "number") allProps.add(p);
- }
- }
- const result: Record = {};
- for (const prop of allProps) {
- const val = Number(gsap.getProperty(element, prop));
- if (Number.isFinite(val)) result[prop] = Math.round(val);
- }
- return result;
-}
-
-// fallow-ignore-next-line complexity
-function readRuntimeValuesForAnim(
- iframe: HTMLIFrameElement | null,
- sel: DomEditSelection,
- anim: GsapAnimation,
-): Record {
- if (!iframe?.contentWindow) return {};
- let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
- try {
- gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
- } catch {
- return {};
- }
- if (!gsap?.getProperty) return {};
- const selector = sel.id ? `#${sel.id}` : sel.selector;
- if (!selector) return {};
- let doc: Document | null = null;
- try {
- doc = iframe.contentDocument;
- } catch {
- return {};
- }
- const element = doc?.querySelector(selector);
- if (!element) return {};
- const result: Record = {};
- for (const prop of Object.keys(anim.properties)) {
- const val = Number(gsap.getProperty(element, prop));
- if (Number.isFinite(val)) result[prop] = Math.round(val);
- }
- return result;
-}
-
-interface DomEditSessionSlice {
+interface DomEditSessionSlice extends EnableKeyframesSession {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
- handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
- handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
- handleGsapConvertToKeyframes: (
- animId: string,
- resolvedFromValues?: Record,
- ) => void;
- handleGsapMaterializeKeyframes?: (animId: string) => Promise;
- handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
- previewIframeRef?: React.RefObject;
}
interface TimelineToolbarProps {
@@ -132,15 +23,20 @@ interface TimelineToolbarProps {
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
-// fallow-ignore-next-line complexity
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
+ const sessionRef = useRef(session);
+ sessionRef.current = session;
+
+ const onToggle = useEnableKeyframes(
+ sessionRef as React.RefObject,
+ );
+
if (!session) return { state: "none" as const, onToggle: undefined };
const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
- const flatAnim = anims.find((a) => !a.keyframes);
let state: "active" | "inactive" | "none" = "none";
if (kfAnim?.keyframes && sel) {
@@ -155,56 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
: "inactive";
}
- // fallow-ignore-next-line complexity
- const onToggle = sel
- ? async () => {
- const t = usePlayerStore.getState().currentTime;
- if (kfAnim?.keyframes) {
- if (kfAnim.hasUnresolvedKeyframes) {
- await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
- }
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
- const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
- const pct =
- elDuration > 0
- ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
- : 0;
- const existing = kfAnim.keyframes.keyframes.find(
- (k) => Math.abs(k.percentage - pct) <= 1,
- );
- if (existing) {
- session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
- } else {
- const runtimeValues = readRuntimeKeyframeValues(
- session.previewIframeRef?.current ?? null,
- sel,
- kfAnim.keyframes.keyframes,
- );
- const values =
- Object.keys(runtimeValues).length > 0
- ? runtimeValues
- : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
- for (const [prop, val] of Object.entries(values)) {
- session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
- }
- }
- } else if (flatAnim) {
- const runtimeProps = readRuntimeValuesForAnim(
- session.previewIframeRef?.current ?? null,
- sel,
- flatAnim,
- );
- session.handleGsapConvertToKeyframes(
- flatAnim.id,
- Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined,
- );
- } else {
- session.handleGsapAddAnimation("to");
- }
- }
- : undefined;
-
- return { state, onToggle };
+ return { state, onToggle: sel ? onToggle : undefined };
}
export function TimelineToolbar({
diff --git a/packages/studio/src/components/editor/GesturePreviewPanel.tsx b/packages/studio/src/components/editor/GesturePreviewPanel.tsx
new file mode 100644
index 000000000..cd0514875
--- /dev/null
+++ b/packages/studio/src/components/editor/GesturePreviewPanel.tsx
@@ -0,0 +1,150 @@
+import { memo, useCallback, useEffect, useMemo, useState } from "react";
+import type { GestureSample } from "../../hooks/useGestureRecording";
+import { simplifyGestureSamples } from "../../utils/rdpSimplify";
+import { inferAllEases } from "../../utils/inferEase";
+import { SliderControl } from "./propertyPanelPrimitives";
+import { LABEL } from "./propertyPanelHelpers";
+import { P } from "./panelTokens";
+
+interface GesturePreviewPanelProps {
+ samples: GestureSample[];
+ totalDuration: number;
+ onCommit: (keyframes: Map>, eases: Map) => void;
+ onDiscard: () => void;
+ onReRecord: () => void;
+ onSimplifiedChange?: (simplified: Map>) => void;
+}
+
+export const GesturePreviewPanel = memo(function GesturePreviewPanel({
+ samples,
+ totalDuration,
+ onCommit,
+ onDiscard,
+ onReRecord,
+ onSimplifiedChange,
+}: GesturePreviewPanelProps) {
+ const [epsilon, setEpsilon] = useState(5);
+
+ const simplified = useMemo(
+ () => simplifyGestureSamples(samples, totalDuration, epsilon),
+ [samples, totalDuration, epsilon],
+ );
+
+ useEffect(() => {
+ onSimplifiedChange?.(simplified);
+ }, [simplified, onSimplifiedChange]);
+
+ const eases = useMemo(() => {
+ const pcts = Array.from(simplified.keys()).sort((a, b) => a - b);
+ return inferAllEases(
+ samples.map((s) => ({
+ time: s.time,
+ properties: s.properties,
+ })),
+ pcts,
+ totalDuration,
+ );
+ }, [samples, simplified, totalDuration]);
+
+ const keyframeCount = simplified.size;
+ const propertyList = useMemo(() => {
+ const keys = new Set();
+ for (const props of simplified.values()) {
+ for (const k of Object.keys(props)) keys.add(k);
+ }
+ return Array.from(keys);
+ }, [simplified]);
+
+ const handleEpsilonChange = useCallback((v: number) => {
+ setEpsilon(Math.round(v * 10) / 10);
+ }, []);
+
+ const handleCommit = useCallback(() => {
+ onCommit(simplified, eases);
+ }, [onCommit, simplified, eases]);
+
+ return (
+
+
+ Gesture Recording
+ {keyframeCount} keyframes
+
+
+
+
Smoothing
+
v.toFixed(1)}
+ onCommit={handleEpsilonChange}
+ />
+
+ Detailed
+ Smooth
+
+
+
+ {propertyList.length > 0 && (
+
+
Recorded properties
+
+ {propertyList.map((prop) => (
+
+ {prop}
+
+ ))}
+
+
+ )}
+
+ {eases.size > 0 && (
+
+
Inferred eases
+
+ {Array.from(eases.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([pct, ease]) => (
+
+ {pct}% → {ease}
+
+ ))}
+
+
+ )}
+
+
+
+ Commit
+
+
+ Re-record
+
+
+ Discard
+
+
+
+ );
+});
diff --git a/packages/studio/src/components/editor/GestureTrailOverlay.tsx b/packages/studio/src/components/editor/GestureTrailOverlay.tsx
new file mode 100644
index 000000000..f067178b4
--- /dev/null
+++ b/packages/studio/src/components/editor/GestureTrailOverlay.tsx
@@ -0,0 +1,128 @@
+import { memo, useMemo } from "react";
+import type { GestureSample } from "../../hooks/useGestureRecording";
+
+interface GestureTrailOverlayProps {
+ samples: GestureSample[];
+ sampleCount?: number;
+ trail?: Array<{ x: number; y: number }>;
+ simplifiedPoints?: Map>;
+ canvasRect: { left: number; top: number; width: number; height: number };
+ compositionSize?: { width: number; height: number };
+ mode: "recording" | "preview";
+ accentColor?: string;
+}
+
+export const GestureTrailOverlay = memo(function GestureTrailOverlay({
+ samples,
+ sampleCount,
+ trail,
+ simplifiedPoints,
+ canvasRect,
+ compositionSize,
+ mode,
+ accentColor = "#3CE6AC",
+}: GestureTrailOverlayProps) {
+ const trailPoints = useMemo(() => {
+ if (trail && trail.length > 1) {
+ return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
+ }
+ if (samples.length === 0) return "";
+ return samples
+ .filter((s) => s.properties.x != null && s.properties.y != null)
+ .map((s) => `${s.properties.x},${s.properties.y}`)
+ .join(" ");
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
+
+ const simplifiedPath = useMemo(() => {
+ if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
+ const pts: Array<{ x: number; y: number; pct: number }> = [];
+ for (const [pct, props] of simplifiedPoints) {
+ if (props.x != null && props.y != null) {
+ pts.push({ x: props.x, y: props.y, pct });
+ }
+ }
+ pts.sort((a, b) => a.pct - b.pct);
+ if (pts.length === 0) return "";
+ return pts.map((p) => `${p.x},${p.y}`).join(" ");
+ }, [simplifiedPoints]);
+
+ const diamondPositions = useMemo(() => {
+ if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
+ const pts: Array<{ x: number; y: number; pct: number }> = [];
+ for (const [pct, props] of simplifiedPoints) {
+ if (props.x != null && props.y != null) {
+ pts.push({ x: props.x, y: props.y, pct });
+ }
+ }
+ return pts.sort((a, b) => a.pct - b.pct);
+ }, [simplifiedPoints]);
+
+ if (samples.length < 2 && !simplifiedPoints) return null;
+
+ return (
+ 0 ? `0 0 ${canvasRect.width} ${canvasRect.height}` : `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`}
+ >
+ {mode === "recording" && trailPoints && (
+
+ )}
+
+ {mode === "preview" && (
+ <>
+ {trailPoints && (
+
+ )}
+ {simplifiedPath && (
+
+ )}
+ {diamondPositions.map((pt) => (
+
+
+
+ ))}
+ >
+ )}
+
+ );
+});
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index a95996e39..fea818d25 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -1,5 +1,6 @@
-import { memo } from "react";
-import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
+import { memo, useRef, useState } from "react";
+import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
+import { useStudioContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import {
EMPTY_STYLES,
@@ -40,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({
assets,
element,
multiSelectCount = 0,
- copiedAgentPrompt,
+ copiedAgentPrompt: _copiedAgentPrompt,
onClearSelection,
onSetStyle,
onSetAttribute,
@@ -52,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
- onAskAgent,
+ onAskAgent: _onAskAgent,
onImportAssets,
fontAssets = [],
onImportFonts,
@@ -76,8 +77,15 @@ export const PropertyPanel = memo(function PropertyPanel({
onConvertToKeyframes,
onCommitAnimatedProperty,
onSeekToTime,
+ recordingState,
+ recordingDuration,
+ onToggleRecording,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
+ const { showToast } = useStudioContext();
+ const [clipboardCopied, setClipboardCopied] = useState(false);
+ const clipboardTimerRef = useRef>(undefined);
+ const currentTime = usePlayerStore((s) => s.currentTime);
if (!element) {
return (
@@ -171,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetManualRotation(element, { angle: parsed });
};
- // Keyframe navigation state
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
- const currentTime = usePlayerStore((s) => s.currentTime);
const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
@@ -265,11 +271,78 @@ export const PropertyPanel = memo(function PropertyPanel({
{
+ const file = element.sourceFile ?? "index.html";
+ let lineNum: number | null = null;
+ try {
+ const src =
+ previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
+ if (src && element.id) {
+ const idx = src.indexOf(`id="${element.id}"`);
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
+ }
+ if (!lineNum && element.selector) {
+ const tag = element.tagName.toLowerCase();
+ const cls = element.selector.startsWith(".")
+ ? element.selector.slice(1).split(".")[0]
+ : null;
+ const search = cls ? `class="${cls}` : `<${tag}`;
+ const idx = src.indexOf(search);
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
+ }
+ } catch {}
+ const fileLoc = lineNum ? `${file}:${lineNum}` : file;
+ const lines = [
+ `Element: ${element.label} (${sourceLabel})`,
+ `File: ${fileLoc}`,
+ `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
+ `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
+ `Tag: <${element.tagName}>`,
+ ];
+ if (
+ element.computedStyles["z-index"] &&
+ element.computedStyles["z-index"] !== "auto"
+ ) {
+ lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
+ }
+ if (gsapAnimations.length > 0) {
+ const anim = gsapAnimations[0];
+ lines.push(
+ `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
+ );
+ const props = Object.entries(anim.properties)
+ .map(([k, v]) => `${k}: ${v}`)
+ .join(", ");
+ if (props) lines.push(`Properties: ${props}`);
+ }
+ const text = lines.join("\n");
+ void navigator.clipboard.writeText(text);
+ showToast(
+ `Copied element info for ${element.label} — paste into any AI agent`,
+ "info",
+ );
+ setClipboardCopied(true);
+ clearTimeout(clipboardTimerRef.current);
+ clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
+ }}
+ className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
+ clipboardCopied
+ ? "text-studio-accent"
+ : "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
+ }`}
+ title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
>
-
+
+
+
+
)}
+ {onToggleRecording && (
+
+ e.preventDefault()}
+ onClick={onToggleRecording}
+ className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
+ recordingState === "recording"
+ ? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
+ : "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
+ }`}
+ >
+
+ {recordingState === "recording" ? (
+
+ ) : (
+
+ )}
+
+ {recordingState === "recording"
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
+ : "Record gesture (R) — move pointer to capture motion"}
+
+
+ )}
+
{showEditableSections && (
;
diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts
index f5e205a76..c3029fffa 100644
--- a/packages/studio/src/components/editor/propertyPanelHelpers.ts
+++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts
@@ -68,6 +68,9 @@ export interface PropertyPanelProps {
value: number | string,
) => Promise
;
onSeekToTime?: (time: number) => void;
+ recordingState?: "idle" | "recording" | "preview";
+ recordingDuration?: number;
+ onToggleRecording?: () => void;
}
/* ------------------------------------------------------------------ */
diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
index a755824d5..c955c03d6 100644
--- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
+++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
@@ -369,15 +369,7 @@ export function StyleSections({
- }
- accessory={
-
- {preferredFillMode}
-
- }
- >
+ }>
{
frame = requestAnimationFrame(update);
- if (rafPausedRef.current) return;
+ if (rafPausedRef.current) {
+ if (childRectsRef.current.length > 0) {
+ childRectsRef.current = [];
+ setChildRectsState([]);
+ }
+ return;
+ }
const sel = selectionRef.current;
const iframe = iframeRef.current;
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx
index 1005328f2..5c4377947 100644
--- a/packages/studio/src/components/renders/RenderQueueItem.tsx
+++ b/packages/studio/src/components/renders/RenderQueueItem.tsx
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
- {/* Actions */}
- {hovered && (
-
- {isComplete && (
-
-
-
-
-
-
-
- )}
-
{
- e.stopPropagation();
- onDelete();
- }}
- className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
- title="Remove"
+ {/* Actions — always visible to prevent layout shifts */}
+
- )}
+
+
+
+
+
+
{
+ e.stopPropagation();
+ onDelete();
+ }}
+ className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
+ title="Remove"
+ >
+
+
+
+
+
);
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx
index 985cbc57b..c62fda6b4 100644
--- a/packages/studio/src/contexts/DomEditContext.tsx
+++ b/packages/studio/src/contexts/DomEditContext.tsx
@@ -67,6 +67,7 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -76,6 +77,7 @@ export function DomEditProvider({
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
+ commitMutation,
},
children,
}: {
@@ -138,6 +140,7 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -147,6 +150,7 @@ export function DomEditProvider({
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
+ commitMutation,
}),
[
domEditSelection,
@@ -203,6 +207,7 @@ export function DomEditProvider({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -212,6 +217,7 @@ export function DomEditProvider({
handleUpdateArcSegment,
invalidateGsapCache,
previewIframeRef,
+ commitMutation,
],
);
return {children} ;
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts
index 6e641dfc8..10d9ef332 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts
@@ -261,13 +261,21 @@ async function commitGsapPositionFromDrag(
callbacks,
clearOffset,
);
- } else if (anim.method === "from") {
- await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
- } else if (anim.method === "fromTo") {
- await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
+ } else if (anim.method === "from" || anim.method === "fromTo") {
+ // from()/fromTo() — convert to keyframes in a single mutation, placing
+ // the dragged position at the 100% (rest) keyframe. A single mutation
+ // avoids the stable-id flip (from→to) that breaks chained mutations.
+ await callbacks.commitMutation(
+ selection,
+ {
+ type: "convert-to-keyframes",
+ animationId: anim.id,
+ resolvedFromValues: { x: newX, y: newY },
+ },
+ { label: "Move layer (keyframe rest)", softReload: true, beforeReload: clearOffset },
+ );
} else {
- // Flat to()/set() — convert to keyframes first so the drag position
- // is captured at the current seek time, not just the tween endpoint.
+ // Flat to()/set() — convert to keyframes then add at current percentage.
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
await commitFlatViaKeyframes(
selection,
@@ -334,65 +342,6 @@ async function commitFlatViaKeyframes(
);
}
-async function commitFromPosition(
- selection: DomEditSelection,
- anim: GsapAnimation,
- delta: { x: number; y: number },
- callbacks: GsapDragCommitCallbacks,
- beforeReload: () => void,
-): Promise {
- const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
- const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
-
- await callbacks.commitMutation(
- selection,
- { type: "update-property", animationId: anim.id, property: "x", value: fromX },
- { label: "Move layer (GSAP from x)", skipReload: true },
- );
- await callbacks.commitMutation(
- selection,
- { type: "update-property", animationId: anim.id, property: "y", value: fromY },
- { label: "Move layer (GSAP from y)", softReload: true, beforeReload },
- );
-}
-
-// fallow-ignore-next-line complexity
-async function commitFromToPosition(
- selection: DomEditSelection,
- anim: GsapAnimation,
- delta: { x: number; y: number },
- callbacks: GsapDragCommitCallbacks,
- beforeReload: () => void,
-): Promise {
- if (anim.fromProperties) {
- const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x);
- const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y);
- await callbacks.commitMutation(
- selection,
- { type: "update-from-property", animationId: anim.id, property: "x", value: fromX },
- { label: "Move (GSAP from x)", skipReload: true },
- );
- await callbacks.commitMutation(
- selection,
- { type: "update-from-property", animationId: anim.id, property: "y", value: fromY },
- { label: "Move (GSAP from y)", skipReload: true },
- );
- }
-
- const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
- const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
- await callbacks.commitMutation(
- selection,
- { type: "update-property", animationId: anim.id, property: "x", value: toX },
- { label: "Move (GSAP to x)", skipReload: true },
- );
- await callbacks.commitMutation(
- selection,
- { type: "update-property", animationId: anim.id, property: "y", value: toY },
- { label: "Move (GSAP to y)", softReload: true, beforeReload },
- );
-}
-
// ── Runtime property reader ───────────────────────────────────────────────
export function readGsapProperty(
diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
index 1029fd6df..a56043fbe 100644
--- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
+++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts
@@ -216,6 +216,12 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
for (const entry of result.values()) {
entry.keyframes.sort((a, b) => a.percentage - b.percentage);
+ const seen = new Set();
+ entry.keyframes = entry.keyframes.filter((kf) => {
+ if (seen.has(kf.percentage)) return false;
+ seen.add(kf.percentage);
+ return true;
+ });
}
return result;
}
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts
index 5140afc31..000b26df3 100644
--- a/packages/studio/src/hooks/useAppHotkeys.ts
+++ b/packages/studio/src/hooks/useAppHotkeys.ts
@@ -81,6 +81,7 @@ interface UseAppHotkeysParams {
onResetKeyframes: () => boolean;
onDeleteSelectedKeyframes: () => void;
onAfterUndoRedo?: () => void;
+ onToggleRecording?: () => void;
}
// ── Hook ──
@@ -106,6 +107,7 @@ export function useAppHotkeys({
onResetKeyframes,
onDeleteSelectedKeyframes,
onAfterUndoRedo,
+ onToggleRecording,
}: UseAppHotkeysParams) {
const previewHotkeyWindowRef = useRef(null);
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
@@ -215,6 +217,8 @@ export function useAppHotkeys({
onResetKeyframesRef.current = onResetKeyframes;
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
+ const onToggleRecordingRef = useRef(onToggleRecording);
+ onToggleRecordingRef.current = onToggleRecording;
// ── Consolidated keydown handler ──
@@ -377,6 +381,20 @@ export function useAppHotkeys({
void handleDomEditDeleteRef.current(domSelection);
}
}
+
+ // R — toggle gesture recording
+ if (
+ event.key === "r" &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ !event.shiftKey &&
+ !isEditableTarget(event.target) &&
+ onToggleRecordingRef.current
+ ) {
+ event.preventDefault();
+ onToggleRecordingRef.current();
+ }
};
// ── Window keydown listener ──
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index c65e87b88..7f162a4c8 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -101,6 +101,8 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject;
reloadPreview: () => void;
+ onCommitAnimatedOffset?: (selection: DomEditSelection, next: { x: number; y: number }) => void;
+
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
@@ -130,6 +132,7 @@ export function useDomEditCommits({
projectId,
projectIdRef,
reloadPreview,
+ onCommitAnimatedOffset,
domEditSelection,
applyDomSelection,
clearDomSelection,
@@ -321,13 +324,16 @@ export function useDomEditCommits({
const handleDomPathOffsetCommit = useCallback(
(selection: DomEditSelection, next: { x: number; y: number }) => {
applyStudioPathOffset(selection.element, next);
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
+ onCommitAnimatedOffset?.(selection, next);
+ return;
+ }
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
});
},
- [commitPositionPatchToHtml, previewIframeRef],
+ [commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset],
);
const handleDomGroupPathOffsetCommit = useCallback(
@@ -339,38 +345,39 @@ export function useDomEditCommits({
.join(":");
for (const { selection, next } of updates) {
applyStudioPathOffset(selection.element, next);
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
+ if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
+ onCommitAnimatedOffset?.(selection, next);
+ continue;
+ }
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: `Move ${updates.length} layers`,
coalesceKey: `group-path-offset:${coalesceKey}`,
});
}
},
- [commitPositionPatchToHtml, previewIframeRef],
+ [commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
applyStudioBoxSize(selection.element, next);
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
});
},
- [commitPositionPatchToHtml, previewIframeRef],
+ [commitPositionPatchToHtml],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
applyStudioRotation(selection.element, next);
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
});
},
- [commitPositionPatchToHtml, previewIframeRef],
+ [commitPositionPatchToHtml],
);
const handleDomManualEditsReset = useCallback(
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 02013491e..1cf7c23f1 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -272,6 +272,7 @@ export function useDomEditSession({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
@@ -434,6 +435,7 @@ export function useDomEditSession({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -450,6 +452,7 @@ export function useDomEditSession({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
@@ -623,6 +626,7 @@ export function useDomEditSession({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -632,5 +636,12 @@ export function useDomEditSession({
handleUpdateArcSegment,
invalidateGsapCache: bumpGsapCache,
previewIframeRef,
+ commitMutation: async (
+ mutation: Record,
+ options: { label: string; softReload?: boolean },
+ ) => {
+ if (!domEditSelection) return;
+ await gsapCommitMutation(domEditSelection, mutation, options);
+ },
};
}
diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts
new file mode 100644
index 000000000..065cd74be
--- /dev/null
+++ b/packages/studio/src/hooks/useEnableKeyframes.ts
@@ -0,0 +1,167 @@
+/**
+ * Centralized "Enable keyframes" logic that handles ALL scenarios:
+ * - Element has explicit keyframes → add/remove at seeked time
+ * - Element has a flat tween → convert + add at seeked time + propagate to end
+ * - Element has no animation (deleted) → create new tween with correct position + keyframes
+ *
+ * Always fetches fresh animation data to avoid stale session state.
+ * Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
+ */
+import { useCallback } from "react";
+import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
+import type { DomEditSelection } from "../components/editor/domEditingTypes";
+import { usePlayerStore } from "../player/store/playerStore";
+import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
+
+export interface EnableKeyframesSession {
+ domEditSelection: DomEditSelection | null;
+ selectedGsapAnimations: GsapAnimation[];
+ previewIframeRef?: React.RefObject;
+ handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
+ handleGsapConvertToKeyframes: (
+ animId: string,
+ resolvedFromValues?: Record,
+ ) => void | Promise;
+ handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
+ handleGsapAddKeyframeBatch?: (
+ animId: string,
+ pct: number,
+ properties: Record,
+ ) => Promise;
+ commitMutation?: (
+ mutation: Record,
+ options: { label: string; softReload?: boolean },
+ ) => Promise;
+}
+
+function readElementPosition(
+ iframe: HTMLIFrameElement | null,
+ sel: DomEditSelection,
+ anim: GsapAnimation | null,
+): Record {
+ const result: Record = {};
+ if (!iframe?.contentWindow) return result;
+
+ let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
+ try {
+ gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
+ } catch {
+ return result;
+ }
+
+ const element = sel.element;
+ if (!element?.isConnected || !gsap?.getProperty) return result;
+
+ const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
+ for (const prop of props) {
+ const val = Number(gsap.getProperty(element, prop));
+ if (Number.isFinite(val)) result[prop] = Math.round(val);
+ }
+
+ return result;
+}
+
+async function fetchAnimationsForElement(sel: DomEditSelection): Promise {
+ const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
+ if (!projectId) return [];
+ const sourceFile = sel.sourceFile || "index.html";
+ const parsed = await fetchParsedAnimations(projectId, sourceFile);
+ if (!parsed) return [];
+ return getAnimationsForElement(parsed.animations, {
+ id: sel.id,
+ selector: sel.selector,
+ });
+}
+
+function computePercentage(t: number, sel: DomEditSelection): number {
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
+ if (elDuration <= 0) return 0;
+ return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10));
+}
+
+// fallow-ignore-next-line complexity
+export function useEnableKeyframes(
+ sessionRef: React.RefObject,
+) {
+ return useCallback(async () => {
+ const session = sessionRef.current;
+ if (!session) return;
+ const sel = session.domEditSelection;
+ if (!sel) return;
+
+ const t = usePlayerStore.getState().currentTime;
+ const iframe = session.previewIframeRef?.current ?? null;
+
+ let anims = session.selectedGsapAnimations;
+ if (anims.length === 0) {
+ anims = await fetchAnimationsForElement(sel);
+ }
+
+ const kfAnim = anims.find((a) => a.keyframes);
+ const flatAnim = anims.find((a) => !a.keyframes);
+
+ if (kfAnim?.keyframes) {
+ const pct = computePercentage(t, sel);
+ const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
+ if (existing) {
+ session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
+ } else if (session.handleGsapAddKeyframeBatch) {
+ const position = readElementPosition(iframe, sel, kfAnim);
+ if (Object.keys(position).length > 0) {
+ await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
+ }
+ }
+ } else if (flatAnim) {
+ const position = readElementPosition(iframe, sel, flatAnim);
+ const hasPosition = Object.keys(position).length > 0;
+
+ await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined);
+
+ const pct = computePercentage(t, sel);
+ if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) {
+ await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position);
+ await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position);
+ }
+ } else {
+ const position = readElementPosition(iframe, sel, null);
+ const pct = computePercentage(t, sel);
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
+ const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
+
+ if (!selector) {
+ session.handleGsapAddAnimation("to");
+ return;
+ }
+
+ if (Object.keys(position).length === 0) {
+ position.x = 0;
+ position.y = 0;
+ position.opacity = 1;
+ }
+
+ const keyframes: Array<{ percentage: number; properties: Record }> =
+ [{ percentage: 0, properties: { ...position } }];
+ if (pct > 1 && pct < 99) {
+ keyframes.push({ percentage: pct, properties: { ...position } });
+ }
+ keyframes.push({ percentage: 100, properties: { ...position } });
+
+ if (session.commitMutation) {
+ await session.commitMutation(
+ {
+ type: "add-with-keyframes",
+ targetSelector: selector,
+ position: Math.round(elStart * 1000) / 1000,
+ duration: Math.round(elDuration * 1000) / 1000,
+ keyframes,
+ },
+ { label: "Enable keyframes", softReload: true },
+ );
+ } else {
+ session.handleGsapAddAnimation("to");
+ }
+ }
+ }, [sessionRef]);
+}
diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts
new file mode 100644
index 000000000..86501341c
--- /dev/null
+++ b/packages/studio/src/hooks/useGestureCommit.ts
@@ -0,0 +1,56 @@
+import { useCallback } from "react";
+import type { DomEditSelection } from "../components/editor/domEditing";
+
+interface GestureCommitParams {
+ commitMutation: (
+ selection: DomEditSelection,
+ mutation: Record,
+ options: { label: string; softReload: boolean },
+ ) => Promise;
+}
+
+export function useGestureCommit({ commitMutation }: GestureCommitParams) {
+ const commitGestureKeyframes = useCallback(
+ async (
+ selection: DomEditSelection,
+ animationId: string,
+ keyframes: Map>,
+ eases: Map,
+ ) => {
+ const sortedPcts = Array.from(keyframes.keys()).sort((a, b) => a - b);
+ if (sortedPcts.length < 2) return;
+
+ await commitMutation(
+ selection,
+ { type: "convert-to-keyframes" as const, animationId },
+ { label: "Convert to keyframes for gesture recording", softReload: false },
+ );
+
+ for (const pct of sortedPcts) {
+ const props = keyframes.get(pct);
+ if (!props) continue;
+
+ for (const [prop, value] of Object.entries(props)) {
+ await commitMutation(
+ selection,
+ {
+ type: "add-keyframe" as const,
+ animationId,
+ percentage: pct,
+ property: prop,
+ value,
+ ease: eases.get(pct),
+ },
+ {
+ label: `Record keyframe at ${pct}%`,
+ softReload: pct === sortedPcts[sortedPcts.length - 1],
+ },
+ );
+ }
+ }
+ },
+ [commitMutation],
+ );
+
+ return { commitGestureKeyframes };
+}
diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts
new file mode 100644
index 000000000..93e47361f
--- /dev/null
+++ b/packages/studio/src/hooks/useGestureRecording.ts
@@ -0,0 +1,305 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePlayerStore, liveTime } from "../player/store/playerStore";
+
+export interface GestureSample {
+ time: number;
+ properties: Record;
+}
+
+interface Modifiers {
+ shift: boolean;
+ alt: boolean;
+ meta: boolean;
+}
+
+interface AccumulatedState {
+ opacity: number;
+ scale: number;
+ z: number;
+}
+
+export function resolveGestureProperties(
+ dx: number,
+ dy: number,
+ scrollDelta: number,
+ modifiers: Modifiers,
+ accumulatedState: AccumulatedState,
+): {
+ properties: Record;
+ nextState: AccumulatedState;
+} {
+ const properties: Record = {};
+ let nextOpacity = accumulatedState.opacity;
+ let nextScale = accumulatedState.scale;
+ let nextZ = accumulatedState.z;
+
+ if (modifiers.meta) {
+ nextOpacity = Math.max(0, Math.min(1, accumulatedState.opacity - dy * 0.005));
+ properties.opacity = nextOpacity;
+ if (scrollDelta !== 0) {
+ nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01);
+ properties.scale = nextScale;
+ }
+ } else if (modifiers.shift) {
+ properties.rotationX = dy * 0.5;
+ properties.rotationY = dx * 0.5;
+ } else if (modifiers.alt) {
+ properties.rotation = dx * 0.5;
+ } else {
+ properties.x = dx;
+ properties.y = dy;
+ }
+
+ if (!modifiers.meta && scrollDelta !== 0) {
+ nextZ = accumulatedState.z + scrollDelta;
+ properties.z = nextZ;
+ }
+
+ return {
+ properties,
+ nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ },
+ };
+}
+
+export function useGestureRecording() {
+ const [isRecording, setIsRecording] = useState(false);
+ const [recordingDuration, setRecordingDuration] = useState(0);
+
+ // Synchronous guard — immune to React's async state batching.
+ // startRecording and stopRecording check this ref, not the useState value.
+ const isRecordingRef = useRef(false);
+
+ const pointerRef = useRef({ x: 0, y: 0 });
+ const startPointerRef = useRef({ x: 0, y: 0 });
+ const scrollDeltaRef = useRef(0);
+ const modifiersRef = useRef({ shift: false, alt: false, meta: false });
+ const accumulatedRef = useRef({ opacity: 1, scale: 1, z: 0 });
+ const basePositionRef = useRef({ x: 0, y: 0 });
+ const scaleRef = useRef(1);
+ const hasMovedRef = useRef(false);
+ const runtimeRef = useRef<{
+ seek: (t: number) => void;
+ set: (target: string, vars: Record) => void;
+ selector: string;
+ startTime: number;
+ duration: number;
+ } | null>(null);
+
+ const rafIdRef = useRef(0);
+ const samplesRef = useRef([]);
+ const trailRef = useRef>([]);
+ const cleanupRef = useRef<(() => void) | null>(null);
+
+ // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording.
+ useEffect(() => {
+ return () => {
+ cleanupRef.current?.();
+ cleanupRef.current = null;
+ isRecordingRef.current = false;
+ };
+ }, []);
+
+ const startRecording = useCallback(
+ (element: HTMLElement, iframeEl: HTMLIFrameElement) => {
+ if (isRecordingRef.current) return;
+ isRecordingRef.current = true;
+
+ samplesRef.current = [];
+ trailRef.current = [];
+ hasMovedRef.current = false;
+ setRecordingDuration(0);
+ scrollDeltaRef.current = 0;
+
+ let baseOpacity = 1;
+ let baseScaleVal = 1;
+ let baseX = 0;
+ let baseY = 0;
+ try {
+ const gsap = (
+ iframeEl.contentWindow as Window & {
+ gsap?: { getProperty: (el: Element, prop: string) => number };
+ }
+ ).gsap;
+ if (gsap?.getProperty) {
+ baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1;
+ baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1;
+ baseX = Number(gsap.getProperty(element, "x")) || 0;
+ baseY = Number(gsap.getProperty(element, "y")) || 0;
+ }
+ } catch {
+ /* cross-origin guard */
+ }
+ accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 };
+ basePositionRef.current = { x: baseX, y: baseY };
+
+ const selector = element.id ? `#${element.id}` : null;
+ try {
+ const win = iframeEl.contentWindow as Window & {
+ gsap?: { set: (t: string, v: Record) => void };
+ __timelines?: Record void; duration: () => number }>;
+ __player?: { getTime: () => number };
+ };
+ const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null;
+ if (win?.gsap?.set && tl?.seek && selector) {
+ runtimeRef.current = {
+ seek: tl.seek.bind(tl),
+ set: win.gsap.set.bind(win.gsap),
+ selector,
+ startTime: win.__player?.getTime() ?? 0,
+ duration: tl.duration(),
+ };
+ }
+ } catch {
+ runtimeRef.current = null;
+ }
+
+ const iframeRect = iframeEl.getBoundingClientRect();
+ const doc = iframeEl.contentDocument;
+ const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement;
+ const declaredWidth = Number(root?.getAttribute("data-width")) || 1920;
+ scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1;
+
+ const handlePointerMove = (e: PointerEvent) => {
+ pointerRef.current = { x: e.clientX, y: e.clientY };
+ modifiersRef.current = {
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ };
+ };
+
+ const handleWheel = (e: WheelEvent) => {
+ scrollDeltaRef.current += e.deltaY;
+ modifiersRef.current = {
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ };
+ };
+
+ const handleKeyChange = (e: KeyboardEvent) => {
+ modifiersRef.current = {
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ };
+ };
+
+ document.addEventListener("pointermove", handlePointerMove, { passive: true });
+ document.addEventListener("wheel", handleWheel, { passive: true });
+ document.addEventListener("keydown", handleKeyChange, { passive: true });
+ document.addEventListener("keyup", handleKeyChange, { passive: true });
+
+ startPointerRef.current = { ...pointerRef.current };
+ const startMs = performance.now();
+
+ let startCaptured = false;
+ const captureStart = (e: PointerEvent) => {
+ if (!startCaptured) {
+ startPointerRef.current = { x: e.clientX, y: e.clientY };
+ startCaptured = true;
+ hasMovedRef.current = true;
+ }
+ };
+ document.addEventListener("pointermove", captureStart, { passive: true, once: true });
+
+ const tick = () => {
+ if (!isRecordingRef.current) return;
+ const now = performance.now();
+ const time = (now - startMs) / 1000;
+ const scale = scaleRef.current || 1;
+ const dx = (pointerRef.current.x - startPointerRef.current.x) / scale;
+ const dy = (pointerRef.current.y - startPointerRef.current.y) / scale;
+ const scrollDelta = scrollDeltaRef.current;
+
+ // Skip zero-displacement samples before the pointer has moved.
+ if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) {
+ rafIdRef.current = requestAnimationFrame(tick);
+ return;
+ }
+ hasMovedRef.current = true;
+
+ const { properties, nextState } = resolveGestureProperties(
+ dx,
+ dy,
+ scrollDelta,
+ modifiersRef.current,
+ accumulatedRef.current,
+ );
+ if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x);
+ if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y);
+
+ accumulatedRef.current = nextState;
+ scrollDeltaRef.current = 0;
+
+ // Manual seek on the raw GSAP timeline (not the Studio player wrapper,
+ // which triggers React state updates). After seek renders all elements
+ // at the correct time, gsap.set overrides the recorded element so it
+ // follows the pointer. The browser paints the set values on this frame;
+ // next tick's seek will overwrite, but we re-apply immediately.
+ if (runtimeRef.current) {
+ try {
+ const seekTime = Math.min(
+ runtimeRef.current.startTime + time,
+ runtimeRef.current.duration,
+ );
+ runtimeRef.current.seek(seekTime);
+ runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
+ liveTime.notify(seekTime);
+ usePlayerStore.getState().setCurrentTime(seekTime);
+ } catch {
+ runtimeRef.current = null;
+ }
+ }
+
+ samplesRef.current.push({ time, properties });
+ // Screen-space pointer position for the ghost trail overlay
+ trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y });
+ setRecordingDuration(time);
+ rafIdRef.current = requestAnimationFrame(tick);
+ };
+
+ setIsRecording(true);
+ rafIdRef.current = requestAnimationFrame(tick);
+
+ cleanupRef.current = () => {
+ cancelAnimationFrame(rafIdRef.current);
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("wheel", handleWheel);
+ document.removeEventListener("keydown", handleKeyChange);
+ document.removeEventListener("keyup", handleKeyChange);
+ document.removeEventListener("pointermove", captureStart);
+ };
+ },
+ [], // No deps — uses refs only for all mutable state
+ );
+
+ const stopRecording = useCallback((): GestureSample[] => {
+ if (!isRecordingRef.current) return [];
+ isRecordingRef.current = false;
+ runtimeRef.current = null;
+ cleanupRef.current?.();
+ cleanupRef.current = null;
+ const frozen = samplesRef.current.slice();
+ setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0);
+ setIsRecording(false);
+ return frozen;
+ }, []); // No deps — uses refs only
+
+ const clearSamples = useCallback(() => {
+ samplesRef.current = [];
+ setRecordingDuration(0);
+ accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
+ scrollDeltaRef.current = 0;
+ }, []);
+
+ return {
+ startRecording,
+ stopRecording,
+ isRecording,
+ samplesRef,
+ trailRef,
+ recordingDuration,
+ clearSamples,
+ };
+}
diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts
index 57d62c36e..ad4459475 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -201,12 +201,14 @@ export function useGsapScriptCommits({
});
}
- onCacheInvalidate();
-
if (result.after != null) {
onFileContentChanged?.(targetPath, result.after);
}
+ if (options.skipReload) return;
+
+ onCacheInvalidate();
+
if (result.parsed?.animations) {
updateKeyframeCacheFromParsed(
result.parsed.animations,
@@ -216,8 +218,6 @@ export function useGsapScriptCommits({
);
}
- if (options.skipReload) return;
-
options.beforeReload?.();
if (options.softReload && result.scriptText) {
@@ -467,6 +467,21 @@ export function useGsapScriptCommits({
},
[commitMutation, activeCompPath],
);
+ const addKeyframeBatch = useCallback(
+ (
+ selection: DomEditSelection,
+ animationId: string,
+ percentage: number,
+ properties: Record,
+ ) => {
+ return commitMutation(
+ selection,
+ { type: "add-keyframe", animationId, percentage, properties },
+ { label: `Add keyframe at ${percentage}%`, softReload: true },
+ );
+ },
+ [commitMutation],
+ );
const removeKeyframe = useCallback(
(selection: DomEditSelection, animationId: string, percentage: number) => {
const sf = selection.sourceFile || activeCompPath || "index.html";
@@ -499,7 +514,7 @@ export function useGsapScriptCommits({
animationId: string,
resolvedFromValues?: Record,
) => {
- void commitMutation(
+ return commitMutation(
selection,
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
{ label: "Convert to keyframes" },
@@ -589,6 +604,7 @@ export function useGsapScriptCommits({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
index 60f288d8e..50e44deb7 100644
--- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts
+++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
@@ -19,6 +19,7 @@ export function useGsapSelectionHandlers({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
@@ -61,6 +62,12 @@ export function useGsapSelectionHandlers({
property: string,
value: number | string,
) => void;
+ addKeyframeBatch: (
+ sel: DomEditSelection,
+ animId: string,
+ percentage: number,
+ properties: Record,
+ ) => Promise;
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
convertToKeyframes: (
sel: DomEditSelection,
@@ -155,6 +162,13 @@ export function useGsapSelectionHandlers({
[domEditSelection, addKeyframe],
);
+ const handleGsapAddKeyframeBatch = useCallback(
+ (animId: string, percentage: number, properties: Record) => {
+ if (!domEditSelection) return Promise.resolve();
+ return addKeyframeBatch(domEditSelection, animId, percentage, properties);
+ },
+ [domEditSelection, addKeyframeBatch],
+ );
const handleGsapRemoveKeyframe = useCallback(
(animId: string, percentage: number) => {
if (!domEditSelection) return;
@@ -165,8 +179,8 @@ export function useGsapSelectionHandlers({
const handleGsapConvertToKeyframes = useCallback(
(animId: string, resolvedFromValues?: Record) => {
- if (!domEditSelection) return;
- convertToKeyframes(domEditSelection, animId, resolvedFromValues);
+ if (!domEditSelection) return Promise.resolve();
+ return convertToKeyframes(domEditSelection, animId, resolvedFromValues);
},
[domEditSelection, convertToKeyframes],
);
@@ -198,6 +212,7 @@ export function useGsapSelectionHandlers({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts
index 4075c711c..d398345a5 100644
--- a/packages/studio/src/hooks/useGsapTweenCache.ts
+++ b/packages/studio/src/hooks/useGsapTweenCache.ts
@@ -95,7 +95,12 @@ export function getAnimationsForElement(
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0) return [];
return animations.filter((a) =>
- a.targetSelector.split(",").some((part) => matchers.has(part.trim())),
+ a.targetSelector.split(",").some((part) => {
+ const trimmed = part.trim();
+ if (matchers.has(trimmed)) return true;
+ const lastSimple = trimmed.split(/\s+/).pop();
+ return lastSimple ? matchers.has(lastSimple) : false;
+ }),
);
}
diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts
index 985025bbd..6204f21bc 100644
--- a/packages/studio/src/hooks/useStudioContextValue.ts
+++ b/packages/studio/src/hooks/useStudioContextValue.ts
@@ -81,6 +81,7 @@ export function useInspectorState(
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
+ isGestureRecording?: boolean,
): InspectorState {
// fallow-ignore-next-line complexity
return useMemo(() => {
@@ -101,9 +102,10 @@ export function useInspectorState(
inspectorPanelActive,
inspectorButtonActive:
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
- shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying,
+ shouldShowSelectedDomBounds:
+ inspectorPanelActive && !rightCollapsed && (!isPlaying || !!isGestureRecording),
};
- }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]);
+ }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]);
}
// fallow-ignore-next-line complexity
diff --git a/packages/studio/src/hooks/useToast.ts b/packages/studio/src/hooks/useToast.ts
index 79ca444ab..a27147a9b 100644
--- a/packages/studio/src/hooks/useToast.ts
+++ b/packages/studio/src/hooks/useToast.ts
@@ -16,5 +16,10 @@ export function useToast() {
if (timerRef.current) clearTimeout(timerRef.current);
});
- return { appToast, showToast };
+ const dismissToast = useCallback(() => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ setAppToast(null);
+ }, []);
+
+ return { appToast, showToast, dismissToast };
}
diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx
index c5c201b3e..b91ef6398 100644
--- a/packages/studio/src/player/components/ShortcutsPanel.tsx
+++ b/packages/studio/src/player/components/ShortcutsPanel.tsx
@@ -27,6 +27,36 @@ const SHORTCUT_SECTIONS = [
{ key: "R", label: "Record gesture" },
],
},
+ {
+ title: "Editing",
+ hints: [
+ { key: "⌘Z", label: "Undo" },
+ { key: "⌘⇧Z", label: "Redo" },
+ { key: "⌘C", label: "Copy element" },
+ { key: "⌘V", label: "Paste element" },
+ { key: "⌘X", label: "Cut element" },
+ { key: "S", label: "Split clip at playhead" },
+ { key: "Del", label: "Delete selected element" },
+ ],
+ },
+ {
+ title: "Gesture recording modifiers",
+ hints: [
+ { key: "Drag", label: "Record x / y position" },
+ { key: "Scroll", label: "Record z depth" },
+ { key: "⇧ Drag", label: "Record rotationX / rotationY" },
+ { key: "⌥ Drag", label: "Record rotation" },
+ { key: "⌘ Drag↕", label: "Record opacity" },
+ { key: "⌘ Scroll", label: "Record scale" },
+ ],
+ },
+ {
+ title: "Panels",
+ hints: [
+ { key: "⌘1", label: "Compositions tab" },
+ { key: "⌘2", label: "Assets tab" },
+ ],
+ },
{
title: "Work area",
hints: [
diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
index 502f19aff..acad691e1 100644
--- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx
+++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const x2 = (kf.percentage / 100) * clipWidthPx;
return (
{
+ {sorted.map((kf, i) => {
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
const kfKey = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(kfKey);
@@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
return (
number): number[] {
+ const curve: number[] = [];
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
+ curve.push(fn(i / (SAMPLE_COUNT - 1)));
+ }
+ return curve;
+}
+
+export const EASE_REFERENCE_CURVES: Record = {
+ linear: buildReferenceCurve((t) => t),
+
+ "power1.out": buildReferenceCurve((t) => 1 - (1 - t) ** 2),
+
+ "power2.out": buildReferenceCurve((t) => 1 - (1 - t) ** 3),
+
+ "power3.out": buildReferenceCurve((t) => 1 - (1 - t) ** 4),
+
+ "power2.in": buildReferenceCurve((t) => t ** 3),
+
+ "power2.inOut": buildReferenceCurve((t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2)),
+
+ "power3.inOut": buildReferenceCurve((t) => (t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2)),
+
+ "back.out(1.7)": buildReferenceCurve((t) => {
+ const c1 = 1.7;
+ const c3 = c1 + 1;
+ return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
+ }),
+
+ "expo.out": buildReferenceCurve((t) => (t === 1 ? 1 : 1 - 2 ** (-10 * t))),
+};
+
+/**
+ * Linearly interpolate between two values.
+ */
+function lerp(a: number, b: number, frac: number): number {
+ return a + (b - a) * frac;
+}
+
+/**
+ * Resample an arbitrary set of (normalized) time/value pairs to SAMPLE_COUNT
+ * evenly-spaced points via linear interpolation.
+ */
+function resampleToGrid(normalized: Array<{ t: number; v: number }>): number[] {
+ const result: number[] = [];
+
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
+ const target = i / (SAMPLE_COUNT - 1);
+
+ // Find the two surrounding samples
+ let lo = 0;
+ let hi = normalized.length - 1;
+ for (let j = 0; j < normalized.length - 1; j++) {
+ if (normalized[j].t <= target && normalized[j + 1].t >= target) {
+ lo = j;
+ hi = j + 1;
+ break;
+ }
+ }
+
+ const tLo = normalized[lo].t;
+ const tHi = normalized[hi].t;
+ const span = tHi - tLo;
+ const frac = span === 0 ? 0 : (target - tLo) / span;
+ result.push(lerp(normalized[lo].v, normalized[hi].v, frac));
+ }
+
+ return result;
+}
+
+/**
+ * Sum of squared differences between two same-length arrays.
+ */
+function ssd(a: number[], b: number[]): number {
+ let sum = 0;
+ for (let i = 0; i < a.length; i++) {
+ const d = a[i] - b[i];
+ sum += d * d;
+ }
+ return sum;
+}
+
+/**
+ * Infer the best-matching GSAP ease name for a segment of raw gesture samples.
+ *
+ * @param rawSamples - Time/value pairs from the gesture recording
+ * @param startTime - Segment start (inclusive)
+ * @param endTime - Segment end (inclusive)
+ * @returns GSAP ease name (e.g. "power2.out")
+ */
+export function inferSegmentEase(
+ rawSamples: Array<{ time: number; value: number }>,
+ startTime: number,
+ endTime: number,
+): string {
+ const segment = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
+
+ if (segment.length < 3) return DEFAULT_EASE;
+
+ const tMin = segment[0].time;
+ const tMax = segment[segment.length - 1].time;
+ const tRange = tMax - tMin;
+
+ const vMin = Math.min(...segment.map((s) => s.value));
+ const vMax = Math.max(...segment.map((s) => s.value));
+ const vRange = vMax - vMin;
+
+ // Degenerate segment — no meaningful motion
+ if (tRange === 0 || vRange === 0) return DEFAULT_EASE;
+
+ // Determine direction: if value decreases over time, flip the progress curve
+ const increasing = segment[segment.length - 1].value >= segment[0].value;
+
+ const normalized = segment.map((s) => ({
+ t: (s.time - tMin) / tRange,
+ v: increasing ? (s.value - vMin) / vRange : 1 - (s.value - vMin) / vRange,
+ }));
+
+ const resampled = resampleToGrid(normalized);
+
+ let bestEase = DEFAULT_EASE;
+ let bestScore = Infinity;
+
+ for (const [name, ref] of Object.entries(EASE_REFERENCE_CURVES)) {
+ const score = ssd(resampled, ref);
+ if (score < bestScore) {
+ bestScore = score;
+ bestEase = name;
+ }
+ }
+
+ return bestEase;
+}
+
+/**
+ * Infer eases for all segments between consecutive simplified percentages.
+ *
+ * @param rawSamples - Time/properties pairs from the gesture recording
+ * @param simplifiedPercentages - Sorted array of keyframe percentages (0-100)
+ * @param totalDuration - Total gesture duration in the same unit as rawSamples[].time
+ * @returns Map of percentage → ease name (ease applies to the segment ENDING at that percentage)
+ */
+export function inferAllEases(
+ rawSamples: Array<{ time: number; properties: Record }>,
+ simplifiedPercentages: number[],
+ totalDuration: number,
+): Map {
+ const result = new Map();
+
+ if (simplifiedPercentages.length < 2) return result;
+
+ for (let i = 1; i < simplifiedPercentages.length; i++) {
+ const pctStart = simplifiedPercentages[i - 1];
+ const pctEnd = simplifiedPercentages[i];
+
+ const startTime = (pctStart / 100) * totalDuration;
+ const endTime = (pctEnd / 100) * totalDuration;
+
+ // Find the primary moving property (largest absolute delta in this segment)
+ const segmentSamples = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
+
+ if (segmentSamples.length < 2) {
+ result.set(pctEnd, DEFAULT_EASE);
+ continue;
+ }
+
+ const firstSample = segmentSamples[0];
+ const lastSample = segmentSamples[segmentSamples.length - 1];
+ const allProps = Object.keys(firstSample.properties);
+
+ let primaryProp = allProps[0];
+ let maxDelta = 0;
+
+ for (const prop of allProps) {
+ const delta = Math.abs(
+ (lastSample.properties[prop] ?? 0) - (firstSample.properties[prop] ?? 0),
+ );
+ if (delta > maxDelta) {
+ maxDelta = delta;
+ primaryProp = prop;
+ }
+ }
+
+ const flatSamples = segmentSamples.map((s) => ({
+ time: s.time,
+ value: s.properties[primaryProp] ?? 0,
+ }));
+
+ result.set(pctEnd, inferSegmentEase(flatSamples, startTime, endTime));
+ }
+
+ return result;
+}
diff --git a/packages/studio/src/utils/rdpSimplify.ts b/packages/studio/src/utils/rdpSimplify.ts
new file mode 100644
index 000000000..ed58ebd85
--- /dev/null
+++ b/packages/studio/src/utils/rdpSimplify.ts
@@ -0,0 +1,183 @@
+/**
+ * Ramer-Douglas-Peucker simplification for time-series data.
+ *
+ * Used to reduce gesture recording samples into a minimal set of keyframes
+ * that approximate the original curve within a configurable tolerance.
+ */
+
+// ---------------------------------------------------------------------------
+// 1D time-series simplification
+// ---------------------------------------------------------------------------
+
+/**
+ * Perpendicular distance from point (t, v) to the line segment between
+ * (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
+ * distance from the point to the interpolated value on the line.
+ */
+function perpendicularDistance(
+ t: number,
+ v: number,
+ t1: number,
+ v1: number,
+ t2: number,
+ v2: number,
+): number {
+ // Degenerate case: start and end share the same time
+ if (t2 === t1) return Math.abs(v - v1);
+ const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
+ return Math.abs(v - interpolated);
+}
+
+/**
+ * Standard Ramer-Douglas-Peucker on 1D time-series data.
+ *
+ * Each point is treated as (time, value) in 2D space. Returns the minimal
+ * subset of input points that approximates the curve within `epsilon`.
+ *
+ * - `epsilon = 0` returns all points (no simplification).
+ * - A large `epsilon` returns just the first and last points.
+ * - Empty or single-point input is returned unchanged.
+ */
+export function simplifyTimeSeries(
+ points: Array<{ time: number; value: number }>,
+ epsilon: number,
+): Array<{ time: number; value: number }> {
+ if (points.length <= 2) return points;
+ if (epsilon <= 0) return points;
+
+ const first = points[0];
+ const last = points[points.length - 1];
+
+ let maxDist = 0;
+ let maxIndex = 0;
+
+ for (let i = 1; i < points.length - 1; i++) {
+ const d = perpendicularDistance(
+ points[i].time,
+ points[i].value,
+ first.time,
+ first.value,
+ last.time,
+ last.value,
+ );
+ if (d > maxDist) {
+ maxDist = d;
+ maxIndex = i;
+ }
+ }
+
+ if (maxDist > epsilon) {
+ const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
+ const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
+ // left includes maxIndex, right starts with maxIndex — drop the duplicate
+ return left.slice(0, -1).concat(right);
+ }
+
+ return [first, last];
+}
+
+// ---------------------------------------------------------------------------
+// Multi-property gesture simplification
+// ---------------------------------------------------------------------------
+
+/**
+ * Simplify gesture recording samples into percentage-keyed keyframes.
+ *
+ * Runs `simplifyTimeSeries` independently per property across all samples,
+ * then merges the retained time points into a single Map keyed by percentage
+ * of `totalDuration` (0–100, rounded to 1 decimal).
+ *
+ * Independent per-property simplification means that complex motion on one
+ * property (e.g. `x`) does not force extra keyframes on a simpler property
+ * (e.g. `opacity`).
+ *
+ * At each retained percentage the output contains all properties interpolated
+ * at that time — not just the property that caused the time point to survive.
+ */
+export function simplifyGestureSamples(
+ samples: Array<{ time: number; properties: Record }>,
+ totalDuration: number,
+ epsilon: number,
+): Map> {
+ if (samples.length === 0) return new Map();
+ if (totalDuration <= 0) return new Map();
+
+ // Collect all property keys present across samples
+ const propertyKeys = new Set();
+ for (const s of samples) {
+ for (const key of Object.keys(s.properties)) {
+ propertyKeys.add(key);
+ }
+ }
+
+ // Run RDP independently per property and collect surviving times
+ const survivingTimes = new Set();
+
+ for (const key of propertyKeys) {
+ const series: Array<{ time: number; value: number }> = [];
+ for (const s of samples) {
+ if (key in s.properties) {
+ series.push({ time: s.time, value: s.properties[key] });
+ }
+ }
+ const simplified = simplifyTimeSeries(series, epsilon);
+ for (const pt of simplified) {
+ survivingTimes.add(pt.time);
+ }
+ }
+
+ // Sort surviving times so we can iterate in order
+ const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
+
+ // For each surviving time, interpolate all properties and store by percentage
+ const result = new Map>();
+
+ for (const t of sortedTimes) {
+ const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
+ const props: Record = {};
+
+ for (const key of propertyKeys) {
+ props[key] = interpolatePropertyAtTime(samples, key, t);
+ }
+
+ result.set(pct, props);
+ }
+
+ return result;
+}
+
+/**
+ * Linearly interpolate a single property value at the given time from the
+ * samples array. Assumes samples are sorted by time.
+ */
+function interpolatePropertyAtTime(
+ samples: Array<{ time: number; properties: Record }>,
+ key: string,
+ t: number,
+): number {
+ // Find bracketing samples that contain this property
+ let before: { time: number; value: number } | undefined;
+ let after: { time: number; value: number } | undefined;
+
+ for (const s of samples) {
+ if (!(key in s.properties)) continue;
+ const v = s.properties[key];
+
+ if (s.time <= t) {
+ before = { time: s.time, value: v };
+ }
+ if (s.time >= t && after === undefined) {
+ after = { time: s.time, value: v };
+ }
+ }
+
+ // Exact match or only one side available
+ if (before && before.time === t) return before.value;
+ if (after && after.time === t) return after.value;
+ if (!before) return after!.value;
+ if (!after) return before.value;
+
+ // Linear interpolation
+ const ratio = (t - before.time) / (after.time - before.time);
+ return before.value + (after.value - before.value) * ratio;
+}