mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
feat(studio): gesture-to-keyframes recording
Gesture recording engine with RAF-based sampling, RDP simplification, velocity-to-ease inference, ghost trail overlay, post-record preview panel, and keyframe commit pipeline. Record button in animation section, R keyboard shortcut, clipboard element context copy with toast, glass toast styling, always-visible render queue actions, and keyframe diamond dedup fix.
This commit is contained in:
@@ -84,6 +84,7 @@
|
||||
"guides/prompting",
|
||||
"guides/hyperframes-vs-remotion",
|
||||
"guides/gsap-animation",
|
||||
"guides/keyframes",
|
||||
"guides/rendering",
|
||||
"guides/remove-background",
|
||||
"guides/hdr",
|
||||
|
||||
@@ -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
|
||||
|
||||
<Note>
|
||||
Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline.
|
||||
</Note>
|
||||
|
||||
## 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
|
||||
|
||||
<Steps>
|
||||
<Step title="Select an element">
|
||||
Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right.
|
||||
</Step>
|
||||
<Step title="Edit property values">
|
||||
Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload.
|
||||
</Step>
|
||||
<Step title="Change the ease">
|
||||
Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live.
|
||||
</Step>
|
||||
<Step title="Verify in Code tab">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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
|
||||
|
||||
<Steps>
|
||||
<Step title="Select an element with x/y motion">
|
||||
The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline.
|
||||
</Step>
|
||||
<Step title="Toggle Arc Motion ON">
|
||||
In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON.
|
||||
</Step>
|
||||
<Step title="Adjust Curviness">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Toggle Auto-Rotate (optional)">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Verify the generated code">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Disable to restore straight motion">
|
||||
Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
|
||||
<Steps>
|
||||
<Step title="Select an element">
|
||||
Click the element you want to animate in the preview.
|
||||
</Step>
|
||||
<Step title="Click Record or press R">
|
||||
In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing.
|
||||
</Step>
|
||||
<Step title="Drag the element">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Stop recording">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Review or undo">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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: <div>
|
||||
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.
|
||||
@@ -1354,10 +1354,25 @@ export function addKeyframeToScript(
|
||||
ease?: string,
|
||||
backfillDefaults?: Record<string, number | string>,
|
||||
): 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);
|
||||
|
||||
@@ -1002,25 +1002,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
});
|
||||
// Stamp data-start / data-duration on GSAP-targeted elements that lack
|
||||
// them so the Studio timeline can discover individual animated elements.
|
||||
// Skip elements whose ancestor already carries timing — stamping them
|
||||
// would override the parent's clip visibility and cause preview/render
|
||||
// parity drift.
|
||||
{
|
||||
const rootComp = resolveRootCompositionElement();
|
||||
const rootDuration = boundDuration > 0 ? boundDuration : 0;
|
||||
const dur = String(rootDuration > 0 ? rootDuration : 1);
|
||||
const seen = new Set<Element>();
|
||||
|
||||
const hasTimedAncestor = (el: HTMLElement): boolean => {
|
||||
let cursor = el.parentElement;
|
||||
while (cursor) {
|
||||
if (cursor.hasAttribute("data-start")) return true;
|
||||
if (cursor === rootComp) return false;
|
||||
cursor = cursor.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Stamp GSAP-targeted elements
|
||||
if (state.capturedTimeline.getChildren) {
|
||||
try {
|
||||
@@ -1030,7 +1017,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!(target instanceof HTMLElement)) continue;
|
||||
if (target === rootComp) continue;
|
||||
if (target.hasAttribute("data-start")) continue;
|
||||
if (hasTimedAncestor(target)) continue;
|
||||
if (seen.has(target)) continue;
|
||||
seen.add(target);
|
||||
target.setAttribute("data-start", "0");
|
||||
@@ -1050,7 +1036,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (el === rootComp) continue;
|
||||
if (el.hasAttribute("data-start")) continue;
|
||||
if (hasTimedAncestor(el)) continue;
|
||||
if (seen.has(el)) continue;
|
||||
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
|
||||
seen.add(el);
|
||||
|
||||
@@ -35,6 +35,9 @@ import type { DomEditSelection } from "./components/editor/domEditing";
|
||||
import { AskAgentModal } from "./components/AskAgentModal";
|
||||
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
|
||||
import { StudioHeader } from "./components/StudioHeader";
|
||||
import { useGestureRecording } from "./hooks/useGestureRecording";
|
||||
import { simplifyGestureSamples } from "./utils/rdpSimplify";
|
||||
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
||||
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
||||
import { StudioPreviewArea } from "./components/StudioPreviewArea";
|
||||
import { StudioRightPanel } from "./components/StudioRightPanel";
|
||||
@@ -128,7 +131,7 @@ export function StudioApp() {
|
||||
return !v;
|
||||
});
|
||||
}, []);
|
||||
const { appToast, showToast } = useToast();
|
||||
const { appToast, showToast, dismissToast } = useToast();
|
||||
const panelLayout = usePanelLayout({
|
||||
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
|
||||
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
|
||||
@@ -306,7 +309,9 @@ export function StudioApp() {
|
||||
onResetKeyframes: () => resetKeyframesRef.current(),
|
||||
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
||||
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
|
||||
onToggleRecording: () => handleToggleRecordingRef.current(),
|
||||
});
|
||||
const handleToggleRecordingRef = useRef(() => {});
|
||||
const selectSidebarTabStable = useCallback(
|
||||
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
|
||||
[],
|
||||
@@ -400,6 +405,76 @@ export function StudioApp() {
|
||||
|
||||
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
||||
|
||||
// Gesture recording
|
||||
const gestureRecording = useGestureRecording();
|
||||
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
|
||||
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const recordingStartTimeRef = useRef(0);
|
||||
|
||||
const stopAndCommitRecording = useCallback(() => {
|
||||
clearInterval(recordingAutoStopRef.current);
|
||||
gestureRecording.stopRecording();
|
||||
const store = usePlayerStore.getState();
|
||||
store.setIsPlaying(false);
|
||||
|
||||
const sel = domEditSession.domEditSelection;
|
||||
const animId = domEditSession.selectedGsapAnimations?.[0]?.id;
|
||||
const samples = gestureRecording.samples;
|
||||
const duration = gestureRecording.recordingDuration;
|
||||
|
||||
if (sel && animId && samples.length > 2 && duration > 0) {
|
||||
const simplified = simplifyGestureSamples(samples, duration, 5);
|
||||
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
|
||||
for (const pct of sortedPcts) {
|
||||
const props = simplified.get(pct);
|
||||
if (!props) continue;
|
||||
for (const [prop, value] of Object.entries(props)) {
|
||||
domEditSession.handleGsapAddKeyframe?.(animId, pct, prop, value);
|
||||
}
|
||||
}
|
||||
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
|
||||
}
|
||||
|
||||
store.requestSeek(recordingStartTimeRef.current);
|
||||
gestureRecording.clearSamples();
|
||||
setGestureState("idle");
|
||||
}, [gestureRecording, domEditSession, showToast]);
|
||||
|
||||
const handleToggleRecording = useCallback(() => {
|
||||
if (gestureState === "recording") {
|
||||
stopAndCommitRecording();
|
||||
return;
|
||||
}
|
||||
const sel = domEditSession.domEditSelection;
|
||||
if (!sel) {
|
||||
showToast("Select an element first", "error");
|
||||
return;
|
||||
}
|
||||
const iframe = previewIframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
recordingStartTimeRef.current = store.currentTime;
|
||||
gestureRecording.startRecording(sel.element, iframe);
|
||||
store.setIsPlaying(true);
|
||||
setGestureState("recording");
|
||||
|
||||
clearInterval(recordingAutoStopRef.current);
|
||||
recordingAutoStopRef.current = setInterval(() => {
|
||||
const { currentTime: t, duration: d } = usePlayerStore.getState();
|
||||
if (d > 0 && t >= d - 0.05) {
|
||||
stopAndCommitRecording();
|
||||
}
|
||||
}, 100);
|
||||
}, [
|
||||
gestureState,
|
||||
gestureRecording,
|
||||
domEditSession.domEditSelection,
|
||||
showToast,
|
||||
stopAndCommitRecording,
|
||||
]);
|
||||
handleToggleRecordingRef.current = handleToggleRecording;
|
||||
|
||||
const handlePreviewIframeRef = useCallback(
|
||||
(iframe: HTMLIFrameElement | null) => {
|
||||
previewIframeRef.current = iframe;
|
||||
@@ -554,6 +629,9 @@ export function StudioApp() {
|
||||
setActiveBlockParams(null);
|
||||
panelLayout.setRightPanelTab("design");
|
||||
}}
|
||||
recordingState={gestureState}
|
||||
recordingDuration={gestureRecording.recordingDuration}
|
||||
onToggleRecording={handleToggleRecording}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -585,8 +663,24 @@ export function StudioApp() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{gestureState === "recording" && previewIframe && (
|
||||
<GestureTrailOverlay
|
||||
samples={gestureRecording.samples}
|
||||
canvasRect={(() => {
|
||||
const r = previewIframe.getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
})()}
|
||||
mode="recording"
|
||||
/>
|
||||
)}
|
||||
{dragOverlay.active && <StudioGlobalDragOverlay />}
|
||||
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
|
||||
{appToast && (
|
||||
<StudioToast
|
||||
message={appToast.message}
|
||||
tone={appToast.tone}
|
||||
onDismiss={dismissToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DomEditProvider>
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
export type RecordingState = "idle" | "recording" | "preview";
|
||||
|
||||
export interface StudioHeaderProps {
|
||||
captureFrameHref: string;
|
||||
captureFrameFilename: string;
|
||||
|
||||
@@ -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 ? (
|
||||
<MotionPanel
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
interface StudioToastProps {
|
||||
message: string;
|
||||
tone?: "error" | "info";
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function StudioToast({ message, tone }: StudioToastProps) {
|
||||
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
const isError = tone === "error";
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
||||
tone === "error"
|
||||
? "bg-red-900/90 border-red-700/50 text-red-200"
|
||||
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
||||
}`}
|
||||
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
|
||||
onClick={onDismiss}
|
||||
role={onDismiss ? "button" : undefined}
|
||||
style={onDismiss ? { cursor: "pointer" } : undefined}
|
||||
>
|
||||
{message}
|
||||
<div
|
||||
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
|
||||
style={{
|
||||
background: isError
|
||||
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
|
||||
: "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
|
||||
backdropFilter: "blur(16px) saturate(1.6)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(1.6)",
|
||||
border: `1px solid ${isError ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
|
||||
boxShadow: [
|
||||
"0 8px 32px rgba(0,0,0,0.35)",
|
||||
`inset 0 1px 0 ${isError ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
|
||||
`inset 0 -1px 0 rgba(0,0,0,0.15)`,
|
||||
].join(", "),
|
||||
}}
|
||||
>
|
||||
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M2 2l6 6M8 2l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,22 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
||||
flatAnim.id,
|
||||
Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined,
|
||||
);
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||
const pct =
|
||||
elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
if (pct > 1 && pct < 99) {
|
||||
const runtimeValues = readRuntimeKeyframeValues(
|
||||
session.previewIframeRef?.current ?? null,
|
||||
sel,
|
||||
[],
|
||||
);
|
||||
for (const [prop, val] of Object.entries(runtimeValues)) {
|
||||
session.handleGsapAddKeyframe(flatAnim.id, pct, prop, val);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
session.handleGsapAddAnimation("to");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import type { GestureSample } from "../../hooks/useGestureRecording";
|
||||
import { simplifyGestureSamples } from "../../utils/rdpSimplify";
|
||||
import { inferAllEases } from "../../utils/inferEase";
|
||||
import { SliderControl } from "./propertyPanelPrimitives";
|
||||
import { LABEL } from "./propertyPanelHelpers";
|
||||
import { P } from "./panelTokens";
|
||||
|
||||
interface GesturePreviewPanelProps {
|
||||
samples: GestureSample[];
|
||||
totalDuration: number;
|
||||
onCommit: (keyframes: Map<number, Record<string, number>>, eases: Map<number, string>) => void;
|
||||
onDiscard: () => void;
|
||||
onReRecord: () => void;
|
||||
onSimplifiedChange?: (simplified: Map<number, Record<string, number>>) => void;
|
||||
}
|
||||
|
||||
export const GesturePreviewPanel = memo(function GesturePreviewPanel({
|
||||
samples,
|
||||
totalDuration,
|
||||
onCommit,
|
||||
onDiscard,
|
||||
onReRecord,
|
||||
onSimplifiedChange,
|
||||
}: GesturePreviewPanelProps) {
|
||||
const [epsilon, setEpsilon] = useState(5);
|
||||
|
||||
const simplified = useMemo(() => {
|
||||
const result = simplifyGestureSamples(samples, totalDuration, epsilon);
|
||||
onSimplifiedChange?.(result);
|
||||
return result;
|
||||
}, [samples, totalDuration, epsilon, onSimplifiedChange]);
|
||||
|
||||
const eases = useMemo(() => {
|
||||
const pcts = Array.from(simplified.keys()).sort((a, b) => a - b);
|
||||
return inferAllEases(
|
||||
samples.map((s) => ({
|
||||
time: s.time,
|
||||
properties: s.properties,
|
||||
})),
|
||||
pcts,
|
||||
totalDuration,
|
||||
);
|
||||
}, [samples, simplified, totalDuration]);
|
||||
|
||||
const keyframeCount = simplified.size;
|
||||
const propertyList = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const props of simplified.values()) {
|
||||
for (const k of Object.keys(props)) keys.add(k);
|
||||
}
|
||||
return Array.from(keys);
|
||||
}, [simplified]);
|
||||
|
||||
const handleEpsilonChange = useCallback((v: number) => {
|
||||
setEpsilon(Math.round(v * 10) / 10);
|
||||
}, []);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
onCommit(simplified, eases);
|
||||
}, [onCommit, simplified, eases]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-neutral-800 bg-neutral-900/80 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-panel-text-1">Gesture Recording</span>
|
||||
<span className="text-[10px] text-panel-text-3">{keyframeCount} keyframes</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className={LABEL}>Smoothing</span>
|
||||
<SliderControl
|
||||
value={epsilon}
|
||||
min={0.5}
|
||||
max={30}
|
||||
step={0.5}
|
||||
displayValue={epsilon.toFixed(1)}
|
||||
formatDisplayValue={(v) => v.toFixed(1)}
|
||||
onCommit={handleEpsilonChange}
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-panel-text-4">
|
||||
<span>Detailed</span>
|
||||
<span>Smooth</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{propertyList.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className={LABEL}>Recorded properties</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{propertyList.map((prop) => (
|
||||
<span
|
||||
key={prop}
|
||||
className="rounded bg-panel-input px-1.5 py-0.5 text-[10px] font-medium text-panel-text-2"
|
||||
>
|
||||
{prop}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eases.size > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className={LABEL}>Inferred eases</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(eases.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([pct, ease]) => (
|
||||
<span
|
||||
key={pct}
|
||||
className="rounded bg-panel-input px-1.5 py-0.5 text-[10px] text-panel-text-3"
|
||||
>
|
||||
{pct}% → {ease}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCommit}
|
||||
className="flex-1 rounded-md py-1.5 text-[11px] font-semibold transition-colors"
|
||||
style={{ background: P.accent, color: "#09090B" }}
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReRecord}
|
||||
className="flex-1 rounded-md border border-neutral-700 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
Re-record
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscard}
|
||||
className="rounded-md border border-neutral-700 px-3 py-1.5 text-[11px] font-medium text-red-400 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import type { GestureSample } from "../../hooks/useGestureRecording";
|
||||
|
||||
interface GestureTrailOverlayProps {
|
||||
samples: GestureSample[];
|
||||
simplifiedPoints?: Map<number, Record<string, number>>;
|
||||
canvasRect: { left: number; top: number; width: number; height: number };
|
||||
mode: "recording" | "preview";
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export const GestureTrailOverlay = memo(function GestureTrailOverlay({
|
||||
samples,
|
||||
simplifiedPoints,
|
||||
canvasRect,
|
||||
mode,
|
||||
accentColor = "#3CE6AC",
|
||||
}: GestureTrailOverlayProps) {
|
||||
const trailPoints = useMemo(() => {
|
||||
if (samples.length === 0) return "";
|
||||
return samples
|
||||
.filter((s) => s.properties.x != null && s.properties.y != null)
|
||||
.map((s) => `${s.properties.x},${s.properties.y}`)
|
||||
.join(" ");
|
||||
}, [samples]);
|
||||
|
||||
const simplifiedPath = useMemo(() => {
|
||||
if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
|
||||
const pts: Array<{ x: number; y: number; pct: number }> = [];
|
||||
for (const [pct, props] of simplifiedPoints) {
|
||||
if (props.x != null && props.y != null) {
|
||||
pts.push({ x: props.x, y: props.y, pct });
|
||||
}
|
||||
}
|
||||
pts.sort((a, b) => a.pct - b.pct);
|
||||
if (pts.length === 0) return "";
|
||||
return pts.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
}, [simplifiedPoints]);
|
||||
|
||||
const diamondPositions = useMemo(() => {
|
||||
if (!simplifiedPoints || simplifiedPoints.size === 0) return [];
|
||||
const pts: Array<{ x: number; y: number; pct: number }> = [];
|
||||
for (const [pct, props] of simplifiedPoints) {
|
||||
if (props.x != null && props.y != null) {
|
||||
pts.push({ x: props.x, y: props.y, pct });
|
||||
}
|
||||
}
|
||||
return pts.sort((a, b) => a.pct - b.pct);
|
||||
}, [simplifiedPoints]);
|
||||
|
||||
if (samples.length < 2 && !simplifiedPoints) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: canvasRect.left,
|
||||
top: canvasRect.top,
|
||||
width: canvasRect.width,
|
||||
height: canvasRect.height,
|
||||
}}
|
||||
viewBox={`0 0 ${canvasRect.width} ${canvasRect.height}`}
|
||||
>
|
||||
{mode === "recording" && trailPoints && (
|
||||
<polyline
|
||||
points={trailPoints}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "preview" && (
|
||||
<>
|
||||
{trailPoints && (
|
||||
<polyline
|
||||
points={trailPoints}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
strokeDasharray="4 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{simplifiedPath && (
|
||||
<polyline
|
||||
points={simplifiedPath}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
{diamondPositions.map((pt) => (
|
||||
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
|
||||
<rect
|
||||
x="-4"
|
||||
y="-4"
|
||||
width="8"
|
||||
height="8"
|
||||
rx="1"
|
||||
transform="rotate(45)"
|
||||
fill={accentColor}
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -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<ReturnType<typeof setTimeout>>(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({
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAskAgent}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
||||
title={copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}
|
||||
onClick={() => {
|
||||
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"}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5" />
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -566,6 +639,31 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{onToggleRecording && (
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
{recordingState === "recording" ? (
|
||||
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
|
||||
) : (
|
||||
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
{recordingState === "recording"
|
||||
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s (R)`
|
||||
: "Record gesture (R)"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditableSections && (
|
||||
<StyleSections
|
||||
projectId={projectId}
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface PropertyPanelProps {
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
recordingState?: "idle" | "recording" | "preview";
|
||||
recordingDuration?: number;
|
||||
onToggleRecording?: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -369,15 +369,7 @@ export function StyleSections({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Fill"
|
||||
icon={<Palette size={15} />}
|
||||
accessory={
|
||||
<div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
|
||||
{preferredFillMode}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Section title="Fill" icon={<Palette size={15} />}>
|
||||
<div className="space-y-4">
|
||||
<SegmentedControl
|
||||
disabled={styleEditingDisabled}
|
||||
|
||||
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{hovered && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={isComplete ? handleDownload : undefined}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isComplete
|
||||
? "text-panel-text-5 hover:text-panel-accent"
|
||||
: "text-panel-text-5/30 pointer-events-none"
|
||||
}`}
|
||||
title={isComplete ? "Download" : "Rendering..."}
|
||||
disabled={!isComplete}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -261,13 +261,9 @@ async function commitGsapPositionFromDrag(
|
||||
callbacks,
|
||||
clearOffset,
|
||||
);
|
||||
} else if (anim.method === "from") {
|
||||
await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
||||
} else if (anim.method === "fromTo") {
|
||||
await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
|
||||
} else {
|
||||
// Flat to()/set() — convert to keyframes first so the drag position
|
||||
// is captured at the current seek time, not just the tween endpoint.
|
||||
// Flat to()/from()/fromTo()/set() — convert to keyframes first so the
|
||||
// drag position is captured at the current seek time as a new keyframe.
|
||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||
await commitFlatViaKeyframes(
|
||||
selection,
|
||||
@@ -334,65 +330,6 @@ async function commitFlatViaKeyframes(
|
||||
);
|
||||
}
|
||||
|
||||
async function commitFromPosition(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
delta: { x: number; y: number },
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload: () => void,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<number>();
|
||||
entry.keyframes = entry.keyframes.filter((kf) => {
|
||||
if (seen.has(kf.percentage)) return false;
|
||||
seen.add(kf.percentage);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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<Window | null>(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 ──
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface UseDomEditCommitsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
reloadPreview: () => void;
|
||||
|
||||
onCommitAnimatedOffset?: (selection: DomEditSelection, next: { x: number; y: number }) => void;
|
||||
|
||||
// From useDomSelection
|
||||
domEditSelection: DomEditSelection | null;
|
||||
applyDomSelection: (
|
||||
@@ -130,6 +132,7 @@ export function useDomEditCommits({
|
||||
projectId,
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
onCommitAnimatedOffset,
|
||||
domEditSelection,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
@@ -321,13 +324,16 @@ export function useDomEditCommits({
|
||||
const handleDomPathOffsetCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
||||
applyStudioPathOffset(selection.element, next);
|
||||
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
|
||||
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
||||
onCommitAnimatedOffset?.(selection, next);
|
||||
return;
|
||||
}
|
||||
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
||||
label: "Move layer",
|
||||
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
[commitPositionPatchToHtml, previewIframeRef],
|
||||
[commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset],
|
||||
);
|
||||
|
||||
const handleDomGroupPathOffsetCommit = useCallback(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
interface GestureCommitParams {
|
||||
commitMutation: (
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
options: { label: string; softReload: boolean },
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useGestureCommit({ commitMutation }: GestureCommitParams) {
|
||||
const commitGestureKeyframes = useCallback(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
keyframes: Map<number, Record<string, number>>,
|
||||
eases: Map<number, string>,
|
||||
) => {
|
||||
const sortedPcts = Array.from(keyframes.keys()).sort((a, b) => a - b);
|
||||
if (sortedPcts.length < 2) return;
|
||||
|
||||
await commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes" as const, animationId },
|
||||
{ label: "Convert to keyframes for gesture recording", softReload: false },
|
||||
);
|
||||
|
||||
for (const pct of sortedPcts) {
|
||||
const props = keyframes.get(pct);
|
||||
if (!props) continue;
|
||||
|
||||
for (const [prop, value] of Object.entries(props)) {
|
||||
await commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe" as const,
|
||||
animationId,
|
||||
percentage: pct,
|
||||
property: prop,
|
||||
value,
|
||||
ease: eases.get(pct),
|
||||
},
|
||||
{
|
||||
label: `Record keyframe at ${pct}%`,
|
||||
softReload: pct === sortedPcts[sortedPcts.length - 1],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
return { commitGestureKeyframes };
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface GestureSample {
|
||||
time: number;
|
||||
properties: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Modifiers {
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
interface AccumulatedState {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
// ── Pure mapping function ──
|
||||
|
||||
/**
|
||||
* Maps raw pointer deltas, scroll delta, and modifier state to GSAP property
|
||||
* values. Pure and testable without DOM.
|
||||
*
|
||||
* Modifier-to-property mapping:
|
||||
* - Plain drag -> x, y (px delta from start)
|
||||
* - Scroll wheel -> z (accumulated scroll delta)
|
||||
* - Shift + drag -> rotationX (vert * 0.5), rotationY (horiz * 0.5)
|
||||
* - Alt + drag horiz -> rotation (horiz * 0.5)
|
||||
* - Cmd/Ctrl + drag V -> opacity (vert delta mapped 0-1, clamped)
|
||||
* - Cmd/Ctrl + scroll -> scale (scroll * 0.01, accumulated from 1.0)
|
||||
*/
|
||||
export function resolveGestureProperties(
|
||||
dx: number,
|
||||
dy: number,
|
||||
scrollDelta: number,
|
||||
modifiers: Modifiers,
|
||||
accumulatedState: AccumulatedState,
|
||||
): {
|
||||
properties: Record<string, number>;
|
||||
nextState: AccumulatedState;
|
||||
} {
|
||||
const properties: Record<string, number> = {};
|
||||
let nextOpacity = accumulatedState.opacity;
|
||||
let nextScale = accumulatedState.scale;
|
||||
let nextZ = accumulatedState.z;
|
||||
|
||||
if (modifiers.meta) {
|
||||
// Cmd/Ctrl held: vertical drag -> opacity, scroll -> scale
|
||||
nextOpacity = Math.max(0, Math.min(1, accumulatedState.opacity - dy * 0.005));
|
||||
properties.opacity = nextOpacity;
|
||||
|
||||
if (scrollDelta !== 0) {
|
||||
nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01);
|
||||
properties.scale = nextScale;
|
||||
}
|
||||
} else if (modifiers.shift) {
|
||||
// Shift held: drag -> rotationX/rotationY
|
||||
properties.rotationX = dy * 0.5;
|
||||
properties.rotationY = dx * 0.5;
|
||||
} else if (modifiers.alt) {
|
||||
// Alt held: horizontal drag -> rotation
|
||||
properties.rotation = dx * 0.5;
|
||||
} else {
|
||||
// Plain: drag -> x/y
|
||||
properties.x = dx;
|
||||
properties.y = dy;
|
||||
}
|
||||
|
||||
// Scroll without Cmd/Ctrl -> z accumulation
|
||||
if (!modifiers.meta && scrollDelta !== 0) {
|
||||
nextZ = accumulatedState.z + scrollDelta;
|
||||
properties.z = nextZ;
|
||||
}
|
||||
|
||||
return {
|
||||
properties,
|
||||
nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useGestureRecording() {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [samples, setSamples] = useState<GestureSample[]>([]);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
|
||||
// Refs for high-frequency data (never useState for 60fps input)
|
||||
const pointerRef = useRef({ x: 0, y: 0 });
|
||||
const startPointerRef = useRef({ x: 0, y: 0 });
|
||||
const scrollDeltaRef = useRef(0);
|
||||
const modifiersRef = useRef<Modifiers>({ shift: false, alt: false, meta: false });
|
||||
const accumulatedRef = useRef<AccumulatedState>({ opacity: 1, scale: 1, z: 0 });
|
||||
|
||||
const rafIdRef = useRef(0);
|
||||
const samplesRef = useRef<GestureSample[]>([]);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const startRecording = useCallback(
|
||||
(element: HTMLElement, _iframeEl: HTMLIFrameElement) => {
|
||||
if (isRecording) return;
|
||||
|
||||
// Reset state
|
||||
samplesRef.current = [];
|
||||
setSamples([]);
|
||||
setRecordingDuration(0);
|
||||
accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
|
||||
scrollDeltaRef.current = 0;
|
||||
|
||||
// Listeners target the overlay element (same div DomEditOverlay uses).
|
||||
// Passive so they never block scrolling.
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
pointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
scrollDeltaRef.current += e.deltaY;
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
modifiersRef.current = {
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
meta: e.metaKey || e.ctrlKey,
|
||||
};
|
||||
};
|
||||
|
||||
element.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
element.addEventListener("wheel", handleWheel, { passive: true });
|
||||
document.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
document.addEventListener("keyup", handleKeyUp, { passive: true });
|
||||
|
||||
// Capture initial pointer position from the first move; until then delta is 0.
|
||||
// We set it explicitly so the first RAF tick sees zero deltas.
|
||||
startPointerRef.current = { ...pointerRef.current };
|
||||
|
||||
const startMs = performance.now();
|
||||
|
||||
// Store pointer start once the first pointermove fires
|
||||
let startCaptured = false;
|
||||
const captureStart = (e: PointerEvent) => {
|
||||
if (!startCaptured) {
|
||||
startPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
startCaptured = true;
|
||||
}
|
||||
};
|
||||
element.addEventListener("pointermove", captureStart, { passive: true, once: true });
|
||||
|
||||
// RAF loop — sample at ~60fps
|
||||
const tick = () => {
|
||||
const now = performance.now();
|
||||
const time = (now - startMs) / 1000;
|
||||
|
||||
const dx = pointerRef.current.x - startPointerRef.current.x;
|
||||
const dy = pointerRef.current.y - startPointerRef.current.y;
|
||||
const scrollDelta = scrollDeltaRef.current;
|
||||
|
||||
const { properties, nextState } = resolveGestureProperties(
|
||||
dx,
|
||||
dy,
|
||||
scrollDelta,
|
||||
modifiersRef.current,
|
||||
accumulatedRef.current,
|
||||
);
|
||||
|
||||
accumulatedRef.current = nextState;
|
||||
// Reset scroll delta after consuming it — it's accumulated per-frame
|
||||
scrollDeltaRef.current = 0;
|
||||
|
||||
const sample: GestureSample = { time, properties };
|
||||
samplesRef.current.push(sample);
|
||||
setRecordingDuration(time);
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
setIsRecording(true);
|
||||
rafIdRef.current = requestAnimationFrame(tick);
|
||||
|
||||
// Store cleanup so stopRecording can tear everything down
|
||||
cleanupRef.current = () => {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
element.removeEventListener("pointermove", handlePointerMove);
|
||||
element.removeEventListener("wheel", handleWheel);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
element.removeEventListener("pointermove", captureStart);
|
||||
};
|
||||
},
|
||||
[isRecording],
|
||||
);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (!isRecording) return;
|
||||
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
|
||||
// Freeze samples into React state
|
||||
const frozen = samplesRef.current.slice();
|
||||
setSamples(frozen);
|
||||
setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1].time : 0);
|
||||
setIsRecording(false);
|
||||
}, [isRecording]);
|
||||
|
||||
const clearSamples = useCallback(() => {
|
||||
samplesRef.current = [];
|
||||
setSamples([]);
|
||||
setRecordingDuration(0);
|
||||
accumulatedRef.current = { opacity: 1, scale: 1, z: 0 };
|
||||
scrollDeltaRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
isRecording,
|
||||
samples,
|
||||
recordingDuration,
|
||||
clearSamples,
|
||||
};
|
||||
}
|
||||
@@ -201,12 +201,14 @@ export function useGsapScriptCommits({
|
||||
});
|
||||
}
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (result.after != null) {
|
||||
onFileContentChanged?.(targetPath, result.after);
|
||||
}
|
||||
|
||||
if (options.skipReload) return;
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (result.parsed?.animations) {
|
||||
updateKeyframeCacheFromParsed(
|
||||
result.parsed.animations,
|
||||
@@ -216,8 +218,6 @@ export function useGsapScriptCommits({
|
||||
);
|
||||
}
|
||||
|
||||
if (options.skipReload) return;
|
||||
|
||||
options.beforeReload?.();
|
||||
|
||||
if (options.softReload && result.scriptText) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const x2 = (kf.percentage / 100) * clipWidthPx;
|
||||
return (
|
||||
<div
|
||||
key={`line-${prev.percentage}-${kf.percentage}`}
|
||||
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: x1,
|
||||
@@ -118,7 +118,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
);
|
||||
})}
|
||||
|
||||
{sorted.map((kf) => {
|
||||
{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 (
|
||||
<button
|
||||
key={kf.percentage}
|
||||
key={`${i}-${kf.percentage}`}
|
||||
type="button"
|
||||
className="absolute"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Velocity curve analyzer — matches gesture recording segments to GSAP ease presets.
|
||||
*
|
||||
* Pure utility: no React, no DOM, no Studio imports.
|
||||
*/
|
||||
|
||||
const DEFAULT_EASE = "power2.inOut";
|
||||
const SAMPLE_COUNT = 21; // 0, 0.05, 0.10, ..., 1.0
|
||||
|
||||
function buildReferenceCurve(fn: (t: number) => number): number[] {
|
||||
const curve: number[] = [];
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
curve.push(fn(i / (SAMPLE_COUNT - 1)));
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
export const EASE_REFERENCE_CURVES: Record<string, number[]> = {
|
||||
linear: buildReferenceCurve((t) => t),
|
||||
|
||||
"power1.out": buildReferenceCurve((t) => 1 - (1 - t) ** 2),
|
||||
|
||||
"power2.out": buildReferenceCurve((t) => 1 - (1 - t) ** 3),
|
||||
|
||||
"power3.out": buildReferenceCurve((t) => 1 - (1 - t) ** 4),
|
||||
|
||||
"power2.in": buildReferenceCurve((t) => t ** 3),
|
||||
|
||||
"power2.inOut": buildReferenceCurve((t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2)),
|
||||
|
||||
"power3.inOut": buildReferenceCurve((t) => (t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2)),
|
||||
|
||||
"back.out(1.7)": buildReferenceCurve((t) => {
|
||||
const c1 = 1.7;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
|
||||
}),
|
||||
|
||||
"expo.out": buildReferenceCurve((t) => (t === 1 ? 1 : 1 - 2 ** (-10 * t))),
|
||||
};
|
||||
|
||||
/**
|
||||
* Linearly interpolate between two values.
|
||||
*/
|
||||
function lerp(a: number, b: number, frac: number): number {
|
||||
return a + (b - a) * frac;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resample an arbitrary set of (normalized) time/value pairs to SAMPLE_COUNT
|
||||
* evenly-spaced points via linear interpolation.
|
||||
*/
|
||||
function resampleToGrid(normalized: Array<{ t: number; v: number }>): number[] {
|
||||
const result: number[] = [];
|
||||
|
||||
for (let i = 0; i < SAMPLE_COUNT; i++) {
|
||||
const target = i / (SAMPLE_COUNT - 1);
|
||||
|
||||
// Find the two surrounding samples
|
||||
let lo = 0;
|
||||
let hi = normalized.length - 1;
|
||||
for (let j = 0; j < normalized.length - 1; j++) {
|
||||
if (normalized[j].t <= target && normalized[j + 1].t >= target) {
|
||||
lo = j;
|
||||
hi = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const tLo = normalized[lo].t;
|
||||
const tHi = normalized[hi].t;
|
||||
const span = tHi - tLo;
|
||||
const frac = span === 0 ? 0 : (target - tLo) / span;
|
||||
result.push(lerp(normalized[lo].v, normalized[hi].v, frac));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of squared differences between two same-length arrays.
|
||||
*/
|
||||
function ssd(a: number[], b: number[]): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const d = a[i] - b[i];
|
||||
sum += d * d;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the best-matching GSAP ease name for a segment of raw gesture samples.
|
||||
*
|
||||
* @param rawSamples - Time/value pairs from the gesture recording
|
||||
* @param startTime - Segment start (inclusive)
|
||||
* @param endTime - Segment end (inclusive)
|
||||
* @returns GSAP ease name (e.g. "power2.out")
|
||||
*/
|
||||
export function inferSegmentEase(
|
||||
rawSamples: Array<{ time: number; value: number }>,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): string {
|
||||
const segment = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
|
||||
|
||||
if (segment.length < 3) return DEFAULT_EASE;
|
||||
|
||||
const tMin = segment[0].time;
|
||||
const tMax = segment[segment.length - 1].time;
|
||||
const tRange = tMax - tMin;
|
||||
|
||||
const vMin = Math.min(...segment.map((s) => s.value));
|
||||
const vMax = Math.max(...segment.map((s) => s.value));
|
||||
const vRange = vMax - vMin;
|
||||
|
||||
// Degenerate segment — no meaningful motion
|
||||
if (tRange === 0 || vRange === 0) return DEFAULT_EASE;
|
||||
|
||||
// Determine direction: if value decreases over time, flip the progress curve
|
||||
const increasing = segment[segment.length - 1].value >= segment[0].value;
|
||||
|
||||
const normalized = segment.map((s) => ({
|
||||
t: (s.time - tMin) / tRange,
|
||||
v: increasing ? (s.value - vMin) / vRange : 1 - (s.value - vMin) / vRange,
|
||||
}));
|
||||
|
||||
const resampled = resampleToGrid(normalized);
|
||||
|
||||
let bestEase = DEFAULT_EASE;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const [name, ref] of Object.entries(EASE_REFERENCE_CURVES)) {
|
||||
const score = ssd(resampled, ref);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestEase = name;
|
||||
}
|
||||
}
|
||||
|
||||
return bestEase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer eases for all segments between consecutive simplified percentages.
|
||||
*
|
||||
* @param rawSamples - Time/properties pairs from the gesture recording
|
||||
* @param simplifiedPercentages - Sorted array of keyframe percentages (0-100)
|
||||
* @param totalDuration - Total gesture duration in the same unit as rawSamples[].time
|
||||
* @returns Map of percentage → ease name (ease applies to the segment ENDING at that percentage)
|
||||
*/
|
||||
export function inferAllEases(
|
||||
rawSamples: Array<{ time: number; properties: Record<string, number> }>,
|
||||
simplifiedPercentages: number[],
|
||||
totalDuration: number,
|
||||
): Map<number, string> {
|
||||
const result = new Map<number, string>();
|
||||
|
||||
if (simplifiedPercentages.length < 2) return result;
|
||||
|
||||
for (let i = 1; i < simplifiedPercentages.length; i++) {
|
||||
const pctStart = simplifiedPercentages[i - 1];
|
||||
const pctEnd = simplifiedPercentages[i];
|
||||
|
||||
const startTime = (pctStart / 100) * totalDuration;
|
||||
const endTime = (pctEnd / 100) * totalDuration;
|
||||
|
||||
// Find the primary moving property (largest absolute delta in this segment)
|
||||
const segmentSamples = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
|
||||
|
||||
if (segmentSamples.length < 2) {
|
||||
result.set(pctEnd, DEFAULT_EASE);
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstSample = segmentSamples[0];
|
||||
const lastSample = segmentSamples[segmentSamples.length - 1];
|
||||
const allProps = Object.keys(firstSample.properties);
|
||||
|
||||
let primaryProp = allProps[0];
|
||||
let maxDelta = 0;
|
||||
|
||||
for (const prop of allProps) {
|
||||
const delta = Math.abs(
|
||||
(lastSample.properties[prop] ?? 0) - (firstSample.properties[prop] ?? 0),
|
||||
);
|
||||
if (delta > maxDelta) {
|
||||
maxDelta = delta;
|
||||
primaryProp = prop;
|
||||
}
|
||||
}
|
||||
|
||||
const flatSamples = segmentSamples.map((s) => ({
|
||||
time: s.time,
|
||||
value: s.properties[primaryProp] ?? 0,
|
||||
}));
|
||||
|
||||
result.set(pctEnd, inferSegmentEase(flatSamples, startTime, endTime));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Ramer-Douglas-Peucker simplification for time-series data.
|
||||
*
|
||||
* Used to reduce gesture recording samples into a minimal set of keyframes
|
||||
* that approximate the original curve within a configurable tolerance.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1D time-series simplification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Perpendicular distance from point (t, v) to the line segment between
|
||||
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
|
||||
* distance from the point to the interpolated value on the line.
|
||||
*/
|
||||
function perpendicularDistance(
|
||||
t: number,
|
||||
v: number,
|
||||
t1: number,
|
||||
v1: number,
|
||||
t2: number,
|
||||
v2: number,
|
||||
): number {
|
||||
// Degenerate case: start and end share the same time
|
||||
if (t2 === t1) return Math.abs(v - v1);
|
||||
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
|
||||
return Math.abs(v - interpolated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Ramer-Douglas-Peucker on 1D time-series data.
|
||||
*
|
||||
* Each point is treated as (time, value) in 2D space. Returns the minimal
|
||||
* subset of input points that approximates the curve within `epsilon`.
|
||||
*
|
||||
* - `epsilon = 0` returns all points (no simplification).
|
||||
* - A large `epsilon` returns just the first and last points.
|
||||
* - Empty or single-point input is returned unchanged.
|
||||
*/
|
||||
export function simplifyTimeSeries(
|
||||
points: Array<{ time: number; value: number }>,
|
||||
epsilon: number,
|
||||
): Array<{ time: number; value: number }> {
|
||||
if (points.length <= 2) return points;
|
||||
if (epsilon <= 0) return points;
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
let maxDist = 0;
|
||||
let maxIndex = 0;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const d = perpendicularDistance(
|
||||
points[i].time,
|
||||
points[i].value,
|
||||
first.time,
|
||||
first.value,
|
||||
last.time,
|
||||
last.value,
|
||||
);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilon) {
|
||||
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
|
||||
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
|
||||
// left includes maxIndex, right starts with maxIndex — drop the duplicate
|
||||
return left.slice(0, -1).concat(right);
|
||||
}
|
||||
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-property gesture simplification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simplify gesture recording samples into percentage-keyed keyframes.
|
||||
*
|
||||
* Runs `simplifyTimeSeries` independently per property across all samples,
|
||||
* then merges the retained time points into a single Map keyed by percentage
|
||||
* of `totalDuration` (0–100, rounded to 1 decimal).
|
||||
*
|
||||
* Independent per-property simplification means that complex motion on one
|
||||
* property (e.g. `x`) does not force extra keyframes on a simpler property
|
||||
* (e.g. `opacity`).
|
||||
*
|
||||
* At each retained percentage the output contains all properties interpolated
|
||||
* at that time — not just the property that caused the time point to survive.
|
||||
*/
|
||||
export function simplifyGestureSamples(
|
||||
samples: Array<{ time: number; properties: Record<string, number> }>,
|
||||
totalDuration: number,
|
||||
epsilon: number,
|
||||
): Map<number, Record<string, number>> {
|
||||
if (samples.length === 0) return new Map();
|
||||
if (totalDuration <= 0) return new Map();
|
||||
|
||||
// Collect all property keys present across samples
|
||||
const propertyKeys = new Set<string>();
|
||||
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<number>();
|
||||
|
||||
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<number, Record<string, number>>();
|
||||
|
||||
for (const t of sortedTimes) {
|
||||
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
|
||||
const props: Record<string, number> = {};
|
||||
|
||||
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<string, number> }>,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user