From 1bdb2d4ec0f18f1f196f912fd5d5ca118b47039e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 12:11:01 -0400 Subject: [PATCH] feat(studio): runtime-synced design panel + 3D props + split polish (#1188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * 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. * feat(studio): design panel integration, timeline polish, feature flag * 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. * fix(studio): counter-rotate drag offset for css-rotated elements 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. * feat(studio): add 'delete all keyframes' to diamond context menu * 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. * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * ci: trigger regression run * feat(studio): design panel integration, timeline polish, feature flag * 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. * feat(studio): add 'delete all keyframes' to diamond context menu * ci: trigger regression run * ci: trigger regression run * ci: trigger regression run * fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard - 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 * fix(studio): block split on sub-compositions 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. * fix(studio): no toast when split is unavailable for compositions * fix(studio): remove defensive toast from split handler — UI gates are sufficient * fix(studio): hide split button for sub-compositions, use scissors icon * ci: trigger regression run * 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. * fix(studio): read ALL animated properties from runtime, not just hardcoded 7 * fix(studio): stronger clip selection border + wider keyframe playhead tolerance - 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) * fix(studio): sync DOM selection to timeline selectedElementId on cold load * fix(studio): use Phosphor Scissors icon for split button * fix(studio): restrict split to media elements only (video, audio, img) * feat(studio): unified commitAnimatedProperty for all GSAP property edits Extract useAnimatedPropertyCommit hook that handles the three-case commit logic: keyframed → add-keyframe, flat → convert + add, no animation → create + convert + add. Wire Z, Scale, RotX, RotY design panel fields and 2D Layout fields through this unified pipeline. Export readAllAnimatedProperties and readGsapProperty from the runtime bridge so the commit helper can read all animated props for backfill. * fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty * chore: remove committed plan files These design plans were accidentally committed and should not be in the PR. --- packages/core/src/parsers/gsapConstants.ts | 9 +- packages/studio/src/App.tsx | 3 + .../src/components/StudioPreviewArea.tsx | 10 +- .../src/components/StudioRightPanel.tsx | 3 + .../studio/src/components/TimelineToolbar.tsx | 62 ++++--- .../src/components/editor/PropertyPanel.tsx | 163 ++++++++++++++++-- .../src/components/editor/manualEditsDom.ts | 50 ++++++ .../studio/src/components/nle/NLELayout.tsx | 3 + .../studio/src/contexts/DomEditContext.tsx | 6 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 4 +- .../src/hooks/useAnimatedPropertyCommit.ts | 131 ++++++++++++++ packages/studio/src/hooks/useAppHotkeys.ts | 28 +++ .../studio/src/hooks/useDomEditSession.ts | 71 +++----- .../studio/src/hooks/useTimelineEditing.ts | 4 +- packages/studio/src/icons/SystemIcons.tsx | 2 + .../src/player/components/ClipContextMenu.tsx | 55 +++--- .../studio/src/player/components/Timeline.tsx | 26 +++ .../src/player/components/TimelineCanvas.tsx | 6 + .../src/player/components/TimelineClip.tsx | 7 +- .../components/TimelineClipDiamonds.tsx | 2 +- 20 files changed, 517 insertions(+), 128 deletions(-) create mode 100644 packages/studio/src/hooks/useAnimatedPropertyCommit.ts 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/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/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 39409046c..a0adf87d4 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -91,6 +91,7 @@ export function StudioRightPanel({ handleGsapUpdateFromProperty, handleGsapAddFromProperty, handleGsapRemoveFromProperty, + commitAnimatedProperty, } = useDomEditContext(); const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } = @@ -211,6 +212,7 @@ export function StudioRightPanel({ onImportAssets={handleImportFiles} fontAssets={fontAssets} onImportFonts={handleImportFonts} + previewIframeRef={previewIframeRef} gsapAnimations={selectedGsapAnimations} gsapMultipleTimelines={gsapMultipleTimelines} gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern} @@ -223,6 +225,7 @@ export function StudioRightPanel({ onAddGsapFromProperty={handleGsapAddFromProperty} onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} + onCommitAnimatedProperty={commitAnimatedProperty} /> ) : motionPanelActive ? ( )} - {onSplitElement && ( - - - - )} + {onSplitElement && + (() => { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + const el = selectedElementId + ? elements.find((e) => (e.key ?? e.id) === selectedElementId) + : 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/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 19dc6e8d9..4abbc018b 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; @@ -75,6 +76,11 @@ interface PropertyPanelProps { ) => void; onRemoveKeyframe?: (animationId: string, percentage: number) => void; onConvertToKeyframes?: (animationId: string) => void; + onCommitAnimatedProperty?: ( + selection: DomEditSelection, + property: string, + value: number | string, + ) => Promise; onSeekToTime?: (time: number) => void; } @@ -169,6 +175,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportAssets, fontAssets = [], onImportFonts, + previewIframeRef, gsapAnimations = [], gsapMultipleTimelines, gsapUnsupportedTimelinePattern, @@ -184,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onAddKeyframe, onRemoveKeyframe, onConvertToKeyframes, + onCommitAnimatedProperty, onSeekToTime, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -238,6 +246,10 @@ export const PropertyPanel = memo(function PropertyPanel({ const commitManualOffset = (axis: "x" | "y", nextValue: string) => { const parsed = parsePxMetricValue(nextValue); if (parsed == null) return; + if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) { + void onCommitAnimatedProperty(element, axis, parsed); + return; + } if (gsapKeyframes && gsapAnimId && onAddKeyframe) { const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10)); onAddKeyframe(gsapAnimId, pct, axis, parsed); @@ -286,6 +298,49 @@ export const PropertyPanel = memo(function PropertyPanel({ const gsapAnimId = gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null; + // 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; + 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 | string }; + } + ).gsap; + if (!gsap?.getProperty) return null; + const el = iframe.contentDocument?.querySelector(selector); + if (!el) return null; + 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)); + if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100; + } + return Object.keys(result).length > 0 ? result : null; + } 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 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("x", next)} @@ -363,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", manualOffset.x)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "x", displayX) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -373,7 +431,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualOffset("y", next)} @@ -385,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", manualOffset.y)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "y", displayY) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -395,7 +456,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("width", next)} @@ -407,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", resolvedWidth)} + onAddKeyframe={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "width", displayW) + } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} /> @@ -417,7 +481,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualSize("height", next)} @@ -429,8 +493,9 @@ 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={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "height", displayH) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} @@ -441,7 +506,7 @@ export const PropertyPanel = memo(function PropertyPanel({
commitManualRotation(next.replace("°", ""))} />
@@ -451,8 +516,9 @@ 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={() => + onCommitAnimatedProperty && + void onCommitAnimatedProperty(element, "rotation", displayR) } onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)} onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)} @@ -460,7 +526,80 @@ export const PropertyPanel = memo(function PropertyPanel({ )}
+ {gsapRuntimeValues && ( +
+
+ 3D Transform +
+
+
+
+ { + const v = parsePxMetricValue(next); + if (v != null && onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", v); + } + }} + /> +
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && ( + onSeekToTime?.(elStart + (pct / 100) * elDuration)} + onAddKeyframe={() => { + if (onCommitAnimatedProperty) { + void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0); + } + }} + onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)} + onConvertToKeyframes={() => gsapAnimId && 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 +
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/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c556718f6..7fa2f1f8e 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -68,9 +68,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, }, @@ -136,9 +136,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, }), @@ -198,9 +198,9 @@ export function DomEditProvider({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache, previewIframeRef, ], diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 13c2ca665..58b604c00 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -383,7 +383,7 @@ async function commitFromToPosition( // ── Runtime property reader ─────────────────────────────────────────────── -function readGsapProperty( +export function readGsapProperty( iframe: HTMLIFrameElement | null, selector: string | null, prop: string, @@ -401,7 +401,7 @@ function readGsapProperty( } } -function readAllAnimatedProperties( +export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, 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/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 61d65af9f..5140afc31 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,30 @@ 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 && + ["video", "audio", "img"].includes(element.tag) && + 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/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 92aed8b00..fce788233 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_GSAP_PANEL_ENABLED, @@ -27,8 +28,8 @@ import { tryGsapDragIntercept, tryGsapResizeIntercept, tryGsapRotationIntercept, - readRuntimeKeyframes, } from "./gsapRuntimeBridge"; +import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; // ── Types ── @@ -206,6 +207,18 @@ export function useDomEditSession({ onClickToSource, }); + // Sync DOM selection → timeline selectedElementId so that clip selection + // highlights and diamond playhead fills work on cold-load URL restore. + useEffect(() => { + if (!domEditSelection?.id) return; + const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState(); + const matchKey = elements.find( + (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id, + ); + const key = matchKey ? (matchKey.key ?? matchKey.id) : null; + if (key && key !== selectedElementId) setSelectedElementId(key); + }, [domEditSelection?.id]); + // ── GSAP script editing ── const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); @@ -216,7 +229,6 @@ export function useDomEditSession({ STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null, gsapSourceFile, gsapCacheVersion, - previewIframeRef, ); const { @@ -230,7 +242,6 @@ export function useDomEditSession({ ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null } : null, gsapCacheVersion, - previewIframeRef, ); const { @@ -494,49 +505,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; @@ -559,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(() => { @@ -702,9 +679,9 @@ export function useDomEditSession({ handleGsapAddKeyframe, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, - handleGsapMaterializeKeyframes, handleGsapRemoveAllKeyframes, handleResetSelectedElementKeyframes, + commitAnimatedProperty, invalidateGsapCache: bumpGsapCache, previewIframeRef, }; diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index e9045b960..f2b7c032a 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -471,13 +471,15 @@ 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) ) { - showToast("This clip cannot be split.", "error"); 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); diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 4caf1a065..b9ae70e13 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 isSplittable = ["video", "audio", "img"].includes(element.tag); + const canSplit = + isSplittable && currentTime > element.start && currentTime < element.start + element.duration; - const splitLabel = canSplit - ? `Split at ${currentTime.toFixed(2)}s` - : "Split (move playhead inside clip)"; + const splitLabel = !isSplittable + ? null + : canSplit + ? `Split at ${currentTime.toFixed(2)}s` + : "Split (move playhead inside clip)"; return (
- - -
+ {splitLabel && ( + <> + +
+ + )}
@@ -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..07b2e0d37 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; @@ -51,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; @@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({ onPointerDown={onPointerDown} onClick={onClick} onDoubleClick={onDoubleClick} + onContextMenu={onContextMenu} > {/* Left accent stripe */}