diff --git a/docs/docs.json b/docs/docs.json
index 3982833c10..c8a1caac63 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 0000000000..74be005978
--- /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 0a9888d327..8471cc925f 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 a4758d62ff..530ede9f8a 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/studio/src/App.tsx b/packages/studio/src/App.tsx
index af9edf1317..480b1ca483 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -35,6 +35,9 @@ 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 { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
import { StudioRightPanel } from "./components/StudioRightPanel";
@@ -128,7 +131,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,7 +309,9 @@ export function StudioApp() {
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
+ onToggleRecording: () => handleToggleRecordingRef.current(),
});
+ const handleToggleRecordingRef = useRef(() => {});
const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
[],
@@ -400,6 +405,76 @@ 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 stopAndCommitRecording = useCallback(() => {
+ clearInterval(recordingAutoStopRef.current);
+ gestureRecording.stopRecording();
+ const store = usePlayerStore.getState();
+ store.setIsPlaying(false);
+
+ const sel = domEditSession.domEditSelection;
+ const animId = domEditSession.selectedGsapAnimations?.[0]?.id;
+ const samples = gestureRecording.samples;
+ const duration = gestureRecording.recordingDuration;
+
+ if (sel && animId && samples.length > 2 && duration > 0) {
+ const simplified = simplifyGestureSamples(samples, duration, 5);
+ const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
+ for (const pct of sortedPcts) {
+ const props = simplified.get(pct);
+ if (!props) continue;
+ for (const [prop, value] of Object.entries(props)) {
+ domEditSession.handleGsapAddKeyframe?.(animId, pct, prop, value);
+ }
+ }
+ showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
+ }
+
+ store.requestSeek(recordingStartTimeRef.current);
+ gestureRecording.clearSamples();
+ setGestureState("idle");
+ }, [gestureRecording, domEditSession, showToast]);
+
+ const handleToggleRecording = useCallback(() => {
+ if (gestureState === "recording") {
+ stopAndCommitRecording();
+ return;
+ }
+ const sel = domEditSession.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);
+ store.setIsPlaying(true);
+ 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,
+ domEditSession.domEditSelection,
+ showToast,
+ stopAndCommitRecording,
+ ]);
+ handleToggleRecordingRef.current = handleToggleRecording;
+
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
@@ -554,6 +629,9 @@ export function StudioApp() {
setActiveBlockParams(null);
panelLayout.setRightPanelTab("design");
}}
+ recordingState={gestureState}
+ recordingDuration={gestureRecording.recordingDuration}
+ onToggleRecording={handleToggleRecording}
/>
)}
@@ -585,8 +663,24 @@ export function StudioApp() {
/>
)}
+ {gestureState === "recording" && previewIframe && (
+ {
+ const r = previewIframe.getBoundingClientRect();
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
+ })()}
+ mode="recording"
+ />
+ )}
{dragOverlay.active && }
- {appToast && }
+ {appToast && (
+
+ )}
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index 3404df15c9..6cc5005b76 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/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index ed68072a85..f965802df1 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 943c12bd4c..a0f2fa84d3 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -198,6 +198,22 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
flatAnim.id,
Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined,
);
+ 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;
+ if (pct > 1 && pct < 99) {
+ const runtimeValues = readRuntimeKeyframeValues(
+ session.previewIframeRef?.current ?? null,
+ sel,
+ [],
+ );
+ for (const [prop, val] of Object.entries(runtimeValues)) {
+ session.handleGsapAddKeyframe(flatAnim.id, pct, prop, val);
+ }
+ }
} else {
session.handleGsapAddAnimation("to");
}
diff --git a/packages/studio/src/components/editor/GesturePreviewPanel.tsx b/packages/studio/src/components/editor/GesturePreviewPanel.tsx
new file mode 100644
index 0000000000..133e11a299
--- /dev/null
+++ b/packages/studio/src/components/editor/GesturePreviewPanel.tsx
@@ -0,0 +1,147 @@
+import { memo, useCallback, 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(() => {
+ const result = simplifyGestureSamples(samples, totalDuration, epsilon);
+ onSimplifiedChange?.(result);
+ return result;
+ }, [samples, totalDuration, epsilon, 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 0000000000..a2a6abefc8
--- /dev/null
+++ b/packages/studio/src/components/editor/GestureTrailOverlay.tsx
@@ -0,0 +1,118 @@
+import { memo, useMemo } from "react";
+import type { GestureSample } from "../../hooks/useGestureRecording";
+
+interface GestureTrailOverlayProps {
+ samples: GestureSample[];
+ simplifiedPoints?: Map>;
+ canvasRect: { left: number; top: number; width: number; height: number };
+ mode: "recording" | "preview";
+ accentColor?: string;
+}
+
+export const GestureTrailOverlay = memo(function GestureTrailOverlay({
+ samples,
+ simplifiedPoints,
+ canvasRect,
+ mode,
+ accentColor = "#3CE6AC",
+}: GestureTrailOverlayProps) {
+ const trailPoints = useMemo(() => {
+ 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(" ");
+ }, [samples]);
+
+ 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 (
+
+ {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 a95996e391..21290390fc 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 && (
+
+
+
+ {recordingState === "recording" ? (
+
+ ) : (
+
+ )}
+
+ {recordingState === "recording"
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s (R)`
+ : "Record gesture (R)"}
+
+
+ )}
+
{showEditableSections && (
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 a755824d50..c955c03d64 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}
-
- }
- >
+ }>
- {/* 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/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts
index 6e641dfc8b..90db5ef564 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts
@@ -261,13 +261,9 @@ 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 {
- // 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()/from()/fromTo()/set() — convert to keyframes first so the
+ // drag position is captured at the current seek time as a new keyframe.
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
await commitFlatViaKeyframes(
selection,
@@ -334,65 +330,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 1029fd6dfb..a56043fbea 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 5140afc31e..000b26df3a 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 c65e87b88b..3c4f73793e 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(
diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts
new file mode 100644
index 0000000000..86501341c3
--- /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 0000000000..9007ba2d94
--- /dev/null
+++ b/packages/studio/src/hooks/useGestureRecording.ts
@@ -0,0 +1,245 @@
+import { useCallback, useRef, useState } from "react";
+
+// ── Types ──
+
+export interface GestureSample {
+ time: number;
+ properties: Record;
+}
+
+interface Modifiers {
+ shift: boolean;
+ alt: boolean;
+ meta: boolean;
+}
+
+interface AccumulatedState {
+ opacity: number;
+ scale: number;
+ z: number;
+}
+
+// ── Pure mapping function ──
+
+/**
+ * Maps raw pointer deltas, scroll delta, and modifier state to GSAP property
+ * values. Pure and testable without DOM.
+ *
+ * Modifier-to-property mapping:
+ * - Plain drag -> x, y (px delta from start)
+ * - Scroll wheel -> z (accumulated scroll delta)
+ * - Shift + drag -> rotationX (vert * 0.5), rotationY (horiz * 0.5)
+ * - Alt + drag horiz -> rotation (horiz * 0.5)
+ * - Cmd/Ctrl + drag V -> opacity (vert delta mapped 0-1, clamped)
+ * - Cmd/Ctrl + scroll -> scale (scroll * 0.01, accumulated from 1.0)
+ */
+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) {
+ // Cmd/Ctrl held: vertical drag -> opacity, scroll -> scale
+ 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) {
+ // Shift held: drag -> rotationX/rotationY
+ properties.rotationX = dy * 0.5;
+ properties.rotationY = dx * 0.5;
+ } else if (modifiers.alt) {
+ // Alt held: horizontal drag -> rotation
+ properties.rotation = dx * 0.5;
+ } else {
+ // Plain: drag -> x/y
+ properties.x = dx;
+ properties.y = dy;
+ }
+
+ // Scroll without Cmd/Ctrl -> z accumulation
+ if (!modifiers.meta && scrollDelta !== 0) {
+ nextZ = accumulatedState.z + scrollDelta;
+ properties.z = nextZ;
+ }
+
+ return {
+ properties,
+ nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ },
+ };
+}
+
+// ── Hook ──
+
+export function useGestureRecording() {
+ const [isRecording, setIsRecording] = useState(false);
+ const [samples, setSamples] = useState([]);
+ const [recordingDuration, setRecordingDuration] = useState(0);
+
+ // Refs for high-frequency data (never useState for 60fps input)
+ 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 rafIdRef = useRef(0);
+ const samplesRef = useRef([]);
+ const cleanupRef = useRef<(() => void) | null>(null);
+
+ const startRecording = useCallback(
+ (element: HTMLElement, _iframeEl: HTMLIFrameElement) => {
+ if (isRecording) return;
+
+ // Reset state
+ samplesRef.current = [];
+ setSamples([]);
+ setRecordingDuration(0);
+ accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
+ scrollDeltaRef.current = 0;
+
+ // Listeners target the overlay element (same div DomEditOverlay uses).
+ // Passive so they never block scrolling.
+
+ 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 handleKeyDown = (e: KeyboardEvent) => {
+ modifiersRef.current = {
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ };
+ };
+
+ const handleKeyUp = (e: KeyboardEvent) => {
+ modifiersRef.current = {
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ };
+ };
+
+ element.addEventListener("pointermove", handlePointerMove, { passive: true });
+ element.addEventListener("wheel", handleWheel, { passive: true });
+ document.addEventListener("keydown", handleKeyDown, { passive: true });
+ document.addEventListener("keyup", handleKeyUp, { passive: true });
+
+ // Capture initial pointer position from the first move; until then delta is 0.
+ // We set it explicitly so the first RAF tick sees zero deltas.
+ startPointerRef.current = { ...pointerRef.current };
+
+ const startMs = performance.now();
+
+ // Store pointer start once the first pointermove fires
+ let startCaptured = false;
+ const captureStart = (e: PointerEvent) => {
+ if (!startCaptured) {
+ startPointerRef.current = { x: e.clientX, y: e.clientY };
+ startCaptured = true;
+ }
+ };
+ element.addEventListener("pointermove", captureStart, { passive: true, once: true });
+
+ // RAF loop — sample at ~60fps
+ const tick = () => {
+ const now = performance.now();
+ const time = (now - startMs) / 1000;
+
+ const dx = pointerRef.current.x - startPointerRef.current.x;
+ const dy = pointerRef.current.y - startPointerRef.current.y;
+ const scrollDelta = scrollDeltaRef.current;
+
+ const { properties, nextState } = resolveGestureProperties(
+ dx,
+ dy,
+ scrollDelta,
+ modifiersRef.current,
+ accumulatedRef.current,
+ );
+
+ accumulatedRef.current = nextState;
+ // Reset scroll delta after consuming it — it's accumulated per-frame
+ scrollDeltaRef.current = 0;
+
+ const sample: GestureSample = { time, properties };
+ samplesRef.current.push(sample);
+ setRecordingDuration(time);
+
+ rafIdRef.current = requestAnimationFrame(tick);
+ };
+
+ setIsRecording(true);
+ rafIdRef.current = requestAnimationFrame(tick);
+
+ // Store cleanup so stopRecording can tear everything down
+ cleanupRef.current = () => {
+ cancelAnimationFrame(rafIdRef.current);
+ element.removeEventListener("pointermove", handlePointerMove);
+ element.removeEventListener("wheel", handleWheel);
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("keyup", handleKeyUp);
+ element.removeEventListener("pointermove", captureStart);
+ };
+ },
+ [isRecording],
+ );
+
+ const stopRecording = useCallback(() => {
+ if (!isRecording) return;
+
+ cleanupRef.current?.();
+ cleanupRef.current = null;
+
+ // Freeze samples into React state
+ const frozen = samplesRef.current.slice();
+ setSamples(frozen);
+ setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1].time : 0);
+ setIsRecording(false);
+ }, [isRecording]);
+
+ const clearSamples = useCallback(() => {
+ samplesRef.current = [];
+ setSamples([]);
+ setRecordingDuration(0);
+ accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
+ scrollDeltaRef.current = 0;
+ }, []);
+
+ return {
+ startRecording,
+ stopRecording,
+ isRecording,
+ samples,
+ recordingDuration,
+ clearSamples,
+ };
+}
diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts
index 57d62c36ec..95e07374ec 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) {
diff --git a/packages/studio/src/hooks/useToast.ts b/packages/studio/src/hooks/useToast.ts
index 79ca444ab4..a27147a9bf 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/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
index 502f19aff7..acad691e11 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 0000000000..ed58ebd85d
--- /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;
+}