From 6521c64ea4148b93421e2274fc0a6711a0da4aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:24:23 -0400 Subject: [PATCH 01/16] feat(core): spring physics solver + runtime fixes + spring ease editor --- packages/core/src/runtime/init.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2a9200e24..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,6 +950,12 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP 3.x skips rendering when totalTime equals the current _tTime. + // A freshly created paused timeline has _tTime=0, so seeking to 0 is a + // no-op — percentage-keyframe values at 0% are never applied. Nudge to + // a micro-offset first to force GSAP to dirty its internal state, then + // seek to the real time so the render produces exact values. + state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From 5b1faf4fb8b8dd116575b881e0a55e7f3e195ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 16:14:24 +0000 Subject: [PATCH 02/16] 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. --- packages/core/src/runtime/init.ts | 6 ------ packages/producer/tests/style-9-prod/output/output.mp4 | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bf4396dec..2a9200e24 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,12 +950,6 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { - // GSAP 3.x skips rendering when totalTime equals the current _tTime. - // A freshly created paused timeline has _tTime=0, so seeking to 0 is a - // no-op — percentage-keyframe values at 0% are never applied. Nudge to - // a micro-offset first to force GSAP to dirty its internal state, then - // seek to the real time so the render produces exact values. - state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } diff --git a/packages/producer/tests/style-9-prod/output/output.mp4 b/packages/producer/tests/style-9-prod/output/output.mp4 index 6d2e3fa8f..e60cbb478 100644 --- a/packages/producer/tests/style-9-prod/output/output.mp4 +++ b/packages/producer/tests/style-9-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8fb3bbb1666443c7f004a7b9c6dfba594097611fed18cf386e91fc45f5d378b -size 13520567 +oid sha256:8caa731179c860a8a112c8f8b217098f2d6e8243d1fd7b05075c632e746525c4 +size 13512151 From 94f35b68f5d3602163fc04c4682a25a8bc308769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 17:43:48 +0000 Subject: [PATCH 03/16] ci: trigger regression run From 6f71a90ad9d4b09fd9ddfdaaa12582a0f0e76459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 01:54:58 -0400 Subject: [PATCH 04/16] 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. --- packages/producer/tests/style-9-prod/output/output.mp4 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/producer/tests/style-9-prod/output/output.mp4 b/packages/producer/tests/style-9-prod/output/output.mp4 index e60cbb478..6d2e3fa8f 100644 --- a/packages/producer/tests/style-9-prod/output/output.mp4 +++ b/packages/producer/tests/style-9-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8caa731179c860a8a112c8f8b217098f2d6e8243d1fd7b05075c632e746525c4 -size 13512151 +oid sha256:d8fb3bbb1666443c7f004a7b9c6dfba594097611fed18cf386e91fc45f5d378b +size 13520567 From a96331c117ddfc0830a4eb7411cb634f271859d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:26:53 -0400 Subject: [PATCH 05/16] feat(studio): design panel integration, timeline polish, feature flag --- packages/studio/src/components/StudioPreviewArea.tsx | 5 ----- packages/studio/src/components/nle/NLELayout.tsx | 3 --- packages/studio/src/player/components/Timeline.tsx | 3 --- 3 files changed, 11 deletions(-) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index fd92d36ff..4967608df 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -107,7 +107,6 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, - handleGsapRemoveAllKeyframes, } = useDomEditContext(); return ( @@ -128,10 +127,6 @@ export function StudioPreviewArea({ onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSelectTimelineElement={handleTimelineElementSelect} - onDeleteAllKeyframes={(_elId) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapRemoveAllKeyframes(anim.id); - }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); if (anim) handleGsapRemoveKeyframe(anim.id, pct); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index cad19d33f..adf338209 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -71,7 +71,6 @@ interface NLELayoutProps { onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; - onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; @@ -124,7 +123,6 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt, onSelectTimelineElement, onDeleteKeyframe, - onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, onToggleKeyframeAtPlayhead, @@ -459,7 +457,6 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} - onDeleteAllKeyframes={onDeleteAllKeyframes} onChangeKeyframeEase={onChangeKeyframeEase} onMoveKeyframe={onMoveKeyframe} onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 27dedc8bd..11a0ec7ef 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -72,7 +72,6 @@ interface TimelineProps { onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; - onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; @@ -93,7 +92,6 @@ export const Timeline = memo(function Timeline({ onBlockedEditAttempt, onSelectElement, onDeleteKeyframe, - onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, onToggleKeyframeAtPlayhead, @@ -572,7 +570,6 @@ export const Timeline = memo(function Timeline({ state={kfContextMenu} onClose={() => setKfContextMenu(null)} onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} - onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)} onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} onCopyProperties={(elId, pct) => { const kfData = keyframeCache.get(elId); From a4fc5b5c73b829bb382b364243e20ea18e8f3a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:28:50 -0400 Subject: [PATCH 06/16] fix(studio): rotation-aware drag + auto-keyframing for resize and rotation U1: stripGsapTranslateFromTransform now rotates the offset vector by the element's CSS rotation angle before subtracting from m41/m42. Fixes elements drifting from cursor during drag when rotated. U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the runtime bridge. Resize and rotation handle changes now create keyframes via the same async pipeline as position drag. CSS path guards prevent double-persistence for GSAP-animated elements. --- .../studio/src/hooks/gsapRuntimeBridge.ts | 106 +++--------------- 1 file changed, 13 insertions(+), 93 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 3a967e5e6..0ed14ea13 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -146,9 +146,7 @@ export async function tryGsapDragIntercept( const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; - await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, { - commitMutation, - }); + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); return true; } @@ -173,33 +171,14 @@ async function commitGsapPositionFromDrag( anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, - iframe: HTMLIFrameElement | null, - selector: string, callbacks: GsapDragCommitCallbacks, ): Promise { - // CSS composition: translate → rotate → transform. The studioOffset is in - // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate - // space (CSS transform). Counter-rotate the offset to match GSAP's frame. - const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation"); - const rotDeg = Number.parseFloat(rotStyle) || 0; - const rad = (-rotDeg * Math.PI) / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - const adjX = studioOffset.x * cos - studioOffset.y * sin; - const adjY = studioOffset.x * sin + studioOffset.y * cos; - const newX = Math.round(gsapPos.x + adjX); - const newY = Math.round(gsapPos.y + adjY); + const newX = Math.round(gsapPos.x + studioOffset.x); + const newY = Math.round(gsapPos.y + studioOffset.y); const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitKeyframedPosition( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - clearOffset, - ); + await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); } else if (anim.method === "from") { await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); } else if (anim.method === "fromTo") { @@ -207,14 +186,7 @@ async function commitGsapPositionFromDrag( } else { // Flat to()/set() — convert to keyframes first so the drag position // is captured at the current seek time, not just the tween endpoint. - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitFlatViaKeyframes( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - clearOffset, - ); + await commitFlatViaKeyframes(selection, anim, newX, newY, callbacks, clearOffset); } } @@ -222,7 +194,8 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - properties: Record, + newX: number, + newY: number, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -234,7 +207,7 @@ async function commitKeyframedPosition( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: { x: newX, y: newY }, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -249,7 +222,8 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - properties: Record, + newX: number, + newY: number, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -267,7 +241,7 @@ async function commitFlatViaKeyframes( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: { x: newX, y: newY }, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -332,49 +306,6 @@ async function commitFromToPosition( ); } -// ── Runtime property reader ─────────────────────────────────────────────── - -function readAllAnimatedProperties( - iframe: HTMLIFrameElement | null, - selector: string, - anim: GsapAnimation, -): Record { - const result: Record = {}; - if (!iframe?.contentWindow) return result; - let gsap: IframeGsap | undefined; - try { - gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; - } catch { - return result; - } - if (!gsap?.getProperty) return result; - let doc: Document | null = null; - try { - doc = iframe.contentDocument; - } catch { - return result; - } - const el = doc?.querySelector(selector); - if (!el) return result; - - const propKeys = new Set(); - if (anim.keyframes) { - for (const kf of anim.keyframes.keyframes) { - for (const p of Object.keys(kf.properties)) { - if (typeof kf.properties[p] === "number") propKeys.add(p); - } - } - } else { - for (const p of Object.keys(anim.properties)) propKeys.add(p); - } - - for (const prop of propKeys) { - const val = Number(gsap.getProperty(el, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); - } - return result; -} - // ── Resize intercept ────────────────────────────────────────────────────── export async function tryGsapResizeIntercept( @@ -404,21 +335,13 @@ export async function tryGsapResizeIntercept( ); } - const selector = selectorForSelection(selection); - const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - const properties = { - ...runtimeProps, - width: Math.round(size.width), - height: Math.round(size.height), - }; - await commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: { width: Math.round(size.width), height: Math.round(size.height) }, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -474,16 +397,13 @@ export async function tryGsapRotationIntercept( ); } - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - const properties = { ...runtimeProps, rotation: newRotation }; - await commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: { rotation: newRotation }, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); From b2b07e900a8b0a480d4cfc4499f5620a691cef33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:33:38 -0400 Subject: [PATCH 07/16] fix(studio): counter-rotate drag offset for css-rotated elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS compose order is translate → rotate → transform. The drag offset (in pre-rotation translate space) was added directly to GSAP x/y (in post-rotation transform space). Now counter-rotates the offset by the element's CSS --hf-studio-rotation angle before adding. --- packages/studio/src/hooks/gsapRuntimeBridge.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 0ed14ea13..910b11d11 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -173,8 +173,18 @@ async function commitGsapPositionFromDrag( gsapPos: { x: number; y: number }, callbacks: GsapDragCommitCallbacks, ): Promise { - const newX = Math.round(gsapPos.x + studioOffset.x); - const newY = Math.round(gsapPos.y + studioOffset.y); + // CSS composition: translate → rotate → transform. The studioOffset is in + // pre-rotation space (CSS translate), but GSAP x/y are in post-CSS-rotate + // space (CSS transform). Counter-rotate the offset to match GSAP's frame. + const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation"); + const rotDeg = Number.parseFloat(rotStyle) || 0; + const rad = (-rotDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const adjX = studioOffset.x * cos - studioOffset.y * sin; + const adjY = studioOffset.x * sin + studioOffset.y * cos; + const newX = Math.round(gsapPos.x + adjX); + const newY = Math.round(gsapPos.y + adjY); const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { From 9470f8f0ebd0213a95bc853fa2a53750d34e500d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:40:40 -0400 Subject: [PATCH 08/16] feat(studio): add 'delete all keyframes' to diamond context menu --- packages/studio/src/components/StudioPreviewArea.tsx | 5 +++++ packages/studio/src/components/nle/NLELayout.tsx | 3 +++ packages/studio/src/player/components/Timeline.tsx | 3 +++ 3 files changed, 11 insertions(+) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 4967608df..fd92d36ff 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -107,6 +107,7 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, + handleGsapRemoveAllKeyframes, } = useDomEditContext(); return ( @@ -127,6 +128,10 @@ export function StudioPreviewArea({ onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSelectTimelineElement={handleTimelineElementSelect} + onDeleteAllKeyframes={(_elId) => { + const anim = selectedGsapAnimations.find((a) => a.keyframes); + if (anim) handleGsapRemoveAllKeyframes(anim.id); + }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); if (anim) handleGsapRemoveKeyframe(anim.id, pct); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index adf338209..cad19d33f 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -71,6 +71,7 @@ interface NLELayoutProps { onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; @@ -123,6 +124,7 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt, onSelectTimelineElement, onDeleteKeyframe, + onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, onToggleKeyframeAtPlayhead, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onBlockedEditAttempt={onBlockedEditAttempt} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} + onDeleteAllKeyframes={onDeleteAllKeyframes} onChangeKeyframeEase={onChangeKeyframeEase} onMoveKeyframe={onMoveKeyframe} onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 11a0ec7ef..27dedc8bd 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -72,6 +72,7 @@ interface TimelineProps { onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onDeleteAllKeyframes?: (elementId: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; @@ -92,6 +93,7 @@ export const Timeline = memo(function Timeline({ onBlockedEditAttempt, onSelectElement, onDeleteKeyframe, + onDeleteAllKeyframes, onChangeKeyframeEase, onMoveKeyframe, onToggleKeyframeAtPlayhead, @@ -570,6 +572,7 @@ export const Timeline = memo(function Timeline({ state={kfContextMenu} onClose={() => setKfContextMenu(null)} onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} + onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)} onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} onCopyProperties={(elId, pct) => { const kfData = keyframeCache.get(elId); From d71e4ad03dbd4674eb10230b7c65be48a2e40574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 14:47:07 -0400 Subject: [PATCH 09/16] fix(studio): include all animated properties in every keyframe commit Position, resize, and rotation intercepts now read ALL animated property values from gsap.getProperty() at commit time and include them in the keyframe. Prevents other properties from jumping to interpolated values between surrounding keyframes when only one property (e.g., width) was explicitly changed. --- .../studio/src/hooks/gsapRuntimeBridge.ts | 92 ++++++++++++++++--- 1 file changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 910b11d11..3a967e5e6 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -146,7 +146,9 @@ export async function tryGsapDragIntercept( const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; - await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, { commitMutation }); + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, { + commitMutation, + }); return true; } @@ -171,6 +173,8 @@ async function commitGsapPositionFromDrag( anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, + iframe: HTMLIFrameElement | null, + selector: string, callbacks: GsapDragCommitCallbacks, ): Promise { // CSS composition: translate → rotate → transform. The studioOffset is in @@ -188,7 +192,14 @@ async function commitGsapPositionFromDrag( const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { - await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + await commitKeyframedPosition( + selection, + anim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + clearOffset, + ); } else if (anim.method === "from") { await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); } else if (anim.method === "fromTo") { @@ -196,7 +207,14 @@ async function commitGsapPositionFromDrag( } else { // Flat to()/set() — convert to keyframes first so the drag position // is captured at the current seek time, not just the tween endpoint. - await commitFlatViaKeyframes(selection, anim, newX, newY, callbacks, clearOffset); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + await commitFlatViaKeyframes( + selection, + anim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + clearOffset, + ); } } @@ -204,8 +222,7 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -217,7 +234,7 @@ async function commitKeyframedPosition( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { x: newX, y: newY }, + properties, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -232,8 +249,7 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -251,7 +267,7 @@ async function commitFlatViaKeyframes( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { x: newX, y: newY }, + properties, }, { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, ); @@ -316,6 +332,49 @@ async function commitFromToPosition( ); } +// ── Runtime property reader ─────────────────────────────────────────────── + +function readAllAnimatedProperties( + iframe: HTMLIFrameElement | null, + selector: string, + anim: GsapAnimation, +): Record { + const result: Record = {}; + if (!iframe?.contentWindow) return result; + let gsap: IframeGsap | undefined; + try { + gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + } catch { + return result; + } + if (!gsap?.getProperty) return result; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return result; + } + const el = doc?.querySelector(selector); + if (!el) return result; + + const propKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") propKeys.add(p); + } + } + } else { + for (const p of Object.keys(anim.properties)) propKeys.add(p); + } + + for (const prop of propKeys) { + const val = Number(gsap.getProperty(el, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + return result; +} + // ── Resize intercept ────────────────────────────────────────────────────── export async function tryGsapResizeIntercept( @@ -345,13 +404,21 @@ export async function tryGsapResizeIntercept( ); } + const selector = selectorForSelection(selection); + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + const properties = { + ...runtimeProps, + width: Math.round(size.width), + height: Math.round(size.height), + }; + await commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { width: Math.round(size.width), height: Math.round(size.height) }, + properties, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -407,13 +474,16 @@ export async function tryGsapRotationIntercept( ); } + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const properties = { ...runtimeProps, rotation: newRotation }; + await commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { rotation: newRotation }, + properties, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); From 92bdaa7ad79e3ed379fe603a000c04d1b28c8bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:24:23 -0400 Subject: [PATCH 10/16] feat(core): spring physics solver + runtime fixes + spring ease editor --- packages/core/src/runtime/init.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2a9200e24..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,6 +950,12 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP 3.x skips rendering when totalTime equals the current _tTime. + // A freshly created paused timeline has _tTime=0, so seeking to 0 is a + // no-op — percentage-keyframe values at 0% are never applied. Nudge to + // a micro-offset first to force GSAP to dirty its internal state, then + // seek to the real time so the render produces exact values. + state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From 4efd0f9af421a81830a5d7d34a098e20ebac711c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 16:14:24 +0000 Subject: [PATCH 11/16] 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. --- packages/core/src/runtime/init.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bf4396dec..2a9200e24 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,12 +950,6 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { - // GSAP 3.x skips rendering when totalTime equals the current _tTime. - // A freshly created paused timeline has _tTime=0, so seeking to 0 is a - // no-op — percentage-keyframe values at 0% are never applied. Nudge to - // a micro-offset first to force GSAP to dirty its internal state, then - // seek to the real time so the render produces exact values. - state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From 9efc8bdb1ff156105118af1c9b1acadaa49889ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 17:43:48 +0000 Subject: [PATCH 12/16] ci: trigger regression run From 3c163cf5ba327c16c3e5bf9b3aaa65d6886f6a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 01:24:23 -0400 Subject: [PATCH 13/16] feat(core): spring physics solver + runtime fixes + spring ease editor --- packages/core/src/runtime/init.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2a9200e24..bf4396dec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,6 +950,12 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP 3.x skips rendering when totalTime equals the current _tTime. + // A freshly created paused timeline has _tTime=0, so seeking to 0 is a + // no-op — percentage-keyframe values at 0% are never applied. Nudge to + // a micro-offset first to force GSAP to dirty its internal state, then + // seek to the real time so the render produces exact values. + state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From 52a7945c18d511c3f2016e6cd7f2253829673f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 16:14:24 +0000 Subject: [PATCH 14/16] 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. --- packages/core/src/runtime/init.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bf4396dec..2a9200e24 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -950,12 +950,6 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { - // GSAP 3.x skips rendering when totalTime equals the current _tTime. - // A freshly created paused timeline has _tTime=0, so seeking to 0 is a - // no-op — percentage-keyframe values at 0% are never applied. Nudge to - // a micro-offset first to force GSAP to dirty its internal state, then - // seek to the real time so the render produces exact values. - state.capturedTimeline.totalTime(seekTime + 0.001, true); state.capturedTimeline.totalTime(seekTime, false); } From 1ffea55c6169f23b7545964307d542d44aeac972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 17:43:48 +0000 Subject: [PATCH 15/16] ci: trigger regression run From ba5a06a3e2febf6b0f14f417380370562229b96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:51:38 -0400 Subject: [PATCH 16/16] feat(studio): runtime-first dynamic keyframe system with auto-materialization Read GSAP keyframe data from the live runtime instead of only the AST parser. Dynamic keyframes (loops, variables, computed selectors) now show diamonds on timeline clips and animation cards in the design panel. On first edit, dynamic code is automatically materialized: - Unresolved keyframes (keyframes: kf) replaced with static object - Unresolved selectors (tl.to(sel, ...)) entire loop unrolled into individual static tl.to() calls per element Key changes: - Parser: hasUnresolvedKeyframes/hasUnresolvedSelector flags - Runtime bridge: scanAllRuntimeKeyframes reads tween.vars from iframe - Tween cache: interval-based runtime scan for dynamic animations - materializeKeyframesInScript + unrollDynamicAnimations parser functions - Keyframe cache dual-writes both sourceFile#id and index.html#id keys - commitMutation updates cache from mutation response - easeEach placement fix (inside keyframes object, not tween vars) --- packages/core/src/parsers/gsapParser.ts | 227 ++++++++++++++++-- packages/core/src/parsers/gsapSerialize.ts | 4 + packages/core/src/studio-api/routes/files.ts | 69 +++++- .../studio/src/components/TimelineToolbar.tsx | 42 +++- .../studio/src/contexts/DomEditContext.tsx | 3 + .../studio/src/hooks/gsapRuntimeBridge.ts | 100 +++++++- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 170 +++++++++++++ .../studio/src/hooks/useDomEditSession.ts | 47 ++++ .../studio/src/hooks/useGsapScriptCommits.ts | 11 + .../studio/src/hooks/useGsapTweenCache.ts | 138 ++++++++++- 10 files changed, 773 insertions(+), 38 deletions(-) create mode 100644 packages/studio/src/hooks/gsapRuntimeKeyframes.ts diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index b4c484bfb..478fbdbba 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -419,11 +419,8 @@ function findAllTweenCalls( this.traverse(path); return; } - const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings); - if (!selectorValue) { - this.traverse(path); - return; - } + const selectorValue = + resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__"; if (method === "fromTo") { results.push({ @@ -697,6 +694,7 @@ function tweenCallToAnimation( const properties: Record = {}; const extras: Record = {}; let keyframesData: GsapKeyframesData | undefined; + let hasUnresolvedKeyframes = false; for (const [key, val] of Object.entries(vars)) { if (BUILTIN_VAR_KEYS.has(key)) continue; @@ -705,6 +703,7 @@ function tweenCallToAnimation( if (key === "keyframes") { const kfNode = findPropertyNode(call.varsArg, "keyframes"); keyframesData = parseKeyframesNode(kfNode, scope); + if (!keyframesData && kfNode) hasUnresolvedKeyframes = true; continue; } @@ -763,6 +762,8 @@ function tweenCallToAnimation( }; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; + if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; + if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; return anim; } @@ -1174,6 +1175,7 @@ export function addKeyframeToScript( percentage: number, properties: Record, ease?: string, + backfillDefaults?: Record, ): string { const loc = locateAnimation(script, animationId); if (!loc) return script; @@ -1189,25 +1191,48 @@ export function addKeyframeToScript( ); if (existingIdx !== -1) { kfNode.properties[existingIdx].value = newValueNode; - return recast.print(loc.parsed.ast).code; + } else { + // Build the new property node with a quoted percentage key + const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; + newProp.value = newValueNode; + + // Insert in sorted order by percentage + let insertIdx = kfNode.properties.length; + for (let i = 0; i < kfNode.properties.length; i++) { + const key = isObjectProperty(kfNode.properties[i]) + ? propKeyName(kfNode.properties[i]) + : undefined; + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertIdx = i; + break; + } + } + kfNode.properties.splice(insertIdx, 0, newProp); } - // Build the new property node with a quoted percentage key - const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; - newProp.value = newValueNode; - - // Insert in sorted order by percentage - let insertIdx = kfNode.properties.length; - for (let i = 0; i < kfNode.properties.length; i++) { - const key = isObjectProperty(kfNode.properties[i]) - ? propKeyName(kfNode.properties[i]) - : undefined; - if (typeof key === "string" && percentageFromKey(key) > percentage) { - insertIdx = i; - break; + // Backfill: when the new keyframe introduces properties absent from other + // keyframes, add default values so GSAP can interpolate them. + if (backfillDefaults) { + const newPropKeys = Object.keys(properties); + const pctProps = filterPercentageProps(kfNode); + for (const prop of pctProps) { + const key = propKeyName(prop); + if (key === pctKey) continue; + const valObj = prop.value; + if (!valObj || valObj.type !== "ObjectExpression") continue; + const existingKeys = new Set( + valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)), + ); + for (const pk of newPropKeys) { + if (existingKeys.has(pk)) continue; + const defaultVal = backfillDefaults[pk]; + if (defaultVal == null) continue; + const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0]; + valObj.properties.push(fillProp); + } } } - kfNode.properties.splice(insertIdx, 0, newProp); + return recast.print(loc.parsed.ast).code; } @@ -1329,10 +1354,12 @@ function insertKeyframesProp( varsArg: any, fromProps: Record, toProps: Record, + easeEach?: string, ): void { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`; + const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; const kfProp = parseExpr(`{ keyframes: {} }`).properties[0]; kfProp.value = parseExpr(kfCode); if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp); @@ -1359,10 +1386,9 @@ export function convertToKeyframesInScript( const originalEase = anim.ease; stripEditableAndEase(varsArg); - insertKeyframesProp(varsArg, fromProps, toProps); + insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined); if (originalEase) { - setVarsKey(varsArg, "easeEach", originalEase); setVarsKey(varsArg, "ease", "none"); } @@ -1400,3 +1426,158 @@ export function removeAllKeyframesFromScript(script: string, animationId: string return recast.print(loc.parsed.ast).code; } + +/** + * Replace a dynamic `keyframes: ` with a static percentage-keyframes object. + * Called when the user first edits a dynamically-generated keyframe in the studio. + */ +export function materializeKeyframesInScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Replace dynamic selector with resolved static string + if (resolvedSelector && loc.target.call.node.arguments[0]) { + loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector)); + } + + const entries: string[] = []; + const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); + for (const kf of sorted) { + const propEntries = Object.entries(kf.properties).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`); + entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); + } + if (easeEach) { + entries.push(`easeEach: ${JSON.stringify(easeEach)}`); + } + + const kfObjCode = `{ ${entries.join(", ")} }`; + const kfParent = varsArg.properties.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (kfParent) { + kfParent.value = parseExpr(kfObjCode); + } else { + const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0]; + varsArg.properties.unshift(kfProp); + } + + removeVarsKey(varsArg, "easeEach"); + + return recast.print(loc.parsed.ast).code; +} + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the entire loop body with unrolled static calls. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Read duration and ease from the original tween vars + const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope); + const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope); + const duration = typeof durationVal === "number" ? durationVal : 8; + const ease = typeof easeVal === "string" ? easeVal : "none"; + const posArg = loc.target.call.positionArg; + const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0; + const posCode = + typeof position === "number" + ? String(position) + : typeof position === "string" + ? JSON.stringify(position) + : "0"; + + // Find the enclosing loop (for/forEach) by walking up the AST path + let loopNode: any = null; + let current = loc.target.call.path; + while (current) { + const node = current.node ?? current.value; + if ( + node?.type === "ForStatement" || + node?.type === "ForInStatement" || + node?.type === "ForOfStatement" || + node?.type === "WhileStatement" + ) { + loopNode = node; + break; + } + if ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ) { + loopNode = node; + break; + } + current = current.parent ?? current.parentPath; + } + + // Build replacement code: individual tl.to() calls for each element + const calls: string[] = []; + for (const el of elements) { + const kfEntries: string[] = []; + const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage); + for (const kf of sorted) { + const propEntries = Object.entries(kf.properties).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); + } + if (el.easeEach) { + kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`); + } + calls.push( + `tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + ); + } + + const replacement = calls.join("\n "); + + if (loopNode) { + // Replace the entire loop with the unrolled calls + const start = loopNode.start ?? loopNode.range?.[0]; + const end = loopNode.end ?? loopNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + // Fallback: replace just the tween call's enclosing expression statement + const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node; + if (stmtNode?.type === "ExpressionStatement") { + const start = stmtNode.start ?? stmtNode.range?.[0]; + const end = stmtNode.end ?? stmtNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + return script; +} diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index 0e482be5d..364c586da 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -23,6 +23,10 @@ export interface GsapAnimation { extras?: Record; /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ keyframes?: GsapKeyframesData; + /** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */ + hasUnresolvedKeyframes?: boolean; + /** True when the tween's target selector couldn't be statically resolved (dynamic). */ + hasUnresolvedSelector?: boolean; } export interface GsapPercentageKeyframe { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 8e409a322..fddd4321d 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -22,6 +22,7 @@ import { removeElementFromHtml, patchElementInHtml, probeElementInSource, + splitElementInHtml, type PatchOperation, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; @@ -316,6 +317,39 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { ); }); + api.post("/projects/:id/file-mutations/split-element/*", async (c) => { + const ctx = await resolveFileMutationContext(c, adapter, "split-element"); + if ("error" in ctx) return ctx.error; + + const parsed = await parseMutationBody<{ + target?: { id?: string; selector?: string; selectorIndex?: number }; + splitTime?: number; + newId?: string; + }>(c); + if ("error" in parsed) return parsed.error; + if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) { + return c.json({ error: "target, splitTime, and newId required" }, 400); + } + + let originalContent: string; + try { + originalContent = readFileSync(ctx.absPath, "utf-8"); + } catch { + return c.json({ error: "not found" }, 404); + } + const result = splitElementInHtml( + originalContent, + parsed.target, + parsed.body.splitTime, + parsed.body.newId, + ); + if (!result.matched) { + return c.json({ ok: false, changed: false, content: originalContent }); + } + writeFileSync(ctx.absPath, result.html, "utf-8"); + return c.json({ ok: true, changed: true, content: result.html, newId: result.newId }); + }); + api.post("/projects/:id/file-mutations/patch-element/*", async (c) => { const ctx = await resolveFileMutationContext(c, adapter, "patch-element"); if ("error" in ctx) return ctx.error; @@ -586,6 +620,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { percentage: number; properties: Record; ease?: string; + backfillDefaults?: Record; } | { type: "remove-keyframe"; animationId: string; percentage: number } | { @@ -600,7 +635,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { animationId: string; resolvedFromValues?: Record; } - | { type: "remove-all-keyframes"; animationId: string }; + | { type: "remove-all-keyframes"; animationId: string } + | { + type: "materialize-keyframes"; + animationId: string; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + easeEach?: string; + resolvedSelector?: string; + allElements?: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>; + }; api.post("/projects/:id/gsap-mutations/*", async (c) => { const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, { @@ -735,6 +786,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { body.percentage, body.properties, body.ease, + body.backfillDefaults, ); break; } @@ -768,6 +820,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId); break; } + case "materialize-keyframes": { + const { materializeKeyframesInScript, unrollDynamicAnimations } = await loadGsapParser(); + if (body.allElements && body.allElements.length > 0) { + newScript = unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements); + } else { + newScript = materializeKeyframesInScript( + block.scriptText, + body.animationId, + body.keyframes, + body.easeEach, + body.resolvedSelector, + ); + } + break; + } default: return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index d280d0733..1535eb80a 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -3,7 +3,7 @@ import { getTimelineZoomPercent, } from "../player/components/timelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; -import { usePlayerStore } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; @@ -85,6 +85,7 @@ interface DomEditSessionSlice { handleGsapRemoveKeyframe: (animId: string, pct: number) => void; handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; handleGsapConvertToKeyframes: (animId: string) => void; + handleGsapMaterializeKeyframes: (animId: string) => Promise; handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; previewIframeRef?: React.RefObject; } @@ -92,6 +93,7 @@ interface DomEditSessionSlice { interface TimelineToolbarProps { toggleTimelineVisibility: () => void; domEditSession?: DomEditSessionSlice; + onSplitElement?: (element: TimelineElement, splitTime: number) => void; } // fallow-ignore-next-line complexity @@ -119,9 +121,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { // fallow-ignore-next-line complexity const onToggle = sel - ? () => { + ? async () => { const t = usePlayerStore.getState().currentTime; if (kfAnim?.keyframes) { + if (kfAnim.hasUnresolvedKeyframes) { + await session.handleGsapMaterializeKeyframes(kfAnim.id); + } const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; const pct = @@ -161,6 +166,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { export function TimelineToolbar({ toggleTimelineVisibility, domEditSession, + onSplitElement, }: TimelineToolbarProps) { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); @@ -212,6 +218,38 @@ export function TimelineToolbar({ )} + {onSplitElement && ( + + + + )}
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 39b7183da..c556718f6 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,6 +68,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -135,6 +136,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -196,6 +198,7 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 3a967e5e6..13c2ca665 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -12,6 +12,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { clearStudioPathOffset } from "../components/editor/manualEdits"; import { usePlayerStore } from "../player/store/playerStore"; +import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -99,6 +100,52 @@ function computeCurrentPercentage(selection: DomEditSelection): number { : 0; } +// ── Dynamic keyframe materialization ────────────────────────────────────── + +async function materializeIfDynamic( + anim: GsapAnimation, + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + selection: DomEditSelection, +): Promise { + if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return; + + if (anim.hasUnresolvedSelector) { + // Unroll: read ALL elements' keyframes from runtime and replace the loop + const allScanned = scanAllRuntimeKeyframes(iframe); + if (allScanned.size === 0) return; + const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ + selector: `#${id}`, + keyframes: data.keyframes, + easeEach: data.easeEach, + })); + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [], + allElements, + }, + { label: "Unroll dynamic animations", skipReload: true }, + ); + return `${anim.targetSelector}-to-0`; + } + + const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); + if (!runtime || runtime.keyframes.length === 0) return; + await commitMutation( + selection, + { + type: "materialize-keyframes", + animationId: anim.id, + keyframes: runtime.keyframes, + easeEach: runtime.easeEach, + }, + { label: "Materialize dynamic keyframes", skipReload: true }, + ); +} + // ── High-level intercept ─────────────────────────────────────────────────── export interface GsapDragCommitCallbacks { @@ -192,10 +239,12 @@ async function commitGsapPositionFromDrag( const clearOffset = () => clearStudioPathOffset(selection.element); if (anim.keyframes) { + const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); + const effectiveAnim = newId ? { ...anim, id: newId } : anim; const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitKeyframedPosition( selection, - anim, + effectiveAnim, { ...runtimeProps, x: newX, y: newY }, callbacks, clearOffset, @@ -334,6 +383,24 @@ async function commitFromToPosition( // ── Runtime property reader ─────────────────────────────────────────────── +function readGsapProperty( + iframe: HTMLIFrameElement | null, + selector: string | null, + prop: string, +): number | null { + if (!iframe?.contentWindow || !selector) return null; + try { + const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + const val = Number(gsap.getProperty(el, prop)); + return Number.isFinite(val) ? Math.round(val) : null; + } catch { + return null; + } +} + function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, @@ -396,7 +463,10 @@ export async function tryGsapResizeIntercept( const pct = computeCurrentPercentage(selection); - if (!anim.keyframes) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -406,6 +476,17 @@ export async function tryGsapResizeIntercept( const selector = selectorForSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + + const backfillDefaults: Record = { ...runtimeProps }; + if (!("width" in runtimeProps)) { + const cssW = readGsapProperty(iframe, selector, "width"); + backfillDefaults.width = cssW ?? Math.round(size.width); + } + if (!("height" in runtimeProps)) { + const cssH = readGsapProperty(iframe, selector, "height"); + backfillDefaults.height = cssH ?? Math.round(size.height); + } + const properties = { ...runtimeProps, width: Math.round(size.width), @@ -419,6 +500,7 @@ export async function tryGsapResizeIntercept( animationId: anim.id, percentage: pct, properties, + backfillDefaults, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -466,7 +548,10 @@ export async function tryGsapRotationIntercept( const pct = computeCurrentPercentage(selection); const newRotation = Math.round(gsapRotation + angle); - if (!anim.keyframes) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -475,6 +560,12 @@ export async function tryGsapRotationIntercept( } const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + + const backfillDefaults: Record = { ...runtimeProps }; + if (!("rotation" in runtimeProps)) { + backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0; + } + const properties = { ...runtimeProps, rotation: newRotation }; await commitMutation( @@ -484,8 +575,11 @@ export async function tryGsapRotationIntercept( animationId: anim.id, percentage: pct, properties, + backfillDefaults, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); return true; } + +export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts new file mode 100644 index 000000000..4aa976f59 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -0,0 +1,170 @@ +/** + * Read GSAP keyframe data from the live runtime in the preview iframe. + * Used to discover dynamic keyframes that the AST parser can't resolve + * (loops, variables, computed selectors). + */ + +interface RuntimeTween { + targets?: () => Element[]; + vars?: Record; + duration?: () => number; + startTime?: () => number; +} + +interface RuntimeTimeline { + getChildren?: (deep: boolean) => RuntimeTween[]; + duration?: () => number; +} + +export function readRuntimeKeyframes( + iframe: HTMLIFrameElement | null, + selector: string, + compositionId?: string, +): { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +} | null { + if (!iframe?.contentWindow) return null; + + let timelines: Record | undefined; + try { + timelines = ( + iframe.contentWindow as unknown as { __timelines?: Record } + ).__timelines; + } catch { + return null; + } + if (!timelines) return null; + + const tlId = compositionId || Object.keys(timelines)[0]; + if (!tlId) return null; + const timeline = timelines[tlId]; + if (!timeline?.getChildren) return null; + + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return null; + } + if (!doc) return null; + + const targetEl = doc.querySelector(selector); + if (!targetEl) return null; + + for (const tween of timeline.getChildren(true)) { + if (!tween.targets || !tween.vars) continue; + let matches = false; + for (const t of tween.targets()) { + if (t === targetEl || (targetEl.id && t.id === targetEl.id)) { + matches = true; + break; + } + } + if (!matches) continue; + + const vars = tween.vars; + if (!vars.keyframes || typeof vars.keyframes !== "object") continue; + + const kfObj = vars.keyframes as Record; + const result: Array<{ percentage: number; properties: Record }> = []; + let easeEach: string | undefined; + + for (const [key, val] of Object.entries(kfObj)) { + if (key === "easeEach") { + if (typeof val === "string") easeEach = val; + continue; + } + const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); + if (!pctMatch || !val || typeof val !== "object") continue; + const percentage = parseFloat(pctMatch[1]); + const properties: Record = {}; + for (const [pk, pv] of Object.entries(val as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) { + result.push({ percentage, properties }); + } + } + + if (result.length > 0) { + result.sort((a, b) => a.percentage - b.percentage); + return { keyframes: result, easeEach }; + } + } + return null; +} + +// fallow-ignore-next-line complexity +export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< + string, + { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + } +> { + const result = new Map< + string, + { + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + } + >(); + if (!iframe?.contentWindow) return result; + + let timelines: Record | undefined; + try { + timelines = ( + iframe.contentWindow as unknown as { __timelines?: Record } + ).__timelines; + } catch { + return result; + } + if (!timelines) return result; + + for (const timeline of Object.values(timelines)) { + if (!timeline?.getChildren) continue; + for (const tween of timeline.getChildren(true)) { + if (!tween.targets || !tween.vars) continue; + const vars = tween.vars; + if (!vars.keyframes || typeof vars.keyframes !== "object") continue; + + const kfObj = vars.keyframes as Record; + const keyframes: Array<{ percentage: number; properties: Record }> = + []; + let easeEach: string | undefined; + + for (const [key, val] of Object.entries(kfObj)) { + if (key === "easeEach") { + if (typeof val === "string") easeEach = val; + continue; + } + const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); + if (!pctMatch || !val || typeof val !== "object") continue; + const percentage = parseFloat(pctMatch[1]); + const properties: Record = {}; + for (const [pk, pv] of Object.entries(val as Record)) { + if (pk === "ease") continue; + if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; + else if (typeof pv === "string") properties[pk] = pv; + } + if (Object.keys(properties).length > 0) { + keyframes.push({ percentage, properties }); + } + } + + if (keyframes.length === 0) continue; + keyframes.sort((a, b) => a.percentage - b.percentage); + + for (const target of tween.targets()) { + const id = (target as HTMLElement).id; + if (id && !result.has(id)) { + result.set(id, { keyframes, easeEach }); + } + } + } + } + return result; +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 5edaef835..92aed8b00 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -27,6 +27,7 @@ import { tryGsapDragIntercept, tryGsapResizeIntercept, tryGsapRotationIntercept, + readRuntimeKeyframes, } from "./gsapRuntimeBridge"; // ── Types ── @@ -215,6 +216,7 @@ export function useDomEditSession({ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, gsapSourceFile, gsapCacheVersion, + previewIframeRef, ); const { @@ -228,6 +230,7 @@ export function useDomEditSession({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, + previewIframeRef, ); const { @@ -491,6 +494,49 @@ export function useDomEditSession({ [domEditSelection, convertToKeyframes], ); + const handleGsapMaterializeKeyframes = useCallback( + async (animId: string) => { + if (!domEditSelection || !gsapCommitMutation) return; + const anim = selectedGsapAnimations.find((a) => a.id === animId); + if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes) + return; + if (anim.hasUnresolvedSelector) { + const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes"); + const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current); + if (allScanned.size === 0) return; + const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ + selector: `#${id}`, + keyframes: data.keyframes, + easeEach: data.easeEach, + })); + await gsapCommitMutation( + domEditSelection, + { + type: "materialize-keyframes", + animationId: animId, + keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [], + allElements, + }, + { label: "Unroll dynamic animations", skipReload: true }, + ); + return; + } + const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector); + if (!runtime || runtime.keyframes.length === 0) return; + await gsapCommitMutation( + domEditSelection, + { + type: "materialize-keyframes", + animationId: animId, + keyframes: runtime.keyframes, + easeEach: runtime.easeEach, + }, + { label: "Materialize dynamic keyframes", skipReload: true }, + ); + }, + [domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef], + ); + const handleGsapRemoveAllKeyframes = useCallback( (animId: string) => { if (!domEditSelection) return; @@ -656,6 +702,7 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, + handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache: bumpGsapCache, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 75484e307..4c5908f4a 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -164,6 +164,17 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (result.parsed?.animations) { + const { setKeyframeCache } = usePlayerStore.getState(); + for (const anim of result.parsed.animations) { + if (!anim.keyframes) continue; + const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; + if (!id) continue; + setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + } + } + if (options.skipReload) return; options.beforeReload?.(); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 70fafa9a4..b2c2d5280 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; +import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; function extractIdFromSelector(selector: string): string | null { const match = selector.match(/^#([\w-]+)/); @@ -53,6 +54,7 @@ export function useGsapAnimationsForElement( sourceFile: string, target: GsapElementTarget | null, version: number, + iframeRef?: React.RefObject, ): { animations: GsapAnimation[]; multipleTimelines: boolean; @@ -94,9 +96,23 @@ export function useGsapAnimationsForElement( }; }, [projectId, sourceFile, version]); + // Retry fetch if we have a target but no animations — handles cold-load race + // where the initial fetch runs before the drilled-down sourceFile is resolved + useEffect(() => { + if (!projectId || !target || allAnimations.length > 0) return; + const timer = setTimeout(() => { + fetchParsedAnimations(projectId, sourceFile).then((parsed) => { + if (parsed && parsed.animations.length > 0) { + setAllAnimations(parsed.animations); + } + }); + }, 800); + return () => clearTimeout(timer); + }, [projectId, sourceFile, target, allAnimations.length]); + const targetId = target?.id ?? null; const targetSelector = target?.selector ?? null; - const animations = useMemo( + const rawAnimations = useMemo( () => targetId || targetSelector ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector }) @@ -104,6 +120,66 @@ export function useGsapAnimationsForElement( [allAnimations, targetId, targetSelector], ); + const animations = useMemo(() => { + const iframe = iframeRef?.current; + let result = rawAnimations; + + // Enrich animations with unresolved keyframes from runtime + if (iframe) { + result = result.map((anim) => { + if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim; + const runtime = readRuntimeKeyframes(iframe, anim.targetSelector); + if (!runtime) return anim; + return { + ...anim, + keyframes: { + format: "percentage" as const, + keyframes: runtime.keyframes, + ...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}), + }, + }; + }); + } + + // Match unresolved-selector animations from the parser to runtime tweens + // targeting this element. This handles fully dynamic code (loop with variable selector). + if (iframe && targetId && result.length === 0) { + const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector); + if (unresolvedAnims.length > 0) { + const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`); + if (runtimeData) { + const scanned = scanAllRuntimeKeyframes(iframe); + const runtimeEntry = scanned.get(targetId); + if (runtimeEntry) { + // Find which unresolved animation index matches this element + // by correlating parser order with runtime tween order + const runtimeIds = Array.from(scanned.keys()); + const runtimeIndex = runtimeIds.indexOf(targetId); + const matchedAnim = + runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length + ? unresolvedAnims[runtimeIndex] + : unresolvedAnims[0]; + if (matchedAnim) { + result = [ + { + ...matchedAnim, + targetSelector: `#${targetId}`, + keyframes: { + format: "percentage" as const, + keyframes: runtimeEntry.keyframes, + ...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}), + }, + }, + ]; + } + } + } + } + } + + return result; + }, [rawAnimations, allAnimations, iframeRef, targetId]); + // Populate keyframe cache for the selected element. // Key format must match timeline element keys: "sourceFile#domId". const elementId = target?.id ?? null; @@ -132,28 +208,72 @@ export function usePopulateKeyframeCacheForFile( projectId: string | null, sourceFile: string, version: number, + iframeRef?: React.RefObject, ): void { const lastFetchKeyRef = useRef(""); + const runtimeScanDoneRef = useRef(""); + useEffect(() => { const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; + runtimeScanDoneRef.current = ""; if (!projectId) return; - let cancelled = false; - fetchParsedAnimations(projectId, sourceFile).then((parsed) => { - if (cancelled || !parsed) return; + const sf = sourceFile; + fetchParsedAnimations(projectId, sf).then((parsed) => { + if (!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); + if (!id || !anim.keyframes) continue; + setKeyframeCache(`${sf}#${id}`, anim.keyframes); + if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); } + runtimeScanDoneRef.current = fetchKey; }); + }, [projectId, sourceFile, version]); - return () => { - cancelled = true; + // Separate effect for runtime keyframe discovery — polls until the iframe + // has loaded GSAP timelines, independent of the AST fetch lifecycle. + useEffect(() => { + if (!projectId) return; + const sf = sourceFile; + + let attempts = 0; + const maxAttempts = 10; + + const tryRuntimeScan = () => { + if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true; + const iframe = iframeRef?.current; + if (!iframe) return false; + const scanned = scanAllRuntimeKeyframes(iframe); + if (scanned.size === 0) return false; + const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); + for (const [id, data] of scanned) { + const cacheKey = `${sf}#${id}`; + const fallbackKey = `index.html#${id}`; + if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue; + const entry = { + format: "percentage" as const, + keyframes: data.keyframes, + ...(data.easeEach ? { easeEach: data.easeEach } : {}), + }; + setKeyframeCache(cacheKey, entry); + if (sf !== "index.html") setKeyframeCache(fallbackKey, entry); + } + runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`; + return true; }; - }, [projectId, sourceFile, version]); + + if (tryRuntimeScan()) return; + + const interval = setInterval(() => { + attempts++; + if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval); + }, 500); + + return () => clearInterval(interval); + }, [projectId, sourceFile, version, iframeRef]); }