diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index c2ad14b92..4d41eb1f3 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -21,18 +21,23 @@ export function updateKeyframeCacheFromParsed( // Convert tween-relative percentages to clip-relative so diamonds // render at the correct position within the timeline clip. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; const timelineEl = elements.find( (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = anim.keyframes.keyframes.map((kf) => { const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { ...kf, percentage: clipPct }; + return { + ...kf, + percentage: clipPct, + tweenPercentage: kf.percentage, + propertyGroup: anim.propertyGroup, + }; }); const existing = merged.get(id); @@ -66,7 +71,7 @@ export function updateKeyframeCacheFromParsed( } } -export function buildCacheKey(sourceFile: string, elementId: string): string { +function buildCacheKey(sourceFile: string, elementId: string): string { return `${sourceFile}#${elementId}`; } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 998c2b2cf..f082ef5ee 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -449,7 +449,9 @@ export function useGsapScriptCommits({ apply: () => { const prev = readKeyframeSnapshot(sf, elementId); if (prev) { - const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); + const newKeyframes = prev.keyframes.filter( + (kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2, + ); writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); } return prev; diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 1a3c1eeed..17103e622 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -264,9 +264,11 @@ export function useGsapAnimationsForElement( (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; - const allKeyframes: GsapKeyframesData["keyframes"] = []; + const allKeyframes: Array< + GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } + > = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; let easeEach: string | undefined; @@ -275,7 +277,8 @@ export function useGsapAnimationsForElement( if (!kf) continue; // Convert tween-relative percentages to clip-relative so diamonds // render at the correct position within the timeline clip. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? elDuration; for (const k of kf.keyframes) { const absTime = tweenPos + (k.percentage / 100) * tweenDur; @@ -283,7 +286,12 @@ export function useGsapAnimationsForElement( elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : k.percentage; - allKeyframes.push({ ...k, percentage: clipPct }); + allKeyframes.push({ + ...k, + percentage: clipPct, + tweenPercentage: k.percentage, + propertyGroup: anim.propertyGroup, + }); } format = kf.format; if (kf.ease) ease = kf.ease; @@ -305,6 +313,9 @@ export function useGsapAnimationsForElement( }; const { setKeyframeCache } = usePlayerStore.getState(); setKeyframeCache(`${sourceFile}#${elementId}`, merged); + // PropertyPanel reads the cache by bare elementId (without sourceFile prefix), + // so write a duplicate entry under the bare key for cross-component lookups. + setKeyframeCache(elementId, merged); }, [elementId, sourceFile, animations]); return { animations, multipleTimelines, unsupportedTimelinePattern }; @@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile( version: number, iframeRef?: React.RefObject, ): void { + const elementCount = usePlayerStore((s) => s.elements.length); const lastFetchKeyRef = useRef(""); const runtimeScanDoneRef = useRef(""); const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; @@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile( if (!id) continue; const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kfData) continue; - // Convert tween-relative percentages to clip-relative. - const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenPos = + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); const tweenDur = anim.duration ?? 1; const timelineEl = elements.find( (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, ); const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 4; + const elDuration = timelineEl?.duration ?? 1; const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { ...kf, percentage: clipPct }; + return { + ...kf, + percentage: clipPct, + tweenPercentage: kf.percentage, + propertyGroup: anim.propertyGroup, + }; }); const existing = mergedByElement.get(id); if (existing) { @@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile( } astFetchDoneRef.current = fetchKey; }); - }, [projectId, sourceFile, version]); + // elementCount is in the deps because new timeline elements (e.g. after a + // sub-composition expand) need their keyframe cache populated immediately; + // without it the effect won't re-run when elements appear/disappear. + }, [projectId, sourceFile, version, elementCount]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 8f16cceec..29dcd10b9 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState { y: number; elementId: string; percentage: number; + tweenPercentage?: number; currentEase?: string; } @@ -113,7 +114,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDelete(state.elementId, state.percentage); + onDelete(state.elementId, state.tweenPercentage ?? state.percentage); onClose(); }} > diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 6df0d41bd..699ebd4d3 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({ onSelectElement?.(el); const absTime = el.start + (pct / 100) * el.duration; onSeek?.(absTime); + const kfData = keyframeCache?.get(elKey); + const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); + usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); }} onShiftClickKeyframe={(elId, pct) => { toggleSelectedKeyframe(`${elId}:${pct}`); @@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({ onSeek?.(absTime); } const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => k.percentage === pct); + const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); setKfContextMenu({ - x: e.clientX, - y: e.clientY, + x: e.clientX + 4, + y: e.clientY + 2, elementId: elId, percentage: pct, + tweenPercentage: kf?.tweenPercentage, currentEase: kf?.ease ?? kfData?.ease, }); }} diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index acad691e1..bbe8b0ac9 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; - const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; + const isHighlighted = isKfSelected || atPlayhead; + const color = isHighlighted ? accentColor : "#a3a3a3"; return (