mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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 };
|
||||
Reference in New Issue
Block a user