From 7a0883264d697396d80f51352094a995ddaa378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 11:51:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(studio):=20keyframe=20hooks=20wiring=20?= =?UTF-8?q?=E2=80=94=20session,=20cache,=20toolbar=20[5/6]=20(#1171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): GSAP keyframe parsing, mutations, and API routes * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance. * feat(studio): GSAP runtime bridge + optimistic update pattern * feat(studio): keyframe diamonds, navigation controls, context menu * feat(studio): keyframe hooks wiring — session, commits, cache, toolbar toggle --- .../studio/src/components/TimelineToolbar.tsx | 196 +++++++++++++++++- packages/studio/src/hooks/useAppHotkeys.ts | 36 +++- .../studio/src/hooks/useDomEditCommits.ts | 37 +++- .../studio/src/hooks/useDomEditSession.ts | 120 ++++++++++- .../studio/src/hooks/useGsapScriptCommits.ts | 140 ++++++++++++- .../studio/src/hooks/useGsapTweenCache.ts | 55 ++++- .../studio/src/player/store/playerStore.ts | 42 ++++ 7 files changed, 607 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 7bf86c193..d280d0733 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -4,24 +4,214 @@ import { } from "../player/components/timelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; import { usePlayerStore } from "../player"; +import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; +import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "./editor/domEditingTypes"; + +function interpolateKeyframeProperties( + keyframes: GsapPercentageKeyframe[], + pct: number, +): Record { + const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); + const allProps = new Set(); + 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 = {}; + 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 { + 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(); + 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 = {}; + 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 { + 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; + handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + previewIframeRef?: React.RefObject; +} interface TimelineToolbarProps { toggleTimelineVisibility: () => void; + domEditSession?: DomEditSessionSlice; } -export function TimelineToolbar({ toggleTimelineVisibility }: TimelineToolbarProps) { +// fallow-ignore-next-line complexity +function useKeyframeToggle(session?: DomEditSessionSlice) { + const currentTime = usePlayerStore((s) => s.currentTime); + 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) { + 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(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; + state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) + ? "active" + : "inactive"; + } + + // fallow-ignore-next-line complexity + const onToggle = sel + ? () => { + const t = usePlayerStore.getState().currentTime; + if (kfAnim?.keyframes) { + 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 }; +} + +export function TimelineToolbar({ + toggleTimelineVisibility, + domEditSession, +}: TimelineToolbarProps) { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); + const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession); return (
-
- Timeline +
+
+ Timeline +
+ {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && ( + + + + )}
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 4308b4bcd..61d65af9f 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -77,6 +77,9 @@ interface UseAppHotkeysParams { handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; + onResetKeyframes: () => boolean; + onDeleteSelectedKeyframes: () => void; + onAfterUndoRedo?: () => void; } // ── Hook ── @@ -98,6 +101,9 @@ export function useAppHotkeys({ handleCopy, handlePaste, handleCut, + onResetKeyframes, + onDeleteSelectedKeyframes, + onAfterUndoRedo, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -144,6 +150,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Undid ${result.label}`, "info"); } @@ -154,6 +161,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); const handleRedo = useCallback(async () => { @@ -167,6 +175,7 @@ export function useAppHotkeys({ return; } if (result.ok && result.label) { + onAfterUndoRedo?.(); await syncHistoryPreviewAfterApply(result.paths); showToast(`Redid ${result.label}`, "info"); } @@ -177,6 +186,7 @@ export function useAppHotkeys({ syncHistoryPreviewAfterApply, waitForPendingDomEditSaves, writeHistoryProjectFile, + onAfterUndoRedo, ]); // ── Stable refs for the consolidated keydown handler ── @@ -197,6 +207,10 @@ export function useAppHotkeys({ handlePasteRef.current = handlePaste; const handleCutRef = useRef(handleCut); handleCutRef.current = handleCut; + const onResetKeyframesRef = useRef(onResetKeyframes); + onResetKeyframesRef.current = onResetKeyframes; + const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); + onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; // ── Consolidated keydown handler ── @@ -292,7 +306,7 @@ export function useAppHotkeys({ return; } - // Delete / Backspace — remove selected element (timeline clip or preview selection) + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && !event.metaKey && @@ -300,6 +314,26 @@ export function useAppHotkeys({ !event.altKey && !isEditableTarget(event.target) ) { + // Priority: selected keyframes take precedence over clip deletion + const { selectedKeyframes } = usePlayerStore.getState(); + if (selectedKeyframes.size > 0) { + onDeleteSelectedKeyframesRef.current(); + usePlayerStore.getState().clearSelectedKeyframes(); + event.preventDefault(); + return; + } + + // Backspace: try resetting keyframes first; fall through to delete if none found + if (event.key === "Backspace") { + const { selectedElementId, keyframeCache } = usePlayerStore.getState(); + if (selectedElementId && keyframeCache.has(selectedElementId)) { + if (onResetKeyframesRef.current()) { + event.preventDefault(); + return; + } + } + } + const { selectedElementId, elements } = usePlayerStore.getState(); if (selectedElementId) { const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index e733a4cdd..658740151 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO import type { EditHistoryKind } from "../utils/editHistory"; import { useDomEditTextCommits } from "./useDomEditTextCommits"; +// ── Helpers ── + +type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> }; + +function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean { + if (!iframe?.contentWindow) return false; + let timelines: Record | undefined; + try { + timelines = (iframe.contentWindow as Window & { __timelines?: Record }) + .__timelines; + } catch { + return false; + } + if (!timelines) return false; + const id = element.id; + for (const tl of Object.values(timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets) continue; + for (const t of child.targets()) { + if (t === element || (id && t.id === id)) return true; + } + } + } catch { + continue; + } + } + return false; +} + // ── Types ── interface RecordEditInput { @@ -290,12 +321,13 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomGroupPathOffsetCommit = useCallback( @@ -307,13 +339,14 @@ export function useDomEditCommits({ .join(":"); for (const { selection, next } of updates) { applyStudioPathOffset(selection.element, next); + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, }); } }, - [commitPositionPatchToHtml], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomBoxSizeCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5942d8f30..209be746a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -16,7 +16,14 @@ import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; import { useDomEditCommits } from "./useDomEditCommits"; import { useGsapScriptCommits } from "./useGsapScriptCommits"; -import { useGsapAnimationsForElement, useGsapCacheVersion } from "./useGsapTweenCache"; +import { + useGsapAnimationsForElement, + useGsapCacheVersion, + usePopulateKeyframeCacheForFile, + fetchParsedAnimations, + getAnimationsForElement, +} from "./useGsapTweenCache"; +import { tryGsapDragIntercept } from "./gsapRuntimeBridge"; // ── Types ── @@ -198,13 +205,21 @@ export function useDomEditSession({ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; + + usePopulateKeyframeCacheForFile( + STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, + gsapSourceFile, + gsapCacheVersion, + ); + const { animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, } = useGsapAnimationsForElement( STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, - domEditSelection?.sourceFile || activeCompPath || "index.html", + gsapSourceFile, domEditSelection ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, @@ -212,6 +227,7 @@ export function useDomEditSession({ ); const { + commitMutation: gsapCommitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -221,6 +237,10 @@ export function useDomEditSession({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, } = useGsapScriptCommits({ projectIdRef, activeCompPath, @@ -270,6 +290,42 @@ export function useDomEditSession({ buildDomSelectionFromTarget, }); + // Wrap the CSS-based path offset commit with GSAP-awareness: when the + // selected element has GSAP animations controlling x/y, read the actual + // interpolated position from the iframe runtime and commit via the GSAP + // script mutation path instead of the CSS translate offset. + const handleGsapAwarePathOffsetCommit = useCallback( + async (selection: DomEditSelection, next: { x: number; y: number }) => { + if (gsapCommitMutation) { + const handled = await tryGsapDragIntercept( + selection, + next, + selectedGsapAnimations, + previewIframeRef.current, + gsapCommitMutation, + async () => { + const pid = projectId; + if (!pid) return []; + const parsed = await fetchParsedAnimations(pid, gsapSourceFile); + if (!parsed) return []; + const target = { id: selection.id ?? null, selector: selection.selector ?? null }; + return getAnimationsForElement(parsed.animations, target); + }, + ); + if (handled) return; + } + handleDomPathOffsetCommit(selection, next); + }, + [ + handleDomPathOffsetCommit, + selectedGsapAnimations, + gsapCommitMutation, + previewIframeRef, + projectId, + gsapSourceFile, + ], + ); + const handleGsapUpdateProperty = useCallback( (animId: string, prop: string, value: number | string) => { if (!domEditSelection) return; @@ -298,8 +354,11 @@ export function useDomEditSession({ (method: "to" | "from" | "set" | "fromTo") => { if (!domEditSelection) return; addGsapAnimation(domEditSelection, method, currentTime); + if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) { + handleDomManualEditsReset(domEditSelection); + } }, - [domEditSelection, addGsapAnimation, currentTime], + [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset], ); const handleGsapAddProperty = useCallback( @@ -342,6 +401,52 @@ export function useDomEditSession({ [domEditSelection, removeGsapFromProperty], ); + const handleGsapAddKeyframe = useCallback( + (animId: string, percentage: number, property: string, value: number | string) => { + if (!domEditSelection) return; + addKeyframe(domEditSelection, animId, percentage, property, value); + }, + [domEditSelection, addKeyframe], + ); + + const handleGsapRemoveKeyframe = useCallback( + (animId: string, percentage: number) => { + if (!domEditSelection) return; + removeKeyframe(domEditSelection, animId, percentage); + }, + [domEditSelection, removeKeyframe], + ); + + const handleGsapConvertToKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + convertToKeyframes(domEditSelection, animId); + }, + [domEditSelection, convertToKeyframes], + ); + + const handleGsapRemoveAllKeyframes = useCallback( + (animId: string) => { + if (!domEditSelection) return; + removeAllKeyframes(domEditSelection, animId); + }, + [domEditSelection, removeAllKeyframes], + ); + + /** + * Reset keyframes for the currently selected element. + * Finds the animation with keyframes from the resolved GSAP animations + * and sends a remove-all-keyframes mutation. Returns true if keyframes + * were found and the mutation was dispatched. + */ + const handleResetSelectedElementKeyframes = useCallback((): boolean => { + if (!domEditSelection) return false; + const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes); + if (!withKeyframes) return false; + removeAllKeyframes(domEditSelection, withKeyframes.id); + return true; + }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -445,7 +550,7 @@ export function useDomEditSession({ handleDomStyleCommit, handleDomAttributeCommit, handleDomHtmlAttributeCommit, - handleDomPathOffsetCommit, + handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, @@ -482,5 +587,12 @@ export function useDomEditSession({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + handleGsapAddKeyframe, + handleGsapRemoveKeyframe, + handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, + handleResetSelectedElementKeyframes, + invalidateGsapCache: bumpGsapCache, + previewIframeRef, }; } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index a45333388..75484e307 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -3,6 +3,8 @@ import type { 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"; const PROPERTY_DEFAULTS: Record = { opacity: 1, @@ -70,6 +72,27 @@ async function mutateGsapScript( } } +function buildCacheKey(sourceFile: string, elementId: string): string { + return `${sourceFile}#${elementId}`; +} + +function readKeyframeSnapshot( + sourceFile: string, + elementId: string | null | undefined, +): KeyframeCacheEntry | undefined { + if (!elementId) return undefined; + return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId)); +} + +function writeKeyframeCache( + sourceFile: string, + elementId: string | null | undefined, + data: KeyframeCacheEntry | undefined, +): void { + if (!elementId) return; + usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data); +} + interface GsapScriptCommitsParams { projectIdRef: React.MutableRefObject; activeCompPath: string | null; @@ -113,7 +136,13 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, mutation: Record, - options: { label: string; coalesceKey?: string; softReload?: boolean }, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + beforeReload?: () => void; + }, ) => { const pid = projectIdRef.current; if (!pid) return; @@ -135,6 +164,10 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (options.skipReload) return; + + options.beforeReload?.(); + if (options.softReload && result.scriptText) { if (!applySoftReload(previewIframeRef.current, result.scriptText)) { reloadPreview(); @@ -225,7 +258,7 @@ export function useGsapScriptCommits({ async ( selection: DomEditSelection, method: "to" | "from" | "set" | "fromTo", - currentTime?: number, + _currentTime?: number, ) => { const { selector, autoId } = ensureElementAddressable(selection); @@ -253,12 +286,15 @@ export function useGsapScriptCommits({ if (!data.changed) return; } - const start = currentTime ?? (Number.parseFloat(selection.dataAttributes.start ?? "0") || 0); + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const position = Math.round(elStart * 1000) / 1000; + const duration = Math.round(elDuration * 1000) / 1000; const toDefaults: Record> = { from: { opacity: 0 }, - to: { opacity: 1 }, + to: { x: 0, y: 0, opacity: 1 }, set: { opacity: 1 }, - fromTo: { opacity: 1 }, + fromTo: { x: 0, y: 0, opacity: 1 }, }; await commitMutation( @@ -267,8 +303,8 @@ export function useGsapScriptCommits({ type: "add", targetSelector: selector, method, - position: start, - duration: method === "set" ? undefined : 0.5, + position, + duration: method === "set" ? undefined : duration, ease: method === "set" ? undefined : "power2.out", properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, @@ -353,7 +389,93 @@ export function useGsapScriptCommits({ [commitMutation], ); + const addKeyframe = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + property: string, + value: number | string, + ) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = [ + ...prev.keyframes, + { percentage, properties: { [property]: value } }, + ].sort((a, b) => a.percentage - b.percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties: { [property]: value } }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const removeKeyframe = useCallback( + (selection: DomEditSelection, animationId: string, percentage: number) => { + const sf = selection.sourceFile || activeCompPath || "index.html"; + const elementId = selection.id; + void executeOptimistic({ + apply: () => { + const prev = readKeyframeSnapshot(sf, elementId); + if (prev) { + const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); + } + return prev; + }, + persist: () => + commitMutation( + selection, + { type: "remove-keyframe", animationId, percentage }, + { label: `Remove keyframe at ${percentage}%`, softReload: true }, + ), + rollback: (prev) => { + writeKeyframeCache(sf, elementId, prev); + }, + }); + }, + [commitMutation, activeCompPath], + ); + + const convertToKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "convert-to-keyframes", animationId }, + { label: "Convert to keyframes" }, + ); + }, + [commitMutation], + ); + + const removeAllKeyframes = useCallback( + (selection: DomEditSelection, animationId: string) => { + void commitMutation( + selection, + { type: "remove-all-keyframes", animationId }, + { label: "Remove all keyframes", softReload: true }, + ); + }, + [commitMutation], + ); + return { + commitMutation, updateGsapProperty, updateGsapMeta, deleteGsapAnimation, @@ -363,5 +485,9 @@ export function useGsapScriptCommits({ updateGsapFromProperty, addGsapFromProperty, removeGsapFromProperty, + addKeyframe, + removeKeyframe, + convertToKeyframes, + removeAllKeyframes, }; } diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index ecca0b3ba..70fafa9a4 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,5 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../player/store/playerStore"; + +function extractIdFromSelector(selector: string): string | null { + const match = selector.match(/^#([\w-]+)/); + return match ? match[1] : null; +} /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { @@ -28,7 +34,7 @@ export function getAnimationsForElement( ); } -async function fetchParsedAnimations( +export async function fetchParsedAnimations( projectId: string, sourceFile: string, ): Promise { @@ -98,6 +104,16 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + // Populate keyframe cache for the selected element. + // Key format must match timeline element keys: "sourceFile#domId". + const elementId = target?.id ?? null; + useEffect(() => { + if (!elementId) return; + const { setKeyframeCache } = usePlayerStore.getState(); + const withKeyframes = animations.find((a) => a.keyframes); + setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined); + }, [elementId, sourceFile, animations]); + return { animations, multipleTimelines, unsupportedTimelinePattern }; } @@ -106,3 +122,38 @@ export function useGsapCacheVersion() { const bump = useCallback(() => setVersion((v) => v + 1), []); return { version, bump }; } + +/** + * Fetch GSAP animations for a file and populate the keyframe cache for all + * elements. Called from the Timeline component so diamonds show without + * requiring a selection. + */ +export function usePopulateKeyframeCacheForFile( + projectId: string | null, + sourceFile: string, + version: number, +): void { + const lastFetchKeyRef = useRef(""); + + useEffect(() => { + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; + if (!projectId) return; + + let cancelled = false; + fetchParsedAnimations(projectId, sourceFile).then((parsed) => { + if (cancelled || !parsed) return; + const { setKeyframeCache } = usePlayerStore.getState(); + for (const anim of parsed.animations) { + if (!anim.keyframes) continue; + const id = extractIdFromSelector(anim.targetSelector); + if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes); + } + }); + + return () => { + cancelled = true; + }; + }, [projectId, sourceFile, version]); +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index fdbf18925..d67aad396 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,6 +1,18 @@ import { create } from "zustand"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; +/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ +export interface KeyframeCacheEntry { + format: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + ease?: string; + easeEach?: string; +} + export interface TimelineElement { id: string; label?: string; @@ -51,6 +63,15 @@ interface PlayerState { /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; + /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */ + selectedKeyframes: Set; + toggleSelectedKeyframe: (key: string) => void; + clearSelectedKeyframes: () => void; + + /** Keyframe data per element id, populated from parsed GSAP animations. */ + keyframeCache: Map; + setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; + setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; @@ -107,6 +128,25 @@ export const usePlayerStore = create((set) => ({ inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + toggleSelectedKeyframe: (key) => + set((s) => { + const next = new Set(s.selectedKeyframes); + if (next.has(key)) next.delete(key); + else next.add(key); + return { selectedKeyframes: next }; + }), + clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + + keyframeCache: new Map(), + setKeyframeCache: (elementId, data) => + set((s) => { + const next = new Map(s.keyframeCache); + if (data) next.set(elementId, data); + else next.delete(elementId); + return { keyframeCache: next }; + }), + requestedSeekTime: null, requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), @@ -169,5 +209,7 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + selectedKeyframes: new Set(), + keyframeCache: new Map(), }), }));