From 4e5aa82cf4e6b87dbd9be706061be0acfe0b3ac3 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/37] 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 911b08a09f1e1c3d629450faeab1b2f5285c40d4 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/37] 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 9260681b2f3e3515618fbb7aeb22ffdcd900cb96 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/37] ci: trigger regression run From 5c134877cf8cf63401d3c21045a6a74056596077 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/37] 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 d6a1c9ddc2dbfb0d3ca7ccb398669bad451f997c 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/37] 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 8d58cb58d433fd554f8ffbe148d0a8202a5b1bc8 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/37] 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 | 204 ++---------------- 1 file changed, 15 insertions(+), 189 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 13c2ca665..0ed14ea13 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -12,7 +12,6 @@ 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 ────────────────────────────────────────────────────────── @@ -100,52 +99,6 @@ 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 { @@ -193,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; } @@ -220,35 +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 newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); - const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitKeyframedPosition( - selection, - effectiveAnim, - { ...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") { @@ -256,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); } } @@ -271,7 +194,8 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - properties: Record, + newX: number, + newY: number, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -283,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 }, ); @@ -298,7 +222,8 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - properties: Record, + newX: number, + newY: number, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -316,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 }, ); @@ -381,67 +306,6 @@ 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, - 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( @@ -463,10 +327,7 @@ export async function tryGsapResizeIntercept( const pct = computeCurrentPercentage(selection); - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { + if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -474,33 +335,13 @@ 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), - height: Math.round(size.height), - }; - await commitMutation( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, - backfillDefaults, + properties: { width: Math.round(size.width), height: Math.round(size.height) }, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -548,10 +389,7 @@ export async function tryGsapRotationIntercept( const pct = computeCurrentPercentage(selection); const newRotation = Math.round(gsapRotation + angle); - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { + if (!anim.keyframes) { await commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, @@ -559,27 +397,15 @@ 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( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, - backfillDefaults, + properties: { rotation: newRotation }, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); return true; } - -export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; From cdb500f098bca28945eb381a55f9e3ec0fd6f0dd 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/37] 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 55f1013f7471705120264a44d51ff306f6cfabd5 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/37] 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 59c000d34431f39d757e472dee2ca5f75eb0bcee 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/37] 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 4f429d4a78d40a11a173ff6141ade88d852e7667 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/37] 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 28229296414ac0dfb331493d1c48100cf5be76be 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/37] 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 2320111d5aacd33c32c6380db97cbb15d07e4b84 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/37] ci: trigger regression run From 5a83f79287ede0f0662a69f586a22fb35a992372 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/37] 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 123c1242537a0431e42df474229d884852406c14 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/37] 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 b9b867c4337d8fd555313695c5fdd8f1257b5cff 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/37] ci: trigger regression run From 16a1bac84e37cb178bae96d83cf144885278bcb2 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 16/37] ci: trigger regression run From 69dacae55db18565e93ab5a8a2c002990a3d2669 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 17/37] 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/contexts/DomEditContext.tsx | 3 --- packages/studio/src/player/components/Timeline.tsx | 3 --- 4 files changed, 14 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/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c556718f6..39b7183da 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,7 +68,6 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -136,7 +135,6 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, @@ -198,7 +196,6 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache, 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 451bf7d10035cbd74ee90f58a234128fbf8c0aa0 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 18/37] 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 +++--------------- .../studio/src/hooks/useDomEditSession.ts | 47 -------- 2 files changed, 13 insertions(+), 140 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 }, ); diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 92aed8b00..5edaef835 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -27,7 +27,6 @@ import { tryGsapDragIntercept, tryGsapResizeIntercept, tryGsapRotationIntercept, - readRuntimeKeyframes, } from "./gsapRuntimeBridge"; // ── Types ── @@ -216,7 +215,6 @@ export function useDomEditSession({ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, gsapSourceFile, gsapCacheVersion, - previewIframeRef, ); const { @@ -230,7 +228,6 @@ export function useDomEditSession({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, - previewIframeRef, ); const { @@ -494,49 +491,6 @@ 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; @@ -702,7 +656,6 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, invalidateGsapCache: bumpGsapCache, From 3fcc92874eee5e668411dd78df1c520b60827907 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 19/37] 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 6ced8730331e216dcb324162747a830f7e5ab8f5 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 20/37] ci: trigger regression run From ce772987369ac28bde8d69afd64bbc71e213c66c 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 21/37] ci: trigger regression run From 72ade4bdc08c0e2b7e4a680ff61362e289670e0e 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 22/37] ci: trigger regression run From 9465dad6c4fbb01d60c90eac20860bc034e35dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:58:37 -0400 Subject: [PATCH 23/37] fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose width/height are animated by GSAP (gsapAnimatesProperty check prevents studio CSS from overwriting GSAP interpolated values) - Delete All Keyframes removes entire animation (handleGsapDeleteAnimation) with fallback to first animation when no keyframed anim exists - Wire split clip through App → StudioPreviewArea → NLELayout → Timeline (onSplitElement prop, toolbar button, S hotkey, clip context menu) - Add onContextMenu to TimelineClip for right-click clip context menu --- packages/studio/src/App.tsx | 3 ++ .../src/components/StudioPreviewArea.tsx | 10 ++-- .../src/components/editor/manualEditsDom.ts | 50 +++++++++++++++++++ .../studio/src/components/nle/NLELayout.tsx | 3 ++ packages/studio/src/hooks/useAppHotkeys.ts | 27 ++++++++++ .../studio/src/player/components/Timeline.tsx | 26 ++++++++++ .../src/player/components/TimelineCanvas.tsx | 6 +++ .../src/player/components/TimelineClip.tsx | 3 ++ 8 files changed, 125 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8171302ab..52f10be23 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -286,6 +286,7 @@ export function StudioApp() { const appHotkeys = useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, + handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, clearDomSelectionRef, @@ -489,6 +490,7 @@ export function StudioApp() { ); return ( @@ -532,6 +534,7 @@ export function StudioApp() { handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} + handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index fd92d36ff..0dfbf0392 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps { updates: Pick, ) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; @@ -67,6 +68,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleBlockedTimelineEdit, + handleTimelineElementSplit, setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, @@ -107,7 +109,7 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, - handleGsapRemoveAllKeyframes, + handleGsapDeleteAnimation, } = useDomEditContext(); return ( @@ -127,10 +129,12 @@ export function StudioPreviewArea({ onMoveElement={handleTimelineElementMove} onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} + onSplitElement={handleTimelineElementSplit} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapRemoveAllKeyframes(anim.id); + const anim = + selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; + if (anim) handleGsapDeleteAnimation(anim.id); }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index c4e50a0d1..27c38e480 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -516,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void { } } +function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { + const win = el.ownerDocument.defaultView as + | (Window & { + __timelines?: Record< + string, + { + getChildren?: ( + deep: boolean, + ) => Array<{ targets?: () => Element[]; vars?: Record }>; + } + >; + }) + | null; + if (!win?.__timelines) return false; + const propSet = new Set(props); + for (const tl of Object.values(win.__timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets || !child.vars) continue; + let targetsEl = false; + for (const t of child.targets()) { + if (t === el || (el.id && t.id === el.id)) { + targetsEl = true; + break; + } + } + if (!targetsEl) continue; + const vars = child.vars; + for (const p of propSet) { + if (p in vars) return true; + } + if (vars.keyframes && typeof vars.keyframes === "object") { + for (const kfVal of Object.values(vars.keyframes as Record)) { + if (kfVal && typeof kfVal === "object") { + for (const p of propSet) { + if (p in (kfVal as Record)) return true; + } + } + } + } + } + } catch { + /* */ + } + } + return false; +} + function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { + if (gsapAnimatesProperty(el, "width", "height")) continue; const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index cad19d33f..e49fb730e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -69,6 +69,7 @@ interface NLELayoutProps { updates: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; @@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSplitElement, onSelectTimelineElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement={onMoveElement} onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} + onSplitElement={onSplitElement} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} onDeleteAllKeyframes={onDeleteAllKeyframes} diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 61d65af9f..60315fb7d 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -62,6 +62,7 @@ interface EditHistoryHandle { interface UseAppHotkeysParams { toggleTimelineVisibility: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; domEditSelectionRef: React.MutableRefObject; clearDomSelectionRef: React.MutableRefObject<() => void>; @@ -87,6 +88,7 @@ interface UseAppHotkeysParams { export function useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete, + handleTimelineElementSplit, handleDomEditElementDelete, domEditSelectionRef, editHistory, @@ -195,6 +197,8 @@ export function useAppHotkeys({ handleToggleRef.current = handleTimelineToggleHotkey; const handleDeleteRef = useRef(handleTimelineElementDelete); handleDeleteRef.current = handleTimelineElementDelete; + const handleSplitRef = useRef(handleTimelineElementSplit); + handleSplitRef.current = handleTimelineElementSplit; const handleDomEditDeleteRef = useRef(handleDomEditElementDelete); handleDomEditDeleteRef.current = handleDomEditElementDelete; const handleUndoRef = useRef(handleUndo); @@ -306,6 +310,29 @@ export function useAppHotkeys({ return; } + // S — split selected clip at playhead + if ( + event.key === "s" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if ( + element && + currentTime > element.start && + currentTime < element.start + element.duration + ) { + event.preventDefault(); + void handleSplitRef.current(element, currentTime); + return; + } + } + } + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 27dedc8bd..fdbbfc11c 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -16,6 +16,7 @@ import { type KeyframeDiamondContextMenuState, } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; +import { ClipContextMenu } from "./ClipContextMenu"; import { GUTTER, TRACK_H, @@ -70,6 +71,7 @@ interface TimelineProps { updates: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; @@ -91,6 +93,7 @@ export const Timeline = memo(function Timeline({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSplitElement, onSelectElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -135,6 +138,11 @@ export const Timeline = memo(function Timeline({ const [showPopover, setShowPopover] = useState(false); const [showShortcutHint, setShowShortcutHint] = useState(true); const [kfContextMenu, setKfContextMenu] = useState(null); + const [clipContextMenu, setClipContextMenu] = useState<{ + x: number; + y: number; + element: TimelineElement; + } | null>(null); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); @@ -532,6 +540,12 @@ export const Timeline = memo(function Timeline({ currentEase: kf?.ease ?? kfData?.ease, }); }} + onContextMenuClip={(e, el) => { + e.preventDefault(); + setSelectedElementId(el.key ?? el.id); + onSelectElement?.(el); + setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); + }} /> @@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {clipContextMenu && ( + setClipContextMenu(null)} + onSplit={(el, time) => onSplitElement?.(el, time)} + onDelete={(el) => _onDeleteElement?.(el)} + /> + )} ); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 1d9233930..215c55b97 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -67,6 +67,7 @@ interface TimelineCanvasProps { onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } @@ -116,6 +117,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onShiftClickKeyframe, onDragKeyframe, onContextMenuKeyframe, + onContextMenuClip, onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; @@ -249,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({ return ( { + e.preventDefault(); + onContextMenuClip?.(e, el); + }} el={previewElement} pps={pps} clipY={CLIP_Y} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index c964545a8..f26701de9 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -23,6 +23,7 @@ interface TimelineClipProps { onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void; onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; children?: ReactNode; } @@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({ onResizeStart, onClick, onDoubleClick, + onContextMenu, children, }: TimelineClipProps) { const leftPx = el.start * pps; @@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({ onPointerDown={onPointerDown} onClick={onClick} onDoubleClick={onDoubleClick} + onContextMenu={onContextMenu} > {/* Left accent stripe */}
Date: Wed, 3 Jun 2026 23:02:31 -0400 Subject: [PATCH 24/37] fix(studio): block split on sub-compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-compositions (data-composition-src) cannot be meaningfully split — the clone would load the same source and fight for the same timeline. Block with a specific toast message and disable in the context menu. --- packages/studio/src/hooks/useTimelineEditing.ts | 8 +++++++- .../studio/src/player/components/ClipContextMenu.tsx | 12 ++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index e9045b960..adefa52a1 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -474,10 +474,16 @@ export function useTimelineEditing({ if ( element.timelineLocked || element.timingSource === "implicit" || + element.compositionSrc || !element.duration || !Number.isFinite(element.duration) ) { - showToast("This clip cannot be split.", "error"); + showToast( + element.compositionSrc + ? "Sub-compositions cannot be split." + : "This clip cannot be split.", + "error", + ); return; } diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 4caf1a065..4199d1a27 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -43,11 +43,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({ const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - 200); - const canSplit = currentTime > element.start && currentTime < element.start + element.duration; + const isComposition = !!element.compositionSrc; + const canSplit = + !isComposition && currentTime > element.start && currentTime < element.start + element.duration; - const splitLabel = canSplit - ? `Split at ${currentTime.toFixed(2)}s` - : "Split (move playhead inside clip)"; + const splitLabel = isComposition + ? "Split (not available for compositions)" + : canSplit + ? `Split at ${currentTime.toFixed(2)}s` + : "Split (move playhead inside clip)"; return (
Date: Wed, 3 Jun 2026 23:03:43 -0400 Subject: [PATCH 25/37] fix(studio): no toast when split is unavailable for compositions --- packages/studio/src/hooks/useTimelineEditing.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index adefa52a1..37a840034 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -478,12 +478,9 @@ export function useTimelineEditing({ !element.duration || !Number.isFinite(element.duration) ) { - showToast( - element.compositionSrc - ? "Sub-compositions cannot be split." - : "This clip cannot be split.", - "error", - ); + if (!element.compositionSrc) { + showToast("This clip cannot be split.", "error"); + } return; } From f6a3c245f4c6a2cc3964e8e4f91722dae43d241e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 23:05:20 -0400 Subject: [PATCH 26/37] =?UTF-8?q?fix(studio):=20remove=20defensive=20toast?= =?UTF-8?q?=20from=20split=20handler=20=E2=80=94=20UI=20gates=20are=20suff?= =?UTF-8?q?icient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/studio/src/hooks/useTimelineEditing.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 37a840034..024777dc2 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -478,9 +478,6 @@ export function useTimelineEditing({ !element.duration || !Number.isFinite(element.duration) ) { - if (!element.compositionSrc) { - showToast("This clip cannot be split.", "error"); - } return; } From 3f969ceb8fcef1f2db9e3bd4ff9ec9133e91a5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 23:21:43 -0400 Subject: [PATCH 27/37] fix(studio): hide split button for sub-compositions, use scissors icon --- .../studio/src/components/TimelineToolbar.tsx | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 1535eb80a..539df6b6e 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -218,38 +218,47 @@ export function TimelineToolbar({ )} - {onSplitElement && ( - - - - )} + {onSplitElement && + (() => { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + const el = selectedElementId + ? elements.find((e) => (e.key ?? e.id) === selectedElementId) + : null; + if (!el || el.compositionSrc) return null; + const canSplit = currentTime > el.start && currentTime < el.start + el.duration; + return ( + + + + ); + })()}
From 166ab92decb6ae9914c7981f854c89535500b450 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 28/37] ci: trigger regression run From 8d72aed0717a2f42aeb070f428084a1c6c4c0281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 23:39:29 -0400 Subject: [PATCH 29/37] feat(studio): runtime-synced design panel values + 3D transform properties The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values from the runtime via gsap.getProperty() at the current seek time. When an element has GSAP animations, the fields reflect the actual interpolated position/size/rotation instead of the CSS defaults. Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX, rotationY, rotationZ, perspective, transformOrigin. --- packages/core/src/parsers/gsapConstants.ts | 9 ++- .../src/components/StudioRightPanel.tsx | 1 + .../src/components/editor/PropertyPanel.tsx | 67 +++++++++++++++---- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 3aa2fc69f..62feac775 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -6,7 +6,7 @@ */ export const SUPPORTED_PROPS = [ - // Transforms + // 2D Transforms "x", "y", "scale", @@ -15,6 +15,13 @@ export const SUPPORTED_PROPS = [ "rotation", "skewX", "skewY", + // 3D Transforms + "z", + "rotationX", + "rotationY", + "rotationZ", + "perspective", + "transformOrigin", // Visibility "opacity", "visibility", diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 39409046c..b49178c9b 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -211,6 +211,7 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} + previewIframeRef={previewIframeRef} gsapAnimations={selectedGsapAnimations} gsapMultipleTimelines={gsapMultipleTimelines} gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 19dc6e8d9..c5623e738 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -52,6 +52,7 @@ interface PropertyPanelProps { onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; + previewIframeRef?: React.RefObject; gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; gsapMultipleTimelines?: boolean; gsapUnsupportedTimelinePattern?: boolean; @@ -169,6 +170,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, + previewIframeRef, gsapAnimations = [], gsapMultipleTimelines, gsapUnsupportedTimelinePattern, @@ -286,6 +288,47 @@ export const PropertyPanel = memo(function PropertyPanel({ const gsapAnimId = gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + // Read GSAP-interpolated values at the current seek time. + // Recomputed on every render (currentTime changes trigger re-render from the store subscription). + const gsapRuntimeValues = (() => { + if (!gsapAnimId || gsapAnimations.length === 0) return null; + const iframe = previewIframeRef?.current; + if (!iframe?.contentWindow) return null; + const selector = element.id ? `#${element.id}` : element.selector; + if (!selector) return null; + try { + const gsap = ( + iframe.contentWindow as unknown as { + gsap?: { getProperty: (el: Element, prop: string) => number }; + } + ).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + const read = (prop: string) => { + const v = Number(gsap.getProperty(el, prop)); + return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; + }; + return { + x: read("x"), + y: read("y"), + width: read("width"), + height: read("height"), + rotation: read("rotation"), + scale: read("scale"), + opacity: read("opacity"), + }; + } catch { + return null; + } + })(); + + const displayX = gsapRuntimeValues?.x ?? manualOffset.x; + const displayY = gsapRuntimeValues?.y ?? manualOffset.y; + const displayW = gsapRuntimeValues?.width ?? resolvedWidth; + const displayH = gsapRuntimeValues?.height ?? resolvedHeight; + const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle; + return (
@@ -351,7 +394,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} @@ -363,7 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", displayX)} onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -373,7 +416,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("y", next)} @@ -385,7 +428,7 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", displayY)} onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -395,7 +438,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("width", next)} @@ -407,7 +450,7 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)} + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", displayW)} onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -417,7 +460,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("height", next)} @@ -429,9 +472,7 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => - onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight) - } + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "height", displayH)} onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -441,7 +482,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualRotation(next.replace("°", ""))} />
@@ -451,9 +492,7 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => - onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle) - } + onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "rotation", displayR)} onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> From 4f6ad68f80d1ef76f31abd9d2a6b4a0b2091dc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 23:46:03 -0400 Subject: [PATCH 30/37] fix(studio): read ALL animated properties from runtime, not just hardcoded 7 --- .../src/components/editor/PropertyPanel.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index c5623e738..bb9005e22 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -288,9 +288,9 @@ export const PropertyPanel = memo(function PropertyPanel({ const gsapAnimId = gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; - // Read GSAP-interpolated values at the current seek time. - // Recomputed on every render (currentTime changes trigger re-render from the store subscription). - const gsapRuntimeValues = (() => { + // Read ALL GSAP-interpolated values at the current seek time. + // Discovers animated properties from the animation's keyframes/tween vars. + const gsapRuntimeValues: Record | null = (() => { if (!gsapAnimId || gsapAnimations.length === 0) return null; const iframe = previewIframeRef?.current; if (!iframe?.contentWindow) return null; @@ -299,25 +299,27 @@ export const PropertyPanel = memo(function PropertyPanel({ try { const gsap = ( iframe.contentWindow as unknown as { - gsap?: { getProperty: (el: Element, prop: string) => number }; + gsap?: { getProperty: (el: Element, prop: string) => number | string }; } ).gsap; if (!gsap?.getProperty) return null; const el = iframe.contentDocument?.querySelector(selector); if (!el) return null; - const read = (prop: string) => { + const propKeys = new Set(); + for (const anim of gsapAnimations) { + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const p of Object.keys(kf.properties)) propKeys.add(p); + } + } + for (const p of Object.keys(anim.properties)) propKeys.add(p); + } + const result: Record = {}; + for (const prop of propKeys) { const v = Number(gsap.getProperty(el, prop)); - return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; - }; - return { - x: read("x"), - y: read("y"), - width: read("width"), - height: read("height"), - rotation: read("rotation"), - scale: read("scale"), - opacity: read("opacity"), - }; + if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100; + } + return Object.keys(result).length > 0 ? result : null; } catch { return null; } From 1d0094251cbe480c08bb9184402b2e812d7231ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 00:09:26 -0400 Subject: [PATCH 31/37] fix(studio): stronger clip selection border + wider keyframe playhead tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selected clip: full accent border (was 38% opacity), subtle glow shadow - Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at high zoom levels, causing diamonds to never highlight) --- packages/studio/src/player/components/TimelineClip.tsx | 4 ++-- .../studio/src/player/components/TimelineClipDiamonds.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index f26701de9..07b2e0d37 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -53,14 +53,14 @@ export const TimelineClip = memo(function TimelineClip({ const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging }); const borderColor = isSelected - ? trackStyle.accent + "60" + ? trackStyle.accent : isHovered ? theme.clipBorderHover : theme.clipBorder; const boxShadow = isDragging ? theme.clipShadowDragging : isSelected - ? `0 0 0 1px ${trackStyle.accent}40` + ? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25` : isHovered ? theme.clipShadowHover : theme.clipShadow; diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 98bde2bd1..502f19aff 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -122,7 +122,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const leftPx = (kf.percentage / 100) * clipWidthPx - half; const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); - const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.05; + const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; return ( ); diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index 3a43f29fb..3e99f2365 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -20,6 +20,7 @@ import { Camera as PhCamera, ArrowClockwise, Gear, + Scissors as PhScissors, } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; @@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise); export const Camera = makeIcon(PhCamera); export const RotateCw = makeIcon(ArrowClockwise); export const Settings = makeIcon(Gear); +export const Scissors = makeIcon(PhScissors); From 66df31dd4bf7abf5f0597c382b73327b31250d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 00:35:07 -0400 Subject: [PATCH 34/37] fix(studio): restrict split to media elements only (video, audio, img) --- .../studio/src/components/TimelineToolbar.tsx | 4 +- packages/studio/src/hooks/useAppHotkeys.ts | 1 + .../studio/src/hooks/useTimelineEditing.ts | 2 + .../src/player/components/ClipContextMenu.tsx | 51 ++++++++++--------- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index f90837a77..5b6d5581b 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -225,7 +225,9 @@ export function TimelineToolbar({ const el = selectedElementId ? elements.find((e) => (e.key ?? e.id) === selectedElementId) : null; - if (!el || el.compositionSrc) return null; + const splittable = + el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag); + if (!splittable) return null; const canSplit = currentTime > el.start && currentTime < el.start + el.duration; return ( diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 60315fb7d..5140afc31 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -323,6 +323,7 @@ export function useAppHotkeys({ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); if ( element && + ["video", "audio", "img"].includes(element.tag) && currentTime > element.start && currentTime < element.start + element.duration ) { diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 024777dc2..f2b7c032a 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -471,10 +471,12 @@ export function useTimelineEditing({ const pid = projectIdRef.current; if (!pid) return; + const splittableTags = new Set(["video", "audio", "img"]); if ( element.timelineLocked || element.timingSource === "implicit" || element.compositionSrc || + !splittableTags.has(element.tag) || !element.duration || !Number.isFinite(element.duration) ) { diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 4199d1a27..b9ae70e13 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -43,12 +43,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({ const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - 200); - const isComposition = !!element.compositionSrc; + const isSplittable = ["video", "audio", "img"].includes(element.tag); const canSplit = - !isComposition && currentTime > element.start && currentTime < element.start + element.duration; + isSplittable && currentTime > element.start && currentTime < element.start + element.duration; - const splitLabel = isComposition - ? "Split (not available for compositions)" + const splitLabel = !isSplittable + ? null : canSplit ? `Split at ${currentTime.toFixed(2)}s` : "Split (move playhead inside clip)"; @@ -59,26 +59,29 @@ export const ClipContextMenu = memo(function ClipContextMenu({ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]" style={{ left: adjustedX, top: adjustedY }} > - - -
+ {splitLabel && ( + <> + +
+ + )}
+ {gsapRuntimeValues && ( +
+
+ 3D Transform +
+
+
+
+ { + const v = parsePxMetricValue(next); + if (v != null && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={(pct) => + onAddKeyframe?.(gsapAnimId, pct, "z", gsapRuntimeValues.z ?? 0) + } + onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + /> + )} +
+ { + const v = Number.parseFloat(next); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "scale", v); + } + }} + /> + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationX", v); + } + }} + /> + { + const v = Number.parseFloat(next.replace("°", "")); + if (Number.isFinite(v) && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "rotationY", v); + } + }} + /> +
+
+ )}
+
+ Stacking +
{ + 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 { @@ -146,7 +193,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,14 +220,35 @@ async function commitGsapPositionFromDrag( anim: GsapAnimation, studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, + iframe: HTMLIFrameElement | null, + selector: string, 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) { - await commitKeyframedPosition(selection, anim, newX, newY, callbacks, clearOffset); + 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, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + clearOffset, + ); } else if (anim.method === "from") { await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); } else if (anim.method === "fromTo") { @@ -186,7 +256,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, + ); } } @@ -194,8 +271,7 @@ async function commitGsapPositionFromDrag( async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -207,7 +283,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 }, ); @@ -222,8 +298,7 @@ async function commitKeyframedPosition( async function commitFlatViaKeyframes( selection: DomEditSelection, anim: GsapAnimation, - newX: number, - newY: number, + properties: Record, callbacks: GsapDragCommitCallbacks, beforeReload: () => void, ): Promise { @@ -241,7 +316,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 }, ); @@ -306,6 +381,67 @@ async function commitFromToPosition( ); } +// ── Runtime property reader ─────────────────────────────────────────────── + +export 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; + } +} + +export 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( @@ -327,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 }, @@ -335,13 +474,33 @@ 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), + 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, + backfillDefaults, }, { label: `Resize (keyframe ${pct}%)`, softReload: true }, ); @@ -389,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 }, @@ -397,15 +559,27 @@ 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( selection, { type: "add-keyframe", animationId: anim.id, percentage: pct, - properties: { rotation: newRotation }, + properties, + backfillDefaults, }, { label: `Rotate (keyframe ${pct}%)`, softReload: true }, ); return true; } + +export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts new file mode 100644 index 000000000..748c7463f --- /dev/null +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -0,0 +1,131 @@ +/** + * Unified helper for committing any GSAP property value from the design panel. + * + * Handles three cases: + * 1. Animation with keyframes → add-keyframe at current percentage + * 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe + * 3. No animation → create tl.to(), convert to keyframes, then add-keyframe + */ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; + +interface CommitAnimatedPropertyDeps { + selectedGsapAnimations: GsapAnimation[]; + gsapCommitMutation: + | (( + selection: DomEditSelection, + mutation: Record, + options: { + label: string; + coalesceKey?: string; + softReload?: boolean; + skipReload?: boolean; + }, + ) => Promise) + | null; + addGsapAnimation: ( + selection: DomEditSelection, + method: "to" | "from" | "set" | "fromTo", + currentTime?: number, + ) => void; + convertToKeyframes: (selection: DomEditSelection, animId: string) => void; + previewIframeRef: React.RefObject; + bumpGsapCache: () => void; +} + +function computePercentage(selection: DomEditSelection): number { + const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; + const currentTime = usePlayerStore.getState().currentTime; + return elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) + : 0; +} + +function selectorFor(selection: DomEditSelection): string | null { + if (selection.id) return `#${selection.id}`; + if (selection.selector) return selection.selector; + return null; +} + +export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { + const { + selectedGsapAnimations, + gsapCommitMutation, + addGsapAnimation, + previewIframeRef, + bumpGsapCache, + } = deps; + + const commitAnimatedProperty = useCallback( + async ( + selection: DomEditSelection, + property: string, + value: number | string, + ): Promise => { + if (!gsapCommitMutation) return; + + const iframe = previewIframeRef.current; + const selector = selectorFor(selection); + const pct = computePercentage(selection); + + let anim: GsapAnimation | undefined = + selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; + + // Case 3: No animation — create one first + if (!anim) { + addGsapAnimation(selection, "to"); + // The addGsapAnimation triggers a reload. We need to wait for the cache + // to update. Use a small delay then bump cache to re-fetch. + await new Promise((r) => setTimeout(r, 500)); + bumpGsapCache(); + // After creation, we can't proceed in this call — the animation isn't + // in our local state yet. The user's next edit will find it. + // For immediate feedback, trigger a convert-to-keyframes on the new animation. + return; + } + + // Case 2: Flat animation — convert to keyframes first + if (!anim.keyframes) { + await gsapCommitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id }, + { label: "Convert to keyframes", skipReload: true }, + ); + } + + // Read all currently animated properties from runtime for backfill + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + + // Build the properties object: all runtime props + the new value + const properties: Record = { ...runtimeProps }; + properties[property] = value; + + // Compute backfill defaults for properties not in existing keyframes + const backfillDefaults: Record = { ...runtimeProps }; + if (!(property in runtimeProps) && selector) { + const cssVal = readGsapProperty(iframe, selector, property); + if (cssVal != null) backfillDefaults[property] = cssVal; + } + backfillDefaults[property] = typeof value === "number" ? value : value; + + await gsapCommitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties, + backfillDefaults, + }, + { label: `Edit ${property} (keyframe ${pct}%)`, softReload: true }, + ); + }, + [selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache], + ); + + return commitAnimatedProperty; +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 69ee75710..fce788233 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -29,6 +29,7 @@ import { tryGsapResizeIntercept, tryGsapRotationIntercept, } from "./gsapRuntimeBridge"; +import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; // ── Types ── @@ -526,6 +527,15 @@ export function useDomEditSession({ return true; }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]); + const commitAnimatedProperty = useAnimatedPropertyCommit({ + selectedGsapAnimations, + gsapCommitMutation, + addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time), + convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId), + previewIframeRef, + bumpGsapCache, + }); + // Sync selection from preview document on load / refresh // eslint-disable-next-line no-restricted-syntax useEffect(() => { @@ -671,6 +681,7 @@ export function useDomEditSession({ handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache: bumpGsapCache, previewIframeRef, }; From 4e11a7ecc504ef100b4f418a691b8a481ba28224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 21:48:05 -0400 Subject: [PATCH 36/37] fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty --- .../src/components/editor/PropertyPanel.tsx | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 6fa5f01c7..4abbc018b 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -418,7 +418,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", displayX)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "x", displayX) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -440,7 +443,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", displayY)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "y", displayY) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -462,7 +468,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", displayW)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "width", displayW) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -484,7 +493,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "height", displayH)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "height", displayH) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -504,7 +516,10 @@ export const PropertyPanel = memo(function PropertyPanel({ keyframes={gsapKeyframes} currentPercentage={currentPct} onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "rotation", displayR)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "rotation", displayR) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -531,17 +546,19 @@ export const PropertyPanel = memo(function PropertyPanel({ }} />
- {STUDIO_KEYFRAMES_ENABLED && gsapAnimId && ( + {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( onSeekToTime?.(elStart + (pct / 100) * elDuration)} - onAddKeyframe={(pct) => - onAddKeyframe?.(gsapAnimId, pct, "z", gsapRuntimeValues.z ?? 0) - } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); + } + }} + onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)} /> )}
From eb8f448a4bb0025b38b13d083d1140d4bb8699b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 23:27:26 -0400 Subject: [PATCH 37/37] chore: remove committed plan files These design plans were accidentally committed and should not be in the PR. --- ...ix-rotate-drag-resize-autokeyframe-plan.md | 169 ------------ ...t-visual-keyframe-property-editing-plan.md | 244 ------------------ 2 files changed, 413 deletions(-) delete mode 100644 docs/plans/2026-06-03-001-fix-rotate-drag-resize-autokeyframe-plan.md delete mode 100644 docs/plans/2026-06-04-001-feat-visual-keyframe-property-editing-plan.md diff --git a/docs/plans/2026-06-03-001-fix-rotate-drag-resize-autokeyframe-plan.md b/docs/plans/2026-06-03-001-fix-rotate-drag-resize-autokeyframe-plan.md deleted file mode 100644 index ea8f59b9c..000000000 --- a/docs/plans/2026-06-03-001-fix-rotate-drag-resize-autokeyframe-plan.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -title: "fix: rotation-aware drag + auto-keyframing for resize and rotation" -status: active -date: 2026-06-03 -type: fix -depth: standard ---- - -# fix: rotation-aware drag + auto-keyframing for resize and rotation - -## Summary - -Fix two auto-keyframing gaps: CSS rotation corrupts the drag offset matrix (elements drift from cursor), and resize/rotate handle changes don't create keyframes (only position drag does). Also documents competitive findings from HyperMotion audit. - ---- - -## Problem Frame - -The drag offset probe in `measureManualOffsetDragScreenToOffsetMatrix` measures how changing `translate` affects screen position. But `stripGsapTranslateFromTransform` subtracts the offset from the transform matrix's m41/m42 entries without accounting for rotation — the offset is in pre-rotation space while m41/m42 are post-rotation. This poisons the probe measurements, making the element drift from the cursor. - -Separately, auto-keyframing only fires for position (via `handleGsapAwarePathOffsetCommit`). Resize and rotation commits go directly to the CSS patch path with no GSAP intercept, so handle changes never create keyframes. - ---- - -## Requirements - -- R1. Dragging a rotated element must track the cursor accurately regardless of rotation angle -- R2. Resizing via bounding box handles must create keyframes when the element has keyframed GSAP animations -- R3. Rotating via the rotation handle must create keyframes when the element has keyframed GSAP animations -- R4. Non-GSAP elements must continue to use the CSS path for resize and rotation (no regression) - ---- - -## Key Technical Decisions - -KTD1. **Rotation-aware strip: rotate the offset vector before subtracting.** Extract the rotation angle from the DOMMatrix itself (via `Math.atan2(m.b, m.a)`) rather than parsing CSS rotation values. This is more reliable because it captures the actual rendered rotation regardless of how it was set (CSS `rotate`, `transform`, GSAP). - -KTD2. **Resize auto-keyframing targets GSAP `width`/`height` properties**, not `scaleX`/`scaleY`. The studio resize handles change pixel dimensions, which maps directly to GSAP `width`/`height`. If the animation uses `scale` instead, the intercept falls back to CSS (same safe-default pattern as position). - -KTD3. **Rotation auto-keyframing targets GSAP `rotation` property.** Read from `gsap.getProperty(el, "rotation")` and compute `newRotation = gsapRotation + deltaAngle`. - -KTD4. **Same async pipeline pattern for all three property types.** Resize and rotation intercepts use the same `beforeReload` + `skipReload` + `await` pattern from the position bridge to prevent snap-back. - ---- - -## Implementation Units - -### U1. Fix rotation-aware strip in `stripGsapTranslateFromTransform` - -**Goal:** Make the transform matrix strip account for CSS rotation so the drag probe produces accurate measurements. - -**Requirements:** R1 - -**Dependencies:** None - -**Files:** -- Modify: `packages/studio/src/components/editor/manualEditsDom.ts` - -**Approach:** In `stripGsapTranslateFromTransform`, before subtracting the offset from m41/m42, extract the rotation angle from the matrix via `Math.atan2(m.b, m.a)`. Rotate the offset vector (offsetX, offsetY) by this angle to get the screen-space contribution, then subtract the rotated vector from m41/m42. - -**Patterns to follow:** The existing `stripGsapTranslateFromTransform` structure — read offset from custom properties, modify matrix, write back. - -**Test scenarios:** -- Element with no rotation: drag tracks cursor normally (regression check) -- Element rotated 45°: drag tracks cursor without drift -- Element rotated 90°: drag X movement moves element vertically on screen (correct for 90° rotation) -- Element rotated 180°: drag works in reverse directions -- Element with rotation + GSAP transform (e.g., at 50% of a rotation tween): drag still tracks - -**Verification:** Drag a rotated element in the preview — the element follows the cursor exactly, with the bounding box overlay aligned. - ---- - -### U2. Add GSAP-aware resize intercept - -**Goal:** When resizing a GSAP-animated element via bounding box handles, create width/height keyframes instead of CSS patches. - -**Requirements:** R2, R4 - -**Dependencies:** None - -**Files:** -- Modify: `packages/studio/src/hooks/gsapRuntimeBridge.ts` -- Modify: `packages/studio/src/hooks/useDomEditSession.ts` -- Modify: `packages/studio/src/hooks/useDomEditCommits.ts` - -**Approach:** Add `tryGsapResizeIntercept` to the runtime bridge — mirrors `tryGsapDragIntercept` but looks for animations with `width`/`height` properties. Reads current values via `gsap.getProperty(el, "width"/"height")`, computes new values, commits via `add-keyframe` at current percentage. Create `handleGsapAwareBoxSizeCommit` wrapper in `useDomEditSession` following the position wrapper pattern. Add `isElementGsapTargeted` guard to `handleDomBoxSizeCommit`. - -**Patterns to follow:** `handleGsapAwarePathOffsetCommit` in `useDomEditSession.ts`, `tryGsapDragIntercept` in `gsapRuntimeBridge.ts`. - -**Test scenarios:** -- Non-GSAP element: resize persists via CSS (existing behavior) -- GSAP element with width/height tween: resize creates keyframe at current percentage -- GSAP element without width/height: resize falls through to CSS path -- Resize at t=0 vs t=50%: keyframe percentage matches playhead -- Undo after resize keyframe: reverts correctly - -**Verification:** Resize a GSAP-animated element → keyframe diamond appears on timeline at the resize time. - ---- - -### U3. Add GSAP-aware rotation intercept - -**Goal:** When rotating a GSAP-animated element via the rotation handle, create rotation keyframes instead of CSS patches. - -**Requirements:** R3, R4 - -**Dependencies:** None - -**Files:** -- Modify: `packages/studio/src/hooks/gsapRuntimeBridge.ts` -- Modify: `packages/studio/src/hooks/useDomEditSession.ts` -- Modify: `packages/studio/src/hooks/useDomEditCommits.ts` - -**Approach:** Add `tryGsapRotationIntercept` to the runtime bridge — looks for animations with `rotation` property. Reads `gsap.getProperty(el, "rotation")`, computes `newRotation = gsapRotation + studioAngle`, commits via `add-keyframe`. Create `handleGsapAwareRotationCommit` wrapper. Add `isElementGsapTargeted` guard to `handleDomRotationCommit`. - -**Patterns to follow:** Same as U2, substituting rotation for width/height. - -**Test scenarios:** -- Non-GSAP element: rotation persists via CSS (existing behavior) -- GSAP element with rotation tween: rotation creates keyframe -- Rotation at various percentages: keyframe placed at correct % -- Combined rotation + drag: both produce separate keyframes - -**Verification:** Rotate a GSAP-animated element → keyframe diamond appears on timeline. - ---- - -## Scope Boundaries - -### In scope -- Rotation-aware transform strip fix -- GSAP-aware resize and rotation intercepts -- CSS path guards for GSAP elements on all three property types - -### Deferred to Follow-Up Work -- HyperMotion-inspired features: record-mode toggle, marquee multi-select, batch drag, named chapters, preset-origin tagging, strength slider on easing, bezier graph editor -- GSAP `scale`/`scaleX`/`scaleY` auto-keyframing via resize handles (currently only pixel `width`/`height`) -- Live interpolated values in the design panel inspector (HyperMotion's `useAnimatedValues` pattern) -- 10ms epsilon dedup on keyframe add - ---- - -## Sources & Research - -### HyperMotion Competitive Audit - -HyperMotion (github.com/psiddharthdesign/hypermotion) is a 3-week-old Electron+React+PixiJS motion design tool. Not GSAP-based — owns its full render pipeline. Key differences: - -| Feature | HyperFrames | HyperMotion | Winner | -|---------|-------------|-------------|--------| -| Engine | GSAP in browser, HTML/CSS source | Custom PixiJS, no GSAP | Different targets | -| Spring physics | Real damped oscillator solver | Stubbed (falls back to ease-out) | **HF** | -| Easing levels | 3 (tween, easeEach, per-kf) | Bezier graph editor + strength slider | Tie (different strengths) | -| Auto-keyframe | Drag-based (this PR adds resize/rotation) | Record-mode toggle (AE semantics) | **HM** (more explicit) | -| clip-path/filter | Supported | Not supported | **HF** | -| Multi-select | Shift-click on diamonds | Marquee + Cmd-click + range | **HM** | -| File format | HTML (human-readable, agentable) | .hype (Yjs CRDT binary) | **HF** (openness) | -| Collaboration | None | Yjs CRDT backbone (future) | **HM** (planned) | -| Export | MP4/WebM via headless Chrome | MP4/WebM/GIF/Lottie | **HM** (more formats) | -| Chapters | None | Named chapters + isolated playback | **HM** | -| Agent integration | Native (skills, CLI, MCP) | MCP server | Tie | - -**Features worth adopting (prioritized):** -1. Record-mode toggle for explicit auto-keyframe control -2. Epsilon dedup on keyframe add (10ms tolerance) -3. Marquee multi-select on timeline diamonds -4. Strength slider abstraction over easing presets -5. Live interpolated values in the inspector panel diff --git a/docs/plans/2026-06-04-001-feat-visual-keyframe-property-editing-plan.md b/docs/plans/2026-06-04-001-feat-visual-keyframe-property-editing-plan.md deleted file mode 100644 index d2e3d47be..000000000 --- a/docs/plans/2026-06-04-001-feat-visual-keyframe-property-editing-plan.md +++ /dev/null @@ -1,244 +0,0 @@ ---- -title: "feat: Visual keyframe property editing — write path for all GSAP properties" -status: active -date: 2026-06-04 -type: feat -branch: feat/keyframe-property-inspector ---- - -## Summary - -Make every GSAP property editable from the Studio design panel. When a user changes any value (Z, Scale, RotX, RotY, opacity, borderRadius, x, y, width, height, rotation, filter, clipPath), the system commits a keyframe at the current playhead position. If no animation exists on the element, one is created automatically. No HTML editing required. - -## Problem Frame - -The read path is complete — the design panel shows GSAP-interpolated values from the runtime at the current seek time. But the write path only works for x/y (via drag), width/height (via resize), and rotation (via handle). The 3D Transform fields (Z, Scale, RotX, RotY), opacity, borderRadius, and all other properties in the animation card are display-only. Users must edit HTML to change these values, which defeats the purpose of a visual editor. - ---- - -## Requirements - -- R1. Editing any numeric GSAP property (Z, Scale, RotX, RotY, opacity, borderRadius, fontSize, letterSpacing, skewX, skewY) in the design panel commits a keyframe at the current playhead percentage. -- R2. Editing any string GSAP property (filter, clipPath, borderRadius as string) in the animation card commits the value to the current keyframe or flat tween. -- R3. If the element has no GSAP animation, editing any animatable value auto-creates a `tl.to()` tween with percentage keyframes containing the new value. -- R4. If the element has a flat tween (no keyframes), editing a value first converts to keyframes format, then commits. -- R5. The design panel shows interpolated values for ALL animated properties at the current seek time — not just the 5 layout fields. -- R6. All property edits support undo/redo through the existing edit history system. -- R7. The keyframe cache updates immediately after each edit — diamonds on the timeline reflect the change without requiring a page refresh. - ---- - -## Key Technical Decisions - -KTD1. **Unified commit pattern**: All property edits flow through a single `commitAnimatedProperty(elementId, property, value)` helper that handles the three cases: (a) animation with keyframes → `add-keyframe`, (b) flat animation → `convert-to-keyframes` then `add-keyframe`, (c) no animation → `addGsapAnimation` then `convert-to-keyframes` then `add-keyframe`. This avoids duplicating the three-case logic across every field's onCommit handler. - -KTD2. **Auto-create uses `tl.to()` with `ease: "none"`**: When creating an animation from scratch, the default is `tl.to("#element", { keyframes: { "0%": { ...currentCssValues }, "100%": { ...currentCssValues, [prop]: newValue } }, duration: elementDuration, ease: "none" }, elementStart)`. This gives immediate visual feedback — the property interpolates from CSS defaults to the new value over the element's duration. - -KTD3. **3D fields commit via the same keyframe pipeline as 2D fields**: No separate "3D mode" — Z, RotX, RotY, Scale use the same `commitAnimatedProperty` as X, Y, W, H, R. The only difference is the GSAP property name passed to the mutation. - -KTD4. **String properties commit via `update-property` for flat tweens and `update-keyframe` for keyframed tweens**: String values (filter, clipPath, complex borderRadius) can't be scrubbed or interpolated in the design panel — they use text input with commit-on-blur. The mutation path is the same as numeric properties. - -KTD5. **`readAllAnimatedProperties` includes all properties in backfill**: When committing a keyframe for one property, the commit reads ALL currently animated properties from the runtime and includes them in the keyframe. This prevents other properties from jumping to CSS defaults between keyframes (the backfill pattern already implemented for resize). - ---- - -## Implementation Units - -### U1. Extract `commitAnimatedProperty` helper - -**Goal:** Create a single function that handles the three-case property commit logic, reusable by all design panel fields. - -**Requirements:** R1, R3, R4, R6, R7 - -**Dependencies:** None - -**Files:** -- Create: `packages/studio/src/hooks/useAnimatedPropertyCommit.ts` -- Modify: `packages/studio/src/hooks/useDomEditSession.ts` (expose the helper via context) -- Modify: `packages/studio/src/contexts/DomEditContext.tsx` (add to context) - -**Approach:** -- The helper receives `(selection, property, value, options?)` and: - 1. Checks if the element has a GSAP animation via `selectedGsapAnimations` - 2. If no animation: calls `addGsapAnimation("to")`, waits for reload, then proceeds - 3. If flat animation (no keyframes): calls `convertToKeyframes`, then proceeds - 4. Reads all animated properties from runtime via `readAllAnimatedProperties` - 5. Adds the new property value to the properties object - 6. Calls `add-keyframe` mutation with backfillDefaults -- Returns a promise that resolves after the mutation completes -- Handles the `gsapCommitMutation` null check (returns early if not available) - -**Patterns to follow:** The existing `tryGsapResizeIntercept` in `gsapRuntimeBridge.ts` implements the same three-case logic for width/height. Extract and generalize that pattern. - -**Test scenarios:** -- Element with keyframed animation: editing Z commits keyframe at current percentage with Z value + all other animated properties -- Element with flat tween: editing Z first converts to keyframes, then adds keyframe -- Element with no animation: editing Z creates new `tl.to()` animation, converts to keyframes, adds keyframe -- After commit: keyframe cache updates, diamond appears on timeline -- Undo after commit: reverts to previous state - -**Verification:** All three paths produce correct keyframe entries in the source HTML. The keyframe cache updates after each mutation. - ---- - -### U2. Wire 3D Transform fields to commit pipeline - -**Goal:** Make Z, Scale, RotX, RotY fields in the design panel editable — editing commits a keyframe. - -**Requirements:** R1, R5 - -**Dependencies:** U1 - -**Files:** -- Modify: `packages/studio/src/components/editor/PropertyPanel.tsx` (wire onCommit handlers) -- Modify: `packages/studio/src/components/StudioRightPanel.tsx` (pass new handler prop) - -**Approach:** -- Replace the current inline onCommit handlers for Z/Scale/RotX/RotY (which only call `onAddKeyframe` and fail silently when no animation exists) with calls to `commitAnimatedProperty` -- Add `onCommitAnimatedProperty` prop to PropertyPanel, passed from StudioRightPanel via DomEditContext -- Each 3D field's onCommit: `commitAnimatedProperty(element, "z", parsedValue)` -- The MetricField scrub behavior provides immediate visual feedback via the existing preview soft-reload path - -**Patterns to follow:** The existing `commitManualOffset` handler in PropertyPanel already does keyframe-aware commits for X/Y — match that pattern for 3D fields. - -**Test scenarios:** -- Edit Z field: value commits as keyframe, preview updates, diamond appears -- Edit Scale field: same behavior -- Edit RotX/RotY: same behavior -- Scrub a 3D field: live preview updates via soft reload -- Edit Z on element with no animation: auto-creates animation, then commits - -**Verification:** Scrubbing the timeline after editing a 3D field shows the interpolated value change in the design panel. - ---- - -### U3. Wire animation card property rows to commit pipeline - -**Goal:** Make numeric property rows in the AnimationCard editable via the same keyframe commit pipeline. - -**Requirements:** R1, R2, R5 - -**Dependencies:** U1 - -**Files:** -- Modify: `packages/studio/src/components/editor/AnimationCard.tsx` (wire PropertyRow onCommit to keyframe-aware handler) -- Modify: `packages/studio/src/components/editor/PropertyPanel.tsx` (pass handler through) - -**Approach:** -- Currently, PropertyRow's `commitProperty` calls `onUpdateGsapProperty` which updates the flat tween's endpoint value. For keyframed animations, this should instead commit a keyframe at the current percentage. -- When the animation has keyframes: `commitProperty` → `commitAnimatedProperty(element, prop, value)` (adds keyframe) -- When the animation is flat: `commitProperty` → `onUpdateGsapProperty` (existing behavior, updates tween endpoint) -- The PropertyRow already handles numeric vs string distinction — this change only affects where the commit goes for keyframed animations - -**Patterns to follow:** The existing `commitProperty` function in AnimationCard (line 286-293). - -**Test scenarios:** -- Edit opacity in animation card on keyframed animation: commits keyframe at current time with opacity + all other properties -- Edit opacity on flat animation: updates tween endpoint (existing behavior) -- Add new property via "+ Effect" then edit value: commits correctly -- String property (filter): text input commit-on-blur updates the keyframe or tween - -**Verification:** Editing a property value in the animation card at different seek times produces different keyframes visible as diamonds. - ---- - -### U4. Auto-create animation on any property edit - -**Goal:** When editing any animatable value on an element with no GSAP animation, automatically create one. - -**Requirements:** R3 - -**Dependencies:** U1 - -**Files:** -- Modify: `packages/studio/src/hooks/useAnimatedPropertyCommit.ts` (auto-create logic) -- Modify: `packages/studio/src/hooks/useGsapScriptCommits.ts` (ensure `addGsapAnimation` returns the new animation ID) - -**Approach:** -- In `commitAnimatedProperty`, when `selectedGsapAnimations.length === 0`: - 1. Call `addGsapAnimation("to")` — this creates `tl.to("#element", { x: 0, y: 0, opacity: 1, duration: D, ease: "power2.out" }, start)` - 2. Wait for the preview to reload (the mutation triggers a reload) - 3. After reload, the tween cache will find the new animation - 4. Call `convertToKeyframes` on the new animation - 5. After reload, add the keyframe with the user's value -- This is a 3-step async sequence. To avoid visible flicker, batch the mutations: `addGsapAnimation` + `convertToKeyframes` with `skipReload: true`, then the final `add-keyframe` with `softReload: true` -- The `addGsapAnimation` function already handles ensuring the element has an ID (`ensureElementAddressable`) - -**Patterns to follow:** The existing `handleGsapAddAnimation("to")` in `useDomEditSession.ts` creates animations from scratch for the toolbar diamond button. - -**Test scenarios:** -- Select a plain `
` with no GSAP animation, edit Z in design panel: animation created, keyframe added, diamond appears -- The auto-created animation uses the element's `data-duration` if present, falls back to composition duration -- The auto-created animation's `data-start` matches the element's position -- Undo reverts the entire creation (animation + keyframe) -- The runtime auto-discovers the new animation (auto-inject `data-track-index`) - -**Verification:** A completely plain HTML element can be animated entirely from the design panel — no code editing required. - ---- - -### U5. Show all animated properties in design panel - -**Goal:** Display interpolated values for ALL animated properties (not just the 5 layout fields + 4 3D fields) in the design panel. - -**Requirements:** R5 - -**Dependencies:** U2, U3 - -**Files:** -- Modify: `packages/studio/src/components/editor/PropertyPanel.tsx` (add dynamic property display) - -**Approach:** -- The `gsapRuntimeValues` IIFE already reads ALL animated properties from the runtime. Currently only X/Y/W/H/R and Z/Scale/RotX/RotY are displayed. -- Add a "Animated Properties" section below 3D Transform that shows any remaining animated properties not already covered by the Layout or 3D sections (opacity, borderRadius, skewX, skewY, fontSize, etc.) -- Each property renders as a MetricField (for numbers) or text display (for strings) with a keyframe navigation control -- This section is hidden when there are no additional animated properties - -**Patterns to follow:** The existing Layout section's MetricField + KeyframeNavigation pattern. - -**Test scenarios:** -- Element with opacity keyframes: opacity value shows in "Animated Properties" section, updates when scrubbing -- Element with borderRadius string animation: borderRadius displays as text, updates when scrubbing -- Element with only x/y: no "Animated Properties" section shown (covered by Layout) -- Element with no animation: no section shown - -**Verification:** Scrubbing the timeline on an element with opacity, borderRadius, or filter keyframes shows the interpolated values updating in real-time in the design panel. - ---- - -## Scope Boundaries - -### In Scope -- Write path for all numeric GSAP properties via design panel -- Write path for string properties via animation card text inputs -- Auto-create animation from scratch when editing any property -- Keyframe cache immediate update after edits -- Undo/redo support for all edits - -### Deferred to Follow-Up Work -- 3D container setup (perspective, transform-style) via UI — users still set these in HTML -- Per-keyframe easing UI — currently only tween-level ease can be set -- Bezier curve editor for custom easing -- Drag handles for 3D properties in the preview viewport -- Creating `from()` or `fromTo()` tweens from the UI (auto-create always uses `to()`) - -### Out of Scope -- Runtime adapter changes — all work is in the studio layer -- Parser changes beyond what's already shipped -- GSAP plugin integration (MorphSVG etc.) from the UI - ---- - -## Open Questions - -- Q1. (Deferred to implementation) The 3-step auto-create sequence (add → convert → add-keyframe) may cause visible flicker if not batched correctly. The `skipReload` option should prevent intermediate reloads, but this needs verification during implementation. -- Q2. (Deferred to implementation) When auto-creating an animation, the default properties (`x: 0, y: 0, opacity: 1`) may not be the right defaults for every element. The implementer should read CSS computed values from the iframe and use those as the 0% keyframe values. - ---- - -## Sources & Research - -- Existing codebase patterns: `tryGsapResizeIntercept` in `gsapRuntimeBridge.ts` implements the three-case commit logic for width/height -- `commitManualOffset` in `PropertyPanel.tsx` shows the keyframe-aware commit pattern for 2D fields -- `addGsapAnimation` in `useGsapScriptCommits.ts` handles animation creation from scratch -- `readAllAnimatedProperties` in `gsapRuntimeBridge.ts` reads all animated props for backfill