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
@@ -57,15 +57,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
onPointerLeave={() => setHovered(false)}
onClick={isComplete ? handleOpen : undefined}
className={[
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
"px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
]
.filter(Boolean)
.join(" ")}
>
<div className="flex items-center gap-2.5">
{/* Thumbnail — static frame; swaps to live video on hover */}
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
<div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
{isComplete && (
<>
{/* Live video — visible on hover */}
@@ -90,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status === "rendering" && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
</div>
)}
{job.status === "failed" && (
@@ -108,11 +108,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-neutral-300 truncate">
<span className="text-[11px] font-medium text-panel-text-2 truncate">
{job.filename}
</span>
{job.durationMs && (
<span className="text-[9px] text-neutral-600 flex-shrink-0">
<span className="text-[9px] text-panel-text-5 flex-shrink-0">
{formatDuration(job.durationMs)}
</span>
)}
@@ -121,12 +121,12 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{job.status === "rendering" && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
<div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
<div
className="h-full bg-studio-accent rounded-full transition-all duration-300"
className="h-full bg-panel-accent rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
@@ -138,57 +138,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status !== "rendering" && (
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
)}
</div>
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{isComplete && (
<button
onClick={handleDownload}
className="p-1 rounded text-neutral-500 hover:text-green-400 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-neutral-500 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>
);