From ffd72eb6596aaf2b6d40f38f9a3a1c727c4b06f0 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 1/8] 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 bd3be3f6a3d56010696b03a92f2c988a9a89c440 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 2/8] 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 a6e368cd92b5c5030be282fd3071851fdf4edd5c 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 3/8] ci: trigger regression run From 62e2916005885c7b00c1a0ee9bd207cab30907ab 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 4/8] 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 5/8] 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 6/8] 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 f52b926b7f12b235c6837c6e582bebdb4997ca8e 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 7/8] =?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 7b29dd3b71ea33639b781945160d151ce6b64cf9 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 8/8] 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 ( + + + + ); + })()}