mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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:
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
@@ -71,11 +72,69 @@ async function mutateGsapScript(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function updateKeyframeCacheFromParsed(
|
||||
animations: GsapAnimation[],
|
||||
targetPath: string,
|
||||
selectionId: string | undefined,
|
||||
mutation: Record<string, unknown>,
|
||||
): void {
|
||||
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
||||
const idsWithKeyframes = new Set<string>();
|
||||
const merged = new Map<string, KeyframeCacheEntry>();
|
||||
for (const anim of animations) {
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id || !anim.keyframes) continue;
|
||||
idsWithKeyframes.add(id);
|
||||
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
||||
const tweenDur = anim.duration ?? 1;
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 4;
|
||||
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
||||
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
||||
const clipPct =
|
||||
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
||||
return { ...kf, percentage: clipPct };
|
||||
});
|
||||
|
||||
const existing = merged.get(id);
|
||||
if (existing) {
|
||||
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
|
||||
for (const kf of [...existing.keyframes, ...clipKeyframes]) {
|
||||
const prev = byPct.get(kf.percentage);
|
||||
if (prev) {
|
||||
prev.properties = { ...prev.properties, ...kf.properties };
|
||||
if (kf.ease) prev.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
}
|
||||
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
} else {
|
||||
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of merged) {
|
||||
setKeyframeCache(`${targetPath}#${id}`, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
|
||||
}
|
||||
const targetId =
|
||||
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
||||
selectionId;
|
||||
if (targetId && !idsWithKeyframes.has(targetId)) {
|
||||
setKeyframeCache(`${targetPath}#${targetId}`, undefined);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
|
||||
}
|
||||
}
|
||||
function buildCacheKey(sourceFile: string, elementId: string): string {
|
||||
return `${sourceFile}#${elementId}`;
|
||||
}
|
||||
|
||||
function readKeyframeSnapshot(
|
||||
sourceFile: string,
|
||||
elementId: string | null | undefined,
|
||||
@@ -83,7 +142,6 @@ function readKeyframeSnapshot(
|
||||
if (!elementId) return undefined;
|
||||
return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
|
||||
}
|
||||
|
||||
function writeKeyframeCache(
|
||||
sourceFile: string,
|
||||
elementId: string | null | undefined,
|
||||
@@ -92,7 +150,6 @@ function writeKeyframeCache(
|
||||
if (!elementId) return;
|
||||
usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
|
||||
}
|
||||
|
||||
interface GsapScriptCommitsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
@@ -108,8 +165,8 @@ interface GsapScriptCommitsParams {
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
onCacheInvalidate: () => void;
|
||||
onFileContentChanged?: (path: string, content: string) => void;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
// fallow-ignore-next-line complexity unit-size
|
||||
@@ -121,6 +178,7 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
}: GsapScriptCommitsParams) {
|
||||
const pendingPropertyEditRef = useRef<{
|
||||
selection: DomEditSelection;
|
||||
@@ -129,7 +187,6 @@ export function useGsapScriptCommits({
|
||||
value: number | string;
|
||||
} | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/** Send a mutation and record the edit in undo history. */
|
||||
const commitMutation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -162,21 +219,23 @@ export function useGsapScriptCommits({
|
||||
});
|
||||
}
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (result.parsed?.animations) {
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
for (const anim of result.parsed.animations) {
|
||||
if (!anim.keyframes) continue;
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id) continue;
|
||||
setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||
}
|
||||
if (result.after != null) {
|
||||
onFileContentChanged?.(targetPath, result.after);
|
||||
}
|
||||
|
||||
if (options.skipReload) return;
|
||||
|
||||
// Write the keyframe cache immediately from the parsed response
|
||||
// (synchronous — the timeline diamonds appear on the next render).
|
||||
if (result.parsed?.animations) {
|
||||
updateKeyframeCacheFromParsed(
|
||||
result.parsed.animations,
|
||||
targetPath,
|
||||
selection.id ?? undefined,
|
||||
mutation,
|
||||
);
|
||||
}
|
||||
|
||||
options.beforeReload?.();
|
||||
|
||||
if (options.softReload && result.scriptText) {
|
||||
@@ -186,6 +245,11 @@ export function useGsapScriptCommits({
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
|
||||
// Bump the cache version AFTER reload so the async re-fetch in
|
||||
// useGsapAnimationsForElement reads the post-reload script, not
|
||||
// the stale pre-reload version that would overwrite fresh data.
|
||||
onCacheInvalidate();
|
||||
},
|
||||
[
|
||||
projectIdRef,
|
||||
@@ -195,9 +259,9 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
],
|
||||
);
|
||||
|
||||
const flushPendingPropertyEdit = useCallback(() => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
@@ -227,7 +291,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[flushPendingPropertyEdit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
@@ -252,7 +315,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const deleteGsapAnimation = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
@@ -263,7 +325,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapAnimation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
@@ -326,7 +387,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, projectIdRef, activeCompPath],
|
||||
);
|
||||
|
||||
const addGsapProperty = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
@@ -347,7 +407,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -358,7 +417,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const updateGsapFromProperty = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -377,7 +435,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
||||
@@ -389,7 +446,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -400,7 +456,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addKeyframe = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -436,7 +491,21 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const addKeyframeBatch = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
) => {
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "add-keyframe", animationId, percentage, properties },
|
||||
{ label: `Add keyframe at ${percentage}%`, softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const removeKeyframe = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
||||
const sf = selection.sourceFile || activeCompPath || "index.html";
|
||||
@@ -463,18 +532,20 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const convertToKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => {
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId },
|
||||
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
|
||||
{ label: "Convert to keyframes" },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeAllKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
@@ -485,7 +556,66 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const setArcPath = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
config: {
|
||||
enabled: boolean;
|
||||
autoRotate?: boolean | number;
|
||||
segments?: Array<{
|
||||
curviness: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}>;
|
||||
},
|
||||
) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "set-arc-path" as const, animationId, ...config },
|
||||
{ label: config.enabled ? "Enable arc path" : "Disable arc path", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const updateArcSegment = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
segmentIndex: number,
|
||||
update: {
|
||||
curviness?: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
},
|
||||
) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "update-arc-segment" as const, animationId, segmentIndex, ...update },
|
||||
{ label: "Update arc segment", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const removeArcPath = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
selection,
|
||||
{ type: "remove-arc-path" as const, animationId },
|
||||
{ label: "Remove arc path", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
const commitKeyframeAtTime = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
absoluteTime: number,
|
||||
animations: GsapAnimation[],
|
||||
properties: Record<string, number | string>,
|
||||
) => commitKeyframeAtTimeImpl(selection, absoluteTime, animations, properties, commitMutation),
|
||||
[commitMutation],
|
||||
);
|
||||
return {
|
||||
commitMutation,
|
||||
updateGsapProperty,
|
||||
@@ -498,8 +628,13 @@ export function useGsapScriptCommits({
|
||||
addGsapFromProperty,
|
||||
removeGsapFromProperty,
|
||||
addKeyframe,
|
||||
addKeyframeBatch,
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
setArcPath,
|
||||
updateArcSegment,
|
||||
removeArcPath,
|
||||
commitKeyframeAtTime,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user