mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-08 15:57:01 +00:00
feat(studio): keyframe cache + commit hooks
Add hooks for keyframe cache population (tween → clip-relative %), mutation dispatch, keyframe snapping, and audio beat detection.
This commit is contained in:
@@ -72,6 +72,8 @@ export function DomEditProvider({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
},
|
||||
@@ -141,6 +143,8 @@ export function DomEditProvider({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
}),
|
||||
@@ -204,6 +208,8 @@ export function DomEditProvider({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
],
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface UseDomEditSessionParams {
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
updateEditingFileContent?: (path: string, content: string) => void;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
fileTree: string[];
|
||||
@@ -100,6 +101,7 @@ export function useDomEditSession({
|
||||
queueDomEditSave,
|
||||
readProjectFile: _readProjectFile,
|
||||
writeProjectFile,
|
||||
updateEditingFileContent,
|
||||
domEditSaveTimestampRef,
|
||||
editHistory,
|
||||
fileTree,
|
||||
@@ -224,12 +226,25 @@ export function useDomEditSession({
|
||||
|
||||
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
||||
|
||||
// Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
|
||||
// reload via refreshKey but don't go through commitMutation, so the cache
|
||||
// would otherwise retain stale keyframe entries).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
prevRefreshKeyRef.current = refreshKey;
|
||||
bumpGsapCache();
|
||||
}
|
||||
}, [refreshKey, bumpGsapCache]);
|
||||
|
||||
const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
|
||||
|
||||
usePopulateKeyframeCacheForFile(
|
||||
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
|
||||
gsapSourceFile,
|
||||
gsapCacheVersion,
|
||||
previewIframeRef,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -260,6 +275,8 @@ export function useDomEditSession({
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
setArcPath,
|
||||
updateArcSegment,
|
||||
} = useGsapScriptCommits({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
@@ -268,6 +285,7 @@ export function useDomEditSession({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate: bumpGsapCache,
|
||||
onFileContentChanged: updateEditingFileContent,
|
||||
});
|
||||
|
||||
// ── Commit handlers (delegated to useDomEditCommits) ──
|
||||
@@ -449,6 +467,22 @@ export function useDomEditSession({
|
||||
bumpGsapCache,
|
||||
});
|
||||
|
||||
const handleSetArcPath = useCallback(
|
||||
(animId: string, config: Parameters<typeof setArcPath>[2]) => {
|
||||
if (!domEditSelection) return;
|
||||
setArcPath(domEditSelection, animId, config);
|
||||
},
|
||||
[domEditSelection, setArcPath],
|
||||
);
|
||||
|
||||
const handleUpdateArcSegment = useCallback(
|
||||
(animId: string, segmentIndex: number, update: Parameters<typeof updateArcSegment>[3]) => {
|
||||
if (!domEditSelection) return;
|
||||
updateArcSegment(domEditSelection, animId, segmentIndex, update);
|
||||
},
|
||||
[domEditSelection, updateArcSegment],
|
||||
);
|
||||
|
||||
// Sync selection from preview document on load / refresh
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -594,6 +628,8 @@ export function useDomEditSession({
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
handleSetArcPath,
|
||||
handleUpdateArcSegment,
|
||||
invalidateGsapCache: bumpGsapCache,
|
||||
previewIframeRef,
|
||||
};
|
||||
|
||||
@@ -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,51 @@ async function mutateGsapScript(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyframeCacheFromParsed(
|
||||
animations: GsapAnimation[],
|
||||
targetPath: string,
|
||||
selectionId: string | undefined,
|
||||
mutation: Record<string, unknown>,
|
||||
): void {
|
||||
const { setKeyframeCache } = 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);
|
||||
const existing = merged.get(id);
|
||||
if (existing) {
|
||||
const byPct = new Map<number, (typeof existing.keyframes)[0]>();
|
||||
for (const kf of [...existing.keyframes, ...anim.keyframes.keyframes]) {
|
||||
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: [...anim.keyframes.keyframes] });
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of merged) {
|
||||
setKeyframeCache(`${targetPath}#${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 +124,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 +132,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 +147,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 +160,7 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
}: GsapScriptCommitsParams) {
|
||||
const pendingPropertyEditRef = useRef<{
|
||||
selection: DomEditSelection;
|
||||
@@ -129,7 +169,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
|
||||
@@ -164,15 +203,17 @@ export function useGsapScriptCommits({
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (result.after != null) {
|
||||
onFileContentChanged?.(targetPath, result.after);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
updateKeyframeCacheFromParsed(
|
||||
result.parsed.animations,
|
||||
targetPath,
|
||||
selection.id ?? undefined,
|
||||
mutation,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.skipReload) return;
|
||||
@@ -195,9 +236,9 @@ export function useGsapScriptCommits({
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
],
|
||||
);
|
||||
|
||||
const flushPendingPropertyEdit = useCallback(() => {
|
||||
const pending = pendingPropertyEditRef.current;
|
||||
if (!pending) return;
|
||||
@@ -227,7 +268,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[flushPendingPropertyEdit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
@@ -252,7 +292,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const deleteGsapAnimation = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
void commitMutation(
|
||||
@@ -263,7 +302,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapAnimation = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (
|
||||
@@ -325,7 +363,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, projectIdRef, activeCompPath],
|
||||
);
|
||||
|
||||
const addGsapProperty = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
@@ -346,7 +383,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -357,7 +393,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const updateGsapFromProperty = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -376,7 +411,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
const defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
||||
@@ -388,7 +422,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const removeGsapFromProperty = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, property: string) => {
|
||||
void commitMutation(
|
||||
@@ -399,7 +432,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation],
|
||||
);
|
||||
|
||||
const addKeyframe = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
@@ -435,7 +467,6 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const removeKeyframe = useCallback(
|
||||
(selection: DomEditSelection, animationId: string, percentage: number) => {
|
||||
const sf = selection.sourceFile || activeCompPath || "index.html";
|
||||
@@ -462,18 +493,20 @@ export function useGsapScriptCommits({
|
||||
},
|
||||
[commitMutation, activeCompPath],
|
||||
);
|
||||
|
||||
const convertToKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => {
|
||||
void 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(
|
||||
@@ -484,7 +517,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,
|
||||
@@ -500,5 +592,9 @@ export function useGsapScriptCommits({
|
||||
removeKeyframe,
|
||||
convertToKeyframes,
|
||||
removeAllKeyframes,
|
||||
setArcPath,
|
||||
updateArcSegment,
|
||||
removeArcPath,
|
||||
commitKeyframeAtTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,11 @@ export function useGsapSelectionHandlers({
|
||||
value: number | string,
|
||||
) => void;
|
||||
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
|
||||
convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => void;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
|
||||
currentTime: number;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
@@ -160,9 +164,9 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapConvertToKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
(animId: string, resolvedFromValues?: Record<string, number | string>) => {
|
||||
if (!domEditSelection) return;
|
||||
convertToKeyframes(domEditSelection, animId);
|
||||
convertToKeyframes(domEditSelection, animId, resolvedFromValues);
|
||||
},
|
||||
[domEditSelection, convertToKeyframes],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
||||
|
||||
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
||||
const byPct = new Map<number, GsapPercentageKeyframe>();
|
||||
for (const kf of keyframes) {
|
||||
const existing = byPct.get(kf.percentage);
|
||||
if (existing) {
|
||||
existing.properties = { ...existing.properties, ...kf.properties };
|
||||
if (kf.ease) existing.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
}
|
||||
}
|
||||
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
};
|
||||
|
||||
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
||||
if (anim.method === "set") {
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 0, properties: { ...anim.properties } }],
|
||||
};
|
||||
}
|
||||
const toProps = anim.properties;
|
||||
const fromProps = anim.fromProperties;
|
||||
if (!toProps || Object.keys(toProps).length === 0) return null;
|
||||
|
||||
const startProps: Record<string, number | string> = {};
|
||||
const endProps: Record<string, number | string> = {};
|
||||
|
||||
if (anim.method === "from") {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = v;
|
||||
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
}
|
||||
} else if (anim.method === "fromTo" && fromProps) {
|
||||
Object.assign(startProps, fromProps);
|
||||
Object.assign(endProps, toProps);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
endProps[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: startProps },
|
||||
{ percentage: 100, properties: endProps },
|
||||
],
|
||||
...(anim.ease ? { ease: anim.ease } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractIdFromSelector(selector: string): string | null {
|
||||
const match = selector.match(/^#([\w-]+)/);
|
||||
return match ? match[1] : null;
|
||||
@@ -182,12 +246,33 @@ export function useGsapAnimationsForElement(
|
||||
|
||||
// Populate keyframe cache for the selected element.
|
||||
// Key format must match timeline element keys: "sourceFile#domId".
|
||||
// Merges keyframes from ALL animations targeting this element and synthesizes
|
||||
// flat tweens so the cache is never downgraded vs the bulk populate.
|
||||
const elementId = target?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!elementId) return;
|
||||
const allKeyframes: GsapKeyframesData["keyframes"] = [];
|
||||
let format: GsapKeyframesData["format"] = "percentage";
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
for (const anim of animations) {
|
||||
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kf) continue;
|
||||
allKeyframes.push(...kf.keyframes);
|
||||
format = kf.format;
|
||||
if (kf.ease) ease = kf.ease;
|
||||
if (kf.easeEach) easeEach = kf.easeEach;
|
||||
}
|
||||
if (allKeyframes.length === 0) return;
|
||||
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
||||
const merged: GsapKeyframesData = {
|
||||
format,
|
||||
keyframes: dedupedKeyframes,
|
||||
...(ease ? { ease } : {}),
|
||||
...(easeEach ? { easeEach } : {}),
|
||||
};
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
const withKeyframes = animations.find((a) => a.keyframes);
|
||||
setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
|
||||
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
||||
}, [elementId, sourceFile, animations]);
|
||||
|
||||
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
||||
@@ -213,25 +298,46 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const lastFetchKeyRef = useRef("");
|
||||
|
||||
const runtimeScanDoneRef = useRef("");
|
||||
const astFetchDoneRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
runtimeScanDoneRef.current = "";
|
||||
astFetchDoneRef.current = "";
|
||||
if (!projectId) return;
|
||||
|
||||
const sf = sourceFile;
|
||||
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
||||
const sfPrefix = `${sf}#`;
|
||||
const fallbackPrefix = "index.html#";
|
||||
for (const key of keyframeCache.keys()) {
|
||||
if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
|
||||
setKeyframeCache(key, undefined);
|
||||
}
|
||||
}
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
for (const anim of parsed.animations) {
|
||||
const id = extractIdFromSelector(anim.targetSelector);
|
||||
if (!id || !anim.keyframes) continue;
|
||||
setKeyframeCache(`${sf}#${id}`, anim.keyframes);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||
if (!id) continue;
|
||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kfData) continue;
|
||||
const existing = mergedByElement.get(id);
|
||||
if (existing) {
|
||||
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...kfData.keyframes]);
|
||||
} else {
|
||||
mergedByElement.set(id, { ...kfData, keyframes: [...kfData.keyframes] });
|
||||
}
|
||||
}
|
||||
runtimeScanDoneRef.current = fetchKey;
|
||||
for (const [id, kfData] of mergedByElement) {
|
||||
setKeyframeCache(`${sf}#${id}`, kfData);
|
||||
setKeyframeCache(id, kfData);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
||||
}
|
||||
astFetchDoneRef.current = fetchKey;
|
||||
});
|
||||
}, [projectId, sourceFile, version]);
|
||||
|
||||
@@ -246,7 +352,8 @@ export function usePopulateKeyframeCacheForFile(
|
||||
|
||||
const tryRuntimeScan = () => {
|
||||
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
||||
const iframe = iframeRef?.current;
|
||||
const iframe =
|
||||
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
||||
if (!iframe) return false;
|
||||
const scanned = scanAllRuntimeKeyframes(iframe);
|
||||
if (scanned.size === 0) return false;
|
||||
@@ -254,7 +361,8 @@ export function usePopulateKeyframeCacheForFile(
|
||||
for (const [id, data] of scanned) {
|
||||
const cacheKey = `${sf}#${id}`;
|
||||
const fallbackKey = `index.html#${id}`;
|
||||
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
|
||||
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
|
||||
continue;
|
||||
const entry = {
|
||||
format: "percentage" as const,
|
||||
keyframes: data.keyframes,
|
||||
@@ -262,6 +370,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
};
|
||||
setKeyframeCache(cacheKey, entry);
|
||||
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
}
|
||||
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const WINDOW_SIZE = 1024;
|
||||
const HOP_SIZE = 512;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
|
||||
const energies: number[] = [];
|
||||
for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < WINDOW_SIZE; j++) {
|
||||
const sample = channelData[i + j]!;
|
||||
sum += sample * sample;
|
||||
}
|
||||
energies.push(sum / WINDOW_SIZE);
|
||||
}
|
||||
|
||||
const beats: number[] = [];
|
||||
const localWindowSize = 20;
|
||||
|
||||
for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
|
||||
let localMean = 0;
|
||||
for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
|
||||
localMean += energies[j]!;
|
||||
}
|
||||
localMean /= localWindowSize * 2;
|
||||
|
||||
const threshold = localMean * 1.5;
|
||||
const current = energies[i]!;
|
||||
|
||||
if (
|
||||
current > threshold &&
|
||||
current > (energies[i - 1] ?? 0) &&
|
||||
current > (energies[i + 1] ?? 0)
|
||||
) {
|
||||
const timeInSeconds = (i * HOP_SIZE) / sampleRate;
|
||||
if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
|
||||
beats.push(Math.round(timeInSeconds * 1000) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return beats;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export async function detectBeatsFromUrl(url: string): Promise<number[]> {
|
||||
const audioContext = new AudioContext();
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return detectBeats(audioBuffer);
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,14 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
};
|
||||
|
||||
// Load MotionPathPlugin on demand if the script uses motionPath
|
||||
// Load MotionPathPlugin on demand if the script uses motionPath.
|
||||
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
|
||||
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
||||
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
||||
pluginScript.onload = () => executeScript();
|
||||
pluginScript.onerror = () => executeScript();
|
||||
doc.head.appendChild(pluginScript);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { computeSnapThreshold, snapKeyframe } from "./keyframeSnapping";
|
||||
|
||||
describe("snapKeyframe", () => {
|
||||
test("snaps to frame boundary", () => {
|
||||
const result = snapKeyframe(0.34, { fps: 30, keyframeTimes: [], threshold: 0.05 });
|
||||
expect(result.snapType).toBe("frame");
|
||||
expect(Math.abs(result.snappedTime - 1 / 3)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
test("snaps to cross-element keyframe when closest", () => {
|
||||
const result = snapKeyframe(1.005, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
|
||||
expect(result.snapType).toBe("keyframe");
|
||||
expect(result.snappedTime).toBe(1.0);
|
||||
});
|
||||
|
||||
test("keyframe snap wins tie with frame at same position", () => {
|
||||
const result = snapKeyframe(1.0, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
|
||||
expect(result.snapType).toBe("keyframe");
|
||||
expect(result.snappedTime).toBe(1.0);
|
||||
});
|
||||
|
||||
test("snaps to beat marker when closer than frame", () => {
|
||||
const result = snapKeyframe(2.49, {
|
||||
fps: 30,
|
||||
keyframeTimes: [],
|
||||
beatTimes: [2.5],
|
||||
threshold: 0.05,
|
||||
});
|
||||
expect(result.snapType).toBe("beat");
|
||||
expect(result.snappedTime).toBe(2.5);
|
||||
});
|
||||
|
||||
test("disabled returns raw time", () => {
|
||||
const result = snapKeyframe(1.5, {
|
||||
fps: 30,
|
||||
keyframeTimes: [1.5],
|
||||
threshold: 0.05,
|
||||
disabled: true,
|
||||
});
|
||||
expect(result.snapType).toBeNull();
|
||||
expect(result.snappedTime).toBe(1.5);
|
||||
});
|
||||
|
||||
test("no snap when outside threshold", () => {
|
||||
const result = snapKeyframe(1.5, {
|
||||
fps: 30,
|
||||
keyframeTimes: [0.5],
|
||||
threshold: 0.05,
|
||||
});
|
||||
expect(result.snapType).toBe("frame");
|
||||
});
|
||||
|
||||
test("empty beat times is graceful", () => {
|
||||
const result = snapKeyframe(0.5, {
|
||||
fps: 30,
|
||||
keyframeTimes: [],
|
||||
beatTimes: [],
|
||||
threshold: 0.05,
|
||||
});
|
||||
expect(result.snapType).toBe("frame");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSnapThreshold", () => {
|
||||
test("returns threshold based on pixels per second", () => {
|
||||
const threshold = computeSnapThreshold(100, 5);
|
||||
expect(threshold).toBe(0.05);
|
||||
});
|
||||
|
||||
test("fallback for zero pixels per second", () => {
|
||||
expect(computeSnapThreshold(0)).toBe(0.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
export type SnapType = "frame" | "keyframe" | "beat" | null;
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snapType: SnapType;
|
||||
}
|
||||
|
||||
export function snapKeyframe(
|
||||
time: number,
|
||||
options: {
|
||||
fps: number;
|
||||
keyframeTimes: number[];
|
||||
beatTimes?: number[];
|
||||
threshold: number;
|
||||
disabled?: boolean;
|
||||
},
|
||||
): SnapResult {
|
||||
if (options.disabled) return { snappedTime: time, snapType: null };
|
||||
|
||||
const { fps, keyframeTimes, beatTimes = [], threshold } = options;
|
||||
|
||||
let bestDist = threshold;
|
||||
let bestTime = time;
|
||||
let bestType: SnapType = null;
|
||||
|
||||
// Priority: cross-element keyframes > beat markers > frame boundaries
|
||||
// Higher priority snaps use strict < so they win on equal distance
|
||||
if (fps > 0) {
|
||||
const frameDuration = 1 / fps;
|
||||
const nearestFrame = Math.round(time / frameDuration) * frameDuration;
|
||||
const dist = Math.abs(time - nearestFrame);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestTime = nearestFrame;
|
||||
bestType = "frame";
|
||||
}
|
||||
}
|
||||
|
||||
for (const bt of beatTimes) {
|
||||
const dist = Math.abs(time - bt);
|
||||
if (dist <= bestDist) {
|
||||
bestDist = dist;
|
||||
bestTime = bt;
|
||||
bestType = "beat";
|
||||
}
|
||||
}
|
||||
|
||||
for (const kt of keyframeTimes) {
|
||||
const dist = Math.abs(time - kt);
|
||||
if (dist <= bestDist) {
|
||||
bestDist = dist;
|
||||
bestTime = kt;
|
||||
bestType = "keyframe";
|
||||
}
|
||||
}
|
||||
|
||||
return { snappedTime: bestTime, snapType: bestType };
|
||||
}
|
||||
|
||||
export function computeSnapThreshold(pixelsPerSecond: number, baseThresholdPx: number = 5): number {
|
||||
if (pixelsPerSecond <= 0) return 0.1;
|
||||
return baseThresholdPx / pixelsPerSecond;
|
||||
}
|
||||
Reference in New Issue
Block a user