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
@@ -1,3 +1,5 @@
import { useRef } from "react";
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
@@ -7,88 +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<string, number> {
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const allProps = new Set<string>();
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<string, number> = {};
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<string, number> {
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<string>();
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<string, number> = {};
for (const prop of allProps) {
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) => void;
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
}
interface TimelineToolbarProps {
@@ -97,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<EnableKeyframesSession | undefined>,
);
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) {
@@ -120,48 +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) {
session.handleGsapConvertToKeyframes(flatAnim.id);
} else {
session.handleGsapAddAnimation("to");
}
}
: undefined;
return { state, onToggle };
return { state, onToggle: sel ? onToggle : undefined };
}
export function TimelineToolbar({