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..bdd4c606f 100644
--- a/packages/core/src/parsers/gsapParser.ts
+++ b/packages/core/src/parsers/gsapParser.ts
@@ -1167,6 +1167,7 @@ export function addAnimationWithKeyframesToScript(
percentage: number;
properties: Record;
ease?: string;
+ auto?: boolean;
}>,
ease?: string,
): { script: string; id: string } {
@@ -1187,6 +1188,7 @@ export function addAnimationWithKeyframesToScript(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
+ if (kf.auto) propEntries.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`;
});
const kfCode = `{ ${kfEntries.join(", ")} }`;
@@ -1354,10 +1356,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);
@@ -1387,6 +1404,24 @@ export function addKeyframeToScript(
kfNode.properties.splice(insertIdx, 0, newProp);
}
+ // Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
+ // explicitly edited by the user), update it to match the new keyframe's
+ // values so the element holds its final position instead of snapping back.
+ // Once the user drags at 100%, `_auto` is gone and we stop touching it.
+ if (percentage < 100 && percentage !== 0) {
+ const pctProps = filterPercentageProps(kfNode);
+ const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
+ if (hundredProp?.value?.type === "ObjectExpression") {
+ const hasAuto = hundredProp.value.properties.some(
+ (p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
+ );
+ if (hasAuto) {
+ const updatedProps = { ...properties, _auto: 1 as number | string };
+ hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
+ }
+ }
+ }
+
// Backfill: when the new keyframe introduces properties absent from other
// keyframes, add default values so GSAP can interpolate them.
if (backfillDefaults) {
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index a4758d62f..73cd6fff1 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline.totalTime(seekTime, false);
}
- // Strip stale CSS offset artifacts from GSAP-targeted elements.
- // These leak into the HTML when the CSS offset path fires for a
- // GSAP-animated element (stale cache race). On reload, both the
- // offset and GSAP transform stack, doubling the visual position.
- const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]");
- if (staleEls.length > 0 && state.capturedTimeline.getChildren) {
- const tweenTargets = new Set();
- try {
- for (const child of state.capturedTimeline.getChildren(true)) {
- if (typeof child.targets === "function") {
- for (const t of child.targets()) tweenTargets.add(t);
- }
- }
- } catch {
- /* timeline access guard */
- }
- for (const el of staleEls) {
- if (!tweenTargets.has(el)) continue;
- const htmlEl = el as HTMLElement;
- htmlEl.removeAttribute("data-hf-studio-path-offset");
- htmlEl.removeAttribute("data-hf-studio-original-translate");
- htmlEl.removeAttribute("data-hf-studio-original-inline-translate");
- htmlEl.style.removeProperty("--hf-studio-offset-x");
- htmlEl.style.removeProperty("--hf-studio-offset-y");
- htmlEl.style.removeProperty("translate");
- }
- }
+ // GSAP bakes the CSS `translate` into style.transform on seek.
+ // The Studio seek wrapper (installStudioManualEditSeekReapply) calls
+ // reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
+ // directly here as well, since the wrapper may not be installed yet
+ // during initial rebind (timing race on first load / soft reload).
+ const applyFn = (window as Record).__hfStudioManualEditsApply;
+ if (typeof applyFn === "function") applyFn();
}
if (resolution.diagnostics) {
postRuntimeMessage({
@@ -1002,23 +982,21 @@ 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.
- {
+ // Only when embedded in an iframe (Studio preview) — production renders
+ // run as the top-level page and must not mutate element timing.
+ if (window.parent !== window) {
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;
+ // Elements inside a sub-composition host are managed by the host's
+ // clip window — stamping them with root-level timing makes the
+ // visibility system treat them as always-visible, overriding the
+ // parent's hidden state after the clip ends.
+ const isInsideSubComposition = (el: Element): boolean => {
+ const host = el.closest("[data-composition-src],[data-composition-file]");
+ return host !== null && host !== rootComp;
};
// Stamp GSAP-targeted elements
@@ -1030,8 +1008,8 @@ 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;
+ if (isInsideSubComposition(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
target.setAttribute("data-duration", dur);
@@ -1050,9 +1028,9 @@ 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;
+ if (isInsideSubComposition(el)) continue;
seen.add(el);
el.setAttribute("data-start", "0");
el.setAttribute("data-duration", dur);
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/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts
index 96f02bb58..f76f2e9a5 100644
--- a/packages/core/src/studio-api/routes/files.ts
+++ b/packages/core/src/studio-api/routes/files.ts
@@ -681,6 +681,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
percentage: number;
properties: Record;
ease?: string;
+ auto?: boolean;
}>;
ease?: string;
};
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index af9edf131..bcbaf68d8 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 { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
import { StudioRightPanel } from "./components/StudioRightPanel";
@@ -94,7 +98,6 @@ export function StudioApp() {
const captionEditMode = useCaptionStore((s) => s.isEditMode);
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
const captionSync = useCaptionSync(projectId);
- const currentTime = usePlayerStore((s) => s.currentTime);
const timelineElements = usePlayerStore((s) => s.elements);
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
const timelineDuration = usePlayerStore((s) => s.duration);
@@ -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,
@@ -136,6 +139,7 @@ export function StudioApp() {
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const pendingTimelineEditPathRef = useRef(new Set());
+ const isGestureRecordingRef = useRef(false);
const reloadPreview = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
@@ -185,6 +189,7 @@ export function StudioApp() {
previewIframeRef,
pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles,
+ isRecordingRef: isGestureRecordingRef,
});
const blockCtx = useMemo(
@@ -306,6 +311,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),
@@ -325,7 +331,6 @@ export function StudioApp() {
compositionLoading,
previewIframeRef,
timelineElements,
- currentTime,
setSelectedTimelineElementId,
setRightCollapsed: panelLayout.setRightCollapsed,
setRightPanelTab: panelLayout.setRightPanelTab,
@@ -400,6 +405,128 @@ export function StudioApp() {
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
+ // Gesture recording
+ const gestureRecording = useGestureRecording();
+ const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
+ // Synchronous mirror of gestureState — immune to React batching.
+ // Prevents double-R-press within a single render cycle from swallowing the stop.
+ const gestureStateRef = useRef<"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 () => {
+ clearInterval(recordingAutoStopRef.current);
+ if (commitInFlightRef.current) return;
+ commitInFlightRef.current = true;
+ gestureStateRef.current = "idle";
+ isGestureRecordingRef.current = false;
+ const frozenSamples = gestureRecording.stopRecording();
+ const store = usePlayerStore.getState();
+ store.setIsPlaying(false);
+ try {
+ const liveSession = domEditSessionRef.current;
+ const sel = liveSession.domEditSelection;
+ if (!sel) {
+ if (frozenSamples.length > 2) {
+ showToast("Selection lost during recording", "error");
+ }
+ return;
+ }
+ const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0;
+
+ if (frozenSamples.length <= 2) {
+ showToast("No gesture detected — move the pointer while recording", "error");
+ return;
+ }
+ if (duration <= 0) {
+ showToast("Recording too short — try again", "error");
+ return;
+ }
+
+ const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
+ const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
+
+ // Always create a new tween scoped to the recording range.
+ // Injecting into an existing tween creates keyframes before the recording
+ // start (from the convert-to-keyframes step), causing wrong positions.
+ const selector = sel.id ? `#${sel.id}` : sel.selector;
+ if (!selector) {
+ showToast("Cannot save — element has no selector", "error");
+ return;
+ }
+ if (liveSession.commitMutation) {
+ const recStart = recordingStartTimeRef.current;
+ 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(recStart * 1000) / 1000,
+ duration: Math.round(duration * 1000) / 1000,
+ keyframes,
+ },
+ { label: "Gesture recording", softReload: true },
+ );
+ }
+ showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
+ } finally {
+ store.requestSeek(recordingStartTimeRef.current);
+ gestureRecording.clearSamples();
+ setGestureState("idle");
+ commitInFlightRef.current = false;
+ }
+ }, [gestureRecording, showToast]);
+
+ const handleToggleRecording = useCallback(() => {
+ if (gestureStateRef.current === "recording") {
+ void stopAndCommitRecording();
+ return;
+ }
+ const sel = domEditSessionRef.current.domEditSelection;
+ if (!sel) {
+ showToast("Select an element first", "error");
+ return;
+ }
+ const iframe = previewIframeRef.current;
+ if (!iframe) {
+ showToast("Preview not ready — try again", "error");
+ return;
+ }
+
+ const store = usePlayerStore.getState();
+ recordingStartTimeRef.current = store.currentTime;
+ const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
+ const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
+ const elementEnd = elDur > 0 ? elStart + elDur : undefined;
+ gestureRecording.startRecording(sel.element, iframe, elementEnd);
+ gestureStateRef.current = "recording";
+ isGestureRecordingRef.current = true;
+ setGestureState("recording");
+
+ clearInterval(recordingAutoStopRef.current);
+ const autoStopAt = elementEnd ?? Infinity;
+ recordingAutoStopRef.current = setInterval(() => {
+ const { currentTime: t, duration: d } = usePlayerStore.getState();
+ const limit = Math.min(autoStopAt, d);
+ if (limit > 0 && t >= limit - 0.05) {
+ void stopAndCommitRecording();
+ }
+ }, 100);
+ }, [gestureRecording, showToast, stopAndCommitRecording]);
+ handleToggleRecordingRef.current = handleToggleRecording;
+
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
@@ -435,12 +562,12 @@ export function StudioApp() {
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
+ gestureState === "recording",
);
useStudioUrlState({
projectId,
activeCompPath,
- currentTime,
duration: effectiveTimelineDuration,
isPlaying,
compositionLoading,
@@ -466,7 +593,6 @@ export function StudioApp() {
compositionLoading,
refreshKey,
setRefreshKey,
- currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -541,7 +667,23 @@ export function StudioApp() {
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
+ isGestureRecording={gestureState === "recording"}
blockPreview={blockPreview}
+ gestureOverlay={
+ 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"
+ />
+ ) : undefined
+ }
/>
{!panelLayout.rightCollapsed && (
@@ -554,6 +696,9 @@ export function StudioApp() {
setActiveBlockParams(null);
panelLayout.setRightPanelTab("design");
}}
+ recordingState={gestureState}
+ recordingDuration={gestureRecording.recordingDuration}
+ onToggleRecording={handleToggleRecording}
/>
)}
@@ -586,7 +731,13 @@ export function StudioApp() {
)}
{dragOverlay.active && }
- {appToast && }
+ {appToast && (
+
+ )}
diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx
index 5399d3f42..4ac1b9230 100644
--- a/packages/studio/src/components/StudioPreviewArea.tsx
+++ b/packages/studio/src/components/StudioPreviewArea.tsx
@@ -56,6 +56,8 @@ export interface StudioPreviewAreaProps {
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
blockPreview?: BlockPreviewInfo | null;
+ isGestureRecording?: boolean;
+ gestureOverlay?: ReactNode;
}
// fallow-ignore-next-line complexity
@@ -74,7 +76,9 @@ export function StudioPreviewArea({
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
+ isGestureRecording,
blockPreview,
+ gestureOverlay,
}: StudioPreviewAreaProps) {
const {
projectId,
@@ -241,7 +245,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}
@@ -256,6 +260,7 @@ export function StudioPreviewArea({
gridSpacing={snapPrefs.gridSpacing}
/>
+ {gestureOverlay}
>
) : null
}
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/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index a8625355b..66129639b 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -252,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset);
const handleOverlayMouseDown = (event: React.MouseEvent) => {
+ if (!allowCanvasMovement) return;
if (suppressNextOverlayMouseDownRef.current) {
suppressNextOverlayMouseDownRef.current = false;
suppressNextBoxMouseDownRef.current = false;
@@ -312,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
};
const handleBoxClick = (event: React.MouseEvent) => {
+ if (!allowCanvasMovement) return;
if (gestureRef.current || groupGestureRef.current) return;
if (suppressNextBoxClickRef.current) {
suppressNextBoxClickRef.current = false;
@@ -344,7 +346,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onPointerUp={gestures.onPointerUp}
onPointerCancel={() => gestures.clearPointerState(selectionRef)}
>
- {hoverSelection && hoverRect && (
+ {hoverSelection && hoverRect && compRect.width > 0 && (
)}
- {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && (
+ {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
<>
{groupOverlayItems.map((item) => (
>
)}
- {!hasGroupSelection && selection && overlayRect && (
+ {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
<>
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
)}
{childRects.length > 0 &&
+ compRect.width > 0 &&
childRects.map((cr, i) => (
;
+ 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 (
+ 1
+ ? `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/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx
index 4eda5c35d..9473b002b 100644
--- a/packages/studio/src/components/editor/LayersPanel.tsx
+++ b/packages/studio/src/components/editor/LayersPanel.tsx
@@ -60,9 +60,9 @@ export const LayersPanel = memo(function LayersPanel() {
refreshKey,
compositionLoading,
timelineElements,
- currentTime,
showToast,
} = useStudioContext();
+ const currentTime = usePlayerStore((s) => s.currentTime);
const {
domEditSelection,
applyDomSelection,
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/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts
index dc4af6756..553bb5e8d 100644
--- a/packages/studio/src/components/editor/manualEditingAvailability.ts
+++ b/packages/studio/src/components/editor/manualEditingAvailability.ts
@@ -74,7 +74,7 @@ export const STUDIO_GSAP_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"],
- true,
+ false,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts
index 05e6a81b0..5c0e01be2 100644
--- a/packages/studio/src/components/editor/manualEdits.ts
+++ b/packages/studio/src/components/editor/manualEdits.ts
@@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
"renderSeek",
);
const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek");
+ wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime");
const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play");
const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play");
const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause");
@@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
for (const timeline of Object.values(studioWin.__timelines ?? {})) {
wrappedNamedTimelineSeek =
wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek;
+ wrapSeekReapplyFunction(studioWin, timeline, "totalTime");
wrappedNamedTimelinePlay =
wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay;
wrappedNamedTimelinePause =
@@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
if (typeof value === "object" && value !== null) {
const tl = value as Record
;
wrapSeekReapplyFunction(studioWin, tl, "seek");
+ wrapSeekReapplyFunction(studioWin, tl, "totalTime");
wrapPlayReapplyFunction(studioWin, tl, "play");
wrapApplyAfterFunction(studioWin, tl, "pause");
studioWin.__hfStudioManualEditsApply?.();
diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts
index 27c38e480..a1961abbd 100644
--- a/packages/studio/src/components/editor/manualEditsDom.ts
+++ b/packages/studio/src/components/editor/manualEditsDom.ts
@@ -273,11 +273,25 @@ export function applyStudioPathOffsetDraft(
): void {
promoteInlineForTransform(element);
writeStudioPathOffsetVars(element, offset, { updateBase: false });
- element.style.setProperty(
- "translate",
- composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
- );
- stripGsapTranslateFromTransform(element);
+
+ const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
+ if (isGsapAnimated) {
+ // For GSAP-animated elements: use gsap.set for positioning (the timeline
+ // is paused during drag). Set translate:none explicitly to prevent
+ // double-counting with the transform.
+ element.style.setProperty("translate", "none");
+ const win = element.ownerDocument.defaultView as
+ | (Window & { gsap?: { set: (el: Element, vars: Record) => void } })
+ | null;
+ win?.gsap?.set(element, { x: offset.x, y: offset.y });
+ } else {
+ // Non-GSAP elements: use CSS translate as before.
+ element.style.setProperty(
+ "translate",
+ composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
+ );
+ stripGsapTranslateFromTransform(element);
+ }
}
/* ── Box size apply ───────────────────────────────────────────────── */
@@ -505,6 +519,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
function reapplyPathOffsets(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
+ // Skip elements where GSAP actively animates position — GSAP bakes the
+ // CSS translate into its transform and sets translate: none every tick.
+ // Stripping/restoring would oscillate against GSAP's rendering.
+ if (gsapAnimatesProperty(el, "x", "y")) continue;
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
if (x || y) {
diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts
index 9df465a00..3d8ee8dbd 100644
--- a/packages/studio/src/components/editor/manualOffsetDrag.ts
+++ b/packages/studio/src/components/editor/manualOffsetDrag.ts
@@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: {
rect: ManualOffsetDragRect;
}): ManualOffsetDragMemberResult {
const initialOffset = readStudioPathOffset(input.element);
+ input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
+ input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
+
+ // Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path
+ // needs the original (uncorrupted) GSAP position to compute the new keyframe value.
+ const win = input.element.ownerDocument.defaultView as
+ | (Window & {
+ gsap?: { getProperty?: (el: Element, prop: string) => number };
+ __timelines?: Record void; paused?: () => boolean }>;
+ })
+ | null;
+ const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
+ const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
+ input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
+ input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
+
+ // Pause GSAP timelines during drag to prevent the tween from overwriting
+ // the draft's gsap.set on every tick. Track which we paused to resume later.
+ if (win?.__timelines) {
+ const paused: string[] = [];
+ for (const [id, tl] of Object.entries(win.__timelines)) {
+ try {
+ if (tl?.pause && !tl.paused?.()) {
+ tl.pause();
+ paused.push(id);
+ }
+ } catch {
+ /* cross-origin guard */
+ }
+ }
+ if (paused.length > 0) {
+ input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(","));
+ }
+ }
+
const initialPathOffset = captureStudioPathOffset(input.element);
const gestureToken = beginStudioManualEditGesture(input.element);
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
@@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
restoreManualOffsetDragMember(member);
+ resumeGsapTimelines(member.element);
}
}
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
endStudioManualEditGesture(member.element, member.gestureToken);
+ member.element.removeAttribute("data-hf-drag-initial-offset-x");
+ member.element.removeAttribute("data-hf-drag-initial-offset-y");
+ member.element.removeAttribute("data-hf-drag-gsap-base-x");
+ member.element.removeAttribute("data-hf-drag-gsap-base-y");
+ resumeGsapTimelines(member.element);
}
}
+
+function resumeGsapTimelines(element: HTMLElement): void {
+ const ids = element.getAttribute("data-hf-drag-paused-timelines");
+ element.removeAttribute("data-hf-drag-paused-timelines");
+ if (!ids) return;
+ const win = element.ownerDocument.defaultView as
+ | (Window & {
+ __timelines?: Record void }>;
+ __player?: { seek?: (t: number) => void; getTime?: () => number };
+ })
+ | null;
+ if (!win) return;
+ // Re-seek to the current time to restore the paused timeline's render state.
+ // play() would start playback; pause() already stops. Seek re-renders at the
+ // current position without starting playback.
+ const t = win.__player?.getTime?.() ?? 0;
+ win.__player?.seek?.(t);
+}
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;
@@ -143,7 +149,8 @@ export function useDomEditOverlayRects({
resolvedElementRef as ResolvedElementRef,
);
if (el && isElementVisibleForOverlay(el)) {
- setOverlayRect(toOverlayRect(overlayEl, iframe, el));
+ const nextRect = toOverlayRect(overlayEl, iframe, el);
+ setOverlayRect(nextRect);
const descendants = el.querySelectorAll("*");
if (descendants.length > 0 && descendants.length <= 60) {
const nextChildRects: OverlayRect[] = [];
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/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx
index 1a61a33f7..48787d076 100644
--- a/packages/studio/src/contexts/StudioContext.tsx
+++ b/packages/studio/src/contexts/StudioContext.tsx
@@ -12,7 +12,6 @@ export interface StudioContextValue {
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch>;
- currentTime: number;
timelineElements: TimelineElement[];
isPlaying: boolean;
editHistory: {
@@ -63,7 +62,6 @@ export function StudioProvider({
compositionLoading,
refreshKey,
setRefreshKey,
- currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -89,7 +87,6 @@ export function StudioProvider({
compositionLoading,
refreshKey,
setRefreshKey,
- currentTime,
timelineElements,
isPlaying,
editHistory,
@@ -112,7 +109,6 @@ export function StudioProvider({
captionEditMode,
compositionLoading,
refreshKey,
- currentTime,
isPlaying,
compositionDimensions,
timelineVisible,
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts
index 6e641dfc8..3ac4036c9 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts
@@ -10,7 +10,7 @@
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
-import { clearStudioPathOffset } from "../components/editor/manualEdits";
+
import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import {
@@ -202,6 +202,10 @@ export async function tryGsapDragIntercept(
const selector = selectorForSelection(selection);
if (!selector) return false;
+ // Keyframe writes at 0%/100% when outside the tween range. Acceptable
+ // trade-off — CSS path must NEVER touch GSAP-targeted elements because
+ // changing the CSS offset corrupts all existing keyframes (baked mismatch).
+
const gsapPos = readGsapPositionFromIframe(iframe, selector);
if (!gsapPos) return false;
@@ -244,48 +248,153 @@ async function commitGsapPositionFromDrag(
const rad = (-rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
- const adjX = studioOffset.x * cos - studioOffset.y * sin;
- const adjY = studioOffset.x * sin + studioOffset.y * cos;
- const newX = Math.round(gsapPos.x + adjX);
- const newY = Math.round(gsapPos.y + adjY);
- const clearOffset = () => clearStudioPathOffset(selection.element);
+ const el = selection.element;
+ const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
+ const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
+ const deltaX = studioOffset.x - origX;
+ const deltaY = studioOffset.y - origY;
+ const adjX = deltaX * cos - deltaY * sin;
+ const adjY = deltaX * sin + deltaY * cos;
+ // Use the GSAP base captured at drag start — the live gsapPos is corrupted
+ // by the draft's gsap.set() calls during drag.
+ const baseGsapX =
+ Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
+ const baseGsapY =
+ Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
+ const newX = Math.round(baseGsapX + adjX);
+ const newY = Math.round(baseGsapY + adjY);
+ // Restore the CSS offset to pre-drag value so the baked translate stays
+ // consistent with existing keyframes. The drag is captured in the new keyframe.
+ const restoreOffset = () => {
+ el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
+ el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
+ el.removeAttribute("data-hf-drag-initial-offset-x");
+ el.removeAttribute("data-hf-drag-initial-offset-y");
+ };
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
- await commitKeyframedPosition(
+
+ // Check if current time is outside the tween's range — extend the tween
+ // to cover the playhead, remap existing keyframes, then add the new one.
+ const ct = usePlayerStore.getState().currentTime;
+ const ts = resolveTweenStart(effectiveAnim);
+ const td = resolveTweenDuration(effectiveAnim);
+ if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
+ await extendTweenAndAddKeyframe(
+ selection,
+ effectiveAnim,
+ { ...runtimeProps, x: newX, y: newY },
+ ct,
+ ts,
+ td,
+ callbacks,
+ restoreOffset,
+ );
+ } else {
+ await commitKeyframedPosition(
+ selection,
+ effectiveAnim,
+ { ...runtimeProps, x: newX, y: newY },
+ callbacks,
+ restoreOffset,
+ );
+ }
+ } 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,
- effectiveAnim,
- { ...runtimeProps, x: newX, y: newY },
- callbacks,
- clearOffset,
+ {
+ type: "convert-to-keyframes",
+ animationId: anim.id,
+ resolvedFromValues: { x: newX, y: newY },
+ },
+ { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
);
- } 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()/set() — convert to keyframes then add at current percentage.
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
await commitFlatViaKeyframes(
selection,
anim,
{ ...runtimeProps, x: newX, y: newY },
callbacks,
- clearOffset,
+ restoreOffset,
);
}
}
+/**
+ * Extend a tween's time range to cover `targetTime`, remap all existing
+ * keyframe percentages to preserve their absolute positions, then add
+ * a new keyframe at the target time.
+ */
+async function extendTweenAndAddKeyframe(
+ selection: DomEditSelection,
+ anim: GsapAnimation,
+ properties: Record,
+ targetTime: number,
+ tweenStart: number,
+ tweenDuration: number,
+ callbacks: GsapDragCommitCallbacks,
+ beforeReload?: () => void,
+): Promise {
+ const tweenEnd = tweenStart + tweenDuration;
+ const newStart = Math.min(targetTime, tweenStart);
+ const newEnd = Math.max(targetTime, tweenEnd);
+ const newDuration = Math.max(0.01, newEnd - newStart);
+
+ // Step 1: Remap all existing keyframes to preserve their absolute times
+ // in the new range, then add the new keyframe.
+ const existingKfs = anim.keyframes?.keyframes ?? [];
+ const remappedKfs: Array<{ percentage: number; properties: Record }> =
+ [];
+ for (const kf of existingKfs) {
+ const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
+ remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
+ }
+
+ // Add the new keyframe at the target time
+ const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
+ remappedKfs.push({ percentage: targetPct, properties });
+
+ // Sort and dedupe
+ remappedKfs.sort((a, b) => a.percentage - b.percentage);
+
+ // Step 2: Delete the old tween and create a new one with the extended range
+ // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair.
+ await callbacks.commitMutation(
+ selection,
+ { type: "delete", animationId: anim.id },
+ { label: "Extend tween range", skipReload: true },
+ );
+
+ const selector = anim.targetSelector;
+ await callbacks.commitMutation(
+ selection,
+ {
+ type: "add-with-keyframes",
+ targetSelector: selector,
+ position: Math.round(newStart * 1000) / 1000,
+ duration: Math.round(newDuration * 1000) / 1000,
+ keyframes: remappedKfs,
+ },
+ { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
+ );
+}
+
// fallow-ignore-next-line complexity
async function commitKeyframedPosition(
selection: DomEditSelection,
anim: GsapAnimation,
properties: Record,
callbacks: GsapDragCommitCallbacks,
- beforeReload: () => void,
+ beforeReload?: () => void,
): Promise {
const pct = computeCurrentPercentage(selection, anim);
@@ -312,7 +421,7 @@ async function commitFlatViaKeyframes(
anim: GsapAnimation,
properties: Record,
callbacks: GsapDragCommitCallbacks,
- beforeReload: () => void,
+ beforeReload?: () => void,
): Promise {
await callbacks.commitMutation(
selection,
@@ -334,65 +443,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/useAskAgentModal.ts b/packages/studio/src/hooks/useAskAgentModal.ts
index ece327998..f88f3f261 100644
--- a/packages/studio/src/hooks/useAskAgentModal.ts
+++ b/packages/studio/src/hooks/useAskAgentModal.ts
@@ -3,6 +3,7 @@ import { copyTextToClipboard } from "../utils/clipboard";
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers";
import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing";
+import { usePlayerStore } from "../player";
// ── Types ──
@@ -11,7 +12,6 @@ export interface UseAskAgentModalParams {
activeCompPath: string | null;
projectDir: string | null;
projectIdRef: React.MutableRefObject;
- currentTime: number;
showToast: (message: string, tone?: "error" | "info") => void;
domEditSelectionRef: React.MutableRefObject;
domEditSelection: DomEditSelection | null;
@@ -23,7 +23,6 @@ export function useAskAgentModal({
activeCompPath,
projectDir,
projectIdRef,
- currentTime,
showToast,
domEditSelectionRef,
domEditSelection,
@@ -91,7 +90,7 @@ export function useAskAgentModal({
const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
const prompt = buildElementAgentPrompt({
selection: domEditSelection,
- currentTime,
+ currentTime: usePlayerStore.getState().currentTime,
tagSnippet,
selectionContext: agentPromptSelectionContext,
userInstruction,
@@ -115,7 +114,6 @@ export function useAskAgentModal({
activeCompPath,
agentPromptSelectionContext,
agentPromptTagSnippet,
- currentTime,
domEditSelection,
projectDir,
showToast,
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 02013491e..f4fa2ce3c 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -50,7 +50,6 @@ export interface UseDomEditSessionParams {
compositionLoading: boolean;
previewIframeRef: React.MutableRefObject;
timelineElements: TimelineElement[];
- currentTime: number;
setSelectedTimelineElementId: (id: string | null) => void;
setRightCollapsed: (collapsed: boolean) => void;
setRightPanelTab: (tab: RightPanelTab) => void;
@@ -92,7 +91,6 @@ export function useDomEditSession({
compositionLoading,
previewIframeRef,
timelineElements,
- currentTime,
setSelectedTimelineElementId,
setRightCollapsed,
setRightPanelTab,
@@ -184,7 +182,6 @@ export function useDomEditSession({
activeCompPath,
projectDir,
projectIdRef,
- currentTime,
showToast,
domEditSelectionRef,
domEditSelection,
@@ -272,6 +269,7 @@ export function useDomEditSession({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
@@ -434,6 +432,7 @@ export function useDomEditSession({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -450,10 +449,10 @@ export function useDomEditSession({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
- currentTime,
handleDomManualEditsReset,
selectedGsapAnimations,
});
@@ -623,6 +622,7 @@ export function useDomEditSession({
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
+ handleGsapAddKeyframeBatch,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
@@ -632,5 +632,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..3978ff871
--- /dev/null
+++ b/packages/studio/src/hooks/useEnableKeyframes.ts
@@ -0,0 +1,171 @@
+/**
+ * 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 },
+ auto: true,
+ } as (typeof keyframes)[number]);
+
+ 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/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts
new file mode 100644
index 000000000..0c9877015
--- /dev/null
+++ b/packages/studio/src/hooks/useGestureRecording.ts
@@ -0,0 +1,340 @@
+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;
+}
+
+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) {
+ // Opacity derived from total vertical displacement (absolute, not accumulated).
+ // Dragging down reduces opacity; dragging back up restores it.
+ nextOpacity = Math.max(0, Math.min(1, 1 - 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 pointerElementOffsetRef = useRef({ x: 0, y: 0 });
+ const runtimeRef = useRef<{
+ seek: (t: number) => void;
+ set: (target: string, vars: Record) => void;
+ selector: string;
+ element: HTMLElement;
+ startTime: number;
+ maxSeekTime: 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, elementEndTime?: number) => {
+ 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 */
+ }
+ // When reapplyPathOffsets has run (translate restored to var-based),
+ // GSAP's cache was stripped — gsapX is 0 but the element is visually
+ // at CSSLeft + translate(offset). gsap.set wipes translate, so we need
+ // baseX to include the offset. When translate is "none" (GSAP owns it),
+ // gsapX already includes the baked offset — don't add.
+ const translateVal = element.style.translate ?? "";
+ if (translateVal.includes("var(")) {
+ const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0;
+ const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0;
+ baseX += offX;
+ baseY += offY;
+ }
+ 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) {
+ const tlDuration = tl.duration();
+ runtimeRef.current = {
+ seek: tl.seek.bind(tl),
+ set: win.gsap.set.bind(win.gsap),
+ selector,
+ element,
+ startTime: win.__player?.getTime() ?? 0,
+ maxSeekTime:
+ elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration,
+ };
+ }
+ } 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;
+
+ // Compute the offset between the element's visual center and the pointer
+ // so the element tracks the pointer exactly during recording (no jump).
+ const elRect = element.getBoundingClientRect();
+ const elCenterViewport = {
+ x: elRect.left + elRect.width / 2,
+ y: elRect.top + elRect.height / 2,
+ };
+ pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move
+
+ 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 };
+ // Compute the offset between the pointer and the element center
+ // so the element follows the pointer without jumping.
+ pointerElementOffsetRef.current = {
+ x: e.clientX - elCenterViewport.x,
+ y: e.clientY - elCenterViewport.y,
+ };
+ 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.maxSeekTime,
+ );
+ runtimeRef.current.seek(seekTime);
+ runtimeRef.current.set(runtimeRef.current.selector, { ...properties });
+ runtimeRef.current.element.style.visibility = "visible";
+ liveTime.notify(seekTime);
+ usePlayerStore.getState().setCurrentTime(seekTime);
+ } catch {
+ runtimeRef.current = null;
+ }
+ }
+
+ samplesRef.current.push({ time, properties });
+ 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 = [];
+ trailRef.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..86bf4fe36 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -78,17 +78,34 @@ function updateKeyframeCacheFromParsed(
selectionId: string | undefined,
mutation: Record,
): void {
- const { setKeyframeCache } = usePlayerStore.getState();
+ const { setKeyframeCache, elements } = usePlayerStore.getState();
const idsWithKeyframes = new Set();
const merged = new Map();
for (const anim of animations) {
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
if (!id || !anim.keyframes) continue;
idsWithKeyframes.add(id);
+
+ // Convert tween-relative percentages to clip-relative so diamonds
+ // render at the correct position within the timeline clip.
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
+ const tweenDur = anim.duration ?? 1;
+ const timelineEl = elements.find(
+ (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
+ );
+ const elStart = timelineEl?.start ?? 0;
+ const elDuration = timelineEl?.duration ?? 4;
+ const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
+ const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
+ const clipPct =
+ elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
+ return { ...kf, percentage: clipPct };
+ });
+
const existing = merged.get(id);
if (existing) {
const byPct = new Map();
- for (const kf of [...existing.keyframes, ...anim.keyframes.keyframes]) {
+ for (const kf of [...existing.keyframes, ...clipKeyframes]) {
const prev = byPct.get(kf.percentage);
if (prev) {
prev.properties = { ...prev.properties, ...kf.properties };
@@ -99,11 +116,12 @@ function updateKeyframeCacheFromParsed(
}
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
} else {
- merged.set(id, { ...anim.keyframes, keyframes: [...anim.keyframes.keyframes] });
+ merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
}
}
for (const [id, entry] of merged) {
setKeyframeCache(`${targetPath}#${id}`, entry);
+ setKeyframeCache(id, entry);
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
}
const targetId =
@@ -201,12 +219,14 @@ export function useGsapScriptCommits({
});
}
- onCacheInvalidate();
-
if (result.after != null) {
onFileContentChanged?.(targetPath, result.after);
}
+ if (options.skipReload) return;
+
+ // Write the keyframe cache immediately from the parsed response
+ // (synchronous — the timeline diamonds appear on the next render).
if (result.parsed?.animations) {
updateKeyframeCacheFromParsed(
result.parsed.animations,
@@ -216,8 +236,6 @@ export function useGsapScriptCommits({
);
}
- if (options.skipReload) return;
-
options.beforeReload?.();
if (options.softReload && result.scriptText) {
@@ -227,6 +245,11 @@ export function useGsapScriptCommits({
} else {
reloadPreview();
}
+
+ // Bump the cache version AFTER reload so the async re-fetch in
+ // useGsapAnimationsForElement reads the post-reload script, not
+ // the stale pre-reload version that would overwrite fresh data.
+ onCacheInvalidate();
},
[
projectIdRef,
@@ -467,6 +490,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 +537,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 +627,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..aa8db6da4 100644
--- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts
+++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
+import { usePlayerStore } from "../player";
/**
* Thin useCallback wrappers that guard on `domEditSelection` before
@@ -19,10 +20,10 @@ export function useGsapSelectionHandlers({
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
+ addKeyframeBatch,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
- currentTime,
handleDomManualEditsReset,
selectedGsapAnimations,
}: {
@@ -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,
@@ -68,7 +75,7 @@ export function useGsapSelectionHandlers({
resolvedFromValues?: Record,
) => void;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
- currentTime: number;
+
handleDomManualEditsReset: (sel: DomEditSelection) => void;
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
}) {
@@ -99,12 +106,12 @@ export function useGsapSelectionHandlers({
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
- addGsapAnimation(domEditSelection, method, currentTime);
+ addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime);
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
handleDomManualEditsReset(domEditSelection);
}
},
- [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
+ [domEditSelection, addGsapAnimation, handleDomManualEditsReset],
);
const handleGsapAddProperty = useCallback(
@@ -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..1a3c1eeed 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;
+ }),
);
}
@@ -251,6 +256,16 @@ export function useGsapAnimationsForElement(
const elementId = target?.id ?? null;
useEffect(() => {
if (!elementId) return;
+
+ // Resolve the element's time range from the player store so we can
+ // convert tween-relative keyframe percentages to clip-relative ones.
+ const { elements } = usePlayerStore.getState();
+ const timelineEl = elements.find(
+ (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
+ );
+ const elStart = timelineEl?.start ?? 0;
+ const elDuration = timelineEl?.duration ?? 4;
+
const allKeyframes: GsapKeyframesData["keyframes"] = [];
let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined;
@@ -258,12 +273,29 @@ export function useGsapAnimationsForElement(
for (const anim of animations) {
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kf) continue;
- allKeyframes.push(...kf.keyframes);
+ // Convert tween-relative percentages to clip-relative so diamonds
+ // render at the correct position within the timeline clip.
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
+ const tweenDur = anim.duration ?? elDuration;
+ for (const k of kf.keyframes) {
+ const absTime = tweenPos + (k.percentage / 100) * tweenDur;
+ const clipPct =
+ elDuration > 0
+ ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
+ : k.percentage;
+ allKeyframes.push({ ...k, percentage: clipPct });
+ }
format = kf.format;
if (kf.ease) ease = kf.ease;
if (kf.easeEach) easeEach = kf.easeEach;
}
- if (allKeyframes.length === 0) return;
+ if (allKeyframes.length === 0) {
+ const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
+ if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
+ setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
+ }
+ return;
+ }
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
const merged: GsapKeyframesData = {
format,
@@ -319,17 +351,34 @@ export function usePopulateKeyframeCacheForFile(
setKeyframeCache(key, undefined);
}
}
+ const { elements } = usePlayerStore.getState();
const mergedByElement = new Map();
for (const anim of parsed.animations) {
const id = extractIdFromSelector(anim.targetSelector);
if (!id) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
+ // Convert tween-relative percentages to clip-relative.
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
+ const tweenDur = anim.duration ?? 1;
+ const timelineEl = elements.find(
+ (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
+ );
+ const elStart = timelineEl?.start ?? 0;
+ const elDuration = timelineEl?.duration ?? 4;
+ const clipKeyframes = kfData.keyframes.map((kf) => {
+ const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
+ const clipPct =
+ elDuration > 0
+ ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
+ : kf.percentage;
+ return { ...kf, percentage: clipPct };
+ });
const existing = mergedByElement.get(id);
if (existing) {
- existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...kfData.keyframes]);
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else {
- mergedByElement.set(id, { ...kfData, keyframes: [...kfData.keyframes] });
+ mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
for (const [id, kfData] of mergedByElement) {
diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts
index 985025bbd..ab1d1c8a8 100644
--- a/packages/studio/src/hooks/useStudioContextValue.ts
+++ b/packages/studio/src/hooks/useStudioContextValue.ts
@@ -17,7 +17,6 @@ interface StudioContextInput {
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch>;
- currentTime: number;
timelineElements: StudioContextValue["timelineElements"];
isPlaying: boolean;
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
@@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
compositionLoading: input.compositionLoading,
refreshKey: input.refreshKey,
setRefreshKey: input.setRefreshKey,
- currentTime: input.currentTime,
+
timelineElements: input.timelineElements,
isPlaying: input.isPlaying,
editHistory: input.editHistory,
@@ -81,6 +80,7 @@ export function useInspectorState(
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
+ isGestureRecording?: boolean,
): InspectorState {
// fallow-ignore-next-line complexity
return useMemo(() => {
@@ -101,9 +101,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/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts
index 5a8b9428f..6ab0b2f80 100644
--- a/packages/studio/src/hooks/useStudioUrlState.ts
+++ b/packages/studio/src/hooks/useStudioUrlState.ts
@@ -11,7 +11,6 @@ import {
interface UseStudioUrlStateParams {
projectId: string | null;
activeCompPath: string | null;
- currentTime: number;
duration: number;
isPlaying: boolean;
compositionLoading: boolean;
@@ -57,7 +56,6 @@ function replaceHash(nextHash: string) {
export function useStudioUrlState({
projectId,
activeCompPath,
- currentTime,
duration,
isPlaying,
compositionLoading,
@@ -72,6 +70,7 @@ export function useStudioUrlState({
applyDomSelection,
initialState,
}: UseStudioUrlStateParams) {
+ const currentTime = usePlayerStore((s) => s.currentTime);
const hydratedSeekRef = useRef(initialState.currentTime == null);
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
const hydratedSelectionRef = useRef(initialState.selection == null);
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index f2b7c032a..589b9b6f4 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -41,6 +41,7 @@ interface UseTimelineEditingOptions {
previewIframeRef: React.RefObject;
pendingTimelineEditPathRef: React.MutableRefObject>;
uploadProjectFiles: (files: Iterable, dir?: string) => Promise;
+ isRecordingRef?: React.RefObject;
}
// ── Helpers ──
@@ -174,6 +175,7 @@ export function useTimelineEditing({
previewIframeRef,
pendingTimelineEditPathRef,
uploadProjectFiles,
+ isRecordingRef,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
@@ -187,6 +189,10 @@ export function useTimelineEditing({
label: string,
buildPatches: PersistTimelineEditInput["buildPatches"],
): Promise => {
+ if (isRecordingRef?.current) {
+ showToast("Cannot edit timeline while recording", "error");
+ return Promise.resolve();
+ }
const pid = projectIdRef.current;
if (!pid) return Promise.resolve();
const queued = editQueueRef.current.then(() =>
@@ -213,6 +219,8 @@ export function useTimelineEditing({
writeProjectFile,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
+ showToast,
+ isRecordingRef,
],
);
@@ -274,6 +282,10 @@ export function useTimelineEditing({
const handleTimelineElementDelete = useCallback(
async (element: TimelineElement) => {
+ if (isRecordingRef?.current) {
+ showToast("Cannot edit timeline while recording", "error");
+ return;
+ }
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
const label = getTimelineElementLabel(element);
@@ -338,6 +350,7 @@ export function useTimelineEditing({
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
+ isRecordingRef,
],
);
@@ -347,6 +360,10 @@ export function useTimelineEditing({
placement: Pick,
durationOverride?: number,
) => {
+ if (isRecordingRef?.current) {
+ showToast("Cannot edit timeline while recording", "error");
+ return;
+ }
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
@@ -415,11 +432,16 @@ export function useTimelineEditing({
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
+ isRecordingRef,
],
);
const handleTimelineFileDrop = useCallback(
async (files: File[], placement?: Pick) => {
+ if (isRecordingRef?.current) {
+ showToast("Cannot edit timeline while recording", "error");
+ return;
+ }
const pid = projectIdRef.current;
if (!pid) return;
const uploaded = await uploadProjectFiles(files);
@@ -453,7 +475,14 @@ export function useTimelineEditing({
);
}
},
- [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
+ [
+ activeCompPath,
+ handleTimelineAssetDrop,
+ timelineElements,
+ uploadProjectFiles,
+ isRecordingRef,
+ showToast,
+ ],
);
const handleBlockedTimelineEdit = useCallback(
@@ -468,6 +497,10 @@ export function useTimelineEditing({
const handleTimelineElementSplit = useCallback(
async (element: TimelineElement, splitTime: number) => {
+ if (isRecordingRef?.current) {
+ showToast("Cannot edit timeline while recording", "error");
+ return;
+ }
const pid = projectIdRef.current;
if (!pid) return;
@@ -555,6 +588,7 @@ export function useTimelineEditing({
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
+ isRecordingRef,
],
);
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 (
,
+ 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;
+}