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:
Miguel Ángel
2026-06-09 18:03:00 +00:00
parent 3c3d77caa3
commit c846250cb4
9 changed files with 493 additions and 45 deletions
@@ -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,
};
+127 -31
View File
@@ -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],
);
+119 -10
View File
@@ -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();
}
}
+3 -1
View File
@@ -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;
}