feat(studio): keyframe system — parser, runtime, timeline UI, design panel, gesture recording (#1311)

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): runtime hooks — global time compiler + keyframe runtime

Add the runtime bridge layer: global time compilation (tween % → clip %),
soft reload after mutations, runtime keyframe preview, and keyframe
commit helper.

* feat(studio): keyframe cache + commit hooks

Add hooks for keyframe cache population (tween → clip-relative %),
mutation dispatch, keyframe snapping, and audio beat detection.

* feat(studio): timeline UI — dopesheet diamonds + keyboard nav

Add dopesheet strip with diamond keyframe indicators, timeline property
rows, keyboard navigation (J/Shift+J/Delete/K), and feature gate
(STUDIO_KEYFRAMES_ENABLED defaults to false).

* feat(studio): design panel — arc controls + ease curve + stagger

Add arc path controls (curviness slider, auto-rotate), motion path SVG
overlay, ease curve visualization, stagger controls, and expanded
animation card. Includes border-radius editor dependency from #1217.

* feat(studio): gesture recording core

Add gesture recording engine with RAF sampling, modifier key property
mapping (Shift→rotationXY, Alt→rotation, Cmd→opacity),
Ramer-Douglas-Peucker simplification, and ghost trail SVG overlay.

* fix(studio): keyframe drag + recording bug bash

21 fixes: capture GSAP base at drag start, translate:none before
gsap.set, skip reapplyPathOffsets for GSAP elements, clamp recording
seek, _auto flag for 100% keyframes, overlay flash fix, block edits
during recording.

* feat(studio): keyframe integration wiring + docs

Wire App.tsx recording orchestration, TimelineToolbar K/R buttons,
PropertyPanel per-property diamonds, shortcuts panel, toast
notifications, and keyframes guide documentation. All gated on
STUDIO_KEYFRAMES_ENABLED (default false).
This commit is contained in:
Miguel Ángel
2026-06-09 18:30:23 -04:00
committed by GitHub
parent 96b8d617d8
commit a468550f82
72 changed files with 4421 additions and 621 deletions
@@ -17,6 +17,46 @@ const SHORTCUT_SECTIONS = [
{ key: "F", label: "Toggle fullscreen" },
],
},
{
title: "Keyframes",
hints: [
{ key: "K", label: "Add keyframe at playhead" },
{ key: "Del", label: "Delete selected keyframe" },
{ key: "H", label: "Toggle hold / bezier" },
{ key: "U", label: "Expand / collapse properties" },
{ 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: [
@@ -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,120 @@
import { memo } from "react";
import type { KeyframeCacheEntry } from "../store/playerStore";
const SUB_TRACK_H = 24;
const DIAMOND_SIZE = 6;
const HALF = DIAMOND_SIZE / 2;
interface TimelinePropertyRowsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipLeftPx: number;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: Set<string>;
onClickKeyframe?: (percentage: number) => void;
}
function extractProperties(data: KeyframeCacheEntry): string[] {
const props = new Set<string>();
for (const kf of data.keyframes) {
for (const key of Object.keys(kf.properties)) {
props.add(key);
}
}
return Array.from(props).sort();
}
export const TimelinePropertyRows = memo(function TimelinePropertyRows({
keyframesData,
clipWidthPx,
clipLeftPx,
accentColor,
isSelected,
currentPercentage,
elementId,
selectedKeyframes,
onClickKeyframe,
}: TimelinePropertyRowsProps) {
const properties = extractProperties(keyframesData);
if (properties.length === 0 || clipWidthPx < 20) return null;
return (
<div className="flex flex-col">
{properties.map((prop) => {
const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties);
if (propKeyframes.length === 0) return null;
return (
<div key={prop} className="relative flex items-center" style={{ height: SUB_TRACK_H }}>
<span className="absolute left-1 text-[8px] font-medium text-neutral-600 z-10 select-none">
{prop}
</span>
<svg
className="absolute"
style={{ left: clipLeftPx, width: clipWidthPx, height: SUB_TRACK_H }}
viewBox={`0 0 ${clipWidthPx} ${SUB_TRACK_H}`}
>
<line
x1={0}
y1={SUB_TRACK_H / 2}
x2={clipWidthPx}
y2={SUB_TRACK_H / 2}
stroke={isSelected ? accentColor : "#525252"}
strokeOpacity={0.15}
strokeWidth={1}
/>
{propKeyframes.map((kf) => {
const x = (kf.percentage / 100) * clipWidthPx;
const y = SUB_TRACK_H / 2;
const key = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(key);
const isHold = kf.ease === "steps(1)";
const fillColor =
isKfSelected || (isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5)
? accentColor
: isSelected
? `${accentColor}80`
: "#737373";
return (
<g
key={kf.percentage}
onClick={(e) => {
e.stopPropagation();
onClickKeyframe?.(kf.percentage);
}}
style={{ cursor: "pointer" }}
>
{isHold ? (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
/>
) : (
<rect
x={x - HALF}
y={y - HALF}
width={DIAMOND_SIZE}
height={DIAMOND_SIZE}
fill={fillColor}
transform={`rotate(45, ${x}, ${y})`}
/>
)}
</g>
);
})}
</svg>
</div>
);
})}
</div>
);
});
export { SUB_TRACK_H };